Architecture
How the pieces fit together: what talks to the player, which package owns
what, where time comes from, and the order things happen in. The source of
truth for behaviour is still the code and the other docs —
format.md for the .vct format, sync.md for the
stage sync protocol, PROJECT.md for the state of each feature.
The diagrams are Mermaid in fenced code blocks: plain text in the repo, rendered by GitHub, and diffable like the rest of the docs. See Keeping these diagrams for why, and for the text and JSON formats the project uses elsewhere.
1. What talks to the player
flowchart LR
op(["Operator<br/>keyboard"]) --> P
midi(["MIDI controller<br/>notes, CC, MMC, Launchkey pads"]) --> P
lib[("Documents/StageDisplay<br/>songs/*.vct, sets/*.vct")] <--> P
aud[("Backing track<br/>WAV / MP3 stems")] --> P
imp[("Import files<br/>ChordPro, chord sheet, MusicXML, MuseScore, MIDI")] --> P
P["<b>vct-player</b><br/>library · pre-play · stage · editors"]
P --> main["Stage display<br/>main window + extra monitors"]
P --> card["Sound card<br/>stems, mixed live"]
P -->|"JSON / UDP 47800"| nat["vct-sync, native clients"]
P -->|"HTTP + WebSocket 47800"| web["Phones, tablets, browsers<br/>built-in web client"]
cli(["vct, ug2vct, sheet,<br/>score2vct, chordpro"]) --> lib
P -. "compiled to wasm" .-> wasm["Browser build<br/>songs in IndexedDB"]
Clients are dumb: they never read a song file or run the engine. They get the whole song once and then a stream of stamped pictures of one moment (sync.md).
2. Packages
Core packages do no I/O and keep no globals: everything is allocated from
the allocator passed in, so the host can pass an arena and free it in one
go. The cmd/ programs own all the I/O, the window and the platform.
flowchart BT
subgraph core["Core: no I/O, no globals"]
vct["<b>vct</b><br/>parse · build · engine<br/>lex · format · set · dump"]
align["<b>align</b><br/>decode · onset · fit · loudness · mix"]
score["<b>score</b><br/>MusicXML · MuseScore · MIDI"]
sheet["<b>sheet</b><br/>chord sheet · lyrics · GP5"]
chordpro["<b>chordpro</b>"]
ug["<b>ug</b><br/>Ultimate Guitar"]
qr["<b>qr</b>"]
player["<b>player</b><br/>driver · view · library · setlist<br/>editor · buffer · doc · complete<br/>layout · theme · look · midi · mixer"]
stagesync["<b>stagesync</b><br/>protocol · server · ws · client · clock"]
lsp["<b>lsp</b>"]
end
subgraph cmds["Commands: files, window, network, devices"]
cvct["cmd/vct"]
cplayer["cmd/player<br/>raylib"]
csync["cmd/vct-sync"]
clsp["cmd/vct-lsp"]
conv["cmd/ug2vct · cmd/sheet<br/>cmd/score2vct · cmd/chordpro"]
end
align --> vct
score --> vct
sheet --> vct
sheet --> chordpro
chordpro --> vct
player --> vct
player --> score
player --> sheet
player --> chordpro
stagesync --> player
stagesync --> vct
lsp --> player
lsp --> vct
cvct --> vct
cvct --> align
cvct --> player
cplayer --> player
cplayer --> vct
cplayer --> align
cplayer --> stagesync
cplayer --> qr
csync --> stagesync
clsp --> lsp
conv --> ug
conv --> score
conv --> sheet
conv --> chordpro
player/look.odin embeds themes/ and layouts/ with
#load_directory, so every built-in theme and layout is in the binary;
cmd/player/ui.odin embeds the fonts the same way.
Platform-specific files are split by suffix rather than by when:
*_darwin.odin (displays, MIDI, opening files), *_js.odin (the wasm
build: files, arena, MIDI, sync, backing), *_other.odin (the fallback),
*_posix.odin. A file marked #+build !js is desktop only.
3. From text to screen
flowchart LR
src[(".vct text")] --> parse["vct.parse<br/>lex, headers, sections"]
set[("set .vct")] --> parse
parse --> diags["Diags<br/>errors stop the track"]
parse --> track["<b>vct.Track</b><br/>flat sections + beats,<br/>lyrics, markers, indices not pointers"]
track --> engine["<b>vct.Engine</b><br/>loop passes, holds, Next,<br/>quiet, countdown"]
clock(["host clock: seconds"]) --> engine
engine --> ev["Events: Beat · Hold · End<br/>polled with a lookahead"]
ev --> view["<b>player.View</b><br/>what the screen shows now"]
ev --> taps["taps ring<br/>same events, as soon as polled"]
view --> stage["cmd/player/stage.odin<br/>+ layout + theme"]
view --> disp["extra monitors<br/>displays.odin"]
taps --> snap["stagesync.state_of_view<br/>one State per event, stamped"]
track --> song["stagesync.song_of_track<br/>the whole song, once"]
snap --> net["UDP / WebSocket"]
song --> net
track --> dump["vct dump<br/>timeline JSON"]
The engine decides nothing about drawing and nothing about sound: it hands out events with times, and the host shows or plays each one when its time comes. That is what lets the same timeline drive the screen, the extra monitors, the sync clients and a video render.
4. Clocks and threads
flowchart TD
subgraph ui["Main thread: one pass per frame"]
input["look_poll · handle_input · midi_input"]
adv["frame_ac += dt x 48000 x speed x backing_trim"]
render["player.render<br/>commands in, engine polled, events out"]
vu["view_update: apply events whose time has passed"]
sf["sync_frame: drain taps, publish"]
draw["draw: stage, panels, extra monitors"]
input --> adv --> render --> vu --> sf --> draw
end
card(["Audio thread<br/>backing_mix sums the stems"]) -->|"frames the mix has taken"| trim["player/lock.odin<br/>stage clock follows the track"]
trim --> adv
dec(["Decode thread<br/>align.decode + fit"]) -->|"PCM ready"| adv
render -->|"Ring buffers"| srv
srv["Sync server threads<br/>UDP recv · TCP accept · per-client WS"] --> peers["peers that pinged in the last 5 s"]
| Clock | Who keeps it | Used for |
|---|---|---|
Driver.frames |
the main thread, atomically | the engine's host clock: frames advanced / 48000 |
| Sound card | raylib's audio device, pulling backing_mix |
the master when a backing track plays; the stage clock is trimmed to the frames the mix has taken |
server_now |
the sync server, from its start | stamps on state; clients fit their own clock to it from ping round trips |
rl.GetTime |
raylib | input, fades, screenshots, the --next/--shot/--quit test driver |
Threads only ever meet through player.Ring (lock-free, single producer,
single consumer) and atomics: commands and events between the UI and the
driver, taps from the driver to the sync server, decoded PCM from the
decode thread.
5. Sequences
5.1 Opening a song and playing it
sequenceDiagram
actor Op as Operator
participant Home as Home / Pre-play
participant Lib as player.Library
participant Parse as vct.parse
participant Drv as player.Driver
participant Eng as vct.Engine
participant View as player.View
participant Stage as Stage screen
Op->>Home: open the library
Home->>Lib: scan songs/ and sets/
Lib-->>Home: entries, with stamps
Op->>Home: pick a song or a set
Home->>Parse: source + set options (arrangement, key, tempo)
Parse-->>Home: Track, or Diags if it has errors
Note over Home: errors refuse the stage, warnings only inform
Home->>Drv: driver_init(track)
Op->>Stage: Start
Stage->>Drv: Command{Start, run, from}
loop every frame
Stage->>Drv: render(frames)
Drv->>Eng: engine_poll(now + lookahead)
Eng-->>Drv: Beat / Hold / End events
Drv-->>Stage: events ring (+ taps ring)
Stage->>View: view_update(now)
View-->>Stage: section, bar, beat, countdown, lyric
end
Op->>Stage: Next (key, pad or MMC fast-forward)
Stage->>Drv: Command{Next}
Drv->>Eng: engine_next(now)
Note over Eng: releases a hold, or ends a loop pass
5.2 A sync client joining and following
See sync.md for the message fields and the clock maths.
sequenceDiagram
participant C as Client (phone, vct-sync)
participant S as Sync server
participant D as Driver taps
participant Snap as state_of_view
Note over C,S: UDP 47800, or GET /ws for a browser
C->>S: ping {id, t0} (8 in a burst, 50 ms apart)
S-->>C: pong {id, t0, t1, t2, session}
Note over C: offset = ((t1-t0) + (t2-t3)) / 2<br/>keep the 16 samples with the smallest delay
S-->>C: song {sections, lyrics, markers}
Note over C: UDP clients fetch GET /song instead
S-->>C: style (or GET /style) and the fonts it names
loop while the song plays
D->>Snap: tapped event, 0.1 s of song time early
Snap->>S: State{seq, at = server_now + (ev.time - now) / speed}
S-->>C: state
Note over C: show it when at - offset is reached
end
C->>S: ping once a second (also the subscription)
Note over S: a client that has not pinged for 5 s is dropped,<br/>and the latest state is repeated every 0.5 s
5.3 The backing track
sequenceDiagram
participant Stage as Stage
participant T as Decode thread
participant Al as align
participant Dev as Sound card
participant Lock as player/lock.odin
Stage->>T: song on stage: stems from the set list's copy, else the files
T->>Al: decode each stem, fit to the longest, sum
alt audio-offset in the header
Note over Al: bar 1 is at that second in the files
else no header
Al->>Al: onset envelope, fit the written beats
Al-->>T: offset + confidence (below 2.0 is not trusted)
end
T-->>Stage: PCM ready (it may arrive after the song started)
Stage->>Dev: play from the point the stage clock has reached
Note over Dev: backing_mix sums the stems on the audio thread,<br/>each at the mixer's level and pan
loop every frame
Dev-->>Lock: frames the mix has taken
Lock-->>Stage: backing_trim: nudge the stage clock onto the track
Stage->>Dev: mute, volume and pan changes, for the mix to pick up
end
5.4 Importing a file in the player
sequenceDiagram actor Op as Operator participant X as transfer.odin participant Cv as player/convert.odin participant Mod as score / sheet / chordpro participant Ed as player.Editor participant Lib as Library Op->>X: IMPORT (or drop a file on the window) X->>Cv: file name + bytes Cv->>Cv: format from the name and the bytes Cv->>Mod: convert to .vct text Mod-->>Cv: text + notes about what it guessed Cv-->>X: text X->>Ed: open it in the editor, not saved yet Op->>Ed: fix it up, the parse runs on every change Op->>Lib: Save, into songs/
EXPORT is the same path backwards, to .vct, ChordPro, a chord sheet, a
lyrics sheet or Guitar Pro 5. Scores are import only.
5.5 Editing in Neovim or Zed
sequenceDiagram participant E as Editor participant L as cmd/vct-lsp participant LSP as lsp package participant P as player.Editor + vct E->>L: initialize, didOpen L->>LSP: message bytes LSP->>P: keep a buffer per document P-->>LSP: lexed lines + Track + Diags LSP-->>L: publishDiagnostics L-->>E: diagnostics E->>L: didChange, completion, hover, documentSymbol, formatting L-->>E: semantic tokens, items, hover, outline, folding, vct.format edits
6. States
What the stage is doing (player.Play_State):
stateDiagram-v2 [*] --> Stopped Stopped --> Playing: Start Playing --> Holding: Hold event Holding --> Playing: Next Playing --> Paused: pause (fades out, song runs on unseen) Paused --> Playing: resume Playing --> Finished: End event Paused --> Finished: it ended while paused Finished --> Playing: Start again Playing --> Stopped: Stop Holding --> Stopped: Stop Paused --> Stopped: Stop Finished --> Stopped: leave the stage
Where the operator is (Screen):
stateDiagram-v2 [*] --> Home Home --> Preplay: a song or set chosen Preplay --> Stage: Start Stage --> Preplay: back Preplay --> Home: back Home --> Edit: new or edit a song Edit --> Stage: audition Stage --> Edit: back to the audition Edit --> Home: close Home --> Set_Edit: new or edit a set Set_Edit --> Home: close
7. Keeping these diagrams
Mermaid in Markdown is the format to use here. It renders on GitHub
with no toolchain, it is plain text so a diagram changes in the same diff as
the code it describes, and flowchart, sequenceDiagram and
stateDiagram-v2 cover everything above. Keep labels short, quote any
label with brackets or slashes in it, and leave the layout to Mermaid
rather than fighting it.
Not used, and why: PlantUML and Graphviz need a renderer in CI to produce anything a reader can see; D2 is nicer at large diagrams but is another tool to install; Excalidraw and Figma files are not diffable and go stale with no warning. A C4 model in Structurizr DSL would be worth it only if these diagrams started to drift from each other.
The text and JSON formats the project already settles on:
| Format | Where | Why |
|---|---|---|
.vct |
songs and sets | the one format a user writes by hand; see format.md |
| JSON5 | layouts/, themes/ |
hand-written config, so comments and trailing commas matter |
| JSON | the sync protocol, vct dump |
machine to machine: every message a complete object with type and v |
#! lines |
tests/corpus/err/*.vct |
the expected diagnostics live in the file they belong to |
| golden JSON | tests/corpus/ok/*.json |
scripts/bless.sh regenerates them, so a timeline change shows up as a diff |
| JSON Schema | schemas/ |
the sync messages, layouts and themes, described for client authors and editors; mise run check:schemas checks the repo's own files against them |
A diagram that doesn't parse renders as a red box on GitHub and nothing else
catches it, so mise run check:diagrams renders every Mermaid block in
docs/ with mermaid-cli and
fails if one won't draw. One wrinkle with the schemas: a layout or theme file
can't carry a $schema key, because the player rejects keys it doesn't know,
so point your editor at the schema instead.