Luminator Technology Group
CreatorStudioDOCUMENTATION · product documentation

CreatorStudio Suite — Documentation

One consolidated document, generated from the repo's source docs. It always reflects the current sources: the portal assembles it live for the Documentation Center (/docs), and npm run docs regenerates DOCUMENTATION.md + DOCUMENTATION.html for offline distribution. Edit the individual docs listed below — never this built file.

Contents


CreatorStudio Suite — Overview

CreatorStudio is a complete passenger-announcement platform for public transit. One team authors what the whole fleet says — the stops, the connections, the safety and service messages — in a visual web app, presses Publish, and every vehicle immediately speaks and displays the right thing at the right moment, in the right language, with a tamper-evident record of what each passenger actually heard.

It replaces per-vehicle programming and vendor-locked hardware workflows with one authoring tool, one always-on engine, and open standards (MQTT PIS-PT in; ADT audio + LED/TFT sign frames out). It runs in the cloud for a whole operator or as a single container on the vehicle — same software either way.


What it does — at a glance

🎧 Author Build announcements as playlists — spoken text, live variables ({nextStop}, {connectingServices}…), pauses and audio clips — in a browser. A worked example library ships to start from.
Trigger 33 built-in transit triggers (approaching / arrived / departing, doors, stop request, detour, disruption, last stop…) plus custom fact-based triggers over 40 live journey facts, universal prerequisite gates (door / stop-button / speed) and geofence zones you draw on a map.
🗣️ Speak Neural text-to-speech from Azure, ElevenLabs or Acapela (swap suppliers without re-authoring), ~50 voices across a dozen languages, per-line voice and bilingual "also speak in".
Show Author full LED sign templates (layouts · displays · vehicle faces) that match the Luminator MatrixRenderer Presentation Schema — Text / Image / Rectangle, scroll, blink, colour rules, symbols — then render interior LED, exterior front/side/rear and TFT live, in real FNT fonts, as mono or RGB Mobitec FF frames — or Hanover HCPS/SuperX frames for Hanover panels. Sign control is vendor-independent: one render pipeline, a protocol adaptor per sign vendor. The same trigger that speaks can override which layout each face shows.
🧾 Prove A durable, tamper-evident proof-of-play audit trail: what was dispatched vs what the vehicle confirmed it played, where and when — exportable as signed evidence for accessibility (ADA) compliance.
🖥️ Operate A live fleet cockpit: heading-up map, per-vehicle event feed, sign previews, performance/health telemetry, and a route simulator to test announcements before they reach a bus.

The suite — how the pieces fit

publish config

live sync ~1 s

synthesize (cached)

journey data

PIS data

audio + LED

monitor + control

announcements

passenger info

On the vehicle

🔊 Vehicle player
Go · Python · browser
→ loudspeaker · interior/exterior LED signs

📱 Passenger information app
Onboard live screen — current & next stop,
route & destination (browser /player)

🎨 Suite — Creator
Authoring: playlists, LED signs, triggers, voices (same app)

☁️ Azure PostgreSQL + Blob
Config store + live notify · shared MP3 cache

🗣️ TTS cloud
Azure AI Speech · Acapela · ElevenLabs

🛰️ Fleet journey data
Live PT-PIS trip data, one stream per vehicle

📡 MQTT broker
The integration hub — topics are the API

⚙️ Suite — Engine
Trigger + audio + matrix LED pipeline (same app)

🖥️ Web dashboard
Monitor cockpit · diagnostics · proof · history

How an announcement happens

From a live trip signal to sound on the bus and pixels on the signs — one pass through the engine:

miss → synthesize

ack when the clip finishes

key = hash(text + voice)

CreatorStudio Engine

approaching-stop · doors-open · detour…

VehicleContext
merge topics → journey state

Trigger engine
edge-detect · debounce · gates

Pipeline
trigger → playlist(s) · LED layouts · priority

Render
resolve {variables} → MP3 + matrix frames

🛰️ Live PIS-PT trip data
{tenant}/{vehicleId}/pis/0/#

MP3 cache
one synth per phrase, fleet-wide

☁️ TTS supplier

🔊 vehicle player

▦ interior + exterior LED
FNT · FF mono/RGB

🧾 proof-of-play

Only state-changing signals wake the trigger engine (the high-frequency GNSS firehose is gated), identical phrases are synthesized once for the whole fleet and reused from the cache, and the played-ack closes the loop so the audit records what a passenger actually heard — not just what was dispatched. Sign faces resolve through the same trigger: a firing trigger can override the layout on each vehicle face; otherwise the bound Display cycles pick the active layout from live journey state.


Customer benefits


Where to go next

This documentation is ordered high-level first, then progressively deeper into the technical detail:

  1. Screens & Features — a visual tour of every screen and what it does.
  2. Functional Specification — what the system does, in requirement-level detail (actors, use cases, functional & non-functional requirements, data model).
  3. Architecture — how it's built: backend/client architecture, software design, interfaces, the runtime sequence, performance and security.
  4. Scaling — the numbers and the knobs behind "thousands of vehicles from one engine".
  5. On-Vehicle Deployment — running the engine as a container on the bus.
  6. Setup & Reference — install, configure, run and deploy (developer/operator reference).

CreatorStudio Suite — Screens & Features

A visual tour of the product. The suite is three web apps behind one portal: CreatorStudio (design what the fleet says), the Engine dashboard (watch it run and prove it), and the Vehicle player (hear it on the bus). Every image below is a live view of the running apps — captured 12 August 2026 (a few panels 31 July) from a cloud deployment of the suite (an Azure VM) riding the live Baltimore MTA feed — on the August day of capture the engine was tracking 7 300+ real vehicles, 3 077 of them on a journey — alongside an Arriva rail trigger configuration and a Vilnius (Judu) LED-sign set, so each screen shows the product operating on real fleet data, not a staged demo.

Platform home

Platform home

In the capture: the portal home in its dark theme. Each card states its promise in one line — the Documentation Center's "Every document in one place", the Player's "plays that vehicle's announcements the instant the engine speaks them, and reports back what actually played", the LED Signs card's "rendered with the exact pixels the engine drives the real signs with" — with capability chips (Playlists, Triggers, TTS & SSML, Lexicon; Any device, No install; Pixel-identical, Kiosk ready) summarising what waits behind each door.

The portal landing page — five doors into the suite from one origin: the Documentation Center (every document, one place), CreatorStudio to author announcements, the Engine Monitor to watch the live fleet, the Vehicle Player to turn any device into a bus speaker, and LED Signs to turn any screen into a vehicle's sign. One sign-in, one design system, everything served together — and the same doors are always one click away on the suite rail inside each app.

Documentation Center

/docs — the one entry point for everything written about the platform: the handbook (this document and its siblings stitched into one continuous read, assembled live from the repo on every request, so it can never go stale against a build), the stakeholder and showcase decks, the printable leave-behinds, and the raw sources. Chapters deep-link, and the Markdown is served raw for anyone who wants to diff it. Neither app carries a documentation panel of its own — they link here, so there is exactly one published copy.

Documentation Center — the handbook

In the capture: the handbook's chapters — Overview & Benefits through Architecture, Hanover LED Signs, Security, Licensing & Packaging, Test Report and Setup & Reference — each a tile naming the source file it is assembled from, under the two ways in: "Read in the browser" and the raw live Markdown. (Captured before the newest chapter joined: the as-built Product RequirementsPRD.md — now sits as section 3, between Screens & Features and the Functional Specification.)

Documentation Center — decks and leave-behinds

In the capture: the presentations grid — the 28-slide Product Deck (arrow keys to present, N for presenter notes, ?print for PDF), the Stakeholder Master Deck, the Showcase Presentation, System Overview & Architecture, the printable leaflet and one-pager, the Software Team Briefing, and "Built with AI — By the Numbers", the measured record of this product's own build.

Documentation Center — the sources

In the capture: the bottom of the page states the contract — the handbook is generated, so you edit the sources (OVERVIEW.md, SCREENS.md, ARCHITECTURE.md…) and never the built output — and "Where things live" maps the repo: docs at the root, screenshots under docs/media/, decks under docs/showcase/. This page is the only place the docs are published; every app's Documentation link comes back here.

User Manual

/manual — the Documentation Center's learn-by-doing companion (added after the capture above, so the portal now has six doors): an interactive, use-case-based user manual embedded in the suite. Each guide is a goal a real user walks in with — "make the bus announce the next stop", "fix a mispronounced stop name", "prove what passengers actually heard" — and walks the real screens step by step over the same live screenshot set this document embeds, with a deep link into the running app at the moment each step needs it. Guides are searchable ("What do you want to do?"), filterable by topic, and tickable — per-step progress lives in the browser, so a new author can work through the catalogue over days. The suite rail carries a Manual entry at its foot in both Angular apps, so help is one click away from inside the product, and /help lands there too.

CreatorStudio — design the announcements

Assistant — describe it, don't build it

Assistant

In the capture: the Assistant's entry screen — four worked example prompts ("Play a chime when the doors open, and say 'stand clear of the doors' as they close", "Warn passengers 200 metres before their stop, and only after 22:00"…), a free-text box, and the promise printed right under it: "Nothing changes until you review and apply."

Describe what passengers should hear — "announce the next stop inside the bus, and the route and destination outside" — and the assistant proposes the playlists and triggers that produce it: the wording, the variables, the output channel, the thresholds, and a custom rule when the request isn't a journey moment ("only after 22:00").

Nothing changes until you say so. Every proposal is itemised on a review card — what it would create, what it would switch on, and what it would replace — each item can be unticked, and applying the whole plan is a single undo. The model never writes the config: its answer is rebuilt field by field against the real catalogue first, so an invented trigger type, a fact the vehicle feed doesn't carry, or a {variable} the engine can't resolve is dropped and reported rather than published.

It runs on your own machines. The assistant asks the portal first, which talks to any OpenAI-compatible model — a local Ollama or LM Studio, or a hosted provider if you prefer — so no announcement text has to leave the site and there is no per-call cost. The screen names the model that answered. With nothing configured it falls back to a cloud service, and then to a keyword draft marked Offline draft; the review-and-apply path is identical either way.

Playlist library

Playlist library

In the capture: the Baltimore project's full repertoire — 18 playlists, 16 of them interior (Approaching Stop, Arrived at Stop, Doors Open, Connection Info, Final Destination Announcement…), one exterior (Route to Destination) and one on both channels ("Bus not in service. New trip starting soon"), each row showing its language, voice, element count and last-edited date at a glance — and, in the left nav, the Audio sub-menu the playlist family lives under: Playlists, Voice, TTS suppliers, Pronunciation, Volume.

Every announcement is a playlist — an ordered list of spoken text, live variables, pauses and audio clips. They're grouped by where they play (interior / exterior / both) with language and element count at a glance. Create, duplicate or edit one inline; a worked-example library ships to copy from. Edits go live fleet-wide the moment you Publish — no redeploy.

Playlist editor

Playlist editor

In the capture: the interior "Next Stop" playlist — speaker routing offered as Interior / Exterior / Both, one voice chosen for the whole playlist, an "Also speak in" picker ready to add a bilingual repeat, and a single static-text element "The next stop is {nextStop}." at 100 % volume with its own volume slider and an optional custom voice for just that line.

Build one announcement element by element: static text, dynamic variables ({nextStop}, {destination}, {connectingServices}…), pauses and pre-recorded clips. Set the voice per playlist — or per line — and the speaker routing. "Also speak in" repeats the whole announcement in a second language and voice for bilingual stops, and Preview auditions it through the real TTS voice.

Triggers — one unified list

Trigger list

In the capture: the whole rule set on one screen — All (39) = the 33 built-in transit triggers plus Baltimore's custom rules, filterable to Active (7) / Off (32) — Journey: Activated and Running, Doors Close, Time to Stop, the three distance thresholds, Approaching Last Stop, Detour, Onward Connections and Service Alerts among the rows, each with its playlist assignment and its LED SIGNS column, because audio and signs are parallel outputs of one event. "Assign default playlists" and "Assign default LED signs" wire the standard setup in one click each.

Built-in and custom triggers live in one list — 33 built-in transit triggers (approaching / arrived / departing a stop, doors, stop request, detour, disruption, last stop and more) and every custom rule beneath them, with Active / Off / All filter chips and a Geofences tab beside. Switch each on per service and map it to the playlist it should speak, with conditions (distance / time thresholds, speed, occupancy) and a priority. The engine fires them automatically from the live trip feed.

Trigger row expanded

In the capture: "Arrived at Stop" opened in place — the AUDIO group (single playlist or an interior/exterior split, sequences and repeats), the PREREQUISITES gates (doors, speed), PRIORITY level 5 with interrupt / queue toggles, and the LED SIGNS group binding one template per face, previewed as live amber pixels: "20 CITY CENTRE" on front and side, the bare "20" on the rear.

Expanding a row reveals everything that trigger does: its audio (a single playlist, or split interior/exterior channels, each optionally a sequence with a repeat count), its prerequisite gates (door open/closed, stop button, speed window — the announcement is suppressed unless every set gate holds), its priority and interrupt/queue behaviour, and the LED layout per vehicle face for as long as it fires.

Custom (fact-based) triggers

Custom triggers

In the capture: an Arriva rail rule built from raw PIS data — "2 min before next stop (fallback, no side/transfer data)": WHEN time to the next stop is above 0 s and at most 120 s AND the journey state is JOURNEY_RUNNING AND (in a nested NOT group) neither the exit side nor any onward connection is known — THEN play the basic two-minute announcement and wait 180 s before it may fire again, with the per-face SIGN bindings folded beneath.

When no built-in trigger fits, compose your own: a Scratch-style boolean expression (ALL / ANY / NOT) over 40 live journey facts — doors, speed, occupancy, connections, exit side, the stops ahead, the destination code the driver keyed in, traction-battery charge, even the engine clock and calendar. Try it feeds sample values and tells you whether it would fire. The very same evaluator then runs in the engine — and the same facts gate the LED sign cycles, so a rotation and an announcement are driven by one rule set.

Custom triggers driving LED faces

In the capture: two Vilnius depot triggers built from raw PIS data — "Trip id matches pattern -[abc123]*d[123]*-" and its inverse — each also driving per-face LED layouts, previewed as live pixels: "20 GRĮŽTA Į PARKĄ" (returning to the depot) on one, "20 CITY CENTRE" on the other, with the "Try it" sample-value tester folded beneath.

Use case: a rail operator's announcement scheme, no code

Arriva trigger set

In the capture: the Arriva tenant's live rule set — eight active triggers, all custom: "2 min before next stop" in a side-and-transfers-aware variant and a data-poor fallback, "2 min before the last stop" and "reached the last stop" each in exit-side/connections and basic variants, and "2 min before departure from the first stop" split by fast vs local train on the trip-id pattern — every one mapped to its own Arriva playlist.

The custom-trigger builder is expressive enough to carry a complete operator scheme: Arriva's train announcements are eight custom rules and zero code — each pair degrading gracefully from "exit side and onward connections known" to a fallback wording when the feed is thinner, and the welcome announcement choosing its phrasing by matching the trip id against fast-train patterns (snel|express|IC).

Geofence zones

Geofence zones

In the capture: the Geofences tab beside the trigger list — two zones over the dark Baltimore map, a free-form purple polygon covering downtown and an amber circle to the north — named, colour-coded and listed on the left with edit / duplicate / delete actions, with "New circle" and "New polygon" ready, each zone usable as an "inside / outside" condition in any custom trigger.

Draw named zones on the map — circles or free-form polygons — and use "inside / outside this zone" as a condition in a custom trigger. Drag a corner to reshape, click an edge to add one. Announce a park-and-ride reminder inside a depot or a chime crossing a boundary — location-driven, no code.

LED Signs — layouts, displays & vehicles

Author the fleet's matrix signs alongside the spoken playlists. The LED Signs screen (/led-signs) is organised into tabs:

LED layout templates

In the capture: 376 templates staying navigable — grouped by category with the Destinations and Main stops drawers open, every row carrying a live pixel thumbnail, its panel model (SS3-FF 16×120, Ultima 32×240…), an element count and where it is used, with search, filters and bulk selection above.

LED vehicle roster

In the capture: the "Example vehicle (US)" roster, in use with six physical signs — Front, Front extra, Left, Right, Rear, Driver — each with its catalog panel, resolution, FF address and colour mode, and a live thumbnail of what that face renders right now: "20 CITY CENTRE" on the destination faces, the bare route number on the rear and driver panels. The three-step banner above spells out the model: a vehicle is its signs, each sign shows a display, a display picks templates.

LED abbreviations

In the capture: the abbreviation table that lets one text fit every panel — all twelve Baltimore CityLink brands mapped to two-letter codes (CityLink RED → RD, BLUE → BL…), each rule with its "when tight" switch. The full brand fits a 200-column front sign; its code fits the 40-column driver panel; the same rules feed the fit checker.

LED symbols

In the capture: value → pictogram mappings driven by the Vilnius feed — a destination containing "Oro uosta" (airport) wears the plane symbol, "Stotis" (station) the train, "apylanka" (detour) the diversion sign — with an upload slot beside the built-in set for a fleet's own artwork.

LED route colours

In the capture: the per-line colour table — red "ordinary city line" chips for lines 11, 1 and 5, the green express 3G, the blue-and-yellow night N9, and Baltimore's CityLink BLUE and RED — each with background / text / outline swatches and a live rendered chip, importable straight from GTFS.

Layouts and displays publish with the rest of the config; the engine rasterises them with the same Luminator FNT fonts the hardware ships and emits mono or RGB Mobitec FF frames — or Hanover HCPS/SuperX frames for signs rostered with the Hanover protocol (vendor-independent).

Destinations — the pre-programmed destination lists

Reached from LED → Destinations: the left nav nests it under LED (and /led-signs/destinations redirects there), because a destination is not a peer of the sign authoring — it is what those signs show for a code the driver enters. The LED Signs screen links across to it as well.

A destination is the code a driver keys in and the text every sign then shows. The PIS-PT feed selects one by number (pis/0/destination's number, optionally scoped to a line by externalDisplay.lineCode) — the standard says the number alone is the normal case, so without a stored list a code-only selection leaves the signs blank.

The Destinations screen (/destinations) holds those lists, and the editor is WYSIWYG — you type on the sign:

Destination editor

In the capture: the "Vilnius (Judu) destinations" list in use, code 5 — Fabijoniškės on line 3G — open in the editor. The panel renders "3G Fabijoniskes" as live amber pixels with the honest warning that the latin-1 sign font draws š and ė as their base letters; four of the six sign tabs carry red fit badges, and "On the other signs" shows the same code on every face at once — including the Right panel clipping to "abijoniskes", exactly the problem the badges flag, and the green route-colour flag on the full-colour front-extra panel.

Destination symbol picker

In the capture: the built-in pictogram library opened from the editor — accessibility, on-board, vehicle, wayfinding and notice symbols, each previewed as real LED dots, with the size switch (8 / 12 / 16 / 24 px) choosing between separately hand-drawn glyphs, so what the picker shows at 16 px is pixel-for-pixel what a 16-px panel will display.

The list in use publishes with the rest of the config; the engine resolves an incoming code through it. Each list has a Priority setting (control bar on the Destinations screen) that decides how a matched code applies against the live feed:

A partial entry only overrides the fields it defines; a code with no list match leaves the live feed untouched, whatever the priority. The destination code and the line code are also trigger facts, so a custom trigger can fire on a specific pre-programmed destination. The Live Fleet Dashboard → Monitor → Displays drawer shows the current Destination code and whether it resolved to a pre-programmed entry (which then takes priority) or falls through to the live feed. With DESTINATION_LIST_PUBLISH=true the engine also publishes the list itself on pis/0/list/destinations so a driver console can offer the codes — off by default, since a real PIS system may own that topic.

Layout editor

Layout editor

In the capture: the 200×24 "Destination + Main stops" template at 6× zoom — the Destination element selected on the canvas with its inspector open (exact X/Y/W/H, content "{destinationMain}", an expression override, letter-case and symbol-slot rules) while the live-preview strip below renders the very frame the sign would show for the sample journey state.

A visual pixel editor for one layout: drag and resize Text, Image and Rectangle elements on a canvas that matches the sign resolution. Set overflow (Clip / Scale / Wrap / Scroll / WrapScale), FNT font ladder, alignment, scroll, alternation, blink and colours. Drop in the same live variables the audio playlists use ({nextStop}, {destination}, {lineCode}…) or a textExpression over globalState. Colour rules restyle the whole face when a field matches a value. Sample journey state drives an amber / RGB preview so what you see is what the bus shows.

Display editor

Display editor

In the capture: a display's three cycles — Not in service, Via destination, Main stops — each with a visual trigger ("Vehicle is not in service", "Destination has a 'via'") and, beneath it, the raw expression it compiles to; the Main stops cycle rotates two layouts on a 10 s / 5 s beat, and the live preview renders the winning layout ("20 CITY CENTRE") for the editable sample state.

Compose the cycle tree for one display: ordered cycles, each with a visual trigger condition (the same Scratch-style builder as custom triggers) that compiles to an enabledExpression, and timed rotations that point at a layoutId for a duration. A live preview shows which layout wins for the current sample state — the same resolver the engine uses at runtime.

Triggers → LED faces

On both the built-in trigger matrix and custom triggers, bind a layout per vehicle face (front / side / rear / interior) for as long as that trigger is firing. Audio playlist and LED override are parallel outputs of the same event — speak "Next stop Central" and show a dedicated destination layout at the same moment. "Assign default LED signs" fills the usual in-service / not-in-service defaults.

Route simulator

Route simulator

In the capture: Baltimore route 26 (Patapsco Station – Mondawmin), the 04:59 PATAPSCO LR trip with its 52 stops, mid-replay — the vehicle-in-use's LED faces rendering live per position ("26 PATAPSCO LR", the front-extra panel scrolling "Stopping at: Mond…"), door / stop-request / journey-state override controls, the event timeline auditioning each spoken line at Mondawmin Metro Station and PULASKI ST & BRYANT AVE with interior / exterior badges, and the full trip traced on the map.

Replay a real GTFS trip stop by stop and see exactly what would be spoken and shown on the LED signs at each event — before anything reaches a vehicle. The timeline models trip-start, approaching / arrived / departing, doors and stop-request (custom triggers interleaved); each row auditions through the real TTS and previews the active matrix layouts. Catch a silent stop, a wrong variable or a blank face on the bench.

Pronunciation lexicon

Lexicon

In the capture: the 69-entry lexicon with the clickable IPA phoneme palette and Import GTFS above, GTFS-imported expansions (ENT → "Ear Nose and Throat", EXPY → "Expressway", FS → "Far Side", GBMC → "Greater Baltimore Medical Center") each with a play button, an approval mark and a blue GTFS source chip — and below, the pre-recorded stop-names section with its bulk MP3 import, where voice-talent clips replace TTS per stop.

Fix how the voice says things: plain respellings ("AVE" → "Avenue") or IPA phonemes, optionally per language. Import stop and route names straight from GTFS and let AI expand the abbreviations — so "AACC" comes out "Anne Arundel Community College", not letter by letter. Applied everywhere the engine speaks.

The Audio sub-menu — voice, suppliers, pronunciation, volume

The audio family lives under Audio in the left nav — the voice is the audio output, not a setting, so it sits beside the playlists it speaks rather than behind a settings cog:

Screen Nav What it holds
Playlists Audio ▸ Playlists The announcement library (above).
Voice Audio ▸ Voice The voice, language and speed new playlists start from — plus one click to switch every existing playlist to it.
TTS suppliers Audio ▸ TTS suppliers Compare all 13 suppliers on the same line, then pick the one — and the voice — the engine renders with, and a failover.
Pronunciation Audio ▸ Pronunciation The phonetic / IPA lexicon and pre-recorded stop-name clips (above).
Volume Audio ▸ Volume Volume adaptation rules combining time of day, weekdays, route, stop and geofence.

The old /settings/… deep links still redirect to their new homes. Settings proper (below) keeps only what is genuinely project-plumbing: timezone, the GTFS feed, and the configuration file. The toolbar keeps the actions — undo/redo, import/export and Publish.

Project voice

In the capture: Audio ▸ Voice — English (US), voice Micah22k_NV at 1.00×, the one-click "All playlists use this voice" already confirmed ("All 18 playlists use Micah22k_NV"), and the "Which supplier speaks it" card linking across to TTS suppliers — with the reminder that voice changes reach the engine only on Publish.

Volume adaptation

In the capture: Audio ▸ Volume — the default volume at 80% and one rule live: "Night quiet hours", dropping announcements to 60% between 22:00 and 06:00. A rule combines any of time of day, weekdays, route, stop and geofence (all its conditions must hold; the most specific matching rule wins) — routes and stops picked from the imported GTFS feed, geofences from the circle/polygon zones drawn on the Triggers map. One-click examples ("Weekday rush boost", "Quiet zone") seed common rules.

Voices & TTS suppliers (Audio ▸ TTS suppliers)

Voices and TTS suppliers

In the capture: the supplier bench with the same line ("The next stop is Central Station.") ready to play through every card — twelve suppliers on screen: Acapela configured and selected as the engine supplier with voice Dimitris22k_NV, Azure Speech and ElevenLabs beside it, Google Cloud TTS / Amazon Polly / ReadSpeaker awaiting credentials with their market-scorecard ranks showing, the self-hosted Kokoro, Piper and eSpeak NG cards, and the endpoint-based Cerence, CereProc and Picovoice Orca — each with Play, "Use for engine" and "Failover" actions.

Play one line through every supplier — 13 of them: Azure Speech, ElevenLabs, Acapela, Google Cloud TTS, Amazon Polly, ReadSpeaker, the self-hosted Kokoro / Piper / eSpeak NG, the endpoint-based Cerence / CereProc / Picovoice Orca, and a silent mock — back to back, and pick which the engine renders with — and which voice that supplier speaks with. Both choices go live on Publish. The picked voice replaces the engine's env default (AZURE_SPEECH_VOICE and friends), so it is what every playlist left on "provider default" says. The A/B comparison uses the engine's real clients, so what you hear is what the fleet gets. Each supplier card shows capability badges — SSML lexicon, per-element volume, offline — straight from the engine's own capability descriptor, so what a supplier silently can't do (say, honour a per-line volume) is visible before it is picked, and the playlist editor repeats the warning next to any volume the active supplier would ignore. A second supplier can be marked as the failover: the engine renders through it while the primary's circuit breaker is open, so a vendor outage keeps announcements speaking (in the failover's voice) instead of degrading to the engine's fallback tone — and the dashboard's Health panel shows each supplier's synth count, mean latency and characters sent. (/voices and the old /settings/suppliers still work and land here.)

Settings — the project plumbing

General settings

In the capture: Settings — the timezone the calendar triggers run on (engine default / host local, with a live "Now in this zone" readout), the GTFS-feed slot that powers the Simulator and every stop picker (imported.zip · 65 routes · 13 733 trips · 3 963 stops), and the configuration-file card that makes the whole project one portable JSON.

Two file actions read alike and are not, so both say which is which: Import / Export (toolbar, and Settings → Configuration file) loads a file into the open project, replacing it; the project ⋮ → Import as a new project… leaves the open one untouched and creates a separate project.

Publish history & rollback

Publish history

In the capture: ten versions of the project, v10 tagged LIVE (published 12 Aug, 12:02), every earlier row one click from Restore, with publisher, timestamp and playlist / trigger counts per version — running on the cloud deployment, where the config_versions table stores the trail durably.

Every publish is recorded — who published what, and when, with playlist and trigger counts. The current version is tagged LIVE; restore any earlier version in one click (it re-publishes as a new version, so nothing is ever lost). Sortable columns and a free-text filter (publisher, note, version) keep long audit trails navigable. Governance for a fleet that speaks to the public.

Engine dashboard — operate & audit

The dashboard is six menu entries, deliberately few: Monitor (the cockpit — map, feed and the displays/source drawer), Performance & Health, Proof of Play, History, Play on a device and Published config — a read-only view of what CreatorStudio last published, not a settings screen; the dashboard has none, because every project setting belongs to the Creator and the operational context (source mode, tenant, vehicle, TFT layout) belongs in the cockpit drawer next to what it affects. Screens that were once separate pages — Live, Map, LED Signs — are panels inside the cockpit now, because an operator watching a bus needs them at the same time, not in different tabs; their old links redirect. Documentation is not a dashboard tab either: it lives in the Documentation Center (/docs), reached from the suite rail that every app shares.

Live monitor cockpit

Live monitor cockpit

In the capture: live vehicle 13027 on Baltimore's route 22 to HOPKINS BAYVIEW, 27% through its 60-stop journey — current stop 40th St & Rotunda Mall Dr, next University Pkwy "arriving now" — with the Source drawer scoped to the baltimore-md-mta tenant (8 tenants on the broker, 3 077 vehicles on a journey, the bridge forwarding 167 msg/s), the raw Inbound PT-PIS feed streaming beneath the vehicle picker, and a header pill flagging 2 450 vehicles silent for more than five minutes.

The operator's cockpit for one vehicle, all on one screen: a heading-up map (the bus always points up) showing the journey, stops coloured by what happened at each, and where every announcement fired; a live feed (this vehicle's timeline, or the whole fleet — click a bus to follow it); and a drawer of tools — the raw PIS-PT signal table, one-click signal injection, and the LED-sign / TFT previews.

Trigger inputs and signal injection

In the capture: the drawer's Triggers tab for the same vehicle — every fact the trigger evaluator sees, live, with its source topic (journey state, next stop 40th St & Rotunda Mall Dr, doors closed, speed 2 km/h), above the one-click injection buttons — stop request, doors open, off-route, crowded, alert, destination override, alarm — for exercising the engine without touching a bus.

Fleet feed

In the capture: the right rail switched to the whole fleet — stop requests, arrivals and departures from vehicles across CityLink RED and BLUE and route 63 scrolling in one feed — while the Source drawer filters the 11 vehicles currently on a journey and offers to serve them all.

LED signs & TFT preview

LED signs and TFT preview

In the capture: every surface of vehicle 13027 mid-journey on route 22 — the interior LED scrolling "NEXT STOP IS UNIVERS…", the exterior front / side / left / rear faces showing "22 HOPKINS BAYVIEW", and the 1920×610 interior TFT beneath on its published layout — all driven by one live journey state, while fleet vehicle cards (Departing / Arrived at Stop) stream in the right rail.

See what the passenger sees: the interior LED dot-matrix, the exterior front / side / rear destination signs, and the interior TFT — all rendered live from the engine's output using the same font the hardware ships, so the preview matches the bus exactly.

LED Signs (live matrix)

The cockpit's Displays drawer loads the published layouts / displays / vehicle roster and renders every face from live MQTT fleet state (or a sample journey). The same cycle + trigger-override pipeline the engine uses decides which layout is active; interior / announcement-mode faces mirror the spoken text. Reload from the running config, or jump to Creator when nothing is published yet. This was a page of its own (/signs) until it became clear it was a second view of what the cockpit already shows — the route now redirects to the cockpit.

Performance & timing

(Performance and Health share one menu entry, switched by a segmented control — they are two reads of the same live engine beat.)

Performance and timing

In the capture: a fully warmed cache — four live announcements (approaching / departing / arrived at stop), every one a cache hit: 3 ms average lead time end to end, 0 ms average TTS, 100% cache hit rate, the timing bars showing resolve / render / publish with no synth segment at all. The cold phrase is paid for once, then the whole fleet reuses it in single-digit milliseconds.

Per-announcement latency broken into resolve / render / TTS / publish, with the cache hit-rate. A cached phrase completes in about 2 ms; a cold synth ~1 s, then it's cached and reused fleet-wide. Prove the engine is fast — and see exactly where any slow announcement spent its time.

Health & telemetry

Health and telemetry

In the capture: one engine tracking 7 322 real vehicles at ~400 msg/s with 21 ms event-loop lag and 249 MB of memory — 8.4 trigger events/s derived from the stream, the upstream bridge at 158 forwarded messages/s (109 597 in total this session), and the rolling telemetry table underneath. The same numbers the load ladder measured, now on live traffic.

Live engine vitals — messages/s, events/s, event-loop lag, memory, cache hit-rate, in-flight / queued / dropped renders, synth-dedup — plus upstream-bridge throughput. Sparklines and a rolling table flag a stale or struggling engine at a glance.

Announcement history

Announcement history

In the capture: 48 records for vehicle 13027's route-22 run — every row showing the trigger (Approaching / Departing / Arrived at Stop), the stop, the announced text with its LED line, the language and voice, the interior output channel and the render time in milliseconds.

Its own menu entry beside Proof of Play: the durable trail of every announcement the engine has spoken, across restarts — vehicle, trigger, stop, transcript and the lexicon-applied spoken text, voice and language, volume and timing. Filter by vehicle / trigger / free text, auto-refresh, export CSV. Proof of Play answers what a passenger was proven to hear; History answers what the engine said — different questions, so they are no longer stacked behind one segmented control.

Proof of Play — the audit trail

Proof of Play audit

In the capture: a live route-22 run under audit — the journey group (47 records, 11:51–12:03) expanded straight into its evidence: each record a rendered LED strip ("THE NEXT STOP IS…", "NOW ARRIVING AT…") with AUDIO / INTERIOR surface badges and its firing trigger per stop, under the group-by chips (journey / route / destination / date / hour / vehicle) and the CSV / JSON proof-pack export.

The durable, tamper-evident record of what was actually played and shown, where and when — grouped here by journey, each record with the LED transcript. This is the accessibility (ADA) compliance evidence: dispatched vs vehicle-confirmed played, exportable as a signed CSV / JSON proof pack.

Proof of Play — every record

Proof of Play records

In the capture: the same audit flattened to one row per record — all 47 of 47 for the HOPKINS BAYVIEW run, each carrying its time, vehicle, surfaces (AUDIO / INTERIOR), route → destination, the announced content, the stop, its GPS location and the firing trigger, under the filter bar with its tenant chip and the CSV / JSON export.

Proof of Play — on the map

Proof of Play map trail

In the capture: all 321 records as dots over the Baltimore map — the route corridors through downtown, out to Dundalk and Middle River, traced by time-ordered trail lines; every announcement of the afternoon verifiable by the place it fired, one click from its transcript.

The same audit plotted geographically: a time-ordered trail of every announcement along the route (audio vs exterior-sign changes), each point clickable for its transcript and detail. Prove an announcement fired at the right place, not just at the right time.

Proof of Play — replay

Proof of Play replay

In the capture: record 9 of 47 — vehicle 13027 on route 22 — re-rendered as the passenger experienced it: the interior LED scrolling "…T STOP IS LIBERTY HEIGH…", the front and side signs showing "22 HOPKINS BAYVIEW", and the spoken transcript beneath ("Next stop is LIBERTY HEIGHTS Avenue & DRUID PARK Drive Northbound"), with transport controls to step the journey frame by frame.

Step through a journey and re-show exactly what a passenger experienced — the interior LED, the exterior signs and the spoken transcript — reconstructed from the record, frame by frame. (The record is the proof; the replay re-voices it for review.)

Play on a device

Play on a device

In the capture: the QR code for vehicle 13027 ready to scan, with open-here and copy-link alternatives — and the "Before you blame the player" card that saves the first support call: tap Start once (browsers block audio until touched), check the iPhone silent switch, reach the broker's WebSocket port, turn the volume up.

Turn any phone, tablet or the onboard unit into a vehicle's speaker: scan the QR code and that device plays the vehicle's announcements the instant the engine speaks them — and reports back what actually played, closing the proof-of-play loop. No install.

Published config (triggers → playlists)

Published config

In the capture: 18 playlists and 33 triggers as the engine runs them — cards for Trip Start, Approaching / Arrived / Departing Stop, Doors Open (At Stop), Stop Request Button and Journey: Not In Traffic, each naming the playlist it speaks with every variable a distinct chip — read straight from the running process, with Import config and "Edit in CreatorStudio" beside the reload.

What the engine is executing right now, read straight from the running process: every trigger and the playlist(s) it speaks, with the templated text ({routeNumber}, {nextStop}, {connectingServices}…). The definitive "what will this bus say" view, plus a config import for offline setups.

On the vehicle

Passenger information app

Boarding a vehicle

In the capture: the boarding screen — type or scan a vehicle (here 13023), "Board this vehicle" or "Show this vehicle's LED signs", and the hand-off QR whose link carries the ids in the path (/player/v/baltimore-md-mta/13023), so it survives every camera app and proxy; the next-stop card waits for the journey.

Passenger information app

In the capture: the boarded rider view on live CityLink LIME data — "This service to northwest hospital", current stop Saratoga St & Saint Paul St, 5 of 52 stops done — with the personal "getting off at" picker set to Edmondson Ave & Arlington Ave and its "11 stops until" alert banner counting down, and an "End & leave" control, all in a plain browser page.

The browser player doubles as a passenger information system: the live headsign (route → destination), current and next stop with an arrival countdown, journey progress, and a personalised "getting off at" stop picker that counts down and alerts you the moment your stop is next. It rides the vehicle's existing feed, speaks the announcements, keeps working offline, and needs no install.

LED signs on any screen

LED player

The player's LED half (/player/led) — like the standalone /sign and /vehicle pages — turns any browser into the vehicle's signs, rendered with the exact pixels the engine drives the hardware with.

In the capture: all six faces of live vehicle 24056 on one page — the amber front sign (route RD to UM MEDICAL CENTER over a scrolling stops line), the full-colour front-extra panel drawing the RD flag white-on-red, both side signs, and the rear and driver panels showing the bare route code — each at its true resolution with the real FNT fonts, each openable full-screen on its own display, with a kiosk view for the whole board.


CreatorStudio Suite — Product Requirements Document (PRD)

Document type: Product Requirements Document — as-built requirement specification · Status: v1.0 Date: 2026-08-12 · Owner: Product · Applies to: main @ 2f9798e


About this document

This PRD is the requirement specification for everything the CreatorStudio Suite has implemented. It was written from the shipped system: every requirement below is implemented and verified unless its row explicitly says otherwise. Use it to answer "what must the product do?" with the confidence that the answer is also "what the product does do, today".

It complements — and does not replace — its siblings:

Document Answers
This PRD The complete, current requirement catalogue — every capability, numbered, with status
FUNCTIONAL-SPEC.md The baseline functional description (v1.0, 2026-07-14) with detailed acceptance criteria, data model and gap register
SCREENS.md Screen-by-screen feature tour with screenshots
ARCHITECTURE.md How it is built — modules, dependencies, patterns
SCALING.md How it holds up — measured limits, phased plan to 2000 vehicles
SECURITY.md Security posture, open risks
TEST-REPORT.md The measured test inventory backing the verification claims here

Conventions. Shall = mandatory and implemented. Requirement IDs are PRD-<AREA>-<nn>. The Spec column cross-references the deeper acceptance criteria in FUNCTIONAL-SPEC.md (FR-…) where the baseline already covers the requirement; new marks capability shipped after that baseline (169 commits between the two documents — the deltas are called out throughout). Known deviations reference the spec's §10 defect register (D-nn).


1. Product overview

1.1 Problem

A transit vehicle must tell passengers where it is and what happens next — audibly (saloon speakers) and visually (interior scrolling sign, exterior destination signs) — in the passenger's language, automatically, without the driver touching anything, identically on every vehicle in the fleet. Under accessibility legislation (ADA and equivalents) the operator must also be able to prove afterwards that those announcements actually happened. Historically this content is baked into vehicle firmware: changing a phrase means a software release and a fleet-wide reflash.

1.2 The product

One sentence: a non-programmer authors what the fleet says and shows; the engine executes that content live against each vehicle's real-time trip feed — synthesising speech, rendering LED-sign graphics for multiple sign vendors, dispatching both back to the vehicle — and records durable, vehicle-confirmed evidence of what passengers actually heard and saw.

The suite is five deliverables behind one portal (:8080):

Deliverable Where Role
CreatorStudio (authoring app) /creator/ · web/creator/ Design playlists, triggers, LED templates, voices, lexicon; publish to the fleet
CreatorStudio Engine src/ Always-on executor: MQTT PIS-PT in → triggers → TTS + LED render → ADT audio + sign frames out
Engine dashboard /monitor/ · web/ Monitor, operate and audit the running fleet
Onboard players clients/go · clients/python · /player Play one vehicle's clips; acknowledge completed playback
Documentation Center /docs The consolidated handbook, decks and leave-behinds, assembled live from the repo

1.3 Product goals

# Goal Delivered by Measure
G-1 Announcements without driver action Trigger engine derives events from the live feed Zero driver input per journey
G-2 Content changes without a software release Publish → engine hot-swap in ~1 s Time from edit to fleet-live
G-3 Provable accessibility compliance Proof-of-play with vehicle-confirmed acks + certified coverage report Coverage % (confirmed ÷ correlatable)
G-4 Fleet-scale at controlled cost Content-addressed TTS cache — one synth per unique phrase, fleet-wide TTS cost scales with phrases, not vehicles
G-5 Multi-operator platform Tenant-scoped projects, config, audit; one engine per tenant Tenants share no data
G-6 Vendor-independent hardware One render pass emits Mobitec FF and Hanover HCPS frames; three interchangeable players Mixed-vendor fleets on one pipeline
G-7 Degraded-network resilience Layered caches, pre-push, store-and-forward proof, on-vehicle deployment option Announcements survive a degraded WAN

1.4 Users

Actor Goal Primary surface
Announcement Author (operator staff, non-technical) Design what the fleet says/shows and when CreatorStudio (/creator/)
Fleet Operator / Dispatcher Watch the live fleet; scope served vehicles; inject test signals Engine dashboard (/monitor/)
Compliance Officer / Auditor Prove announcements played; certify coverage; export evidence Proof of Play + /proof/report
Field Technician Commission a vehicle; spot-check audio on a phone Browser player (/player), QR hand-off
Passenger (indirect) Hear and see where the vehicle is going Speakers + LED signs
Integrator / Sign controller Drive physical LED signs over RS-485 Per-address FF / Hanover raw-frame topics

1.5 Scope

In scope: authoring; publication and config lifecycle; trigger evaluation; speech synthesis (13 suppliers); interior/exterior/matrix-template sign rendering (FF + Hanover); dispatch and priority arbitration; onboard playback and clip caching; proof-of-play capture, reporting and durable persistence; fleet monitoring and diagnostics; deployment back-office, on-vehicle and offline-bundle.

Out of scope (deliberately): vehicle hardware and wiring; the upstream trip-data system (PIS-PT/GTFS-RT is consumed, not produced); driver-facing UI; passenger mobile apps; ticketing.


2. Functional requirements

2.1 PRD-PRT — Portal, suite UX and documentation

ID Requirement Spec
PRD-PRT-01 All web surfaces shall be served from one portal origin (:8080): / → the Monitor cockpit (default screen), /portal the landing menu, /creator/ the authoring app, /monitor/ the dashboard, /player the browser player, /docs the Documentation Center. Legacy routes redirect. new (root→monitor)
PRD-PRT-02 Both Angular apps shall share one design system — the Figma/PrimeNG theme (@primeuix/themes preset), dark scheme by default — and a persistent left rail (Creator / Engine / Player, with Manual / Documentation at the foot) so switching apps is one click. The kit is extracted as the reusable packages/suite-ux package (tokens, base CSS, rail/theme components, shell templates) for the next product. new (suite-ux)
PRD-PRT-03 Monitor and Creator shall be responsive (960/640 px breakpoints, drawer nav on small screens, both orientations) with touch affordances (coarse-pointer hit areas, tap-to-open legend/popovers). new
PRD-PRT-04 The Documentation Center shall assemble the handbook live from the repo's Markdown on every request (/documentation.html, raw /documentation.md), and serve the decks and leave-behinds. npm run docs regenerates the distributable copies; CI fails if the built copy drifts.
PRD-PRT-05 Left-nav sub-menus are the navigation pattern for sectioned screens (URL-driven ?tab=, shown only while the section is open) — in-page tab strips are not used. new
PRD-PRT-06 The portal shall serve a /build-stats page — the measured record of the AI-assisted build (figures baked in, refreshed by scripts/build-stats-measure.mjs). new
PRD-PRT-07 The portal shall serve an interactive, use-case-based user manual at /manual (alias /help) — "I want to…" guides that walk the real screens step by step over the live screenshot set (docs/media/), with live search, topic filters, per-step progress ticked off in the browser, a screenshot lightbox and deep links into the running apps. It is embedded in the suite: a portal card, a Documentation Center card and the suite rail's Manual item all lead there. new

2.2 PRD-PRJ — Projects, tenancy and configuration lifecycle

ID Requirement Spec
PRD-PRJ-01 Authoring shall be organised into Projects — named, self-contained configs (playlists, triggers, voices, lexicon, geofences, volume rules, LED templates). Create/rename/duplicate/delete/import/export; deleting the last project is refused. FR-CFG-01
PRD-PRJ-02 Exactly one project shall be published per (tenant, fleet); publishing upserts engine_config and appends a monotonic config_versions row in one transaction, recording the source project. FR-CFG-02/06
PRD-PRJ-03 Publication shall be blocked while any enabled trigger references a missing playlist; the config shall be linted live on a dedicated Issues screen with a nav count badge — errors (unspoken playlists, unresolvable variables, dangling playlist refs, never-fires triggers) and warnings (dead single playlist behind a split, event variables the trigger cannot provide, gate-aware variable checks on custom triggers), each deep-linking to the offending entity. FR-CFG-03/10
PRD-PRJ-04 The engine shall hot-swap a published config in ~1 s without restart (Postgres LISTEN/NOTIFY, or the retained MQTT config notify + portal API for vehicles that cannot reach the database). FR-CFG-04/05
PRD-PRJ-05 Config reads shall be remote-first with the on-disk file (CONFIG_PATH) as hot-reloaded offline fallback; CONFIG_SOURCE_ENABLED=false forces the file. FR-CFG-05
PRD-PRJ-06 An author shall roll back to any earlier version in one click; restore re-publishes as a new version — history is append-only. FR-CFG-07
PRD-PRJ-07 All authoring state shall be tenant-scoped (tenant resolved from ?tenant= → host-shell global → last used → default); tenants share no projects, config, history or audit data. FR-CFG-08
PRD-PRJ-08 Persistence shall be API-first (same-origin portal Creator API, GET/PUT/DELETE /api/projects, debounced saves flushed on page hide) with a localStorage fallback that covers offline development, and visible status chips ("Offline — local only", "Local backup paused", "Not saving") when cloud or local storage degrades. A legacy single config migrates into a "Default" project automatically. new (post-Supabase)
PRD-PRJ-09 Config shall round-trip as a versioned JSON envelope (luminator.announcement-config, currently schemaVersion 1.5.0) interoperable with the engine and the legacy app; edits shall be undoable (full snapshot history, keystrokes coalesced); deleting a referenced entity scrubs its references. Import explicitly replaces the open project (undoable); "import as a new project" is the non-destructive path. FR-CFG-09/11/12
PRD-PRJ-10 The dashboard's Config panel import shall publish to the live config (visible immediately), and the Creator shall be able to pull the live published config back in as an editable project (/creator/?importPublished=1) — so a config received as a file can be viewed, edited and re-published. new
PRD-PRJ-11 The platform data layer shall be Azure-hosted and self-hostable: Azure PostgreSQL (projects, engine_config, config_versions, proof_of_play — schema in db/migrations/, applied by npm run db:migrate) + Azure Blob (shared TTS cache tier), with the portal API in front for browsers and vehicles. The entire backend also runs 100 % locally against a stock Postgres container (db:start + stack:local). new (migration 2026-08-07)

2.3 PRD-CNT — Content: playlists and elements

ID Requirement Spec
PRD-CNT-01 A playlist shall be an ordered list of elements of exactly five types — static-text, dynamic-text, pause, audio-file, audio-ref — with speaker routing interior / exterior / both. FR-CNT-01/02
PRD-CNT-02 Voice shall be settable per playlist and overridable per element; an element with its own voice splits the TTS run and resolves its variables in that voice's language. FR-CNT-03
PRD-CNT-03 Dynamic text shall resolve the engine's variable catalogue from live journey state ({nextStop}, {destination}, {skippedStop}, {exitSide}, {situationHeading}, {connectingServices}, {stateOfCharge}, …), language-aware via name_Multilanguage (16 locales). A variable-parity test fails the build if the Creator offers a variable the resolver cannot fill. FR-CNT-04, D-03
PRD-CNT-04 Per-element volume (0–100) shall apply as SSML prosody where the supplier's capability descriptor declares support; plain-text suppliers drop it predictably (log line + authoring-time warning). Voice pitch is deliberately not authorable. FR-CNT-05/06
PRD-CNT-05 Multilingual playlists: one action duplicates the spoken block in a second language and voice, preserving pauses and cadence. FR-CNT-08
PRD-CNT-06 Pre-recorded stop-name clips shall substitute for TTS when a stop-name variable matches a recording (bulk import parses name + language from file names); the rest of the announcement is still synthesised. FR-CNT-09
PRD-CNT-07 An author shall audition any playlist through the engine's real TTS supplier before publishing, with realistic sample variables and the lexicon applied. FR-CNT-10
PRD-CNT-08 The Creator shall produce a printable documentation sheet of the audio configuration — every playlist with its rendered script, voice and firing triggers; every enabled trigger with conditions, gates and priority; custom triggers as readable rules; the speech setup — printable to PDF, flowing across pages. new
PRD-CNT-09 An AI authoring assistant (/assistant) shall turn a plain-language request into a reviewable plan of playlists, built-in triggers and custom rules: every item individually tickable with rationale, replace-warnings where a trigger already plays something, validated against the real domain model before apply (model output is never trusted as config), applied as one undo entry. Served local-first by the portal's /ai/assistant against any OpenAI-compatible endpoint (default: local Ollama); an offline keyword-drafting fallback is clearly badged and travels the same validate → review → apply path. new
PRD-CNT-10 The project shall carry an IANA timezone setting (with a live "now in this zone" check) used by time/weekday/calendar facts and published to the engine. new
PRD-CNT-11 Volume adaptation: announcement loudness shall be rule-based, each rule freely combining time of day, weekdays, route, stop and geofence (circle or polygon from the geofence library) — all conditions must hold, the most specific matching rule wins. Route and stop values are picked from the imported GTFS feed (free text without one); the clock is the project timezone; geofence rules need a trusted GNSS fix. new

2.4 PRD-TRG — Triggers and rules

ID Requirement Spec
PRD-TRG-01 The system shall implement the canonical 33 built-in trigger types (journey lifecycle 7, stop flow 7, sensors 2, distance/time thresholds 5, spatial/motion 3, service/passenger 7, local-only 2). A bidirectional parity guard (contract test + CI step) fails the build if engine and authoring manifest drift. FR-TRG-01
PRD-TRG-02 Triggers shall fire on state transitions, debounced (1500 ms per vehicle+kind), with a settle window suppressing stale bursts after (re)connect; GPS-derived triggers only evaluate on a trusted GNSS fix. FR-TRG-02/03/07
PRD-TRG-03 Threshold triggers shall carry typed conditions (time s; distance m/ft — threshold only, speech is metres; geofence radius+centre; speed below/above/between in km/h/mph; occupancy %; exit side). FR-TRG-04, D-06
PRD-TRG-04 Every trigger shall support the universal prerequisite gates (door open / door closed / stop button / velocity below / above), authorable in the Creator, fail-closed, with gate-blocked fires held and retried for a bounded window. FR-TRG-05/06
PRD-TRG-05 Custom (fact-based) triggers: nested ALL/ANY/NOT expressions over a catalogue of 40 live facts in 7 categories (time & calendar, journey, stops, doors, movement, energystateOfCharge/charging, passengers & service) plus named geofences; fire modes becomes-true / while-true with cooldown; a live "Try it" verdict. A parity test locks the catalogue to the engine's snapshotFacts(). FR-TRG-08/09/10
PRD-TRG-06 A named geofence library (circles 20–4000 m, polygons ≥3 vertices) shall be author-editable on a map — drag/insert/delete corners, move, resize, duplicate, recolour — and usable by custom triggers. FR-TRG-11/12
PRD-TRG-07 Trigger→playlist resolution shall support interior/exterior split playlists, sequences and repetitions, authored in the trigger row; a triggerJobsParity test pins the Creator preview to the engine's resolveJobs. FR-TRG-13
PRD-TRG-08 Built-in and custom triggers shall be authored in one unified triggers list. new
PRD-TRG-09 Correctness guards: last-stop and journey-approaching-last-stop are mutually exclusive (no double final-stop announcement); stop-skipped's two detection sources resolve to one announcement per skipped stop; exit-side fires only for an authored side. D-11/12/02

2.5 PRD-LEX — Pronunciation

ID Requirement Spec
PRD-LEX-01 A pronunciation lexicon shall override how words are spoken — plain respelling, IPA (validated, with graceful degradation), say-as, optionally restricted to one language — applied identically in engine render, preview and simulator. FR-CNT-07
PRD-LEX-02 Matching shall be word-boundary and case-insensitive and shall work for non-ASCII names (Malmö, Örebro, Arabic script) — Unicode-aware boundaries. new
PRD-LEX-03 Authoring support: a click-to-insert IPA symbol palette, live IPA validation and normalisation, worked one-click examples, per-entry Listen (real <phoneme> SSML on suppliers that support it, respelling elsewhere — matching engine behaviour), an Approved QA marker, and an author guide (docs/pronunciation-guide.md). new
PRD-LEX-04 A GTFS import shall parse a feed client-side, extract stop/route/headsign names, expand abbreviations via the portal AI (RD → Road), and let the author review/edit before adding; imported entries carry a GTFS source tag. new
PRD-LEX-05 Speak-it-in: the author shall be able to say the name into the microphone as it should be pronounced; the recording is analysed (Azure speech-to-text, phoneme granularity, unscripted) and the heard IPA plus a recognized-text respelling seed fill the builder's fields, with the author's own clip kept playable for an A/B against Listen. Analysis unavailable (no Azure key, offline) degrades to record-and-compare-by-ear. new

2.6 PRD-VOX — Speech synthesis and suppliers

ID Requirement Spec
PRD-VOX-01 The TTS supplier shall be pluggable across 13 suppliers: Azure AI Speech, ElevenLabs, Acapela, Google Cloud TTS, Amazon Polly, ReadSpeaker, Cerence, CereProc, Picovoice Orca, and self-hosted OpenAI-compatible Kokoro / Piper / eSpeak, plus Mock (silent, offline — CI/load tests). new (was 4)
PRD-VOX-02 Each supplier shall declare a capability descriptor (ssml, prosodyVolume, phoneme, offline) so features degrade predictably: honoured where declared, dropped with a log line + authoring warning elsewhere; the Voices screen badges capabilities. new
PRD-VOX-03 The Creator's voice pickers shall be provider-aware (per playlist/element): only voices valid for the active supplier are offered, with catalogues fetched live from each supplier, A/B audition on a common sample line, warnings where playlists pin their own voice (with a one-click repoint), and an embedded decision guide (the TTS market-research scorecard, benchmark floor and TCO scenarios). new
PRD-VOX-04 TTS shall be resilient: retry with exponential backoff + jitter; a circuit breaker that fast-fails and half-opens with a single probe; auth errors skip retries; a pre-recorded fallback clip so an outage never drops an announcement (signs still render from text). FR-REN-05
PRD-VOX-05 A configured fallback supplier (TTS_FALLBACK_PROVIDER, or config.ttsFallbackProvider from the Creator) shall take over while the primary's breaker is open — a vendor outage speaks in another voice, not a tone. Per-provider health is tracked. new
PRD-VOX-06 Speech rate (and pitch at the SSML layer, though pitch is not authorable) shall be applied via SSML prosody where supported. FR-CNT-05/06

2.7 PRD-REN — Rendering, caching and pre-delivery

ID Requirement Spec
PRD-REN-01 The engine shall render a playlist to one audio clip + transcript, merging consecutive same-voice elements into one TTS request; a voice or volume change splits the run. FR-REN-01
PRD-REN-02 Identical phrases shall be synthesised once for the whole fleet — content-addressed layered cache (memory LRU → disk, capped by CACHE_MAX_FILES → Azure Blob), with single-flight coalescing of concurrent identical requests. FR-REN-02/03
PRD-REN-03 The engine shall predictively pre-render the next PRERENDER_LOOKAHEAD stops' announcements (deduped per journey/stop; volatile playlists skipped) so live triggers hit a warm cache (~0 TTS latency). FR-REN-04
PRD-REN-04 Closed-corpus pre-push (opt-in PREPUSH_ENABLED): when a journey's stop list becomes known, render the journey's whole announcement corpus and deliver the clips to the players' on-device caches on …/pis/0/tts/cache; the pushed clipKey is byte-identical to the live publish so announcements can go out key-only (hundreds of bytes, not audio). A degraded-link aid, explicitly not an offline guarantee. new
PRD-REN-05 Pre-recorded audio (audio-file/audio-ref) shall be fetched with retry + backoff, cached fleet-wide by URL; a permanent failure drops just that clip, not the announcement.
PRD-REN-06 An announcement with unresolved required variables shall be droppable rather than spoken with a gap (SKIP_INCOMPLETE_ANNOUNCEMENTS); optional variables never block. Unknown {tokens} are logged by name. FR-REN-10

2.8 PRD-LED — LED signage (vendor-independent)

ID Requirement Spec
PRD-LED-01 Every interior-routed announcement shall render to a 16×144 amber 1-bpp bitmap on …/pis/0/display (same traceId as the audio), centred when it fits, flagged scroll when wider. FR-REN-06
PRD-LED-02 Exterior destination signs (front 192 / side 160 / rear 48 px × 24 px, configurable): fixed route number that never scrolls + destination that shrinks/condenses to fit and scrolls only as a last resort; published retained on route/destination change. Destination text scales to fit by default. FR-REN-07
PRD-LED-03 The same bitmaps shall be emitted as raw Mobitec FF frames (board 1463-L, graphic font w, checksum per §4, interior scroll frames 0xA5/0xD5) and as Hanover HCPS/SuperX frames (STX…ETX + 2-char checksum, {\pic} raster) on parallel topics — mixed Luminator + Hanover fleets run from one render pass (Hanover interior text wider than the panel uses the SuperX \sl repeat-forever hardware scroll). Frames are signature-deduped (republished only when pixels/address/protocol change); checksums and raster packing verified byte-exact against both vendors' docs. FR-REN-08/11
PRD-LED-04 Every raw-frame path shall also fan out per bus address (…/ff/{address}, …/hanover/{address}, fixed faces and template signs alike) so a sign controller subscribes to exactly its own address; a re-addressed or removed sign has its stale retained topic cleared. FR-LED-10
PRD-LED-05 Matrix LED templates (Luminator MatrixRenderer contract — Display → Cycles → Layout → Elements): the Creator authors pixel-accurate layouts (six element types — Text, Image, Rectangle, Line, Ellipse, Symbol — with overflow/alignment/scroll/text alternations (one element rotating languages or messages on a timer)/blink/colour expressions/font ladders), displays with condition-gated cycles, a vehicle roster (Ultima models, resolution, position, address, colour mode, per-sign wire `protocol: ff hanover), symbol mappings and colour rules; the engine rasterises with real **FNT fonts** and emits mono/RGB FF (protocol 03090) or Hanover frames, gated by DISPLAY_TEMPLATE_MODE`. Creator and engine models/renderers are kept in lockstep by parity tests.
PRD-LED-06 Triggers (built-in and custom) shall optionally bind a layout per vehicle face for the duration of the fire; otherwise the Display's cycles select from globalState — the same fact catalogue that drives audio. FR-LED-05
PRD-LED-07 RTL scripts (Arabic/Hebrew) shall render correctly on every LED surface: auto-detected (no authoring flag), Arabic contextual shaping + bidi at one chokepoint (src/shared/fnt.ts), Unifont fallback glyphs for missing code points, and RTL text scrolling left-to-right so the reader meets the first word first. new
PRD-LED-08 Pre-programmed destination lists shall be authorable as multiple named lists (code → what every sign shows, optionally scoped by line code) with duplicate-code detection, GTFS headsign seeding, a WYSIWYG editor on the real panel (typed straight into the rendered sign at real resolution/font through the engine's renderer, cross-checked live against the vehicle's other panels, with text/shape/symbol/image elements and per-destination "own arrangement" overrides), a printable signed approval review sheet, and a Check fit sweep with an actionable fit report. The engine resolves a code-only pis/0/destination selection against the active list; the list publishes retained on …/pis/0/list/destinations when DESTINATION_LIST_PUBLISH=true. new
PRD-LED-11 Sign text shall be restylable by authored rules applied identically in Creator preview, dashboard and engine: abbreviations (whole-value or per-word, "always" vs "only when tight"), symbol mappings (field eq/contains/regex → built-in pictogram or uploaded image), and route colours (per-line background/text/outline, importable from GTFS route_color without overwriting hand-picked rows). FR-LED-04, new
PRD-LED-12 The Creator shall maintain the vehicle roster UX at fleet quality: catalog-first panel picker (Luminator models), auto-assigned FF addresses, per-sign display binding vs announcement mirror, one-click seeders (standard vehicle, EMEA/US/Vilnius worked examples), "Assign default templates" size matching, and Validate fit — sweeping every GTFS line, destination and stop name through the vehicle's templates with an "add abbreviation and re-validate in place" fix loop. new
PRD-LED-09 Browser surfaces shall rasterise with the same font and shared LED core the engine ships (/led-font.json, @shared/* modules used verbatim) so previews, replays and the standalone HTML sign clients are pixel-identical to the hardware frames: /sign (any display device is one physical sign face), /vehicle (every exterior panel from one shared journey state), the /signs index (worked cases generated from the client's own demo config so it cannot drift), and /player/led (the player's LED half, live over the credential-safe SSE bridge — no broker address on the device). FR-REN-09, new
PRD-LED-10 The interior TFT (1920×610) shall be previewable in the Monitor drawer with an expand overlay. Battery/energy facts surface on signs via {stateOfCharge} facts, Energy LED situations and battery presets/symbols. new

2.9 PRD-DSP — Dispatch and arbitration

ID Requirement Spec
PRD-DSP-01 Exactly one announcement shall play per vehicle at a time; every offer resolves to play · interrupt · queue · drop. Priority 1–11 authorable (flat or nested shape) with per-type defaults; an interrupt aborts the superseded render so its audio is never published; the queue is priority-ordered, bounded (8), and sheds the lowest-priority tail. FR-DSP-01…05
PRD-DSP-02 Audio shall publish as ADT 4.x (base64 MP3/OPUS, integer speaker levels), conforming to the transHub AsyncAPI spec, validated by contract tests. Payloads carry the clipKey for the on-device cache. FR-DSP-06
PRD-DSP-03 The engine shall render only for the served set by default (RENDER_SCOPE=selection; retained control topic; empty set = silence — production-safe). all is load-test/on-vehicle only; an invalid value fails at startup. FR-DSP-07

2.10 PRD-PLY — Onboard playback

ID Requirement Spec
PRD-PLY-01 Three interchangeable players (Go, Python, browser) shall implement one contract: subscribe …/pis/0/tts, queue and play strictly one at a time, ack engine/played when the clip finishes ({traceId, tenant, vehicleId, dispatchedAt, playedAt, status}), never block the network loop, time out wedged clips. FR-PLY-01…04
PRD-PLY-02 Players shall maintain an on-vehicle clip cache (Go/Python: LRU-bounded directory, default 64 MB; browser: IndexedDB, last 150 clips): pre-pushed clips stored by clipKey and never played on arrival; a key-only payload replays cached bytes (hundreds of bytes instead of ~100 KB); a clip past expiryDateTime (default 300 s after dispatch) is dropped and acked expired, never played late. Ack statusplayed / failed / expired. new
PRD-PLY-03 Headless players ship as multi-arch containers (amd64 + arm64), outbound-only MQTT, --device /dev/snd; the zero-install browser player is deep-linkable (?tenant=&vehicle= and the path form /player/v/<tenant>/<vehicle> used by QR hand-offs, immune to query stripping), wake-locked, volume from speakers.INTERNAL. FR-PLY-05…07

2.11 PRD-AUD — Proof of play, evidence and compliance

ID Requirement Spec
PRD-AUD-01 The system shall keep an append-only audit trail on one timeline — audio (dispatched), exterior (sign changed), played (vehicle-confirmed) — each record carrying vehicle, GPS, time, trigger, stop, route, destination, journey ref, transcript and the lexicon-adjusted spoken text, language, voice, routing, volume, timing, cache stats, display status. FR-AUD-01/02
PRD-AUD-02 Played acks correlate to dispatch by traceId; coverage = confirmed ÷ correlatable dispatches (uncorrelated excluded, a later success supersedes a failed retry). FR-AUD-03/04
PRD-AUD-03 The trail shall be queryable/aggregatable (tenant, vehicle, route, destination, journey, trigger, surface, free text, time; group by journey/route/destination/date/hour/vehicle — server-side over the full filtered set) with four dashboard views (Groups, Table, Map, Replay), KPI summary, CSV/JSON proof-pack export; replay is labelled a reconstruction. FR-AUD-05/10
PRD-AUD-04 A certified coverage report (/proof/report, JSON/CSV/print HTML) shall include per-route/vehicle/day breakdowns, an exceptions table, a basis disclaimer, an operator sign-off block and a SHA-256 integrity hash; with GTFS, schedule-based expected coverage flags stops never attempted at all. FR-AUD-06/07/08
PRD-AUD-05 The trail shall be durably persisted per tenant to Postgres (PROOF_DB_ENABLED, batched, best-effort — the local file stays primary) and shall store-and-forward: while the database is unreachable (vehicle offline, tunnel, dead zone) rows are buffered and retried with capped backoff, probing the link at most once a minute (metered-SIM friendly) — a vehicle offline for an hour uploads the whole hour on reconnect. Bounded by PROOF_BUFFER_MAX (default 10 000 ≈ a service day; overflow sheds oldest, the file keeps them, and the shed count is reported on recovery). A sink failure never blocks the render pipeline. FR-AUD-09, new

2.12 PRD-OPS — Operations, monitoring and diagnostics

ID Requirement Spec
PRD-OPS-01 An operator shall control the served set from the dashboard (retained, survives engine restart, bidirectional re-sync); a tenant scope narrows the bridge at runtime. FR-OPS-01
PRD-OPS-02 The engine shall publish retained health telemetry (engine/health: throughput, event rate, loop lag, RSS, cache hit rate, in-flight/queued/dropped/interrupted, pre-render, TTS breaker/fallback/retry) and per-announcement metrics (engine/metrics: resolve/render/TTS/publish + GPS); the dashboard shows stat cards, sparklines, latency breakdowns, an event map, and operational alerts (breaker open, drops, silent vehicles). FR-OPS-02/03/04
PRD-OPS-03 A retained fleet directory (engine/fleet) shall populate the vehicle picker without subscribing the whole fleet, capped by FLEET_MAX_BYTES so a large broker cannot exceed the broker's max packet size and freeze the picker. FR-OPS-05, new
PRD-OPS-04 Operators shall inject test signals (stop request, doors, off-route, exit side, crowding, alert, alarm, destination override) on real PIS topics; authors shall simulate a full GTFS journey — the bus driven along the real route shape on a map at 0.5–4× speed, a timeline of modelled PIS events with custom triggers interleaved, each event resolving to the exact spoken text (real-TTS audition in the selected voice), a live LED panel of the vehicle's signs at the simulated moment, and manual overrides (doors, stop request, journey state, destination code). FR-OPS-06/07
PRD-OPS-05 The upstream bridge shall follow the dashboard selection, run lean fleet discovery (retained journeystate + destination only), tenant-scope, dedup byte-identical re-publishes, and publish retained bridge health. FR-OPS-08
PRD-OPS-06 The browser shall reach the broker through a same-origin MQTT relay on the portal port by default (/mqtt-proxy — credentials stay server-side, only :8080 need be reachable; DASHBOARD_WS_MAX_CLIENTS bounds it), with the direct-WebSocket path as opt-out; the broker chip names the transport, target and failure reason. new
PRD-OPS-07 A diagnosis command (npm run doctor, GET /diag) shall walk the chain — broker TCP → MQTT session → WS listener → enabled triggers → engine heartbeat (retained, so a live engine can't read dead) → served set → live PIS traffic (live publishes, not retained backlog) — and name the first broken link. new
PRD-OPS-08 Process hygiene: npm run stop kills engine/player/simulator/dashboard but never the broker; each app auto-clears its previous instance on start, so re-runs never double up (no doubled announcements).

2.13 PRD-DEP — Deployment and environments

ID Requirement Spec
PRD-DEP-01 One multi-arch image (amd64 + arm64, node:22-slim, pure-JS deps) shall serve both back-office hosts and ARM onboard units; only the environment differs.
PRD-DEP-02 Back office: single long-running replica per inbound stream (per-vehicle state in memory), config from Postgres, served set from the dashboard. CD: git push main auto-deploys to the Azure VM (systemd-supervised stack). new (VM CD)
PRD-DEP-03 On-vehicle: the whole stack (broker + engine + player) on one onboard unit via docker-compose.vehicle.ymlRENDER_SCOPE=all for the one local bus, file-first config with optional OTA via CONFIG_API_URL + retained notify, persistent TTS-cache and proof volumes, memory-bounded (512 MB), outbound-only MQTT behind NAT.
PRD-DEP-04 Offline bundles: the Creator shall export a per-route bundle (/export/bundle, token-gated; zip schema creatorstudio-offline-bundle 1.0.0 — clips as playable .mp3, engine tts-cache seed, config, manifest) with a free Estimate step first that itemises what still needs a live link (requiresLiveData: connections, service alerts, clock/countdown variables). Clip bytes and clipKeys are byte-identical to what the engine publishes live (shared enumeration/render path with the pre-push); npm run import:bundle seeds a vehicle's player and engine caches from it, with optional hash --verify. new
PRD-DEP-05 Limited connectivity shall be a documented, supported spectrum of shipped patterns (docs/limited-connectivity.md): A central engine + on-vehicle caches (a fully pre-pushed two-announcement stop costs < 1 KB of announcement traffic), B on-vehicle engine for true offline (offline TTS via the self-hosted suppliers, proof store-and-forward), C offline bundle for no-cloud — each with its bandwidth budget and an explicit "still needs a live link" list. A late announcement is treated as worse than a missed one: in a dead zone pattern A's announcement is missed, never late (expiry enforced at the player). new
PRD-DEP-06 All Node entry points shall run with --use-system-ca so corporate TLS-inspection roots are trusted from the OS store.

2.14 PRD-SEC — Security

ID Requirement Spec
PRD-SEC-01 Mutating or cost-bearing portal routes (POST /config, /tts-preview, /gtfs/import, /player/ack, …) shall require Authorization: Bearer $DASHBOARD_TOKEN when set (constant-time compare); reads are open.
PRD-SEC-02 published_by on a publish shall derive from a server-validated token when present, and every version row records published_by_verified — a self-asserted label can never read as evidence of a person. D-20
PRD-SEC-03 Broker credentials shall stay server-side (the same-origin MQTT relay); the Creator bundle ships no keys (runtime-injected env). new
PRD-SEC-04 Known open risks are tracked, not hidden: broker ws:// + anonymous access and QoS-0 acks (D-16/D-17) are deferred to Luminator Suite integration (wss, credentials, ACLs, ack QoS); Azure key rotation (D-18) and the legacy app's dev broker password (D-19) remain outstanding. Real user login awaits a suite identity provider. §10.4

3. Non-functional requirements

Values marked [M] are measured, not estimated (see TEST-REPORT.md / SCALING.md).

ID Requirement Target / result
NFR-PERF-01 Announcement latency, cache hit (dominant case) ≈2 ms, ttsMs=0 [M]
NFR-PERF-02 Cold synth of a unique phrase ≈1.0 s, once per phrase fleet-wide [M]
NFR-PERF-03 Publish → fleet live ≈1 s (LISTEN/NOTIFY hot-swap)
NFR-SCAL-01 One engine process, 2-vCPU/4 GB host 2000 vehicles: 29 ms p50 loop lag, 250 MB RSS, zero drops [M]; ceiling ~3000–3500
NFR-SCAL-02 Multi-tenant model One engine+bridge per tenant; bridge pre-scopes; engine reads only its (tenant, fleet) row
NFR-COST-01 TTS cost scales with unique phrases, not vehicles Content-addressed cache + single-flight
NFR-AVAIL-01 A TTS supplier outage never drops an announcement Retry → breaker → failover supplier → fallback clip; signs still render
NFR-AVAIL-02 No crash on missing broker; unattended reconnect everywhere Daemon semantics, restart: unless-stopped
NFR-RES-01 Flat memory under sustained load LRU + disk cap + idle context eviction [M]
NFR-SAFE-01 Connecting to a live fleet announces nothing until opted in RENDER_SCOPE=selection default; invalid value fails startup
NFR-PORT-01 Players on x86-64 + ARM64, headless, behind NAT Multi-arch images, outbound-only
NFR-OPS-01 100 % local operation with no cloud dependency db:start + stack:local from empty

4. External interfaces (summary)

MQTT — the primary integration contract (full payload contracts in docs/mqtt-specification.md): inbound PIS-PT on {tenant}/{vehicleId}/pis/0/#; outbound …/tts (ADT 4.x), …/tts/cache (pre-push), …/display, …/display/exterior (+ /ff, /hanover, and per-address variants of every raw path), …/display/signs[/…] (matrix templates), …/list/destinations; control engine/control/select + engine/control/tenant; telemetry engine/health, engine/metrics, engine/fleet, engine/bridge; acks on engine/played.

HTTP (portal :8080) — apps at /, /monitor/, /creator/, /player, /docs (+ the showcase decks and /build-stats); sign clients at /sign, /vehicle, /signs, /player/led; APIs: /config (GET/POST), /api/projects, /api/publish, /api/engine-config, /api/config-versions, /api/gtfs/expand, /history (filters/groupBy/CSV), /proof/report, /led-font.json, /tts-providers · /tts-voices · /tts-preview · /tts-briefing · /tts-scorecard.xlsx, /ai/assistant, /gtfs/*, /export/bundle, /diag, /broker, /mqtt-proxy (WS relay), /player/stream (SSE) · /player/ack · /player/qr.svg. Mutating or cost-bearing routes are token-gated (PRD-SEC-01); responses gzip; upload caps enforced.

External services — MQTT broker(s) (local + Luminator suite upstream); Azure AI Speech and the other 12 TTS suppliers; Azure PostgreSQL; Azure Blob Storage.


5. Verification and traceability

Mechanism Coverage
Automated tests 1698 tests, 0 failed/skipped — engine 750 cases / 83 files (node:test), front-end 948 cases / 76 files (Vitest: dashboard 225, Creator 723). Line coverage: engine 92.5 %, dashboard 92.6 %, Creator 90.7 %. The suite needs no network, broker or cloud (two integration tests run a real in-memory broker). Full inventory in TEST-REPORT.md.
Parity guards Trigger set (engine ↔ authoring manifest, bidirectional), variable picker ↔ resolver, custom-fact catalogue ↔ snapshotFacts(), Creator preview ↔ resolveJobs, Creator ↔ engine LED models and renderers, sample config renders end-to-end.
Wire-contract typecheck tsc --noEmit gates src/shared/payloads.ts across engine and Angular — a contract change is a compile error on both sides.
Byte-exact protocol pins FF and Hanover checksums/rasters verified against the vendor docs' worked examples; decode round-trips tested.
Living demos proof:demo, ff:demo, led:demo, multi:demo (cross-talk + served-set isolation), bridge:test, config:test, prerender:demo.
CI Azure Pipelines: typecheck + tests + trigger parity + app builds on every PR/push; docs drift check.

Verification gaps (unchanged from the baseline): no automated end-to-end run of the Go/Python players against a broker; FF/Hanover frames never driven into physical signs; report integrity hash tested at unit level only.


6. Known gaps and exclusions

The authoritative register is FUNCTIONAL-SPEC.md §10. Still-open items, summarised:


CreatorStudio Suite — System Functional Description

Document type: System Functional Description (SFD) · Status: Baseline v1.0 · Date: 2026-07-14 Owner: System Design & Product · Applies to: feature/polygon-edit @ 4c3ee35


About this document

This is the functional specification: what the system does, for whom, and how you would know it worked. It is deliberately distinct from its two siblings:

Document Answers
This document What are the capabilities, who uses them, what are the rules, what are the acceptance criteria
ARCHITECTURE.md How it is built — modules, dependencies, patterns
SCALING.md How it holds up — bottlenecks, phased plan to 2000 vehicles

Requirements are numbered (FR-xxx-nn) and carry acceptance criteria so they can be traced to tests. Behaviour described here was read from the code, not inherited from prior documentation; where the code and the existing documentation disagree, the code wins and the discrepancy is recorded in §10 Defect & Gap Register.

Reader's guide

You are a… Read
Exec / stakeholder §1 Purpose · §2 Actors · §3 Functional decomposition · §10 Gaps
Product manager §1–§4 · §5 Functional requirements · §10 Gaps · §11 Roadmap
System designer / architect All, especially §5 · §6 Data · §7 Interfaces · §8 NFRs
QA / verification §5 acceptance criteria · §9 Verification & traceability
Compliance / audit §5.7 Proof of Play · §7.3 Reports · §10 (integrity risks)

1. Purpose, scope and business objective

1.1 The problem

A transit vehicle must tell passengers where it is and what happens next — audibly (saloon speakers) and visually (interior scrolling sign, exterior destination signs) — in the passenger's language, automatically, without the driver touching anything, and identically on every vehicle in the fleet. Under accessibility legislation (ADA and equivalents) the operator must also be able to prove, after the fact, that those announcements actually happened.

Historically this content is baked into vehicle firmware: changing a phrase means a software release and a fleet-wide reflash.

1.2 What this system does

In one sentence: it lets a non-programmer author what the fleet says, then executes that authored content live against each vehicle's real-time trip feed — synthesising speech, rendering sign graphics, dispatching both back to the vehicle, and recording durable evidence of what the passenger actually heard.

1.3 Business objectives

# Objective How the system delivers it Measured by
BO-1 Announcements without driver action Trigger engine derives events from the live trip feed Announcements dispatched per journey with zero driver input
BO-2 Content changes without a software release Authoring app publishes to the engine; engine hot-swaps in ~1 s Time from edit to fleet-live; zero redeploys
BO-3 Accessibility compliance, provable Proof-of-play trail correlates dispatch with vehicle-confirmed playback; certified coverage report Coverage % (confirmed ÷ correlatable dispatches)
BO-4 Fleet-scale at controlled cost Content-addressed TTS cache: a phrase is synthesised once for the entire fleet TTS cost scales with unique phrases, not vehicles
BO-5 Multi-operator (multi-tenant) from one platform Tenant-scoped projects, config, and audit trail Tenants share no data; one engine process per tenant

1.4 Scope

In scope: authoring; publication; trigger evaluation; speech synthesis; interior and exterior sign rendering; dispatch and priority arbitration; onboard playback; proof-of-play capture and reporting; fleet monitoring.

Out of scope (deliberately): vehicle hardware and wiring; the upstream trip-data system (PIS-PT is consumed, not produced); driver-facing UI; passenger mobile apps; ticketing.


2. Actors and stakeholders

Actor Type Goal Primary surface
Announcement Author Human (transit operator staff, non-technical) Design what the fleet says and when CreatorStudio Creator (/creator/)
Fleet Operator / Dispatcher Human Watch the live fleet; scope which vehicles are served; inject test signals Engine Dashboard (/monitor/)
Compliance Officer / Auditor Human Prove announcements were played; certify coverage; export evidence Proof of Play + /proof/report
Field Technician Human Commission a vehicle; spot-check audio on a phone Browser player (/player)
Passenger Human (indirect beneficiary) Hear and see where the vehicle is going Vehicle speakers + LED signs
Trip Data Feed (PIS-PT) External system Publishes live journey state per vehicle MQTT {tenant}/{vehicleId}/pis/0/#
TTS Supplier External system Synthesise speech Azure Speech (active), Acapela, ElevenLabs, Mock
Onboard Unit Machine Play the clip; acknowledge completion Go / Python / browser player
Sign Controller Machine Drive the physical LED signs over RS-485 — vendor-independent Mobitec FF frames · Hanover HCPS/SuperX frames
Configuration Store External system Hold projects, published config, version history, durable audit Azure PostgreSQL (via the portal API) + Azure Blob Storage

3. Functional decomposition

The system is eight functional blocks across three planes. Each block is independently testable and communicates only over the published interfaces (§7).

ASSURANCE PLANE — after the fact

EXECUTION PLANE — run time

AUTHORING PLANE — design time

published config

played ack

FB-1 Content Authoring
playlists · voices · lexicon

FB-2 Rule Authoring
triggers · conditions · geofences

FB-3 Config Lifecycle
projects · publish · version · rollback

FB-4 Situation Awareness
ingest · aggregate · derive state

FB-5 Decision
trigger evaluation · gates · priority

FB-6 Realisation
TTS · cache · LED · FF encode

FB-7 Dispatch & Playback
schedule · publish · play · ack

FB-8 Evidence & Operations
proof of play · reports · health

Block Responsibility Realised by
FB-1 Content Authoring What is said: playlists, elements, voices, per-element overrides, lexicon, pre-recorded clips web/creator/ playlist editor, voices, settings
FB-2 Rule Authoring When it is said: 33 built-in triggers, fact-based custom triggers, geofence library web/creator/ triggers, custom triggers, geofences
FB-3 Config Lifecycle Projects, validation, publish, version history, rollback, tenant scoping Creator services + publish-engine-config / manage-project edge functions
FB-4 Situation Awareness Consume PIS-PT; aggregate multi-topic retained state into one journey state per vehicle src/pis/ptContext.ts
FB-5 Decision Edge-detect events; evaluate conditions, prerequisite gates, custom expressions; resolve priority src/pis/ptEngine.ts, src/engine/customTriggers.ts, src/pipeline.ts
FB-6 Realisation Resolve variables; synthesise/cache audio; rasterise announcement signs and MatrixRenderer layout templates; encode mono/RGB FF and Hanover HCPS frames (vendor-independent sign output) src/engine/playlistRenderer.ts, tts/*, ledRender.ts, exteriorSign.ts, matrix/*, ff/, hanover/
FB-7 Dispatch & Playback Per-vehicle scheduling (play/interrupt/queue/drop); publish ADT + display; onboard playback + ack src/engine/scheduler.ts, clients/go, clients/python, public/player.html
FB-8 Evidence & Operations Proof-of-play capture, coverage certification, fleet monitoring, served-set control src/engine/history.ts, proofReport.ts, proofSink.ts, web/ dashboard

4. Operational concept — primary use cases

UC-1 — Author and publish an announcement (FB-1 → FB-3)

An author selects a project, creates a playlist ("Next stop {nextStop}"), picks a voice, assigns it to the approaching-stop trigger, previews it through the real TTS supplier, resolves any lint findings, and clicks Publish. Within ~1 second every vehicle on that fleet begins using the new wording. No redeploy, no vehicle touch.

UC-2 — Announce a stop (FB-4 → FB-7)

The vehicle's feed reports progress to the next stop. The engine detects the transition, confirms the trigger's conditions and prerequisite gates hold, renders the playlist (cache hit ≈ 2 ms), publishes an ADT audio payload plus an interior LED bitmap, the onboard player plays it, and — when the clip finishes — acknowledges playback. The record becomes evidence.

UC-3 — Interrupt for an emergency (FB-5, FB-7)

An alarm fires (priority 11) while an ambient announcement (priority 5) is mid-render. The scheduler interrupts: it aborts the superseded render so its audio is never published, and plays the alarm. Neither talks over the other.

UC-4 — Prove compliance for an audit period (FB-8)

A compliance officer scopes a route and date range and generates a certified coverage report: dispatched vs confirmed-played, per route/vehicle/day, an itemised exception list, an operator sign-off block, and a SHA-256 integrity hash. With GTFS enabled the report additionally reports stops that were never attempted — which dispatch-based coverage alone cannot detect.

UC-5 — Scope the fleet safely (FB-8 → FB-5)

The engine defaults to RENDER_SCOPE=selection: it announces nothing until an operator explicitly opts vehicles into the served set. This makes "connect to production and accidentally talk to 2000 buses" impossible by default.

UC-6 — Roll back a bad publish (FB-3)

An author publishes wording that reads badly on air. From History they restore an earlier version in one click; it is re-published as a new version — the audit trail is append-only and nothing is rewritten.


5. Functional requirements

Convention: shall = mandatory, implemented. Each requirement carries an acceptance criterion (AC). Requirements marked have a known deviation recorded in §10.

5.1 FR-CFG — Configuration authoring and lifecycle

ID Requirement AC
FR-CFG-01 Authoring shall be organised into Projects — named, self-contained configurations (playlists, triggers, voices, lexicon, geofences, volume rules). Create/rename/duplicate/delete/select/import/export a project; deleting the last project is refused.
FR-CFG-02 Exactly one project shall be published per (tenant, fleet) at a time; the published row is what the engine reads. engine_config has a unique constraint on (tenant, fleet); the switcher tags the live project.
FR-CFG-03 Publication shall be blocked when any enabled trigger references a playlist that does not exist. Publish dialog refuses to open; the offending trigger is listed. Disabled triggers are exempt.
FR-CFG-04 The engine shall hot-swap a published config without restart, within ~1 s. Publish → engine logs a config version bump and applies new wording on the next trigger.
FR-CFG-05 The engine shall read config remote-first (Postgres direct or portal API) with an on-disk file as offline fallback, and hot-reload the file on change. With a remote source configured the file is shadowed; with none the file wins.
FR-CFG-06 Every publish shall append an immutable version record (version, config snapshot, note, publisher, source project, timestamp). config_versions row created per publish; version is monotonic per (tenant, fleet).
FR-CFG-07 An author shall be able to roll back to any earlier version in one click. Restore re-publishes the old config as a new version; history is never rewritten.
FR-CFG-08 All authoring state shall be tenant-scoped; tenants shall share no projects, config, history, or audit data. Switching tenant loads a disjoint project set; RLS enforces tenant on read; edge functions enforce the verified tenant claim on write.
FR-CFG-09 Config edits shall be undoable (≥50 steps), with rapid keystrokes coalesced. Undo/redo restores prior state; a burst of typing is one undo entry.
FR-CFG-10 The system shall lint the config live and surface findings before publish. Errors: enabled trigger with no playlist; trigger pointing at a deleted playlist. Warnings: playlist with no spoken content; event-specific variable used on a trigger that cannot provide it.
FR-CFG-11 Deleting a referenced entity shall scrub its references, never dangle. Deleting a playlist clears it from every trigger slot and sequence; deleting a geofence clears it from every custom-trigger block.
FR-CFG-12 Config shall be importable/exportable as a versioned JSON envelope interoperable with the engine and the legacy app. schema: luminator.announcement-config, schemaVersion: 1.2.0; round-trips without loss.

5.2 FR-CNT — Content (what is said)

ID Requirement AC
FR-CNT-01 A playlist shall be an ordered list of elements of exactly five types: static-text, dynamic-text, pause, audio-file, audio-ref. Each type is addable, reorderable, deletable.
FR-CNT-02 A playlist shall declare speaker routing: interior, exterior, or both. Routing determines ADT speaker levels and whether an interior LED frame is published.
FR-CNT-03 Voice shall be settable per playlist and overridable per element. An element with its own voice splits the TTS run and renders under that voice.
FR-CNT-04 Dynamic text shall resolve 30 variables from live journey state, in the language of the voice speaking them. {nextStop} etc. resolve; name_Multilanguage variants are selected per element language (16 locales).
FR-CNT-05 Volume shall be settable per element (0–100) and applied as SSML prosody. Honoured where the supplier declares prosodyVolume (Azure/Google/Polly); a volume change splits the TTS run like a voice change. On plain-text suppliers the renderer drops the volume predictably (runs stay merged, a log line names it) and the Creator warns while authoring.
FR-CNT-06 Voice pitch shall not be authorable. Deliberate: a shifted pitch produces audibly broken speech that no log or payload reveals. Carried in the model, ignored by the engine.
FR-CNT-07 A pronunciation lexicon shall override how words are spoken, by plain respelling or IPA, optionally restricted to one language. Applied identically in engine render, playlist preview and simulator (word-boundary, case-insensitive).
FR-CNT-08 The system shall support multilingual playlists: one authoring action duplicates the spoken block in a second language and voice, preserving pauses and cadence. The playlist announces in both languages; variables resolve their per-language name variants.
FR-CNT-09 Pre-recorded stop-name clips shall substitute for TTS when a stop-name variable matches a recording; the remainder of the announcement is still synthesised. Bulk import parses stop name and language from file names; clips may be embedded as data URIs or hosted.
FR-CNT-10 An author shall be able to audition a playlist through the engine's real TTS supplier before publishing. Preview renders with realistic sample variable values and the lexicon applied.
FR-CNT-11 The TTS supplier shall be selectable per config, from Azure / ElevenLabs / Acapela / Mock, with live A/B comparison. Voice catalogues are fetched from each supplier; "Use for engine" is applied to the fleet on publish.

5.3 FR-TRG — Rules (when it is said)

ID Requirement AC
FR-TRG-01 The system shall implement a canonical set of 33 built-in trigger types spanning journey lifecycle (7), stop flow (7), sensors (2), distance/time thresholds (5), spatial/motion (3), and service/passenger info (7)+2 local. Enumerated in §5.3.1. A bidirectional parity guard fails the build if the engine and the authoring manifest drift.
FR-TRG-02 Triggers shall fire on state transitions, not on message arrival, and shall be debounced (default 1500 ms per vehicle+kind). A repeated identical message produces no second announcement.
FR-TRG-03 A settling window (default 1500 ms) shall suppress firing immediately after a vehicle is first seen or re-seen. Reconnecting to a fleet with retained topics does not produce a burst of stale announcements.
FR-TRG-04 Threshold triggers shall carry conditions with explicit operators and units. Time (s), distance (m/ft), geofence (radius m + centre), speed (below/above/between, km/h or mph), occupancy (%). Defaults defined in §5.3.2.
FR-TRG-05 Any trigger shall support universal prerequisite gates: door open, door closed, stop button pressed, velocity below, velocity above. A trigger is suppressed unless every set gate holds. Gates are fail-closed — a velocity gate with no speed reading blocks the fire. Authored per trigger under Prerequisites in the Creator (D-07).
FR-TRG-06 A fire blocked only by a prerequisite gate shall be held and retried, not discarded, for a bounded window. Gate-hold retries until it fires, the window expires, or the vehicle moves to the next stop.
FR-TRG-07 GPS-derived triggers (speed, geofence) shall only be evaluated when the GNSS fix is trusted, and shall hold the last trusted value across an untrusted gap. An untrusted gap does not manufacture a spurious rising edge.
FR-TRG-08 The system shall support custom (fact-based) triggers: a nested boolean expression (ALL/ANY/NOT) over a catalogue of live facts and named geofences, with a fire mode, cooldown, priority and playlist. Author builds the expression visually; "Try it" gives a live would-fire verdict.
FR-TRG-09 The custom-trigger fact catalogue shall cover 40 facts in 7 categories, including engine-clock time and calendar facts, so purely schedule-driven announcements are possible with no vehicle data. Enumerated in §5.3.3. Operators are type-aware (boolean/number/enum/string). A parity test keeps the catalogue and the engine's snapshotFacts() in lockstep.
FR-TRG-10 Custom triggers shall support fire modes becomes-true (rising edge, once) and while-true (repeats), throttled by a cooldown. First fire is never suppressed by cooldown; a disabled or unassigned trigger is skipped and its edge state cleared.
FR-TRG-11 The system shall maintain a named geofence library supporting circles and polygons, usable by custom triggers. Circle: centre + radius (20–4000 m). Polygon: ≥3 vertices, point-in-polygon by ray casting.
FR-TRG-12 An author shall be able to draw and reshape geofences on a map: drag a corner, insert a corner on an edge, delete a corner, move the whole zone, resize a circle, duplicate, recolour, delete. Corner deletion is refused below 3 vertices (a degenerate ring would silently match nothing). Edits are staged; Cancel discards, Save commits.
FR-TRG-13 Trigger→playlist resolution shall support interior/exterior split playlists, sequences and repetitions. Engine resolves and concatenates sequences, then repeats. Authored in the trigger row's Audio group (D-08); a triggerJobsParity test pins the Creator's preview to the engine's resolveJobs.

5.3.1 Built-in trigger set (33)

Group Trigger types
Journey lifecycle (7) journey-not-in-traffic, journey-not-in-traffic-countdown, journey-activated, journey-running, journey-offroute, journey-approaching-last-stop, journey-arrived-at-destination
Trip / stop flow (7) trip-start, approaching-stop, arrived-at-stop, departing-stop, last-stop, stop-skipped, stop-request
Sensors (2) doors-open, doors-close
Distance / time thresholds (5) distance-to-stop, distance-before-stop, distance-after-stop, time-to-stop, time-after-stop
Spatial / motion (3) geofence, speed, exit-side
Service / passenger info (7) connection-info, situation-message, alarm-activation, destination-override, passenger-load, detour, bus-type ⚠ never fires — flagged unavailable in the Creator
Local-only (2) — accepted for import, not MQTT-fired stationary-display, volume-calibration

MQTT-driven set = 30. Custom triggers fire as a 31st event kind outside this taxonomy.

5.3.2 Condition catalogue

Condition Operator Unit Default
timeToStopSeconds seconds 60
timeAfterStopSeconds ≥ since departure seconds 30
distanceToStopMeters / distanceBeforeStopMeters m or ft (threshold only{distanceToStop} always speaks metres) 200
distanceAfterStopMeters ≥ travelled from departure point m or ft 200
geofenceCondition within / outside / entering / leaving ⚠ collapse to 2 behaviours within
geofenceRadius + geofenceCenter ≤ haversine metres 100
speedCondition below (<) / above (>) / between (inclusive) km/h or mph — (unset ⇒ never fires)
occupancyThreshold ≥ (worst car) percent 80
exitSides ∈ authored sides (feed Both also satisfies a Left- or Right-only selection) unset ⇒ every real side

Prerequisite gates (authored per trigger under Prerequisites; universal, and they fail closed — a gate whose signal is absent blocks the announcement): requiresDoorOpen, requiresDoorClosed, requiresStopButtonPressed, requiresVelocityBelow, requiresVelocityAbove (both in speedUnit). The engine also declares requiresDirection, not enforced — no forward/reverse signal in the feed and deliberately not authorable (D-04).

5.3.3 Custom-trigger fact catalogue (40)

Category Facts
Time & calendar (7) hour, minute, weekday, isWeekend, month, dayOfMonth, year — engine clock in the project's IANA timezone
Journey (9) journeyState (7 states), offRoute, lineNumber, lineCode, destinationNumber (the pre-programmed code the driver keyed in), countdownSeconds, countdownMinutes, tripId, extraText
Stops (10) nextStopName, nextStopSeq, distanceToNext, timeToStopSec, distanceFromPrev, finalDestination, stopInfoType, viaPassed, mainStops, nextStopBoardingAllowed
Doors & buttons (2) doorOpen, stopPressed
Movement (2) speedKmh, gpsTrusted
Energy (2) stateOfCharge (traction-battery %, from vehicle/energy), charging — electric-fleet triggers; a feed without energy data never satisfies either
Passengers & service (8) occupancyPercent, connectionCount, connectionDelayed, connectionCancelled, maxConnectionDelaySec, situationCount, alarmActive, exitSide
Geofence (per zone) inside / outside any named zone in the library (lat/lon are geofence-only pseudo-facts, not offered as plain conditions)

The same catalogue feeds the LED templates: a cycle's visual condition compiles to an expression over globalState.<factId>, so a sign rotation and an announcement are gated by the same facts.

Operators by type — boolean: isTrue, isFalse, changed · number: lt, lte, gt, gte, eq, ne, changed · enum: eq, ne, changed · string: eq, ne, contains, isEmpty, isNotEmpty, changed.

5.4 FR-REN — Realisation (audio and signage)

ID Requirement AC
FR-REN-01 The engine shall render a playlist to one audio clip plus a transcript, merging consecutive elements that share a voice into a single TTS request. A voice or volume change splits the run; identical runs still hit the shared cache.
FR-REN-02 Identical phrases shall be synthesised once for the entire fleet (content-addressed cache: memory LRU → filesystem → Azure Blob Storage). Cache key = hash of the exact synthesis inputs. Second vehicle speaking the same phrase costs zero TTS calls.
FR-REN-03 Concurrent identical synth requests shall be collapsed into one (single-flight). A fleet-wide cold miss on the same phrase triggers one supplier call, not hundreds. Surfaced as synthCoalesced.
FR-REN-04 The engine shall predictively pre-render upcoming stops so live announcements hit a warm cache. Volatile playlists (containing {currentTime}, {distanceToStop}, …) are correctly skipped.
FR-REN-05 TTS shall be resilient: retry with backoff, circuit-breaker, and fallback audio. Auth errors (401/403) skip retries. A supplier outage never drops an announcement; the LED sign still renders from text. Breaker state is on Health.
FR-REN-06 The interior sign shall be rendered as a 16×144 amber 1-bpp bitmap, centred when it fits and flagged scroll when wider. Published with the same traceId as the audio, for outputType interior or both.
FR-REN-07 Exterior signs shall be rendered for front (192px) / side (160px) / rear (48px), 24px tall: a fixed route number that never scrolls, plus a destination that shrinks to fit and scrolls only as a last resort. Published retained, only when route/destination changes.
FR-REN-08 The same bitmaps shall also be emitted as raw Mobitec FF frames (board 1463-L, graphic font w) on separate topics for an RS-485 gateway. Frame = 0xFF · address · data · checksum · 0xFF; decode round-trip is tested. Interior addr 10; exterior front/side/rear 1/2/3.
FR-REN-09 The browser shall rasterise signs with the same font the engine ships, so previews and replays match the hardware exactly. Font served at /led-font.json; dashboard and engine produce identical bitmaps.
FR-REN-10 An announcement with unresolved required variables shall be droppable rather than spoken with a gap. Governed by SKIP_INCOMPLETE_ANNOUNCEMENTS. Nine variables are classified optional and never block.
FR-REN-11 Sign output shall be vendor-independent: the same bitmaps shall also be emitted as Hanover HCPS/SuperX graphic frames on parallel …/hanover topics (in parallel with FF when both are enabled). Frame = STX · '0' · addr(hex) · SuperX {\pic} · ETX · 2-char checksum; checksum + raster packing verified byte-exact against the vendor docs in Hanover/; decode round-trip tested. On by default (DISPLAY_HANOVER_ENABLED).

5.4.1 FR-LED — Matrix LED templates (Luminator MatrixRenderer)

Authors design destination and interior signs as first-class content — the same Display → Cycles → Layout → Elements contract as LuminatorSuite.Unit.MatrixRenderer.Api — and bind them to vehicle faces and triggers. Audio playlists and LED templates are parallel outputs of the same publish / trigger pipeline.

ID Requirement AC
FR-LED-01 The Creator shall author LED layouts as width×height pixel canvases of Text / Image / Rectangle elements with declarative auto-format (overflow, alignment, scroll, alternation, blink, colours, image scaling). Layout editor supports create / duplicate / delete; live thumbnail + sample-state preview.
FR-LED-02 A Display shall hold an ordered cycle tree; each cycle may carry a visual trigger (when / caseWhen) that compiles to enabledExpression, plus timed rotations pointing at a layoutId. Display editor preview shows the same "which layout wins" result the engine resolver produces.
FR-LED-03 Authors shall maintain a vehicle roster (Ultima models, resolution, position, FF address, colour mode) and bind each face to a Template display or Announcement-text source. "Assign default templates" / "Set up standard vehicle" produce a working in-service / not-in-service setup.
FR-LED-04 Symbol mappings and colour rules shall restyle a face from live field values (e.g. line code → pictogram; service state → colours). Applied identically in Creator preview, dashboard live panel and engine render.
FR-LED-05 Built-in and custom triggers shall optionally bind a layout per vehicle face for as long as the trigger fires; otherwise the bound Display cycles select the layout from globalState. Override order: firing trigger face binding → else Display cycles. NIS can blank unbound faces.
FR-LED-06 The engine shall rasterise layouts with real Luminator FNT fonts (font ladder) and emit mono or RGB Mobitec FF frames (encodeFf / encodeFfRgb, protocol 03090). Round-trip / parity tests cover FNT parse, layout render and FF RGB encode. Gated by display.templateMode.
FR-LED-07 Creator models (web/…/led-template.ts) and engine models (src/domain/ledTemplate.ts) shall stay in lockstep; browser and engine layout renderers shall produce matching frames. Guarded by test/ledTemplate.test.ts and matrix/layout parity tests.
FR-LED-08 The Engine dashboard's Displays drawer (in the Monitor cockpit) and the Creator Simulator shall preview the published templates against live / simulated journey state. Same cycle + trigger-override pipeline as the engine; empty config links to Creator. The former standalone /signs page is merged into the cockpit and redirects there.
FR-LED-09 Each rostered sign shall declare its wire protocol (ff default, or hanover), and the publish shall emit the matching raw frame per sign — mixed-vendor rosters supported in one build. protocol: 'hanover' signs publish <signs>/{slug}/hanover + the …/signs/hanover aggregate instead of FF; protocol changes alone re-publish (signature covers it). Covered by matrixParity.test.ts.
FR-LED-10 Raw wire frames shall also publish on address-specific topics on every raw-frame path — <signs>/ff/{address}, <signs>/hanover/{address}, plus the fixed-face …/display/ff/{address}, …/display/exterior/ff/{address} and Hanover twins — so a sign controller subscribes to its own bus address instead of filtering the aggregate. Signs/faces sharing an address concatenate on its topic; on the template path a re-addressed or removed sign has its old retained address topic cleared. Covered by matrixParity.test.ts + pipeline.test.ts.

5.5 FR-DSP — Dispatch and arbitration

ID Requirement AC
FR-DSP-01 Exactly one announcement shall play per vehicle at a time. Enforced by a per-vehicle scheduler slot. Zero cross-talk verified at fleet scale.
FR-DSP-02 Priority shall be authorable (1–11) in both a flat and a nested shape, with per-type defaults when unset. Defaults: alarm 11 · detour/situation 10 · stop-request/off-route 9 · … · volume-calibration 1.
FR-DSP-03 The scheduler shall resolve every offer to exactly one of play · interrupt · queue · drop. play if idle; interrupt if strictly higher priority and interruptLower; queue if queueIfBlocked; else drop.
FR-DSP-04 An interrupt shall abort the superseded render so its audio is never published on top of the higher-priority clip. The aborted render skips its publish; interrupted is counted on Health.
FR-DSP-05 The queue shall be priority-ordered (ties by arrival), bounded, and shall shed the lowest-priority tail on overflow. Default depth 8. Expired entries are purged before each decision.
FR-DSP-06 Audio shall be published as an ADT 4.x payload (base64 MP3/OPUS, integer speaker levels 0–100). Conforms to the transHub AsyncAPI spec; validated by contract tests.
FR-DSP-07 The engine shall render only for vehicles in the served set by default (RENDER_SCOPE=selection); an empty set means silence. Production-safe: connecting to a live fleet announces nothing until an operator opts vehicles in. all is load-test only and raises a visible warning in the UI.

5.6 FR-PLY — Onboard playback

ID Requirement AC
FR-PLY-01 Three interchangeable players (Go, Python, browser) shall implement one identical contract, so a fleet can mix them. Subscribe {tenant}/{vehicleId}/pis/0/tts; ack engine/played with {traceId, tenant, vehicleId, dispatchedAt, playedAt, status}.
FR-PLY-02 A player shall acknowledge when the clip finishes, not when it arrives. This is what distinguishes proof-of-play from proof-of-dispatch. statusplayed | failed.
FR-PLY-03 Clips shall be queued and played strictly one at a time; announcements shall never overlap. Go: single-goroutine drain of a bounded channel. Python: bounded queue + one worker. Browser: serialised promise chain.
FR-PLY-04 A player shall never block its network loop; an over-full queue shall drop with a log line, and a wedged clip shall time out (120 s). A stuck decoder cannot silence the vehicle permanently.
FR-PLY-05 Playback volume shall be taken from speakers.INTERNAL (0–100), scaled by an operator-set device volume. ⚠ No player drives speakers.EXTERNAL (§10).
FR-PLY-06 Headless players shall ship as multi-arch containers (amd64 + arm64) needing only outbound MQTT and --device /dev/snd. Runs behind vehicle NAT unchanged; reconnects on its own.
FR-PLY-07 A zero-install browser player shall be deep-linkable and QR-handoff-able for demos and field spot-checks. /player?tenant=…&vehicle=…; requests a screen wake-lock; one tap to satisfy browser autoplay policy.

5.7 FR-AUD — Evidence and compliance

ID Requirement AC
FR-AUD-01 The system shall keep an append-only audit trail of three record types on one timeline: audio (dispatched), exterior (sign changed), played (vehicle-confirmed). Local rolling JSON-lines, bounded and auto-trimmed; writes are serialised and never block the render pipeline.
FR-AUD-02 An audio record shall carry vehicle, tenant, GPS, time, trigger, stop, route, destination, journey ref, transcript, the lexicon-adjusted spoken text, language, voice, routing, volume, timing, cache stats, and whether the interior sign displayed it. Full field list in §6.3.
FR-AUD-03 A played record shall be correlated to its dispatch by traceId and shall carry the original dispatch time, so end-to-end latency is measurable. Enables the dispatched-vs-played distinction.
FR-AUD-04 The system shall compute coverage = confirmed ÷ correlatable dispatches. Records with no traceId are excluded from the rate, not counted as failures. A later played supersedes an earlier failed (a retry that succeeded). Per-announcement outcome ∈ confirmed | failed | unconfirmed | uncorrelated.
FR-AUD-05 The audit trail shall be queryable and aggregatable by tenant, vehicle, route, destination, journey, trigger, surface, free text and time range; and groupable by journey/route/destination/date/hour/vehicle. Server-side aggregation over the full filtered set, not just the page.
FR-AUD-06 The system shall produce a certified coverage report in JSON, CSV and print-ready HTML. Includes coverage headline, per-route/vehicle/day breakdowns, an itemised exceptions table, an explicit basis disclaimer, and an operator sign-off/attestation block.
FR-AUD-07 The report shall carry an integrity hash (SHA-256 over the canonical outcome tuples) so a stored report can be proven unaltered. Regenerating the report over the same data reproduces the hash.
FR-AUD-08 With a GTFS feed, the report shall additionally certify schedule-based expected coverage — stops that were never announced at all. Dispatch-based coverage cannot detect a never-attempted stop; this closes that hole.
FR-AUD-09 The audit trail shall be durably persistable beyond the engine host, per tenant. Opt-in Postgres sink (PROOF_DB_ENABLED), batched, best-effort: a sink failure is logged and never breaks the pipeline; the local file remains the record.
FR-AUD-10 Replay shall re-show the interior and exterior signs and re-voice the transcript, and shall be labelled a reconstruction. The UI states plainly: "Replay re-voices the transcript via the browser — the record is the proof."

5.8 FR-OPS — Operations and monitoring

ID Requirement AC
FR-OPS-01 An operator shall control the served set (which vehicles the engine renders for) from the dashboard; the control shall be retained and shall survive an engine restart. engine/control/select; bidirectional — the UI re-syncs from whoever set it.
FR-OPS-02 The engine shall publish health telemetry at a fixed interval: throughput, event rate, event-loop lag, RSS, cache hit rate, in-flight/queued/dropped/interrupted, dedup, pre-render, and TTS breaker state. Full field list in §7.1. Per-interval counters reset each beat.
FR-OPS-03 The engine shall publish per-announcement metrics broken into resolve / render / TTS / publish, with GPS. Drives the latency breakdown and the event map.
FR-OPS-04 The system shall raise operational alerts: TTS breaker open, announcements dropped, vehicles silent >5 min. Surfaced as banners in the dashboard header.
FR-OPS-05 A retained fleet directory shall let the vehicle picker populate without subscribing to the whole fleet. One topic (engine/fleet) carries route, destination, current stop, stops left, phase per vehicle.
FR-OPS-06 An operator shall be able to inject test signals into a vehicle (stop request, doors, off-route, exit side, crowding, alert, alarm, destination override) to exercise triggers without waiting for real conditions. Publishes real PIS topics; the stop-request is momentary and auto-releases.
FR-OPS-07 An author shall be able to simulate a real GTFS journey and see, per event, exactly what would be spoken — including custom triggers edge-detected the same way the engine does. Timeline shows "Trigger not enabled" / "no playlist assigned" / the resolved spoken text; each event is auditionable.
FR-OPS-08 The upstream bridge shall be lean: follow the served set, keep fleet discovery minimal, tenant-scope discovery, and drop byte-identical re-publishes. Publishes its own retained health (forwarded/s, deduped/s, bridged vehicles).

6. Data model — key entities

6.1 Authoring entities

contains

has

has

has

has

has

has

contains

fires

fires

tests

published as

PROJECT

ANNOUNCEMENT_CONFIG

PLAYLIST

TRIGGER

CUSTOM_TRIGGER

GEOFENCE

LEXICON_ENTRY

VOLUME_RULE

ELEMENT

CONFIG_VERSION

Entity Key attributes
Project id, tenant, fleet, name, description, config, timestamps
Playlist id, name, language, outputType (interior|exterior|both), voiceSettings, elements[]
Element id, type (5 literals), content, volume (0–100), voiceSettings?, pauseDuration?, audioUrl?
Trigger type (1 of 33), enabled, playlistId (+ interior/exterior/sequence/repetition slots), condition, priority, interruptLower, queueIfBlocked
Custom Trigger id, name, enabled, when (nested ALL/ANY/NOT expression), fireMode, cooldownSec, playlistId, priority
Geofence id, name, colour, shape: {kind: circle, lat, lng, radius} | {kind: polygon, points: [lat,lng][]}
Lexicon Entry word, pronouncedAs, ipa?, language?
Volume Rule type (combined — ANDed timeRange / weekdays / routes / stops / geofenceIds conditions; legacy time-of-day|route|stop still honoured), volume, enabled

6.2 Runtime entities

Entity Meaning
VehicleContext Per-vehicle aggregation of all retained PIS-PT topics; idle-TTL evicted
JourneyState Normalised view derived from the context — the input to trigger evaluation
PisTriggerEvent An edge-detected event (31 kinds incl. custom) with its payload
Announcement job A resolved (trigger → playlist[s] → repetitions) unit of work with a priority

6.3 Evidence entities (HistoryRecord / proof_of_play)

at · type (audio|exterior|played) · traceId · tenant · vehicleId · vehicleKey · lat · lon · kind · triggerType · stopName · transcript · spoken · language · voice · outputType · volume · playlists[] · reps · bytes · totalMs · ttsMs · cacheHits · cacheMisses · unresolved · displayed · displayScroll · route · destination · journeyRef · faces[] (exterior) · playStatus (played) · dispatchedAt (played)

6.4 Persistence

Store Holds Written by Read by
projects Authoring source of truth manage-project (service role) Creator (anon, RLS, tenant-aware)
engine_config The published output, one row per (tenant, fleet) publish-engine-config (service role) Engine (anon + Realtime)
config_versions Append-only publish history Portal /api/publish (one transaction) Creator History screen
proof_of_play Durable audit trail Engine sink (batched pg INSERT) Dashboard / Creator (portal API, tenant-aware)
config/announcement-config.json Offline fallback config Operator / import Engine, only when no remote config source is set
.data/history.jsonl Local rolling audit log Engine /history, /proof/report

Direction matters. projects is the input, engine_config the output, the file the fallback. Writing to engine_config does not change what the author sees — it is overwritten by the next publish. To change what an author sees, write the project.


7. External interfaces

7.1 MQTT — the primary integration contract

Topic Dir Payload
{tenant}/{vehicleId}/pis/0/<sub> in PIS-PT trip data (multi-topic, retained)
{tenant}/{vehicleId}/pis/0/tts out ADT 4.x audio — base64 clip + integer speaker levels + transcript
{tenant}/{vehicleId}/pis/0/display out Interior sign, 1-bpp bitmap JSON
{tenant}/{vehicleId}/pis/0/display/exterior out Front/side/rear signs (retained)
…/display/ff · …/display/exterior/ff out Raw Mobitec FF frames (RS-485 gateway)
…/display/hanover · …/display/exterior/hanover out Raw Hanover HCPS/SuperX frames — same bitmaps, Hanover signs (RS-485 gateway)
…/display/signs · …/display/signs/ff out Matrix template signs — one frame per rostered vehicle face (JSON + mono/RGB FF), deduped by signature; gated by DISPLAY_TEMPLATE_MODE. Per-address raw frames on …/signs/ff/{address} · …/signs/hanover/{address}
…/pis/0/list/destinations out The active pre-programmed destination list (retained), off unless DESTINATION_LIST_PUBLISH=true
engine/played in Played ack{traceId, tenant, vehicleId, dispatchedAt, playedAt, status}
engine/metrics out Per-announcement timing + GPS
engine/health out at, renderScope, served, vehicles, msgPerSec, eventsPerSec, inFlight, queued, dropped, interrupted, memRssMb, loopLagMs, cacheHitRate, cacheSize, synthCoalesced, prerendered, prerenderQ, ttsBreakerOpen, ttsFallbacks, ttsRetries
engine/fleet out Fleet directory (retained)
engine/bridge out Bridge health (retained)
engine/control/select ctl Served set (retained) — {vehicleKeys: []}
engine/control/tenant ctl Bridge tenant scope (retained)

Inbound PIS-PT sub-topics consumed: journey, destination, destination/override, list/stops, linkprogress, stopinfo, journeystate, sensors/door, sensors/stop_button, vehicle/gnss_location, vehicle/exit_sides, connections, passenger_load, alarm_activation, shape.

7.2 HTTP — portal server (:8080)

Route Purpose
/ · /monitor/* · /creator/* Portal menu, engine dashboard, authoring app (one origin, no CORS)
GET/POST /config Live config (remote-first); POST imports a config export
GET /history Audit query — filters, groupBy, format=csv
GET /proof/report Certified coverage reportformat=json|csv|html, expected=1 for GTFS coverage
GET /led-font.json Shared LED font (browser rasterises identically to the engine)
GET /tts-providers · /tts-voices · POST /tts-preview Supplier catalogue and A/B audition through the engine's real clients
POST /gtfs/import · GET /gtfs/* GTFS feed upload and introspection
GET /player · /player/v/<tenant>/<vehicle> · /player/qr.svg · /player/hosts Browser vehicle player and QR handoff (the QR carries the ids in the path, not a query string)
GET /docs · /documentation.html · /documentation.md · /docs/src/* Documentation Center — the handbook assembled live from the repo's Markdown, its raw sources, and the decks
GET /architecture.md Architecture doc (raw Markdown)

Mutating or cost-bearing routes (POST /config, /tts-preview, /gtfs/import, /player/ack) require Authorization: Bearer $DASHBOARD_TOKEN when that variable is set (constant-time compare); reads are open.

7.3 External services

Service Protocol Auth
MQTT broker MQTT 3.1.1 (mqtt:// 1883, ws:// 9001) user/pass (optional) ⚠ see §10
Azure AI Speech REST + SSML subscription key
Azure PostgreSQL (config, versions, proof) pg SQL + LISTEN/NOTIFY; portal /api/* for browsers & vehicles DATABASE_URL (sslmode=require); optional CONFIG_API_TOKEN
Azure Blob Storage audio-cache container (MP3 cache), objects tts-cache/<sha256>.mp3 container SAS URL (CACHE_BLOB_SAS_URL)
Acapela / ElevenLabs REST account key/token

8. Non-functional requirements

Values marked [M] are measured, not estimated.

ID Requirement Target Evidence
NFR-PERF-01 Announcement latency, cache hit (the dominant fleet-wide case) ≈2 ms total, ttsMs=0 [M] live run, Azure Speech
NFR-PERF-02 Announcement latency, cold synth of a unique phrase ≈1.0 s, once per unique phrase [M] live run
NFR-PERF-03 Config publish → fleet live ≈1 s pg_notify/LISTEN hot-swap
NFR-SCAL-01 Single hardened engine process shall serve 2000 vehicles on a 2-vCPU / 4 GB host 2000 vehicles: 29 ms p50 loop lag, 250 MB RSS, zero drops, 100% cache hit [M] load ladder 50→4000, 2026-07-06
NFR-SCAL-02 Known single-process ceiling ~3000–3500 on that hardware (4000 saturates the loop at ~870 ms lag) [M] same ladder
NFR-SCAL-03 Multi-tenant scaling model One engine process per tenant (bridge pre-scopes; engine reads only its (tenant, fleet) row) Realised model for a ~5k-vehicle multi-tenant broker
NFR-COST-01 TTS cost shall scale with unique phrases, not vehicle count Fleet growth adds ~zero TTS cost for repeated phrases Content-addressed cache + single-flight dedup
NFR-AVAIL-01 A TTS supplier outage shall not drop an announcement Retry → breaker → fallback clip; signs still render from text tts/resilient.ts
NFR-AVAIL-02 The engine shall never crash on a missing broker; players shall reconnect unattended Daemon semantics; restart: unless-stopped
NFR-RES-01 Memory shall stay flat under sustained fleet load LRU audio cache + on-disk file cap + idle context eviction [M] RSS stable across the ladder
NFR-SAFE-01 Connecting to a live fleet shall announce nothing until explicitly opted in RENDER_SCOPE=selection default; empty served set = silence; an invalid value fails at startup A typo cannot silently change who gets served
NFR-SEC-01 Config reads shall use the public anon key under RLS; all writes shall go through service-role edge functions that enforce the verified tenant claim No anon write path exists
NFR-PORT-01 Onboard players shall run on x86-64 and ARM64, headless, behind vehicle NAT Multi-arch images; outbound-only MQTT
NFR-OPS-01 The whole backend shall run 100% locally with no cloud dependency db:start applies every migration from empty; stack:local points everything at it

9. Verification and traceability

Mechanism Coverage
Automated test suite 581 engine tests across 67 files (node:test, 96.5 % line coverage) plus 758 front-end tests across 67 files (Vitest + jsdom) — 1339 in total, all passing. Covers trigger detection and prerequisite gates, custom fact triggers and geofence geometry, priority resolution and scheduling, the matrix-LED pipeline end to end (FNT, layouts, cycles, colour rules, mono/RGB FF, browser↔engine parity), pre-programmed destination lists, ADT payload conformance, multilingual variable resolution, per-element voice/volume, SSML, TTS resilience and the supplier HTTP clients, config env permutations and Postgres LISTEN reconnect, GTFS parsing and expected coverage, proof-of-play correlation/report/sink, project CRUD/migration/validation, and a full engine boot against a real broker. Breakdown: TEST-REPORT.md.
Trigger-parity guard A strict bidirectional contract between the engine's trigger set and the authoring manifest — same types both ways, matching local flags, every MQTT trigger wired to an emitted event. Enforced by a contract test and a standalone CI step. This is what catches authoring/engine drift automatically.
Typecheck tsc --noEmit gates the shared wire contracts (src/shared/payloads.ts) across engine and Angular — a contract change is a compile error on both sides.
Sample-config test Every trigger resolves to a real playlist, every {variable} is one the resolver knows, every chime file exists, and every playlist renders to real speech with no unfilled variables — a playlist that would be silently dropped in production fails the build instead.
End-to-end demos as living verification proof:demo, ff:demo, led:demo, multi:demo (N vehicles, asserts no cross-talk + served-set isolation), bridge:test, config:test, prerender:demo.
CI Azure Pipelines runs typecheck + tests + trigger-parity + dashboard build on every PR and push to main, blocking merges on drift or a broken build.

Verification gaps: no automated test drives the Go/Python players end-to-end against a broker; the FF hardware scroll frames are pinned byte-for-byte against the spec but have never been driven into a real sign; report integrity hashing is tested only at unit level.


10. Defect and gap register

The register is the product backlog's input. Severity is functional impact, not code quality.

10.1 Authored-but-inert — the author can configure something that does nothing

These are the highest-value findings: the UI implies a capability the runtime does not deliver, silently.

# Finding Severity Impact
D-01 bus-type trigger never fires, yet the Creator offers a City/Intercity/Regional/Shuttle/School multi-select for it. Fixed (authoring side) — the multi-select is gone; the trigger is listed as unavailable with the reason, its toggle refuses a fresh enable, and an enabled legacy one is an error in Issues. The type stays in the catalogue so configs round-trip and engine↔manifest parity holds. It still cannot fire: that needs an upstream vehicle-type signal. High Resolved as far as the feed allows
D-02 exitSides is declared but never read. The engine fires exit-side on any change to a non-Unknown side. Fixedexit-side now fires only for an authored side; unset/empty still means every real side, and a feed Both satisfies a Left- or Right-only selection. High Resolved
D-03 Arrivals variables ({arrivingBuses}, {arrivalMinutes}, {arrivalRoute}, {arrivalDestination}) are offered in the Creator's variable picker but are not implemented in the resolver. Fixed — removed from the picker; a variable-parity contract test now fails the build if the Creator ever offers a variable the resolver can't fill; the linter errors on any unresolvable token (typos included); and the engine logs unknown {variables} by name instead of dropping the announcement in silence. High Resolved
D-04 requiresDirection gate is declared but never enforced — no forward/reverse signal exists in the feed. Medium Documented, but still authorable in the legacy app.
D-05 Geofence modes enteringwithin and leavingoutside at runtime (the edge comes from the caller, so the four modes collapse to two behaviours). Medium Four options, two behaviours. Either implement true edge semantics or reduce the choice.
D-06 {distanceToStop} is always spoken in metres, ignoring the trigger's distanceUnit. Closed by decision (2026-07-22): spoken distance is always metres. distanceUnit converts the threshold only — authoring "within 500 feet" still works — and the announcement speaks the feed's metres. The Creator now says so: the variable is labelled Distance to stop (metres) and choosing feet shows a note. Pinned by a test so it isn't "fixed" into feet later. Medium — resolved (behaviour intentional)

10.2 Capability regressions in the Angular Creator

The Angular Creator replaced the React app but does not yet expose everything the engine supports. Configs round-trip these fields safely (they survive import/export), but there is no UI to set them.

# Missing from the Creator Engine support Severity
D-07 Universal prerequisite gates (door open/closed, stop button, velocity below/above) Fixed — a Prerequisites group on every trigger (the gates are universal, so it shows even for triggers with no condition of their own). The two door flags are one three-way control, so "open AND closed" cannot be authored; an empty speed box clears the gate rather than storing 0; and the linter errors on gate pairs an imported config may still carry (both doors, an empty speed window, a door gate contradicting doors-open/doors-close). Fully implemented, incl. gate-hold retry High — resolved
D-08 Interior/exterior split playlists, sequences, repetitions Fixed — an Audio group in the expanded trigger row: single playlist or an interior/exterior split, either one a reorderable sequence with a repeat count. Switching back to single clears the channel playlists (leaving them would keep the engine playing them behind the UI's back), and the collapsed row summarises split/sequenced audio instead of showing a select that tells half the story. A triggerJobsParity contract test pins the Creator's preview to the engine's resolveJobs. Fully resolved and rendered Medium — resolved
D-09 Custom-trigger interruptLower / queueIfBlocked Honoured by the scheduler Low
D-10 Volume-rule priorityOrder; volume rule route/stop ids (only names are editable) Honoured Low

Mitigation today: custom (fact-based) triggers can express most gate logic, since the facts include doorOpen, stopPressed and speedKmh. But custom triggers bypass the gates, the settle window and the debounce entirely (D-13) — so it is not a like-for-like substitute.

10.3 Correctness and semantics

# Finding Severity
D-11 last-stop and journey-approaching-last-stop fire on the same state entry… the vehicle announces the final stop twice. Fixed — the two are mutually exclusive: last-stop (priority 8) wins whenever it is enabled and journey-approaching-last-stop yields to it; with only the latter enabled it fires as before. High
D-12 stop-skipped has two independent firing rules (sequence jump >1, and stopinfo.type = PASSAGE) which can both hit for the same skip. Fixed — the two sources now resolve to one decision (which stop was skipped) and each stop is announced at most once, including when PASSAGE and the pointer jump arrive in different ingests (different topics, so the common case). Serving a stop clears the memory, so a loop route that bypasses the same sequence number later still announces it. Medium — resolved
D-13 Custom triggers bypass universal prerequisite gates, the post-sighting settle window, and matchTrigger. Medium — an author reasonably expects gates to be universal.
D-14 The custom-trigger engine emits a connectionPlatformKnown fact that is absent from the authorable fact catalogue. Low — capability exists, not exposed.
D-15 Polygon point-in-polygon is planar ray-casting (no antimeridian / great-circle handling). Low — irrelevant for transit-scale zones; would break at ±180° longitude.

10.4 Security and audit integrity

# Finding Severity
D-16 Played-acks are QoS 0 and the broker's WebSocket listener is plain ws:// with allow_anonymous. Any device on the network can subscribe to a vehicle's audio — or publish fake played-acks into the ADA audit trail. Critical (before any real-vehicle deployment) — this undermines the evidentiary value of the entire proof-of-play feature. Needs wss:// + credentials.
D-17 Because acks are QoS 0, an unconfirmed dispatch is not necessarily an unplayed one — coverage under-reports on ack loss. High — the report's basis disclaimer must say so (it does), but the transport should be made reliable.
D-18 The Azure Speech key was shared in plaintext during development and should be rotated. High — outstanding.
D-19 The legacy React app carries a hardcoded dev broker password in DEFAULT_MQTT_CONFIG, which ships in its client bundle. Medium — that app is no longer served by the portal, but the credential is still in the repo.
D-20 published_by in the audit trail is just the tenant string Partly fixed — the integrity half. published_by is now derived from the caller's server-validated token whenever a user is behind the request (the client's own value is ignored in that case), and every row records published_by_verified so a self-asserted label can never be read as evidence of a person; History marks the two differently. The Creator still has no login of its own — it accepts a token from a host shell (SessionService, the identity twin of the tenant seam), so real names appear the day the suite signs users in. Until then rows stay honestly unverified. Medium — integrity resolved; naming a person still needs an identity provider

10.5 Functional gaps (known, accepted)

# Gap Note
D-21 No player drives the exterior loudspeaker. The payload carries speakers.EXTERNAL, but driving it also means raising a digital output to enable that amplifier — unit-specific wiring. An exterior-only announcement plays at zero gain rather than out of an exterior speaker. Requires vehicle integration
D-22 FF hardware scroll — the byte layout is settled (03090 R5 §7.3.3: four settings bytes, [mode, count, time, speed]; R7's two-byte Example 3 is an error R5 removed) and the interior sign now emits 0xA5/0xD5 scroll frames. Exterior signs remain static — a scrolling destination is a sub-region, needing a second text field per frame. Implemented (interior); needs bench verification
D-23 Audio ducking on interrupt — the superseded clip hard-stops; no cross-fade. The engine does not own the speaker. Accepted
D-24 MP3 concatenation is a byte-wise join — plays fine everywhere; sample-accurate gapless needs ffmpeg. Accepted
D-25 Per-element volume applies only where the supplier declares it (capability descriptor: Azure/Google/Polly); plain-text suppliers drop it with a log line + an authoring-time warning. Accepted
D-26 Players use only audio[0]; multi-clip audio[], contentUrl, and expiryDateTime are unhonoured. Accepted
D-27 Per-vehicle state is in memory — horizontal scale needs sharding or Redis. Not a functional need at 2000 (NFR-SCAL-01)
D-28 engine/fleet payload is O(fleet) (~940 KB at 4000 vehicles, republished every second) — should be gated/decimated when nobody is subscribed. Scale hygiene
D-29 Retained-topic startup flood — reconnecting against a large fleet replays ~5 retained topics × N vehicles at once, causing multi-second lag spikes at 2000+. Scale hygiene

10.6 Documentation drift

# Finding
D-30 ARCHITECTURE.md states 140 tests across 25 files and 34 backend modules, and documents neither proof_of_play (durable audit sink), the /proof/report certified coverage report, nor GTFS expected-coverage. Fixed (2026-07-24) — the whole doc set was reconciled against the code in one pass: counts re-measured (581 engine tests / 67 files / 65 modules, 758 front-end tests / 67 files), the module map and responsibility table extended to the matrix-LED pipeline, custom triggers, destination lists, the proof sink/report and GTFS, the MQTT and HTTP tables completed (display/signs, list/destinations, /proof/report, /docs, the auth gate), and the screen/nav descriptions brought in line with the merged cockpit and the split Proof / History menus. TEST-REPORT.md is now generated from the runner, case for case.
D-31 The config_versions publish-history table and the parse-trigger-rule edge function are live but described only in the Creator's own screens documentation, not in the architecture's interface section.

11. Roadmap

Ordered by the register above, not by engineering convenience.

Now — trust and safety (blocks any real-vehicle deployment)

  1. Secure the transport (D-16, D-17): wss:// + credentials on the broker; make played-acks reliable (QoS ≥1). Without this the proof-of-play trail is forgeable, which negates BO-3.
  2. Rotate the Azure Speech key (D-18); purge the hardcoded broker password (D-19).
  3. Fix the authored-but-inert set (D-01, D-02, D-03): either wire them up or remove them from the UI. Done — the authoring surfaces are gone or flagged. bus-type and the arrivals data still need upstream signals before they can do anything (items 10 below).
  4. Fix the double-announce on the final stop (D-11). Done.

Next — close the authoring regression 5. Restore prerequisite-gate authoring in the Creator (D-07) — the engine's headline safety gating is currently unreachable from the UI. Done. 6. Sequence / repetition / split-playlist authoring (D-08). Done. 7. Per-user identity on publish and proof (D-20). Publish half done: identity is verified server-side when a token is present and every row says whether it was. What remains is an identity source — either the suite hands the Creator a signed-in user's token, or the Creator grows its own login (who provisions accounts is the open question). Proof-of-play records are untouched by this.

Then — completeness 8. FF hardware scroll (D-22) — verify the interior frames on a bench sign, then extend to the exterior destination sub-region. 9. Exterior-speaker enablement (D-21) — needs vehicle integration. 10. bus-type and stationary arrivals — once the feed exposes formation/type and real-time arrivals. 11. Per-tenant proof retention/rotation.

Scale (not required for 2000) 12. TTS throughput tier (the remaining real wall: Azure F0 caps ~20 req/s). 13. engine/fleet decimation (D-28); paced retained-topic backfill (D-29). 14. Horizontal sharding / Redis-backed state for headroom and availability beyond ~3000. 15. ffmpeg gapless concatenation (D-24).

Housekeeping 16. Reconcile ARCHITECTURE.md with the current system (D-30). Done (2026-07-24) — every document re-checked against the code; the remaining sliver is D-31 (authoring-side tables).


12. Glossary

Term Meaning
PIS-PT Passenger Information System — Public Transport. The inbound MQTT trip-data protocol (multi-topic, retained).
ADT 4.x The transHub audio message spec published on …/tts — base64 audio plus per-speaker volumes.
Tenant An operator/agency namespace (e.g. baltimore-md-mta); the first topic segment and the isolation boundary.
Fleet A sub-scope within a tenant. One config is published per (tenant, fleet).
Journey One scheduled run of a vehicle, identified by vehicleJourneyRef; the natural grouping for proof-of-play.
Trigger A rule that fires an announcement on a PIS-PT condition. 33 built-in types, plus custom fact-based triggers.
Custom trigger An author-composed boolean expression over live facts and geofences — the extension point when no built-in trigger fits.
Project A named, self-contained configuration. The authoring input.
engine_config The published output — the single row the engine actually reads.
Playlist An ordered list of elements rendered into one announcement.
Served set The vehicles the engine currently renders for (operator-controlled, retained). Empty = silence.
Proof of Play The durable record of what was played/shown, where and when — dispatch confirmed by the vehicle.
Dispatched vs Played Dispatched = the engine published a clip. Played = the vehicle acked after the clip finished. Only the latter is evidence.
Coverage Confirmed plays ÷ correlatable dispatches. Records with no traceId are excluded from the rate.
FF / Mobitec 1463-L The LED sign driver-board serial protocol; font w is its graphic (bitmap) font.
Hanover HCPS / SuperX Hanover Displays' sign protocol: HCPS frames (STX…ETX + two ASCII-hex checksum chars) carrying SuperX messages; {\pic} embeds a bitmap as a vertical raster. Specs in Hanover/.

CreatorStudio — Architecture & System Documentation

This repo holds the CreatorStudio suite — two apps that make up one product:

The original React/Vite authoring app in audiocreator/ has been migrated to the Angular Creator at web/creator/ and is retired/vendored (no longer served — see §5). The database source of truth is db/migrations/ (plain SQL, applied by npm run db:migrate); its old Supabase backend (audiocreator/supabase/) was removed in the Azure migration.

All three are served together behind a single portal (scripts/dashboard-server.ts): / landing · /creator/ authoring app · /monitor/ dashboard (see §5). The engine is the primary subject of this document; the authoring app is covered where it drives the engine (config, trigger parity) and in §5.

This document is the single source of architectural truth for the project. Diagrams are Mermaid and render on GitHub and in the portal's Documentation Center (/docs → the consolidated handbook), which is where every document is published — the dashboard itself no longer carries a documentation panel.


About this document — who should read what

You are a… Start with Then read
Stakeholder / exec §1 What it does · §3 Capabilities §14 Gaps · §15 Roadmap
Product manager §1 · §3 Capabilities · §7 Interfaces §14 Gaps · §15 Roadmap · §16 Glossary
Designer §1 · §3 Capabilities · §6.4 LED/sign rendering · §5 Frontend §7 Interfaces
Backend developer §4 Backend architecture · §6 Software design · §7 Interfaces §8 Runtime · §10 Performance · §11 Scalability
Frontend developer §5 Frontend · §7 Interfaces (HTTP + payloads) §6.4 rendering
DevOps / SRE §9 Deployment · §10 Performance · §11 Scalability · §12 Security §13 Testing

1. What it does (system function)

Modern transit vehicles must announce stops to passengers audibly (speakers) and visually (interior + exterior LED signs), in multiple languages, triggered automatically by where the bus is and what it is doing. Authoring that content (playlists, trigger rules, voices) is done in the CreatorStudio web app. This engine is the runtime that executes it live across the fleet.

In one sentence: it subscribes to each vehicle's live trip feed, decides what to say and show, synthesizes the audio, renders the sign graphics, and publishes them back to the vehicle — for hundreds of buses at once — while keeping a tamper-evident record of everything that played.

publishes config

telemetry

Live trip data
(position, stops, doors…)

CreatorStudio Engine

CreatorStudio app
(playlists · triggers · voices)
in-repo · /creator/

🔊 Spoken announcements

▦ Interior LED sign

▦ Exterior destination signs

🧾 Proof-of-play audit

📡 Dashboard
/monitor/

The authoring app and the dashboard are both in this repo (web/creator/ and web/) and served by the portal alongside the engine; the retired React original in audiocreator/ still deploys standalone to Lovable.

Value delivered


2. Tech stack

Layer Technology
Backend (engine) Node.js ≥ 20 (dev on 24), TypeScript 5.9 (ESM / NodeNext), run via tsx (dev) or tscnode (prod)
Authoring app (web/creator/) Angular 20 + PrimeNG 20, themed with the Figma @primeuix/themes preset (web/src/theme/figma-preset, dark default); signals, hash routing, lazy chunks. Built under portal base /creator/. (Migrated from the React/Vite app in audiocreator/, which remains as standalone/Lovable source.)
Dashboard (web/) Angular 20 (standalone components, signals, hash routing, lazy chunks), @angular/build:application (esbuild); Leaflet, Mermaid, PrimeNG + the same Figma preset. Built under portal base /monitor/
Portal scripts/dashboard-server.ts — one no-framework HTTP server mounts both Angular front-ends + the data endpoints on one origin (:8080); one shared Figma/PrimeNG design system across both (see §5)
Transport / integration MQTT 3.1.1 — TCP 1883, WebSocket 9001. The primary integration surface.
Broker aedes in-process (dev, scripts/dev-broker.ts); Mosquitto (prod)
Text-to-speech Azure AI Speech (active) · Acapela Cloud · ElevenLabs — pluggable behind one interface (engine); authoring previews via the portal's /tts-preview
Config source CreatorStudio → Azure Database for PostgreSQL (live, pg_notify/LISTEN) with an on-disk JSON fallback
Cache & storage In-memory LRU → filesystem → Azure Blob Storage (MP3 cache); append-only JSON-lines (audit)
Sign protocols Vendor-independent — one render pipeline, per-vendor encoders: generic 1-bpp bitmap (JSON) + Mobitec FF (driver board 1463-L, graphic font w) + Hanover HCPS/SuperX (graphic picture frames)

Backend runtime deps (package.json): mqtt, pg, dotenv, leaflet, marked, mermaid, jszip, qrcode-generator (the last four serve the portal: docs rendering, GTFS/zip import and the player QR code). Dev/tooling: typescript, tsx, aedes, ws, @types/ws, @types/node, concurrently, cross-env. Codebase: 75 backend modules (src/**/*.ts), 74 engine test files (671 tests), plus the Angular dashboard and Creator under web/ (72 spec files, 877 tests), and the retired/vendored React CreatorStudio app under audiocreator/ (its own package.json + Vitest suite). Full breakdown and coverage: TEST-REPORT.md.


3. Capabilities (feature map)

Capability What it does Where
Trigger engine 33-type CreatorStudio trigger set; edge-detected from live PIS-PT signals (stop flow, doors, journey lifecycle, distance/time thresholds, geofence, speed, occupancy, exit-side) pis/ptEngine.ts
Prerequisite gates Any trigger can require door-open/closed, stop-button, or a velocity window before it fires; authored per trigger under Prerequisites in the Creator pis/ptEngine.ts
Playlist rendering static/dynamic text, pauses, pre-recorded audio clips → one MP3 + transcript engine/playlistRenderer.ts
Multilingual Resolves name_Multilanguage stop/destination variants per element's language engine/variableResolver.ts
Per-element voice / volume Elements may override voice; volume applied where the supplier declares prosody-volume support (its capability descriptor — Azure/Google/Polly), dropped with a log line elsewhere. Voice pitch is fixed at natural and is not authorable — a shifted pitch produces audibly broken speech that no log or payload reveals playlistRenderer.ts, tts/provider.ts
Priority scheduling One announcement per vehicle at a time; higher priority interrupts / queues / drops engine/scheduler.ts
Interior LED sign Renders the spoken line to a 16×144 amber dot-matrix bitmap (scrolls if wide) engine/ledRender.ts
Exterior destination signs Front/side/rear headsigns (route + destination), shrink-to-fit + scroll engine/exteriorSign.ts
Matrix LED templates Luminator MatrixRenderer contract: Display → Cycles → Layout → Text/Image/Rectangle; expressions over globalState; trigger face overrides; Ultima vehicle roster domain/ledTemplate.ts, engine/matrix/*, Creator /led-signs
Pre-programmed destinations The codes a driver keys in: a stored list resolves pis/0/destination.number (optionally scoped by lineCode) into sign text + spoken destination, with a per-list priority against the live feed; optionally republished retained on pis/0/list/destinations engine/destinationList.ts, shared/ledDestinations.ts, Creator /destinations
Certified coverage report A signed, self-contained proof-of-play report (summary, per-route breakdown, integrity hash, CSV/HTML) served at /proof/report, with GTFS-derived expected stop coverage to show what was missed, not just what played engine/proofReport.ts, gtfs/expectedCoverage.ts
GTFS feed Imported schedule data behind the portal (/gtfs/*): routes, trips, stop names for the simulator, the lexicon import and expected-coverage matching gtfs/feed.ts, scripts/dashboard-server.ts
FNT fonts Real Luminator .FNT / .FON fonts + font ladder for layout text shared/fnt.ts, shared/fnt-fonts.generated.ts
Mobitec FF output Mono graphic-font w frames and RGB COLORTEXT / colour-bitmap frames (03090) on separate topics engine/ff/ffEncoder.ts, engine/ff/ffRgbEncoder.ts
Hanover output The same bitmaps as HCPS/SuperX {\pic} graphic frames on parallel …/hanover topics — sign-vendor independence from one render pass engine/hanover/hanoverEncoder.ts
TTS resilience Retry + backoff + circuit breaker + fallback audio around the provider; per-supplier latency/error/volume counters; open breaker → render through the configured failover supplier tts/resilient.ts, tts/index.ts
Fleet-wide cache Identical phrase synthesized once; memory → FS → Azure Blob tts/cache.ts
Predictive pre-render Warms the cache for upcoming stops so live announcements are instant engine/prerender.ts
AI authoring assistant Describe the announcements in plain language → a reviewable plan of playlists + triggers (§7.3b). Runs against a local model through the portal (no cloud, no per-call cost) — or any OpenAI-compatible endpoint — with an offline keyword draft as the fallback. The model proposes; a client-side validator rebuilds every field against the real catalogue, and nothing lands until the author applies it — as one undo web/creator/app/lib/assistant.ts, scripts/lib/assistant-ai.ts, src/shared/assistantPrompt.ts, Creator /assistant
Live config sync Publish in CreatorStudio → engine hot-swaps via Postgres pg_notify/LISTEN (~1 s); vehicles poll the portal API on a retained MQTT nudge config/pgConfigSource.ts, config/httpConfigSource.ts
Proof of Play Durable audit of audio + interior + exterior events with GPS + time; table/map/replay/analytics engine/history.ts, web/…/proof
Vehicle players (§5.2) The client side — what actually makes a sound on the bus. Headless Go and Python clients (x86-64 + ARM64, Docker) and a zero-install browser player; each plays one vehicle and acks engine/played when the clip finishes clients/go, clients/python, public/player.html

4. Backend architecture

4.1 System context

On the vehicle — the client side (§5.2)

Fleet

{tenant}/{vehicleId}/pis/0/#

PIS-PT

Publish to engine (portal /api/publish)

pg_notify: engine_config_changed

synth (HTTPS)

MP3 cache

tts · display · display/ff

…/pis/0/tts (ADT audio)

…/display/ff (sign frames)

engine/played

played-ack

metrics · health

ws:// MQTT + HTTP

GTFS-RT → PIS-PT bridge
(per tenant)

Vehicle player
Go · Python · browser
→ loudspeaker

Sign controller
RS-485 · Mobitec FF / Hanover HCPS

CreatorStudio app
authors playlists + LED signs + triggers
in-repo · portal /creator/

Azure PostgreSQL
config

Azure Blob
MP3 cache

MQTT Broker

CreatorStudio Engine
(Node/TS)

Dashboard (Angular)
portal /monitor/

Azure AI Speech (REST)

The engine and dashboard never call each other directly — MQTT topics + a small HTTP surface are the entire contract (see §7). The vehicle is no different: a player is just another MQTT client, so anything that can subscribe to a topic and decode an MP3 can be the speaker.

Note the loop is closed. The player publishes engine/played once a clip has finished, so the proof-of-play trail records what a passenger actually heard — not merely what the engine dispatched. That distinction is what an accessibility (ADA) audit asks for.

4.2 Module dependency map

index.ts (composition root)

config.ts · ConfigStore

config/pgConfigSource.ts
· httpConfigSource.ts

mqtt/client.ts · MqttBus

pis/ptContext.ts

pis/ptEngine.ts

engine/customTriggers.ts
(fact snapshot + expression)

pipeline.ts

engine/scheduler.ts

engine/playlistRenderer.ts

engine/variableResolver.ts

engine/volume.ts · lexicon.ts

engine/adtPayload.ts

engine/history.ts

engine/proofSink.ts
(durable mirror)

engine/prerender.ts

engine/ledRender.ts · exteriorSign.ts

engine/matrix/*
globalState · publish · layoutRenderer

engine/destinationList.ts

engine/ff/ffEncoder.ts · ffRgbEncoder.ts

tts/index.ts (factory)

tts/resilient.ts

tts/provider.ts

tts/cache.ts

engine/fleet.ts

shared/payloads.ts

shared/led*.ts
expression · cycle · layout · destinations
shared with the browser

4.3 Module responsibilities

Module Responsibility Key exports
index.ts Composition root: wires MQTT → context → engine → scheduler → pipeline. Runtime hardening: ingest gating, cached enabled-trigger conditions, render backpressure (acquire/release counting semaphore, MAX_CONCURRENT_RENDERS), context eviction, render-scope gate, exterior-sign publishing, fleet directory, engine/health + engine/metrics. main()
config.ts loadEnv()EnvConfig; ConfigStore — remote-first (Postgres/API) with file fallback, hot-reload, version() bump on reload. loadEnv, ConfigStore
config/pgConfigSource.ts Reads the published engine_config row over pg and LISTENs for engine_config_changed on a dedicated connection — self-healing (capped 30 s backoff, refetch-on-reconnect). Enabled by DATABASE_URL. PgConfigSource
config/httpConfigSource.ts The vehicle variant (no DB reachability): GET {CONFIG_API_URL}/api/engine-config, refreshed when the portal's retained MQTT config notify (creatorstudio/{tenant}/{fleet}/config/updated) arrives. HttpConfigSource
db/pool.ts The shared pg connection pool (lazy, sslmode honoured) used by the config source, proof sink and portal API. getPool
mqtt/client.ts Broker connection; subscribe inbound + control; publish JSON or raw Buffers (FF frames); topic templating; never rejects on disconnect (daemon). MqttBus
pis/ptContext.ts Parses topics, aggregates retained PIS-PT per vehicle, derives normalized JourneyState + extras (location, journeyRef, ML name maps). VehicleContext, deriveState, parseTopic
pis/ptEngine.ts Edge-detects trigger events on signal change; evaluates threshold conditions and universal prerequisite gates. PtTriggerEngine
domain/announcement.ts Authoring model (mirror of the UI): playlists, elements, triggers, conditions, voices, volume, lexicon, priority. AnnouncementConfig, …
pipeline.ts Per-event orchestration: resolve trigger→playlist(s) → render → ADT → publish; priority resolution; per-element runs; metrics + proof records. renderAndPublish, resolveTriggerPriority
engine/scheduler.ts Per-vehicle priority scheduler: play / interrupt / queue / drop, priority-ordered queue with overflow. An interrupt aborts the superseded render (AbortSignal) so it never double-plays; forget() releases a vehicle's slot on eviction. AnnouncementScheduler
engine/fleet.ts Builds the compact per-vehicle fleet directory (route · destination · current stop · stops-left · phase, in-service first) from in-memory contexts; published retained on engine/fleet so the dashboard's picker needn't subscribe to the whole fleet. buildFleet
engine/playlistRenderer.ts Playlist → MP3 + transcript + timing; splits runs by voice/volume; caches + retries pre-recorded clips. renderPlaylist
engine/variableResolver.ts 20+ dynamic variables; language-aware name variants. resolveVariables
engine/adtPayload.ts Builds the ADT 4.x audio message (AsyncAPI spec): MP3/OPUS, integer speakers 0–100. buildAdtPayload
engine/history.ts Durable proof-of-play audit — append-only JSON-lines, bounded/auto-trimmed, async serialized writes (never blocks the event loop) with flush() on shutdown. HistoryLog, historyPath
engine/prerender.ts Predictive pre-rendering — warms the cache for the next N stops, deduped, throttled. Prerenderer
engine/led-font.ts · ledRender.ts Embedded 8-wide × 8-tall bitmap font (letters + composable diacritics) doubled to 16 rows; text → 1-bpp bitmap (center/scroll). renderLed, fontToJSON
engine/ledEncoder.ts · exteriorSign.ts Interior payload encoder; exterior front/side/rear composition (route + shrink-to-fit destination). encodeDisplay, buildExteriorPayload
engine/customTriggers.ts The custom (fact-based) trigger runtime: snapshots the live facts from a VehicleContext, evaluates the authored boolean expression + geofences, and keeps per-vehicle edge/cooldown state (becomes-true vs while-true). CustomTriggerEngine, snapshotFacts
engine/matrix/* The MatrixRenderer pipeline: globalState.ts (facts + geofences + destination fields the template expressions read), publish.ts (render every roster sign from its bound Display → JSON payload + FF/Hanover frames per the sign's protocol, signature-deduped), layoutRenderer.ts (elements → pixels, mono + colour), png.ts (image decode). buildGlobalState, buildLedSigns, renderLayout
engine/destinationList.ts Runtime half of the pre-programmed destination lists — turns the driver's code (pis/0/destination.number, or its override twin) into destination text for speech and signs, honouring the list's priority against the live feed. applyDestination, selectedDestination
engine/volume.ts · lexicon.ts Volume adaptation rules — combined conditions over time-of-day, weekdays, route, stop and geofence (all must hold; most specific match wins; legacy single-condition rules still honoured) — and the pronunciation lexicon (whole-word respelling, IPA <phoneme>, language scoping). resolveVolume, applyLexicon
engine/proofSink.ts Optional durable mirror of every proof record to Postgres (proof_of_play, batched multi-row INSERT via the shared pool; opt-in PROOF_DB_ENABLED=true), failure-isolated — the local JSON-lines log stays the primary. createProofSink
engine/proofReport.ts The certified coverage report served at /proof/report: summary + per-route breakdown, GTFS expected vs confirmed stops, integrity hash, CSV and self-contained HTML. buildProofReport, reportCsv, reportHtml
gtfs/feed.ts · expectedCoverage.ts Lazy cached reader over a GTFS .zip (routes / trips / stops for the simulator and lexicon import) and the trip-matching that turns a journey into expected stop coverage. GtfsFeed, expectedCoverage
shared/led*.ts The LED core shared verbatim with the browser (@shared/*): expression evaluation, cycle resolution, layout rendering, text rules/abbreviation, via + main-stops facts, destinations, symbols, route colours — so Creator preview, dashboard and engine rasterise identically. resolveActiveLayoutId, renderLayout, GlobalState
engine/ff/ffEncoder.ts Mobitec 1463-L FF frames via graphic font w (5-dot columns); checksum + addressing; hardware scroll (0xA5/0xD5); decodeFf for tests. encodeFf, decodeFf
engine/ff/ffRgbEncoder.ts The colour half of the same protocol (03090 §7.6): COLORTEXT, 16-entry RGB444 colour table, colour bitmap — RGB panels get real frames, not a mono approximation. encodeFfRgb
engine/hanover/hanoverEncoder.ts Hanover LED destination signs — same bitmaps, different vendor: HCPS frame (STX·cmd·addr·body·ETX + two ASCII-hex checksum chars) carrying a SuperX {\pic} vertical-raster graphic; Hilde text + infohub MQTT-bridge JSON too; decodeHanover for tests. Checksum + packing verified byte-exact against the vendor docs in Hanover/. encodeHanoverGraphic, encodeHanoverText, decodeHanover
tts/provider.ts · index.ts · resilient.ts Provider contract; factory (TTS_PROVIDER); resilience wrapper (retry/breaker/fallback, same cache keys). TtsProvider, createTtsProvider
tts/azure.ts · acapela.ts · elevenlabs.ts REST clients; Azure builds SSML (rate/pitch/volume prosody, <break>). AzureSpeechClient, …
tts/cache.ts Layered MP3 cache — bounded LRU → FS → Azure Blob Storage (optional, container SAS URL); single-flight getOrSynth (concurrent callers for the same key join one synth) and FS eviction (CACHE_MAX_FILES, default 20000 — oldest MP3s pruned by mtime); stats(). TtsCache
shared/payloads.ts Single source of truth for wire contracts, imported by engine and Angular via @shared/*. ADTAudioPayload, DisplayPayload, ExteriorPayload, MetricsPayload, HealthPayload

4.4 External services

Service Endpoint / protocol Used by Auth
MQTT broker MQTT 3.1.1 over mqtt:// / ws:// MqttBus user/pass (optional)
Azure AI Speech POST …/cognitiveservices/v1 (SSML) AzureSpeechClient Ocp-Apim-Subscription-Key
Azure Database for PostgreSQL (config) pg — SQL reads + LISTEN engine_config_changed (engine_config) PgConfigSource (back office; vehicles use the portal API via HttpConfigSource) DATABASE_URL (sslmode=require)
Azure Blob Storage (cache) raw HTTPS against a container SAS URL, objects tts-cache/<sha256>.mp3 TtsCache CACHE_BLOB_SAS_URL (SAS)
Acapela / ElevenLabs REST respective clients account key/token

Corporate TLS: all Node processes run with --use-system-ca so HTTPS trusts the OS certificate store (required behind TLS-inspecting proxies).


5. Client architecture (web/ · clients/)

Everything downstream of the broker: the operator-facing apps (§5.1) and the vehicle players that actually make a sound (§5.2). Both are ordinary MQTT clients — neither has a private API into the engine.

5.1 Dashboard & Creator (web/)

Angular 20 standalone-component app: signals for state, hash routing, lazy-loaded routes (every panel is its own chunk; the cockpit and its Leaflet map are the heavy ones), esbuild bundle. It imports the engine's payload contracts directly from src/shared/payloads.ts via the @shared/* alias — producer and consumer share one type source — and the LED core from src/shared/led*.ts.

Six menu entries, each a route: Monitor · Performance & Health · Proof of Play · History · Play on a device · Config. Documentation is not one of them — it lives in the Documentation Center, reached from the suite rail.

Area Module Purpose
MQTT store mqtt.service.ts (root) WebSocket connection; routes every engine topic into signals; selection + auto-play
UI state ui-state.service.ts (root) Panel state persisted across navigation and sessions (localStorage)
Monitor (cockpit) panels/monitor-cockpit.component.ts The merged operator view: heading-up map hero, fleet/vehicle feed rail, and a collapsible drawer holding the source/inject controls, the raw PIS-PT table and the live Displays (interior + exterior LED, matrix signs, TFT). Monitor / Live / Map / LED Signs were separate pages once; they are one screen now, and those routes redirect here
Proof of Play panels/proof.component.ts Groups (journey/route/destination/date/vehicle) · Table · Map · Replay; CSV/JSON export; mini LED previews
History panels/history.component.ts The announcement audit trail (every announcement across restarts) with filters and CSV export
Diagnostics panels/diagnostics.component.ts Performance and Health under one menu, switched by a segmented control (performance.component + health.component)
Map panels/map.component.ts (lazy) Live event map (dark theme): every trigger/announcement plotted where + when, numbered markers coloured by trigger, rich popups compressing the monitor's outputs (audio + interior/exterior sign text, surfaces, timing), a synced time-ordered timeline, current-position marker, route shape/stops, and a legend
Play on a device / Config panels/device-player.component.ts, panels/config.component.ts QR hand-off of a vehicle to a phone; the live triggers→playlists view of what the engine is executing
Sign rendering led-render.ts, led-layout-render.ts, led-mini/interior-sign/exterior-sign, led-exterior-live.component.ts Canvas dot-matrix and full matrix layouts using the same font and the same shared LED core as the engine (/led-font.json, @shared/led*)

Unified portal server (scripts/dashboard-server.ts) — a tiny no-framework server that serves both front-ends and the runtime data endpoints from one origin (no CORS/CDN), so the two apps appear as one product behind a landing-page menu:

Mount Served Build-time base
/ Redirects to the Engine monitor (/monitor/#/monitor) — the default screen
/portal Landing page (public/portal.html) — the menu
/monitor/ Angular engine dashboard (web/dist/web) baseHref /monitor/ (prod config)
/creator/ Angular CreatorStudio authoring app (web/dist/creator) baseHref /creator/ (creator project)
/docs Documentation Center (public/docs.html) — the one entry point for every document
/documentation.html · /documentation.md · /docs/src/* · /docs/media/* The consolidated handbook (assembled live from the repo's Markdown on each request), its raw sources and screenshots
/stakeholder, /presentation, /architecture-overview, /leaflet, /one-pager, /software-team The decks and leave-behinds under docs/showcase/
/player (+ /player/v/<tenant>/<vehicle>) The zero-install browser vehicle player (public/player.html) + its bridge (/player/stream, /player/ack, /player/qr.svg, /player/hosts, /player/live)
/config, /history, /proof/report, /led-font.json, /architecture.md, /tts-*, /gtfs/* Shared runtime/data endpoints (§7.2) root

Both apps are Angular projects in the web/ workspace, mounted purely at build time via each project's baseHref — so the server just maps a URL prefix to a dist dir with SPA fallback, no per-framework logic. Their API fetches are root-absolute and the dashboard's broker WebSocket targets …:9001, so both keep working under their prefixes; the Creator talks only to the portal's same-origin /api (projects, config publish, GTFS expand). Old /#/…, /ng/…, and the migration's temporary /creator-ng/… links redirect to their new homes. In dev, ng serve (npm run stack:dev) runs on :4200 and proxies data endpoints to :8080. The retired React CreatorStudio (audiocreator/) is still runnable standalone (npm run cs:dev on :8090, Lovable deploy) but is no longer served by the portal.

Suite navigation — both apps render a persistent 64px left rail (app switcher: brand → portal, Creator /creator/, Engine /monitor/, Player /player, and the Documentation Center /docs set apart at the foot as reference rather than an app; the current app is highlighted). The Angular Creator reuses the dashboard's web/src/app/suite-rail.component.ts directly. Both share the Figma/PrimeNG design system (§2), so the suite reads as one product. Documentation deliberately lives outside either app's own menu: one page publishes the handbook, the decks and the sources, and both apps link to it.

5.2 Vehicle players — the client side (clients/, /player)

The engine renders audio and publishes it; something on the vehicle has to play it. Three interchangeable clients do that, all speaking the same contract, so a fleet can mix them.

Client Source Runs on Use it for
Go clients/go x86-64 · ARM64, headless The onboard unit. One static binary, no runtime to install.
Python clients/python x86-64 · ARM64, headless A Pi, or a box that already has Python; easiest to adapt in the field.
Browser public/player.html/player Any phone, tablet, laptop Demos, spot-checks, a driver's phone. No install at all.

The contract — identical in all three:

subscribe   {tenant}/{vehicleId}/pis/0/tts   ADT 4.x — audio[0].content is base64 MP3 (or WAV)
publish     engine/played                    {traceId, tenant, vehicleId, playedAt, status}

Four behaviours matter and are deliberate:

How audio reaches the loudspeaker (headless clients):

player → ffmpeg (decode MP3/WAV, apply speakers.INTERNAL volume) → aplay → ALSA
       → /dev/snd/pcmC0D0p → sound card → amplifier → loudspeaker

ffplay is deliberately not used, although it looks like the obvious choice: it needs SDL and is absent from Alpine's ffmpeg package, so a client that reaches for it silently falls back to a backend that cannot decode MP3 at all.

Deployment. Both headless clients ship as multi-arch Docker images (linux/amd64 + linux/arm64; the Go image cross-compiles rather than emulating, and its dependencies are vendored so the build is hermetic). A container has no sound card, so it needs --device /dev/snd — and the entrypoint joins the host's audio group by numeric gid, because it is 18 on Alpine but 29 on Debian/Raspberry Pi OS, and the kernel checks the number, not the name. Details: clients/README.md.

Not handled. The exterior loudspeaker. The payload carries speakers.EXTERNAL, but driving it on a real vehicle also means raising a digital output to enable that amplifier while the clip plays — unit-specific wiring (§7.1, on-vehicle integration), not implemented in these clients.


6. Software design

6.1 Core patterns

6.2 Configuration flow

CreatorStudio authors into a projects row (the source of truth for what the Creator lists); Publish to engine maps it (a no-op — the app already exports the engine's AnnouncementConfig shape) and upserts it to Postgres engine_config via the portal's POST /api/publish — one transaction with a monotonic config_versions row. A DB trigger fires pg_notify('engine_config_changed'); the engine's PgConfigSource reads that row and LISTENs, hot-swapping the fleet config in ~1 s (vehicles get the same effect from HttpConfigSource + the retained MQTT notify). The on-disk CONFIG_PATH is the offline fallback and is itself hot-reloaded.

The direction matters operationally: projects is the input, engine_config the output, and the file the fallback. Reads are remote-first, so a config written to the file is invisible while the live source is enabled, and a config written to engine_config does not change what the Creator lists — it is overwritten by the next publish. To change what an author sees, write the project.

An example of using that direction deliberately: the worked-example playlist library (config/announcement-config.sample.json) can be merged into any of the three targets without its triggersscripts/lib/example-playlists.mjs appends the playlists and asserts triggers / customTriggers are byte-identical before writing. Since a Playlist carries no trigger field and the engine only ever walks trigger → playlist (§6.3), an unreferenced playlist is inert: authors get a library to copy from and the fleet says exactly what it said before.

6.3 Trigger → announcement decisioning

ptEngine fires an event only when (a) a state transition occurred, (b) the debounce elapsed, (c) the trigger's threshold condition matches (distance/time/geofence/speed/occupancy), and (d) its universal prerequisite gates hold (door/stop-button/velocity). pipeline then resolves interior/exterior/legacy playlists + sequences + repetitions, renders per-element (splitting TTS runs by voice/volume), applies volume adaptation, and routes to speakers via the ADT payload.

6.4 LED / sign rendering (for designers)


7. Interfaces & APIs

7.1 MQTT topic contract (primary integration surface)

Topic Dir Payload Producer → Consumer
{tenant}/{vehicleId}/pis/0/<sub> in PIS-PT JSON (see below) bridge/simulator → engine, dashboard
{tenant}/{vehicleId}/pis/0/tts out ADT 4.x audio (base64 MP3 + integer speakers + transcript) engine → vehicle players (§5.2), dashboard
engine/played in {traceId, tenant, vehicleId, dispatchedAt, playedAt, status} — sent when a clip finishes vehicle player → engine (proof of play)
…/pis/0/display · …/display/exterior out 1-bpp bitmap JSON (interior · front/side/rear) engine → sign controller, dashboard
…/pis/0/display/ff · …/display/exterior/ff out raw Mobitec FF frames (font w; addr 10 · 1/2/3). Each frame also mirrors on its address subtopic — …/ff/{address} — so a controller subscribes to its own bus address engine → RS-485 gateway / sign controller
…/pis/0/display/hanover · …/display/exterior/hanover out raw Hanover HCPS/SuperX frames — the same bitmaps for Hanover signs, in parallel with FF (DISPLAY_HANOVER_ENABLED, on by default). Same per-address fan-out: …/hanover/{address} engine → RS-485 gateway / sign controller
…/pis/0/display/signs · …/display/signs/ff · …/display/signs/hanover out matrix template signs — one frame per rostered vehicle face (LedSignsPayload JSON; FF mono/RGB or Hanover HCPS per the sign's protocol), signature-deduped. Gated by DISPLAY_TEMPLATE_MODE. The raw-frame topics also fan out per bus address — …/signs/ff/{address} · …/signs/hanover/{address} carry only that address's frames, so a controller subscribes to its own address instead of filtering the aggregate engine → sign controller, dashboard
…/pis/0/list/destinations out The active pre-programmed destination list (retained, {number, name, lineNumber} rows) so a driver console can offer the codes. Off unless DESTINATION_LIST_PUBLISH=true — a real PIS system may own this topic engine → driver console
engine/metrics out per-announcement timing + lat/lon engine → dashboard
engine/health out throughput/health (vehicles, served, renderScope, msg/s, events/s, loop lag, RSS, cache hit-rate, in-flight, queued, dropped, interrupted, synthCoalesced) engine → dashboard / ops
engine/fleet out fleet directory (retained): per-vehicle route · destination · current stop · stops-left · phase engine → dashboard (vehicle picker)
engine/bridge out bridge health (retained): up/local, mode, tenant, bridged vehicles, throughput, dedup rate bridge → dashboard
engine/control/select ctl {"vehicleKeys":[…]} (retained) — the served set (render scope) dashboard → engine
engine/control/tenant ctl {"tenant":"…"} (retained) — narrows the bridge's discovery to one tenant dashboard → bridge

Inbound PIS-PT sub-topics consumed: journey, destination, destination/override, list/stops, linkprogress, stopinfo, journeystate, sensors/door, sensors/stop_button, vehicle/gnss_location, vehicle/exit_sides, connections, passenger_load, alarm_activation, shape.

7.2 HTTP API (dashboard-server)

Route Serves
GET / + assets portal landing, then the two SPAs under /monitor/ and /creator/; unknown paths inside a mount → its index.html (hash routing)
GET /led-font.json shared LED font (browser rasterizes identically to the engine)
GET /architecture.md this document (raw)
GET /documentation.md · /documentation.html the consolidated handbook, assembled live from the source Markdown on every request (scripts/lib/docs.ts); /docs is its browsable home and /docs/src/* serves the whitelisted sources
GET/POST /config live config (remote-first, file fallback); POST imports a CreatorStudio export
GET /history proof-of-play. Filters: tenant, vehicle, type (audio|exterior), route, destination, journey, trig, q, since, until, limit. format=csv for CSV. groupBy=journey|route|destination|date|hour|vehicle for server-side analytics aggregation.
GET /proof/report the certified coverage report over that trail — scope by tenant/route/vehicle/from/to, expected=1 to cross-reference the GTFS schedule (what was never attempted), format=json|csv|html (the HTML is self-contained and carries an integrity hash)
GET /tts-providers · /tts-voices?provider= · POST /tts-preview supplier catalogue (which are configured, their default voice), a supplier's voice list, and a one-line audition through the engine's real client
POST /gtfs/import · GET /gtfs/{status,values,routes,trips,trip} the GTFS feed behind the simulator, lexicon import and expected coverage (upload capped; a missing feed answers {feed:null} rather than failing)
GET/PUT/DELETE /api/projects · GET /api/engine-config · GET /api/config-versions · POST /api/publish · POST /api/gtfs/expand the Creator API (scripts/lib/creator-api.ts): projects CRUD, the published config row, version history, the transactional publish (upsert + config_versions + MQTT notify) and AI abbreviation expansion
GET /player (+ /player/v/<tenant>/<vehicle>, /player/stream, /player/hosts, /player/qr.svg, /player/live, POST /player/ack) the zero-install browser vehicle player and its server bridge (SSE audio stream, LAN hosts, QR hand-off, played-ack relay, and the recently-announcing vehicles the setup screen prefills from). QR hand-offs use the /player/v/… path form — query strings have been seen stripped between scan and phone

Auth. Every mutating or cost-bearing route (POST /config, /tts-preview, /gtfs/import, /player/ack, and the Creator API writes PUT/DELETE /api/projects, POST /api/publish, POST /api/gtfs/expand) is gated by authorized(): with DASHBOARD_TOKEN set it requires Authorization: Bearer … compared in constant time; unset (the lab default) leaves the portal open. Read routes are unauthenticated by design — the dashboard is an ops tool on a trusted network.

7.3 Config source (engine_config in Postgres)

One row per (tenant, fleet): tenant, fleet, config (jsonb — the AnnouncementConfig), schema_version, published_project_id (which project produced it; FK → projects, engine ignores it), updated_at. Keyed by a composite (tenant, fleet) primary key. Writes go through the portal's POST /api/publish (scripts/lib/creator-api.ts — one transaction: the engine_config upsert plus a monotonic config_versions row); a DB trigger fires pg_notify('engine_config_changed') on every insert/update. The engine reads with pg and LISTENs on that channel (PgConfigSource) — and when CONFIG_DB_TENANT_VALUE is set, filters to its own (tenant, fleet) row (empty = single-tenant). Vehicles, which cannot reach the database, use HttpConfigSource instead: GET {CONFIG_API_URL}/api/engine-config, re-fetched when the portal's retained MQTT notify (creatorstudio/{tenant}/{fleet}/config/updated) arrives. The whole schema lives in db/migrations/ (baseline 0001_baseline.sql, applied by npm run db:migrate), so the DB is reproducible from empty on any stock Postgres with no external dependency.

7.3a Projects & multitenancy (authoring side)

Authoring is organised into Projects (public.projects: id, tenant, fleet, name, description, config jsonb, timestamps) — named self-contained configs, one published per (tenant, fleet). Writes go through the portal's PUT / DELETE /api/projects (bearer-gated when DASHBOARD_TOKEN is set); reads are GET /api/projects?tenant=&fleet=. Every query is parameterized and tenant-scoped in the portal API — tenant is an explicit parameter today; deriving it from an authenticated session (Entra ID sign-in, verified published_by) is the documented follow-up (§12). The Creator's TenantService resolves the active tenant (?tenant=window.__AUDIO_SUITE_TENANT__ → last used → default) and scopes every project read/write, so tenants are fully separated. Engine payload/MQTT/trigger contracts are unchanged by any of this.

7.3b AI authoring assistant (local-first)

The Creator's /assistant screen turns a plain-language description ("announce the next stop inside the bus, route + destination outside") into a plan of playlists and triggers. It asks two providers in order, so the feature degrades instead of disappearing:

Order Provider Where Needs
1 Portal POST /ai/assistant scripts/lib/assistant-ai.ts, same origin as the Creator Any OpenAI-compatible chat-completions endpoint — a local Ollama / LM Studio / llama.cpp / vLLM, or a hosted one. ASSISTANT_AI_URL/_MODEL/_KEY; unset = local Ollama
2 Offline draft the browser, draftPlanLocally nothing — keyword rules over the same trigger catalogue, labelled Offline draft

So an on-prem install needs no cloud backend and no AI gateway: the portal already runs on the operator's machine and already holds the credentials, the Creator is served from the same origin (no CORS), and no announcement text leaves the site. The turn records which provider answered and the screen shows it, because a 7B local model and a hosted one write visibly different announcements.

The server half takes the same request and returns the plan at the top level. The client sends the catalogue of what may be built — trigger types, engine {variables}, the custom-trigger fact list, the project's existing playlists and geofences — because the Angular domain model is the single source of truth for it. The prompt itself lives once, in src/shared/assistantPrompt.ts — the portal imports it directly, so there is no carried copy to drift. Replies are parsed leniently — local models routinely wrap JSON in ``` fences or chat around it.

The model's answer is never trusted as config. web/creator/app/lib/assistant.ts rebuilds every field against the real model — unknown trigger type, a type the engine can never fire, a fact the PIS feed doesn't carry, an unresolvable {variable} (which would make the engine drop the whole announcement), an audio URL outside the preset list, an empty rule that would fire on every update — each is dropped and reported rather than applied. Nothing reaches the config until the author ticks it on the review card, and the whole plan lands as one undo entry (ConfigService.applyAssistantPlan). When the gateway is unreachable the same screen answers from a local keyword draft, marked Offline draft, through the identical validate → review → apply path.

7.4 Payload contracts

Defined once in src/shared/payloads.ts: ADTAudioPayload, ADTAudioItem, DisplayPayload, ExteriorPayload/ExteriorFace, MetricsPayload, HealthPayload. The ADT audio message conforms to the transHub ADT 4.x AsyncAPI spec.


8. Runtime sequence — signal to loudspeaker

Vehicle player (§5.2)HistoryCacheTTSrenderPlaylistpipelineSchedulerPtTriggerEngineVehicleContextindex.tsBrokerVehicle player (§5.2)HistoryCacheTTSrenderPlaylistpipelineSchedulerPtTriggerEngineVehicleContextindex.tsBrokeralt[cache miss]…/pis/0/linkprogressupdate(sub, payload)ingest(ctx) (state-changing topics only)[approaching-stop] (condition + prerequisite gates pass)offer(event, priority)play (or interrupt/queue/drop)renderPlaylist (per-element voice/volume)get(cacheKey)synthesize (SSML, HTTPS)MP3put(cacheKey)audio + transcript + timingpublish …/tts (ADT) · …/display · …/display/ffpublish engine/metricsappend proof record (dispatched)…/pis/0/tts (ADT audio)decode → ALSA → loudspeaker (queued, never overlapping)engine/played (AFTER the clip finishes)played-ack → the record becomes proof of PLAY, not just dispatch

The last three steps are the client side (§5.2), and they are what make the record admissible: up to publish …/tts the engine only knows what it sent.


9. Deployment & processes

Each vehicle — onboard unit (x86 or ARM)

Back office — host / container

engine — node dist/src/index.js

dashboard-server :8080

player (Go or Python)
docker · --device /dev/snd

MQTT broker :1883 tcp · :9001 ws

Browser — dashboard · /player

Azure PostgreSQL
+ Azure Blob


10. Performance

Latency budget (per announcement) — trigger fire → resolve → render (TTS) → publish. Measured on a live local run (Azure Speech, real journey):

Path Latency Notes
Cache hit (phrase seen before) ~2 ms total, ttsMs=0 Dominant case fleet-wide
Cache miss (first synth of a unique phrase) ~1.0 s (ttsMs≈1015) One-time per unique phrase, then cached
MP3 size ~16–30 KB per announcement base64 in the ADT payload

Why it stays fast at scale


11. Scalability


12. Security & operational concerns


13. Testing & quality


14. Gaps & known limitations

Area Status
Config coverage Everything CreatorStudio authors for live MQTT triggers is honored (voices, per-element voice/volume/pitch, all conditions, prerequisite gates, priority, volume adaptation, lexicon, dynamic variables).
bus-type trigger Declared but not fired — PIS-PT carries no vehicle formation/type signal. Needs an upstream source. The Creator marks it unavailable (no condition editor, no fresh enable, an Issues error if a legacy config enables it), so nobody can author against it unknowingly.
requiresDirection gate Non-blocking — no forward/reverse signal in the feed.
stationary-display / volume-calibration Intentionally not engine-driven (local kiosk/technician). Their arrivals variables ({arrivingBuses} …) were removed from the Creator's picker — with no real-time arrivals source they resolved empty and dropped the announcement; a variable-parity test now keeps picker and resolver in lockstep.
{serviceExpiry} variable Empty — no expiry field in the line-status feed.
dialect / parentPlaylistId Not used — voiceId encodes locale; multilingual handled via in-playlist name variants.
FF hardware scroll Implemented for the interior sign (0xA5/0xD5, four settings bytes per 03090 R5 §7.3.3 — R7's two-byte Example 3 is an error R5 corrected). Not yet verified on hardware: §5.4.1.1 says 0xD2 is ignored under horizontal scroll, so the per-band x-reset a multi-band graphic-font-w bitmap relies on needs a bench test. Exterior signs still send static frames — their destination scrolls inside a sub-region, which needs a second text field in the frame.
MP3 concatenation Binary byte-wise join (plays fine everywhere); ffmpeg needed for sample-accurate gapless.
Per-element volume on plain-text suppliers Every supplier declares a capability descriptor ({ ssml, prosodyVolume, phoneme, offline }, tts/provider.ts). Volume is honoured where prosodyVolume is declared (Azure/Google/Polly — SSML prosody or an API gain); on the ~10 plain-text suppliers the renderer drops it predictably — runs stay merged, a log line names the drop, the playlist editor warns while authoring and the Voices screen shows per-supplier feature badges. (Voice pitch is fixed at natural on every provider — see §3.)
Audio ducking on interrupt Interrupt hard-stops the current clip (no cross-fade) — the engine doesn't own the speaker.
Exterior loudspeaker (players) The players (§5.2) drive speakers.INTERNAL only. The exterior speaker also needs a digital output raised to enable its amplifier while the clip plays — unit-specific wiring, not implemented.
Player transport security The broker's WebSocket listener is plain ws:// with allow_anonymous. Fine on a lab LAN; before a player runs on a real vehicle it needs wss:// + credentials, or any device on the network can subscribe to a vehicle's audio — or publish fake played-acks into the ADA audit trail.
Multi-instance state Per-vehicle state is in-memory; horizontal scale needs sharding or Redis (see §11).

15. Roadmap / TODO

Near-term

  1. Rotate the Azure Speech key (security — outstanding).
  2. FF hardware scroll on a bench sign — confirm multi-band x behaviour under horizontal scroll, then extend it to the exterior destination sub-region.
  3. Pre-recorded audio provenance — optional pixel/audio snapshots in proof-of-play for legal-grade evidence.

Mid-term 4. Two-way / upstream drive — publish engine output to real vehicle units (needs a dry-run mode + ops sign-off). 5. bus-type + stationary arrivals — implement once the fleet feed exposes formation/type and real-time arrivals. 6. Per-tenant retention — separate/rotating proof-of-play logs per tenant for audit isolation.

Resilience — graceful degradation on the vehicle (TTS market-research Finding 2 / Scenario E "hybrid")

The engine central-renders-and-streams: the content-addressed cache lives in the back office. Two layers now sit on the vehicle: the on-vehicle clip cache (§5.2 — every payload carries a content-hash clipKey, every received clip is kept on the device, a key-only payload replays cached bytes, expiryDateTime is enforced) and the closed-corpus pre-push (PREPUSH_ENABLED=true, src/engine/prepush.ts): the moment a journey's stop list is known, the engine renders the journey's whole announcement corpus — every remaining stop for stop-varying triggers, once for journey-constant ones, volatile free-text excluded — and delivers the clips to {tts topic}/cache, with the pushed clipKey byte-identical to what the live trigger later publishes. A pre-pushed journey therefore needs only tiny key-only messages at announcement time; the corpus is ~95 % closed (stop names, safety, service phrases).

What the pre-push is NOT: an offline guarantee. In a central-render deployment the trigger decision is made in the back office from PIS-PT that crosses the WAN — when the link is fully down the engine never learns the bus reached the stop, so no message (full or key-only) is sent and the cached clips sit unused. The pre-push buys zero announcement latency and survival of a degraded link (a few hundred bytes gets through where hundreds of kilobytes time out); it does not survive a dead one. The shipped fully-offline path is the on-vehicle engine deployment (ON-VEHICLE.md, docker-compose.vehicle.yml): PIS-PT originates on the bus's own network, so triggers never cross the WAN, and the engine's persistent cache covers the audio. Keeping a thin player fully offline instead would need local trigger evaluation in the players (a pre-pushed stop→clipKey manifest + the player watching local stop progress, with careful engine-vs-player arbitration) — that belongs with the gated item below, not with this pre-push.

  1. Embedded fallback engine in the player — last-resort local synth (eSpeak NG or Piper) for free-text when offline, mirroring the server-side provider clients. Completes Scenario E. Note F4: Piper is now GPLv3 (legal review before shipping in the appliance); eSpeak is the intelligibility floor but robotic — degraded fallback only, never primary. Local trigger evaluation (playing the pre-pushed corpus with no link at all) belongs with this item.

Gating for item 7: on-vehicle deployment first needs the pre-production security blockers closed — wss:// + broker credentials/ACLs (today's ws:// + allow_anonymous lets any device subscribe to a vehicle's audio or publish fake played-acks into the ADA audit trail, §14) — which are Luminator Suite-integration scope.

Scale 8. Horizontal sharding / Redis-backed state for the 2000-bus target (SCALING.md). 9. ffmpeg gapless concatenation for sample-accurate multi-segment audio.


16. Glossary

Term Meaning
PIS-PT Passenger Information System — Public Transport. The inbound MQTT trip-data protocol (multi-topic, retained).
ADT 4.x The transHub audio message spec published on …/tts (base64 audio + speaker volumes).
Tenant An operator/agency namespace (e.g. baltimore-md-mta); the first topic segment.
Journey One scheduled run of a vehicle; identified by vehicleJourneyRef (used to group proof-of-play).
Trigger A rule that fires an announcement/sign on a PIS-PT condition (33 types).
Project A named, self-contained AnnouncementConfig; exactly one is published per (tenant, fleet).
Tenant Isolation boundary; projects and engine_config are scoped by (tenant, fleet).
Playlist An ordered list of elements (text, dynamic text, pause, audio) rendered to one announcement.
FF / Mobitec 1463-L The LED sign driver-board serial protocol; font w is its graphic (bitmap) font.
Hanover HCPS / SuperX Hanover Displays' sign protocol: HCPS frames (STX…ETX + two ASCII-hex checksum chars) carrying SuperX messages; {\pic} embeds a bitmap as a vertical raster. Specs in Hanover/.
Proof of Play The durable audit record of what was played/shown, where and when.
Selection The set of vehicles the engine currently renders for (dashboard-controlled).

Scaling plan — multi-vehicle (target: 2000 vehicles)

Development plan for scaling the CreatorStudio Engine from a single-process, few-hundred-vehicle setup to a fleet of ~2000 vehicles.

TL;DR: after Phase 0 hardening, a single hardened process now handles ~2000 vehicles comfortably on modest hardware (measured — see the ladder below). The remaining walls are TTS throughput/cost and, beyond ~3000 on 2 cores, the single-process CPU ceiling. In-memory caches and the inbound firehose have been bounded/optimised, so sharding is now an availability/headroom play — not a functional necessity for 2000. Full fleet scale is achievable with a real TTS tier (Phase 1) and, for headroom/availability beyond ~3000, sharding + a clustered broker (Phases 2–3).

Multi-tenant note. Across a larger (~5k-vehicle) multi-tenant broker, the realised scaling model is one engine process per tenant: the bridge pre-scopes to a tenant (UPSTREAM_TENANT) and the engine reads only its (tenant, fleet) config (CONFIG_DB_TENANT_VALUE). Each tenant's ~hundreds–2000 vehicles then fit a single hardened process per the ladder below, and tenants scale horizontally by process rather than by sharding one giant process.


1. Where it stands today

Single Node process:

Per-subsystem assessment at 2000 vehicles

Subsystem Load at 2000 vehicles Verdict
Inbound parse GNSS @1 Hz ≈ 2000 msg/s + other topics → ~3–6k msg/s of JSON.parse + context updates on one event loop Feasible on a good core (measured OK to ~3000); GNSS gated so it doesn't drive detection
Trigger eval Cached enabled-trigger condition map (rebuilt only on config change), getCondition() O(1), threshold math only when enabled, GNSS gated ✅ Fixed (Phase 0)
TTS ~15–30 announcements/s peak; Azure F0 caps ~20 req/s → 429s. Single-flight dedup + warm cache cut the peak synth rate materially Remaining wall — needs paid tier or self-hosted for true peak
Memory Mem LRU (TTS_MEM_CACHE_MAX) + on-disk cap (CACHE_MAX_FILES) + context idle-TTL sweep ✅ Fixed (bounded)
Concurrency Counting render semaphore (MAX_CONCURRENT_RENDERS) + per-vehicle priority scheduler + single-flight synth dedup ✅ Fixed (backpressure + scheduler)
Publish ~15–30 ADT payloads/s × ~30 KB ≈ ~1 MB/s Fine
Broker 2000 publishers + engine + dashboards Mosquitto OK-ish; EMQX better for 2000+
Single process One core, single point of failure Measured OK to ~3000 on 2 cores; still a SPOF — sharding is availability/headroom, not a functional need for 2000

2. Known issues to fix before scale (tech debt) — ✅ done in Phase 0


3. Phased plan

Phase 0 — Measure & harden a single instance — ✅ DONE & VERIFIED

Verified (isolated broker, 150 vehicles): health metrics flowing; GNSS gating (msg/s ≫ events/s); event-loop lag ~30 ms under load; RSS stable ~110 MB; backpressure sheds bursts; stale vehicles evicted after TTL. Offline LRU unit test also passes.

Measured ceiling (2026-07-06, load ladder 50 → 4000)

Run on a small 2-vCPU / 4 GB Linux box (aedes broker + engine + load-gen on the same host), TTS_PROVIDER=mock with 30 ms simulated synth latency so the ladder measures the engine, not the TTS vendor. GNSS @1 Hz per vehicle, stop advance every 12 s, ~30 s steady state per rung. Steady-state event-loop lag (p50) per fleet size:

Vehicles msg/s lag p50 RSS dropped
500 500 20 ms 181 MB 0
1000 1000 21 ms 186 MB 0
2000 2000 29 ms 250 MB 0
3000 3000 86 ms 289 MB 0
4000 4000 ~870 ms 324 MB 0

Verdict: a single hardened instance handles 2000 vehicles comfortably even on a 2-core box — cache hit-rate 100%, zero drops/queue overflow. The wall is ~3000–3500 on this hardware (lag grows superlinearly; 4000 saturates the loop). Real server hardware moves the wall up, but 2000 no longer requires Phase 2 sharding — sharding becomes an availability/headroom play, not a functional necessity.

Findings from the ladder:

Repeat with: TTS_PROVIDER=mock npm run dev + npm run load -- 2000 (mock provider: src/tts/mock.ts, MOCK_TTS_LATENCY_MS simulates per-miss synth cost). Also tune MAX_CONCURRENT_RENDERS (renders are mostly fast cache hits, so it can be well above 8).

Phase 1 — TTS scaling (the cost/throughput blocker)

Exit criteria: sustained render rate at peak with acceptable latency and no 429s; cache hit-rate > ~90% in steady state.

Phase 2 — Horizontal sharding (the actual 2000+ answer)

The engine keeps per-vehicle state in memory, so a vehicle's messages must stick to one worker. Two options:

Recommendation: (a) now (2–4 shards cover 2000 comfortably); (b) when elasticity beyond that is needed.

Note: plain MQTT + wildcards match a whole topic level, not a prefix — you can't subscribe mta-maryland/1*/.... So sharding needs either a shard topic level from the bridge, a router that re-publishes to shard topics, or the shared-subscription + external-state model.

Phase 3 — Operations


4. Capacity estimate for 2000 vehicles

Verdict: achievable with Phase 0 hardening + a proper TTS tier + Phase 2a sharding


5. Suggested order of work

  1. Phase 0 hardening (LRU caches, context eviction, ingest optimizations, backpressure) — safe, immediate wins on the current single instance.
  2. Load generator + metrics — measure the real ceiling.
  3. Phase 1 TTS (tier/self-host + shared cache) — remove the cost/throughput wall.
  4. Phase 2a sharding — cross the 2000 line.
  5. Phase 3 ops hardening — make it production-grade.

Running the engine on a vehicle (Docker)

The CreatorStudio Engine normally runs in the back office serving a whole fleet. It can also run on the vehicle itself — the complete audio stack on one onboard unit (x86-64 or ARM64):

   local MQTT broker  ·  the engine (scoped to this vehicle)  ·  the audio player

The vehicle's PIS-PT system publishes {tenant}/{vehicleId}/pis/0/# to the local broker; the engine turns it into speech + LED-sign content and publishes it back; the player makes the sound. Because the local broker carries only the one local vehicle, the engine runs RENDER_SCOPE=all and serves that bus with no dashboard and no selection — a self-contained unit.

Choosing between this and the central engine for a poorly-connected fleet? See docs/limited-connectivity.md — the deployment patterns, what each one still needs the WAN for, and the bandwidth budget.

It's the same image as the back-office engine; only the environment differs. Everything below is wired up in docker-compose.vehicle.yml and the multi-arch Dockerfile.


1. Build the image

Onboard units are usually ARM64, so build multi-arch and push to a registry the vehicle can pull from (the base image is multi-arch and the engine's deps are pure JS — one build serves both):

docker buildx build --platform linux/amd64,linux/arm64 \
  -t <your-registry>/creatorstudio-engine:latest --push .
#   npm run engine:image:multiarch   does the same

To test on the machine you're on, a single-arch local image:

npm run engine:image        # docker build -t creatorstudio-engine:latest .

2. Put this fleet's config on the unit

Export the config from the Creator (Export/Import) and drop it at ./config/announcement-config.json. The compose mounts ./config into the engine — this file is the offline source of truth. (Or use live updates from the Creator instead — see Config below.)

3. Run it

On the onboard Linux unit:

VEHICLE=14004 TENANT=baltimore-md-mta \
  AZURE_SPEECH_KEY=… AZURE_SPEECH_REGION=… \
  docker compose -f docker-compose.vehicle.yml up -d --build

Convenience scripts:

npm run vehicle:up      # start (build + up -d)
npm run vehicle:logs    # follow the engine's logs
npm run vehicle:down    # stop

The player needs the sound card. That's why the compose maps --device /dev/snd. If the unit drives audio another way — or you play on a phone in the cab via the browser /player page against the broker's :9001 — drop the player service.


Configuration

Variable Purpose
VEHICLE Required — the vehicle id (e.g. 14004).
TENANT Operator namespace (e.g. baltimore-md-mta).
AZURE_SPEECH_KEY / AZURE_SPEECH_REGION / AZURE_SPEECH_VOICE Text-to-speech. Leave the key empty to run silent (TTS_PROVIDER=mock) on the bench.
CONFIG_API_URL Default unset — file-first. Point it at the portal (+ optional CONFIG_API_TOKEN) for live over-the-air config.
ENGINE_MEM / NODE_OPTIONS Memory cap for a constrained device — defaults 512m / heap 384 MB (steady state ~90 MB).

File vs live config

Offline audio

The TTS MP3 cache is a persistent volume (engine-cache), so once a phrase has been synthesized it plays with no network. For an often-offline bus, pre-warm the cache on the bench — run a journey once while online and the volume carries the audio onto the road. A brand-new phrase still needs Azure; when it can't be reached, the engine falls back to a pre-recorded clip and the LED text still goes out. The proof-of-play audit log persists in the engine-data volume.

The players cache too: every clip a player receives is kept on the unit under the payload's clipKey (content hash), so the engine can re-announce with a key-only message — a few hundred bytes instead of the audio — and expired clips are dropped (acked expired), never played late. With PREPUSH_ENABLED=true on the engine, the closed-corpus pre-push fills that cache the moment a journey's stop list is known (every remaining stop's announcements, delivered on …/pis/0/tts/cache), so a whole journey's audio is on the bus before it is needed. Note the pre-push is a degraded-link aid, not an offline guarantee — with a central engine the trigger decision still crosses the WAN, so a dead link means no announcement. This deployment — the engine running on the vehicle — is the fully-offline architecture: triggers never leave the bus. Mount the players' CLIP_CACHE_DIR on persistent storage so the cache survives a restart; see clients/README.md.

Offline announcement bundles

Instead of pre-warming on the bench by driving a journey, export the whole corpus ahead of deployment: the Creator's Export → Export offline bundle (or POST /export/bundle on the portal, token-gated) enumerates every announcement the published config can need over the GTFS stops of the chosen routes, renders them through the engine's exact parity path, and produces one zip:

In the bundle Seeds How
clips/{clipKey}.mp3 the players' CLIP_CACHE_DIR npm run import:bundle -- --bundle b.zip --player-cache <dir> (renames to the players' {clipKey}.bin)
tts-cache/{sha256}.mp3 the engine's CACHE_DIR (the engine-cache volume) --engine-cache <dir> (name-preserving — real cache hits from the first announcement)
config/announcement-config.json the engine's CONFIG_PATH file --config-out <file>
manifest.json the audit trail transcripts, clip↔trigger mapping, and requiresLiveData

Add --verify to hash-check every clip before trusting it. The export costs TTS only on a cold cache — re-exports ride the shared cache for pennies.

What still needs the cloud: playlists naming live-moment data — {connectingServices}, {serviceAlerts}, {currentTime}, occupancy, countdowns… — cannot be pre-rendered; the export lists them per trigger in the manifest's requiresLiveData (and in the Creator's estimate step) so there are no surprises on the road. One caveat: per-stop clips are keyed on the exact stop name text, so export from a GTFS feed whose names match what the live PIS feed announces — a differing spelling only costs a re-synth on the engine-cache layer, but a thin player's key-only cache would miss that clip.

Networking

The engine makes only outbound MQTT connections and opens no inbound ports, so it sits behind the vehicle's NAT unchanged and reconnects on its own when the link drops. The broker exposes 1883 (the vehicle's units connect here) and 9001 (WebSocket, for the browser /player on a phone).


Verify it's working

With trip data flowing to the local broker:

docker compose -f docker-compose.vehicle.yml logs -f engine

You should see mqtt connected, config loaded, and — once the vehicle moves — announcement delivered lines, plus periodic health beats showing renderScope:"all", served:1, vehicles:1. A healthy engine sits at ~90 MB RSS.

No data yet? Confirm the vehicle's PIS-PT feed is reaching the broker (docker compose -f docker-compose.vehicle.yml exec broker mosquitto_sub -t '#' -v), and that VEHICLE/TENANT match the topic the vehicle publishes on.


Controlling Hanover LED signs

The platform drives Hanover Displays LED destination signs from the same render pipeline that drives Luminator/Mobitec panels. Nothing about the authoring changes: routes, destinations, LED templates and announcement sign text are authored once in the Creator, the engine renders them to pixels once, and a per-vendor protocol adaptor frames those pixels for whichever sign is bolted to the vehicle. A mixed fleet — Luminator front sign, Hanover side sign — runs from one build, one configuration, one publish.

This page is the integration guide: what the engine emits for Hanover signs, on which MQTT topics, how the wire format works, how to hook up the physical sign, and how the implementation was verified. The primary references are the vendor specifications in Hanover/ (HCPS protocol manual, Guide to SuperX, the Hilde format note) — every framing rule below is tested byte-exact against the worked examples in those documents.


1. One render pass, two vendors

Vehicle

Engine

Authoring

…/display/ff topics

…/display/hanover topics

Creator
LED templates · playlists

Render
one 1-bpp bitmap per sign

Mobitec FF encoder
engine/ff

Hanover HCPS/SuperX encoder
engine/hanover

RS-485 gateway
or Hanover infohub

Hanover sign
DIP address 0–F

The Hanover adaptor (src/engine/hanover/hanoverEncoder.ts) is deliberately downstream of everything: fonts, RTL shaping, abbreviations, scrolling decisions, template rules and destination lists have all already happened by the time it runs. It receives finished pixels and returns wire bytes. That is what makes the output pixel-identical across vendors — and it is asserted, not assumed: a pipeline test decodes the published Hanover frame and compares it bit-for-bit against the sign bitmap published for the same announcement.

2. What the engine publishes

Hanover output is on by default (DISPLAY_HANOVER_ENABLED, set false to disable) and runs in parallel with the FF topics — enabling it changes nothing for existing Mobitec installations. Three paths, mirroring the FF ones:

Output Topic (default pattern) Content
Interior sign {tenant}/{vehicleId}/pis/0/display/hanover + …/hanover/{address} The announcement sign text as one HCPS graphic frame, addressed to the interior sign (default address 4). If the text is wider than the panel it carries a SuperX scroll (\sl, repeat forever) so the sign hardware scrolls it. The same frame mirrors on the address subtopic.
Exterior faces {tenant}/{vehicleId}/pis/0/display/exterior/hanover + …/hanover/{address} Front, side and rear as three self-addressed frames concatenated into one payload (defaults: front 0, side 1, rear 2), published retained so a (re)connecting gateway immediately has the current faces. Each face also lands on its own address subtopic, so a per-sign controller subscribes to just its DIP/rotary address.
Template signs {tenant}/{vehicleId}/pis/0/display/signs/hanover + per-sign …/display/signs/{slug}/hanover + per-address …/display/signs/hanover/{address} LED-template (MatrixRenderer) signs whose roster entry declares protocol: 'hanover'. The aggregate carries every Hanover-protocol sign concatenated; each sign also gets its own subtopic ({slug} = kebab-cased position, deduped with -{address}); and each bus address gets its own raw-frame topic carrying only that address's frames (signs sharing an address concatenate) — a sign controller subscribes to its DIP/rotary address instead of filtering the aggregate. Signature-deduped — a frame is republished only when its pixels, address or protocol actually change; stale retained subtopics (slug and address) are cleared with empty payloads when the roster changes.

A sign's address is the DIP/rotary switch on the Hanover sign itself (one hex digit, 0F). The engine's address map is configuration (§6); make it match what is set on the hardware.

3. The wire format in one page

Every payload is one or more HCPS frames:

STX(0x02) · command('0') · address(one ASCII hex digit) · body · ETX(0x03) · CK1 CK2

CK1 CK2 are the two's-complement 8-bit sum of everything after STX through ETX, as two uppercase ASCII-hex characters. Worked example from the vendor's Hilde note — reproduced byte-exact by the test suite:

[STX]01345<Køge St.<Øster Banevej[ETX]F3

The body is either:

4. Hooking up the hardware

The engine speaks MQTT; Hanover signs speak RS-485. Two integration patterns:

A. RS-485 gateway (direct). A small onboard process subscribes to the …/hanover topics and writes the payload to the serial line (electrical parameters per the sign's manual in Hanover/). Concatenated payloads split without any framing metadata: SuperX bodies are pure printable ASCII — never STX/ETX — so the receiver scans for ETX, takes the two checksum characters after it, and that is one frame:

for (let start = 0; start < payload.length;) {
  const etx = payload.indexOf(0x03, start);
  const frame = payload.subarray(start, etx + 3);   // ETX + CK1 CK2
  start = etx + 3;
}

This split contract is pinned by a test, and decodeHanover (exported by the encoder module) is available to any TypeScript gateway for validation — it parses a frame back to address, body, bitmap and scroll flags and reports checksumOk so a corrupted byte is flagged, never silently displayed.

B. Hanover infohub (MQTT bridge). Fleets running Hanover's own infohub/DPI ecosystem publish the frame wrapped in the infohub JSON envelope instead of raw bytes. The encoder module exports both halves, matching the vendor SDK exactly:

topic:   infohub/dpi/sign/request/{address}/json     (status/response mirror it)
payload: {"requestId":null,"requestData":{"hanover_hcp":{"command":"0","message":"<frame>"}}}

5. Authoring — nothing new to learn

6. Configuration reference

Variable Default Meaning
DISPLAY_HANOVER_ENABLED true Master switch for all three Hanover outputs
DISPLAY_HANOVER_ADDR_FRONT 0 Exterior front face → sign DIP address
DISPLAY_HANOVER_ADDR_SIDE 1 Exterior side face → sign DIP address
DISPLAY_HANOVER_ADDR_REAR 2 Exterior rear face → sign DIP address
DISPLAY_HANOVER_ADDR_INTERIOR 4 Interior announcement sign → DIP address
MQTT_DISPLAY_HANOVER_TOPIC_PATTERN {tenant}/{vehicleId}/pis/0/display/hanover Interior topic
MQTT_EXTERIOR_DISPLAY_HANOVER_TOPIC_PATTERN {tenant}/{vehicleId}/pis/0/display/exterior/hanover Exterior faces topic
MQTT_LED_SIGNS_HANOVER_TOPIC_PATTERN {tenant}/{vehicleId}/pis/0/display/signs/hanover Template-sign aggregate topic

7. How this is verified — and what is not, yet

The adaptor ships with 15 dedicated encoder tests plus integration tests in the pipeline and template suites:

Honest gap: everything above proves conformance to the vendor's written specification. The adaptor has not yet been driven against a physical Hanover sign or a live infohub hub — that bench test (especially large picture messages through the hub's own RS-485 framing) is the remaining step before production sign-off, and the first thing to schedule when hardware is available.


Security review — 2026-07-16 (updated after the Supabase→Azure migration)

A security pass over the CreatorStudio suite: secret/credential leakage, injection, XSS, SSRF, path traversal, DoS, and authorization. This records what was found, what was fixed in this change, and what remains an open architectural risk requiring a product decision (not silently changed, because the fix would break the currently-working app or needs real authentication). The Open Risks section has been re-baselined against the new architecture (Azure PostgreSQL + the portal's Creator API; no Supabase, no browser-held keys).

Headline

Fixed in this change

# Issue Severity Fix
1 DOM-XSS — untrusted MQTT/GTFS/config fields (triggerLabel/announcement ref, geofence name, proof type, GTFS stop name) interpolated unescaped into Leaflet bindTooltip/bindPopup (Leaflet renders strings as HTML). A crafted ref/name like <img src=x onerror=…> executed in the dashboard origin. High HTML-escape every such field before it enters a Leaflet HTML string (map, proof, geofences, simulator components).
2 Unauthenticated config overwrite (POST /config) — any device on the LAN could replace the engine's live config (e.g. empty arrays → silence all announcements fleet-wide). Critical Optional bearer-token gate (DASHBOARD_TOKEN); when set, config-import / GTFS-import / TTS-preview require Authorization: Bearer <token> (constant-time compare). Unset = unchanged behaviour + a startup warning.
3 Prototype-pollution vector via POST /config (JSON persisted for the engine's loader). Low/Med Reject payloads containing __proto__ / constructor / prototype keys at the boundary.
4 Forged proof-of-play (POST /player/ack) — arbitrary JSON was published verbatim to the audit topic. High Whitelist + bound the fields, constrain status, and server-stamp playedAt; reject records with no traceId.
5 Paid-TTS abuse (POST /tts-preview) — unauthenticated calls burn the operator's TTS quota. High Gated behind DASHBOARD_TOKEN (see #2). (Add per-IP rate limiting if exposed publicly.)
6 GTFS import DoS — 200 MB uploads + zip-bomb decompression → disk/heap exhaustion. Med/High Upload capped (GTFS_MAX_UPLOAD_BYTES, default 80 MB) and a per-entry uncompressed-size guard in GtfsFeed (GTFS_MAX_ENTRY_BYTES, default 300 MB) rejects zip bombs.
7 SSE bridge resource exhaustion — unbounded player-stream connections and distinct broker subscriptions. Med PlayerBridge caps concurrent connections and distinct subscriptions (PLAYER_BRIDGE_MAX_CONNECTIONS/…_SUBSCRIPTIONS); over cap → 503. Also fixed a double-decrement in cleanup.
8 Info disclosureGET /config returned the absolute install path (config not found at C:\Users\…). Low Generic message.
9 </script> breakout in the env <script> injected into the Creator HTML. Low Escape << in the injected JSON.
.gitignore only ignored .env/.env.local; a .env.production/.env.vehicle could be committed by git add -A. Low Ignore all .env.* except .env.example.

All fixes are covered by tests (bridge caps + validation in test/playerBridge.test.ts; the front-end suites still pass) and verified at runtime (401/400 responses confirmed).

Confirmed safe (checked, not vulnerable)

SPA static serving (serveSpa containment check) and /docs/media (basename, no URL-decode) block path traversal; /player/qr.svg does not fetch the URL (no SSRF) and never echoes it into markup; /tts-preview validates provider against an allowlist and takes endpoints from env (no SSRF); MQTT topic ids are validated (no wildcard/separator injection) and acks publish to a server-fixed topic; player.html assigns every untrusted field via textContent only; no eval/new Function/ bypassSecurityTrust*; every portal-API/engine SQL statement goes through pg with bound parameters (no SQL injection — identifiers like CONFIG_DB_TABLE are allowlist-validated); the readBody cap is enforced.

Open risks — require a product decision (NOT changed here)

These stem from one root cause: the CreatorStudio has no end-user authentication — the browser holds no credential at all (good), but the "tenant" is a client-supplied value the portal API trusts. Fixing that means introducing real sign-in, which would change how the app works, so it is documented rather than silently enabled. The Azure migration itself closed the risks that were inherent to the old Supabase surface:

  1. CRITICAL — publish-engine-config trusts body.tenant. CLOSED (surface deleted) / restated: the edge function is gone; publishing is now the portal's POST /api/publish, which is bearer-gated by DASHBOARD_TOKEN and parameterized — but it still takes tenant from the request body. With the token set, only token-holders can publish at all; per-tenant authorization within the API still requires real sign-in (below).
  2. HIGH — manage-project trusts body.tenant for anon callers. CLOSED (surface deleted) / restated: project writes are now PUT/DELETE /api/projects behind the same DASHBOARD_TOKEN gate. Same residual as (1): tenant is an explicit parameter, not a session-derived claim.
  3. HIGH — RLS read policies collapse to world-readable for the anon key. CLOSED. There is no anon key and no browser-reachable database gateway any more: the Postgres server is private (VM allow-list / VNet, sslmode=require) and every read goes through the portal API, which asks for one tenant's rows explicitly. The old open :54321 gateway with the demo JWT is decommissioned.
  4. HIGH — the dashboard-server has no authentication by default. Unchanged, and now it also fronts the database: the DASHBOARD_TOKEN gate protects the mutating endpoints (including every Creator API write) when set — set it (and bind the server to a trusted network) before any real deployment. Reads and the passenger player remain open by design.
  5. MED — CORS * on the mutating edge functions. CLOSED. The edge functions are deleted and the Creator is served from the same origin as the API it calls — no cross-origin surface remains.

Recommended priority: set DASHBOARD_TOKEN in every non-dev deployment (4) → real sign-in for per-tenant authorization (1)/(2).

Deployed posture (post-migration)

To fully close (1)–(2): add real authentication to the Creator

The remaining exposure all stems from the Creator having no user login. The path is Entra ID sign-in, and the seams are already in place:

  1. Sign the operator in with Entra ID and attach the session token to the Creator's API calls — the SessionService bearer plumbing already forwards a token when a host shell supplies one.
  2. Derive tenant server-side from the verified token in the portal API (drop the body/query tenant for authenticated callers) — the tenant columns are already on every table.
  3. Record a server-verified published_by on publish — config_versions.published_by_verified already exists and honestly defaults to false today.

CreatorStudio Suite — License & Commercial Packaging

Document type: Product packaging brief (illustrative)
Audience: Product, Sales ops, Finance, Suite platform owners
Status: Draft for planning — figures are example unit economics, not committed list prices
Date: 2026-07-19


1. Packaging principles

Principle Meaning
Modular Customers buy only what they need; modules attach to a thin Core.
Vehicle-scaled Recurring revenue tracks fleet size (vehicles under management).
Usage-aware for TTS AI speech can be included or metered so cloud cost stays predictable.
Suite-aligned Modules map to Luminator Suite capabilities (Matrix signs, MQTT, on-vehicle Units).
Honest value Each SKU states who benefits, what problem it solves, and how revenue scales.

2. License modules (SKUs)

L1 — Core Announcements Engine (required)

What it is Always-on engine: MQTT PIS-PT in → trigger evaluation → ADT audio out → thin-client playback contract + basic announcement-driven interior display.
Value proposition Run passenger audio without per-vehicle firmware content. One cloud (or edge) brain; vehicles stay thin clients.
Includes Trigger engine (built-in set), playlist render pipeline, ADT publish, engine/played ack path, config hot-swap, single-tenant runtime.
Does not include Neural TTS suppliers, Matrix LED authoring, proof packs, multi-tenant.
Suggested metric € / vehicle / month (active vehicles in served set).
Example price €8 / vehicle / month

L2 — Creator Authoring Studio

What it is Browser app to design playlists, triggers, geofences, lexicon, publish history / rollback, GTFS simulator.
Value proposition Non-engineers change what the fleet says without a software release. Content velocity measured in seconds, not weeks.
Includes Projects, playlists, 33 triggers + custom triggers, geofences, simulator, publish pipeline to engine config.
Depends on L1
Suggested metric € / authoring seat / month or included with L1 for fleets ≥ N vehicles.
Example price €120 / seat / month (first 2 seats included with L1 above 100 vehicles)

L3 — Cloud AI Speech

What it is Pluggable neural TTS (Azure / ElevenLabs / Acapela), fleet-wide content-addressed cache, multilingual + “also speak in”.
Value proposition 64-bit AI quality from the cloud — works with 32-bit onboard units (e.g. ICU602i). One cloud speech entitlement instead of an onboard TTS licence per vehicle.
Includes Supplier connectors, cache (memory/FS/object), A/B voice compare via engine, lexicon application at synth time.
Depends on L1 (+ L2 to author content)
Suggested metric € / vehicle / month (includes a monthly synth pool) + overage € / 1 000 unique phrases.
Example price €4 / vehicle / month (includes 5 000 unique-phrase synths / fleet / month fleet-wide via cache); overage €3 / 1 000 new unique phrases

L4 — Matrix LED Signs

What it is MatrixRenderer-aligned layouts / displays / vehicle faces; FNT fonts; mono + RGB Mobitec FF and Hanover HCPS/SuperX (vendor-independent sign output); trigger face overrides.
Value proposition Destination and interior signs become first-class Suite content — same Publish as audio; drives Ultima / FF hardware you already sell.
Includes Creator LED editors, engine matrix rasteriser, …/display/signs + FF topics, dashboard live LED panel.
Depends on L1 (+ L2 for authoring)
Suggested metric € / vehicle / month (vehicles with LED faces enabled).
Example price €6 / vehicle / month

L5 — Proof of Play & Compliance

What it is Tamper-evident audit (dispatched vs played), map/replay, certified coverage report (CSV/JSON/HTML), optional GTFS expected coverage.
Value proposition Turns “we think it announced” into exportable evidence for ADA / accessibility and operator QA.
Includes History store, proof UI, report generation, integrity hash / attestation block.
Depends on L1 (thin clients must ack)
Suggested metric € / vehicle / month or fleet flat + vehicle.
Example price €3 / vehicle / month

L6 — Fleet Operations Dashboard

What it is Live cockpit (map, feed, health, performance), device QR handoff, published-config view.
Value proposition Operations can see and control what the fleet is saying/showing without SSH into vehicles.
Includes Monitor cockpit, diagnostics, multi-vehicle selection, play-on-device.
Depends on L1
Suggested metric Included with L1 above threshold, else € / ops seat.
Example price Included with L1 ≥ 50 vehicles; otherwise €80 / ops seat / month

L7 — Passenger Information App

What it is Browser /player PIS: headsign, current/next stop, “getting off at”, offline-tolerant journey view + optional audio.
Value proposition Zero-install passenger / staff screen on any device; same MQTT feed as the vehicle.
Depends on L1
Suggested metric € / vehicle / month (enabled vehicles) or included.
Example price €1.5 / vehicle / month

L8 — Multi-tenant Platform

What it is Tenant-scoped config, audit, and engine processes for operators / OEMs hosting many customers.
Value proposition One Suite deployment, many isolated fleets — commercial hosting and OEM white-label.
Depends on L1
Suggested metric Platform fee + per-tenant + per-vehicle.
Example price €2 500 / month platform + €400 / tenant / month + underlying L1–L7 vehicle fees

L9 — On-vehicle Edge Engine

What it is Same engine as a hardened multi-arch container on the vehicle (offline / autonomy scenarios).
Value proposition Cloud pattern without permanent WAN: last-known-good config, local MQTT, optional cloud sync when online.
Depends on L1 (licence of record still vehicle-based)
Suggested metric Uplift on L1 for edge-enabled vehicles.
Example price +€3 / vehicle / month on L1

Bundle Modules Typical buyer Example list (per vehicle / month)*
Starter Audio L1 + L2 + L3 + L6 Operator wanting spoken announcements only €8 + €0† + €4 + €0 = €12
Audio + Signs Starter + L4 Full saloon + destination experience €12 + €6 = €18
Compliance Audio + Signs + L5 Regulated / ADA-sensitive markets €18 + €3 = €21
Full PIS Compliance + L7 Audio + signs + passenger screen €21 + €1.5 = €22.5
OEM Hosted Full PIS + L8 (+ L9 optional) Luminator / integrator hosting many fleets Platform fees + Full PIS vehicle rates

*Illustrative. †Authoring seats included above 100 vehicles in this example model.


4. Revenue scaling examples (illustrative)

Assumptions for the examples below (planning model only):

Example A — Single operator, organic fleet growth

Year Vehicles Mix (blended ASP) ARR Cumulative ARR
Y1 200 €14 (mostly Starter + TTS) €33 600 €33 600
Y2 450 €18 (Signs attach) €97 200
Y3 800 €20 (Compliance + PIS) €192 000
Y4 1 200 €21 €302 400
Y5 1 800 €22 €475 200

Reading: Revenue scales primarily with vehicles under management and secondarily with module attach (LED, Proof, Passenger).

Example B — Module attach economics (500-vehicle fleet, steady state)

Module Attach Vehicles billed Example unit Monthly Annual
L1 Core 100% 500 €8 €4 000 €48 000
L2 Creator seats 4 seats €120 €480 €5 760
L3 AI Speech 100% 500 €4 €2 000 €24 000
L4 Matrix LED 80% 400 €6 €2 400 €28 800
L5 Proof 70% 350 €3 €1 050 €12 600
L6 Ops included €0 €0
L7 Passenger 50% 250 €1.5 €375 €4 500
Total €10 305 €123 660

Blended ≈ €20.6 / vehicle / month across 500 vehicles.

Example C — OEM / multi-tenant host (L8)

Item Volume Example unit Monthly
Platform fee (L8) 1 €2 500 €2 500
Tenants 12 €400 €4 800
Vehicles (Full PIS @ €20) 3 000 €20 €60 000
Edge uplift (L9) on 20% 600 €3 €1 800
Hosted ARR run-rate €69 100 / mo → ~€829 k / yr

Reading: L8 turns Luminator (or a partner) into the commercial host; vehicle modules remain the volume engine.

Example D — Why Cloud AI Speech (L3) expands margin vs onboard TTS

Model 500 vehicles Speech cost shape
Legacy onboard TTS licence €15 / vehicle / year paid to voice vendor × 500 = €7 500 / yr fixed, quality capped by 32-bit hardware Linear with fleet, no cache benefit
L3 Cloud AI (example) €4 × 500 × 12 = €24 000 / yr revenue; COGS mostly unique-phrase synths (cache → high GM after warm-up) Revenue to Luminator; quality upgrades without hardware refresh

Onboard licences are typically cost to the operator or OEM; L3 is a sellable module with improving unit economics as the phrase cache hits ~100% in steady state.


5. What is sold vs what is free/open

Surface Commercial treatment
MQTT topic contract / ADT / FF payloads Open integration surface (enables ecosystem; not a SKU)
Go / Python / browser thin clients Included with L1 (or open-source distribution policy TBD)
MatrixRenderer on-sign Unit Existing Suite product — L4 complements, does not replace
Professional services (integration, GTFS, training) Time & materials / fixed SOW — outside ARR

6. Packaging guardrails

  1. L1 is always required — no orphan modules.
  2. L3 without L2 is possible (engine-only + imported config) but not the default motion.
  3. L5 requires client ack — player compliance is part of the value story.
  4. L4 pricing applies only to vehicles with template-mode signs enabled.
  5. Publish example prices as “planning figures” until Finance/Sales ops finalize list price and discount bands.

7. Next product decisions


CreatorStudio Suite — Test Report

Scope: the Node/TypeScript announcement engine (src/**) and the two Angular front-ends (web/ — the Engine dashboard and the CreatorStudio authoring app). Engine suite: 83 files, 750 cases (Node's built-in test runner + coverage). Front-end suite: 76 files, 948 cases (Vitest + jsdom via Angular's @angular/build:unit-test — dashboard 225, creator 723). Total: 1698 tests — 1698 passed · 0 failed · 0 skipped. Coverage: engine 92.5 % lines · 84.2 % branches · 72.7 % functions (all loaded files, incl. the Creator-side mirrors the parity tests pull in — see §3); front-end 92.6 % lines (dashboard) / 90.7 % (creator), 74.9 % / 72.2 % functions. Generated: 2026-08-07 (measured on this commit, not carried forward — after the Supabase→Azure migration replaced supabaseConfigSource/assistantPromptParity with pgConfigSource/creatorApi).

Reproduce:

npm test                                  # pass/fail only
NODE_OPTIONS="--import tsx" node --test --experimental-test-coverage test/*.test.ts
npm --prefix web run test:coverage        # both front-ends, with coverage

The suite needs no network, no broker and no cloud — every external (MQTT, TTS supplier, the database, HTTP audio fetch) is faked in-process, so the run is deterministic and offline. The two exceptions run a real in-memory broker (aedes) in-process: mqttBusIntegration and engineBoot.


1. Result summary (engine)

Test files 83
Test cases 750
Passed 750 (100 %)
Failed 0
Skipped / todo 0
Duration ~10 s (~26 s with coverage)
Runner node --test (built-in TAP), TS via tsx
Flakes observed one timing-sensitive case (cache disk-prune) can overrun its window when the whole suite runs under coverage on a loaded machine; it passes standalone and in the plain run

There are no failing, skipped or quarantined tests. Nothing is xfail'd.


2. Results by suite

Every suite below passed in full. Case counts are exact (they sum to 750).

Triggers & journey state

Suite (test/…) Cases Area under test
ptEngine.test.ts 34 PIS-PT → trigger firing: arrival/departure/passage, doors, trip-start, approaching, detour, situation alerts, geofence, stop-skip/-request, exit-side, alarm, occupancy, last-stop exclusivity
customTriggers.test.ts 18 Fact-based custom triggers: comparators, changed, groups (all/any/not), geofence in/out, rising-edge vs while-true, per-vehicle cooldown & edge state, stop-relative sign facts against the first stop still ahead
geofencePolygon.test.ts 17 Polygon geofence editing: vertex move/insert/delete, degenerate-ring refusal, and that a reshape actually changes what is inside
ptContext.test.ts 11 PIS-PT context: topic parse, tracked-vehicle gating, next-stop/destination/phase, route stops, connection enrichment, countdown
triggerHardening.test.ts 10 Retained-burst settle window, gate-hold (door), name-hold (late stop list), strict-stops distrust
prerequisites.test.ts 8 Universal prerequisite gates: door-open/closed, stop-button, velocity window, ungated
priority.test.ts 8 Priority tiers: default levels, interrupt vs ambient, interrupt aborts superseded render, slot release
manifestSync.test.ts 3 Engine ↔ CreatorStudio trigger-type parity (33 types), removed service-disruption
variableParity.test.ts 3 Every {variable} the Creator offers is one the resolver can fill (and the mirror matches exactly)
triggerJobsParity.test.ts 2 The Creator's audio preview resolves a trigger to the same jobs the engine renders (split/sequence/repeat)
scheduler.test.ts 2 Per-vehicle scheduler: priority interrupt/queue/drop, expired-entry purge

Render, speech & audio

Suite (test/…) Cases Area under test
ttsClients.test.ts 24 The three original supplier HTTP clients: request build (auth, voice, format), success → buffer, error-status mapping, ElevenLabs' proxy-block branch, Acapela's login flow
lexicon.test.ts 18 Pronunciation lexicon respelling + SSML (<phoneme>, say-as), language scoping, XML escaping, the Unicode-boundary fix that lets non-ASCII names (Malmö/Örebro/Arabic) match, validateIpa degradation
ttsNewClients.test.ts 17 The 2026 supplier additions behind stub fetch: Google/Polly/ReadSpeaker request shapes (SigV4, form POST), the OpenAI-compatible self-hosted trio, the generic HTTP adapter's raw/base64 envelopes and voice-catalogue endpoints
pipeline.test.ts 12 End-to-end render→publish: ADT audio, speaker routing, LED+FF sign, the Hanover twin frame pixel-identical to the published sign, metrics, proof record, skip-incomplete, abort-on-supersede, TTS-failure containment
multilingual.test.ts 10 Language-variant stop/destination resolution, {currentStop}/{countdown}/{geofenceName}, empty-payload → empty (never a literal token)
perElementVoice.test.ts 9 Per-element voice/volume run-splitting, run merging, pause folding, capability-gated lexicon SSML, volume dropped (runs stay merged) on a supplier without prosody-volume
ttsSsml.test.ts 8 SSML prosody (rate/volume), pitch never rendered & not in cache key, escaping / no SSML-injection, break tags
resilientTts.test.ts 6 Resilient TTS wrapper: retry, fallback audio, fail-fast on auth error, circuit breaker
adtPayload.test.ts 5 ADT 4.x MQTT audio message shape, speaker routing, base64/MP3 encoding, content-hash clipKey
stopRecordings.test.ts 5 Recorded stop-name clips: match by id/name/language, inline data: URI, fetch-fail → TTS fallback
ttsSwitch.test.ts 5 Provider switching: name list, construct-without-network, credential gating, and a published default voice re-keying the cache
cache.test.ts 4 TTS cache: bounded LRU + stats, disk spill, throwing-shared-layer degrade, disk prune
ttsFailover.test.ts 4 Provider auto-failover: fallback config guards, primary-equals-fallback clears, an open primary breaker routes pin/describe/synth to the fallback (cache key follows), no-fallback behaviour unchanged
volume.test.ts 9 Volume adaptation: disabled→null, stop-over-route precedence, time-of-day (incl. midnight wrap); combined rules — AND over time/weekday/route/stop/geofence, circle + polygon matching, trusted-fix gating, no-condition rules never match, specificity beats legacy singles
audioFile.test.ts 3 Preset MP3: fetch-once-then-cache, 500 retry, 404 drop-but-still-play
prepush.test.ts 3 Closed-corpus pre-push: whole remaining journey per stop + journey-constant once + volatile never, clipKey parity with the live pipeline's published payload, dedupe / journey change / per-journey cap
prerender.test.ts 3 Next-stop prerender warming, cache-hit after warm, de-dupe until journey changes
resilienceHardening.test.ts 3 Races: mid-render provider hot-swap key safety, never-settling publish, publish-while-disconnected
synthDedup.test.ts 3 Synth single-flight: concurrent collapse, later cache hit, non-cacheable dedup
ttsCapabilities.test.ts 3 The capability matrix: all 13 suppliers declare { ssml, prosodyVolume, phoneme, offline } (fails on an undeclared newcomer); wrappers pass it through; undeclared → plain text
audioFormat.test.ts 2 Preset clips match TTS output format (24 kHz mono)
fallbackCache.test.ts 2 Resilient fallback buffer tagged & never written to the fleet cache
breaker.test.ts 1 Circuit breaker open → half-open single-probe

LED signs — matrix templates, fonts and frames

Suite (test/…) Cases Area under test
vilniusRequirements.test.ts 38 The Vilnius (Judu) sign set end to end — each authored requirement (capitals, bracket/comma alternation, keyword pictograms, trip-id depot takeover, mirrored rear, boarding notice, countdown floor, G-line inversion, extra-text rotation) asserted on the frames the engine actually publishes
matrixParity.test.ts 24 Browser ↔ engine parity for the MatrixRenderer pipeline: PNG decode, colour rules, real FNT ladder, cycle resolution, identical frames both sides, per-sign FF/Hanover protocol split
ledTemplate.test.ts 20 The template model + expression evaluator: optional chaining, object-map idiom, comparisons, no reachable globals (no eval)
ledMatch.test.ts 16 Value matching for rules and cycles: eq / contains / regex, whitespace-insensitivity, empty-expected never matches
ledStops.test.ts 16 Route stops from list/stops: call order, timing-point flags under any spelling, main-stops fallback, and the departed stop dropping off "Stopping at:" before linkprogress catches up
hanoverEncoder.test.ts 15 Hanover HCPS/SuperX frames: byte-exact Hilde-doc checksum vectors, SuperX §9 raster vector, round-trip, scroll flags, CP1252, infohub bridge JSON, corrupted-byte detection (checksumOk), the 32767-byte \bin cap, the concatenated exterior-frame split contract, all-spaces clear frame
ffEncoder.test.ts 13 Mobitec FF/graphic-font-w frames: checksum, column packing, round-trip, address mapping, hardware-scroll bytes
ledShapes.test.ts 13 Line / rectangle / circle primitives: continuity, stroke width, fill vs outline
ledDestinations.test.ts 12 Destination-code resolution: exact (number, lineCode) beats wildcard, unknown code leaves the feed alone
ledRouteColors.test.ts 12 Route-colour lookup (exact, case-insensitive, line before technical code) and lifting a GTFS feed's colours
ledTextRules.test.ts 10 Text transforms (upper/lower/title incl. Lithuanian), show-only-part, part rotation
ledVia.test.ts 10 … via X splitting and the via passed fact (true only once every call at the via stop is behind)
destinationList.test.ts 9 Pre-programmed destinations: code-only selection, override precedence, stored text beating the feed
exteriorSign.test.ts 9 Mobitec exterior sign: per-face text, 24px height, route-zone cap, scroll vs static, change signature
fnt.test.ts 9 Real Luminator .FNT parsing: metrics, glyph shape sanity, measurement + uppercase fallback
ledRtl.test.ts 9 Arabic/Hebrew LED text: Unifont fallback glyphs, contextual Arabic shaping, bidi reorder — all at the one shared/fnt.ts chokepoint, auto-detected with no authoring flag
customerByConfiguration.test.ts 8 The same primitives configured for a different operator — proving the Vilnius set is configuration, not code
ffRgb.test.ts 8 Colour FF frames (03090 §7.6): RGB444 conversion, escaping, colour table, colour bitmap
ledAbbrev.test.ts 8 Abbreviation rules: value vs word mode, word boundaries, always/all phases
ledMideastDemo.test.ts 8 The Jerusalem AR/HE/EN trilingual demo vehicle: low-res 16 px panels, textAlternations language rotation, route simulation
ledSignClient.test.ts 8 The standalone HTML LED-sign client (/sign, /vehicle): shows exactly the pixels the engine drives, live via the server bridge
ledRender.test.ts 7 Interior LED dot-matrix rasteriser: static vs scroll, glyph packing, Swedish diacritics, spacing
ledSignVilnius.test.ts 5 The Vilnius sign set rendered through the standalone sign client (same frames as the engine)
engineDestinations.test.ts 2 Destination-list priority modes on the wire (signs win / feed wins)
ledEncoder.test.ts 2 LED bitmap base64 payload, FF extension point

Config, proof and platform

Suite (test/…) Cases Area under test
assistantAi.test.ts 16 The portal's local AI assistant: OpenAI-compatible provider resolution (default local Ollama), request/plan shapes, error paths — fetch injected, no socket
playerBridge.test.ts 16 The browser player's server bridge: topic prefixes, filter matching, id validation (no wildcards/spaces), state-vs-announcement replay rules (tts/audio/display/tts-cache never replayed)
configEnv.test.ts 13 loadEnv() permutations: required vars, per-provider credential enforcement, TTS_FALLBACK_PROVIDER (typo throws at boot, not mid-outage), Hanover on-by-default with DIP-address remap, RENDER_SCOPE (typo throws), every default toggle
diagnostics.test.ts 14 npm run doctor / GET /diag: URL redaction/resolution helpers, TCP probe never throws, retained-heartbeat liveness (fresh/stale/absent/no-timestamp), heartbeat captured behind a large retained backlog without hanging the probe
creatorApi.test.ts 8 The portal's Creator API: scoped reads, one-transaction publish (monotonic version, rollback → no notify), body validation, authorized() write gate, tenant-scoped delete
projects.test.ts 9 Multi-project config: legacy migrate, deep-clone, duplicate, ref validation, publish-selection, multitenant scoping
proofSink.test.ts 9 Durable proof mirror: batching, auto-flush, parameterized multi-row INSERT shape, failure isolation
sampleConfig.test.ts 9 Shipped sample config integrity: unique ids, valid trigger→playlist refs, known variables, real speech renders
fleet.test.ts 12 The published fleet directory: every field derived, missing signals collapse to empty rather than crash, payload capped (FLEET_MAX_BYTES) so the retained directory never outgrows the broker
gtfsFeed.test.ts 8 GTFS zip reader: routes/trips/stops parsing, natural sort, status + distinct values
liveVehicles.test.ts 8 /player/live — which vehicles are announcing right now (the player setup screen's single-vehicle prefill)
mqttWsProxy.test.ts 8 The dashboard's MQTT relay: the trust boundary (credentials stay server-side) and the framing, not MQTT itself
configStore.test.ts 7 Config load: remote-first, throwing-remote fallback, empty/corrupt/partial handling, serialised concurrent loads
mergeSamplePlaylists.test.ts 7 Sample-library import: triggers untouched, idempotent top-up, voice remap, name-prefixing
expectedCoverage.test.ts 6 GTFS trip matching: overlap scoring, best-trip pick, tie-break, journey coverage / missed stops
mqttClient.test.ts 6 MQTT client hardening: throwing handler contained, non-JSON/scalars dropped, /shape skip, topic templating
proofReport.test.ts 6 Proof-of-play report: summary, per-route breakdown, scope filter, integrity hash, CSV, self-contained HTML
offlineExport.test.ts 5 Offline bundle export: corpus enumeration (per-stop/per-destination, volatile excluded), clip cap reported, exported clipKeys byte-for-byte cover what pre-push delivers
historyLog.test.ts 5 Proof append log: serialised appends, disk trim, atomic trim, durable-mirror isolation, flush-on-shutdown
proof.test.ts 5 Proof-of-play correlation: played-ack vs dispatched, failure/retry supersede, exterior exclusion
pgConfigSource.test.ts 6 LISTEN/NOTIFY subscription: one reconnect per connection loss, exponential backoff, clients grow linearly (leak regression), scope-filtered notifications, tenant-scoped fetch
mqttBusIntegration.test.ts 3 Real broker (aedes): connect → subscribe → receive → publish, and publish rejects when never connected
engineBoot.test.ts 2 Full engine boot against a real broker: ingest a journey, appear in the fleet directory, stop cleanly; engine/health published retained so a late subscriber sees a live engine immediately
offlineBundle.test.ts 1 Bundle round-trip: manifest, config envelope, verifiable clips and tts-cache entries

3. Coverage by module (engine)

node --test --experimental-test-coverage, whole suite. All files: 93.04 % lines · 84.59 % branches · 73.33 % functions. The aggregate includes every file the suite loads — the engine proper, the portal's server libs (scripts/lib/*) and the Creator-side mirrors the parity tests import — so the per-module rows below are what to read, not the single headline number.

Module Lines % Branch % Funcs % Note
domain/announcement.ts 100 100 100
domain/triggerParity.ts 100 100 91.7
domain/customTrigger.ts 96.3 87.5 90.9 incl. pointInPolygon geofence math
engine/adtPayload.ts 100 100 100 incl. the clipKey content hash
engine/destinationList.ts 100 96.4 100
engine/fleet.ts 100 100 100
engine/hanover/hanoverEncoder.ts 100 98.2 100 Hanover HCPS/SuperX sign adaptor
engine/ledRender.ts 100 91.3 100
engine/proofSink.ts 100 97.4 83.3
engine/variableResolver.ts 100 92.1 100
engine/volume.ts 100 78.6 100
engine/matrix/globalState.ts · index.ts · layoutRenderer.ts 100 100 100 the matrix render core
engine/lexicon.ts 99.3 88.1 87.5
engine/led-font.ts 99.3 84.6 80.0
engine/playlistRenderer.ts 98.9 89.9 100 incl. capability-gated SSML + volume drop
engine/ff/ffEncoder.ts 98.8 92.0 92.3
engine/prepush.ts 98.3 75.8 95.0 closed-corpus pre-push
engine/ff/ffRgbEncoder.ts 98.2 89.7 100
engine/matrix/publish.ts 99.2 86.5 100 incl. the per-sign FF/Hanover protocol split
engine/matrix/png.ts 98.0 58.2 80.0 PNG variants (interlace/bit depths) unexercised
engine/prerender.ts 97.8 66.0 94.1 branch gaps around volatile-playlist skip
engine/customTriggers.ts 96.2 83.0 81.3
engine/history.ts 96.1 85.3 83.3
engine/scheduler.ts 96.1 84.6 94.7
engine/ledEncoder.ts 95.5 80.0 100
engine/exteriorSign.ts 93.6 88.9 83.3
engine/proofReport.ts 86.3 68.4 87.5 lowest engine line-cov; report-formatting branches
pis/ptContext.ts 99.2 90.0 95.5
pis/ptEngine.ts 98.5 93.2 72.7 small function gaps around rarely-built events
config.ts 98.9 82.4 87.5
config/pgConfigSource.ts 98.1 82.6 78.3
pipeline.ts 96.1 85.4 84.0
mqtt/client.ts 96.1 81.0 90.9 lifted by the real-broker integration test
index.ts 80.9 52.6 35.7 composition root — booted end-to-end twice (engineBoot); the wiring keeps growing (pre-push, template signs, destination lists, Hanover exterior faces) faster than its tests
gtfs/expectedCoverage.ts 97.4 73.6 100
gtfs/feed.ts 97.9 62.3 100 malformed-feed branches
logger.ts 100 94.1 100
shared/assistantPrompt.ts 100 92.3 100
shared/ledAbbrev.ts · ledCycle.ts · ledStops.ts · ledPtSymbolArt.ts 100 80–97 69–100
shared/ledPresetVilnius.ts · ledPresetMideast.ts 98–100 91–96 82–86
shared/ledLayoutRender.ts 96.2 89.5 92.5
shared/ledMatch.ts 99.4 81.1 77.8
shared/ledExpression.ts 89.1 79.0 77.0
shared/ledOverride.ts 92.1 84.8 70.2
shared/ledPtSymbols.ts 90.0 91.9 85.7
shared/ledDestinations.ts 83.7 90.8 57.8 Creator-side halves covered in the front-end suite
shared/ledRtl.ts 83.2 86.7 55.9 ditto — the browser exercises the other paths
shared/ledVia.ts · ledTextRules.ts · ledRouteColors.ts 75–80 91–94 65–67 ditto
shared/fnt.ts 69.4 85.5 61.0 engine renders through a subset; browser-side rendering covers the rest (matrixParity pins agreement)
shared/ledShapes.ts 60.4 94.5 56.0 ditto
tts/provider.ts · resilient.ts · ssml.ts 100 82–100 93–100 incl. the capability descriptor
tts/azure.ts 100 95.6 87.5
tts/elevenlabs.ts 99.4 82.6 75.0
tts/cache.ts 97.0 85.1 87.0
tts/http-tts.ts 97.8 70.0 66.7
tts/mock.ts 94.3 100 71.4
tts/polly.ts 92.5 70.3 81.3
tts/openai-speech.ts 92.0 66.7 78.6
tts/google.ts 88.3 81.5 76.9
tts/acapela.ts 88.4 74.4 73.7 the login flow has more states than the other suppliers
tts/readspeaker.ts 86.8 58.8 70.0 least-covered supplier client
tts/index.ts 94.7 85.3 100 provider construction switch + breaker-driven failover
scripts/lib/player-bridge.ts 98.8 84.4 85.7
scripts/lib/mqtt-ws-proxy.ts 98.8 79.6 85.0
scripts/lib/assistant-ai.ts 100 86.5 84.6
scripts/lib/live-vehicles.ts 100 90.9 100
scripts/lib/diagnostics.ts 72.2 78.5 77.8 helpers + the MQTT/liveness probes are tested; the presentation paths run in npm run doctor
scripts/lib/example-playlists.mjs · merge-sample-playlists.mjs 87–93 25–53 83–100 support scripts
web/creator/app/lib/projects.ts 95.9 88.9 100 pulled in by projects.test.ts
web/creator/app/models/announcement.ts 96.0 95.9 80.0 pulled in by the parity tests
web/creator/app/models/custom-trigger.ts 84.6 87.1 79.4 pulled in by the parity tests
web/creator/app/models/led-template.ts 84.7 91.7 17.0 large Creator model pulled in whole by the matrix parity tests; its editor logic is covered in the front-end suite
web/led-sign/demo-config.ts · resolve.ts 95–100 82–92 91–100 the standalone sign client's core

4. Coverage gaps & risk read

Well protected. The safety-critical core — trigger firing (ptEngine), custom/geofence triggers, the render→publish pipeline, priority/interrupt, config load + fallback, proof-of-play, the whole LED matrix pipeline, and every resilience path (retry, breaker, single-flight, cache degrade, capability degradation, pre-push dedupe/cap) — sits at 93–100 % line coverage with explicit hardening tests. The suite's clear strength is failure-mode behaviour: throwing handlers, corrupt config, dead broker, dead TTS, mid-render aborts.

Weak spots, by risk:

  1. src/index.ts — 79 % line / 32 % functions. The composition root is booted end-to-end exactly once (engineBoot.test.ts) and its wiring keeps growing (pre-push, template signs, destination lists, health) faster than its tests. This remains the largest single gap, and it widened since the last report.
  2. engine/proofReport.ts — 86 % line / 68 % branch. This produces the compliance CSV/HTML evidence; the uncovered branches are alternate report shapes worth pinning given its purpose.
  3. The newer TTS supplier clientsreadspeaker (59 % branch), openai-speech (67 %), http-tts (70 %), polly (70 %) are thinner than azure/elevenlabs; their error-envelope and voice-catalogue branches are the untested paths.
  4. Shared LED text helpers, engine sidefnt.ts (69 % line), ledShapes (60 %), ledVia/ledTextRules/ledRouteColors (75–80 %). The same modules are exercised from the browser suite and matrixParity pins frame-level agreement, but an engine-only regression in the uncovered halves could pass CI's engine job.
  5. Branch gaps in input parsersmatrix/png.ts (58 %), gtfs/feed.ts (62 %), prerender.ts (66 %). These handle third-party data (uploaded PNGs, operator GTFS zips); malformed input is the untested path.
  6. scripts/lib/diagnostics.ts — 46 % line. Only its pure helpers are unit-tested; the probe orchestration runs for real in npm run doctor.

Still with no tests: src/shared/payloads.ts and src/pis/types.ts — type/schema declarations with no executable logic.


Ordered by value.

  1. index.ts wiring — drive startEngine() through eviction, a render-scope change, a config hot-swap, a destination-list publish and a pre-push round, asserting the topics that result. Highest-value gap (§4.1), and it has grown since first listed.
  2. Player end-to-end — no automated test drives the Go/Python/browser players against a broker; the clip-cache contract (store on tts/cache, key-only replay, expiry → expired ack) is only asserted engine-side. The players now carry real logic, so this gap is bigger than it was.
  3. Report shapes (proofReport) — empty scope, a journey with no GTFS match, and a CSV/HTML snapshot per shape, since this artefact is the compliance evidence.
  4. Malformed third-party input — a truncated GTFS zip, an interlaced/16-bit PNG, a layout whose font is missing: assert degrade, never throw.
  5. Property/fuzz round-trips on the encoders (ffEncoder, ffRgbEncoder, ledEncoder, ledRender) — arbitrary text/bitmaps encode→decode without loss and never throw.
  6. Load/scale — a sustained-throughput run matching SCALING.md's numbers (events/s, cache hit-rate, loop lag) as a guarded CI job rather than a manual ladder.

Previously listed items A–F are done: the reconnect leak regression (pgConfigSource), the supplier HTTP clients (ttsClients, extended by ttsNewClients + ttsCapabilities), the MQTT connection lifecycle (mqttBusIntegration), config.ts branch permutations (configEnv), full-boot E2E (engineBoot), and the front-end suite (§6).


6. Front-end test suite (web/)

Both Angular apps — the Engine dashboard (web/src) and the CreatorStudio authoring app (web/creator) — are tested automatically.

Runner. Angular 20's first-party @angular/build:unit-test builder with Vitest + jsdom (replacing the deprecated Karma + Chrome default). It runs entirely in Node — no browser process — so it is fast, deterministic and CI-friendly. TestBed is auto-initialised; a shared setup file (src/test-setup.ts) stubs the browser APIs the app touches on init (fetch, WebSocket, matchMedia, ResizeObserver, canvas 2D, requestAnimationFrame), and a providers file supplies the router, PrimeNG preset, animations and (for the creator) MessageService.

Reproduce:

npm run web:test                      # both apps (from the repo root)
cd web && npx ng test web             # dashboard only
cd web && npx ng test creator         # creator only
npm --prefix web run test:coverage    # both, with v8 coverage

Results.

App Files Cases Result Lines Branches Funcs
Dashboard (web) 34 225 all pass 92.6 % 65.6 % 74.9 %
CreatorStudio (creator) 41 706 all pass 90.5 % 73.5 % 70.5 %
Front-end total 75 931 all pass

What's covered.

Where the percentages come from. Function coverage sits in the ~70–75 % band because v8 only counts a listener as covered when its DOM event actually fires, and the two heaviest areas — Leaflet map rendering and the LED canvas editors (layout / display / destination sign) — cannot lay out under jsdom. Those paths are instead pinned engine-side, where the same shared LED core (src/shared/led*.ts) is exercised and matrixParity.test.ts asserts the browser and engine renderers agree frame-for-frame.


Appendix — full engine test-case catalog (738, all passing)

Generated from the runner, one section per file, in the order node --test test/*.test.ts runs them.

test/adtPayload.test.ts

test/assistantAi.test.ts

test/audioFile.test.ts

test/audioFormat.test.ts

test/breaker.test.ts

test/cache.test.ts

test/configEnv.test.ts

test/configStore.test.ts

test/creatorApi.test.ts

test/customTriggers.test.ts

test/customerByConfiguration.test.ts

test/destinationList.test.ts

test/diagnostics.test.ts

test/engineBoot.test.ts

test/engineDestinations.test.ts

test/expectedCoverage.test.ts

test/exteriorSign.test.ts

test/fallbackCache.test.ts

test/ffEncoder.test.ts

test/ffRgb.test.ts

test/fleet.test.ts

test/fnt.test.ts

test/geofencePolygon.test.ts

test/gtfsFeed.test.ts

test/hanoverEncoder.test.ts

test/historyLog.test.ts

test/ledAbbrev.test.ts

test/ledDestinations.test.ts

test/ledEncoder.test.ts

test/ledMatch.test.ts

test/ledMideastDemo.test.ts

test/ledRender.test.ts

test/ledRouteColors.test.ts

test/ledRtl.test.ts

test/ledShapes.test.ts

test/ledSignClient.test.ts

test/ledSignVilnius.test.ts

test/ledStops.test.ts

test/ledTemplate.test.ts

test/ledTextRules.test.ts

test/ledVia.test.ts

test/lexicon.test.ts

test/liveVehicles.test.ts

test/manifestSync.test.ts

test/matrixParity.test.ts

test/mergeSamplePlaylists.test.ts

test/mqttBusIntegration.test.ts

test/mqttClient.test.ts

test/mqttWsProxy.test.ts

test/multilingual.test.ts

test/pgConfigSource.test.ts

test/offlineBundle.test.ts

test/offlineExport.test.ts

test/perElementVoice.test.ts

test/pipeline.test.ts

test/playerBridge.test.ts

test/prepush.test.ts

test/prerender.test.ts

test/prerequisites.test.ts

test/priority.test.ts

test/projects.test.ts

test/proof.test.ts

test/proofReport.test.ts

test/proofSink.test.ts

test/ptContext.test.ts

test/ptEngine.test.ts

test/resilienceHardening.test.ts

test/resilientTts.test.ts

test/sampleConfig.test.ts

test/scheduler.test.ts

test/stopRecordings.test.ts

test/synthDedup.test.ts

test/triggerHardening.test.ts

test/triggerJobsParity.test.ts

test/ttsCapabilities.test.ts

test/ttsClients.test.ts

test/ttsFailover.test.ts

test/ttsNewClients.test.ts

test/ttsSsml.test.ts

test/ttsSwitch.test.ts

test/variableParity.test.ts

test/vilniusRequirements.test.ts

test/volume.test.ts


CreatorStudio — Authoring App + Engine

Two apps, one product, both in this repo and served from a single portal:

The authoring app designs the announcements; the engine runs them in production, headlessly, for every vehicle; the dashboard watches it. All three run behind one portal (npm run dashboardhttp://localhost:8080): / landing menu · /creator/ authoring app · /monitor/ engine dashboard — see Unified portal.

📖 Documentation Center — the one entry point for all docs. In the running portal: http://localhost:8080/docs (consolidated handbook, decks, leave-behinds, raw sources). In the repo: docs/README.md is the same index.

Software architecture (modules, services, APIs, diagrams) — see ARCHITECTURE.md. Scaling to a large fleet (2000-vehicle plan, bottlenecks, phases) — see SCALING.md. Requirement specification (the complete as-built requirement catalogue) — see PRD.md.

Repository layout

src/            Engine (Node/TS): MQTT → trigger engine → TTS → ADT audio + LED signs
web/            Angular workspace — two apps + the shared Figma/PrimeNG theme:
  web/          →  Engine dashboard (/monitor/): monitor cockpit · diagnostics · proof · history
  web/creator/  →  CreatorStudio authoring app (/creator/): playlists · LED signs · triggers · settings · simulator
  web/src/theme/   the Figma preset (@primeuix/themes), shared by both apps
audiocreator/   Original React/Vite CreatorStudio — retired standalone source (not served by the portal)
db/             DB source of truth: migrations/ (0001_baseline.sql — engine_config, projects,
                config_versions, proof_of_play), applied by `npm run db:migrate`
scripts/        Ops & dev tools: portal/dashboard server (incl. the Creator API), broker, bridge, simulators, demos
config/         On-disk announcement-config.json (engine fallback when the live config source is off)
public/         Portal pages served as-is: portal.html (landing) · docs.html (Documentation Center) · player.html
docs/           Documentation index (README.md) · media/ screenshots · showcase/ decks · notes/
test/           Engine test suite (node:test) — 67 files, 581 tests; see TEST-REPORT.md

Both front-ends build from the web/ Angular workspace into one portal — see Unified portal.

How it works

From a live trip signal to sound on the bus and pixels on the signs — one pass through the engine:

miss → synthesize

engine/played · ack when the clip finishes

key = hash(text + voice)

CreatorStudio Engine — one always-on process

PisTriggerEvent
approaching-stop · doors-open · detour…

MqttBus · subscribe

VehicleContext
merge retained topics → JourneyState
per-vehicle · in memory

PtTriggerEngine
edge-detect on state change
debounce · gates · thresholds

pipeline
trigger → playlist(s)
priority · sequences · repetitions

renderPlaylist
resolve {variables} · merge speech runs
→ MP3 + transcript

buildAdtPayload · ledRender · ffEncoder
ADT 4.x audio + LED-sign bitmaps

🛰️ Live PIS-PT trip data
{tenant}/{vehicleId}/pis/0/#
(retained · multi-topic)

☁️ TTS provider
Azure · Acapela · ElevenLabs

Layered MP3 cache
memory → disk → Azure Blob
one synth per phrase, fleet-wide

🔊 …/pis/0/tts
ADT audio → vehicle player

▦ …/display · …/display/ff
interior + exterior LED

🧾 proof-of-play
durable audit trail

Only state-changing topics wake the trigger engine (the high-frequency GNSS firehose is gated), identical phrases are synthesized once for the whole fleet and reused from the cache, and the played-ack closes the loop so the audit records what a passenger actually heard — not just what was dispatched.

Per-vehicle state lives in memory (the trigger engine is a state machine over the message stream), so this is a single long-running process — deliberately, since a persistent MQTT subscription can't live in a serverless function.

MQTT-PIS-PT input

Trip data arrives as the MQTT-PIS-PT protocol (spec in MQTT/). It is not one flat message — data is spread across retained topics under {tenant}/{vehicleId}/pis/0/.... The engine subscribes to +/+/pis/0/#, keeps a per-vehicle context (src/pis/ptContext.ts) that merges the topics, and derives trigger events (src/pis/ptEngine.ts) from discrete signal changes.

The engine implements the full CreatorStudio trigger set (33 types), kept in sync with the exported audio-engine-manifest-*.json (a test fails if the app adds a type the engine doesn't implement). The MQTT-driven ones:

Trigger Derived from
journey-not-in-traffic / -countdown / journey-activated / journey-running / journey-offroute / journey-approaching-last-stop / journey-arrived-at-destination journeystate.state transitions
trip-start journeystate → JOURNEY_RUNNING (+ first sighting)
approaching-stop linkprogress.callSequenceNumber changes (name from list/stops)
arrived-at-stop stopinfo.type = ARRIVAL
departing-stop stopinfo.type = DEPARTURE or PASSAGE (announces the upcoming stop)
stop-skipped linkprogress.callSequenceNumber jumps forward by >1
doors-open / doors-close sensors/door.doorOpen rising / falling edge
last-stop journeystate → JOURNEY_APPROACHING_LAST_STOP
stop-request sensors/stop_button.stopPressed false→true
detour journeystate → JOURNEY_OFFROUTE, or a connections.situationMessages appears
connection-info connections.connections[] becomes non-empty
situation-message connections.situationMessages[] appears (the sole service-alert path)
alarm-activation alarm_activation payload appears
destination-override destination/override.name set
exit-side vehicle/exit_sides.exitSide = Left/Right/Both
passenger-load passenger_load worst-car occupancy crosses the threshold
time-to-stop / time-after-stop linkprogress.expectedArrivalTime / time since last DEPARTURE crosses threshold
distance-to-stop / distance-before-stop / distance-after-stop linkprogress distance thresholds
speed vehicle/gnss_location.speedOverGround vs the condition
geofence vehicle/gnss_location position vs the geofence centre/radius

bus-type is evaluated inline; stationary-display and volume-calibration are local-only (kiosk/technician), accepted for config import but not auto-fired.

Dynamic variables ({nextStop}, {currentStop}, {destination}, {skippedStop}, {exitSide}, {situationHeading}, {alarmText}, {followingStop}, {connectingServices}, {distanceToStop}, {currentSpeed}, …) are resolved from the aggregated context, language-aware via name_Multilanguage. Announcements go out on {tenant}/{vehicleId}/pis/0/tts.

Predictive pre-rendering. As a vehicle progresses, the engine warms the TTS cache for the next PRERENDER_LOOKAHEAD stops' announcements in the background (render-and- discard, deduped per journey/stop). When the real trigger fires, the audio is already cached — each stop plays with ~0 TTS latency and cold synths collapse under fleet load. Volatile playlists (containing {currentTime}, {distanceToStop}, …) are skipped since their text can't be predicted. Verify it with npm run prerender:demo.

Closed-corpus pre-push (opt-in: PREPUSH_ENABLED=true). Goes one step further: the moment a journey's stop list is known, the engine renders the journey's whole announcement corpus (every remaining stop, plus journey-constant playlists once) and delivers the clips to the vehicle players' on-device cache on {tenant}/{vehicleId}/pis/0/tts/cache. The pushed clipKey is byte-identical to what the live trigger later publishes, so an announcement can go out key-only — a few hundred bytes over the link instead of the audio. Tuning: PREPUSH_CONCURRENCY (default 1), PREPUSH_MAX_PER_JOURNEY (default 400). See clients/README.md for the player side. Not an offline guarantee: the trigger decision still crosses the WAN, so a fully dead link means no announcement regardless — the pre-push helps a degraded link and announcement latency. For genuinely offline operation run the engine on the vehicle (see ON-VEHICLE.md), where triggers never leave the bus.

Interior LED sign. For every announcement routed to the interior speaker (outputType interior/both), the engine rasterizes the spoken text onto a 16-row × 144-column monochrome amber bitmap and publishes it to {tenant}/{vehicleId}/pis/0/display (same traceId as the audio). Text wider than 144 px is centered-off and flagged scroll for the sign to pan across at DISPLAY_SCROLL_SPEED px/s. The payload is a self-describing 1-bpp bitmap (format: bitmap-1bpp, base64 data, geometry + scroll metadata); an ff encoder is scaffolded for the Luminator FF frame protocol once its spec is available. The font is a fill-height 5×7 doubled to 16 rows and covers the accented letters the multilingual announcements need (å ä ö ü é …). Verify with npm run led:demo (prints the published sign as ASCII-art) and watch it live on the dashboard Interior LED sign panel.

Example …/display payload:

{ "traceId":"…", "format":"bitmap-1bpp", "color":"amber", "rows":16, "cols":144,
  "viewportCols":144, "packing":"row-major-msb", "scroll":false, "scrollSpeedPxPerSec":30,
  "text":"Next stop Centralstationen", "textWidthPx":208, "data":"<base64 1-bpp>" }

Exterior destination signs. The engine also renders the front / side / rear headsigns — all 24px high but in different widths (front 192 / side 160 / rear 48 px, configurable). Front/side show a fixed route number on the left (never scrolls, full height) plus the destination in the remaining area; the rear shows just the route (the public line number from the destination topic). The destination is shrunk to fit its area (condensed + smaller text) and only scrolls if it still doesn't fit at the smallest size. These signs are persistent: published retained to {tenant}/{vehicleId}/pis/0/display/exterior only when the vehicle's route/destination changes. The dashboard shows all three in the Exterior LED signs panel (below the interior sign), and npm run led:demo prints them as ASCII-art.

Mobitec FF protocol (graphic font w). Alongside the self-describing bitmap payloads above, the engine also emits the raw Mobitec FF frame (driver board 1463-L, spec in FF/) for a serial (RS-485) gateway, on separate topics:

Each frame is 0xFF · address · sign-data · checksum · 0xFF. The rendered bitmap is carried with graphic font w (font id 0x77): every byte 0x20–0x3F is one vertical column of 5 dots (LSB = top), so a sign taller than 5 is drawn in stacked 5-row bands. The checksum is the escaped (address + sign-data) mod 0x100 (§4). The payloads are raw bytes (not JSON) so a gateway can write them straight to the bus. npm run ff:demo drives a journey, splits + decodes the published frames, validates every checksum, and prints the reconstructed signs as ASCII-art. Addresses, topics, and enable are configurable (DISPLAY_FF_*, MQTT_*_FF_TOPIC_PATTERN).

Hanover protocol (HCPS + SuperX) — vendor-independent sign control. The same rendered bitmaps also drive Hanover LED destination signs (spec set in Hanover/), published in parallel with FF on separate topics so mixed Luminator + Hanover fleets run from one pipeline:

Each frame is STX(0x02) · command '0' · address (one hex digit, the sign's DIP/rotary setting) · SuperX message · ETX(0x03) · checksum (two ASCII-hex chars; two's complement of the byte sum after STX through ETX). The bitmap travels as a SuperX picture {\mode0{\pic\picwW\pichH HEX}}, packed column-major with LSB = top row per 8-row band — both the checksum and the packing are verified byte-exact against the worked examples in the vendor docs. Configurable via DISPLAY_HANOVER_* and MQTT_*_HANOVER_TOPIC_PATTERN (enabled by default). The full integration guide — hardware hookup, frame splitting, the infohub bridge, verification status — is HANOVER-SIGNS.md (its own chapter in the handbook).

Live upstream feed (independent — no Node-RED)

The engine consumes PIS data from its local broker (MQTT_URL). To feed it live fleet data without an external Node-RED flow, run the built-in bridge (scripts/bridge.ts). By default it follows the dashboard's selection: it mirrors the full {tenant}/{vehicleId}/pis/0/# stream (retain preserved, engine-output subtopics tts,audio,display skipped) for only the vehicle(s) the dashboard selects. Alongside that it runs lightweight fleet discovery — mirroring just the retained journeystate and destination topics for every vehicle so the fleet directory and tenant/vehicle pickers populate without forwarding the whole broker (UPSTREAM_DISCOVERY_TOPICS; the heavy list/stops payloads are deliberately excluded). A tenant scope (engine/control/tenant, set from the dashboard at runtime, or UPSTREAM_TENANT to pre-scope at startup) narrows discovery from all tenants to one so it stops pulling the whole broker — for a multitenant fleet, run one engine+bridge per tenant. Unchanged re-publishes are dropped (UPSTREAM_DEDUP), and the bridge publishes retained health telemetry to engine/bridge (up/down, mode, tenant, bridged vehicles, throughput) for the dashboard's bridge-status strip. Set UPSTREAM_FOLLOW_SELECTION=false to instead bridge a static UPSTREAM_MQTT_TOPIC for the whole fleet.

# set UPSTREAM_MQTT_URL / _USERNAME / _PASSWORD in .env, then:
npm run bridge          # upstream → local, one-way
# or the whole live stack in one terminal:
npm run stack:live      # broker + bridge + engine + dashboard

Luminator suite brokers live at mqtt.{na,eu,qa,dev}.suite.luminator.com:1883 (plaintext, MQTT v4); na carries the North-America fleet (e.g. baltimore-md-mta, matching the TFT env=na). Leave UPSTREAM_MQTT_URL empty to disable the bridge. See scripts/bridge.ts.

TTS resilience

The TTS provider is wrapped in a resilience layer (src/tts/resilient.ts, on by default): retries transient synth failures with exponential backoff + jitter, trips a circuit breaker after repeated failures (fast-failing instead of hammering a dead service, then half-opening after a cooldown — admitting a single probe (not a recovery stampede) — to recover), and falls back to a pre-recorded clip (TTS_FALLBACK_AUDIO) so an Azure outage/rate-limit/bad-key never drops an announcement — and because the LED signs render from text, the visual announcement still goes out. Auth errors (401/403) skip retries. Breaker state, fallback and retry counts appear in engine/health (dashboard Health tab). Tune via TTS_* env vars (see .env.example).

Identical announcements requested at the same instant are coalesced by single-flight (TtsCache.getOrSynth): a fleet-wide cache miss (many buses hitting the same "Next stop…") triggers one synth, not hundreds — the rest await it (synthCoalesced on Health). The on-disk cache is bounded by CACHE_MAX_FILES (default 20000; 0 = unlimited) — oldest MP3s are pruned by mtime so the always-on worker can't fill the disk.

CreatorStudio (authoring app)

The CreatorStudio — where playlists / triggers / conditions / voices / speaker routing / pronunciations are authored — is an Angular 20 + PrimeNG app under web/creator/ (a second project in the web/ workspace), themed with the Figma preset so it matches the engine dashboard exactly.

Authoring is organised into Projects — named, self-contained configs (playlists, triggers, voices, volume, lexicon), with exactly one project published per (tenant, fleet) at a time. The selected project is the working editor, so every screen operates on it. Persistence is API-first — same-origin reads/writes of the projects table through the portal's Creator API (GET/PUT/DELETE /api/projects, see scripts/lib/creator-api.ts) — with a localStorage fallback that also covers offline dev (a visible "Offline — local only" chip shows when the API is unreachable); a legacy single config migrates into a "Default" project automatically. Publishing sends the selected project's config to the engine via POST /api/publish (one transaction: the engine_config upsert — which also records engine_config.published_project_id — plus a monotonic config_versions row). See web/creator/app/services/{project,tenant,config,publish}.service.ts.

Multitenant: the active tenant is resolved by TenantService (?tenant= URL param → window.__AUDIO_SUITE_TENANT__ set by a host shell → last used → default), and all project storage/queries/writes are scoped by it, so each tenant gets a fully separate project set.

Migration note: the original React/Vite CreatorStudio (from Lovable) still lives under audiocreator/ and can be run/built standalone (npm run cs:dev on :8090, npm run cs:build, deployed at audiocreator.lovable.app). It is no longer served by the portal — the Angular app replaced it at /creator/. Kept in-repo as the source of truth for the engine's trigger-parity guard and for reference; the swap is reversible.

Unified portal (one app, one menu)

Both apps (now both Angular) are served from the single dashboard server on http://localhost:8080 behind a landing-page menu — no separate ports or origins:

URL App
/ Redirects to the Engine monitor (/monitor/#/monitor) — the default screen
/portal Landing page (public/portal.html) — the menu
/creator/ CreatorStudio authoring app (Angular · web/creator/)
/monitor/ Engine dashboard (Angular · web/)
/player Vehicle player — any phone/tablet becomes a bus speaker (no install)
/docs Documentation Center — the handbook (live from the repo's Markdown), decks and raw sources
npm run apps:build     # build BOTH Angular apps: web (baseHref /monitor/) + creator (baseHref /creator/)
npm run dashboard      # serve the portal on http://localhost:8080

One suite: both apps carry a persistent left rail (brand → portal, plus Creator, Engine and Player buttons with the current app highlighted, and the Documentation Center set apart at the foot), so switching is always one click in the same place. The landing cards open the same four. Both share the Figma/PrimeNG design system (@primeuix/themes preset from UX/figma-theme, vendored to web/src/theme/figma-preset/), default to its dark scheme, and render the same PrimeNG components — so look-and-feel is identical.

Mounting is done at build time: each Angular app sets its baseHref (/monitor/ and /creator/) in its angular.json project. Shared API routes (/config, /history, /led-font.json, /architecture.md) and the ws://…:9001 broker stay at the origin root, so the dashboard's absolute fetches and WebSocket keep working under /monitor/. Old /#/…, /ng/…, and the migration's temporary /creator-ng/… links all redirect to their new homes.

The Creator's API config (apiBaseUrl, default '' = same origin — no keys in the bundle) has a built-in default in web/creator/environment.ts, overridable at runtime with no rebuild via window.__AUDIOCREATOR_ENV__ — which the portal server injects into /creator/ from CREATOR_API_URL / CREATOR_ENGINE_TENANT / CREATOR_ENGINE_FLEET (see dashboard-server.ts). So the same bundle can target a local stack, staging, or prod. The CreatorStudio's pis/ linter, triggerEngine, and triggerMqttReference are the source of truth the engine's trigger-parity guard (npm run trigger:parity) validates against.

Note: audiocreator/src/types/announcement.ts still contains a hardcoded dev broker password in DEFAULT_MQTT_CONFIG (carried over verbatim from the source). Rotate/remove it if that broker matters — it ships in the client bundle.

Configuration source

The fleet's playlists / triggers / voices are authored in the CreatorStudio UI. There are two ways to get that config into the engine:

Which config wins — and which one the UI shows

Three different things hold a config, and confusing them wastes an afternoon:

Where What it is Who reads it
projects table the authoring source — one row per named project the Creator's playlist list renders this
engine_config table the published output — publishing copies a project's config here the engine and the dashboard's /config
config/announcement-config.json the offline fallback (gitignored) the engine, but only when the live source is off or empty

Reads are remote-first: ConfigStore (and the dashboard's GET /config) try the database and fall back to the file. So while DATABASE_URL is set, editing the on-disk file changes nothing you can see — the published row shadows it. And because engine_config is the output of a project, writing there does not change what the Creator lists; edit the project, then publish.

stack:local points the engine at the local Postgres too, not at your .env. It sets DATABASE_URL to postgres://postgres:postgres@127.0.0.1:5432/creatorstudio for every process it starts. If your .env names a hosted database, that database is not what a stack:local run reads or writes. Check how the stack was launched before you publish anything.

Example playlist library

config/announcement-config.sample.json is a ready-to-run worked example — 22 playlists covering the whole journey, each already wired to the trigger that fires it. Copy it to CONFIG_PATH and the engine has something to say from the first message.

node scripts/build-sample-config.mjs                  # regenerate the library (stable ids, no diff churn)
node --import tsx scripts/preview-sample-config.mjs   # print what every example actually says

Onward connections (the richest set — the feed carries real-time connection data):

Playlist Fires when Says
Onward Connections connection-info "At Central Station, you can change for CityLink SILVER to Curtis Bay"
Connection Delayed delay ≥ 1 min, < 10 min "One of your onward connections is delayed by 6 minutes…"
Connection Major Delay delay ≥ 10 min "An onward connection is delayed by more than ten minutes…"
Connection Cancelled any cancelled "The following onward connection has been cancelled: the 22 to Essex…"
Onward Connections — with platform connections and a platform is known (example, off) "…departing from platform B."
Onward Connections — English + Spanish connections (example, off) the whole announcement repeated in Spanish, in a Spanish voice

The three real-time variants are custom (fact-based) triggers — the engine has only one built-in connection-info trigger, so the delayed/cancelled/platform cases are expressed as conditions on connectionDelayed, maxConnectionDelaySec, connectionCancelled and connectionPlatformKnown.

The platform example is gated on connectionPlatformKnown on purpose. {connectionPlatform} is an optional variable: when the feed omits it, it resolves to empty — and an ungated playlist would cheerfully announce "…departs from platform ." to a bus full of passengers.

The last two ship disabled: the built-in connection-info trigger already announces connections, so enabling one of them as well would announce the same connections twice. Turn one on instead.

The rest of the journey — one example per trigger, all enabled: Welcome Aboard (chime), Exterior Route Call, Arrived at Stop, Departing Stop, Stop Requested (chime), Doors Opening / Closing (chime), Exit Side, Approaching Final Stop, Arriving at Final Destination, Stop Not Served, Detour, Crowded Vehicle, Service Alert (chime + {situationHeading}/{situationBody}), and Emergency Alarm (priority 10 — it interrupts whatever is playing rather than queueing behind it).

test/sampleConfig.test.ts keeps the library honest: every trigger resolves to a real playlist, every {variable} is one the resolver knows, every chime file exists, and every playlist renders to real speech with no unfilled variables — a playlist that would be silently dropped in production fails the build instead.

Adding the examples to a config you already have

Copying the sample over CONFIG_PATH replaces everything, which is no use once you have a real config. These scripts add the 22 playlists to an existing config without wiring a single trigger, so they show up as ready-made examples to copy from and nothing new announces:

npm run config:add-examples                          # the on-disk file at CONFIG_PATH
npm run config:project-examples:local -- --project Baltimore   # a Creator project (what the UI lists)
npm run config:publish-examples:local                # the published engine_config row

Pick the target from the table above — for the examples to appear in the Creator, they have to go into the project; engine_config is only the published output.

This is safe by construction: a Playlist has no trigger field, and the engine only ever walks trigger → playlist, so a playlist nothing references is never rendered. The scripts enforce it rather than assume it — triggers and customTriggers are asserted byte-identical before anything is written, and the write is refused if a trigger moved.

test/mergeSamplePlaylists.test.ts pins the guarantee: triggers deep-equal the original, every sample playlist lands, none is referenced by any trigger, and a second run is a no-op.

Configuration coverage

The engine honours (essentially) everything CreatorStudio can author:

Intentionally not engine-driven: stationary-display and volume-calibration triggers (local kiosk/technician, not MQTT-fired) and their params; requiresDirection (no forward/reverse signal in PIS-PT today, so it never blocks); dialect (the Azure voiceId already encodes the locale); and parentPlaylistId language-variant auto-selection (multilingual is handled via name variants within a playlist). MQTT transport settings stay UI-only — the engine owns transport.

Setup

npm install
docker compose up -d          # local Mosquitto broker on localhost:1883 (+ ws:9001)
cp .env.example .env          # set credentials for your TTS_PROVIDER (default azure → AZURE_SPEECH_KEY/REGION)
cp config/announcement-config.sample.json config/announcement-config.json
npm run dev                   # watch mode
# or
npm run build && npm start

The default MQTT_URL is mqtt://localhost:1883. docker compose up -d starts a local Mosquitto broker (anonymous, dev-only) that both the engine and the simulator connect to. Point MQTT_URL at your real broker for staging/production.

No Docker? Run npm run broker in a separate terminal — it starts an in-process aedes broker on localhost:1883, logging every publish. Handy for a self-contained local run.

Fully local (no cloud / no Lovable)

The entire database schema lives in db/migrations/ (plain SQL, applied by scripts/db-migrate.ts), so the backend runs 100% locally against a stock Postgres container — no dependency on any hosted project:

npm run db:start        # starts a plain postgres:16 docker container on 127.0.0.1:5432
npm run apps:build      # build the Angular apps once
npm run stack:local     # broker + bridge + engine + dashboard, one command — it applies the
                        #   migrations first and points every process at the local Postgres
# open http://localhost:8080/creator/
npm run db:stop         # remove the container when done

stack:local points everything at the local Postgres: it sets DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/creatorstudio for every process it starts, and the Creator talks same-origin to the portal's /api (which uses that same database). Your .env's DATABASE_URL is ignored for the duration — so a hosted database named there is neither read nor written by a stack:local run. Use stack:dev if you want the engine on your .env target.

Works from other devices too: the Creator calls the portal same-origin (/api), so a phone or iPad that opens http://<your-ip>:8080/creator/ sees the same projects as the dev machine — there is no per-device backend URL to get wrong. Only if the API is truly unreachable does the Creator fall back to that browser's private localStorage (and says so in the header).

Verify TTS on its own

npm run tts:test -- "Next stop is Central Station"

Synthesises one line with the configured provider and writes tts-test-output.mp3. Good first check that your Azure key/region/voice work before involving MQTT.

Web dashboard (simulator + monitor)

npm run stack:ui     # broker + engine + dashboard in one terminal
# then open http://localhost:8080  → the portal landing; Monitor is under /monitor/

The dashboard server is the unified portal (see Unified portal above): / is the menu, /monitor/ the engine dashboard, /creator/ the CreatorStudio. Run npm run apps:build once so both apps are built before serving.

A browser dashboard that talks to the broker over WebSocket (ws://localhost:9001). Six menu entries — Monitor (the cockpit: map, fleet/vehicle feed, and a drawer with the source controls, the raw PIS-PT table and the live LED/TFT displays), Performance & Health, Proof of Play, History, Play on a device and Published config (read-only: what CreatorStudio last published — the dashboard has no settings of its own). The old standalone Live / Map / LED Signs pages are panels inside the cockpit now and their routes redirect; documentation moved out to the Documentation Center, linked from the suite rail:

On Windows: .\start.ps1 -Ui starts broker + engine + dashboard in separate windows and opens the browser. (The dashboard replaces the CLI simulator and player — it does both in the browser.)

Source modes & vehicle selection. The Source panel toggles Simulator (inject synthetic PIS-PT) vs Live (consume real data). In Live mode a searchable bus list is discovered from retained journeystate topics. First pick a tenant (scopes the bridge to that tenant only — see the bridge section), then tick one or more buses to build the served set:

  1. the focused vehicle's full data ({tenant}/{vehicleId}/pis/0/#) is subscribed for the detail views, and
  2. the dashboard publishes {"vehicleKeys":["tenant/id", …]} to SELECTION_TOPIC so the engine renders audio for exactly those vehicles.

Under the default RENDER_SCOPE=selection an empty served set means the engine renders nothing (production-safe) — not the whole fleet. The selection is retained, so it survives an engine restart; Clear all empties the served set (the engine goes silent until you opt vehicles back in). RENDER_SCOPE=all (load-test only) ignores the selection and serves every fully-tracked vehicle.

Run the whole demo stack (one command)

npm run stack        # broker + engine + player in one terminal; Ctrl-C stops all
npm run simulate -- VEH-001   # in a second terminal: send a journey

stack runs the local broker, the engine (WAV output for reliable local playback), and the onboard vehicle player (which plays announcements on this PC's speakers) together with color-coded logs. Ctrl-C kills all three — no orphan processes.

On Windows you can instead launch each part in its own window:

.\start.ps1              # broker + engine + player, separate windows
.\start.ps1 -Simulate    # also send a VEH-001 journey

Onboard vehicle players

Three clients, one contract — each plays one vehicle's announcements and acks what it actually played (see clients/README.md):

Client Runs on Use it when
Goclients/go x86-64 · ARM64, headless The onboard unit. Single static binary, no runtime.
Pythonclients/python x86-64 · ARM64, headless A Pi; easiest to tweak in the field.
Browser/player Any phone/tablet/laptop Demos and spot-checks. No install.

Both headless clients ship as multi-arch Docker images (docker buildx build --platform linux/amd64,linux/arm64), and need --device /dev/snd to reach a speaker.

Browser player — any device (/player)

npm run play only runs on Windows (it shells out to PowerShell to play the clip). For a real vehicle — or a phone, tablet, Raspberry Pi or the vehicle's own head unit — open the browser player instead. It is a single page with no install step.

Finding it in the app, easiest first:

http://<host>:8080/player?tenant=baltimore-md-mta&vehicle=14004

It subscribes to that one vehicle's {tenant}/{vehicleId}/pis/0/tts over MQTT-over-WebSocket (the broker's ws://<host>:9001 listener — it talks to the broker directly, not through the dashboard server), decodes the base64 clip out of the ADT 4.x payload and plays it immediately. Clips are queued, so announcements never overlap. speakers.INTERNAL sets the volume.

Because it is the vehicle, it closes the proof-of-play loop: when a clip finishes it publishes a played-ack to engine/played (traceId, tenant, vehicleId, playedAt, status) — so the audit trail records what was actually played, not merely dispatched. Same contract as scripts/vehicle-player.ts.

Notes:

Individual components

Command What it does
npm run broker Local MQTT broker (aedes) on localhost:1883 (+ ws 9001)
npm run dev Engine (MP3 output)
npm run dev:wav Engine (WAV output — simplest for local playback)
npm run play -- VEH-001/pis/0/tts Vehicle audio player → PC speakers (Windows only; see /player below for any device)
npm run simulate -- VEH-001 Publish a synthetic PT-PIS journey
npm run dashboard Unified portal server on localhost:8080 (/ menu · /monitor/ dashboard · /creator/ CreatorStudio)
npm run apps:build Build both Angular front-ends for the portal (dashboard /monitor/ + Creator /creator/)
npm run tts:test -- "text" One-off TTS synth check
npm run bridge Upstream→local PIS bridge (follow-selection + tenant/fleet discovery + dedup)
npm run multi:demo -- N [select] Multi-vehicle proof — drives N vehicles concurrently; select mode proves served-set isolation
npm run bridge:test Exercises the bridge (dedup, discovery exclusion, tenant scoping, health)
npm run config:test Smoke-test the live config read/LISTEN path against DATABASE_URL
npm run config:add-examples Add the 22 example playlists to the on-disk config — no triggers, nothing announces
npm run config:project-examples:local -- --project NAME Same, into a Creator project (what the UI lists). Dry-run; add --apply
npm run config:publish-examples:local Same, into the published engine_config row. Dry-run; add --apply
npm run docs:html -- FILE.md FILE.html Render a doc to one self-contained HTML page (mermaid pre-rendered to SVG)
npm run stop Kill previous engine/player/simulator/dashboard — broker stays up

Restarting cleanly (keep the broker running)

Stopping a background npm run … can leave its child process alive, which stacks up duplicates (two engines → doubled announcements; two dashboard tabs → echo). To avoid that:

Try it without a live feed

npm run simulate -- VEH-001 publishes a synthetic journey (approaching → at-stop → … → final) for one vehicle. Watch the engine fire triggers and publish announcements to VEH-001/pis/0/tts.

Environment

See .env.example. Key variables:

Var Purpose
MQTT_URL Broker URL; scheme sets the transport (mqtt://, mqtts://, ws://, wss://)
MQTT_INBOUND_TOPIC PT-PIS subscription filter (vehicleId is read from the payload)
MQTT_TTS_TOPIC_PATTERN Outbound topic; {vehicleId} is substituted
CONFIG_PATH Exported AnnouncementConfig JSON (hot-reloaded)
TTS_PROVIDER azure, acapela, elevenlabs, or mock (silent, offline — load tests/CI)
TTS_FALLBACK_PROVIDER Failover supplier: rendered through while the primary's circuit breaker is open, so a vendor outage speaks in another voice instead of the fallback tone. A published config.ttsFallbackProvider (Creator → Voices) overrides it. Unset = no failover.
AZURE_SPEECH_KEY + AZURE_SPEECH_REGION Azure Speech resource (F0 free tier works)
AZURE_SPEECH_VOICE Neural voice, e.g. en-US-JennyNeural, sv-SE-SofieNeural
ACAPELA_BASE_URL + ACAPELA_TOKEN (or ACAPELA_EMAIL/ACAPELA_PASSWORD) Acapela credentials
ELEVENLABS_API_KEY Required only when TTS_PROVIDER=elevenlabs
CACHE_BLOB_SAS_URL Optional shared MP3 cache tier: an Azure Blob container SAS URL (objects tts-cache/<sha256>.mp3)
AUDIO_BASE_URL Where to fetch relative /audio/... chime files from
RENDER_SCOPE Which vehicles get audio: selection (default, production-safe — only the dashboard's served set; empty = silent) or all (load-test — every fully-tracked vehicle). Any other value fails at startup.
CACHE_MAX_FILES On-disk TTS cache cap (files, default 20000; 0 = unlimited). Oldest MP3s pruned by mtime.
SELECTION_TOPIC / TENANT_TOPIC Control topics the dashboard publishes: served set (engine/control/select) and tenant scope (engine/control/tenant)
UPSTREAM_MQTT_URL (+ _USERNAME/_PASSWORD) Upstream PIS broker for the bridge (empty = bridge disabled)
UPSTREAM_FOLLOW_SELECTION Bridge follows the dashboard selection (default true); false = bridge a static UPSTREAM_MQTT_TOPIC
UPSTREAM_TENANT Pre-scope the bridge to ONE tenant at startup (empty = browse/discover every tenant). For a multitenant fleet, run one engine per tenant.
UPSTREAM_DISCOVERY_TOPICS / UPSTREAM_DEDUP Fleet-discovery topics (default journeystate+destination) and byte-identical re-publish dedup
MQTT_FLEET_TOPIC / HEALTH_TOPIC / BRIDGE_HEALTH_TOPIC Directory (engine/fleet), engine health (engine/health), bridge health (engine/bridge)
DATABASE_URL Live config source: the Postgres connection string the engine reads engine_config from, with LISTEN for pushes (empty = file-only).
CONFIG_API_URL (+ CONFIG_API_TOKEN) Vehicle alternative when the DB is unreachable: the portal's GET /api/engine-config, refreshed by the retained MQTT config notify.
CONFIG_DB_TENANT_VALUE Multitenant opt-in: engine reads only its own (tenant, fleet) row (empty = single-tenant; not from UPSTREAM_TENANT).
CONFIG_SOURCE_ENABLED false disables the live source outright, so the on-disk CONFIG_PATH file wins. Default true whenever a source is configured.
CONFIG_DB_TABLE / _COLUMN Where the published config lives. Default engine_config / config.
CONFIG_DB_FILTER_COLUMN / _FILTER_VALUE Which row is this engine's. Default fleet / default.
DASHBOARD_PORT Portal server port (default 8080).
MQTT_WS_PROXY Dashboard reaches the broker through a same-origin relay on the dashboard port (default true): browser ⇄ server ⇄ broker, credentials server-side. false = the browser connects to the broker directly.
DASHBOARD_WS_URL / BROKER_WS_PORT Only for the direct path (setting DASHBOARD_WS_URL also disables the relay): the broker URL the browser connects to (default ws://<page host>:9001; {host} is substituted client-side). That port must then be reachable from the operator's network, and an https page needs wss://. Per-browser override: ?broker=ws://host:port.
DASHBOARD_WS_MAX_CLIENTS Simultaneous dashboards through the relay (default 20); each holds one upstream broker connection.
CREATOR_API_URL / CREATOR_ENGINE_TENANT / CREATOR_ENGINE_FLEET Injected by the portal into /creator/ — only needed when the Creator should talk to a different portal (default: same origin, no config).

Diagnostics — "it's deployed, but nothing happens"

The engine fails quietly by design: an unreachable broker is retried forever, an unpublished config falls back to the on-disk file, and a vehicle nobody selected is simply not served. Each is correct on its own, and none of them logs an error — which is why a healthy-looking deployment can be incapable of announcing anything. doctor walks the whole chain and names the broken link:

npm run doctor                                    # on the host
docker exec -it audiocreator-apps npm run doctor  # all-in-one container
npm run doctor -- --json                          # machine-readable
curl -s http://<host>:8080/diag | jq              # same checks over HTTP (no shell needed)

It checks, in order: broker TCP → MQTT session → WebSocket listener (what the browser needs) → config has enabled triggers → engine heartbeat → served set → live PIS traffic. The first failure is normally the only real one. The two that account for most deployments:

Note that triggers are edge-detected: a vehicle whose retained state never changes emits no events even when everything above is green.

Corporate TLS inspection

On networks that do HTTPS inspection (a proxy re-signs traffic with an internal root CA), Node rejects the re-signed certs with UNABLE_TO_GET_ISSUER_CERT_LOCALLY even though browsers work. All npm scripts run Node with --use-system-ca, so Node trusts the OS certificate store (the same one the corporate root lives in).

If you run Node directly without that flag, either add --use-system-ca yourself or set NODE_EXTRA_CA_CERTS=/path/to/corp-root-ca.pem. In cloud/Docker there is usually no inspection, and --use-system-ca simply falls back to the OS defaults.

Deployment

The same image (Dockerfile) runs two ways — only the environment differs. It's multi-arch: node:22-slim and the engine's pure-JS deps mean one build serves both x86-64 back-office hosts and ARM64 onboard units (Raspberry Pi and friends):

npm run engine:image                       # single-arch image for this machine
npm run engine:image:multiarch             # buildx: linux/amd64 + linux/arm64
# or: docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/creatorstudio-engine --push .

Back office (fleet)

Long-running container. Any always-on host works — a small VM, a container service (Fly.io, Render, Azure Container Apps, ECS), or a Kubernetes Deployment with replicas: 1. Config from the Postgres database (DATABASE_URL), dashboard picks the served set (RENDER_SCOPE=selection).

Scaling note: the trigger engine keeps per-vehicle state in memory, so run a single replica per inbound stream. To scale horizontally, shard vehicles across instances by subscribing each to a disjoint topic filter, or move per-vehicle state into a shared store (Redis) first.

On a vehicle (onboard unit)

The engine can also run on the vehicle itself — the whole audio stack on one onboard unit: local MQTT broker · the engine · the audio player. Because the local broker carries only the local vehicle, RENDER_SCOPE=all serves that one bus with no dashboard and no selection — a self-contained unit. Full step-by-step guide: ON-VEHICLE.md. docker-compose.vehicle.yml wires it together:

VEHICLE=14004 TENANT=baltimore-md-mta \
  AZURE_SPEECH_KEY=… AZURE_SPEECH_REGION=… \
  docker compose -f docker-compose.vehicle.yml up -d --build
# or: npm run vehicle:up   ·   npm run vehicle:logs   ·   npm run vehicle:down

The vehicle's PIS-PT system publishes {tenant}/{vehicleId}/pis/0/# to the local broker; the engine renders speech + LED-sign content and publishes it back; the player makes the sound (needs --device /dev/snd — provided by the compose).

Known limitations / next steps