Show time code tracking
This section is for anyone running the cue sheet who's never touched Docker, Terminal, or MIDI before. Just follow the steps in order, top to bottom. π
π‘ This walkthrough assumes the show computer is already set up the way iPat configured it (Horae routed to IAC, the auto-start relay service installed). You're just turning things on, not building them.
- π³ Docker Desktop is installed on this Mac
- πΌ Horae is installed
- βοΈ The auto-start relay service has been set up once by iPat (you won't need to touch Terminal for this β it starts itself)
If all three are already true, skip straight to "Show day" below. π
Click the Docker whale icon π³ in your Applications folder (or menu bar if it's already running). Wait until the icon in the top menu bar stops animating β that means it's ready.
Open Terminal, paste this one line, press Enter:
cd ~/Documents/Git/Show_Timecode && docker compose up -d cuesheet
β οΈ Only startcuesheethere β notrelay. The timecode relay for this show runs a different way (see step 3), and starting both at once will fight over the same connection.
- Open Horae πΌ
- Make sure
IAC Driver Bus 1has a β checkmark in the MTC Destination list on the right - Press
βΆοΈ Play on the timecode generator
It should already be running quietly in the background (it auto-starts when you log in). To double check:
launchctl list | grep tc-relayIf you see a line printed back, β you're good β skip to step 5.
If you see nothing printed, start it manually:
cd ~/Documents/Git/Show_Timecode
uv run --with websockets --with python-rtmidi python3 tc_relay.py --mtc "IAC"Leave that Terminal window open in the background while the show runs.
In your web browser, go to:
http://localhost:8080
(Someone viewing from a different laptop/tablet on the same WiFi? Use the show computer's network address instead of localhost β ask iPat for that number if you don't have it.)
At the top of the cue sheet, in the Chase bar:
- Find the Source dropdown β choose WebSocket
- In the box that appears, type:
ws://localhost:8765 - Click Connect
- The big clock at the top should turn π΅ blue and start counting, matching Horae's numbers
- As each cue's moment arrives, its Status turns π’ green "Running" automatically, then back to π‘ Standby when it's done
That's it β you're ready for the show. π¬β¨
| What you see | What it means | What to do |
|---|---|---|
| Cue sheet page won't load at all | Docker isn't running | Go back to Step 1 π³ |
Clock stays gray, says --:--:--:-- |
Not connected yet | Redo Step 6 β check you typed ws://localhost:8765 exactly |
| Clock is blue but numbers aren't moving | Horae isn't sending timecode | In Horae: is IAC Driver Bus 1 checked? |
launchctl list | grep tc-relay shows nothing, and manual start doesn't help either |
Ask iPat β this needs the one-time setup checked | π |
| Cues never turn green even though the clock is moving | Wrong Act selected | Check the Act dropdown next to the |
When in doubt: close the browser tab, reopen http://localhost:8080, and redo Steps 5β6. That fixes almost everything. π
Bridges MIDI Time Code (MTC) β local, virtual bus, or network (RTP-MIDI) β or a free-run generator to any number of WebSocket clients (browsers, OSC bridges, etc.).
Used as the TC source backend for timecode_cue_sheet.html.
pip install websockets # all modes
pip install python-rtmidi # MTC mode onlyRaspberry Pi / Debian: add
--break-system-packagesif using system Python.
pip install websockets python-rtmidi --break-system-packagesDecodes MTC quarter-frames and full-frame SysEx from any MIDI input.
Framerate (24 / 25 / 29.97DF / 30) is auto-detected from the MTC stream.
python3 tc_relay.py --mtc "MOTU"--mtc accepts a substring of the MIDI port name (case-insensitive).
To list available MIDI ports without starting the relay:
python3 - <<'EOF'
import rtmidi
m = rtmidi.MidiIn()
print(m.get_ports())
EOFQuarter-frame note: MTC encodes TC across 8 QF messages spanning 2 frames.
The relay applies the standard +2 frame latency compensation so the displayed TC matches the actual playhead position.
MTC over the network (Horae / Visual Productions TimeCore):
tc_relay.py only ever talks to a named MIDI port β it doesn't care whether that port is local hardware, a virtual bus (IAC), or a network session. To get MTC across the network:
- Have Horae or a TimeCore send RTP-MIDI (AppleMIDI) on the network β TimeCore supports RTP-MIDI natively; in Horae, add an RTP-MIDI destination alongside (or instead of) IAC.
- Join that RTP-MIDI session at the OS level on the machine running the relay:
- macOS: Audio MIDI Setup β Window β MIDI Studio β MIDI Network Setup β connect to the session.
- Linux:
rtpmididcreates a matching ALSA MIDI port for any RTP-MIDI session it discovers/joins on the network. - Windows: install the Tobias Erichsen
rtpMIDIdriver and add the session there.
- Once joined, the session shows up as a normal MIDI port β point
--mtcat a substring of its name, same as any local port:
python3 tc_relay.py --mtc "TimeCore"Generates TC from a given start point, incrementing in real time. Useful for testing the cue sheet without any MIDI hardware or network session.
python3 tc_relay.py --fake 01:00:00:00
python3 tc_relay.py --fake 00:59:50:00 --fps 25| Argument | Default | Description |
|---|---|---|
--fake |
β | Start TC HH:MM:SS:FF |
--fps |
30 |
Framerate for the generator |
The generator broadcasts at 10 Hz β sufficient for smooth UI updates.
All modes broadcast to ws://0.0.0.0:<ws-port> (default 8765).
Message format: plain JSON, one per frame update:
{"tc": "01:00:23:14"}The cue sheet HTML accepts both plain "HH:MM:SS:FF" strings and the JSON wrapper.
Any other client (Node, Max/MSP, TouchOSC, etc.) can consume the same stream.
| Argument | Default | Description |
|---|---|---|
--ws-port |
8765 |
WebSocket server port |
Multiple clients are supported simultaneously β the relay broadcasts to all connected sockets concurrently.
In timecode_cue_sheet.html:
- Chase bar β Source: WebSocket
- Enter
ws://<Pi IP>:8765 - Click Connect
- TC display turns blue when the WS feed is active
If the relay is running on the same machine as the browser, use
ws://localhost:8765.
Everything's editable directly in the table β no re-import needed:
- Add a cue β toolbar οΌ Add Cue (appends to the current/default act), or hover any row and click its οΌ to insert right after it.
- Delete a cue β hover a row, click β (confirms first).
- Rename / edit fields β click into Cue#, Label/Scene, Trigger, Dept, Device, Action, Fade, or Notes and type. Commits on blur or Enter.
- Edit Duration β click the Dur cell, type
HH:MM:SS:FF. TC In/Out for every following cue ripples forward automatically β same mechanism as drag-reordering. - All of the above feed the existing β Undo button and autosave (localStorage), so none of this is riskier than a drag-reorder.
Live sync across hosts β any edit (rename, add/delete/reorder, status, notes, meta fields, TC color) is pushed to every other browser connected via Source: WebSocket to the same relay, over that same connection β no separate setup. A browser that joins later gets the current cue sheet immediately (the relay caches the latest snapshot for new connections). The one thing that's deliberately not synced is the transient "Running" highlight β each host computes that itself from the shared TC feed, so it stays consistent without fighting over the wire. Hosts using MTC (Web MIDI) as their TC source instead of WebSocket aren't on this channel and won't send or receive edits.
Status β a dropdown per cue: Standby / Ready / Running / Hold / Done / Cut. While a cue is the one currently under the TC playhead, its Status is automatically forced to "Running" (green, pulsing) β this overwrites whatever was set manually. The instant that cue's TC window ends, it reverts to "Standby" (not whatever it was before β a cue left on "Hold" or "Cut" will read "Standby" after the playhead passes through it). Driven purely by the chase engine, so it behaves the same regardless of TC source (Manual / WebSocket / MTC).
Dockerfile + docker-compose.yml run the relay and the cue sheet as two containers β relay (the WS relay, multi-stage build so the final image only carries the ALSA runtime lib, not the compiler) and cuesheet (nginx serving timecode_cue_sheet_4.html as a static site).
docker compose up -d --build- Relay:
ws://<host>:8765β defaults to the free-run generator from01:00:00:00, so it works out of the box with zero hardware/config, good for testing the cue sheet. - Cue sheet UI:
http://<host>:8080
Changing ports β both are env-var driven (${RELAY_WS_PORT:-8765} / ${CUESHEET_PORT:-8080} in docker-compose.yml):
CUESHEET_PORT=9090 docker compose up -d # one-off, this run only
cp .env.example .env # persistent β compose auto-reads .env
echo "CUESHEET_PORT=9090" >> .env
docker compose up -d
docker compose up -d --force-recreate cuesheet # already running, just remap (no rebuild)Recreating a container to change its published port is the ceiling of what Docker's port-publishing model allows β there's no true hot remap on a running container.
Switching modes β edit command: in docker-compose.yml for a persistent deployment:
command: ["--mtc", "TimeCore"]or override ad hoc without touching the file:
docker compose run --rm -p 8765:8765 relay --mtc "TimeCore"then docker compose up -d to apply.
Fake mode works anywhere β generated data, no L2 or USB dependency, so default bridge networking is fine on any Docker host, including Docker Desktop on macOS.
MTC mode needs Linux + USB/network MIDI passthrough β MIDI access doesn't cross the Docker Desktop macOS VM boundary cleanly. On a Linux host, uncomment in docker-compose.yml:
devices:
- /dev/snd:/dev/snd
group_add:
- audio
command: ["--mtc", "MOTU"]That covers local/USB MIDI. For an RTP-MIDI network session (Horae or TimeCore), join the session with rtpmidid on the Linux host first (outside the container) β it creates a regular ALSA MIDI port, which then just needs the same /dev/snd passthrough to reach the container.
If the container still can't see the interface after that, privileged: true is the blunt fallback β wider blast radius, only reach for it if the device mapping + group approach doesn't resolve ALSA permissions. On macOS, run tc_relay.py --mtc "..." natively for MTC (see Requirements above) β Docker isn't the right tool for that mode there.
vs. systemd β for a dedicated always-on Pi doing MTC capture, the systemd unit below is still the better fit: direct hardware access, no container/USB passthrough fuss, journalctl for logs. Docker's the better fit for Fake mode, or when you want the relay + cue sheet reproducible/portable across machines.
sudo tee /etc/systemd/system/tc-relay.service << 'EOF'
[Unit]
Description=Timecode WebSocket Relay
After=network.target
[Service]
WorkingDirectory=/var/www/showcontrol
ExecStart=/usr/bin/python3 /var/www/showcontrol/tc_relay.py --mtc "MOTU" --ws-port 8765
Restart=always
RestartSec=3
User=pi
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now tc-relay
# check status
sudo systemctl status tc-relay
journalctl -u tc-relay -fChange --mtc "MOTU" to --mtc "TimeCore" (or whatever the RTP-MIDI session/port is named once joined) or --fake 01:00:00:00 as needed.
| Mode | Command |
|---|---|
| MTC from MOTU interface | python3 tc_relay.py --mtc "MOTU" |
| MTC over RTP-MIDI (Horae/TimeCore) | python3 tc_relay.py --mtc "TimeCore" |
| MTC, custom WS port | python3 tc_relay.py --mtc "USB MIDI" --ws-port 9000 |
| Free-run from 1h | python3 tc_relay.py --fake 01:00:00:00 |
| Free-run, 25fps | python3 tc_relay.py --fake 00:59:50:00 --fps 25 |
No module named rtmidi
pip install python-rtmidi --break-system-packagesMIDI port not found
python3 -c "import rtmidi; print(rtmidi.MidiIn().get_ports())"
# use a substring of the printed port name in --mtcRTP-MIDI session (Horae/TimeCore) not showing up as a MIDI port
- The relay never talks to the network directly β the OS must join the RTP-MIDI session first (Audio MIDI Setup on macOS,
rtpmididon Linux, the Tobias Erichsen driver on Windows) - Once joined it appears in
rtmidi.MidiIn().get_ports()like any other port β if it's not there, the session wasn't joined, not a relay problem
Browser says WS connection refused
- Confirm the relay is running:
sudo systemctl status tc-relay - Confirm the port is open:
ss -tlnp | grep 8765 - Confirm no firewall blocking:
sudo ufw allow 8765/tcp
TC displayed but frozen in browser
- MTC source stopped sending β relay freewheels for 2s then freezes
- Check MIDI cable / interface driver, or that Horae/TimeCore is still transmitting on the network session
Web MIDI (in-browser MTC) not working on LAN
- Chrome requires a secure context for Web MIDI (
https://orlocalhost) - On plain
http://192.168.x.x, use the relay instead β connect via WebSocket source in the cue sheet
MTC hardware ββUSB/DINββββββββΆ python-rtmidi
MTC network ββRTP-MIDIβββββββΆ (OS joins session, appears as a MIDI port)
(Horae / TimeCore) β
Free-run generator βΌ
asyncio broadcast βββΆ ws://host:8765
β
ββββββββββββΌβββββββββββ
Browser 1 Browser 2 Browser N
(cue sheet HTML, any device on LAN)