A script GPS for theatre technicians.
Choufleur follows a live performance in real time by listening to the actors' microphones, keeps every technician's screen on the right page of the script, and taps them on the shoulder before their cues.
It never triggers anything. Sound, lighting, video, flys — the operator always makes the call. Choufleur just makes sure that if you're deep in an EQ tweak when your next cue approaches, you get a peripheral nudge in time to refocus.
- A server (macOS primary) takes per-actor audio feeds from the console — Dante or direct outs, up to 16 channels — and runs local speech recognition (Whisper) against the known script. Fully offline; no internet dependency.
- Any device on the venue network opens the web client, joins the show, and picks the cue list that concerns it (LX, sound, video, flys, stage management). Each operator sees the shared script position, their own cues, and their own notes.
- Warnings are configurable per cue (standby/final lead times) and deliberately peripheral — a red frame at the edge of vision, not an alarm.
- The system tracks position with honest confidence levels. When it's lost, it says so and asks for help: the first operator to respond can scroll position for everyone until tracking re-locks.
- Multilingual by design — including several languages within one play, down to individual bilingual lines.
- Rehearsal-friendly: jump to any scene or cue, pause while the director gives notes, loop the same passage, and re-import a rewritten script without losing a single cue or note.
Phase 0 — tracking-engine risk spike, and it has now met real theatre. Two full performances of Hécube, pas Hécube (Tiago Rodrigues, Comédie-Française) have been tracked end to end against the operator's own script and conduite, and the display has been watched and corrected from the chair through several complete runs.
On the operator's prepped script, tracking both nights:
| night 16 | night 17 | |
|---|---|---|
| glances landing inside a 6-line window | 93 % | 95 % |
| p90 position error | 2 lines | 1 line |
| jumps over 100 lines | 0 | 0 |
| time without a trustworthy position | 34 s | 0 s |
| time at low confidence | 22.6 % | 15.8 % |
Two hours of performance, one mono mixed feed, French, against a script six months
older than the recordings. End-to-end latency is 351 ms median, and small runs at
6.2× real time — so the compute budget is not the constraint.
What that took, and what it says. Almost every gain came from the matcher rather than from recognition, and almost every one was found by an operator watching the page and describing what felt wrong:
- Long moves went to zero. Charging confirmations for distance rather than score — a move of a hundred lines must be seen more times before it is believed.
- Silence, music and improvisation stop the clock. A passage the script cannot predict is marked as such, and the tracker waits rather than treating the noise as evidence against where it is.
- Long speeches hold their place. A five-second fragment agrees with a fraction of a 173-word line, so consecutive fragments are scored together while the position is stalled. Time at low confidence roughly halved.
- A bigger model does not help.
mediumwrites a measurably better transcript — lines recognisable as written 84 → 120 — and tracks no better, three separate ways. The binding constraint is the matcher.
A prep and live display is served from the same binary: the script scrolling under a continuous follower, an operator's cue sheet on a rail beside it with leader lines to the exact phrase that fires each cue, and editors for the script, the cues and the cue list's own vocabulary. Several devices join independently — position is shared, everything else belongs to a list.
An app, signed, for the machine that runs the show. It is what makes live capture possible at all: macOS gives a plain binary a microphone stream that runs and delivers nothing — no error, no prompt — and only a bundle is ever asked about. It holds one window on the library, downloads the models on first run, and takes every server with it when it closes.
A buzzer, for the operator whose eyes are not on the page: a wrist wearable — a XIAO nRF52840, a DRV2605L haptic driver, and an actuator still on trial — paired to any screen over Web Bluetooth. The page computes the warnings and writes one-byte opcodes; the wrist answers with patterns countable without looking: one soft bump at standby, two sharp clicks at final, three light ticks when tracking is lost with a cue near. Both ends say so when the link is gone. Firmware in buzzer/, same toolchain as headtracker_v1; the wire contract is the table in buzzer/README.md.
Still open. Multitrack has never been tracked against a corpus, which is the case with the most to gain since knowing who is speaking should resolve most remaining ambiguity. Near-identical lines still cause the one reproducible error. The app is not notarized yet, so another Mac will refuse it until it is. And the buzzer has vibrated an LRA on the bench, calibrated, from a page-style opcode over BLE — but no wrist has yet worn it through a show. The actuator tryout has run its three contenders on two people and six sites: the ERM is strongest and cannot do the short countable bursts the vocabulary is made of, the axial coin LRA loses its stroke under a strap, and the lateral LRA reads best where it will be worn — at 1.4 V, which is what a 3.3 V rail gives a 2 V part. Findings in docs/choufleur-buzzer-notes.md.
Findings, including the ones that failed and why, are in docs/choufleur-phase0-notes.md.
cd server && cargo test # 261 tests; those needing models skip without them
../scripts/fetch-models.sh # Whisper + Silero, ~490 MB, once
cd .. && ./server/target/release/choufleur-replay make-fixture corpus/fixture-smoke
./server/target/release/choufleur-replay transcribe corpus/fixture-smoke -o out/segments.jsonl
./server/target/release/choufleur-replay track corpus/fixture-smoke --segments out/segments.jsonl -o out/trace.jsonl
./server/target/release/choufleur-replay eval corpus/fixture-smoke --trace out/trace.jsonl --segments out/segments.jsonlWatch it follow a show, with the audio audible and the script on screen:
# the library, and a show server started from it — what the app runs
./server/target/release/choufleur-replay ui --port 8080
# a live run on its own: sound out of the default device, the page at localhost:8080
./server/target/release/choufleur-replay serve <manifest> --port 8080
# the same script with no audio, for preparing it and its cue lists
./server/target/release/choufleur-replay serve <manifest> --prep --port 8080scripts/sidecar.sh # build the server the app carries
cd server/crates/choufleur-app && cargo tauri dev
# signed, and a DMG beside it
APPLE_SIGNING_IDENTITY='Developer ID Application: … (TEAMID)' scripts/release-app.shAdd APPLE_ID, APPLE_PASSWORD (an app-specific one) and APPLE_TEAM_ID — in
scripts/.env.release, which is not committed — and it notarizes too. Without that a
second Mac refuses it; release-app.sh says so rather than leaving it to be discovered
on the door of a venue.
Tagging is what cuts a release. .github/workflows/release.yml builds on an Apple
Silicon runner, runs the tests, signs, notarizes both the app and the disk image, and
proves the result before publishing — a build that Gatekeeper would reject fails the job
instead of becoming a download. It leaves a draft, so nothing is public until you
say gh release edit v0.1.0 --draft=false.
# the version lives in two files and the workflow refuses a tag that disagrees
git tag -a v0.1.0 -m "…" && git push origin main --follow-tagsFive secrets, in the Choufleur environment under Settings → Secrets and variables →
Actions — each project on the account keeps its own, and the release job names that
environment to be given them. It requires a reviewer, so a run waits for one click
before it starts. The workflow
checks all five are present, and that the certificate matches the identity, before it
builds anything — the whole point being that the bundler would otherwise warn and
publish an unsigned DMG.
| Secret | What it is |
|---|---|
MACOS_CERT_P12_BASE64 |
the Developer ID Application certificate and its private key, as base64. From Keychain Access: expand the triangle, select both rows, Export 2 items as .p12, then base64 -i cert.p12 | pbcopy. Exporting the certificate alone signs nothing |
MACOS_CERT_PASSWORD |
the password given to that .p12 |
NOTARY_API_KEY_BASE64 |
the App Store Connect key file, as base64 — base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy. Apple lets you download a .p8 once, so keep the original |
NOTARY_API_KEY_ID |
the key's id — the XXXXXXXXXX in AuthKey_XXXXXXXXXX.p8 |
NOTARY_API_ISSUER |
the issuer uuid, from App Store Connect → Users and Access → Integrations → Keys. One per account, shown above the key list |
Notarizing with an API key rather than an Apple ID and app-specific password is
deliberate: the key belongs to the team rather than to one person's account, and it
keeps working when that account's password changes. release-app.sh accepts either, so
a local build can still use APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID.
The microphone only works from the app. A binary run from a terminal is given a
stream that delivers nothing at all rather than an error, and never appears in System
Settings to be allowed. cargo tauri dev is not proof either — under it, capture is
attributed to the terminal's own permission. Test in the bundle.
Models are fetched once per machine into ~/Choufleur/models, from the app's first-run
panel or from a terminal:
./server/target/release/choufleur-replay models list # what is here, and where to put it
./server/target/release/choufleur-replay models fetch # ~490 MB, resumable, checksummed| Document | Contents |
|---|---|
| docs/choufleur-prd_1.md | Product requirements — architecture, tracking, warnings, protocol, platform |
| docs/choufleur-notation_1.md | Normative spec — cue notation, line identity, language tagging, show file format |
| docs/choufleur-devplan_1.md | Development plan — phased milestones, go/no-go gate, workspace layout, test strategy |
| docs/choufleur-phase0-notes.md | What building it has taught us, including two findings that change the design |
| docs/choufleur-buzzer-notes.md | The evening the buzzer met a wrist — bugs the bench found, what the chip said about the actuator, two people and six sites |
choufleur/
├── server/ # Rust workspace — see server/README.md
│ └── crates/
│ ├── choufleur-core # tracking engine: normalization, matching, position
│ ├── choufleur-asr # resample, VAD, Whisper: buffers in, segments out
│ ├── choufleur-server # the library on disk: shows, versions, safe writes
│ ├── choufleur-replay # the binary: servers, audio, CLI, and the web client
│ └── choufleur-app # the desktop shell — one window, and everything's lifetime
├── buzzer/ # cue-warning wearable — Zephyr firmware; the wire contract is its README
├── corpus/ # evaluation recordings — manifests in git, audio is not
├── research/ # Python sidecar for forced alignment; never in the show path
├── scripts/ # model fetching, and building the app
├── remote/ # Flutter/Dart — web-first client (Phase 3, not started)
├── docs/ # PRD, notation spec, development plan, notes
├── LICENSE-MIT # MIT OR Apache-2.0 dual license
├── LICENSE-APACHE #
└── README.md
Part of an open source theatre tooling ecosystem, all built from direct professional practice: Tagada (stage position tracking), S21 HiJack (DiGiCo S21 snapshot extension), WFS DIY (open source wavefield synthesis), and an audio description workspace.
Dual-licensed MIT OR Apache-2.0, at your option — the Rust ecosystem convention. Show files you produce with Choufleur are your data.
"It's like a GPS for your script, so you always know where you are and what's coming up."