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). |
- Every message is a UTF-8 JSON object with
"type"and"v": 1. Ignore messages with anotherv, and ignore fields you don't know (new ones may be added within v1). - Datagrams stay under 1400 bytes; the text fields of a
stateare cut to 160 bytes, at a character boundary. Asonghas no such limit. schemas/sync.schema.jsondescribes every message, with one of each inschemas/examples/to test a client against.mise run check:schemaschecks them.
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.
clientis a number of your own (1 to 2³²−1) that stays the same from one connection to the next: keep it somewhere that outlives a restart (the web client keeps it in the browser's storage). It is how host permission finds its way back to you after a reload or a dropped connection. A client without one is listed as unnamed and can't be given permission.nameis what the player's list calls you, up to 40 bytes ("Drummer's phone", "Front of house"). A later ping may change it.
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:
- send a burst of 8 pings 50 ms apart when you connect;
- then send one ping a second;
- if no pong arrives for 5 seconds, start again with a burst (and with broadcast, look for the server again).
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:
- Ignore a state whose
sessionisn't the one from your pongs, or whoseseqisn't higher than the highest you've seen. That drops repeats and late arrivals. - A new state replaces any queued state whose
atis the same or later (for example, a stop cancels the beats sent ahead of it). Queue it. - 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:
- An asymmetric route (much slower one way than the other) shifts the clock estimate by up to half the difference.
- Resuming from a
hold, and stopping, take effect when they are pressed, so they reach clients one network delay late. Everything else is sent up to 0.1 s ahead. - A display's own lag (rendering, vsync, the panel itself) is up to the
client. Offer a per-device offset setting if it matters. The web client
takes
?offset=MS, and?debugshows the round trip and how far from its time each state was drawn. - A browser draws once a frame, so the web client puts each state up on the frame nearest its time: within half a frame (8 ms at 60 Hz).
- TCP resends a lost packet instead of skipping it, so on a lossy Wi-Fi link a WebSocket client can be late by a resend. UDP clients skip the lost state instead.