Visual Click Track

Visual Click Track

A screen-based click track for worship teams: instead of (or as well as) an audio click in your ears, a display shows the current section, the bar and beat, and a countdown to the next section.

Songs are described in a small text format, .vct. Learn it from the guide, docs/guide.md; the full specification is docs/format.md, and examples/example-song.vct uses most of it.

Layout

docs/architecture.md has the diagrams: what talks to the player, which package owns what, where time comes from, and the order things happen in.

Path What
vct/ Core package: parses .vct, builds the beat timeline, and plays it with a runtime engine (engine.odin); lex.odin splits lines into coloured spans and rewrites them for the editor. No I/O, no globals; everything is allocated from the allocator you pass in. Public types are flat, C-shaped structs, so the package can later be exported with a C ABI or ported to C.
cmd/vct/ vct command-line tool: check, lint, outline, dump and align.
ug/, cmd/ug2vct/ ug2vct: makes a .vct from an Ultimate Guitar pro tab (see below). ug/ is the conversion, with no I/O.
sheet/, cmd/sheet/ sheet: a chord sheet (chords above the words) to .vct or Guitar Pro (see below). sheet/ is the conversion, with no I/O; sheet/text.odin also reads lyrics sheets and writes both kinds, for the player's IMPORT and EXPORT.
score/, cmd/score2vct/ score2vct: MusicXML, MuseScore and MIDI to .vct (see below). score/ is the conversion, with no I/O; the player's IMPORT uses it too.
chordpro/, cmd/chordpro/ chordpro: ChordPro to .vct and back (see below). chordpro/ is the conversion, with no I/O.
sections/ Names a song's sections from the words it sings (Verse 1, Chorus, Pre-Chorus), for imports that bring no names: a score marked A, B, C, or a sheet with no headings. Repetition does the work; no I/O.
player/ Platform-independent part of the desktop player: runs the engine on a frame clock and keeps the screen state; the editor's model (text buffer with undo, structured edits, autocomplete).
cmd/player/ vct-player, the desktop player built with raylib: the library screens, the pre-play screen, plays a song on screen, and edits songs and sets (edit.odin, edit_form.odin, set_edit.odin). macOS is the primary target.
web/, scripts/build-web.sh The web version of the player: the page that loads it (web/index.html) and the script that compiles cmd/player to WebAssembly.
stagesync/, cmd/vct-sync/ Stage sync: the server vct-player runs (on by default), so other screens on the network show the same beat at the same moment, with the song map and lyrics. JSON over UDP and WebSocket, pure Odin (core:net); the built-in web client is stagesync/web/index.html, and vct-sync is a terminal client. The player lists the screens following (H) and can give one host permission, which lets it control the player. The protocol is in docs/sync.md.
lsp/, cmd/vct-lsp/, editors/ vct-lsp, a language server for .vct (diagnostics, semantic highlighting, completion, hover, outline, folding, formatting with vct.format), built on the editor's model; lsp/ handles messages with no I/O. Setup for Neovim and Zed in editors/README.md.
qr/ QR code encoder (ISO/IEC 18004, byte mode, all versions), for the phone link on the home screen (Q). No I/O.
packaging/macos/, scripts/package-macos.sh, scripts/notarize-macos.sh The macOS app: Info.plist template, and scripts that build, sign and notarise StageDisplay.app.
layouts/ Stage display layouts (JSON5): which widgets the stage shows and where, and how the phone client arranges its panels. All are built in, and each stage display can show a different one; see below.
themes/ Themes (JSON5): colours, line thicknesses, fonts and text sizes, for the stage and the phone client. All are built in; see below.
schemas/ JSON Schemas for the stage sync messages (sync.schema.json, with an example of each message in examples/) and for the layout and theme files. mise run check:schemas checks the files in layouts/ and themes/ and the examples against them.
assets/fonts/ Fonts built into the player: Barlow (Medium, SemiBold, Bold, ExtraBold) for text, Share Tech Mono for numbers. SIL Open Font Licence for each alongside.
tests/ Unit tests, plus a language-neutral corpus: corpus/err/*.vct list the diagnostics they must produce in trailing #! lines; corpus/ok/*.vct must parse cleanly and match <name>.json when one exists.
scripts/bless.sh Regenerates the golden files: the corpus timelines, and the importers' .vct output.
docs/mobile.md No code yet: the research behind embedding the player in native mobile apps. What already builds for iOS, why the window and audio layer is the only blocker, how raylib, SDL3 and sokol compare, why the wasm build isn't the way in (and is worth keeping anyway), and a feature-by-feature look at what reaches a phone. Tracked as feature 28 in docs/PROJECT.md.

Build and test

Requires the Odin compiler.

odin build cmd/vct -out:build/vct
odin test tests

build/vct check   examples/example-song.vct
build/vct lint    examples/example-song.vct   # also warns about things that look unintended
build/vct outline examples/example-song.vct
build/vct dump    examples/example-song.vct > timeline.json
build/vct reflow  song.vct --write             # join lyric lines that go by too fast to read
build/vct align   song.vct --audio track.wav   # the audio-offset that puts bar 1 on the music (--write saves it)

odin build cmd/player -out:build/vct-player
build/vct-player examples/example-song.vct
build/vct-player examples/example-song.vct --role drums   # only the cues for drums (and everyone)

Two checks cover what the Odin tests can't, both optional and neither needed to build:

mise run check:schemas    # layouts/, themes/ and the sync examples against schemas/
mise run check:diagrams   # every Mermaid diagram in docs/ still draws

check:schemas needs uv (or python3 with jsonschema and json5); check:diagrams needs mermaid-cli, which it fetches with npx if mmdc isn't on PATH.

Docs site

scripts/build-docs.py (mise run build:docs) turns this README, docs/*.md and editors/README.md into a static site in build/docs (plain HTML, one stylesheet, Mermaid diagrams drawn in the browser). It needs uv, or python3 with markdown-it-py and mdit-py-plugins. Any static server will do to read it locally:

mise run docs           # builds, then serves build/docs at http://localhost:8000

To host it on Cloudflare Pages, upload the folder with Wrangler:

npx wrangler pages deploy build/docs --project-name stagedisplay-docs

or connect the repo in the Cloudflare dashboard with the build command pip install markdown-it-py mdit-py-plugins && python3 scripts/build-docs.py and the output directory build/docs.

Editor support (Neovim, Zed)

mise run build:vct-lsp builds build/vct-lsp, a language server for .vct files. Put it on PATH, then follow editors/README.md for Neovim (a plugin directory) or Zed (a dev extension).

Web version

scripts/build-web.sh (mise run build:web) compiles the player to WebAssembly and writes a static site to build/web: index.html, index.js, index.wasm, index.data (the example songs), odin.js and the icon. It needs Emscripten's emcc on PATH (install emsdk, then source emsdk_env.sh) to link raylib for the browser. Serve the folder over HTTP (a file:// page can't load the wasm) and open it:

scripts/build-web.sh
mise run web            # builds, then serves build/web at http://localhost:8000

The web version is the same app: library, stage and editors. Songs come in by dropping files on the page or with Add song (the browser's file picker); they are copied into the browser's storage (IndexedDB), so they are still there after a reload. On the first visit the library starts with the examples. Not on the web yet: MIDI, --render, the command-line options, and saving songs back out of the browser.

Rendering a song to video

vct-player --render OUT song.vct plays the song from start to finish into a video file, frame for frame as the stage shows it, with the song's audio: file (any format ffmpeg reads) lined up by audio-offset. It needs ffmpeg and ffprobe on PATH, and draws off screen as fast as it can (no real-time wait; under Linux without a display, run it with xvfb-run).

build/vct-player --render song.mp4 examples/example-song.vct
mise run render -- song.mp4 examples/example-song.vct --fps 30 --size 1280x720

Options: --audio FILE and --audio-offset SEC override the song's header (with no offset in either, beat detection finds it), --fps N (60), --size WxH (1920x1080), --lead SEC (2: how long the stopped stage shows before the song starts), --tail SEC (3: how long the finished stage stays up, longer if the audio runs on). Loops and holds are released as they come, so the song plays its written length and stays in step with the audio; --next SEC (repeatable) presses Next at that second of the video instead, as on stage. .mp4, .mov, .mkv and .m4v get H.264 and AAC; other extensions use ffmpeg's defaults for the format.

To render a set, give --set NAME (and --library DIR if needed) instead of a song: OUT is then a folder, and each song is rendered to its own NN Title.mp4 in it, in set order, as the set plays it (its arrangement, key and tempo). Songs that can't be played are skipped and reported.

build/vct-player --render videos/ --set "Sunday"

Stage displays (more monitors)

With a projector or another monitor plugged in, press D on the home screen (or click DISPLAYS) and turn on the displays that should show the stage. Each one gets a full-screen window that can't be clicked: the stage while a song is on it, and "Stage Display" otherwise, as the web client shows. The display with the app's own window can't be picked, so the controls stay where they are. The choice is remembered by display name; --display N (repeatable, numbered as in the list) picks displays for one run instead.

Each display can show a layout of its own, so one screen can be set up for the singers and another for the band. In the list, Shift and the display's number — or a click on the layout shown on its row — moves it on to the next layout (anything the settings' layout takes, your own included); a display with none set shows the stage's own. --display N:LAYOUT sets one for a single run.

A set or a song can choose for every display at once with a stage-layout: line (format §3 and §12): while it is open, each display turned on shows that layout instead of its own, and layout: does the same for the player's own screen. stage-layout[Name]: gives the display of that name (as this list shows it) a layout of its own instead. The built-in click layout is made for this — only the metronome, the count-in to each section and the cues — so a band screen can stay that simple while the operator keeps the full stage:

set:                    Sunday Morning
stage-layout:           click
stage-layout[EPSON PJ]: singers

The stage fills whatever screen it's on: on a 4:3 projector, a 16:10 laptop or an ultrawide monitor the layout spreads out to the screen's shape instead of leaving black bars, with text at the same size.

Sharing the stage with other screens

vct-player runs a sync server on port 47800 (--sync-port N changes it). It's on by default: Shift+S on the home screen turns it off and on, and --no-sync starts with it off. While it's off, the home screen's footer and the QR code screen have an Enable Sync button instead. Screens on the same network show the same section, bar and beat as the player, in time with it, along with the song map and the lyrics. They don't need the song file.

build/vct-player examples/example-song.vct
build/vct-sync                 # or: build/vct-sync 192.168.1.20

Connected screens, and letting one control the player. H (on the set list, the pre-play screen, or the stage while sync is on) lists the screens following, with the name each gives itself and where it is. Turning HOST on for one — its number key, or a click — lets that screen start and stop the song, press Next, jump to a section by tapping the song map, and pick a song from the set list, as if it were pressing the keys on the player. Nobody has it until the operator hands it out, it can be taken back the same way, and the player's footer says while anyone has it. A phone keeps its permission across a page reload.

To write a client of your own (a native phone app, say), see docs/sync.md.

Settings, themes and layouts

The player reads settings.json5 from its config folder (~/Library/Application Support/vct-player/ on macOS, ~/.config/vct-player/ on Linux), writing a commented one on the first run:

{
  theme: "default",       // the stage's theme
  layout: "stage",        // the stage's layout
  mobile: {               // the phone client, if it should differ
    theme: "daylight",
    layout: "focus",      // uses this layout's `mobile` section
  },
}

Names are looked up in themes/ and layouts/ in the library (Documents/StageDisplay/, where the editor saves) first, then in themes/ and layouts/ beside the settings file (your own, as NAME.json5), then among the built-in ones: themes default, daylight (dark on white, for bright rooms) and contrast (white and yellow on black, bigger type); layouts stage, focus, singers (the words as large as the screen allows, with the key and the count-in — made for a singers' monitor), click (only the metronome, the count-in to each section and the cues — made for a band's stage display), metronome (the metronome face filling the screen, with the count-in, cues and song map), lyrics (the whole song's words beside the beat, with their chords), and strip and sheet for players who follow the music rather than a counter: the song drawn like a lead sheet, as bars of slashes with the chords over their beats, the cues over theirs and the words underneath. strip is one row of bars sliding past a playhead; sheet is lines of bars down the screen that turn a page a bar before each section. Their chart widget takes view (strip or sheet), flow (follow or page), bars and turn (bar, beat or downbeat), so a layout of your own can mix them. A value with a slash or ending in .json5 is a file path. Saving any of these files (or a theme's font) while the player runs applies it straight away; a file with a mistake leaves the default in its place and says why on screen for a few seconds and on stderr.

mise run player -- --theme daylight --layout focus examples/example-song.vct
build/vct-player --theme ./my-theme.json5 --settings ./gig-settings.json5

T on the home screen (or its LAYOUTS + THEMES button) lists every layout and theme, with a thumbnail of each. Built-in ones can only be copied (Enter, or Ctrl/Cmd+D for any); a copy goes in the library's layouts/ or themes/ folder, where it can be renamed (R) and deleted (Delete). N starts an empty one. U (USE) makes one the player's own: it is written into settings.json5 as layout: or theme:, leaving the rest of the file, comments and all, as it was. Renaming one changes the sets, displays and settings that name it, and the themes built on it; deleting one puts those back to the player's own (the prompt lists them). A copied built-in theme is just base: "NAME", so it follows the built-in until something is changed.

Enter (or EDIT) on one of your layouts opens the layout editor: the stage as it will look, playing a built-in song, with every widget's box over it. Drag a box to move it and its edges or corners to size it; it snaps to the grid. The arrow keys move the selected widget a cell (with Shift, its right or bottom edge), and the panel on the right lists the widgets in drawing order and sets the selected one's scale, which songs it shows for, its highlight and chords, and the song map's ticks (B). A adds a widget, Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z redoes, and Ctrl/Cmd+S saves. Below the preview: pause it, skip to the next section, play the song with or without its words, and try it in another theme. Saving rewrites the file from the layout, so comments in it aren't kept; the mobile section is.

One of your themes opens in the theme editor the same way, or with E / EDIT THEME from the layout editor (a built-in theme is copied first). Every field a theme file sets is listed beside the preview, the ones the theme sets itself marked; a colour is set with hue, saturation and brightness sliders or typed as hex (Enter), a size on a slider (Left/Right step it), a font by choosing or typing a file, or dropping one on the window. Delete puts a field back to the base's. Saved, a theme is its base and what differs from it.

A set can carry its own layout: and theme: (format §12, set with L and H in the set editor), which stand in for these while the set is open, and a song its own layout: (format §3), which stands in while the song is on stage. L on the stage or the pre-play screen moves the stage on to the next layout for this run only. The order, strongest first: L, --layout / --theme, the song's, the set's, the settings file's. A set's or song's stage-layout: is for the stage displays (above).

--theme and --layout override the settings for one run (the stage, and any display with no layout of its own), and --settings FILE reads another settings file. -define:LAYOUT=name (or LAYOUT=name mise run build:player) picks the layout used when the settings name none. mise run build:player builds optimised (-o:speed, 15–20 s), as the packaged app is: unoptimised, beat detection and levelling make a song's backing track load about 3× slower. OPT=none mise run build:player builds in about 2 s, for when you are changing code rather than playing.

Themes. themes/default.json5 lists every key with its default. A theme of your own only needs what it changes:

{
  base: "default",                          // start from another theme
  colors: { highlight: "#7CFC00", accent: "#2E5E3E" },
  sections: { chorus: "#2E5E3E" },          // song map colours by section kind
  sizes: { flash: 30, radius: 4, text_scale: 1.1, glow: 0 },
  fonts: { sans: "fonts/Inter.ttf", bold: "fonts/Inter-Bold.ttf", mono: "fonts/JetBrainsMono.ttf" },
  mobile: { flash: 1.6, radius: 4, text_scale: 1.2 },
}

colors are the palette (background, text, ink on coloured tags, accent, highlight, alert, muted, next and more); sizes are line thicknesses (the downbeat flash, beat column outlines and corners, the song map's boxes and playhead) and the stage's text and number scale, on the 1920×1080 canvas; fonts are TrueType/OpenType files relative to the theme file (positioned by their own metrics); mobile sizes the phone client's flash, corners and text. The phone client uses the theme's colours and fonts too: the player serves the font files to it, so it needs no internet at the venue. The song editor's code always uses Share Tech Mono.

Layouts. A layout lists widgets, each with a box [x, y, w, h] on the 1920×1080 canvas (boxes sit on a 20-unit grid, 96×54 cells, which is what the layout editor snaps to; a file written by hand may use any number), an optional scale, an optional when ("lyrics" or "no-lyrics") and, on the lyrics, now-lyrics, next-lyrics and all-lyrics widgets, highlight: true to show the current line bright (off by default; the next line lights just after the last beat of the bar before it). The karaoke widget always lights the current line and scales to its box. See layouts/stage.json5 and player/layout.odin for the widget types.

A layout's mobile section arranges the phone client as a CSS grid of its panels (header, map, state, section, beat, next, cues, lyrics) for each orientation, with a text scale per panel; panels left out are hidden:

mobile: {
  portrait:  { areas: ["header", "map", "section", "beat", "lyrics"],
               rows: "auto auto auto auto minmax(0, 1fr)" },
  landscape: { areas: ["header header", "section lyrics", "beat lyrics"],
               columns: "1fr 1fr", rows: "auto 1fr 1fr" },
  landscape_no_lyrics: { areas: ["header", "section", "beat"] },
  scale: { section: 1.5, beat: 1.4 },
}

odin test tests checks every theme and layout file.

lyrics is the layout for songs with words: NOW and NEXT with their cues down the left, the beat counter in the middle, and the whole song's words on the right, scrolled to the line being sung, as the web client shows them. Its all-lyrics widget takes chords: true to draw each line's chords above the words they fall on.

The song-map widget fills its box. It takes ticks: true to draw a progress bar under the current section, with a mark at the start of each bar; without it (the default) nothing is drawn under the blocks.

Importing from Ultimate Guitar

ug2vct starts a .vct from an Ultimate Guitar "Official" (pro) tab. Pass the tab id or its URL:

odin build cmd/ug2vct -out:build/ug2vct
build/ug2vct https://tabs.ultimate-guitar.com/tab/glen-hansard/falling-slowly-official-2459456
# wrote falling-slowly.vct (7 sections, 64 bars); bar counts are estimates, check them

It reads api-web.ultimate-guitar.com/v1/tab/pro/meta?id=N with libcurl (vendor:curl). macOS has libcurl built in; on Linux, building ug2vct needs the libcurl and mbedtls development packages (on Debian/Ubuntu, libcurl4-openssl-dev libmbedtls-dev). That endpoint needs no login, cookies or special headers. If Ultimate Guitar ever starts refusing it, set UG_COOKIE to the Cookie header your browser sends.

The tab gives the title, artist, tempo and the sections of its chord sheet. (The bars themselves are in the tab reader's score file, which is encrypted per download, so the tool doesn't use it.) It doesn't give bar counts, key or time signature, so:

Check estimated bar counts against the recording before using the file on stage. Only the structure is copied: never the words. Other options: -o FILE (- for standard output), --count-in N and --json FILE (convert a saved response instead of fetching it).

Reading section bars with Claude for Chrome

Instead of scrolling the tab and noting each section's bar by hand, you can ask Claude for Chrome to read them off the page you're viewing:

  1. In Chrome, log in to Ultimate Guitar and open the tab, e.g. https://tabs.ultimate-guitar.com/tab/glen-hansard/falling-slowly-official-2459456. Use the Tab view (not Chords), so bar numbers are shown.

  2. Open the Claude side panel (the Claude icon in the toolbar) and send:

    On this Ultimate Guitar tab, list every section label shown above the
    staff (Intro, Verse 1, Chorus, ...) in order, with the number of the bar
    it starts on, then the number of the last bar of the song. Scroll through
    the whole tab to the end. Reply with one line only, in exactly this form
    and with nothing else:
    Intro:1, Verse 1:5, Chorus:21, end:70
    Only names and bar numbers: no lyrics, chords or notes.
    
  3. Check a couple of entries against the page (the first section is usually at bar 1, and the numbers must increase), then paste the line into --sections:

    build/ug2vct 2459456 --key C --sections "Intro:1, Verse 1:5, Chorus:21, end:70"
    

If a section label is repeated (two Choruses in a row), keep both entries: ug2vct turns equal sections in a row into x2. If the reply doesn't parse, ug2vct says so and writes nothing.

Chord sheets to .vct or Guitar Pro

sheet reads a chord sheet as copied from a song site ([Verse 1] headings, chord names on the line above the words they fall on) and writes a .vct or a Guitar Pro 5 file:

odin build cmd/sheet -out:build/sheet
build/sheet vct --tempo 68 --key C --artist "Glen Hansard" falling-slowly.txt
build/sheet gp5 --tempo 68 --key C --artist "Glen Hansard" falling-slowly.txt

A sheet has no bars, so as with ChordPro each chord gets one bar (a line of words with no chords, one; --bars-per-chord N changes it). Check the bar counts against the recording. A line is a chord line when every word on it is a chord (Am, F#m7b5, Csus2, C/F); chord-only lines (an intro) are bars with no words. Headings are [Name], or a known name ending in a colon (Chorus:). --title (default: the file name), --artist, --key, --time and, for vct, --count-in N set the song's details; vct needs --tempo (gp5 uses 120 without it).

The .vct has the words and chords of each section, like a ChordPro import. The Guitar Pro file has a track of one rest per bar, with the chord's name above it (a chord diagram with no fingering), the words under that chord as text, and a marker at each section. It has no notes, so it's a chart to read or add to in Guitar Pro or TuxGuitar, not something to play back. The bar has to be one rest long: 2/4, 3/4, 4/4, 6/8 and so on, not 5/4.

ChordPro import and export

chordpro import makes a .vct from a ChordPro file, and chordpro export writes a .vct as ChordPro:

odin build cmd/chordpro -out:build/chordpro
build/chordpro import song.cho          # wrote amazing-grace.vct; bar counts are estimates, check them
build/chordpro export examples/example-song.vct -o -

Import keeps the title, artist (or subtitle), key, capo, tempo, time, copyright and CCLI number, the sections ({start_of_verse: Verse 1}, {soc}, {chorus} to repeat the last chorus, and {c: Bridge}-style headings), the words and the chords. ChordPro has no bars, so each line of words gets one bar per chord (a line with no chords, one bar), at the bar the line starts on, and its chords become a > 1 | G | D | chord line. Other comments become markers, and tab and grid blocks are skipped. --bars-per-chord N changes the ratio; --tempo, --time, --key and --count-in N set those headers (a file with no {tempo} needs --tempo). Check the bar counts against the recording.

Export writes the standard tags, then each section as an environment, with each line's chords spread evenly over its words (.vct knows which bar a chord is in, not which word). The rest (the exact section line, markers, chord lines, lyric positions, other languages and headers ChordPro has no tag for) goes into {x_vct_header: …}, {x_vct_section: …}, {x_vct_line: …} and {x_vct_at: …} directives. Other apps ignore those, and import reads them back, so exporting and importing again gives the same song. Songs that use include: can't be exported yet.

Scores: MusicXML, MuseScore and MIDI

score2vct makes a .vct from a score. The player's IMPORT takes the same files.

odin build cmd/score2vct -out:build/score2vct
build/score2vct song.mscz          # wrote test-song.vct; check the section names and bar counts
build/score2vct song.mxl -o -      # MusicXML (.musicxml, .xml or compressed .mxl)
build/score2vct song.mid

From MusicXML and MuseScore (.mscz, or an uncompressed .mscx) it keeps the title, artist, key, tempo, time signatures and key changes. It starts a section at each rehearsal mark. Where every mark is just a letter (A, B, C), the sections are named from the words instead (Verse 1, Pre-Chorus, Chorus, Bridge, Outro; package sections): the parts that come back are the chorus and what leads into it, the rest are verses in order. The names are guesses, so check them in the editor. Bars before the first mark are an Intro, and a pickup bar is a Pickup 0+B. Repeats and first and second endings are played out in full, and a section played twice in a row becomes x2. D.S. and D.C. aren't followed. A change of tempo or time signature part-way through a section splits it, and the second part is silent. The chord symbols become a chord line for each section (a bar with no symbol keeps the chord before it). The words become lyric lines, a new line after each rest, from the first part with lyrics, using verse 2 on a repeat's second pass.

MIDI files have no chord symbols or rehearsal marks. Markers are used as section names, lyric events as words, and the tempo, time and key events as they are; a file without markers is one section. A score with no tempo gets 120 BPM, as in MuseScore, with a comment saying so.

MuseScore files are read directly, with no MuseScore install or account. musescore.com's old developer API (keys by email) no longer seems to be offered, and downloads there are limited by account and copyright, so the importer works on files you already have. mscore -o song.musicxml song.mscz (the MuseScore app's command line) converts a score that won't import into MusicXML.

Importing and exporting in the player

The SONGS tab's IMPORT button (or I) takes songs from files (the native picker; several at once) or pasted text, in any of these formats: .vct, ChordPro, a chord sheet (the "chords over lyrics" text Ultimate Guitar shows, with [Verse 1] headings), a lyrics sheet (the same without chords, as SongSelect gives it), or a score: MusicXML, MuseScore or MIDI (files only; see above). The format is worked out from the file name and the text; a file whose format can't be told asks for it, and pasted text shows the format it looks like, which you can change. Chord and lyrics sheets have no published spec; sheet/text.odin describes the rules it follows (those of ChordSheetJS's Ultimate Guitar parser).

A sheet with no [Verse 1] headings at all (words pasted from a web search, say) is split into sections and named from its words, the same way as a score's lettered marks: the blank lines between the parts where there are any, else the lines that come back later (a chorus) and what lies between them.

Like a ChordPro import, bar counts are guesses (one bar per chord in a line, or per line of words), and a song with no tempo gets 100 BPM: the imported file says so in a comment at the top. Check both in the editor.

EXPORT, on a song's row (or X) and in the editor (Ctrl+E), writes the song as a chord sheet, lyrics sheet, ChordPro or .vct, with the native save panel (a download on the web). Sheets start with Title:, Artist:, Key:, Tempo: lines, which the import reads back.

Player

vct-player uses vendor:raylib, which ships with the Odin compiler, so there's nothing else to install. Songs and sets are plain files in ~/Documents/StageDisplay/songs and …/sets (--library DIR uses another folder). The home screen has two tabs, SONGS and SETS (Tab switches). Pass .vct files on the command line, drop them on the window or press A to type a path: they are copied into the songs folder. Each song shows its version (version: in the file, else "Original"), key, tempo, time and a compact timeline. Two files with the same title and version are shown as Title (file) with a warning; give one a version: to tell them apart. R re-reads the folders (after editing files outside the app).

Enter (or double-click) on a song or set opens the pre-play screen: the set's name (or the song's) over the list of songs with what the count-in before each will be. Drag the grip (or Alt+↑/↓) to reorder them for this run; the saved set doesn't change. Enter starts from the selected song, and the stage moves on through the list. On the stage: Enter starts and stops, Esc goes back to the pre-play screen, the set's name shows above the song's, Space (or →, ↓, Page Down, which is what most page-turner pedals send) is Next, R reloads the file after editing it, F toggles fullscreen, L moves the stage on to the next layout for this run (also on the pre-play screen; the footer shows the one in use). Shift+←/→ puts the song before or after this one on the stage, stopped and ready to start. Ctrl/Cmd+1…9 (0 for the tenth) jumps to that section, as the controller's section pads do, and so does a click on a block of the song map. Stopped, a jump only marks the section (outlined on the map); Enter then starts the song there. While a song is playing Esc stops it only if a second Esc follows within two seconds (the footer asks for it), so one stray key can't end a song mid-service. A song with several audio tracks (audio[Drums]: drums.wav, format §3) plays them all together; 1–9 mute and unmute them, and Tab shows a mixer with a volume and pan slider for each. While a track plays the stage clock follows it rather than the other way round — the sound card's clock isn't the screen's, and the band plays to what it hears — so the two don't drift apart over a long song; the mixer panel shows how far apart they are and the speed the clock is running at to keep up (--drift prints the same). Songs with lyrics (" lines, format §5.1) show the line being sung karaoke style above the song map, with the next line dimmed beneath it (tagged ON 3 when it comes in on a beat other than 1). Files with errors show their diagnostics and won't play.

Sets

A set is a file in the sets folder (format §12): a name and a list of songs. N on the SETS tab (or E on a set) opens the set editor: add songs from your library (always as the original), drag them into order, and for each song click the arrangement (or Left/Right) to play one of the song's arrangement:s, and type a key (K) or tempo (T) to change them for this set only. Ctrl/Cmd+S saves.

The set also carries the look it is played in: the LAYOUT, STAGE DISPLAYS and THEME chips beside its name (L, D and H, or a click on either end of a chip) pick from the same layouts and themes the settings file takes, and are saved as the set's layout: (this screen), stage-layout: lines and theme: (format §12). The STAGE DISPLAYS chip (D) opens a list of the displays connected and those the set names: a layout for every display, and one for each display by name (Left/Right, or a click). Left as the player's own — shown dimmed, the stage displays' as "per display" — the set changes nothing. The choice applies from the moment the set is opened: the lyrics layout for a wordy evening service, contrast for a sunlit room.

Editing songs

Press E (or the row's EDIT button) to edit the selected song, E on the stage when it's stopped, or N / NEW SONG to start one from a template. The editor has two views of the same file, switched with Ctrl/Cmd+T:

The backing track goes in the AUDIO field (on a Mac, CHOOSE… opens the file picker; the audio suggestion in the text view does too). A file in the song's folder is written as a relative path. FIND (or Ctrl/Cmd+B) finds audio-offset by beat detection; leave it empty to detect it when the song plays.

Drag blocks on the song map to rearrange sections; the preview shows the song's length, bars and tempo as you go. F5 (or Ctrl/Cmd+Enter, or PLAY) auditions the song as it stands, saved or not, from the section at the caret with a bar of lead-in (Shift+F5 from the top), with its backing track at full tempo; Esc brings you back to the editor where you were. While auditioning, the PRACTICE slider (or [ and ], 0 to reset) slows the song down or speeds it up, from 40% to 150%, even mid-song. Ctrl/Cmd+S saves (a new song asks for a path and joins the library), Ctrl/Cmd+Z undoes, and Esc goes back, asking first if there are unsaved changes.

macOS app

On a Mac, scripts/package-macos.sh builds build/StageDisplay.app: a binary (Apple Silicon) with the icon and a .vct file association, so double-clicking a song (or dropping one on the Dock icon) opens it. It signs ad-hoc by default, which is enough to run it on the Mac that built it.

To hand it to others, sign with your Developer ID and notarise:

xcrun notarytool store-credentials vct-notary --apple-id you@example.com --team-id TEAMID
scripts/package-macos.sh   # picks your Developer ID identity automatically; SIGN_ID=... overrides
scripts/notarize-macos.sh   # writes build/StageDisplay-<version>-macos.zip

VERSION, BUNDLE_ID and ARCHS can be set too; see the script headers.

Using the core package

import "core:mem/virtual"
import vct "path/to/vct"

arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
defer virtual.arena_destroy(&arena)

track := vct.parse(src, virtual.arena_allocator(&arena))
if !track.ok {
	// track.diags has line/column/message for each problem
}
for beat in track.beats {
	// beat.t (seconds), beat.bar, beat.beat, beat.accent,
	// beat.say (voice cue, "" for none), beat.section, beat.marker, beat.lyric
}

Playing a track

The engine is the transport: it plays the timeline against your clock and handles loop, hold, quiet, the operator's Next button and the screen countdown. It does no I/O; you pass in your own monotonic time and pull events with a short lookahead.

e: vct.Engine
vct.engine_init(&e, track)
vct.engine_start(&e, now())

events: [64]vct.Event
for e.state != .Finished {
	n := vct.engine_poll(&e, now() + 0.05, events[:])
	for ev in events[:n] {
		// ev.kind: .Beat (ev.click, ev.accent, ev.say, ev.marker,
		// ev.countdown, ev.bars_left), .Hold or .End, all at ev.time
	}
	if next_button_pressed {
		vct.engine_next(&e, now()) // ends a loop, releases or skips a hold
	}
}