Visual Click Track

Stage sync protocol, v1

vct-player shares its stage with other screens on the local network (on by default; Shift+S on the set list, or --no-sync, turns it off). A sync client (a phone or tablet app, another computer, vct-sync in a terminal) shows the same section, bar and beat at the same moment as the player. Clients are "dumb": they don't read songs or run the engine. Each message is a complete picture of one moment, stamped with when it happens on the player's clock. The client works out the player's clock and shows each picture when its time comes.

A client can also be given host permission by the player's operator, which lets it send commands back (start, stop, Next, a section jump, a song from the set list). Nothing may control the player until the operator turns it on for that client, in the player's list of connected screens (H).

Clients also get the whole song when it goes on stage (the song message): every section in played order with its bars, tempo and flags, plus all the lyric lines and markers. States point into it by index, so a client can draw a song map, the full lyrics with the current line lit, and so on.

The server is stagesync/server.odin and stagesync/web.odin. stagesync/client.odin is the reference UDP client, used by cmd/vct-sync. stagesync/web/index.html is the reference WebSocket client: the player serves it, so any phone or browser can follow along by opening a URL.

Transport

The server listens on one port number, 47800 by default (--sync-port N changes it), for both UDP and TCP. IPv4.

UDP One JSON message per datagram. For native apps; the lowest latency.
GET / (HTTP) The built-in web client. Open http://<player>:47800/ on a phone.
GET /ws (WebSocket, RFC 6455) The same JSON messages, one per text frame. For browsers, or any app that would rather have a reliable stream.
GET /song (HTTP) The current song message as JSON (404 if no song is up). For UDP clients, which can't get it in a datagram.
GET /setlist (HTTP) The current setlist message as JSON.
GET /style (HTTP) The current style message as JSON: how the player's theme says clients should look.
GET /fonts/… (HTTP) The font files a style names (TrueType).

Finding the server

Over UDP, send pings to the server's address if you know it. If not, send them to the broadcast address 255.255.255.255:47800. Take the source address of the first pong as the server, then ping it directly. A web page served by the player connects to ws://<the page's host>/ws.

Pings: subscribing and clock sync

Client → server:

{"type":"ping","v":1,"id":7,"t0":12.345,"client":2748193044,"name":"Drummer's phone"}

t0 is the client's own clock in seconds when it sent the ping. Use any steady clock (not wall time, which can jump). id must not be 0.

client and name say who you are, for the player's list of connected screens. Both may be left out by a client that only follows along.

Server → client:

{"type":"pong","v":1,"id":7,"t0":12.345,"t1":503.2101,"t2":503.2102,"session":2500656380}

t1 is when the server received the ping and t2 is when it replied, both on the server's clock (seconds since it started). Note t3, your clock when the pong arrives. Then:

offset = ((t1 - t0) + (t2 - t3)) / 2     server time = client time + offset
delay  = (t3 - t0) - (t2 - t1)

Keep the last 16 samples and use the offset of the one with the smallest delay. That sample is the least affected by queueing, and its error is at most delay / 2.

A ping is also how you subscribe. The server sends states to every client that has pinged in the last 5 seconds, so keep pinging:

session is a random number for each server run. If it changes, the server has restarted: drop your clock samples and queued states, and start over.

Over WebSocket, pings and pongs are the same JSON in text frames. The connection itself is the subscription, so there's no timeout, but keep pinging once a second for the clock (and close and reconnect if pongs stop for 5 seconds). WebSocket's own ping and pong frames are answered too, but they don't carry the times.

Song

Server → client, whenever a song goes on stage (opened, reloaded or edited). Over WebSocket it's pushed, and a new connection gets the current one before anything else. Over UDP, fetch GET /song when a state's song differs from the one you have.

{"type":"song","v":1,"session":2500656380,"song":1,
 "title":"Example Song","artist":"Example Worship","key":"A","tempo":72,"num":4,"den":4,
 "bars":82,"length":270.66,
 "sections":[
   {"name":"Count-in","def":-1,"count_in":true,"loop":false,"hold":false,"quiet":false,"pass":1,"passes":1,
    "first_bar":0,"bar_count":1,"beat_count":4,"start":0,"end":3.33,"tempo_start":72,"tempo_end":72,
    "num":4,"den":4,"first_lyric":0,"lyric_count":0,"first_marker":0,"marker_count":0,"chords":""},
   {"name":"Verse 1", "def":1, "first_bar":5, "bar_count":8, "first_lyric":0, "lyric_count":4,
    "chords":"| A  E | F#m  D | A  E | D  E |", "...": "..."}],
 "lyrics":[{"bar":1,"beat":1,"text":"Amazing grace, how sweet the sound",
            "chords":[{"name":"A","col":0},{"name":"E","col":14}]}, "..."],
 "markers":[{"bar":3,"beat":1,"text":"Keys only"}, "..."]}
Field Meaning
song Id of this song on stage; states refer to it. Counts up with each new song (or new version of one).
title, artist, key, tempo, num, den From the song's header.
bars Bars from bar 1 to the end.
length Seconds as written, count-in included, holds and extra loop passes not.
sections The played timeline, in order: the count-in, then each pass of each section line (Chorus 8 x2 is two). States' section_index points in here.
sections[].name As shown on stage.
def Which section line of the file it came from; −1 for the count-in. The passes of one section share it, so a client that lists a song's words shows each section once.
count_in, loop, hold, quiet The section's flags. A loop repeats until Next; a hold waits for Next at its end.
pass, passes Which pass of an xN section this is.
first_bar, bar_count, beat_count Bar numbers are global (bar 1 is the first bar after the count-in; count-in bars are 0, −1, …). A trailing partial bar counts as a bar.
start, end Seconds from the first beat, as written (one pass, no holds).
tempo_start, tempo_end BPM; they differ for a tempo ramp.
num, den Time signature.
first_lyric, lyric_count The section's lines in lyrics. The passes of an xN section share them.
first_marker, marker_count The same for markers.
chords The section's chord line (a > marker whose text is bars of chords) as | C | G |, "" if it has none. What to show for a section nobody sings in, so the band can follow those bars.
bar_chords The same chords bar by bar: one string per bar of the section ("A E", "F#m D"), "" for a bar its chord lines don't cover, and an empty list when it has none. For a client that draws the song as a chart.
lyrics[].chords The [C] chords written in that line's words (format §5.1), in the order they appear: name as written, col the character (not byte) of text the chord falls on, so a client can cut the text at it and draw the chord above what follows. Empty for a line with none, and for markers.
lyrics[], markers[] bar and beat are 1-based within one pass of the section, as in the .vct file. A last-bar marker carries the bar it resolves to in its section's first play. A marker with no banner (a dyn=/feel= change or a chord line) has an empty text. Marker roles, kinds and pass= aren't sent; --role filters only the player's own stage.

Style

Server → client: the player's theme and the phone layout, so a client can look like the stage (and like the theme the operator picked for phones: settings mobile.theme and mobile.layout). Over WebSocket it's pushed, a new connection gets it first, and it's sent again whenever the settings, theme or layout files change. Over UDP, fetch GET /style. Clients that don't draw the stage's look can ignore it.

{"type":"style","v":1,"session":2500656380,"name":"daylight",
 "colors":{"background":"#ffffff","ink":"#ffffff","text":"#000000","accent":"#5b2a86",
   "accent_line":"#8c6bb1","highlight":"#0057d9","soft":"#6a5acd","alert":"#d00000",
   "muted":"#4a4a4a","next":"#b0006a","dot":"#c9b6dd","panel":"#f0f0f0","faint":"#9a9a9a","online":"#008a3a"},
 "sections":{"verse":"#7e57c2","pre_chorus":"#9c6ade","chorus":"#5b2a86","bridge":"#00838f",
   "instrumental":"#3949ab","other":"#616161","count_in":"#dddddd"},
 "flash":1.8,"flash_start":3.2,"radius":6,"text_scale":1,
 "fonts":{"sans":["/fonts/sans-500.ttf?v=3","/fonts/sans-600.ttf?v=3","/fonts/sans-700.ttf?v=3","/fonts/sans-800.ttf?v=3"],
   "mono":"/fonts/mono.ttf?v=3"},
 "grids":{"portrait":{"areas":["header","map","state","section","beat","next","cues"],"columns":"","rows":"auto auto auto 1fr auto auto auto"},
   "landscape":{"areas":null,"columns":"","rows":""},"portrait_no_lyrics":{"areas":null,"columns":"","rows":""},
   "landscape_no_lyrics":{"areas":null,"columns":"","rows":""}},
 "scale":{"header":1,"map":1,"state":1,"section":1.5,"beat":1.4,"next":1.3,"cues":1.2,"lyrics":1,"chart":1},
 "chart":{"view":"sheet","flow":"page","bars":4,"turn":"bar"}}
Field Meaning
name The theme's name.
colors CSS hex colours (#rrggbb, or #rrggbbaa when not opaque), by what they're for: see themes/default.json5. ink is text on an accent tag; panel, faint and online are for clients (the lyrics panel, lines not being sung, the connected light).
sections Song map colours by section kind, as the player picks them from a section's name (verse…, pre-chorus…, chorus…/refrain…, bridge…, instrumental…/interlude…/break…, anything else is other), and the count-in's.
flash, flash_start The downbeat border's width, in % of the screen's short side; flash_start at the start of a section.
radius Corner radius, in CSS px.
text_scale All text, times this.
fonts Paths on this server of the text font's four weights (500, 600, 700, 800) and the numbers' font. ?v= changes when they might have.
grids The phone layout, as CSS grids of the web client's panels (header, map, state, section, beat, next, cues, lyrics, chart): areas are grid-template-areas rows (. for an empty cell), columns and rows the track lists ("" for the client's default). One grid per shape: portrait (or narrow), landscape, and the same for songs without lyrics, which fall back to the plain ones. A grid with areas: null keeps the client's own. Panels a grid leaves out are hidden.
scale A text scale per panel.
chart How the chart panel draws the song, from the layout's mobile.chart: view strip (one row of bars) or sheet (lines of bars), flow follow (moves with the playhead) or page (turns at each section), bars across (or along a line), and turn (bar, beat or downbeat: how early a page turns). The built-in web client draws it as the stage's chart widget does.

Set list

Server → client, whenever the player's set list changes (songs added, moved, removed, selected or reloaded). Over WebSocket it's pushed, and a new connection gets the current one after the song. Over UDP, fetch GET /setlist (404 until the player has published one).

{"type":"setlist","v":1,"session":2500656380,"title":"Sunday","is_set":true,
 "selected":1,"current":-1,
 "items":[{"title":"Falling Slowly","sub":"Glen Hansard · Original","key":"C","tempo":68,"meter":"4/4","playable":true}]}

selected is the highlighted row and current the index of the song on stage, −1 for none (both −1 when the list is empty). sub is "Artist · Version · Arrangement" as the player shows it, and meter the first section's time signature ("" when the song can't be read).

Host permission and commands

The player's operator decides which clients may control it, one by one, in the player's list of connected screens (C there). Nobody has permission to begin with, and the operator can take it back at any time. It goes by client id, so a phone that reloads its page or drops off the network for a moment keeps it.

Grant

Server → client, when the operator turns permission on or off, and when a client that already has it (re)joins. Over UDP it goes to the address that pinged; over WebSocket, to that connection.

{"type":"grant","v":1,"session":2500656380,"client":2748193044,"host":true}

Ignore one whose client isn't yours. Treat a lost connection as no permission; the player says so again when you are back.

Command

Client → server, over UDP or WebSocket. Dropped unless the sender has host permission, so there's no harm in a button being pressed before it does.

{"type":"command","v":1,"client":2748193044,"cmd":"section","index":4}
cmd What the player does — the same as its own key
play Enter: start the song; pause one that's playing; from a pause, start the section again. From the set list, put the selected song on stage and start it.
stop Esc: stop the song.
next Space: release a loop or a hold, or move on after the end.
resume Space while paused: pick the song up where it has got to.
section Jump to section index of the song on stage (an index into the song message's sections, as a state's section_index). A section the song marks nojump is left alone.
song The set list's row index: selected before a song is on stage, put on the stage (stopped, ready to start) while one is.
close Leave the stage for the set list.

index is 0 for the commands that don't take one. Anything the player can't do right now (a jump with nothing on stage, a song that won't play, a command while its editor is open) is ignored — the states that follow say what really happened, and nothing else answers a command.

States

Server → client. The server sends a state as soon as it knows it, up to 0.1 s before it's due. It also sends the latest state to a new client, and over UDP repeats it every 0.5 s.

{"type":"state","v":1,"session":2500656380,"seq":42,"at":503.95,
 "run":1,"state":"playing","title":"Example Song","artist":"Example Worship","key":"A",
 "section":"Verse 1","section_index":2,"section_count":17,"next":"Pre-Chorus",
 "loop_pass":1,"looping":false,"bar":5,"beat":1,"beats_in_bar":4,"bars_left":4,
 "countdown":0,"accent":"primary","click":true,"tempo":72.0,"duration":0.8333,
 "marker":"Drums in","lyric":"I once was lost, but now am found",
 "next_lyric":"Was blind, but now I see"}

Handling:

  1. Ignore a state whose session isn't the one from your pongs, or whose seq isn't higher than the highest you've seen. That drops repeats and late arrivals.
  2. A new state replaces any queued state whose at is the same or later (for example, a stop cancels the beats sent ahead of it). Queue it.
  3. Each frame, show the latest queued state whose at <= server time (client time + offset). It stays up until the next one is due.

Fields:

Field Meaning
at Server time from which this is what the stage shows. On a beat, the moment the beat sounds.
run Goes up by one each time a song is started.
song Id of the song on stage, 0 when idle.
state idle (no song on stage: the set list or editor is up), stopped, playing, holding (waiting on a hold for Next), paused (the stage is paused: clients stand at the start of the section), finished.
title, artist, key The song.
section Section name as shown (Count-in during the count-in). "" before the start.
section_index, section_count Position in the played timeline (the song's sections): the count-in, then each pass of each section.
next, next_index The section after this one, "" and −1 if none. When stopped, the first section.
loop_pass Pass of a loop section (1, 2, …); 1 elsewhere.
looping In a loop that hasn't been released with Next yet.
bar Bar number; the count-in bars are 0, −1, …
beat, beats_in_bar 1-based beat in the bar, and the bar's length. beat is 0 when not on a beat (stopped, holding, finished).
bars_left Bars left in this section, or in this loop pass, counting this one.
countdown Beats to the next section during the cue window; 0 outside it.
accent primary (beat 1), secondary (e.g. beat 4 of 6/8) or none.
click false in a quiet section.
tempo BPM as played, including any speed change (practice slider, MIDI clock).
duration Seconds this beat lasts on the server clock; 0 when not on a beat. Use (server time - at) / duration for progress through the beat, e.g. for a flash or a sweeping hand.
marker, marker_index The marker text on screen and its index in the song's markers; "" and −1 if none.
lyric, lyric_index The lyric line on screen and its index in the song's lyrics; "" and −1 if none.
next_lyric, next_lyric_index The line after it.
metronome true while the player is in metronome mode (M on any of its screens): clients should show only a metronome.
chords true while the player is showing chords above the words (C on any of its screens). A client follows it, and may let the viewer choose for themselves until the player changes it again, as the built-in web client's Chords button does.

Accuracy

On a quiet network, clients show each beat within a millisecond or two of the player's screen (vct-sync prints how late each line is). What the protocol can't correct: