Project tracker
The living record of the project: what each feature is, what's done, open issues and questions, and what's left. Update it in the same commit as the work it describes.
Status key: β done Β· π§ in progress Β· β³ not started Β· π€ deferred
Issues and questions have stable IDs (ENG-Q1, PLAT-Q1, β¦) so commits and
PRs can refer to them. When one is resolved, move it to the feature's
Decisions list with the answer rather than deleting it.
Overview
A screen-based click track for worship teams: a display shows the current
section, bar and beat, and a countdown to the next section, with an optional
voice announcing sections. Songs are written in the .vct text format
(format.md). How the packages, clocks and flows fit together is
drawn in architecture.md.
| # | Feature | Status | Where |
|---|---|---|---|
| 1 | Track format spec | β v0.1 | docs/format.md, docs/guide.md |
| 2 | Parser and timeline builder | β | vct/parse.odin, vct/build.odin |
| 3 | CLI | β | cmd/vct/ |
| 4 | Tests and CI | β | tests/, .github/workflows/test.yml |
| 5 | Runtime engine | π§ | vct/engine.odin (merged, #2) |
| 6 | Player platform and app shell | π§ | cmd/player/, player/, packaging/macos/, scripts/package-macos.sh |
| 7 | Audio click output | π§ optional (K) | β |
| 8 | Voice cues | β | β |
| 9 | Screen display | π§ | cmd/player/stage.odin, cmd/player/ui.odin, cmd/player/displays*.odin, cmd/player/look.odin, cmd/player/chart.odin, player/layout.odin, player/chart.odin, player/theme.odin, player/look.odin, player/displays.odin, layouts/, themes/ |
| 10 | Operator controls | π§ | cmd/player/main.odin |
| 11 | Backing track sync | π§ | align/, vct align in cmd/vct/ |
| 12 | C ABI / ports | π€ | β |
| 13 | Songs, sets and the library | π§ | player/library.odin, vct/set.odin, cmd/player/home.odin, preplay.odin, set_edit.odin |
| 14 | Deferred format features | π€ | docs/format.md Β§11 |
| 15 | Ultimate Guitar import | β | ug/, cmd/ug2vct/ |
| 16 | Lyrics | π§ | vct/parse.odin, vct/build.odin, player/view.odin, cmd/player/stage.odin |
| 17 | Song editor | π§ | vct/lex.odin, player/buffer.odin, player/editor.odin, player/doc.odin, player/complete.odin, cmd/player/edit.odin, cmd/player/edit_form.odin |
| 18 | Video render | β | cmd/player/render.odin |
| 19 | Stage sync | π§ | stagesync/, cmd/player/sync.odin, cmd/player/clients.odin, cmd/vct-sync/ (on by default, Shift+S; UDP + WebSocket + web client; connected screens and host permission, C; docs/sync.md) |
| 20 | Web version | π§ | web/, scripts/build-web.sh, cmd/player/main_web.odin, player/file_js.odin |
| 21 | Language server | π§ | lsp/, cmd/vct-lsp/, editors/, vct/format.odin (formatting) |
| 22 | ChordPro import and export | β | chordpro/, cmd/chordpro/ |
| 23 | Chord sheet conversion | β | sheet/, cmd/sheet/ |
| 24 | Import and export in the player | π§ | sheet/text.odin, player/convert.odin, cmd/player/transfer.odin |
| 25 | Score import (MusicXML, MuseScore, MIDI) | π§ | score/, cmd/score2vct/, player/convert.odin |
| 26 | Section names from the words | β | sections/, score/write.odin, sheet/text.odin |
| 27 | Lyrics and chord sources (online APIs) | β³ | β |
| 28 | Mobile apps (iOS, Android) | π€ researched | docs/mobile.md |
| 29 | Layout and theme editor | β | player/design.odin, player/layout_edit.odin, player/theme_edit.odin, cmd/player/design*.odin, player/layout.odin (the grid), player/look.odin (the library's folders), layouts/, themes/ |
Next up (aim: use it live): work through
live-checklist.md β what needs hands, hardware or a room:
dragging files in, the Launchkey pads, the stage displays on a second monitor
(DISP-I4), readability at a distance (DISP-Q1), then a rehearsal pass with a
monitor and synced phones, one of them with host permission (SYNC-I10). What a
Mac could check on its own was done on 2026-10-10: the .app builds and opens
a double-clicked song from cold (APP-I5, which froze before), Retina, fullscreen,
the audio picker and FIND (EDIT-I5), the set editor's chips (SET-I10) and the
Documents library (SET-I1).
Release work (notarisation, CI on macOS, web hosting) waits until after live
use.
Branches: none open; everything is merged to main.
1. Track format spec
β
v0.1 β docs/format.md, docs/guide.md, examples/example-song.vct
Done
- Header, sections, modifiers, markers, tempo/meter, voice cues and countdown, diagnostics, informal grammar, timeline description.
docs/guide.md: a guide for the people writing charts (musicians first), built up from a six-line song to a full one with Amazing Grace as the running example: sections, repeats/loop/hold and Next, tempo and meter, voice cues, arrangements, markers, chords, lyrics, dynamics and keys, song information, audio and stems, sets,include:, importing, the error messages with fixes, mistakes the player can't catch, and a reference. Uses the player's editor, not thevctCLI. format.md stays the specification; each links to the other.tests/docs_test.odinkeeps the docs in step with the parser: every```vctblock in the guide parses and lints clean (```vct-setblocks parse as sets), both docs mention every header, modifier and flag (HEADER_NAMES,MODIFIER_KEYS,FLAG_NAMESinvct/parse.odin, which the parser itself uses), feel, cue kind and dynamic, and the guide's limits table matches the constants. CLAUDE.md says to update both docs with any format change.
Issues / questions
-
FMT-I1: Gotcha in the spec itself:
Verse 1with the bar count forgotten is a valid 1-bar section called Verse. Mitigated byvct outline; a warning heuristic might help. -
FMT-Q1:
The engine's runtime behaviours (ENG-Q1 to ENG-Q7) weren't in the spec.Resolved: written into Β§10.1. -
FMT-I2: The guide repeats the spec's tables in plainer words, so a format change needs both updating.
tests/docs_test.odincatches a missing name, a broken example or a changed limit, not a description that's gone stale. -
FMT-I3:
format.md Β§4.3 said only the first eight sections get a pad;Fixed in the spec.player/pads.odingives sixteen. -
v0.2 arrangements (stage 1 of the v0.2 plan), folded into the sets work's
arrangement:header (format.md Β§3.1): list items takexN(repeat),*N(play at N bars) and(A, B) x2groups; names ignore case and spaces; the first arrangement is the default (a set's empty arrangement means it), and a song with none plays as written. Also the informationalvct:header. Examples and the set editor follow (examples/example-song.vctnow hasFullfirst). An earlier[Name]/order:design was dropped in favour of this one. -
nojump(done): a section flag meaning a controller pad can't jump to it (format.md Β§4.3), so a song with more than sixteen sections can choose which sixteen get pads. Parsed, dumped, completed, hover text, and a NOJUMP chip in the structure view. Changes nothing in the timeline. -
Stage 2 (done):
dyn=(six levels as letter/number/word, ramps, persists),feel=(seven feels, persists),tacet,band=(free text),pulse=N(accent every N beats),half(click on every second beat), and state markers (> 5 dyn=mf feel=swing: change mid-section, no banner, carry on into later sections). InSection/vct dump, on the stage (NOW row: dynamic letter with six rising bars, feel tag, TACET, one tag perband=part) and in editor completion.tacetmutes the click through the engine's existingquietpath. Not done: a dedicated who-plays icon set (the band tags are text) andband=entries likeno-drumsmeaning something.
Planned (agreed in the v0.2 design interview)
- Stage 3 (done): headers
capo:,lang:,key[Name]:; sectionkey=(modulation, with semitones up),lead=(carries on),harm=(one section); lyric[tag]and"fr 1 wordslanguage lines, the language picked withOptions.lang/ a set'slang=(format Β§5.1, Β§12). Stage: KEY, LEAD and HARM tags on the NOW row and the lyric tag beside the line. Not done: choosing the language or a singer's key from the set editor or live (only the set file'slang=picks one);key[Name]isn't used by the stage yet; no fallback to the unlabelled lines when a section has none in the chosen language. - Stage 4 (done): marker options (
5-8range,last-bar,[role],!kind,pass=N; format Β§5.2;vct-player --role NAME), chord lines (> 1 | A E | F#m D |, shown for this bar and the next on the NOW row), headersccli,copyright,tags,source, sectionnote=,include: file.vct(sections, markers and lyrics from a file in the song's folder; format Β§2.1), relaxed section lines (Verse 1: 8,[Verse 1] 8,Verse 1 (8)),+continuation lines, the/lyric shorthand, andvct lint(a forgotten bar count, a section no arrangement plays, two markers on one beat, apass=that never happens; this is FMT-I1's warning heuristic). Roles don't filter voice cues or the synced phone/web stage yet. Review follow-ups: marker options, lyric[tag]//andIntro: 4only count in their exact forms so v0.1 text keeps its meaning; the editor cachesinclude:files while open;vct dumpcarries the marker options;tests/corpus/ok/features.vctis a golden file for stages 2 to 4. A set list saves each song's own text, so an included file's content is read from the song's folder each time it loads, not saved with the set. - ChordPro import/export (done): see Β§22.
Issues / questions
- None open.
Remaining
- Nothing for FMT-I1:
vct lintnow warns about it.
3. CLI
β
β cmd/vct/main.odin
Done
check,outline,dump(JSON used by the golden corpus).reflow(--write): joins lyric lines that go by too fast to read into ones that stay up for a phrase, using the same pass as score import (vct/phrase.odin,vct/reflow.odin; Β§25). For a song imported before the pass existed, or one whose lines were written short.align,loudnessandreflowhave amise runtask each (mise run align -- song.vct --audio track.wav), like the other tools;check,lint,outlineanddumpgo throughmise run vct.
Remaining
-
vct play: a real-time terminal player built on the engine, with a key for Next. Useful for trying songs before any GUI exists. - Possibly
vct simulate --next t1,t2: prints the engine's event log for given Next presses (handy for bug reports).
4. Tests and CI
β
β tests/, scripts/bless.sh, .github/workflows/test.yml
Done
- Error corpus (
#!expected diagnostics), ok corpus with golden JSON, timeline unit tests, engine tests on a simulated clock, player driver/view tests (beat events on time, stop/restart). - CI: vet, tests,
vct checkon every example. Odin version pinned inscripts/install-odin.sh(shared with the cloud SessionStart hook). - A macOS job (with a
changesjob usingdorny/paths-filter, so it runs only when code it builds or tests changes) is written intest.yml, but commented out: it has never run. Uncommenting it is a manual commit (feature 6). - Checks outside Odin, both optional locally:
mise run check:schemas(scripts/check-schemas.py) validateslayouts/,themes/andschemas/examples/against the JSON Schemas inschemas/, andmise run check:diagrams(scripts/check-diagrams.sh) renders every Mermaid diagram indocs/so one that won't draw fails instead of showing a red box on GitHub. - Docs site:
mise run build:docs(scripts/build-docs.py) builds the README,docs/*.mdandeditors/README.mdinto static HTML inbuild/docs, keeping the repo's paths so relative links and GitHub-style heading anchors work; links to other repo files go to GitHub.mise run docsserves it locally; it deploys to Cloudflare Pages as is (README).
Remaining
- Create the Cloudflare Pages project for the docs site and deploy it (needs the user's Cloudflare account; commands in the README).
-
docs/PROJECT.mdlinks to#2-parser-and-timeline-builder, but feature 2 has no section of its own, so the link goes nowhere (on GitHub too). - Engine golden tests (event logs as text files) if the unit tests become hard to read.
- Add the docs job (schemas and diagrams): paste
docs/ci/docs-job.ymlinto.github/workflows/test.yml(workflow files need a manual commit). - Keep
schemas/sync.schema.jsonbesidestagesync/protocol.odinwhen the protocol gains fields; nothing checks that the two agree.
5. Runtime engine
π§ β vct/engine.odin, tests/engine_test.odin (merged to main in
berryp/visual-click-track#2)
The transport: plays a built Track against the host's clock. Pure logic,
pull-based engine_poll(until) with a lookahead; events carry everything
the screen needs.
Done
engine_init/engine_start/engine_stop/engine_next/engine_poll.looppasses until Next;holduntil Next;quietmutes the click but not the voice; a loop's cue for the following section is gated until it's released (with a spoken marker restored under a gated cue).- Countdown (beats to the next section during the cue window) and bars left.
- Hold/End events; output buffers of any size give the same events.
- 12 tests, including a full run of the example song.
Decisions (signed off October 2026; written into format.md Β§10.1)
- ENG-Q1: Next partway through a loop's last bar β the pass finishes; only the cue words still to come are spoken.
- ENG-Q2: Next during a
holdβ before the hold it cancels it; during it, the next section starts immediately, with no extra count. - ENG-Q3: Next in a section with no
looporholdβ ignored. - ENG-Q4:
holdon the last section β waits for Next before End. - ENG-Q5:
loop+holdon one section β two presses. - ENG-Q6: Countdown window β kept as is (last bar, or last two with cue-ahead 2; the whole count-in; the last section counts to the end; blank while a loop is unreleased).
- ENG-Q7: Loop/hold decisions are made when the beat after the decision point is polled β acceptable; hosts keep the lookahead short.
Remaining
- Get answers to ENG-Q1 to ENG-Q7 and record them in the spec (format.md Β§10.1).
- Open a PR and merge (berryp/visual-click-track#2).
- Start from a chosen beat:
engine_start_at(used by the editor's audition, feature 17). The player's stage still always starts at the top. - Pause/resume, as a screen-only pause: the host keeps the engine running and shows the section start, so nothing is rewound (feature 10, Pause). A true pause of the engine is still open.
- Tempo override / practice speed on the stage? (Auditions from the editor have a practice tempo that scales the clock: feature 17.)
6. Player platform and app shell
π§ β cmd/player/main.odin (raylib app), cmd/player/open_darwin.odin
(Finder open-file), player/ (testable core: driver.odin, view.odin,
ring.odin), tests/player_test.odin, packaging/macos/ and
scripts/package-macos.sh, scripts/notarize-macos.sh (the macOS app)
Done
vct-player [file.vct]...: the files are copied into the library (feature 13); opening a song shows its diagnostics and refuses to play if it has errors; R reloads after editing.- No sound: the UI thread advances the engine (
player.Driver) once per frame from raylib's frame time; commands go in and events come out through single-producer/single-consumer queues. Events are tagged with a run number so a stop/restart never shows stale beats. - Test flags
--autoplay,--next SEC,--jump SEC N(pad N, for the backing track's crossfade),--shot SEC FILE,--quit SECfor scripted headless runs and screenshots. scripts/screenshots.sh [OUT] [PREFIX](mise run screenshots) drives those flags to screenshot every screen intobuild/screenshots/: the home screen and its panels (phone code, displays, connected screens, normalise), the pre-play screen (in each theme), the stage (count-in, a verse, one role's cues, and every layout in every theme), the song editor (both views, the suggestions, a new song), the set editor (and its look picked) and the layout and theme editor (both lists and both editors). Each shot is its own run, in a scratch HOME and library, so your settings, songs and sets are untouched; under Linux without a display it usesxvfb-run.JOBS=Nruns N at once (4; the whole run is under a minute on a Mac),NO_BUILD=1skips the build. Not reachable by the test flags, so not shot: C (chords), M (metronome mode), the mixer (needs a backing track), the import and export dialogs, and stage displays.- Font: Barlow built in (
assets/fonts/, SIL OFL alongside); see feature 9. - macOS app container:
scripts/package-macos.shbuildsbuild/StageDisplay.app(arm64 binary,Info.plistfrompackaging/macos/, icon fromassets/icon/(the beat number, beat dots and song map, drawn bymise run icon), hardened-runtime codesign, ad-hoc by default).scripts/notarize-macos.shsubmits it to Apple, staples the ticket and writes the zip to distribute.install-odin.shnow installs the macOS compiler too. A ready-to-paste CI job is indocs/ci/macos-job.yml. - Double-click opening: the bundle declares
.vct(type<bundle id>.vct, owner rank) and the player handles Finder's open-file request (application:openFile:, added to GLFW's delegate class) whether it's launching or already running, so Dock drops and Open With work too. The file joins the set list if it isn't there and goes on stage ready to start; during playback it only joins the list. Type-checks for darwin_arm64 and darwin_amd64 from Linux; not run on a Mac. - Removed the Skald GUI bootstrap (
cmd/vct-gui,third_party/skaldsubmodule, thevendor:stbbuild ininstall-odin.sh, the submodule step in the SessionStart hook) after GUI-Q1 was decided for raylib.
Decisions
- PLAT-Q1: Which platform first? β Desktop. Other platforms (tablet, phone, web, a dedicated stage box) come later.
- PLAT-Q4: Which desktop OS first? β macOS is the primary target. Linux still builds (development and CI); Windows later.
- PLAT-Q5: Language? β Odin, importing
vctdirectly (no FFI). - GUI-Q1: Which toolkit: Skald or raylib? β raylib (
vendor:raylib). It ships with the Odin compiler (ready-built macOS libraries for Intel and Apple silicon), needs no submodule, SDL3 or Vulkan. A Skald GUI had been bootstrapped in parallel (#3: a song-info screen); it was removed. - PLAT-Q6: How to wrap the player as a native app? β A plain
.appbundle around the raylib binary (not a Swift shell, web wrapper or other toolkit). Revisit a native shell only for iPad or MIDI input. Apple Developer account: yes, so Developer ID signing and notarisation. - APP-D1: Open
.vctby double-click? β Yes (see above). - GUI-I1 (CI check for the Skald GUI) and GUI-I2 (Skald's
inittakes no arguments) β dropped with Skald.
Issues / questions
- PLAT-I1: Development happens in a Linux container and CI runs on Ubuntu,
so nothing yet builds or runs on macOS. Retina scaling,
fullscreen on a second display and
.apppackaging can only be checked on a Mac. - APP-I5:
The open-file handler is added afterConfirmed on a Mac, and worse than feared: a file opened to launch the app (Finder's double-click,InitWindow.open) arrived insideInitWindowbefore the handler was there, so AppKit tried to open it as a document itself, failed, and put up a modal alert β the player sat frozen behind it (sampled: main thread inNSDocumentController presentErrorβNSAlert runModal). Fixed by addingapplication:openFile:to GLFW'sGLFWApplicationDelegateclass by name, beforeInitWindow(the class is in the binary from the start), rather than to the delegate object after it. Checked with the packaged app under a separate bundle ID:open -a β¦ song.vctfrom cold now adds the song and shows it on the pre-play screen, and opening another while it runs still works. With the method there from the start, AppKit also handed it the command line's file-like arguments (a--shotpath, the--libraryfolder);NSTreatUnknownArgumentsAsOpenis registered off for the run, so only real opens (Apple events) come through β checked both ways. - APP-I8: The app links Homebrew's raylib (
/opt/homebrew/Cellar/raylib), built for macOS 26, while it is built for and claims macOS 11 (-minimum-os-version:11.0,LSMinimumSystemVersion); the linker warns about it. It may not start on an older macOS. A raylib built for 11 (or the vendored one, if its slice can be made to link) would fix it, or the minimum raised to match. - APP-I6:
CFBundleIdentifierdefaults toio.github.berryp.visualclicktrack(BUNDLE_IDoverrides it); pick the real one before the first release, since it ties the file association and notarisation record. - APP-I7: Notarisation is blocked on this Mac. The only signing identities
are two "Apple Development: Berry Phillips (CY9VLP4MQ4)" certificates
(Personal Team), and
security find-identity -vreports neither as valid. Notarisation needs a "Developer ID Application" certificate, which only the paid Apple Developer Program issues (PLAT-Q6 says an account exists; confirm it is the paid one and create the certificate), plusxcrun notarytool store-credentials vct-notary. "Berry's MacBook Pro" is a UTM server certificate, not a code-signing identity. - PLAT-Q2: Is a single device running both the display and the in-ear click enough, or do several screens need to stay in sync (e.g. a stage display plus the MD's laptop)?
- PLAT-Q3: How are song files managed on the device: open file, a folder, sync?
- APP-I1: Only tested in the Linux container, under Xvfb. Not yet run on macOS.
- APP-I4: Pressing Next right at the end of a loop pass can leave the "Looping β Space to move on" hint up for one more pass while the engine has already moved on. Cosmetic.
- APP-Q1: Playback starts 0.15 s after pressing Enter. Should there be a longer pre-roll, or a visual "get ready"?
Remaining
- Decide PLAT-Q1 (desktop), PLAT-Q4 (macOS), PLAT-Q5 (Odin).
- Decide the toolkit (GUI-Q1: raylib) and remove the Skald GUI.
- Decide PLAT-Q2, PLAT-Q3.
- App shell: load a song, show diagnostics (refuse to play on errors, per spec Β§8), play/stop/Next.
- Make
scripts/install-odin.shwork on macOS (asset names checked against the release). - Add the macOS CI job (PLAT-I1): uncomment the
changesandmacosjobs at the end of.github/workflows/test.yml(they are there, commented out;docs/ci/macos-job.ymlhas the same text). Workflow files need a manual commit. - Package as a macOS
.appbundle with the.vctassociation. - Run
scripts/package-macos.shthenscripts/notarize-macos.shon a Mac and confirm: it launches, Retina is sharp, double-click opens a song from cold and while running, Gatekeeper accepts the stapled app (APP-I5). Done, ad-hoc signed: it launches, its screenshots come at 2Γ (Retina), and opening a song from cold (after the APP-I5 fix) and while running both work. Left: notarising and Gatekeeper, which wait on APP-I7. - Notarise in CI (certificate and notary credentials as secrets).
- CI-I1:
odin check player -no-entry-point -vetandodin check cmd/player -vetare in the Vet step of.github/workflows/test.yml. - Headless smoke test in CI (build and run under Xvfb with
--shot). - Open a recent file / remember the last folder (depends on PLAT-Q3).
7. Audio click output
π§ β off by default. K turns an audible click on or off (any screen,
Ctrl/Cmd+Shift in the editors, like C and M). cmd/player/click.odin makes
two short sine ticks at startup (1.6 kHz on a bar's first beat, 1 kHz
otherwise) and plays one through raylib when a beat that has the engine's
click flag reaches the screen, so quiet and tacet stay silent. It is
timed by the display, not the audio clock, so it is not sample-accurate, and
it isn't sent to synced screens or played on the web.
- Listen to it on a real sound card; the click volume and tone are unchecked (there is no audio device in CI).
8. Voice cues
β Removed from the player β it has no voice output and no VOICE caption.
The engine still emits Event.say and the format keeps say=; the player
ignores them.
9. Screen display
π§ β cmd/player/stage.odin (widgets), cmd/player/ui.odin (canvas,
text, tags, numbers, shapes, the theme's palette and fonts),
player/view.odin (state), player/layout.odin and layouts/*.json5
(layouts), player/theme.odin, player/ttf.odin and themes/*.json5
(themes), player/look.odin (settings file, finding themes and layouts),
player/displays.odin (which layout each display shows),
cmd/player/look.odin (loading, applying and reloading them)
Implements the "Stage Display" prototype (https://claude.ai/artifact/DsY6wKttw2xTF8doGxnAWB), keeping its 1920Γ1080 coordinates, palette (black, mauve, cyan, pink, red) and Barlow type.
design-research.md (2026-10-10) collects prior art (AbleSet, MultiTracks, ProPresenter, OnSong, Ontime and others), what musicians ask for on forums, legibility and timing ideas from other fields, and a ranked list of what to add, compared with what the stage does now. Reddit couldn't be reached and isn't in it.
design/stage.pen (Pen, 2026-10-10) mocks up the play screens with the UX review's fixes (DISP-D11): the stage widgets as components, Band in eight states (playing, countdown, count-in, open repeat, hold, song ended, no words in 3/4, the operator's window), Drums, Vocals, Words & chords and Chart, the before-the-set card on a stage display, and two phone screens, each captioned with the UX-n findings it answers. Default theme only.
Decisions
-
DISP-D1: Numbers in seven-segment style, as in the prototype? β No: a monospace font with a similar look. Share Tech Mono: narrow, straight-sided and square-cornered like segment digits, close to the prototype's digit proportions, and it sits well next to Barlow.
-
DISP-D2: Layouts to try out, chosen when building β JSON5 files in
layouts/, built in with-define:LAYOUT=name(LAYOUT=name mise run build:player; defaultstage). JSON5 because Odin'score:encoding/jsonreads it with no new dependency, and it allows comments, unquoted keys and trailing commas. TOML and YAML have no core parser; a custom DSL wasn't worth its own parser. Files are checked strictly (unknown keys and types are errors), and a test parses every file inlayouts/. A missing layout fails the build. (Since DISP-D6 layouts are picked at runtime; the define only sets the default.) -
DISP-D5: Themes β JSON5 files in
themes/(and the user's own), like layouts:colors(a semantic palette: background, ink, text, accent, accent_line, highlight, soft, alert, muted, next, dot, plus panel, faint and online for the phone),sections(song map colours by section kind),sizes(line thicknesses, corner radius, glow strength, the stage's text and number scale),fonts(TrueType/OpenType files, relative to the theme) andmobile(the phone's flash, corners and text scale). Everything is optional over abasetheme (default: the built-inDEFAULT_THEME, whichthemes/default.json5writes out in full; a test keeps them equal). Colours are semantic rather than per-widget, so one theme file stays short and every screen (home, editor, dialogs) follows it. Fonts are placed by their own metrics (hhea ascent/descent, OS/2 cap height, the digit's advance), so another face sits like Barlow does. The text and number scales apply to the stage only; other screens keep their sizes so their fixed layouts don't overflow. The song editor's code font stays Share Tech Mono (its grid is built on its metrics). -
DISP-D6 (was DISP-Q5): Faster layout experiments, and layouts changing fonts and colours β a settings file picks the theme and layout at runtime, and every file is reloaded when it changes.
vct-player/settings.json5in the config folder (written from a commented template on the first run) hastheme,layoutandmobile: {theme, layout}. Names resolve to the user'sthemes/NAME.json5orlayouts/NAME.json5beside it first, then the built-in ones (embedded with#load_directory); a value with a slash or.json5is a path.--theme,--layoutand--settingsoverride them for one run. The files read (and a theme's fonts) are checked once a second and everything is reloaded and applied on a change, including on the phones β but not while a song is on stage: the check stats every file it watches, and a slow answer from one of them (a theme kept on a network volume, say) would land on the frame clock in front of the band. Edits are picked up as soon as the song stops. A broken file falls back to the built-in default and the reason shows for 15 s on screen and on stderr. Fonts and colours belong to the theme, not the layout, so one theme fits every layout. -
DISP-D8: A layout for songs with words β
layouts/lyrics.json5and anall-lyricswidget: NOW and NEXT with their cues down the left, the beat counter and the count-in in the middle, and the whole song's words on the right, scrolled to the line being sung, as the web client's panel shows them (same order, headings, positions, current and next line, focus a third of the way down).chords: trueon the widget draws each line's[C]chords above the words they fall on (LYR-D6). The lyrics panel is ~700 wide, so the beat counter keeps most of its size; a lyrics-heavy split was the alternative.
Done
-
Fixed 1920Γ1080 canvas scaled to the window and letterboxed; default window 1280Γ720.
-
Header: SONG title, KEY, TEMPO (BPM) with a quarter note and the Italian tempo name, T.S., SUB DIV. (see DISP-Q3). Note symbols are Unicode characters (U+1D15D to U+1D161) set in Noto Music (OFL,
assets/fonts/), not drawn shapes. -
Metronome face: two rings, a hand that sweeps once per beat, two dots that flash cyan on each beat, and the beat number. Beat 1 of the bar flashes harder: the rings light cyan too and the dots swell and glow wider, fading with the flash. During the two-beat lead before the count-in the hand sweeps and flashes on each beat too.
-
Numbers (tempo, time signature, beat, bar, count-in) are set in Share Tech Mono (OFL), sized by digit height to the prototype's digit boxes; the current count glows. The font's bitmap is drawn at 256 px (
MONO_BASE); numbers bigger than that on screen (the fullscreen count-in fills most of a 1440p screen, the stage's bar number passes it) use a signed-distance-field copy (Mono_Fonts.sdf, 128 px, drawn withSDF_FS, a GLSL 330 or WebGL 1 shader), so they stay sharp rather than stretching the bitmap. If the shader doesn't build they fall back to the bitmap. Checked with--shotat 2560Γ1440: the fullscreen count-in and the stage's bar number. -
Beat columns, one per beat of the bar (any meter; they shrink to fit), with rounded corners. Fill shows the accent: full for beat 1, two thirds for a secondary accent (beat 4 of 6/8), one third otherwise. The current beat lights lavender β cyan and fades.
-
NOW (section name, bar
NN / total) and NEXT (pink) panels. COUNT-IN digits, one per beat of the section's last bar: the current count glows cyan, counts already passed stay mauve. -
Song map: blocks sized by beats and coloured by section type, bar numbers, the played part dimmed, a white box on the current section, a dashed pink box on the next, and a cyan playhead.
-
If the widget has
ticks: true(off by default; TICKS or B in the layout editor), a progress bar under the current section's block, filled cyan as it plays, with a white vertical mark at the start of each bar; without it, nothing is drawn under the blocks. The blocks fill the box's height below the playhead's marker (and above the progress bar), so a box of any height works; the built-in layouts give it 1800Γ100 (it was 180 tall, with fixed block rows). -
Red border flash on every downbeat, thicker and longer at the start of a section or loop pass. While counting in it flashes on every beat, and the NEXT section name flashes white with it, and the beats left to count in show as a red number right-aligned on the same line.
-
States the prototype doesn't cover, in its style (DISP-Q4): stopped ("ENTER to start" in the dial), hold (red HOLD in the dial and a red "HOLD Β· SPACE" tag), unreleased loop (cyan "SPACE TO MOVE ON" tag, NEXT dimmed), end,
LOOP n/n / N/NO CLICKtags beside NOW, a CUE panel for markers, a key-help footer, and restyled error and empty screens. -
Fixed in passing: in the prototype, a two-digit tempo's name overlapped its last digit; digits are now left-aligned and the name follows them.
-
Checked under Xvfb at each state (count-in, marker, countdown, loop, release, hold, end, 6/8, quiet, downbeat flash).
-
Layouts (DISP-D2): the stage is a list of widgets (title, set-position, practice, key, tempo, time-signature, subdivision, metronome, beats, lyrics, now-lyrics, next-lyrics, karaoke, now, bar, next, count-in, cue, next-cues, song-map, flash, beat-count), each with a box on the canvas, an optional
scaleand an optionalwhen(lyrics/no-lyrics). Each widget draws in its own coordinates, translated and scaled with rlgl; flexible ones (beats, lyrics, song map, title and section names) use the box's width.layouts/stage.json5has no metronome face or count-in text ("Verse 2 in 4"); thebeat-countwidget fills the middle in their place, scaled to its box (a big beat number in the beat columns' colour animation,/ Nbeats per bar beside it, bottom-aligned, and a row of dots below that flash the current beat; four dots are about as wide as the text); it hides once the song has finished (the web client hides its beat number and dots too). Thebeatscolumns andmetronomewidgets still exist for other layouts;layouts/focus.json5is a first alternative: bigger NOW / NEXT / bar / count-in, no metronome face.layouts/metronome.json5is the metronome face across the whole canvas (scale 1.3), with the title, tempo and set position on top, the count-in line, cues and next cues, the song map along the bottom and the fullscreen count-in last, like every built-in. Its name is lowercase like the others, so the built-ins list in order (clickfirst). -
The words of the whole song (DISP-D8): the
all-lyricswidget draws every section's lines once (passes share them, as in the web client), under the section's name in cyan caps, with thebar.beatin front in dim mono, the line being sung white, the next grey and the rest dim (#555, the web client's colour). It slides the current line to 30% down the box, easing in about a third of a second like the web client'stransform .3s ease, and is clipped to its box (begin_stage_clip, a scissor in the stage's own canvas, not the 1920Γ1080 one). A section nobody sings in shows its> |chord line instead, as| C | G | Am | F |, so the band can follow those bars; one with neither words nor chords is left out. Withchords: true, each line's chords are drawn above the word they fall on: the words are measured at the size they end up (long lines shrink to fit), and a chord that would run into the one before it is nudged right, so the words never move.layouts/lyrics.json5uses it;odin test testschecks the file and the widget's flags. Checked on macOS with--shot: a verse, a chorus with chords inside words (ap[E]pear), a long song scrolling mid-song with lines clipped at both edges, and a chord-only Intro row. -
The stage fills any screen shape (DISP-D4): its canvas is the layout's 1920Γ1080 grown in one direction to the window's or display's shape (
player.stage_canvas_size: 16:10 β 1920Γ1200, 4:3 β 1920Γ1440, 21:9 β 2520Γ1080), at the scale letterboxing would have used, and each widget's box is moved and stretched in proportion (player.widget_fit) with its scale kept. Text stays the same size; the gaps grow, and the widgets that use their box (song map, lyrics, beat count, section names, the flash border) take the extra room. Layout files are unchanged. Applies to the window, the stage displays and--render --size; the home, pre-play and editor screens stay letterboxed at 1920Γ1080. The stage's footer sits in its bottom right corner; the practice slider's mouse uses the same mapping (mouse_stage). Tested (stage_canvas_fits_the_screen) and checked with--shotin windows at 4:3, 16:10 and 21:9. -
Stage displays (DISP-D3),
cmd/player/displays.odin: D on the home screen, or its DISPLAYS button, lists the connected monitors (name, size, number). Clicking a row or pressing its number turns it on or off. Each monitor turned on gets a full-screen window that takes no clicks or focus (GLFW mouse passthrough, not focused on show). It shows the stage, without the footer or the practice slider, while a song is on stage; otherwise it shows "Stage Display" / "Waiting for a song on stageβ¦" like the web client's idle screen. The monitor with the app's own window is listed as THIS WINDOW and can't be picked; if the window moves onto a picked monitor, that output closes until it moves off. Monitors plugged in or out are followed each frame. The choice is saved by monitor name (a second of the same name isName (2)) invct-player/displays.txtin the config folder.--display N(repeatable) picks monitors by number for one run without saving;--type SEC dopens the list for screenshots. How it works: raylib has one window, so the outputs are GLFW windows made directly. The GLFW functions are declared indisplays.odinand resolved from raylib's static library. The windows share raylib's GL 3.3 context. Each frame the stage is drawn into a render texture at each output's framebuffer size, and each window shows it with a one-triangle shader (vendor:OpenGL). Their swap interval is 0, so the main window's vsync sets the pace. On macOS the window is borderless over the monitor, raised to the menu bar's level + 1 (displays_darwin.odin), because GLFW's own full screen switched a scaled Retina panel to another video mode (1920Γ1200 instead of 3024Γ1964). Elsewhere it is GLFW full screen at the current mode. Checked on a MacBook (one display only) with a temporary build that allowed the window's own monitor and read the output back withglReadPixels: the idle screen and a playing stage at the native 3024Γ1964, the mode unchanged, the controls still focused. Not checked on a real second monitor or on Linux. -
A layout per display (DISP-D7), so one screen can be the singers' and another the band's. Each row of the displays list has the layout that display shows: Shift and the row's number, or a click on it, moves it to the next one (
player.look_nameslists the built-in layouts and the user's own;look_next_namesteps through them), turning the display on if it was off. A display with none of its own shows the stage's layout. The layouts are loaded bylook.odinwith the stage's (g_look.displays), so they take anything the settings'layouttakes β a built-in name, the user'slayouts/NAME.json5, or a path β their files are watched for edits like the rest, and one that can't be loaded says why on screen and falls back to the stage's layout. The stage is drawn into each output's texture with that display's widgets (draw_outputβdraw_stage_canvas(app, now, layout)). The choice is saved per display indisplays.txtaslabel<TAB>layout(player/displays.odin,display_picks_parse/_text, tested); a line with no tab is a display on the stage's layout, so files written before this still read.--display N:LAYOUTsets one for a run.layouts/singers.json5is a third built-in layout for the use this was wanted for: the words as large as the screen allows, with the key, NOW, NEXT and the count-in, and the beat count in their place for songs without lyrics. Checked on a Mac with two monitors (--display 2:singers, and the same line saved indisplays.txt) and with bad names (--display 2:nope), which show the error on screen and on stderr. -
A look per set, and switching layouts live (DISP-D9). A set's
layout:andtheme:(format Β§12) are worn from the moment the set is opened β on the pre-play screen, the stage, the stage displays that have no layout of their own, and the synced screens β and the player's own come back when the set is closed.look.odinresolves them exactly as the settings' names (a built-in, the user'sNAME.json5, or a path), watches their files for edits like the rest, and a name it can't find leaves the built-in default in its place with the reason on screen. L on the stage or the pre-play screen moves the stage on to the next layout (look_names' order, as the displays list cycles) for this run only; the footer shows the layout in use, and nothing is written. Strongest first: L,--layout/--theme, the set's, the settings file's.--render --setwears the set's look too. Checked with--shot: a set onlyrics+contraston the pre-play screen and the stage, two presses of L throughsingersand on, and a set naming a layout and theme that don't exist (the error banner, both fallbacks). -
Host and stage layouts per set and per song (DISP-D12). A set's
stage-layout:and a song'slayout:andstage-layout:headers (format Β§3, Β§12;vct/set.odin,vct/parse.odin,Meta.layout/stage_layout). The host (the player's own window), strongest first: L,--layout, the song'slayout:, the set's, the settings file's. Every stage display: the song'sstage-layout:, the set's, the display's own pick, else the host's.look.odinkeeps the winning stage layout ing_look.stageandlook_display_layoutreturns it for every output.open_songapplies the song's (look_song_look),close_songandend_auditiondrop them, and opening a set forgets them; a song that names alayout:forgets one picked with L, as a set does. The set editor has a third chip, STAGE DISPLAYS (D; "per display" when the set names none), and the name box is narrower to make room. Renaming or deleting a layout rewrites sets'stage-layout:lines too. The song editor completes both headers with the built-in layout names, and the LSP describes them. New built-in layoutlayouts/click.json5: the cue across the top, the metronome in the middle, the next section's cues beside it and the count-in big along the bottom, for a band screen ("main screen default, external display only metronome, count-ins and cues"). Tests: set round trip and rewrite withstage-layout:, the song headers. Checked with--shot:--layout clickplaying (count-in, metronome, next cues), a song withlayout: click, and the set editor's three chips. Stage displays themselves weren't checked: this Mac has one monitor (DISP-I4). -
Naming displays in a set or song (DISP-D13):
stage-layout[Display]:, read likekey[Name]:intoSet.stage_layouts/Meta.stage_layouts(vct.Stage_Layout, display "" for every display); a display named twice or a name left empty is an error, in sets and songs alike (stage_layout_add).vct.stage_layout_forpicks a display's line, else the plain one.look.odinloads them intog_look.stageas rules, strongest first (the song's for one display, the song's for all, the set's the same way), andlook_display_layout(label, value)takes the first that matches the output's monitor name. The set editor's STAGE DISPLAYS chip (D) now opens a panel (set_stages.odin): a row for every display, then each display the set names (NOT CONNECTED when it isn't) and each connected one it doesn't (display_labels), each with a layout chip (Left/Right or a click; the first step back is none). The chip shows the every-display layout with a count of named ones (click +1). Renames rewritestage-layout[β¦]:lines too. Tests: parsing, errors, matching ignoring case, the set round trip and rewrite. Checked with--shot: the panel with a set naming a display that isn't connected. The panel's keys and clicks were checked by reading:--typesends characters, not arrow keys. -
The chart, for players who follow the music rather than a counter (DISP-D10): the
chartwidget draws the song like a lead sheet β a rhythm slash per beat on a faint staff line, the chords of each bar (from its> |lines) over the beat they fall on, cues in pink over theirs, the words under the bars they're sung over (the line being sung bright), and each section's name in its song-map colour with its pass, LOOP, HOLD, a tempo or key change and a dynamic that changes. Played bars go dim, the current beat lights up in a white box round its bar, and a playhead runs through. Passes ofxNare written out; aloopis drawn once with repeat signs and the playhead goes back to its start each pass (it's the timeline, which plays a loop's beats again). Aholdgets a fermata, the end a final double bar, the count-in big 1 2 3 4. Options:viewstrip(one row across, bars in beat proportion, a gap between sections, the name sticking to the left edge) orsheet(lines ofbarsbars, each section starting a line with its name in a gutter that stays in view; one of two bars or fewer carries on the line before when it fits),flowfollow(the strip slides under a playhead 28% in; the sheet keeps the current line second from the top) orpage(the view holds still and turns so a section starts at the left or top,turna bar, a beat or no time before it β a bar early keeps the bar being played in view, and an unreleased loop doesn't turn), andbars(4). Jumps β a turn, a new line, a loop going round β ease over 0.35 s. The bars run off the box's sides to the screen's edges. The layout maths is inplayer/chart.odin(built each frame from the track; the view is worked out from the beat and the time since it sounded, so each stage display needs no state of its own), the drawing incmd/player/chart.odin. Theheaderwidget goes with it: the set and song with the key, tempo and meter; NOW with the bar (and loop pass); NEXT within 4 bars, the beats in the cue window,after SPACEin a loop orHOLD Β· SPACE; and a banner for a cue from two bars before it (outlined,in 6) to a bar after (filled,now). Built-in layoutsstrip(strip, follow) andsheet(sheet, page, a bar early), neither with the flash (add aflashwidget for it). Phones: achartpanel in the web client draws the same thing in JavaScript, in the same units scaled to the panel, set by the layout'smobile.chart; the syncsongmessage gained each section'sbar_chordsand thestylemessage thechartoptions (docs/sync.md). Designed from interactive mockups and an interview: all four combinations kept, slashes over ticks, dots or numbers, the turn a bar early, repeats written out, the loop going back, short sections running on, words under the bars, the song map and flash off. Tests:tests/chart_test.odin(bars, lines and run-on, follow and page targets, loops not turning, the easing, the layout options) andsync_song_bar_chords. Checked with--shotin both layouts: the count-in, a verse with words and a cue, a page turn a bar early, a loop's second pass, the Tag and the held Ending; and the web client at phone size in the browser pane withsheet. -
The fullscreen count-in (DISP-D12): the
fullscreen-count-inwidget covers its box with the theme's background in the last bar before each section and draws the next section's name ("VERSE 1 IN") over the beats left as one big number, in the beat columns' colour animation (accent β soft β highlight on each beat, with a glow). It counts that bar only, even in acue-ahead: 2window, down to 1, and hides on the next section's first beat (4, 3, 2, 1 in 4/4). It is the last widget of every built-in layout, boxed over the whole canvas, so it is drawn over the rest (layouts_all_parsechecks it). The count-in's last bar is counted the same way. Before a count-in played from its first beat there is a lead ofplayer.LEAD_BEATS(2) silent beats at the first beat's length (player.lead_in):view_leadputs it on the Start command (Command.lead, added to the driver's start time) and notes when it runs on the view (View.lead_at,lead), and the widget flashes a dot on each of its beats under the first section's name. Every start inmain.odingoes through it (Enter, a pad's jump, restarting a section,--autoplay,--render); backing tracks startleadlater (backing_start,backing_jump, and a track that finishes loading after the start), and--renderputs bar 1 that much later in the video. The layout editor's preview starts without a lead. Not drawn in metronome mode. Tests:driver_leads_into_the_count_in. Checked with--shoton a Mac onexample-song.vct(72 bpm,cue-ahead: 2): the dot lit and fading under INTRO IN, the count-in's 4 and 1, the stage back on the Intro's first beat, the Intro's second-to-last bar on the stage (7 beats), then VERSE 1 IN 4, 3, 2, 1 and the stage on Verse 1's first beat.
Decisions
-
DISP-D12: A count-in the band can't miss β a widget that takes the whole screen for the last bar before each section, on in every built-in layout, with two beats of flashing dot before the song's count-in. One bar, not the cue window, so the screen is covered for a bar at most; it counts to 1 and goes on the next section's first beat, so the downbeat lands on the stage. The two beats are a silent lead added before the count-in rather than taken from it, so the count-in still counts a full bar; the song starts two beats later than it did, and the timeline (and
audio-offset) is unchanged. The phones don't show the dot or the fullscreen count. -
DISP-D11: What the band sees, after a review of the stage and pre-play screens from a performer's one-second glance (2026-10-10; findings UX-1 to UX-35, top five: one number per question, unambiguous cues, the gap between songs, no red flash, a quieter stage) β one counter, cues in words, the next song as a card, and nothing on band screens that is an instruction to the operator.
- The
beat-countwidget is the one number: the bar within the section ("BAR 3 of 8"), and in the countdown window the beats left to the next section, counting down under "PRE-CHORUS IN" (the count-in too). NEXT no longer has the red countdown or the white pulse on every beat, so no two numbers count against each other (UX-1, UX-8, UX-10). Holding shows HOLD / WATCH THE LEADER and paused PAUSED / KEEP PLAYING in its place (UX-12). In metronome mode it is still the beat. Its dots are the pulse; the current one stays lit for the whole beat, with no glow (UX-31). - Cues: the current one dims once its bar has passed (a
5-8range after bar 8) (UX-14). The next section's cues show only in the current section's last two bars (and the count-in), under anIN PRE-CHORUStag rather thanCUE, with their role tag, as "Hits on 3 & 4 Β· bar 4, beat 3" (UX-2, UX-13, UX-15). The chart header says "in 2 beats". - Band wording, no keys: READY, HOLD / WATCH THE LEADER, KEEP PLAYING, "REPEAT UNTIL CALLED Β· 2ND TIME" and "WHEN THE LEADER CALLS IT" replace ENTER TO START, SPACE TO GO ON, SPACE TO RESUME, ENTER TO REPLAY, HOLD Β· SPACE and SPACE TO MOVE ON; NEXT reads SONG ENDS, not END under ENDING (UX-11, UX-30). The pass of a repeat is text beside NOW, not a small tag (UX-29).
- Between songs: when a song of a set ends, the next one goes on stage
stopped (
show_song), so every screen shows its key, tempo and meter rather than the old song's. A stopped song on stage is drawn as a card (draw_song_card: SONG 2 OF 5, the title, key Β· tempo Β· meter, the count-in, "then" the song after) over the song map, which stays so the operator can click where to start; a section marked to start from shows the stage. Stage displays show the same card for the selected song while the operator is on the pre-play screen, instead of "Waiting for a song on stageβ¦" (UX-3, UX-18). Next (Space, a pedal) no longer starts a song, so a double press after the last hold can't start the next one; Enter, with its guard, does (UX-4).--renderframes before the song starts show the card too. - Removed from the built-in layouts: the
flashwidget (all of them), the time signature and subdivision (stage,lyrics), the count-in line inlyrics(the beat count counts), the beat columns and tempo insingers(its count-in line is for songs with words only).focuslights the line being sung (UX-5, UX-6, UX-9, UX-21, UX-24). The widgets remain for layouts of one's own. - Smaller things: names too long for NOW and NEXT go onto two lines at 62%
before they shrink (
text_wrap_fit, UX-16); the title is cut with "β¦" rather than shrunk (UX-17); TEMPO shows "72 bpm" without the Italian name (UX-21); the dynamic is its letter only, without the bar meter that read as a signal icon; the karaoke strip's next line is never bigger than 70% of the line being sung as drawn (UX-7); the song map has no tag and no bar numbers, and a name that doesn't fit becomes "V1", "PC" or a cut name rather than "PRβ¦" (UX-28); the all-lyrics panel drops thebar.beatin front of each line and its unlit chords are stronger (UX-5, UX-27);daylight's faint grey is darker (UX-27). - Operator: R (reload) is ignored while a song runs, and Shift+β / Shift+β mid-song take a second press within 2 s, as Esc does, with the footer asking for it (UX-19). The footer says ENTER start/pause (UX-20).
- Pre-play: COUNT-IN BEFORE (the column's own name), the Enter hint inside the right margin, and a key to the "+" after a time (UX-34).
- Phone: the bar is the bar within the section, so it adds up with "bars
left" (UX-23); next-section markers with no banner (chord lines) are
skipped instead of showing "on 1" (UX-22); cues read "Β· bar 5"; Hold β
watch the leader; the set list shows key, bpm and meter larger (the
setlistitems gainedmeter, docs/sync.md) (UX-35). Checked with--shoton a Mac: the count-in (one countdown, 4 then 3), a verse, a long section name wrapping, a cue dimming, the hold, the end of a song moving the set on to the next song's card, a loop's second pass, 3/4,lyrics,singers,focusand the pre-play screen. The stage displays' card is checked by reading only (one monitor); the phone changes by parsing the page's script, not on a phone.
- The
-
DISP-D10: A view for players who read notation β a
chartwidget that draws the song as bars of slashes with chords, cues and words, in a strip or a sheet, following the playhead or turning pages, set per layout (built-insstripandsheet), with a compactheaderwidget for NOW, NEXT and cues. Picked from mockups of the four combinations; every one is kept, as options rather than four layouts. No key switches the chart's mode on stage: a layout picks it and L switches layouts. Chords come from chord lines only; chords written in the words ([D]grace) have no beat of their own, so they stay with the lyrics widgets. -
DISP-D9: Picking a theme and layout without editing the settings file β the set carries them (
layout:,theme:, chosen in the set editor) and L switches the stage's layout live, for the run. The look belongs with the service rather than the machine: the same set wears the same screens wherever it is played, while the settings file stays the one place a machine's own default lives. A picker screen writingsettings.json5was the alternative (DISP-I8); it would have to keep the file's comments, and it answers a different question β what this machine does by default, not what this service needs. Live switching writes nothing, so a layout tried mid-rehearsal can't quietly change a saved set. -
DISP-D12: Different layouts on the host and the stage displays per set and song β
layout:stays the host's, a newstage-layout:is every stage display's, and a song's headers win over its set's while it is on stage.layout:already meant the operator's screen (and displays with no layout of their own), so it kept its name and meaning rather than gaining ahost-layout:alias. Astage-layout:wins over the layout picked for each display in the displays list, as the set'slayout:wins over the settings file: the set says what the service needs, so a stale pick on the machine can't override it. Displays can be named for layouts of their own (DISP-D13). -
DISP-D13 (was DISP-Q10): One set, a layout per display β the set or song names the display:
stage-layout[Display]: Name, the display's name as the displays list shows it, matched ignoring case, over the plainstage-layout:. Written likekey[Name]:andaudio[Label]:. A song's plainstage-layout:wins over a set's named line: the song's lines together stand in for the set's, which is easier to explain than mixing them line by line. The alternative, letting each display's own pick win over the set's, would leave a set unable to say what a screen shows. In the set editor the displays come from those connected and those the set names, so a venue's screens can be set up away from them. -
DISP-D4: Screens that aren't 16:9 β the stage fills them, rather than letterboxing the 1920Γ1080 design: the canvas takes the screen's shape and widget boxes are mapped onto it in proportion (see Done). Per-shape layout files were the alternative; proportional mapping needs none.
-
DISP-D3: Several displays β the operator picks displays on the home screen (D), remembered by name, plus
--display N. The operator's own monitor can't be picked. The other displays are view-only and show "Stage Display" when no song is on stage. -
DISP-D7: Which layout each display shows β each display is set to one in the displays list (Shift + its number, or a click on the layout), saved with the display's name in
displays.txt, and loaded throughlook.odinso it can be a built-in, one of the user's own or a file, and follows edits. The alternatives were one layout for every output (what it was) and keeping the choice insettings.json5next tolayout:β the displays file already holds which monitor shows the stage, so its layout belongs with it, and it is written by the panel rather than by hand. -
Themes (DISP-D5) and the settings file (DISP-D6). The ~600 uses of the old colour constants became the palette's fields (
pal.text,pal.highlight, β¦); text on coloured tags and lit buttons usesink; text on song map blocks pickstextorink, whichever contrasts more; the editor's own syntax pastels are pulled toward the text colour on a light theme; the QR code stays black on white. Built-in themes:default,daylight(dark on white, strong colours, heavier lines, no glows) andcontrast(white and yellow on black, 10% bigger text and numbers, thick lines). Checked: with the default theme,--renderframes and the editor are pixel-identical to the build before themes; each theme under Xvfb; a user theme with DejaVu fonts; editing the settings file mid-song switched the stage and the phones; a bad settings key shows the error banner. Tests: every theme and layout file loads, the default file matches the code, fields and errors, colours,basechains, font metrics of the built-in faces, settings, finding files.
Issues / questions
- DISP-Q9 (from DISP-D11): The review also suggested renaming the built-in
layouts for who they are for and trimming them to four:
stageβ Band,singersβ Vocals,lyricsβ Words & chords,sheetβ Chart, withfocusmerged intostageandstripkept only as a chart option. Not done: sets and settings files name layouts, so it needs old names kept as aliases. Do it? - DISP-I12 (from DISP-D11): Left as they were: the phone's own downbeat
flash (the theme's
mobile.flash);contrast's current beat dot, which is paler than the others, and its cyan NEXT (UX-25, UX-26); section names the song map doesn't know, which are grey like an intro (UX-28); and the phone's set list doesn't mark which song is next. In the countdown the section's name is both over the number and in NEXT; worth asking a band whether that helps or is one too many. - DISP-I7: A theme's text and number scales grow text from its baseline or
top, so big values can crowd neighbouring widgets (text_fit shrinks names
to their box's width, not its height). Layout
scaleis the better tool for sizing one widget. - DISP-I8:
There's still no screen for picking the player's own theme or layout.The layouts and themes screen (feature 29, T) has USE, which writeslayout:ortheme:intosettings.json5, keeping its comments (DESIGN-D9). The phone's own (mobile.theme,mobile.layout) are still the file's. - DISP-I9: Reloading is by polling (once a second, a stat per file), and a font edited in place is only noticed if its size or time changes.
- DISP-I4: Stage displays haven't been tried with a real second monitor or projector (only on the one built-in display, with the own-monitor check turned off), nor on Linux/X11 or Wayland. Check on a Mac with two displays: macOS "Displays have separate Spaces" on and off, unplugging while playing, and that the operator window keeps keyboard focus.
- DISP-I5: Outputs are drawn at their own resolution with no multisampling
(the main window has 4Γ MSAA), so diagonal edges may look a little rougher
on a 1080p projector. Drawing at 2Γ and scaling down, as
--renderdoes, would fix it at some GPU cost. - DISP-I6: Proportional mapping only moves and stretches boxes, so on a very tall or very wide screen widgets with fixed-size contents (key, tempo, NOW, NEXT) sit at the top left of a bigger box, leaving gaps, and nothing grows to use them. A layout could later say how each box anchors or scales; a screen can already be given its own layout file (DISP-D7), which covers the worst of it.
- DISP-I10: The displays list's own clicks and keys for the layout (Shift +
a number, a click on the chip) were checked by reading and in a screenshot
of the panel, not by hand:
--typecan't reach the panel's input, and this Mac had only one monitor by then. What a display ends up showing was checked instead through--display N:LAYOUTand a saveddisplays.txt. - DISP-I13 (from DISP-D12): Renaming or deleting a layout on the layouts
and themes screen rewrites sets, not songs, so a song's
layout:orstage-layout:naming it shows the error banner and falls back. Songs' layouts don't apply to--render's chord setting (thechordsflag is read from the layout once, as with L). The phone client follows the host's layout, including a song's. A display's name with a colon in it can't be written instage-layout[β¦]:(the header ends at the first colon). A misspelt display name has no effect and no warning, since the set can't know which displays exist; the set editor's panel lists it as NOT CONNECTED. - DISP-Q8: On the sheet, turning a bar early keeps the line before the section in view for the whole section (so the playhead stays on screen through that last bar), which leaves two lines ahead instead of three on a 16:9 screen. The other way is one line of context during the turn only, then a second slide on the downbeat. Keep it?
- DISP-Q9: Marker roles (
> 5 [drums] Hits) only filter with--role NAME, a testing option (scripts/screenshots.sh). Every screen in the app shows every cue with its role as a tag. Should a stage display, layout, set or synced phone be able to pick a role, so the drummer's monitor shows only the drummer's cues (and everyone's)? The guide (Β§8) says it can't yet. - DISP-I11: The chart was checked in screenshots and the phone panel in a
browser at phone size, not on a stage monitor at a distance (DISP-Q1) or
at fast tempos, where a 0.35 s slide is longer than a beat (the rest of
the slide is cut at the next beat). The phone doesn't get marker roles or
pass=, so its chart shows every cue on every pass. - DISP-Q7: Only the layout differs per display; the theme is the stage's.
Should a display be able to take its own theme too (a projector in a lit
room on
daylightwhile the stage stays dark), whichlook.odincould load the same way? - DISP-Q6: Should a stopped song (before Enter, or after it finishes) show the stage on the outputs, as now and as the web client does, or "Stage Display" until it starts?
- DISP-Q1: Is the layout right for a stage monitor at a distance?
- DISP-Q2: The prototype's control strip (tempo slider, restart, jump to transition) and click-to-seek on the song map are not implemented: the engine can't seek yet (see feature 5, "start from a chosen section"). Wanted for rehearsal?
- DISP-Q3: SUB DIV. shows the note the click plays (one click per beat of the time signature: a quarter in 4/4, an eighth in 6/8). The prototype shows four beamed sixteenths, but the format has no subdivisions yet (format Β§11). OK?
- DISP-Q4: Placement of the additions listed above (loop/hold/end, CUE, footer) is my own; the prototype doesn't show them.
- DISP-I1: In the first bar of a two-bar cue window (cue-ahead 2), the count-in digits stay dim until the last bar. The prototype only defines a one-bar count-in.
- DISP-I2: Glows are approximated with translucent layers, not a blur.
- DISP-I3: The practice slider's click/drag goes through the
practicewidget's box and scale. Drawing was checked in both layouts; dragging it in thefocuslayout hasn't been tried by hand.
Remaining
- Answers to DISP-Q2 to DISP-Q4.
- Check on a Mac: Retina sharpness, fullscreen on a second display (DISP-I4).
- Choose which displays show the stage (DISP-D3).
- A layout per display (DISP-D7).
- Check the displays list's layout keys and clicks by hand (DISP-I10), and answer DISP-Q7.
- Themes and a settings file to pick the theme and layout (DISP-D5, DISP-D6).
- The chart widget and the
stripandsheetlayouts (DISP-D10). - Try the chart on a stage monitor at a distance and at a fast tempo (DISP-I11), and answer DISP-Q8.
10. Operator controls
π§ β cmd/player/main.odin
Done
- Double-tap guard (
player/guard.odin): after Enter or MIDI Play is let through (or a song is started from the set), further Enter / Play presses are ignored for 1 s. MIDI Stop, Next and Space aren't guarded. MIDI Play and Enter on the stage share one path (pause_or_start); the boundgobutton (the Launchkey's Play) is the same as MIDI Play. - Esc confirm (
Press_Confirminplayer/guard.odin, tested intests/midi_test.odin): the guard above stops a second press, which is the opposite of what Esc needs, so Esc has its own. While a song is playing, held or paused, one Esc only arms a 2 s window and the footer asks "ESC AGAIN TO STOP" in red; a second Esc inside it stops the song. Enter or Next instead forgets the first press, and the window closes on its own. Esc on a stopped song still goes straight back to the pre-play screen, and the audition's Esc (back to the editor) is untouched. The prompt is in the operator's footer, which the stage outputs and sync clients don't draw. The window and its timer are unit-tested and the footer was checked on a Mac screenshot; the keypresses themselves haven't been tried by hand (the test flags can't send Esc or Shift+arrow). - Audio fades: the backing track fades out over 0.15 s when the song stops,
finishes or pauses, instead of cutting off (
BACKING_OUT,player/fade.odin), and fades in over 0.25 s when a pause is resumed (BACKING_IN). By ear only, as there is no audio device in CI. - Pause (
pause_or_startincmd/player/main.odin; Enter and MIDI Play, both behind the double-tap guard): on the stage, Enter / Play starts a stopped song, pauses a playing or holding one, and restarts the shown section if pressed again while paused. Pausing is visual only: the engine, clock and backing clock run on, the screen (Play_State.Paused, "PAUSED / SPACE TO RESUME") stands at the first beat of the section the song was in, and the sound fades out.Viewfollows the engine in a shadow copy (view_pause,view_resume, tested intests/player_test.odin). Space while paused puts the screen at the song's real position, with no count-in, and the sound joins there and fades in. Esc / MIDI Stop stop a paused song; Next, the pads do nothing while paused. If the song ends while paused, resuming shows the end. Sync clients get onepausedstate (the section's first beat, not on a beat; the web page says "Paused") and no beats while paused;g_sync.viewkeeps following the taps meanwhile, and on resume the clients get one state for where the song has got to. - Keyboard: Enter start/pause (see Pause), Esc stop (twice, see above),
Space / β / β / Page Down for
Next (page-turner pedals send these), Shift+β / Shift+β for the previous and
next song of the set (the same
skip_songthe MIDI bindings use: the song goes on the stage stopped, ready to start; nothing at either end of the set or while auditioning; mid-song it takes a second press within 2 s, DISP-D11), Ctrl/Cmd+1..9 and 0 jump to the 1st..10th jumpable section (the samejump_sectionas the section pads; so does a click on a song-map block; while stopped it only marks the section,app.cue_set, and Enter starts there), R reload (not while a song runs: it stops it), F fullscreen (on macOS this also hides the menu bar and Dock; not yet checked on a Mac). The stage footer lists them, with the song keys only when the set has more than one song. - MIDI input (macOS, CoreMIDI;
player/midi.odinparses,cmd/player/midi*.odinapply): every source is listened to, including ones plugged in later. Play (MIDI Start/Continue, MMC Play, Mackie note 94) starts a song (the selected one from the set list) or releases a hold; Stop (MIDI Stop, MMC Stop/Pause, Mackie 93) stops; Fast Forward (MMC, Mackie 92) is Next. Tempo follows MIDI clock live (the song's clock speed becomes clock BPM Γ· written tempo, limited to 40β150%);--midi-tempo-cc Nalso maps a knob to that range. Tempo resets to as written when a song opens. Parser unit tests intests/midi_test.odin; checked end to end with a virtual source. - Bound buttons (any controller, any channel; a press is a value above 0):
--midi-go B(the same as MIDI Play and Enter: start, pause, restart the section),--midi-next B,--midi-prev-song B,--midi-next-song B, whereBisnote:Norcc:N.--midi-logprints every message received, to find a button's number. Meant for a Novation Launchkey Mini MK3 in DAW mode (Play is CC 115, Track left/right CC 103/102, Scene CC 104/105, on channel 16, per the DrivenByMoss script; unconfirmed on the hardware). Previous/next song puts that song on stage stopped (skip_song). Saved bindings:midi.txtin the library folder (player/midi_config.odin), lines likego: cc:115,prev-song:,next-song:,next:,tempo-cc:(decimal); the command line overrides it, and the first run writes a starter file with the Launchkey mapping below. Tests intests/midi_test.odin. Measured on the Mini: Play CC 0x73, Record 0x75, Track right/left 0x66/0x67, Scene up/down 0x68/0x69, all on channel 16. On macOS the player also finds a destination named like "Launchkey ... DAW" and sends it DAW mode on, session pads and volume knobs (note 0x0C, CC 3 = 2, CC 9 = 1, as the DrivenByMoss script does), because without them the Mini offers only drum and custom; it switches DAW mode off on quit.--midi-loglists the destinations it sees, and--headlessruns with no window shown, to watch the log. Not yet run against the hardware. - Session pads (Launchkey in session layout;
player/pads.odin): the pad notes are0x60β0x67(top row) and0x70β0x77(bottom), channel 1. The player follows the pad layout from the Launchkey'sBF 03 nnmessage (2 is session) and only treats those notes as pads then, so keys played high up the keyboard don't trigger them. All sixteen pads (two rows) work as one bank whose meaning follows the screen. On the set screen (pre-play) they are the set's songs (red, the selected one white; a press selects the row; ones that can't play are ignored). On the stage they jump to a section: the first sixteen sections withoutnojump, each once, lit in the song map's colour (section_kind; grey intro/outro/tag, purple verse, lilac pre-chorus, pink chorus, light blue bridge, blue instrumental), the one playing white. A jump while playing lands on the same bar and beat of the target section as the one now sounding (not its first beat), because the lead may change section and the player notice late; a shorter target is entered at its last bar. From a stopped song it restarts from the bar before the section (as the editor's audition does), or on its first beat with a backing track, whose audio crossfades to the same place. Lights are palette indices sent as channel-1 note-ons to the DAW port, only what changed, and everything again every 2 s; dark off the set and stage and while auditioning. Palette indices from the DrivenByMoss script, matched by eye to the colour chart; not yet run on the hardware.
Issues / questions
- CTRL-Q1: Is this key mapping right for your pedal?
Does Esc stopping playback risk accidental stops mid-song?Resolved: yes, so Esc now needs a second press within 2 s to stop a playing song (see Done). Still open for the pedal itself. - CTRL-Q3: Are the 1 s lockout, the 2 s Esc window and the 0.15 s / 0.25 s fades right on stage? Should MIDI Stop be confirmed like Esc (a labelled button is a deliberate press, so it isn't yet), and should MIDI Play release a hold as it used to (now Next, Space and the bound next button do)?
- CTRL-Q2: Is the MIDI mapping right for your controller? Many send plain control changes for transport (varies by model), which aren't mapped yet. Should Play at the end of a song restart it, and should Rewind do anything?
Remaining
- On-screen buttons (mouse/touch).
- MIDI on Linux/Windows (macOS only for now). Check the bound buttons on a Launchkey Mini MK3 (Play works).
11. Backing track sync
π§ β audio and audio-offset headers are reserved in the format; beat
detection finds the offset (align/, vct align).
Done
-
align/: decodes the backing track with ffmpeg (mono, 11025 Hz), builds an onset envelope (log spectral flux), and fits the track's beat times to it. The opening 30 s pick candidate offsets; a section-by-section fit of the whole song (shift Β±β beat, tempo Β±8 %, chained so drift shows where it starts) chooses between them. The envelope is scaled by its level over the surrounding ~8 s, so a quiet intro counts as much as a loud chorus, and only offsets where the written song ends within 20 s of the audio's end are tried (a 4:41 song was put 52 s into its 4:47 track before). It reports the offset, near-equal alternatives, and each section's played tempo and drift, suggesting a tempo where the written one looks wrong. Sections under 8 beats are only shifted. -
vct align song.vct [--audio FILE] [--write]prints the report;--writesetsaudio-offsetthrough the editor'sdoc_set_header. -
Tests (
tests/align_test.odin): synthetic clicks of the example song give the offset within 10 ms; a Verse 2 played at 75 with 72 written is measured and suggested, and the other sections aren't; noise is rejected (confidence 1.4 vs 20 for clicks, threshold 2); clicks with a first 40 s ten times quieter still give the offset. -
Player:
cmd/player/backing.odinplays the song'saudio:file (raylib audio device) from the stage clock, bar 1 onaudio-offset; with no header offset, beat detection finds it (also used by--render). Decoding and detection run on a thread when the song goes on stage, and the song can't be started until they are done: Enter, MIDI Play, a sync client and an audition's start are ignored while it loads. Stopped, the footer says LOADING AUDIOβ¦ and then AUDIO READY. Starting a set therefore puts its first song on stage stopped if its track is still loading. (Fixed: starting a set opened its first song and started it in the same frame, so the track always joined late; on a cold first run β ffmpeg and the file not yet cached β it was measured joining 1.28 s in, cutting the intro.) A track that begins late anyway (a slow frame) joins at the song's place. Plays in editor auditions too, from the audition's start point; not at a practice speed other than 1. Not on the web. The track is decoded from the copy the set list keeps (through a temporary file), so a song restored from the saved set list plays even after its audio file is renamed or moved; a failed decode is reported once. (Fixed: the finished loading job was kept, so the next frame joined its already-freed thread and crashed.) Whatever the file has before bar 1 (audio-offset> 0) plays during the count-in, so a lead-in such as a one-beat guitar slide is heard before the song; the guide says so (Β§13). Beat detection can put such a track a beat out. -
Gain and normalising (
align/loudness.odin): theaudio-gain:header (dB,-3,+1.2,-3 dB) is the track's level. With Normalise on (the home screen's NORMALISE button and its target stepper, or L / [ ]; saved invct-player/normalize.txt;--normalize [LUFS]for one run), a song withoutaudio-gainis brought to the target (default -16 LUFS): integrated loudness by BS.1770-4 K-weighting with the R128 gates, run in-process on the decoded 44.1 kHz stereo (no second ffmpeg pass). A song withaudio-gainis left as written. Boosts go through a look-ahead limiter (whole-track: minimum over 5 ms, averaged over 5 ms, 80 ms release, linked channels, ceiling -1 dBFS) instead of clipping; the gain is corrected for what the limiter takes off (up to 4 re-measurements) and capped at +18 dB; silent or very short tracks are left alone.--renderdoes the same on the decoded audio and hands ffmpeg the result.vct loudness song.vct [--audio FILE] [--target LUFS] [--write]prints the measurement and the gain, and--writebakes it intoaudio-gain(likevct align --write). Tests: a -20 dBFS sine reads -20 LUFS and +6 dB per doubling, normalising lands on the target (also on peaky material, where it needs more gain than the difference), the limiter never passes the ceiling (including a peak in the first few ms) and leaves quiet material and signals under the ceiling alone, silence is rejected. -
Editor (feature 17): if the
audio:file isn't on disk, the form warns under the AUDIO field with a REFRESH chip that checks again (audio_missing). -
Editor (feature 17): AUDIO and AUDIO-OFFSET fields, the macOS file picker for the track, and FIND / Ctrl+B to set
audio-offsetby beat detection. -
Multi-stem playback:
audio[Label]: fileheaders (repeatable;vct.Stem,vct.meta_stems) name labelled stems, played together; a plainaudio:is one more stem labelledBacking. The set list keeps every stem's bytes (Entry.stems). The player decodes the stems side by side (a thread each, under the loading thread), pads them to the longest, finds the offset and the loudness gain on their sum (align.mix_pcm,align.analyse_pcm), applies the one gain to each, and plays them all through one raylib audio stream, summed on the audio thread bybacking_mix(AUDIO-I4). The mixer (player/mixer.odinfor the maths,cmd/player/mixer.odinfor input and drawing): keys 1-9 mute and unmute stems, Tab shows a panel with a volume and pan slider per stem (drag, mouse wheel, Shift+wheel for pan, right click resets); the levels are published to the audio thread every frame, so changes are live. Settings last until the song is closed.--rendermixes the stems down at full volume, with each stem'sgain=andpan=. Tests: header parsing, mixer maths, mixing and beat detection on a mix. -
The stage clock follows the track (
player/lock.odin,backing_followincmd/player/backing.odin): the track plays on the sound card's clock while the stage clock ran on the frame clock, so the two drifted apart over a song (a card 0.1 % out is 120 ms by the end of two minutes, and further the longer the song). The framesbacking_mixhas been asked for are counted (played), and the stage clock's speed is trimmed to follow them (at most Β±1 %, a 30 ms gap closed over about three seconds), so the backing is the master as it is in a DAW-driven rig: the band plays to what it hears. The frames are the track's own (44.1 kHz), but the device decides how fast it asks for them, so the rate is measured over two seconds of playing and snapped to the nearest standard rate (lock_rate) β which the track's nominal rate always is, so the snap can no longer pick the wrong one as it could when the device's own rate was being guessed; the first drift reading is the audio pipeline's own latency and is kept as the mark, so only what moves from there is followed. A gap too big to trim (Β±250 ms: the device stalling) moves the track to the song with the jump's crossfade, and after three of those in a song the clock is left to run on its own. The mixer panel shows the drift and the clock's speed;--driftprints them every two seconds, and--type SEC tabopens the mixer for a screenshot. Tests (tests/player_test.odin): the rate is read from a rough count and anything unlike a standard rate rejected; a card 0.1 % fast ends a two-minute song within a buffer of the track with the gap no longer growing and the clock running at the card's speed to 20 ppm; a steady latency isn't taken for drift; a 200 ms lurch is trimmed away at the capped speed; a device that keeps stalling is moved three times and then let go; and the bursts of a 43 ms buffer don't shake the clock. -
Backing track or stems:
audio-mode: backing | stems(vct.Audio_Mode) picks the plainaudio:track or theaudio[Label]:stems; not said, they play together as before.vct.meta_stemsreturns what plays andvct.meta_tracksevery file named (the library takes them all on import, so the mode can change later).audio[Label]: file mutedstarts a stem muted. In the player, a song with more than one track lists its tracks along the bottom left of the stage, each with its mute key (1-9), a muted one dimmed (draw_stem_strip); the strip moves above the footer when the two would meet. Completion and hover knowaudio-modeandmuted. Tests: parsingmuted(and a file name with the word in it), each mode. -
Per-stem gain and pan:
audio[Label]: file gain=-3 pan=-20(in any order withmuted;vct.Stem.gainin dB,.pan-100 to 100;MAX_GAINΒ±40 dB shared withaudio-gain, viaparse_db). The gain is applied to the stem's samples when it's decoded (align.apply_gain, limited), before the stems are summed for beat detection and loudness, so normalising keeps the balance; the mixer's volume still starts at 100%. The pan is where the mixer's pan slider starts (Mix_Track.home), and a right click returns to it.--render's mixdown applies both (render_pan, the live pan law). Tests: parsing in any order, the defaults, out-of-range and malformed values, the corpus dump, hover.
Not done (multi-stem)
- AUDIO-D3: the stems must be sample-aligned (one
audio-offsetandaudio-gainfor all); there is no per-stem offset. - AUDIO-D4: the mixer is player-only. The web client can't send commands
(Ping is its only message), so mixing from a phone needs a new inbound
message in
stagesync/; the mixer state isn't in the syncStateeither. - The mixer can't be driven from MIDI yet,
--renderignores live mixer settings (it uses the song'sgain=/pan=), and the editor's AUDIO field edits only the plainaudio:header (stems are written in the text view). Not checked by ear: the build machine's audio output wasn't used. - Memory: each stem is held decoded (about 10 MB a minute), and songs stay decoded after they close, up to 1 GiB beyond the ones in use (AUDIO-I7).
Issues / questions
- AUDIO-I4: Jumping a section used to reload every stem on the main thread
(
LoadSoundFromWaveover the whole remaining track, tens of MB a stem), so the operator's pad froze the stage display and gapped the audio mid-song. β fixed: all the stems go out through one audio stream whose callback (backing_mix) sums them from a frame number, so a jump, a late join and the lock's re-seek are an atomic store and cost nothing on the main thread. Checked against the old path: levels and raylib's pan law come out bit-identical across the mixer's volume and pan range (raylib pans the stream too, so its centred share is divided back out βBACKING_PAN_CENTRE), and two live jumps in a 90 s three-stem song each re-settle the lock inside Β±3.4 ms. Not checked by ear. - AUDIO-I5: Closing a song joined the decode thread, so skipping to the next
song in a set while its stems were still decoding froze everything β the
stage display, input, MIDI and the sync feed β until ffmpeg finished. On a
four-stem song that was measured at 8.2 s. β fixed: the job copies the
bytes and the sections and beats it reads, so it owns nothing of the song's
and can be left to finish and throw its work away; the worker and
backing_closerace for it with one compare-and-exchange, and whichever loses frees it. The same switch now takes 0.34 s, and the drop path is clean under-sanitize:address. - AUDIO-I6: A missing ffmpeg only showed as a backing track that never
arrived: the message went to stderr, which nobody reads at a gig, and the
song played silently. β fixed:
align.ffmpeg_okis asked once at startup and the pre-play screen carries a NO FFMPEG line naming how many songs in the set have a track, so it is seen while the set can still be sorted out rather than at the downbeat. Checked with ffmpeg off PATH, in the singular and plural, and absent when it is there. - AUDIO-I7: Every time a song went on stage, its backing track was decoded,
beat-detected and measured from scratch, so switching songs or going back
to the pre-play screen and in again meant waiting through LOADING AUDIOβ¦
each time. Measured on a 4:47 song with Normalise on (release build): 1.2 s
for one WAV with
audio-offset, 1.8 s with detection, 4.8β5.4 s for four m4a stems. Most of ffmpeg's share was core:os'sprocess_exec, which reads a pipe 1 KB at a time and busy-polls it (0.29 s for ffmpeg on its own, 0.78 s through the pipe), and the stems were decoded one after another. β fixed:align.ffmpeg_rawhas ffmpeg write to temporary files and waits for it;backing_decoderuns a thread per stem; and the detected offset and loudness gain are kept for the run (g_measured, keyed by an XXH3 hash of the files' bytes β or path, size and time for a file read from disk β each stem'sgain=, the song's beats and sections,audio-offset,audio-gainand the Normalise target), so a song opened again only decodes. Now, in the player (-o:speed): 0.6 s first / 0.37 s again for the WAV with an offset, 1.3 s / 0.38 s with detection, 1.9 s / 0.95 s for four stems. Tested:decode_reads_ffmpeg_output(a 3 s WAV round-trips sample for sample, and an unreadable file gives ffmpeg's error). β then the decoded tracks themselves are kept (player/audio_cache.odin, keyed by the same hash): reference-counted entries, held by each loading job and by the track on stage; unheld ones dropped least recently used first over 1 GiB (AUDIO_CACHE_BYTES, about 100 stem-minutes), a held one never. And once the song on stage has its track, the set's next song is loaded in the background (backing_preload, a low-priority thread); a job asking for a song another is still decoding waits for it rather than decoding twice, and a load whose song closes with no one waiting for it is given up. Measured (release build, a set of the three songs above, 3 s on each): the first song 0.68 s, then every song after, forward and back, 5β27 ms; the cache at 290 MB. Skipping straight on (no wait) puts the next song on stage still preloading, and it waits for that rather than starting again; skipping away while every song loaded, and both runs, were clean under-sanitize:address. Tests (tests/audio_cache_test.odin): a hit after a load, LRU dropping with held entries kept over the cap, a load shared with a waiting job, a failed or abandoned load retried, a cancelled wait. A preload runs at a low priority, so it gives way to the stage: its threads at.Lowand its ffmpegs undernice -n 10(align.decode_stereo'sbackground, on macOS and Linux). Checked withps: the song going on stage decodes at the player's own priority, the preloaded next song's four stems 10 lower. A preload a song then waits for (put on stage before it finished) stays low; with the machine otherwise idle that costs nothing. A dev build without-o:speedruns detection and levelling about 3Γ slower than a packaged one (0.64 s β 3.4 s for the detection above), somise run build:playernow builds with-o:speedtoo (about 15β20 s instead of 2 s);OPT=nonegives the quick unoptimised build. - AUDIO-I8: Putting a song on stage, and preloading the next one (AUDIO-I7),
copied every stem's file bytes on the main thread so the loading job could
outlive the song's entry: 100β170 ms for four WAV stems (6β10 frames the
stage display lost, now also while a song played), 20β45 ms for one WAV,
4β8 ms for m4a stems, and on every open, cache hits included. β fixed:
the bytes are
player.Audio_Bytes, outside the entry's arena and counted; an entry, the runs of it in a set (entry_variant, which no longer needs the song it was made from to outlive it) and a loading job each hold a reference, and the last to let go frees them, from whichever thread. A reload keeps the old bytes by reference when it can't read the files again. Now under 0.1 ms on the main thread in every case. Checked: every song of a set comes up with all its stems, clean under-sanitize:address; testentry_audio_is_shared_by_reference(a run and a job keep the bytes after the song is removed, a reload without the file keeps them, the last release frees them, nothing leaked). - AUDIO-D2: Normalising is a player setting, measured the first time a song
goes on stage in a run (AUDIO-I7); the audio file is never changed. To keep a level with the song,
vct loudness --writewrites it intoaudio-gain, and a song with that header is not normalised again (so the header is "the level for this song", not a trim on top of normalising). β decided. - AUDIO-I2: ffmpeg's
-ac 2lowers a mono file by 3 dB when it makes it stereo, so mono tracks play (and are measured, and render with a gain or Normalise) 3 dB under the file. Normalising evens it out; only a hand-setaudio-gainon a mono file is 3 dB lower than the number says. A stereo upmix at full level (pan=stereo|c0=c0|c1=c0) would fix it. - AUDIO-I3: True peak isn't measured (the -1 dBFS ceiling leaves room for peaks between samples); no UI for the 18 dB boost cap or the -1 dBFS ceiling.
- AUDIO-D1: Loops and holds aren't supported with a backing track: the audio plays straight through (not paused or repeated). β decided.
- AUDIO-I1: The clocks no longer drift apart (the stage clock follows the
track, above), and playback is now run on a Mac with a sound card rather
than checked only to the point of detection and loading: a 110 s click
track played with
--driftreported the device at 48 kHz and held the drift inside Β±2.8 ms for the whole song, with no re-seeks. Not checked by ear yet, and the build machine still has no sound card. What is left is how closely the two can be held: the device only says which block it last took, so the clock sits within one buffer of the track (Β±5 ms on a 10 ms buffer) and can wander a few ms over a minute when the readings fall in step with the blocks; a running fit over a longer window would tighten that. This Mac's built-in output keeps step with the frame clock to well under a millisecond, so the trim stays near zero there β the clocks that pull apart are a separate interface's, which is what a gig uses, so a 0.1 %-fast card is shown in the tests rather than on this machine. (Fixed: a headeraudio-offset: 0was read as "detect"; it now means bar 1 at the start of the file.) Alignment assumes the audio follows the written timeline (one pass each). - ALIGN-I1: Checked on synthetic clicks and one real song (Falling Slowly, 70 BPM, stems without drums): the vocal stems give the right offset (0.86 s); the others pick a beat or a bar either side (ALIGN-I4), and a strings-only stem lands 27 s in. Untested on rubato.
- ALIGN-I2:
A tempo more than 8 % off sits at the edge of the search and reads as a wrong suggestion (e.g. 80 written vs 72 played reports 86.4).Fixed: the range is Β±12 % (TEMPO_RANGE), and a best fit in the outer tenth of it (BEYOND_EDGE) counts asbeyond, since the search can't see past its limit: the report says "tempo more than 12 % off", prints no played tempo and suggests nothing. The edge test used to be an exact hit on the coarse grid, which a refined fit could step past: 60 BPM against 72 written settled on 80 and suggested it. Tested inalign_tempo_far_off(65 against 72 is measured and suggested; 60 is flagged). Still open: abeyondsection's wrong factor is used to place the sections after it, so they start from a worse guess. - ALIGN-I3:
The report's number columns come out zero-padded (Fixed: both pad strings (00001,+00001ms) with this Odin'sfmtwidth on numbers;vct outline's columns show the same.align/report.odin,format_outlineinvct/dump.odin). - ALIGN-I4: A uniform click track (or a steady stem) can't tell beats or bars apart, so the offset may be a beat or a bar out; the alternatives line lists the others.
- ALIGN-I5: A debug build takes ~4.6 s on a 4.5-minute song (1.4 s with
-o:speed), mostly ranking the candidates. - ALIGN-Q1: Write suggested tempos back too, or keep them as advice?
Remaining
- Editor action (Ctrl+B): run the fit and set
audio-offset(undoable). - ALIGN-I2, ALIGN-I3;
alignrun task inmise.toml; README and format.md mentionvct align. - Decide AUDIO-Q1; play the file aligned so bar 1 is at
t = count-in lengthplusaudio-offset.
12. C ABI / ports
β³ β the core's public types are already flat and C-shaped.
Not needed: the desktop player is written in Odin and imports vct
directly (PLAT-Q5). Revisit for mobile or web.
Remaining
- Export
parseand the engine with a C ABI (and a header) when a non-desktop platform needs it. (Web doesn't: the whole player compiles to wasm, feature 20. Mobile barely does either: an@(export)proc already builds into an iOS static library as it stands β feature 28.)
13. Songs, sets and the library
π§ β player/library.odin (library, sessions), player/setlist.odin
(entries, summary), vct/set.odin (set files), cmd/player/home.odin
(home screen), cmd/player/preplay.odin, cmd/player/set_edit.odin;
tested in tests/library_test.odin and tests/player_test.odin
Songs and sets are plain files in the user's Documents folder
(~/Documents/StageDisplay/{songs,sets}; --library DIR overrides; on the
web, /data/StageDisplay). The library is whatever is in those folders; the
old cache (vct-player/setlist.txt in the config folder) is gone.
Done
- Format (docs/format.md Β§3, Β§3.1, Β§12):
set:marks a file as a set, withsong: file [arrangement="β¦"] [key=β¦] [tempo=β¦]lines;version:marks an alternate version;arrangement: Name = Section, β¦lists named section orders (every name must be a section).vct.parse_withplays a song with an arrangement, key or tempo (Options);vct.parse_set,format_set,is_set. - Home screen has two tabs (Tab key or click): SONGS (sorted, with a
VERSION column showing the label or "Original") and SETS. No rearranging
there. Enter or double-click opens the pre-play screen; E edits; N makes a
new song or set; A or drop adds songs (copied into
songs/together with a backing track named by a bare file name next to the song); R re-reads the folders. - Songs with the same title and version (but different files) are shown as
Title (file)with a DUPLICATE TITLE tag and a warning; aversion:on either removes both. - Pre-play screen: the set's name (or the song's) as the title, the songs in order with key, tempo, time, timeline and COUNT-IN per row, and under the list the selected song's count-in beat by beat. Drag the grip or Alt+Up/Down reorder for this run only (the run holds copies; the saved set isn't touched). Enter starts from the selected row; the stage then goes on through the list: when a song ends the next goes on stage stopped, shown as a card, and Enter starts it (DISP-D11). Esc on the stage returns here.
- Playing one song from the SONGS tab makes a set of just that song.
- Stage shows the set name above the song name.
- Set editor (
Eon a set,Non the SETS tab): name and song list. Songs are added from the library as the original; per row, click or Left/Right changes the arrangement, K or a click types a key, T or a click a tempo; reorder, remove, Ctrl/Cmd+S saves tosets/<name>.vct. - The look a set is played in (DISP-D9, feature 9): the set editor has STAGE
LAYOUT and THEME chips beside the set's name (L and H, or a click on either
end of a chip, as the arrangement control works), cycling the player's own
β shown dimmed, and stored as no name at all β then every layout or theme
look_namesoffers. They are saved as the set'slayout:andtheme:(format Β§12,vct.format_settakes aSetnow) and read back when the set is opened. Fixed in passing: the arrangement control's two arrows were wound clockwise, so raylib culled them and they never drew; the new chips would have had the same. - Only
.vctfiles are songs or sets: other files in the folders are ignored, and adding a file of another type says it isn't a.vctfile. - The library follows the folders: the home screen checks every second
(
library_sync) and picks up files added, deleted or changed outside the app, keeping the highlighted row. R still re-reads everything. - The song editor watches its file: if it changes on disk a prompt offers R reload (one undo step, so the editor's text can be brought back) or K keep mine; if it is deleted, K keeps editing and saving makes it again. Saving over a file that exists and isn't the one the editor last saw (Save as to an existing name, or a file changed since opening, including after K) asks: O overwrite or R save under another name (a numbered name is suggested).
- First launch moves an old saved set list's songs into the library
(
.migratedmarker in the library folder).
Issues / questions
- SET-I10:
The chips' own clicks, and Ctrl/Cmd+S with a look set, haven't been pressed by hand.Clicked with real pointer events and saved with βS on a Mac: the layout and theme chips each moved on one, and the set file gotlayout: focusandtheme: contrast. - SET-I2: Songs and sets can't be deleted from the app; delete the files in Finder (the home screen notices on its own).
- SET-I9: Only the home screen watches the folders; the set editor's song list and the pre-play screen hold songs, so they keep what they had until you go back home. Not tried on a Mac with Finder renames/deletes.
- SET-I3: The set editor has no text view; set files can be edited by hand.
- SET-I4: Arrangements and
version:aren't in the song editor's structure form yet (the text view highlights and completes them). - SET-I5: A key change in a set is a label only; nothing transposes.
- SET-I6: Old audio embedded in a saved set list was only moved over when its song named it by a bare file name.
- SET-I7: A crash after saving a new set and playing it that showed "can't
play" in the list: macOS aborted in
setTitle:on an invalid window title (preplay_open). Not reproduced (the saved files are valid and play from the command line), so the cause is unknown.window_titlenow falls back to the plain title if one isn't valid UTF-8, so it can't abort the app; if the fallback title ever shows, the name that was passed is the thing to look at. - SET-I8:
The set editor dropped a song'sFixed:lang=when it saved.song_clonenow copies it. There's no way to change it in the editor yet. - SET-I1: Drag and drop not yet tried on a Mac (it can't be faked: Finder
starts the drag). The Documents folder was checked: run with no
--library, the player made~/Documents/StageDisplay/{songs,sets}(under a scratch home) and added the song given to it.
Decisions
- SET-Q1: Named or multiple set lists? β Yes: each set is a file.
- SET-Q2: Previous/next song on the stage? β Yes (October 2026): bound
to MIDI buttons (
--midi-prev-song,--midi-next-song, feature 10) and to Shift+β / Shift+β on the stage. Shift so a stray arrow can't change song: β on its own is Next. - SET-Q3: Adding songs uses the native picker on macOS; elsewhere the path prompt and drag and drop.
- SET-D1: Where a set's layout and theme live β on the set as a whole
(
layout:,theme:), not on eachsong:line. A service wants one look its band reads all evening; a look that changed song by song would flicker between songs and double the controls in every row. A per-song override could be added later aslayout=on asong:line without moving this.
Remaining
- Keys for previous/next song (Shift+β / Shift+β; MIDI was done).
- A layout and theme per set, and L to switch layouts live (SET-D1, DISP-D9).
- SET-I2 to SET-I4, SET-I10.
14. Deferred format features
π€ β format Β§11: swing/subdivisions, click sound choice, compound-meter
pulse (pulse=). Arrangement lists and repeat groups shipped in v0.2
(feature 1, format Β§12).
15. Ultimate Guitar import
β
β ug/convert.odin (meta JSON to .vct, no I/O), cmd/ug2vct/main.odin
(the CLI), tests/ug_test.odin with a made-up fixture in tests/ug/meta.json
Done
ug2vct <tab id or URL>fetchesapi-web.ultimate-guitar.com/v1/tab/pro/meta?id=Nwith libcurl (vendor:curl, linked to the system library: built into macOS; on Linux the libcurl and mbedtls dev packages) and writes<title>.vct. Takes a bare id, an API URL (id=) or a tab page URL (...-2459456).- Headers: none are needed. With and without the browser's cookies, the
response was the same (checked October 2026).
UG_COOKIE, if set, is sent as the Cookie header in case Cloudflare starts refusing plain requests. - Title, artist, tempo (the strumming pattern's
bpm, else the tab'stempo: on tab 2459456 the page shows 68, the strummingbpm, whiletemposays 120), capo noted in a comment. Sections come from the chord sheet's[Name]lines. Repeats come from[Chorus x2]or from identical consecutive sections, and are written asxN. --sections "Intro:1, Verse 1:5, β¦, end:70": each section's first bar as the tab page shows it, then the last bar. Gives exact bar counts and replaces the chord sheet's sections; consecutive equal sections becomexN. The README explains how to get the list from Claude for Chrome on the tab page (a prompt that asks for names and bar numbers only).examples/falling-slowly.vct: made withug2vct 2459456 --key C --sections "Intro:1, Verse 1:5, Chorus:18, Verse 2:27, Chorus:40, Bridge:56, Outro:65, end:70"(list from Claude for Chrome). 70 bars at 68 BPM, matching the recording's 4:08.- Otherwise, bar counts are one per chord (
--bars-per-chord N), or scaled to the recording's length with--fit. The section's chords go in a comment, and a header comment compares the file's total with the recording's length. - The output is parsed with
vct.parsebefore it's written; a file with errors isn't written. - Only structure and chord names are kept. The chord sheet's words are not copied.
Issues / questions
- UG-I1: The meta response has no bar counts, key or time signature (its
tracks[].measurescome back empty, logged in or not). Without--sections, bar counts are a guess and need checking by ear. - UG-I2: Only "Official" (pro) tabs have this endpoint; ordinary chord tabs return 404.
- UG-I3: Linking
ug2vcton Linux needslibcurl4-openssl-devandlibmbedtls-dev. CI only type-checks it, so it's unaffected. The cloud SessionStart hook (.claude/hooks/session-start.sh) installs them when missing (about 3 s; a failed install only warns). Local Linux builds need them installed by hand. - UG-Q1: Is one bar per chord the better default, or
--fit? With the strumming tempo, one bar per chord gave 64 bars against about 70 for the recording on the first song tried.
Decisions
- UG-D1: Read the tab reader's score file for exact bars? β No. The
reader downloads it from
tabs.ultimate-guitar.com/tab/download/filewith a signature the page generates (s=), and the file (XTZheader) is encrypted differently on each download. Decrypting it would mean getting round Ultimate Guitar's protection of Pro content, so section bars come from--sectionsinstead, read off the rendered page.
Remaining
- CI-I2: add
odin check ug -no-entry-point -vetandodin check cmd/ug2vct -vetto the Vet steps of.github/workflows/test.yml(done by hand).
16. Lyrics
π§ β format Β§5.1; vct/parse.odin (parse_lyric), vct/build.odin,
vct/phrase.odin and vct/reflow.odin (grouping lines so they can be
read; Β§25), player/view.odin (lyric, view_next_lyric),
cmd/player/stage.odin (draw_lyrics); branch claude/lyrics-support-b462af
Decisions
- LYR-D1: How are lyrics timed? β Timed lines, like markers:
" <bar>[.<beat>] wordsunder a section, repeated on every pass. - LYR-D2: Where do they show? β A strip on the stage display for the band (not a congregation screen): the current line large and white, the next line dimmed beneath it. Only in songs that have lyrics, the beat columns shrink (124 β 48 high) to make room.
- LYR-D7: Where is "show the chords" turned on? β A
chords: truein the layout for what the stage starts with, and C to toggle it live on any screen (Ctrl/Cmd+Shift+C in the editors, where a letter is typing). It is a setting of the app, not of a song, so it survives closing one; metronome mode (M) now works the same way, everywhere, and no longer resets on close. Both are sent to the synced clients, which follow the player but can choose for themselves until it changes again. - LYR-D6 (answers LYR-Q2): How are chords written over the words? β
[C]in the words, where the chord falls (" 1 [C]I don't know you [C/F]But I want you), as ChordPro and the chord sheets people already have. A bar-level> | C | C/F |chord line says which bar a chord is in; this says which word, which is what a song sheet shows. The two live together: chord lines for the bars nobody sings over, chords in the words for the rest. Rejected: column-aligned chord lines above the words (fragile to edit, and re-anchoring them to changed words is guesswork); per-word beat positions (bigger, and chords would land on beats, not words); estimating a position from the bar-level chords (what the ChordPro export does today, and CP-I3 is the complaint about it).
Done
-
Editable in the song editor (feature 17): colouring, a snippet, and lyric rows in the structured view.
-
Parsing with diagnostics (before any section, no position, no words, out of order, outside the section).
Track.lyrics, lyric ranges onSection_DefandSection,Beat.lyric,Event.lyric. -
vct dumpaddslyricsper section andlyricper beat (golden file re-blessed; the change only adds fields).vct outlinelists them. -
View: a line stays up until the next one, or until a section with no lyrics starts (so pickups carry over). The next line wraps round an unreleased loop. Nothing shows before starting; the first next line appears once it is no more than a bar ahead (as after a clear).
-
The next line gets an
ON Ntag in front when it starts on beat N other than 1 (written" bar.N), so singers know where it comes in. -
Highlight is optional and off by default:
highlight: trueon anow-lyrics,next-lyricsorlyricswidget draws the current line bright; without it every line is grey. When on, the next line lights up just after the last beat of the bar before it (15% into that beat;view_pre_lyric, stagecurrent_lyric), and the strip then shows the line after it as next. -
Example song has lyrics (Amazing Grace, public domain).
-
Tests: error corpus (
lyric-*.vct), placement acrossxNpasses, view current/next with a pickup, a loop and a section without lyrics. -
Checked on macOS with
--shot: count-in (next line only), a verse, and anON 3tag on a next line starting on beat 3. -
Lyrics layout (LYR-D3):
layouts/stage.json5lists every line of the current section under NOW (current line white, the rest grey) and of the next section under NEXT (first line white once the count-in starts), via thenow-lyrics/next-lyricswidgets. The count-in sits above the beat columns, which shrink to make room, and readsVerse 2 in 4with only the number lit; it's hidden until the count-in starts. Thelyricsstrip widget remains forfocus.json5. -
Karaoke (LYR-D4):
layouts/stage.json5now shows akaraokewidget by default instead of thenow-lyrics/next-lyricscolumns: the line being sung large and white across the width (above the song map), the next line dimmed beneath it with itsON Ntag. It's thelyricsstrip (draw_lyrics) always highlighted and scaled to its box's height (the strip is sized for 106). For songs with lyrics the beat count shrinks (440 β 400), the practice slider moves up, and the cue moves under NOW. Thenow-lyrics/next-lyricswidgets remain for other layouts. Checked on macOS with--shot: intro (next line only), a verse with a cue, anON 3tag, and a song without lyrics. -
layouts/stage.json5no longer shows thebarwidget (bar within the section) under the beat count, with or without lyrics; the widget remains for other layouts (focus.json5keeps it). Checked on macOS with--shot: a verse with lyrics and a cue, and a song without lyrics. -
Without the bar, songs without lyrics give the beat count the room (440 β 560 high; the dots end just above the cue). The karaoke widget's next line is bigger than the strip's for reading at a distance (40 vs 30 units, about 60 px on stage;
draw_lyricstakes the next line's size), and its box moves up 20 px to fit. TheON Ntag is centred on the next line. Checked with--shot: anON 3next line, and a cue under the bigger beat count in a song without lyrics. -
The cue sits under NOW in every song (not only those with lyrics), and the practice slider below it, so the strip above the song map is only for lyrics. Checked with
--shot: a cue with and without lyrics. The slider's new place isn't checked (no--shotroute into an audition). -
The
ON Ntag stays on an off-the-downbeat line when it lights up early (on the last beat of the bar before it), so it shows until the line actually starts, not only while it is the dimmed next line. Compiles (odin check cmd/player -vet); not checked with--shot. -
next-cueswidget, under NEXT inlayouts/stage.json5: the next section's cues (markers), dimmed, one per line asDrums in on 5(on 4.3off the downbeat) with just the position lit in cyan, like the count-in; the same size as the cue under NOW (56), shrinking to fit the box. Checked with--shot: the intro (Drums in on 5) and Verse 1 (Hits on 3 & 4 on 4.3). -
Showing them (LYR-D7):
Ctoggles chords andMmetronome mode from the library, pre-play and stage screens, and with Ctrl/Cmd+Shift in the editor and set editor (screen_toggles); both live on the app, so they hold from song to song. The stage's footer lists C, and the layout'schords: truesets what the stage starts with. The editors' bindings aren't checked by hand:--typeputs text straight into the buffer rather than through raylib, so nothing here can press a key. The defaultstageandfocuslayouts were re-checked with--shotand are unchanged but for the footer's newC chords. -
Chords in the words (LYR-D6):
[C]brackets come out of the words and becomeLyric.chords(Chord_At{name, col}, a byte offset intoLyric.text), invct dumptoo. A[tag]is told from a chord by the space after it ([Ann] wordsis a tag,[C]wordsa chord), so files written before this parse the same; a bracketed word alone ([Instrumental]) is still the words, and brackets with a space in them ([2 bars]) still text. Chord names stay free text, as in a chord line. The chords of each line of the/shorthand are its own. The editor and LSP colour them (Span_Kind.Chord, split out ofWords, whichlyric_fieldsstill reports whole so the structured view edits the line as written).vct lintwarns about a[tag]that reads as a chord and about a chord name that doesn't (vct.is_chord_name, whichsheet.is_chordnow calls too: the same question had two answers). Tests: parse (columns, tag, shorthand, inside a word, after the last word, brackets that stay text), lint and lexer spans. Checked in the editor with--shot: the chords of a lyric line colour as chords, not as words, and the song still parses. The corpus file waits for the ChordPro export to write chords back (feature 22), since its round-trip test would fail on a line the exporter drops. -
Clearing lyrics (LYR-D5): a lyric line with a position and no words (
" 5) clears the line on screen from that beat. After a clear the next line shows (dimmed) only in the bar before it starts, not through the whole gap. The song editor keeps an empty words field empty. Tests: parse (lyric-malformed.vctno longer errors on" 2), view next line after a clear. Not checked with--shot.
Issues / questions
- LYR-I1: The fonts only load Latin-1 plus a few punctuation marks, so
lyrics in other scripts (or with characters like
Ε) draw as boxes. - LYR-I2: A long line is shrunk to fit 1800 px rather than wrapped, so very long lines get small. Split them in the file for now.
- LYR-I3: With APP-I4's timing (Next at the very end of a loop pass), the next line can show the loop's first line for one more pass.
- LYR-Q1: Should a key toggle the strip off (e.g. for the drummer's screen), or a full-screen lyrics view be added later?
- LYR-Q2:
Show chords above the words? That needs a chord syntax.Answered by LYR-D6; shown by theall-lyricswidget (DISP-D8).
Remaining
- LYR-Q1.
- Check readability at distance on a stage monitor (with DISP-Q1).
17. Song editor
π§ β vct/lex.odin (line lexer and line formatters), player/buffer.odin
(text buffer, undo), player/editor.odin (model, new-song template),
player/doc.odin (structured edits), player/complete.odin
(autocomplete), cmd/player/edit.odin (screen, text view, song map,
preview), cmd/player/edit_form.odin (structured view);
tests/editor_test.odin.
An editor for .vct files inside vct-player, with a text view and a
structured view of the same file.
Decisions
- EDIT-D1: Where does the editor live? β Inside
vct-player, as a third screen beside the set list and the stage, sharing its fonts, palette and section colours. - EDIT-D2: Text or structured editing? β Both, switchable (Ctrl/Cmd+T or the TEXT / STRUCTURE tabs).
- EDIT-D3: The text is the only source of truth. The structured view reads the lexed lines and writes changes back a line at a time (or moves whole sections), so the two views never disagree and comments, blank lines and column alignment survive structured edits.
- EDIT-D4: First-version extras chosen: drag to rearrange sections, autocomplete and snippets, a new-song template.
- EDIT-Q1: Play the song from the editor (audition, then come back to the same place)? β Yes: F5 / Ctrl+Enter / PLAY plays the text as it stands (saved or not) from the section at the caret, with a bar of lead-in; Esc returns to the editor unchanged.
Done
- Opening: E or the row's EDIT button on the set list, E on the stage when stopped (also from the "can't play" screen), N or NEW SONG for a new song from a template. Esc goes back to where you came from; with unsaved changes it asks first (S save, D discard, Esc stay).
- Saving: Ctrl/Cmd+S or SAVE writes the file (ending with a newline) and reloads its set-list entry. A new song asks for a path (Save as: the song's title in the selected song's folder, selected so typing replaces it; asks before replacing a file) and joins the set list. The title shows a dot, and the window title a β’, while there are unsaved changes.
- Text view: line numbers, syntax colours from the lexer (header keys and values, section names, lengths, modifiers, strings, markers, comments), a section-colour stripe in the gutter, current-line highlight, selection, blinking caret, scrolling (wheel, Page Up/Down, keeps the caret in view, horizontal for long lines). Keys: arrows (with Shift to select, Ctrl/Alt for words), Home (indent, then line start), End, Ctrl+Home/End, Enter (keeps the indent), Tab (two spaces), Backspace/Delete (Ctrl/Alt for words), Ctrl+A/C/X/V, Ctrl+Z, Ctrl+Shift+Z or Ctrl+Y, Ctrl+/ comment lines, Ctrl+D duplicate the section, Alt+Up/Down move the section. Mouse: click, drag to select, double-click a word, click the gutter for a line. Typing coalesces into one undo step until the caret moves.
- Live diagnostics: parsed on every change. Red/pink squiggles under the token at fault (held back on the line being typed until typing pauses), a dot on the line number, the caret line's problem in the status strip, and a PROBLEMS list in the side panel (click to go to the line).
- Status strip: the caret's line and column, and for a section line where it falls in the song (bars, start time, tempo, meter, loop/hold).
- Preview panel: key, BPM (range), time signature, length, bars and section count as the song stands, or CAN'T PLAY.
- Song map along the bottom: one block per section (sized by time, or by bars while the song has errors), coloured like the stage, loop/hold bars under them, the caret's section outlined. Drag a block to rearrange sections (a cyan line shows where it lands); click one to go to it.
- Autocomplete (opens as you type a word; Ctrl+Space shows everything;
Up/Down, Enter or Tab, Esc; click an item): header keys not yet in the
file (lined up with the other values, a default value selected), header
values (time signatures, countdown, cue-ahead, count-in, keys), section
names (the next number β "Verse 3" after "Verse 2" β names already in the
song, then the common names), each with a length ready to type over and
lined up with the section above; modifiers not already on the line
(loop, hold, quiet, silent, xN, @tempo, @from>to, meters, say="β¦",
show="β¦", ahead=2) with the part to change selected; marker snippets on
an indented line or after
>;say/say="β¦"after marker text. Nothing inside quotes or comments. - Structured view: a SONG form (title, artist, key, tempo, time as text fields; count-in, countdown and cue-ahead as choice chips; emptying a field removes the header line; a missing one is added after the others) and a SECTIONS table. Each section: grip, name, bars, ΓN, tempo, time (placeholders show the inherited tempo and meter), LOOP / HOLD / QUIET / SILENT toggles, + MARKER, COPY, delete; a second line for say=, show= and ahead=; unknown modifiers or the section's error shown beside them. Each marker: position, text, SAY toggle and its say= text, delete. Clicking a field selects its value (typing replaces it); Enter applies, Tab applies and moves to the next field, Esc cancels, clicking elsewhere applies. Drag the grip to reorder (with auto-scroll); + ADD SECTION at the end. Ctrl+Z / Ctrl+Shift+Z undo and redo here too.
- Moving a section takes its markers, its lyric lines and the full-line comments directly above it; blank lines and loose comments between sections stay put.
- Lyrics (feature 16): lyric lines (
" 5 words) are coloured (pink quote, cyan position, light words; quotes in the words aren't strings), offered as a snippet when"starts an indented line (and with the marker snippets), and shown in the structured view under their section with the markers, in file order: position and words fields and delete. + LYRIC adds a line a bar after the section's last lyric (lyrics must be in order) and starts editing its words. - Audition: F5, Ctrl/Cmd+Enter or PLAY puts the editor's text (unsaved
changes included) on the stage and starts it from the section at the
caret (text view) or last clicked (structured view, outlined), starting
at the previous section's last bar so the countdown into it shows; a
looporholdin that lead-in bar plays straight through. The first section, or Shift+F5 / Shift+click, starts from the top with the count-in. The caption under PLAY says where it will start ("from Chorus Β· F5"); a song with errors won't play. On stage an AUDITION tag shows; Enter restarts from the same place, Space is Next, and Esc or E (or Next once it has finished) goes back to the editor exactly as it was. Files opened from Finder while editing only join the set list (before, one could replace the editor and lose unsaved changes). Engine:engine_start_at(e, now, beat); driver Start commands carryfromandrelease;player.audition_startpicks the beat. - Practice tempo while auditioning: a PRACTICE slider on the stage
(40%β150% in 5% steps, a mark at 100%), dragged or clicked, or
[/](also-/=),0for full tempo. It scales the stage's clock, so beats, countdowns, holds and Next all stay in step and a change applies at once, mid-song too; the TEMPO header shows the practice BPM. The setting stays for later auditions in the session and the editor's PLAY caption mentions it ("from Chorus at 80%"). Only auditions use it; a song played from the set list is always at full tempo. A song with a backing track (audio:) hides the slider and auditions at full tempo, since the audio can't follow it.player/practice.odin(tested). - Backing track (feature 11): the SONG form has an AUDIO row: the path
(typed), CHOOSE⦠for the native picker on macOS (NSOpenPanel, audio
types;
pick_audioincmd/player/open_darwin.odin), and AUDIO-OFFSET ("detect" when empty) with FIND. FIND or Ctrl/Cmd+B (both views) fits the song to the track withalignand writesaudio-offsetas one undo step; the note shows the fit's summary (offset, any tempo that sounds off). It blocks the UI for the few seconds the fit takes, after showing "Finding the beatβ¦". A file in the song's folder (or below) is written relative to it (player.audio_name). Choosing a track (CHOOSEβ¦ or theaudiocompletion) finds the offset at once when the song has noaudio-offsetyet; one already set is left alone. Detection (align.detect) first finds the leading silence (RMS under -50 dBFS), then fits the beat grid, at the tempo each section is played at, to offsets from a bar before the first sound to two bars after it (never before the audio's start). It sets the header only when one offset beats the runner-up's whole-song fit byOBVIOUS_MARGIN(4 %); otherwise the note gives the best guess and the alternatives and sets nothing. Either way a section that sounds 2 % or more off its written tempo is warned of ("Warning: Verse 2 sounds like 75 BPM (written 72)"). On the real stems (Flowers, Falling Slowly) a beat or bar either side fits within 1-3 %, so they come out as suggestions, the right one (Falling Slowly 0.004 s, Flowers 0.075 s) among them but not always first (ALIGN-I4).vct alignand the player's automatic offset use the same window. Completion offersaudioandaudio-offset; acceptingaudioon macOS opens the picker and fills the value. The preview panel shows the track's name and offset. Auditions play the track (feature 11). The row is hidden on the web (no backing track there); off macOS the path is typed. - Test flags for scripted runs:
--edit FILE|new,--edit-view text|structure,--goto LINE,--type SEC TEXT. - 20 tests (
tests/editor_test.odin): lexing, line round trips, buffer editing/undo/dirty tracking/UTF-8 columns, section moves (with markers and comments), duplicate/delete/add, headers, field rewrites, completion contexts, the template; lyric lexing/formatting, moving and adding lyrics, the lyric snippet; audition start points, a loop lead-in playing through,engine_start_at; practice steps and the slowed clock;audio/audio-offsetcompletion, relative audio paths. Checked under Xvfb with xdotool: field edits, toggles, grip and map drags, completion, Alt+Up, + MARKER / COPY / delete / undo, Save as for a new song, the unsaved-changes prompt, editing from the stage.
Issues / questions
- EDIT-Q2: Save as uses a typed path, like adding songs (SET-Q3): raylib has no native file dialog.
- EDIT-I5:
The audio picker and FIND haven't been run on a Mac with a real track.Run on a Mac with real key and pointer events (CoreGraphics): CHOOSEβ¦ opened the native panel, ββ§G and the path chosest.wav, FIND ran on its own and reported, and βS saved. Three things it found, fixed: after the panel closed the keyboard didn't come back to the window, so βS or Ctrl+S did nothing until it was clicked (0 saves in 3 without the fix, every time with it β the window is made key again after either native panel); a track next to the song was stored as a full path when the picker's path was the resolved one (/private/tmpfor/tmp;audio_namenow also compares with symlinks resolved,real_path); and FIND's long note was drawn over the caption under PLAY (the caption now gives way while a note shows, and the note is capped in width). Still: a new, unsaved song stores the picked track's full path, and Save as doesn't make it relative. - EDIT-I1: Only tested under Xvfb. Cmd shortcuts on macOS are mapped (Super counts as Ctrl) but untested; dead keys and IME input are untested.
- EDIT-I2: A structured edit rewrites its line in a standard form: the
name and length columns and any trailing comment keep their place, but
modifiers are reordered (xN, meter, tempo, flags, ahead, say, show) and
separated by two spaces, and
X2becomesx2. - EDIT-I3: Lines rewritten by structured edits or moves get LF endings in a CRLF file. Tabs count as one column.
- EDIT-I4: Structured-view fields only select-all on click; there's no mouse caret placement or partial selection inside a field (arrows, Home/End work).
Remaining
-
EDIT-Q2.
-
Check on a Mac (EDIT-I1).
-
Find / replace.
-
Maybe: warn about FMT-I1 (
Verse 1with no bar count) in the editor.
18. Video render
β
β cmd/player/render.odin (--render and its options in
cmd/player/main.odin), canvas_fit's g_target in cmd/player/ui.odin
Done
vct-player --render OUT song.vctplays the song start to finish into a video: the samedrawas the window, into a render texture, on a fixed clock (framenis exactlyn / fpsseconds of engine time, so nothing drifts), piped as raw RGBA toffmpeg. Runs as fast as it can draw; nothing is shown and the set list isn't touched.mise run render.- The video opens on the stopped stage for
--leadseconds (2, rounded to whole frames), then starts as if Enter were pressed (the 0.15 s start delay included), and ends--tailseconds (3) after the song finishes, showing the finished stage, or when the audio ends if later. - Audio: the song's
audio:file (or--audio FILE; any format ffmpeg reads, a URL in the header is passed to ffmpeg as is) placed so thataudio-offset(or--audio-offset) falls on bar 1, i.e. delayed or cut from the front. Length fromffprobe. - With no
--audio-offsetand noaudio-offset:header, the offset is found by beat detection (align/), printed asaudio-offset N (detected). --render OUT --set NAMErenders a set: OUT is a folder, and each song gets its ownNN Title.mp4in set order, played as the set plays it (arrangement, key, tempo). One hidden window and render texture serve all the songs; a song that can't be played is reported and skipped, and the exit code is non-zero if any failed.--audio/--audio-offsetapply to every song, so they are best left out for a set.- Loops and holds are released as soon as they start (a hold before its
hold point, so it never pauses), so the video follows the written
timeline and the audio.
--next SECpresses Next at given video times instead; after the last one releasing is automatic again, so a render always ends (and gives up after 6 hours). - Drawn at 2Γ and scaled down by ffmpeg (area filter), standing in for the
window's 4Γ MSAA.
--fps(60),--size WxH(1920x1080, even sizes)..mp4/.mov/.mkv/.m4vget H.264 (CRF 18) and AAC 192k with faststart. - Checked under Xvfb: a FLAC track of beeps from 1.0 s with
audio-offset: 1.0lands its first beep on the frame of bar 1's downbeat flash (4.15 s with the default lead, 2.65 s with--lead 0.5, 2.15 s with--lead 0, the stopped stage showing until the start); a loop and a hold play through;--nextgives a second loop pass and a manual hold release (lengths as the timeline predicts); frames match--shotscreenshots of the app.
Decisions
- VID-Q1: Show anything before the start? β Yes: the stopped stage, for
a configurable time (
--lead SEC, default 2).
Issues / questions
- VID-I1: Slow without a GPU: in this container (software OpenGL under
Xvfb) the example song's 4:34 at 1080p60 took 19:50, about 4.3Γ its
length (drawing at 2Γ, i.e. 4K, dominates). Not yet timed on a Mac. If it's
slow there too, try
--fps 30, a smaller--size, or make the supersampling optional. - VID-I2: With
--nextmaking extra loop passes or holds, the audio doesn't follow (it plays once from its offset). Same question as AUDIO-Q1. - VID-Q2: Practice tempo / a speed option for renders? Not offered yet.
19. Stage sync
π§ β stagesync/ (protocol, clock, server, web/WebSocket, client,
addresses), stagesync/web/index.html (web client), cmd/player/sync.odin
sync_style (the theme and phone layout), qr/ (QR codes),
cmd/player/sync.odin, cmd/player/phone.odin (address and QR code),
cmd/player/clients.odin (connected screens and host permission),
cmd/vct-sync/, docs/sync.md, tests/sync_test.odin
Lets other screens on the network (a phone or tablet browser, an app, another
computer) follow the player. The host is in charge and clients are dumb: each message
is the whole stage at one moment, stamped with the server time it happens
at. Clients don't read songs or run the engine. Pure Odin (core:net).
Decisions
- SYNC-D7 (was SYNC-I4, the web client used the system font): the player
serves the fonts. A
stylemessage (pushed first to each WebSocket client and again on every theme change, and atGET /style) carries the phone theme's colours, section colours, sizes, the paths of its fonts (/fonts/sans-500.ttfβ¦/fonts/mono.ttf, the theme's files or the built-in Barlow and Share Tech Mono) and the phone layout's grids. No internet is needed at a venue. Replaced font files stay in memory until the server stops, so a slow phone mid-download never holds the lock. - SYNC-D8: Phone layouts β a
mobilesection in layout files: a CSS grid of the web client's panels (header,map,state,section,beat,next,cues,lyrics) per shape (portrait, landscape, and each without lyrics) plus a text scale per panel.grid-template-areasis expressive enough for stacks, columns and spans, needs no new layout engine in the page, and is checked strictly by the player (known panels, rectangular rows, and track lists limited to safe characters). The page's own CSS islayouts/stage.json5's grids, and a test keeps them equal. Settingsmobile.layoutcan take the phone layout from a different file than the stage's (which may then have onlymobile). - SYNC-D1: Ableton Link? β No. Clients only follow the host, so there's no need for peers to agree on a tempo. Link is C++ and GPLv2+, and its clients would still need the song and the engine. (Replaces the earlier Link core.)
- SYNC-D2: Transport β JSON over UDP, port 47800. UDP because a lost
beat should be skipped, not resent late. JSON so any app can speak it and
nc -ucan read it. Discovery by broadcast ping. - SYNC-D3: Timing β NTP-style pings: use the offset of the
lowest-delay sample of the last 16. The driver polls the engine 0.1 s
ahead (
Driver.lookahead, only while sync is on) and taps each event as it's polled, so beats reach clients before they're due. - SYNC-D4 (was SYNC-Q1): Browsers? β Yes: WebSocket and a built-in web
client. Uses the same port number over TCP.
GET /serves the page,/wscarries the same JSON messages,/songserves the song map. HTTP and RFC 6455 are done by hand: SHA-1 and base64 come fromcore. There's a thread per connection, and sends come from the publishing thread so nothing waits. - SYNC-D6: Sync on by default? β Yes. It's turned on and off from the
home screen only (Shift+S, or the Enable Sync buttons);
--no-syncstarts with it off. It isn't remembered between launches. - SYNC-D9: Can a client control the player? β Only one the operator has
allowed, and only the things the player's own keys do. The player lists
the screens following (H) and the operator turns HOST on for a row;
nothing may control it until then, and it can be taken back the same
way. Permission goes by the
clientid in the pings, not by address or connection, so a phone that reloads its page or drops off Wi-Fi keeps it; a client that sends no id can't be given it at all. Commands are the operator's own actions (play,stop,next,resume,section,song,close), applied through the same procedures as the keys and the Launchkey pads, so a client can't do anything the operator couldn't. The alternative β a client asking for control and the operator approving β was dropped: the operator can see who is on the list and has to act either way. - SYNC-D5 (was SYNC-Q2): More than the current moment? β Yes: a
songmessage with the played timeline (every section pass with bars, tempo, time signature, flags), all lyric lines and markers. States point into it (song,section_index,next_index,lyric_index,marker_index). It's pushed over WebSocket, and fetched by UDP clients from/songbecause it's too big for a datagram.
Done
- Server: a thread answers pings straight from the socket (accurate
stamps). States are sent to every client that has pinged in the last 5 s,
the latest is repeated every 0.5 s, and new clients get it straight away.
vct-player --sync [--sync-port N]. - State: song, play state (idle/stopped/playing/holding/finished), section, next section, bar, beat, countdown, bars left, loop pass and whether it's unreleased, accent, tempo as played (with the practice/MIDI speed), beat duration, marker, lyric line and next line.
- Web client (
stagesync/web/index.html, built into the player, no dependencies). While a song is on stage it holds a screen wake lock, so the display doesn't dim and the phone doesn't lock (re-taken when the page returns to the foreground; browsers without the API just dim as usual). It shows the song map with progress through the section, the section name with the countdown to its right, bar, beat dots (just the current beat lit in bright green, red on the downbeat, beside a large beat counter), next section, a cue area (the marker on screen and the next section's markers,Drums in on 5), and the full lyrics scrolling with the current line lit and the next one half-lit. Every row has a fixed size, so nothing moves as its contents change: long section names shrink to fit, and the downbeat flash is drawn over the page rather than as a border that pushes it in. The flash lasts at most 0.12 s. Plus the stage palette, landscape and portrait layouts, reconnects,?offset=MSand?debug. It declares itself dark (color-scheme), so it looks the same in dark mode, and setsdarkreader-lockso Dark Reader leaves the song map's colours and playhead alone. Each state goes up on the frame nearest its time (within half a frame). - Metronome mode: M shows only the song name, the beat
counter with its dots and the red flash (
app.metronome; the other widgets are skipped). It is sent asmetronomein everystate, and the web client then shows only a metronome (a tap doesn't close it). The web client also has a Metronome button in its header: a black screen that flashes white on beat 1 and grey on the others, fading out through the beat, closed by a click. Not checked on a phone. - Chords on the clients, and both toggles everywhere (LYR-D7): the
songmessage carries each lyric line's chords (lyrics[].chords,colin characters so a client can cut the text at it), each section's chord line (sections[].chords, for the sections nobody sings in) andsections[].def(which section line of the file a pass came from, so a client lists a section once instead of guessing fromfirst_lyric).state.chordssays whether the player is showing chords. The web client draws each chord above the words it falls on β oneinline-blocksegment per chord, the chord a block inside it, so a chord wider than its words spreads them as a printed sheet does (the stage keeps the words still and nudges the chord instead, because its lines are shrunk to a fixed width) β lists a chord row for sections with no words, and has a Chords button beside Metronome that follows the player and can be overridden until the player changes its mind. Checked with headless Chrome against a running player: chords on (segments, chord row, button lit) and off from the player's side (#lines.nochords). The button's rules (the viewer's choice holds until the player changes its own, then it follows again) were checked by running that part of the client's script under node with a stub for the two elements it touches; a click in a real browser isn't checked.vct-syncreads the longersongmessage unchanged (checked against a player: 10 sections, 60 lyric lines, beats under a millisecond late). Its styles use the theme's variables (SYNC-D7), so chords follow a light theme too. - Themes and layouts on phones (SYNC-D7, SYNC-D8): the web client's
panels are now children of one CSS grid (the
main/#nowwrappers are gone), placed by name; colours are CSS variables set from thestylemessage; text on the marker pickstextorinkfor contrast; font sizes and fixed row heights multiply by the theme's and the panel's text scale; the page switchescolor-schemeandtheme-colorfor a light theme.layouts/focus.json5gives phones a single centred column with the section, beat and next section big and no lyrics. Checked in headless Chromium (landscape 1180Γ820 and phone 390Γ844): the default look matches the old page's positions (now in Barlow), anddaylight+focusapplied live when the settings file was edited. Test:/style,/fonts/β¦and replacing the files. - Set list (
setlistmessage,GET /setlist): the player's set list (title, artist/version/arrangement, key, tempo, meter, whether it plays, the selected row and the song on stage) is pushed to WebSocket clients whenever it changes and to each new one. The web client shows it, selected row highlighted, while no song is on stage (the player's pre-play screen). Not yet shown on a phone while a song plays, and UDP clients must fetch/setlist. - Sync is on by default (SYNC-D6). Shift+S turns it off and on at runtime,
on the home screen only, and
--no-syncstarts with it off. Turning it on mid-song picks the song and beats up straight away. If the port is taken (a second player, say), it stays off and the footer says why in red; the launch no longer fails.--syncis still accepted. - Phone link (
cmd/player/phone.odin): the home screen's footer (left-aligned) shows the page's address and how many screens are following, or an Enable Sync button while sync is off. Q shows a QR code for the address full size, with any other addresses listed: on the home screen (an Enable Sync button instead while sync is off) and on the stage while sync is on (off, Q does nothing there). Not in the editor or the path prompt, where it's a letter. Q, Esc, Enter or a click closes it, and Left/Right shows one of the machine's other addresses instead (SYNC-I7). Checked with real keypresses (xdotool on Xvfb). The address is the best LAN IPv4 (private ranges first; loopback and link-local left out), checked again every 5 s. It comes fromcore:net's interface list plus the default route's source address (a connected UDP socket viacore:sys/posix), becauseenumerate_interfacesis stubbed out on Linux in this Odin. The QR encoder isqr/, pure Odin. Checked by decoding 60 codes (30 versions, all 8 masks, all levels) and the player's own screenshots with zxing-cpp. - Client (
stagesync/client.odin, non-blocking, called once a frame): finds the server by broadcast, follows a server restart (session), drops repeats and late states (seq), and lets a stop overtake beats sent ahead.fetch_songgets the map over HTTP.vct-sync, the terminal client, prints the song map when a song goes up and each state when it comes due, with how late it was. - Checked end to end under Xvfb, on loopback and by broadcast discovery:
beats print 0β2.5 ms after they're due, and loop release, hold, resume and
finish all reach the client. The web client was checked in headless
Chromium (landscape and phone): it stays connected, follows the beats and
draws each state within 7 ms of its time. Tests cover the protocol, the
clock maths, the peer table, the state and song built from a track, HTTP
request parsing, the WebSocket accept key and framing, a real UDP server
and client on loopback, and a real WebSocket session (handshake,
greeting, ping/pong, push) plus
/song. - Fixed:
odin test testshung on macOS. The web accept loop relied on a receive timeout on the listener to noticequit, but macOS ignoresSO_RCVTIMEOonaccept()(and neithershutdownnorcloseon a listener wakes it), soserver_stopwaited forever on the accept thread. The listener is now non-blocking and the loop sleeps 20 ms when there's nothing to accept; accepted sockets are set back to blocking, since macOS passes non-blocking on to them. - Connected screens and host permission (SYNC-D9,
cmd/player/clients.odin): H on the set list, the pre-play screen, or the stage while sync is on shows the screens following β the name each gives itself, whether it's a browser or an app, its address β with HOST on or off per row (its number key, or a click), and H, Esc, Enter or a click outside closes it. The set list footer says how many screens follow and, in a tag, how many can control; the stage's help line hasH screens. Pings now carry aclientid and aname(protocol.odin, docs/sync.md Β§Pings); the server keeps the ids it has granted, tells a client with agrantmessage, and queues thecommandmessages from granted clients for the player, which applies them once a frame (remote_input) through the same procedures as its keys. The web client names itself from the browser (?name=NAMEoverrides) and keeps its id in the browser's storage; granted, it shows a HOST tag and a bar of Play / Next / Stop / β (and Resume while paused), and a tap on the song map jumps to a section, a tap on a set list row picks a song.vct-syncnames itself andclient_commandsends commands, for client authors. Checked end to end on loopback: avct-sync-style client and a headless Chrome page were each listed, granted from the panel, told so, and their Play/Stop reached the player (it paused and stopped, and the pages saw it). Tests cover the peer table with ids and names, the command names, and permission over both UDP and WebSocket (a command before and after the grant).
Issues / questions
- SYNC-I8: Host permission isn't remembered between launches, by design for
now (a client keeps it only while the player runs). If a venue always has
the same phone running the show, saving the granted ids beside
displays.txtwould save handing it out each time. - SYNC-I9: Up to 16 clients can hold permission at once (
MAX_GRANTS), and the list shows the first 8 rows with a count of any more. Plenty for a stage; both are one constant each. - SYNC-I10: The player's list and the web client's controls haven't been tried on a real phone yet, only in headless Chrome.
- SYNC-I1: Resuming from a hold (and stopping) happens at the press, so it reaches clients one network delay late (about 9 ms on loopback, once through the frame loop). A short resume delay would fix it (ties in with ENG-Q2).
- SYNC-I2: While sync is on, Next must come 0.1 s (the lookahead) before the end of a loop pass to release it, not one frame before (ENG-Q7).
- SYNC-I3: what
mise run vetchecks and CI doesn't, all in one place now that CI-I1 and SCORE-I7 are in: addodin check stagesync -vet -no-entry-point,odin check qr -vet -no-entry-point,odin check align -vet -no-entry-point,odin check sections -vet -no-entry-point(feature 26),odin check cmd/vct-sync -vetandodin check cmd/player -vet -target:js_wasm32 -no-entry-point -define:RAYLIB_WASM_LIB=env.oto the Vet step of.github/workflows/test.yml. Workflow files need a manual commit. - SYNC-I5: No HTTPS/WSS. Fine on a local network. A page loaded over HTTPS
from elsewhere couldn't connect to
ws://(mixed content), which is why the player serves the page itself. - SYNC-I7:
A machine on two networks (wired and Wi-Fi, say) shows the default route's address, with no way to pick another.Resolved: Left and Right on the QR screen walk the addresses (phone_pick), and the footer and code follow. The choice sticks across the 5 s refresh while this machine still has that address (stagesync.address_index), and falls back to the best one when it doesn't. It isn't saved between runs, and the stage's web clients aren't told to reconnect to the new address.lan_addresseswas run on the Mac for the first time here: it gives two (192.168 Wi-Fi first, then a 100.64/10 one), so the picker has something to pick there. The keypress itself hasn't been tried by hand. - SYNC-I6: Up to 32 web connections, each with its own thread. Plenty for a stage. A single poll-based loop would scale further if ever needed.
- SYNC-I11:
A web connection sent its pong before noting who the ping said it was, so anything that had seen the pong (the test, on a slow runner) could list the client before it had a name. The connection now notes it first, then sends the pong, then any host grant, so what goes over the wire is in the same order as before. Shown by holding the connection for 50 ms after the pong: the old order failed the test 20 times out of 20, the new one none.sync_websocket_sessionfailed now and then in CI (id 0, no name for the browser client).
Remaining
- Web client (SYNC-D4) and the song map (SYNC-D5).
- Native client apps, if the web page isn't enough.
- Show the player's address on the home screen, and a QR code on Q.
- Show them on the stage too (a corner widget), for joining mid-song.
- A sync on/off switch (Shift+S, Enable buttons) and a client count in the player's UI.
- SYNC-I7: pick which address the phones are told (Left/Right on the code screen).
- List the connected screens and let the operator give one host permission (SYNC-D9).
- SYNC-I1, SYNC-I3, SYNC-I10.
20. Web version
π§ β scripts/build-web.sh (mise run build:web, mise run web to serve),
web/index.html, cmd/player/main_web.odin, cmd/player/open_js.odin,
cmd/player/midi_js.odin, cmd/player/sync_js.odin, player/file_js.odin,
player/arena_js.odin
Done
- The player (set list, stage, editor) compiles to WebAssembly:
odin build cmd/player -target:js_wasm32 -build-mode:obj, linked by Emscripten'semccwith Odin'svendor/raylib/wasm/libraylib.web.a. The output inbuild/webis a static site:index.html(fromweb/index.html),index.js,index.wasm,index.data(the example songs, preloaded at/examples),odin.js(the Odin runtime's imports) and the icon. - Shared code no longer imports
core:os(which won't compile for js): files and paths go throughplayer/file.odin(core:os, desktop) orplayer/file_js.odin(Emscripten's C library); entries and the editor useplayer.Arena(virtual memory on desktop, the runtime's heap arena on the web, where there is no virtual memory).main.odinholds the sharedstartupand per-framestep;main_desktop.odinthe command line, the window loop and--render;main_web.odinthe exportsindex.htmlcalls, a malloc-backed allocator (Odin's own wasm heap would fight Emscripten's) andemscripten_set_main_loop. - Storage:
/datais IndexedDB (IDBFS), synced in before start and out after each save, so the set list (/data/vct-player/setlist.txt) and songs (/data/songs/) survive a reload. First visit: the set list starts with the bundled examples. - Adding songs: dropping files on the page (raylib's drop, copied into
/data/songs) or Add song / A (the browser's file picker,vctPickFiles). New songs from the editor save to/data/songs. - The canvas follows the browser window.
mise run vettype-checks the web build too. - Checked in headless Chromium (SwiftShader WebGL): the home screen with the examples, open and play a song, the editor, the file picker, resizing, and a deleted song staying deleted after a reload.
Issues / questions
- WEB-I1:
mise run buildnow needsemcc(viabuild:web); without Emscripten installed it fails at that step.scripts/install-odin.shdoesn't install emsdk. - WEB-I2: No MIDI (
midi_js.odinis a stub; Web MIDI could feedplayer.Midi_Parser), no--render, no command-line test options, and no stage sync server or phone link (sync_js.odinstubssync.odinandphone.odin: a page can't listen on UDP/TCP). A web page could still be a sync client of a desktop player (feature 19). - WEB-I3: Songs can't be saved back out of the browser (download / export),
and a backing track named by
audio:is only found if it was added to/data/songsnext to the song. - WEB-I4: Not checked on a real GPU, a phone or a tablet, or on a HiDPI
screen (
WINDOW_HIGHDPIis off on the web; text may be soft on Retina). - WEB-Q1: Where should the web version be hosted (GitHub Pages from CI)?
- WEB-I5: The web version reads
/data/vct-player/settings.json5(written on first visit) and the built-in themes and layouts, but a browser user can't edit that file or add themes; it needs a picker (DISP-I8, now the layouts and themes screen's USE, not yet tried in a browser) or a?theme=URL parameter. Not run in a browser since themes (no emcc here); the object file builds andmise run vetchecks it. - WEB-Q2:
Is the wasm build worth keeping, or is it the way to embed the player in native mobile apps?Resolved (MOB-D1): it is neither the mobile mechanism nor dead weight β it is the web version, and it is why the shared code already compiles for iOS. Kept. See feature 28.
Remaining
- Build the web version in CI (needs emsdk; workflow files need a manual commit) and decide WEB-Q1.
- Export / download a song from the editor (WEB-I3).
- Web MIDI (WEB-I2).
- Check on a Retina screen and an iPad (WEB-I4).
21. Language server
π§ β lsp/ (server.odin: JSON-RPC dispatch and documents; features.odin;
types.odin), cmd/vct-lsp/ (stdio framing), editors/nvim/,
editors/zed/, editors/README.md, vct/format.odin (formatter);
tests/lsp_test.odin, tests/format_test.odin. Merged in #56.
vct-lsp, an LSP server so .vct songs can be written in Neovim, Zed or
any other LSP editor with the same checks and help as the built-in editor.
Decisions
- LSP-D1: Written in Odin on the editor's model: each open document is a
player.Editor, so lexing, parsing, completion (player.complete), section blocks and the status strip's section summary are shared, not copied.problem_rangeandsection_infomoved fromcmd/player/edit.odinintoplayer/editor.odinfor this. - LSP-D2:
lsp/does no I/O (handle(server, body) -> []string), so tests drive it with JSON strings;cmd/vct-lsponly reads and writes theContent-Lengthframing. - LSP-D3: Highlighting is semantic tokens with standard token types (no
tree-sitter grammar). Neovim colours them by default; Zed needs
"semantic_tokens": "full"for VCT (Zed's language config makes the grammar optional).
Done
- Full document sync; diagnostics published on open and change (whole-file problems on line 1), cleared on close.
- Semantic tokens (full) from
vct.lex's spans. - Completion from the editor's autocomplete, as text edits over the word
before the caret; the editor's selection becomes a snippet placeholder
when the client takes snippets. Trigger characters
>,",@. - Hover: header keys and modifiers (from format.md), and on section, marker and lyric lines where the section falls (bars, start, tempo, meter).
- Document symbols (headers; sections with markers and lyrics as children) and folding ranges (a section with its markers and lyrics).
- UTF-16 positions by default, UTF-8 when the client offers it.
shutdown/exit,MethodNotFoundfor other requests.- Fixed: completion with nothing to offer (e.g.
"typed in the header) crashed the server, leaving Zed with stale diagnostics and no colours. Checked by replaying typing (a character at a time, then deleting) on every line of the examples and the test corpus. - Fixed: Zed asks for a file's semantic tokens only once, so a reopened
file (or one opened after the first) had no colours. After each open the
server sends
workspace/semanticTokens/refreshwhen the client supports it, and ignores the client's replies to its requests. - Format v0.2: completion and hover know the new headers (
vct,capo,lang,key[Name],ccli,copyright,tags,source,include) and section modifiers (dyn=,feel=,key=,lead=,harm=,band=,pulse=,note=,tacet,half);arrangementhover explains the list items (x2,*4, groups) and the first-is-default rule.section_infolives inplayer/editor.odin. (The earlier[Name]/order:syntax was replaced by thesearrangement:lists before it reached a release, so the language server no longer mentions blocks.) - Neovim: a plugin directory (
ftdetect,ftplugin,lsp/vct.lua,plugin/vct.lua) for 0.11+. Checked headless in Neovim 0.12.3: the filetype is set, the server attaches, diagnostics, semantic tokens and completion arrive. - Zed: a dev extension (
extension.toml,languages/vct/config.toml,semantic_token_rules.json, a Rust shim usingzed_extension_api0.7 that runsvct-lspfrom PATH). Tried in Zed 1.22: colours, diagnostics, and reopening files (after the two fixes above). - Formatting (
textDocument/formatting, LSP-Q1):vct.formatlines a song up in columns, asexamples/example-song.vctis written: header values at column 12 or past the longest key; section lengths at column 15 or past the longest name; each modifier position in its own column, 3 spaces past the widest modifier with another after it; marker and lyric text at the length column, indented 2; a trailing comment keeps its column when the line fits before it, else follows after 2 spaces; no trailing whitespace, one final newline, CRLF and a BOM kept. Only the whitespace between tokens changes (names, values, marker text and lyrics verbatim; lines never added, removed or reordered except trailing blanks; lines with no name or length only trimmed), andformatparses the result and backs off unless the song, its problems and their lines are identical. Formatting twice changes nothing (checked on every example and corpus file). On save: Neovim's ftplugin (off withvim.g.vct_format_on_save = false; checked headless) and Zed by default (format_on_save, formatterlanguage_server). mise run build:vct-lsp,mise run lsp;vetcoverslspandcmd/vct-lsp. 8 LSP tests and 4 formatter tests (tests/lsp_test.odin).
Issues / questions
- LSP-I1:
The Zed extension hadn't been tried in Zed.Resolved: tried in Zed 1.22, which found the two bugs fixed above. - LSP-I2:
CI didn't vetResolved: added to the Vet step oflsporcmd/vct-lsp..github/workflows/test.yml(committed by hand). - LSP-I3: The hover's section summary needs the song to parse; with an error anywhere, only header and modifier hovers show.
- LSP-Q1: Formatting? β Yes, on save, in the style of
examples/example-song.vct(see Done). Formattingexample-song.vctitself only moves its header values one column, pastarrangement:. - LSP-Q3: Use
vct.formatelsewhere too: avct fmtcommand (and a CI check that the examples are formatted), and a Format command in the player's editor? - LSP-Q2: Ship
vct-lspin the macOS app or a release download, and publish the Zed extension, so users don't have to build them?
Remaining
- Decide LSP-Q2 and LSP-Q3.
22. ChordPro import and export
β
β chordpro/import.odin (ChordPro to .vct), chordpro/export.odin
(.vct to ChordPro), both with no I/O; cmd/chordpro/main.odin (the CLI);
tests/chordpro_test.odin with fixtures in tests/chordpro/
Done
chordpro import FILE.chowrites<title>.vctnext to it (-o FILE,-for standard output). It keeps the title, artist (or subtitle), key, capo, tempo, time, copyright and ccli tags (the first of each;{meta: name value}too), sections from{start_of_X}environments (with or without a label; short formssov/soc/sob),{chorus}(repeats the last chorus) and comment headings outside an environment ({c: Bridge x2}is a section played twice). Unlabelled verses are numbered by their place among all the verses. Lines outside any section start one (Introfor chords before anything, else a verse), and a blank line ends it. Other comments become markers, or#comments at the top before the first section. Tab, grid, abc, ly, svg and textblock environments are skipped, and[*annotations]are dropped.- Bars are guessed: each line takes one bar per chord (
--bars-per-chord N; a line with no chords takes one), its words go at its first bar, and its chords become a> |chord line and stay in the words, against the word each one falls on ([C]I don't know you, format Β§5.1): the chord line gives the bars, the words give the positions, and the stage uses both. The spacing that lined words up under their chords goes (runs of spaces become one, and a space straight after a chord goes, so the chord sits against its word β in.vcta bracketed word with a space after it is a[tag]). A comment at the top says they're estimates.--tempo,--time,--keyand--count-inoverride or add headers; a file with no{tempo}needs--tempo. As withug2vct, the output is parsed before it's written. chordpro export FILE.vctwrites<title>.cho. Standard tags for title, artist, key, capo, tempo, time, copyright and ccli; each section as averse,chorus(Chorusβ¦, Refrainβ¦) orbridgeenvironment labelled with its name. A line that says where its chords go (Lyric.chords) is written with them where they belong; one that doesn't gets the chords of the bars it covers (from the section's chord lines, first pass), spread evenly over its words, as before. Chords before the first line of words become a chord-only line.- Round trip: everything else goes into
{x_vct_header},{x_vct_section},{x_vct_line}(markers, chord lines, lines in other languages, lyric lines with brackets or no words) and{x_vct_at}(a line's position and[tag]). Import uses them in place of its guesses, so export then import gives the samevct dump(tested onfeatures.vctandexample-song.vct, both of which now have chords in their words).{x_vct_at: 1 chords}marks a line whose chords are where they belong, so the import keeps them; without it the chords on the line are the spread approximation and are dropped, as before.
Issues / questions
- CP-I1:
The CI workflow didn't vetResolved: added to the Vet step ofchordproorcmd/chordpro..github/workflows/test.yml. - CP-I2: Export refuses songs with
include:. The raw lines it copies would have to come from the included files. - CP-I3:
Exported chord positions within a line are approximate:Resolved for lines that say where their chords go (LYR-D6); a line with only bar-level chords is still spread evenly, since nothing knows better..vctknows each chord's bar, not its word. - CP-I4: Import drops a
#that follows a space in words and headers, because.vctwould read it as a comment.
Remaining
- Read
{start_of_grid}bars as chord lines (they give real bar counts). - Mid-song
{key},{tempo}and{time}askey=,@TandN/Don the section (only the first is used now). - Import from the player's library screen, not just the CLI (Β§24).
- Section labels with a repeat (
{soc: Chorus x2}) now play the section that many times, andOptions.fromnames the source in the note at the top.
23. Chord sheet conversion
β
β sheet/sheet.odin (the parser, and .vct by way of ChordPro),
sheet/gp5.odin (Guitar Pro 5), both with no I/O; cmd/sheet/main.odin (the
CLI); tests/sheet_test.odin with a fixture in tests/sheet/
Done
sheet vct|gp5 FILE.txtreads a chords-over-words sheet:[Name]headings (or a known name ending in:), a line whose words are all chords is a chord line, and one directly above words goes with them. Chords keep the column they were written at, so each one gets the words under it.vct: the sheet is written as ChordPro (chords inline at their columns, one{start_of_verse: label="β¦"}per section so any heading is kept as written) and handed tochordpro.to_vct, so bars are as in feature 22: one per chord (--bars-per-chord), one for a line with no chords, and the chords stay over the words they were written over (feature 22), which is what the sheet's columns were for. Chords above the first word of an indented line land at its start, so two can share a column ([C/F][C]Words fall through me); the stage draws them side by side.--tempois required;--titledefaults to the file name. The output is parsed before it's written.gp5: a GP 5.10 file, one guitar track, a bar per chord. Each bar is one rest (whole, or dotted for 3/4 and 6/8) carrying the chord as a diagram with a name and no fingering, and the words under the chord as beat text; a section is a rehearsal marker on its first bar. Header: title, artist, tempo (120 if none), time, key signature. Text is written as Windows Latin-1 (other characters become?).- Checked by reading the output with pyguitarpro: every bar's chord, text,
marker and duration come back, and rewriting what it read gives the same
bytes. The golden
tests/sheet/falling-slowly.gp5was made that way; the test only compares bytes, so rerun that check ifgp5.odinchanges.
Issues / questions
- SHEET-I1: Not opened in Guitar Pro itself (no copy here), only in pyguitarpro. Try the golden file in Guitar Pro or TuxGuitar.
- SHEET-I2: The
.vctheader comment says "From ChordPro" because it comes from the ChordPro importer. (chordpro.Options.fromnow names the source; the player's import, Β§24, uses it.) - SHEET-I3: The sheet's
x2/(repeat)marks aren't read; a heading such as[Chorus x2]keeps the text as its name. (The player's import, Β§24, reads them:sheet/text.odin.) - SHEET-I4: A one-word lyric line that is also a chord (
A,Am) is read as a chord line. - SHEET-I5: Bars that aren't one rest long (5/4, 12/8) are refused for
gp5.
Remaining
- Add
odin check sheet -no-entry-point -vetandodin check cmd/sheet -vetto the Vet step of.github/workflows/test.yml(CI changes can't be pushed from here;mise run vetalready has them). - Notes in the Guitar Pro file (strummed chord shapes) so it plays back.
- Import from the player's library screen, not just the CLI (Β§24).
24. Import and export in the player
π§ β sheet/text.odin (sheet β ChordPro, no I/O, beside Β§23's parser), player/convert.odin
(format detection, import into the library, export), cmd/player/transfer.odin
(the dialogs), the pickers in cmd/player/open_*.odin;
tests/sheet_test.odin with fixtures tests/sheet/song*
Done
- Research (October 2026): the "chords over lyrics" sheet Ultimate Guitar shows
and SongSelect's lyrics (the same without chords) have no published
spec. The closest reference is ChordSheetJS's
UltimateGuitarParser([Verseβ¦]/[Chorusβ¦]lines,Key: valuemetadata, a line is chords when every word is a chord, chords paired with the words below by column); OnSong documents a similar "chords over lyrics" style withName:headings. SongSelect's text export is only described by third parties (title line, sections, CCLI footer). sheet/text.odin: a chord sheet or lyrics sheet becomes ChordPro (chords merged into the words by column), then.vctthroughchordpro.to_vct; a.vctbecomes ChordPro then a sheet ([Name xN]headings, chords over words,Title:/Artist:/Key:/Capo:/Tempo:/Time:lines first). Headings:[Anything], or a known section name alone (Chorus,Verse 2:). A SongSelect title line andCCLI Song #/Β©footer are read; its licence lines dropped. A lyrics sheet never reads a line as chords.- A sheet with no
[Verse 1]headings at all is split into sections and named from its words first (sheet.with_sections, Β§26), so pasted words come in as Verse 1, Chorus, β¦ rather than one long section. player/convert.odin:detect_format(extension first, then the text: parses as.vct, ChordPro directives or[C]inline chords, chord lines and headings),to_vct(a song with no tempo gets 100 BPM and a comment saying so; a title from the file name when the text has none),library_import_text,export_song,export_name.- Home screen: IMPORT (I) between NEW SONG and ADD SONG (both made narrower): from files (native multi-select picker; the path prompt where there's none) or pasted text (Ctrl/Cmd+V, format chips with the detected one marked, Enter to import). Files whose format can't be told are asked about one at a time with a preview (1-4, S skip, Esc skip the rest). ADD SONG is unchanged (kept as well, decided October 2026).
- EXPORT on a song row (X; the row's timeline is narrower to fit it) and
in the editor header (Ctrl+E; exports the text as it is, saved or not):
chord sheet, lyrics sheet, ChordPro or
.vct, through the native save panel (NSSavePanel) on macOS, a browser download on the web, andStageDisplay/exports/elsewhere. Video stays on the CLI (--render). - Checked by screenshots of the home screen, the import menu, the paste dialog, the ask dialog, the export dialog and the editor header (macOS).
Issues / questions
- XFER-I1: The macOS open and save panels for import/export haven't been clicked through yet (the bare binary can't be driven by computer use); try IMPORT β FROM FILES and EXPORT β a format in the app.
- XFER-I2: The web build's import picker and download (
vctPickImport,vctDownload) type-check but haven't been run (no Emscripten here). - XFER-I3: The package has two sheet readers: Β§23's
parse(used by thesheetCLI andgp5) andto_chordpro(the player's import, which also reads metadata, repeats, bare headings, SongSelect and lyrics-only sheets). They were written in parallel; foldparseontoto_chordproor the other way round. - XFER-I4: Bars from a sheet are guesses, as with ChordPro, and a lyrics sheet gives one bar per line. Exported chord positions within a line are approximate (CP-I3).
- XFER-I5: Dropping a non-
.vctfile on the window is refused (ADD SONG takes only.vct, #77); it could go through IMPORT's detection instead. IMPORT saves a.vctsong found under another extension with a.vctname. - XFER-Q1: SongSelect's real text export hasn't been seen: confirm the title and footer handling against a downloaded file.
Remaining
- XFER-I1, XFER-I2 and XFER-I3.
25. Score import (MusicXML, MuseScore, MIDI)
π§ β score/ (no I/O: zip.odin, xml.odin, a reader per format in
musicxml.odin, mscx.odin, midi.odin, and write.odin for the .vct),
vct/phrase.odin (grouping the words into readable lines) and
vct/reflow.odin (the same on a .vct already written),
cmd/score2vct/main.odin (the CLI), formats .MusicXML, .MuseScore, .MIDI
in player/convert.odin; tests/score_test.odin with fixtures in
tests/score/ (the same song as .musicxml, .mxl, .mscx and .mscz;
the MIDI file is built in the test)
Done
- One model for all three formats: each bar's time signature, length, tempo,
key and rehearsal mark, its chord symbols and lyric syllables (with verse
number and whether a rest comes before), and its repeat signs and volta
numbers.
write_vctplays repeats out (voltas included), splits sections at marks and at changes of tempo, meter or key (the continuation issilent), after a partial bar (N+B; a pickup isPickup 0+B), and merges identical neighbours intoxN. Chords become one chord line per section (a bar with no symbol keeps the chord before). Words become phrases (a new one after a rest, not inside a word, and after a repeat's jump), whichvct.phrase_breaksthen groups into lyric lines that can be read. A repeat's later passes use that verse when a bar has it. Each chord symbol is also written into the words, over the syllable sung at or after it ([D7]the sound, format Β§5.1): a score knows both times exactly, so the stage can show the chords where a song sheet would. A chord that changes while a word is held belongs to what is sung next, and one with nothing sung after it in the line goes at the end of it. Joining phrases into a line carries their syllables along, so the chords stay on their words. The chord line keeps every chord, bar by bar, whether or not anything is sung over it. Checked on a MuseScore export of a pop song (206 chord symbols, 374 syllables) read into the player: the chords sit over the right words on the stage. - Readable lyric lines (SCORE-I3): a phrase between rests can be two beats
long, so a syncopated chart flashed a line a second.
vct/phrase.odinis a Knuth-Plass-style pass over the phrase boundaries that joins neighbours until each line is worth reading. Its budget is in seconds, so it scales itself: a fast song is joined hard, a slow one is left alone. The cost that matters is a line on screen for undermin(3 s) seconds, squared and weighted 14; the rest only choose between groupings that are all long enough β distance fromideal(4.2 s, the long side squared), a reading rate overmax_cps(14 characters a second), where the break falls (free after.!?, 0.4 after,;:, 2 elsewhere) and 9 for leaving a dangling pickup word ("I") at the end of a line. Lines are never joined pastmax_chars(48), to a line that would stay up pastmax(6 s), or across a rehearsal mark unless the first is a pickup in its section's last bar (which carries into the next section anyway, format Β§5.1). The importer's old "a lone pickup word joins the line after it" rule is gone; this subsumes it. The numbers come from subtitling practice (Netflix: 42 characters a line, 5/6 s at the least, 7 s at the most, 17-20 CPS) loosened for singers, who need the line up before they sing it, and from stage-display practice (2-4 lines a slide, a new line at a natural pause). Tried on a real MuseScore chart (Flowers, 118 BPM): 60 lines of which 11 were under 2 s and the shortest 0.51 s, down to 46 with none under 2 s and the shortest 2.03 s; the same words at 70 BPM are barely touched. The timing a join gives up is still on screen as the line'sON Ntag. vct reflow FILE [--write](Β§3) runs the same pass over a.vctthat's already written, so a song imported before this, or one whose lines were typed short, can be fixed without losing hand-named sections. It replaces the words in place (nothing else on the line moves) and drops the lines it joined away. It leaves alone a line that clears the lyrics, says who sings with a[tag], stands for several with/, or carries a comment; and it does nothing at all to a song with errors, one written in more than one language (joining one would pull it out of step with the other) or one thatinclude:s another file. The result is parsed again and thrown away if it wouldn't read back the same, asvct/format.odindoes.- MusicXML: partwise, plain or
.mxl(container.xml's rootfile). Bars, marks (<rehearsal>), tempo (<sound tempo>, else<metronome>), keys and repeats from the first part; chords (<harmony>,kind@textor the usual suffix for<kind>,/bass) from the first part with any; words from the first part with lyrics (its first voice with lyrics, for rests). - MuseScore 3 and 4 (
.mscx,.mscz):metaTags or the title frame; measures withlen=(pickups),TimeSig,KeySig(concertKeyoraccidental),Tempo(quarter notes per second),RehearsalMark,Harmony(tonal pitch classes; 4.5'sharmonyInfo),Lyrics(nois 0-based), durations with dots, tuplets (4'sTuplet/endTupletand 3's by id), grace notes skipped,locationmoves,startRepeat/endRepeatandVoltaspanners. Only each measure's first voice is read. - MIDI (format 0/1, PPQ): bars from the time signatures up to the last note,
tempo, key, markers (or cue points) as section names, lyric events as words
(
-joins syllables; karaoke files' leading-space words;/,\and line ends as line breaks, else a gap of two beats). The title is a conductor track's name. score2vct FILE [-o FILE], and the player's IMPORT (file picker on macOS and the web takes.musicxml .mxl .xml .mscz .mscx .mid .midi). Scores can't be exported. The.vctis parsed before it's used.- Tried on a real MuseScore 4.3 arrangement (a pop song for small ensemble,
5 parts, 94 bars, 8 rehearsal marks, 206 chord symbols, lyrics) exported
as
.mscz,.mxland.mid:.msczand.mxlgive the same.vct(sections AβH with the right bar counts, chords, words at the right beats); the.midhas no markers or lyrics, so it's one 94-bar section at 118 BPM. - Research (October 2026), "use the MuseScore API": musescore.com's REST API
needed a consumer key requested by email, and nothing current shows it
still being offered. Downloads there are limited by account (20 a day;
basic accounts only public-domain and original scores; "official" scores
never). So there's no API to build on, and reading
.msczdirectly needs neither an account nor MuseScore installed. The MuseScore app's command line (mscore -o out.musicxml in.mscz,--score-meta) could convert files we can't read, but it isn't on the web or most stage machines.
Decisions
- SCORE-D1: What to do about marks that are only letters (A, B, C)? β Name the sections from the words they sing (Β§26), where every mark is a placeholder. The comment at the top says the names are guesses.
Issues / questions
- SCORE-I1: Words above the staff (
<words>Verse</words>, MuseScore staff/system text) aren't read, so a score that names its parts that way rather than with marks gets guessed names (Β§26). (Marks that are only letters are now named from the words: SCORE-D1.) - SCORE-I2: D.S., D.C., segno, coda and fine aren't followed; only repeat barlines and voltas. Nested repeats are played as if flat.
- SCORE-I3 (done): lyric lines were phrases between rests, which could be
very short.
vct/phrase.odinnow groups them;vct reflowfixes a song imported before it. Still open: a score whose syllables are allsingle(common when typed in as words) giveswan na, notwanna. - SCORE-I4: Tempo, mark and key changes part-way through a bar are taken at the bar's start. Hairpins, dynamics and other text aren't read.
- SCORE-I5: MIDI files without markers (MuseScore's MIDI export writes none) are one section; bars at the end are counted to the last note's end.
- SCORE-I6: Dropping a score on the window isn't an import (XFER-I5), and the web build's picker change hasn't been run (XFER-I2).
- SCORE-I7:
Resolved: both lines are in the Vet step..github/workflows/test.yml's Vet step doesn't checkscoreorcmd/score2vctyet.
Remaining
- Try IMPORT with a
.mscz,.mxland.midin the app (macOS picker). - SCORE-I7: the two vet lines are in CI.
- Guitar Pro (
.gp,.gp5) import, if wanted: the same model would fit.
26. Section names from the words
β
β sections/sections.odin (the detector, no I/O), used by
score/write.odin (name_sections) and sheet/text.odin
(with_sections, called from player/convert.odin);
tests/sections_test.odin with the fixture
tests/sections/falling-slowly-lyrics.txt
Done
- An import that brings no section names gets them from the words the song
sings. Two cases: a score whose rehearsal marks are all placeholders
(A, B, C, 12), and a chord or lyrics sheet with no
[Verse 1]headings at all. sections.name_blocks: blocks that share at least half their words (SAME, the words in both over the words in either) are the same part of the song. The part sung most often is theChorus(the wordiest where two are sung as often); another part that comes back is aPre-Choruswhen every one of its blocks runs straight into the chorus, aTagwhen every one follows it, else aRefrain. What's left isVerse 1,Verse 2, β¦ in order, except: a block with no words (Introfirst,Outrolast, elseInstrumental), a first block of one line (a pickup word, soIntro), a last block shorter than a verse with a chorus before it (Outro), and the first one-off block with two choruses before it and one after (Bridge). A block that has a name already keeps it, and isn't counted in the numbering.sections.split: blocks for words with no headings. The blank lines where the words have any; where they haven't (words pasted from a search result), a run of lines that come back later is one block and what lies between them another, with two choruses in a row cut apart by the run's period. A run of one line over and over ("Hallelujah" four times) has period 1, so it's kept whole rather than cut into a block a line. Two lines are the same line when they match word for word or share three quarters of their words (LINE_SAME), so "you have a choice" and "you had a choice" are one line.sections.is_placeholder: a name of up to three letters or digits (A, B1, 12, or nothing) that isn't one of the format's own names (Β§4.4). The score import only renames when every mark is a placeholder, so a score that names its parts is left alone β and only when the score has words at all, so a lead sheet with no lyrics keeps its A, B, C rather than trading them forIntro/Instrumental/Outroguessed from nothing.- Score:
silentcontinuations (split for a change of tempo or meter) share the block and the name of the section they go on from, and names this package gave itself (Pickup,Intro) stay. The comment at the top says the names are guesses from the words. - Tried on the MuseScore arrangement of Flowers (Miley Cyrus, small ensemble, marks AβH): Intro, Verse 1, Pre-Chorus, Chorus, Verse 2, Pre-Chorus, Chorus, Pre-Chorus, Chorus β the song's own structure, with the bar counts as before.
- Sheets:
sheet.with_sectionswrites[Name]lines into the sheet beforeto_chordproreads it, under any chord line that belongs with the block's first words, and leaves metadata, the SongSelect title line and the CCLI footer where they are. A sheet that has a heading of its own, or whose words make only one block, comes back unchanged. - The words of Falling Slowly pasted from a web search (30 lines, no blank lines and no headings) import as Verse 1, Chorus, Verse 2, Chorus, Chorus, Outro, one bar a line.
- Tests: the pasted words end to end, a chord sheet with no headings and no
blank lines (chords kept with their words), the naming rules (a wordless
intro and outro, a tag, a bridge, a name kept),
splitboth ways, andis_placeholder.
Decisions
- SECT-D1: Where does the detector live? β A package of its own
(
sections), sincescoreandsheetboth need it and neither should depend on the other. It knows the format's section names throughvct. - SECT-D2: Does
sheet.to_chordproname the sections itself? β No:player.detect_formatcallsto_chordproon single lines to spot a heading, and naming inside it would make every line look like one. The player's import callswith_sectionson the sheet first instead. - SECT-D3: Use the title to pick the chorus (it often holds the title's words)? β No. The wordiest of the parts sung most often is enough on the songs tried, and the title is as likely to be in a verse ("Falling slowly, eyes that know me").
Issues / questions
- SECT-I1: A song whose parts don't come back gets
Verse 1,Verse 2, β¦ for all of them (the test score does): with no repetition there's nothing to go on. - SECT-I2: The thresholds come from two songs (
SAME0.5,LINE_SAME0.75). A verse line sung with one word changed out of five ("light on the water", "dark on the water") stays a verse line, but a long near-repeat could still be read as a chorus line. More imports will tell. - SECT-I3: Chords and bar counts aren't looked at, only words. A long
wordless block in the middle is
Instrumentalwhether it's a solo or a turnaround. - SECT-I4: Only sheets and scores are named. A ChordPro file with no
{start_of_verse}still imports as one section (chordpro/import.odinmakes it), andsheet.parse(Β§23's reader, used by thesheetCLI andgp5) doesn't callwith_sections, sosheet vctdoesn't get names either. Folding the two sheet readers together (XFER-I3) would fix the second. - SECT-I5: Words pasted with no headings and no chords still come out as
.Unknownfromdetect_format, so the format chip has to be picked by hand in the paste dialog. Reading a page of plain words as a lyrics sheet would make the paste one keystroke shorter, at the risk of taking any text for a song. - SECT-Q1: Should a verse sung twice be
Verse 1twice rather thanRefrain? A hymn's repeated verses would read better that way.
Remaining
- Try it on more imports: a hymn with a refrain, a song with a real bridge, a MIDI with lettered markers, and a SongSelect sheet.
- SECT-I4 and SECT-I5 if wanted.
- Add
odin check sections -no-entry-point -vetto the Vet step of.github/workflows/test.yml(listed with the rest of the gap under SYNC-I3; CI files can't be pushed from here, andmise run vethas it).
27. Lyrics and chord sources (online APIs)
β³ β nothing built. Research for fixing imported words and filling in chords,
done October 2026. Checked by hand with curl; nothing here is in the app.
What's out there
- LRCLIB (
lrclib.net, MIT-licensed service, crowd-sourced data): free, no key and no account.GET /api/search?track_name=&artist_name=givesplainLyrics(with blank lines between the parts) andsyncedLyrics([mm:ss.xx] line, LRC), plusdurationandhasWordSync;GET /api/getwants an exactdurationas well and 404s without one ({"name":"TrackNotFound"}). Rate-limited, 429 withRetry-After, and it asks clients to send a realUser-Agentand not hammer it. There's apublishendpoint too. Tried: Falling Slowly came back with stanzas and timestamps from 14.50 s. No section names. - Musixmatch: the industry database (millions of synced lyrics), but the free developer tier gives 30% of a song's lyrics and a couple of thousand calls a day; full lyrics need a commercial licence. No section names either.
- Genius: its API has metadata and song URLs but not the lyrics β
the words only exist in the page, and scraping them is against its terms.
Genius pages are the one common source whose words do carry
[Verse 1]-style labels. - SongSelect (CCLI): the one licensed source for worship songs with section labels, chord charts and ChordPro, and the natural fit for this app's users. Access is a partner programme, not a public API (Planning Center, Elvanto, WorshipTools and others use it); partners note that chord charts come through as PDFs that can't be edited. Would mean applying to CCLI and users signing in with their own CCLI licence.
- Chords from audio: Klangio (
klang.io) has a REST API for transcription with chord recognition and beat tracking, and writes MusicXML β which this project already imports. Paid, by the transcription. Chordify has no public API. Running chord recognition locally (Chordino, Essentia) is the other way, and needs no service. - Chords from tabs: Songsterr's public JSON needs no key
(
GET /api/songs?pattern=β¦gives song ids, tracks and hashes; the revision endpoints hold the tab bar by bar, so real bar counts, which UG-I1 lacks) but the tabs are other people's transcriptions and its terms weren't checked. Hooktheory's API answers "which songs contain this progression" with artist, song and section name, so a section's chords could be looked up to guess its name; it needs an account and is a roundabout way round. - Ultimate Guitar stays as it is (Β§15): meta JSON for structure and chords, no words.
Suggestions, in the order they'd pay off
- Fix the words from LRCLIB in the editor (free, no key, no account):
look the song up by title and artist, then match each of our lyric lines
to the fetched ones with the same word overlap Β§26 uses, and offer the
changes. It would clean up what a score's syllables give
(
wright my name,cherryred,survive the fall) and what a search result mangles, and the blank lines inplainLyricsfeed Β§26'ssplitstraight away, so the parts get named as well. - Time the lyrics from
syncedLyrics: with the song's tempo (or the offset Β§11'salignfinds),[mm:ss.xx]becomesbar.beat, which is exactly what a"line wants. That would fix the long-and-short phrases a score gives (SCORE-I3) and give lyrics to a MIDI or a chord sheet that has none. - Make it a source, not a dependency: one small package (no I/O, as
with
ug), one CLI to try it with, cached on disk, and every import working as it does now when the network is away or the song isn't there. Send aUser-Agentthat names the app, back off on 429. - Ask before fetching: looking a song up sends its title and artist to someone else. Worth a setting that's off by default, and a line in the README about where the words come from and that LRCLIB's are crowd-sourced (so not authoritative) β and that the song's own licence (CCLI for a worship team) is the user's to hold.
- Later, if wanted: Klangio for chords from a backing track (it writes MusicXML, so Β§25 would read the result), or a SongSelect partnership for a licensed source with section names and charts.
Issues / questions
- API-Q1: Which to build first β fixing words (1) or timing them (2)? Timing needs the offset to be right; fixing words doesn't.
- API-Q2: Does fetching the words belong in the player (a button in the
editor) or only in a CLI that writes a
.vct? - API-I1: None of the free sources gives section names, so Β§26's guessing stays, whatever is added here.
28. Mobile apps (iOS, Android)
π€ β researched October 2026, no code, and parked: not a near-term requirement. The findings keep: they say what to do when it comes back, and MOB-D1 (keep the wasm build) is settled either way. The research is written up in mobile.md: what already ports, why raylib is the blocker, the backend comparison, why wasm isn't the way in, the recommended architecture, a feature-by-feature compatibility table and what the shells have to bridge.
The goal: the native desktop and mobile apps are thin shells that embed one reusable player and bridge it to the system.
Done
- Measured (on
dev-2026-05) that every package that doesn't import raylib type-checks for-subtarget:iphoneunchanged βvct,player,align,score,sheet,chordpro,stagesync,qr,lspβ and so doescmd/player; Android needs onlyODIN_ANDROID_NDKset. A static library with the parser, timeline builder, engine and driver builds for iOS today (324 KB arm64,LC_BUILD_VERSION platform 2, exporting a C-ABI symbol), and for the simulator (platform 7). This is a dividend of the web build (feature 20), which is what gotcore:osout of the shared code. Re-checked after the themes, clock-lock and audio-thread work:player/theme.odin,look.odin,ttf.odinandlock.odinare platform-free too,sections/checks for iOS as well, and the new mixer callback makes the audio seam easier, not harder (MOB-I2). architecture.md draws the player as it stands; mobile.md says what would have to change.
Decisions
- MOB-D1: wasm is not the mechanism for the mobile apps (no Web MIDI in WebKit ever, no UDP/TCP listening, suspended audio, and a native wasm runtime would mean reimplementing the raylib backend as host calls to run code that already compiles to arm64) β and the wasm code stays anyway: it is the web version, and it is why the shared code compiles for iOS today. Its only real cost is WEB-I1.
- MOB-D2: mobile graphics, input and audio go through SDL3 (
vendor:sdl3, official iOS and Android support, can sit in a host-owned view). Desktop and web keep raylib, which has no iOS backend and isn't zero-install for SDL3's part of the job.
Issues / questions
- MOB-Q1: how much of the app goes to mobile? raylib call sites are
concentrated in the desktop-only screens (
edit.odin149,set_edit.odin99,home.odin83) while the stage path is 48 (stage.odin) plus 83 (ui.odin) plus 12 (cmd/player/look.odin) β 143 of 758. Porting the stage (and the pre-play list) first is under a fifth of the work and is what a musician needs on a phone or tablet. The editor is keyboard-driven (Enter, Tab, E, N, Ctrl+β¦) with no touch design at all; it should stay desktop-first. - MOB-I1: backing tracks are decoded whole into memory at 44.1 kHz 16-bit
stereo (
align.PCM_RATE): 53 MB a stem for a five-minute song, 423 MB for eight. Fine on a Mac, not on a phone. Mobile needs streaming decode (an SDL3 audio stream or a miniaudio data source) before multi-stem songs work. - MOB-I2:
the clock has no answer for drift against the backing track on a device.Largely answered by the audio-thread mixer (AUDIO-I4): the stems go out through one stream whose callbackbacking_mixsums them from a frame cursor, andplayer/lock.odintrims the clock from the frames that mixer is asked for β our own count, not the device's. Both are platform-free in shape, so a mobile backend inherits them by callingbacking_mixfrom its own audio callback. What's left is small: raylib's pan law is divided back out (BACKING_PAN_CENTRE) and must not be carried over to SDL3, and the settling still wants checking on a device. - MOB-I3: iOS UDP broadcast needs the
com.apple.developer.networking.multicastentitlement (applied for from Apple) on top ofNSLocalNetworkUsageDescription, and there are recent reports ofEACCESeven with it. TCP listening is unrestricted, so the sync server and web client work; onlyvct-sync's broadcast discovery doesn't. The QR code (Q) and typed address already cover that, and Bonjour/mDNS would too. - MOB-I4: Android MIDI is not a port of the CoreMIDI code (which iOS does
take as it stands): it needs
AMidi(NDK, API 29+) with theMidiDevicehanded down fromandroid.media.midiover JNI, i.e. real shell work. - MOB-I5: audio on iOS needs the session set to playback and the
audiobackground mode, or the click stops on the silent switch or a screen lock β on stage, both are fatal. AlsoisIdleTimerDisabledso the screen stays up. If miniaudio is used directly it must be compiled as Objective-C, with runtime linking off and CoreAudio/AudioToolbox linked, for notarisation. - MOB-I6: SDL3 on iOS is "full-size, single window only", so the desktop's extra stage displays β now a layout each, the singers' screen and the band's (feature 9) β have no equivalent; AirPlay or a synced screen is the answer. Backgrounding gives 5 seconds to save state.
- MOB-I7: no
--renderon mobile (it shells out to ffmpeg) and no CLI options. The importers are pure Odin and already compile for iOS, so in-app IMPORT can work, but the file pickers need shell bridges (UIDocumentPickerViewController, Android SAF) behind the existingpick_files/open_*.odinseam. - MOB-Q2:
documents_dirandconfig_dir(wheresettings.json5lives) have no Android answer ($HOME and an XDG config dir aren't things); the shell would passgetExternalFilesDirandgetFilesDirdown. iOS needs no change: both land in the app container. - MOB-Q3: worth noting the cheap alternative β a native sync client (the protocol and a web client already exist, docs/sync.md) is a useful phone app with no player port at all. It needs a desktop player on the network, so it complements the embedded player rather than replacing it.
Remaining
- Add the iOS subtarget to
mise run vet(odin check player -target:darwin_arm64 -subtarget:iphone -no-entry-point, and the other I/O-free packages) so today's portability doesn't rot. - Pull the ~40 drawing, input and audio primitives behind a
gfxseam (fromui.odin), withgfx_raylibfor desktop and web; nothing changes behaviour. - Spike: an iOS shell (Xcode, SDL3.xcframework) linking the Odin player as a static library, drawing the stage only, playing one song with its backing track. This is what proves MOB-I1, MOB-I2 and MOB-I5.
- Then the pre-play list and touch controls for Next/Start (MOB-Q1).
- Then Android with the same
gfx_sdl3(MOB-I4, MOB-Q2).
29. Layout and theme editor
β
β player/design.odin (the browse screen's model: listing, names,
copying, renaming, deleting), player/layout_edit.odin (the layout
editor's model: selection, undo, dragging on the grid),
player/theme_edit.odin (the theme editor's model: fields, undo, writing
with relative fonts), cmd/player/design.odin (the screen),
cmd/player/design_layout.odin (the layout editor),
cmd/player/design_theme.odin (the theme editor),
cmd/player/design_preview.odin (the editors' preview),
player/layout.odin (LAYOUT_GRID, grid_snap, grid_snap_box,
layout_write), player/theme.odin (theme_write), player/look.odin
(the library's folders), layouts/*.json5, themes/*.json5
Lay out the stage by dragging widgets rather than by editing JSON5 by hand: a browse screen listing every layout and theme, a canvas where widgets are moved and resized on a grid, a panel for the selected widget's settings, and a theme editor for the colours, sizes and fonts β with the real stage drawing underneath, so what the screen shows is what the stage will show.
Decisions
- DESIGN-D1: Where the editor lives β a screen inside
vct-player, like the song and set editors, not a second app. Every widget draws throughcmd/player/stage.odin, which ispackage mainand keyed offApp,Frameand thepal/szglobals; a separate binary would need all of that (1219 + 468 lines) pulled into a shared package first, and would then have to be kept in step with it for ever. A screen reuses the drawing, the theme and layout loading (cmd/player/look.odin), the file watching and the library as they are. The alternative β a separate app drawing named placeholder boxes instead of real widgets β was rejected: judging a layout or a theme needs the real thing. - DESIGN-D2: Positioning β a 20-unit grid over the 1920Γ1080 canvas (96Γ54
cells), which drags and resizes snap to. It is fine enough for real
placement and coarse enough to line widgets up without measuring them
against each other. The built-in layouts were re-snapped to it (nothing
moved more than 10 units; only
metronome,beats,beat-count,karaoke, the lyrics panels,next-cuesandflashuse their box's height at all, so the height changes are invisible). 10 was too fine to tidy anything, 40 too coarse for the 44-unit practice slider and the 36-unit beats strip.layout_parsedoes not reject off-grid boxes: a file written by hand stays valid, and a test keeps the built-ins on the grid. - DESIGN-D3: Where edited layouts and themes are saved β the library
(
Documents/StageDisplay/layouts/andthemes/, besidesongs/andsets/), not the config folder. They are content, like songs; they belong where the rest of the user's work is, and with the library they can be copied between machines in one move. The config folder's ownvct-player/{themes,layouts}keeps working (look_findsearches the library first, then the config folder, then the built-ins). - DESIGN-D4: Built-in layouts and themes are read-only, with a Clone button that copies them into the library under a new name; the browse screen lists them alongside the user's own so there is one place to look. A clone may not take a built-in's name (the lookup would let it shadow the built-in, and the list would show the name twice).
- DESIGN-D5 (was DESIGN-Q3): A saved theme is its
baseplus what differs (theme_write), not a full copy: a clone is two lines and follows the built-in when that changes, as the settings template already tells people to write their own. - DESIGN-D6: The layout editor has undo and redo. A layout is a few dozen widgets, so each edit keeps a copy of the whole list; no command objects.
- DESIGN-D7 (was DESIGN-Q4): The preview plays the built-in example song
(
examples/example-song.vct, embedded): it has lyrics, chords and cues, so every widget has something to show, and the preview doesn't depend on what is in the library. - DESIGN-D9 (was DESIGN-Q1): The browse screen sets the player's own
layout and theme (USE, or U), which answers DISP-I8. It writes
settings.json5withplayer.settings_set, which finds the key's value in the text (a small JSON5 scanner over objects, arrays, both quotes and both kinds of comment) and replaces just that, so the file's comments, alignment and commented-out lines stay as they were; a key that isn't there is added at the top; a file that doesn't parse isn't touched, and an edit that wouldn't read back isn't written. A layout picked with L for the run gives way to it;--layout/--theme, or the set last opened, still win, and the note says so. USE isn't offered on one that can't be loaded, on a layout with no widgets, or on the one already chosen. - DESIGN-D8: The colour picker is the player's own (a hex field and hue,
saturation and brightness sliders), not raygui's, which is in
vendor:raylibbut wouldn't match the rest of the app.
Done
- DESIGN-D2's grid:
LAYOUT_GRID,grid_snapandgrid_snap_boxinplayer/layout.odin, the four built-in layouts re-snapped, and tests that every built-in layout is on the grid and that snapping rounds a half cell up and keeps a box at least one cell. Checked by screenshotting all four layouts before and after under the example song. - DESIGN-D3's lookup:
look_findandlook_namestake aLook_Dirs(the library root, then the settings file's folder) instead of one folder, so<library>/layouts/NAME.json5and<library>/themes/NAME.json5are found by name, shadowing the settings folder's and the built-ins.look_inittakes the library root (--library), andlook_pollnotices a file appearing in either folder, so the editor's first save is picked up without a restart. The folders are made when something is saved into them, not at startup: nothing new appears in anyone's Documents folder until the editor is used. Checked with--library DIR --layout miniagainst a layout in a temp library (found, drawn, and in the L cycle), plus a test for the search order. layout_writeandtheme_write: the parsed model back out as JSON5, in the style of the hand-written files (defaults left out, themobilesection kept). Tests write every built-in layout and thedaylightandcontrastthemes and parse them back to the same thing, and check that an unchanged theme is just its base.- With the chart widget (feature 9, #111), which landed alongside this:
layout_writewrites a chart widget'sview,flow,barsandturnwhere they aren'tCHART_DEFAULTS(only on chart widgets: other widgets carry no options a file can give them), and themobilesection'schart; the round-trip test now compares that too.sheet.json5andstrip.json5are on the grid: the header from y 24 to 20, the chart from [60, 170, 1800, 900] to [60, 180, 1800, 880], so it keeps a cell's margin at the bottom of the screen rather than touching it, as the other layouts do; checked by screenshots of both under the example song. The browse screen's test reads the built-in names instead of listing them. - The browse screen (
Screen.Design; T or LAYOUTS + THEMES on the home screen). Two tabs, LAYOUTS and THEMES, as the home screen's SONGS and SETS. Every layout or themelook_findcan reach is listed once, from where it would be taken (look_list): the library's own first, then the settings folder's, then the built-ins. Each row has a thumbnail (a layout's boxes in miniature; a theme's background, text, numbers and colours), a BUILT IN or SETTINGS FOLDER tag, ON STAGE for the one the stage is wearing, and the widget count or the theme's base. Any row can be copied (COPY, Ctrl/Cmd+D, or Enter on one that isn't the library's); the library's own can be renamed (R) and deleted (Delete, after a confirmation). N makes an empty one. Names are checked as they're typed (look_name_problem: a file name, not taken, ignoring case, and not a built-in's). A copy of a layout is its file as it is, comments included; of a built-in or settings-folder theme,base: NAME(DESIGN-D5). The list is read again once a second, for files changed outside the app.remove_fileandrename_filewere added toplayer/file.odinand the web build'sfile_js.odin. Checked by screenshots of both tabs, the copy prompt, the copied theme and the file it wrote, and a refused name, under--librarywith--type t,d,nand a typed name; rename and delete are covered by tests of the model, not driven on screen (--typecan't press R or Delete there). Tests: the list's order and shadowing, names, copy names, what a copy and a new one start as, saving, renaming and deleting. - The layout editor (
Design_View.Layout; Enter or EDIT on one of the library's layouts). The preview (DESIGN-D7) is the real stage, drawn bydraw_stage_canvasinto a 1920Γ1080 texture before the frame is drawn (as the displays are) and shown at 1380 wide. It runs the example song on aplayer.Driverof its own, in a two-song set with the other example so the title and set-position widgets have something to show, pressing Next itself through aholdorloopand starting again when the song ends. While it draws, the app's song, view, set list and look (palette, sizes, fonts) are swapped for the preview's and put back;g_outputis set, as for a display, so the song map's click-to-jump can't reach the real stage (the web build'sg_outputbecame a variable for this). Its theme is any oflook_names(.Theme)(< and >, or T), loaded with its own fonts when they aren't the stage's. WITH WORDS / NO WORDS switches between the song and the same song with its lyric lines taken out, so widgets with awhencan be seen. Every box is outlined over it (dashed when this kind of song doesn't show it), the hovered and selected ones named, the selected one with handles. Dragging useslayout_box_drag, one undo per drag; a press picks the selected box's handles first, then the smallest box under the mouse (DESIGN-I2); the cursor shows what a press would grab. The panel lists the widgets in drawing order (scrolls past 11) and sets the selected one's scale (WIDGET_SCALES),when(ALWAYS, WORDS, NO WORDS),highlight,chordsand the song map'stickswhere they apply, its place in the order, duplicate and delete. A (or + ADD WIDGET) opens a palette of all 23 widgets with what each is for; one is added where the built-in layouts put it (widget_default_box). Undo and redo (DESIGN-D6) keep a copy of the widget list per change, up to 200. Ctrl/Cmd+S saves withlayout_write; the first save of a file that had comments says they aren't kept (DESIGN-Q2), and one with no widgets says the stage can't wear it yet. Esc deselects, then leaves, asking first if there are unsaved changes. Checked by screenshots under--librarywith--typecommands (open NAME,sel N,nudge,size,drag HANDLE DX DY,add NAME,undo,redo,save,words,theme NAME,palette): the preview in the default and daylight themes, with and without words, a selection, the palette, and the save note; the saved copy ofstagehad the moves made (and kept through an undo and redo), itsmobilesection intact, and the stage played it under--layout. Tests: the drag geometry (snapping, a cell at least, on the canvas), picking the smallest box past the flash and hidden widgets, undo and redo, unsaved-ness, order, delete, duplicate, scale steps. - With the chart and header widgets (#111): both are in the palette (now
five columns, for 25 widgets), and a new widget starts as a built-in
layout has it (
widget_default, which looks instripandsheettoo), chart options included, so a new chart is saved with options that load. A chart's settings sit in the panel under SHOWN FOR: STRIP or SHEET, FOLLOW or PAGE, BARS (1 to 16) and, for PAGE, how early it turns; the panel's rows follow the selected widget's kind, and the widget list is a row shorter to make room. Checked by a screenshot of a copy ofstripwith its chart set to PAGE and 6 bars, and the file it saved; a test that a new chart writes a layout that loads. - The theme editor (
Design_View.Theme; Enter or EDIT on one of the library's themes, or E / EDIT THEME in the layout editor, which copies a built-in or settings-folder theme into the library first and comes back to the layout when done). The same preview, wearing the theme as it stands (preview_use_themeeach frame; fonts reload only when they change), plays the stage's layout (< and > pick another) or, from the layout editor, the layout being edited, unsaved changes and all. Beside it, every field a theme file sets (theme_field_list: 14 colours, 7 song map colours, 14 sizes, 5 fonts, 4 phone sizes) under headings, each with its value, and a mark on the ones the theme sets itself rather than taking from its base. Below, the selected field: a colour's swatch, hex (Enter types one) and hue, saturation and brightness sliders whose tracks show what each position gives (DESIGN-D8; raylib'sColorToHSVandColorFromHSV, the hue kept while dragging so a grey doesn't lose it); a size on a slider over the rangetheme_parseallows, stepped by twentieths or units (theme_number_step) with - and + or Left and Right; a font chosen with the macOS open panel (pick_font), typed, dropped on the window, or put back to the built-in face. RESET or Delete puts a field back to its base's. Undo and redo keep a copy of the theme per change, a slider's drag being one. Saving writestheme_edit_src:theme_writeover the base, with fonts in the theme's folder made relative again (DESIGN-I1). The phone's sizes got atheme_mobile_fieldtable like the stage's, which the parser now uses too, so their ranges are in one place;theme_color_field,theme_size_fieldand their name lists became public for the editor. Checked by screenshots under--librarywith--type(open NAME,sel FIELD,set VALUE,save,esc, and the layout editor'sedit-theme): a copy of contrast with its accent, highlight and flash changed (the preview showing each, the saved file just those three overbase: "contrast"); and from the layout editor, the built-in default copied to "default copy", its background changed, saved, and the layout editor's preview wearing it after. Tests: the field list covers the file, each field reads and writes the right part, snapping, undo and redo, reset, unsaved-ness, and the written file (relative and outside fonts).
Issues / questions
- DESIGN-Q2: A layout or theme written by the editor loses its comments (it is written from the parsed model, not edited as text). Fine for files the editor made; it would quietly strip a hand-written file's notes on the first save. A warning before the first save over a commented file, or keeping a header comment, are the options.
- DESIGN-I1:
The theme editor saves throughtheme_loadmakes font paths absolute, andtheme_writewrites them as it finds them.theme_edit_src, which makes a font in the theme's own folder relative again; one elsewhere stays absolute (a theme copied with its fonts elsewhere would want them copied too, which nothing does). Colours come out as lowercase hex where the built-in files use capitals; harmless. - DESIGN-I3:
Renaming or deleting a layout or theme doesn't change what names it.It does now (look_retarget): the library's sets (vct.set_rewrite_look, the header's value changed in place, or its line taken out on a delete so the set wears the player's own), the library's themes built on a renamed theme (theme_rebase, theirbase:), the settings file'slayout/themeand the phone's (settings_set; the built-in default on a delete), and the displays (displays.txt; the stage's own on a delete). The delete prompt says what names it first. Themes built on a deleted theme are only counted and warned about: giving them another base would change how they look. Themes in the settings folder aren't rebased (the library is the editor's), and a song or set outside the library isn't looked at. - DESIGN-I6: The preview's theme only reloads its fonts when they change by path; a font file replaced in place while the editor is open keeps the old glyphs until the theme is opened again.
- DESIGN-I4:
The mouse itself hasn't driven the editors.Driven on a Mac with real pointer and key events (CoreGraphicsCGEventPost, aimed in canvas units at the 1280Γ720 window, calibrated against where the player said each press landed). Layout editor: a click selecting a box, a drag moving it 200,40 and its corner sizing it 100,40 (both exact on the grid), a click in a gap picking the flash, the widget list scrolled with the wheel and clicked, SCALE +, UNDO, REDO and SAVE, the file holding the result. Theme editor: a row, the hue slider dragged, the hex field typed into, a click away, the size slider dragged, + and RESET, a font row scrolled to, CHOOSE FILE's panel opened and closed with Esc, a font path typed, SAVE. Three things it found, fixed: a drag dropped the pointer's last movement when the button came up in the same frame (both editors' drags now count that frame); a click away from a field being typed in was ignored until Enter or Esc (it now finishes the field β applied, or cancelled if it's a colour that doesn't read β and counts as a click); and a colour's hex field had to be backspaced out before a new one could be typed (the value is now selected as the field opens, so typing replaces it). Not reached: a font file dropped from the Finder, the cursor shapes (screenshots don't show the cursor), the edges and corners other than bottom right, and the S and V sliders. Later, on a Mac: Esc leaving a layout with no unsaved changes crashed on a bounds check. Closing zeroes the edit (its selection back to 0, not -1), and the mouse step still ran that frame and looked up widget 0 of an empty list. The input step now stops once a key has left the editor.--type esccloses without the mouse step, so the scripted runs missed it. - DESIGN-I5:
The editor doesn't notice its file changing on disk while it's open.Each editor notes its file's state when it opens and after each save (Disk_Watch), looks again once a second, and shows CHANGED ON DISK beside the name when something else has written it. Saving over a changed file asks first: save over it, reload it (the editor's changes go), or keep editing; leaving with unsaved changes asks the same way if it comes to that. The editor's own saves move the noted state on, so they never trip it. - DESIGN-I2: Several widgets draw nothing for most of a song (
practiceonly while auditioning,flashon a beat,count-inat a section's end), andflashcovers the whole canvas. The editor outlines and labels every box over the preview, lists the widgets beside it, and picks the smallest box under the cursor, so each can be seen and grabbed.
Remaining
- The grid, and the built-in layouts on it (DESIGN-D2).
-
layout_writeandtheme_write: the parsed model back out as JSON5 (DESIGN-D5), with round-trip tests. - The library's
layouts/andthemes/folders, andlook_findsearching them (DESIGN-D3). - The browse screen: built-ins read-only with Clone, with rename, delete and New (DESIGN-D4); a key on the home screen to reach it.
- Opening the library's themes in the theme editor.
- The editors notice their file changing on disk (DESIGN-I5). Checked by a shell rewriting the file two seconds into a run: the tag showed, saving opened the prompt and left the outside version on disk (layout and theme editor), reloading brought it in, and saving over it wrote the editor's and cleared the tag.
- Renaming or deleting keeps sets, displays, the settings file and
themes built on it in step (DESIGN-I3). Checked under a scratch $HOME
and
--library: renaming a layout that a set, the settings file (the stage's and the phone's) and a display named changed each in place, comments kept; deleting it took the set's line out and put the settings and the display back to the defaults, after a prompt that listed them. Tests: the set edit in place (a trailing comment kept, whole names only, a delete taking the line), and the library pass counting and rewriting sets and a theme's base. - Set the player's own layout and theme from the browse screen
(DESIGN-D9, DISP-I8). Checked against a hand-written settings file
with comments under
--settings: USE on a layout and a theme changed exactly those two values, and the player took them on at once. Tests:settings_seton the template (its commented-out mobile layout left alone), nested and quoted keys with comments in the way, a name with quotes, adding a missing key, a file that doesn't parse. - The layout editor: the preview (DESIGN-D7) with every box outlined and
a list of the widgets (DESIGN-I2); drag and resize on the grid, arrow
keys a cell at a time; a widget palette; the selected widget's settings
(
scale,when, andhighlightandchordswhere they apply); a lyrics / no-lyrics switch; the preview's theme; undo and redo (DESIGN-D6); a warning before unsaved changes are dropped. - Drive the layout editor with the mouse on a Mac (DESIGN-I4).
- The theme editor: colours (DESIGN-D8), section colours, sizes within
the ranges
theme_parseallows, fonts (DESIGN-I1) and the phone's sizes, with the same preview; reachable on its own and from the layout editor. - Drive the theme editor's sliders and the font picker on a Mac (DESIGN-I4).
- A font file dropped from the Finder onto the theme editor.