Visual Click Track

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.