# 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

- [Overview & Benefits](#overview-benefits) — `OVERVIEW.md`
- [Screens & Features](#screens-features) — `SCREENS.md`
- [Product Requirements (PRD)](#product-requirements-prd) — `PRD.md`
- [Functional Specification](#functional-specification) — `FUNCTIONAL-SPEC.md`
- [Architecture](#architecture) — `ARCHITECTURE.md`
- [Scaling](#scaling) — `SCALING.md`
- [On-Vehicle Deployment](#on-vehicle-deployment) — `ON-VEHICLE.md`
- [Hanover LED Signs](#hanover-led-signs) — `HANOVER-SIGNS.md`
- [Security](#security) — `SECURITY.md`
- [Licensing & Packaging](#licensing-packaging) — `LICENSE-PACKAGING.md`
- [Test Report](#test-report) — `TEST-REPORT.md`
- [Setup & Reference](#setup-reference) — `README.md`


---

<a id="overview-benefits"></a>

# 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

```mermaid
flowchart LR
  CREATOR["🎨 Suite — Creator<br/>Authoring: playlists, LED signs, triggers, voices <i>(same app)</i>"]
  SUPA[("☁️ Azure PostgreSQL + Blob<br/>Config store + live notify · shared MP3 cache")]
  TTS["🗣️ TTS cloud<br/>Azure AI Speech · Acapela · ElevenLabs"]
  FEED["🛰️ Fleet journey data<br/>Live PT-PIS trip data, one stream per vehicle"]
  BROKER{{"📡 MQTT broker<br/>The integration hub — topics are the API"}}
  ENGINE["⚙️ Suite — Engine<br/>Trigger + audio + matrix LED pipeline <i>(same app)</i>"]
  DASH["🖥️ Web dashboard<br/>Monitor cockpit · diagnostics · proof · history"]

  subgraph VEHICLE["On the vehicle"]
    direction TB
    VEH["🔊 Vehicle player<br/>Go · Python · browser<br/>→ loudspeaker · interior/exterior LED signs"]
    PAX["📱 Passenger information app<br/>Onboard live screen — current &amp; next stop,<br/>route &amp; destination <i>(browser /player)</i>"]
  end

  CREATOR -->|"publish config"| SUPA
  SUPA -->|"live sync ~1 s"| ENGINE
  ENGINE <-->|"synthesize (cached)"| TTS
  FEED -->|"journey data"| BROKER
  BROKER -->|"PIS data"| ENGINE
  ENGINE -->|"audio + LED"| BROKER
  BROKER -->|"announcements"| VEH
  BROKER -->|"passenger info"| PAX
  DASH <-->|"monitor + control"| BROKER
```

- The **MQTT broker is the integration hub — the topics *are* the API.** Everything talks through it: the live PT-PIS journey feed comes in, the Engine's audio + LED frames go out to the vehicles, and the dashboard rides the same topics to monitor and control the fleet.
- On the vehicle, the **Passenger information app** (the browser `/player`) shows riders the live journey — current and next stop, route and destination — while the **vehicle player** (headless Go/Python, or the same browser page) makes the sound and drives the interior/exterior LED signs. Both render straight from the broker's topics; nothing else needs installing.
- **CreatorStudio** (authoring — playlists, **LED signs**, triggers, voices, and the route simulator) and the **Web dashboard** (one Monitor cockpit with the map, the fleet feed and the live sign previews in a drawer, plus diagnostics, proof of play and history) are browser apps sharing one design system — and one portal, which also publishes the **Documentation Center**.
- The **Engine** turns the trip feed into audio and matrix sign frames (Luminator MatrixRenderer-compatible layouts, real FNT fonts, mono/RGB Mobitec FF **and** Hanover HCPS/SuperX — sign-vendor independent) and reads its config live from **Azure PostgreSQL** (`pg_notify`/LISTEN) — a *Publish* hot-swaps the fleet in ~1 s with no redeploy. Voices are synthesized once via the **TTS cloud** and cached fleet-wide.

## How an announcement happens

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

```mermaid
flowchart TB
  FEED["🛰️ Live PIS-PT trip data<br/>{tenant}/{vehicleId}/pis/0/#"]
  subgraph ENGINE["CreatorStudio Engine"]
    direction TB
    CTX["VehicleContext<br/>merge topics → journey state"]
    ENG["Trigger engine<br/>edge-detect · debounce · gates"]
    PIPE["Pipeline<br/>trigger → playlist(s) · LED layouts · priority"]
    REND["Render<br/>resolve {variables} → MP3 + matrix frames"]
  end
  CACHE[("MP3 cache<br/>one synth per phrase, fleet-wide")]
  TTS["☁️ TTS supplier"]
  AUDIO["🔊 vehicle player"]
  SIGNS["▦ interior + exterior LED<br/>FNT · FF mono/RGB"]
  PROOF["🧾 proof-of-play"]

  FEED --> CTX --> ENG -->|"approaching-stop · doors-open · detour…"| PIPE --> REND
  REND <-->|"key = hash(text + voice)"| CACHE
  CACHE -.->|"miss → synthesize"| TTS
  REND --> AUDIO
  REND --> SIGNS
  REND --> PROOF
  AUDIO -.->|"ack when the clip finishes"| PROOF
```

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

- **Accessibility compliance you can prove.** Every announcement and sign change is recorded — dispatched
  vs vehicle-confirmed, with location, timestamp and transcript — and exportable as a signed proof pack.
  When a regulator or a rider asks "did the stop get announced?", you have the evidence.
- **One tool for the whole fleet.** Author once, Publish once; the change reaches every vehicle instantly
  over the air. No per-vehicle programming, no field visits, no re-flashing signs.
- **No vendor lock-in on voices.** Compare Azure, ElevenLabs and Acapela side-by-side and switch suppliers
  without touching a single announcement. ~50 neural voices, a dozen languages, bilingual stops.
- **Resilient by design.** TTS retries, a circuit breaker and fallback audio keep the fleet talking when a
  supplier wobbles; if the cloud is unreachable the engine keeps running on the last-known-good config.
- **Scales without re-work.** Identical phrases are synthesized once and reused fleet-wide, so cost and
  latency stay flat as vehicles are added — thousands of vehicles from one engine.
- **Deploy anywhere.** Run it centrally for an operator, or as a single hardened container **on the
  vehicle** (multi-arch, non-root) for on-board autonomy — the same software, your choice.
- **A better passenger experience.** Clear spoken announcements plus a live, branded passenger information
  screen (current/next stop, countdown, a personal "getting off at" alert) that works on any device.
- **Signs as first-class content.** Design destination and interior LED layouts the same way you design
  playlists — visual editor, trigger-driven cycles, Ultima vehicle roster — and ship them as the same
  MatrixRenderer / Mobitec FF content the Luminator Suite already understands. Sign hardware is
  **vendor-independent**: the identical rendered pixels also drive **Hanover** LED destination signs
  (HCPS/SuperX graphic frames), so mixed Luminator + Hanover fleets run from one pipeline.

---

## Where to go next

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

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

---

<a id="screens-features"></a>

# 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](/docs/media/platform-home-dark.jpg)

*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](/docs/media/docs-center-handbook.jpg)

*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 Requirements** —
`PRD.md` — now sits as section 3, between Screens & Features and the Functional Specification.)*

![Documentation Center — decks and leave-behinds](/docs/media/docs-center-decks.jpg)

*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](/docs/media/docs-center-sources.jpg)

*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](/docs/media/creator-assistant.jpg)

*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](/docs/media/creator-playlists-library.jpg)

*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](/docs/media/creator-playlist-editor-next-stop.jpg)

*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](/docs/media/creator-triggers-matrix.jpg)

*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](/docs/media/creator-triggers-expanded.jpg)

*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](/docs/media/creator-custom-triggers.jpg)

*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](/docs/media/creator-custom-triggers-led.jpg)

*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](/docs/media/creator-triggers-arriva.jpg)

*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](/docs/media/creator-geofence-zones.jpg)

*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:

- **Layout templates** — reusable pixel canvases (presets by face — front / side / rear / interior).
  Create, duplicate or delete; each row shows a live thumbnail of the layout. The list is a full
  work surface: search by name, sign type or category, filter by panel size, usage or **category**,
  group by **category** (the default), sign type (catalog panel / face), market or usage, sort by
  name / size / elements / usage, fold groups away individually or all at once, and multi-select rows
  or whole groups for bulk duplicate / delete.
  **Categories are the filing drawers.** A config fills up fast — the standard family is seeded for
  every sign resolution, the via and main-stops seeders add one per size, presets and destination
  copies add more — so every template is filed: in the category its author gave it, or in the family
  its id says generated it (*Standard sign layouts*, *Vilnius (Judu) sign set*, *Via destination*,
  *Main stops*, *Not in service*, *Destinations*), and anything else under *My templates*. Existing
  configs therefore open already sorted, with nothing to migrate. Select any number of rows and
  **Move** them into a drawer — pick one in use or type a new name — or **Unfile** them again; it is
  one undo step. A template authored while a category filter is on is filed there, a copy keeps the
  drawer of the template it came from, and the layout editor carries the same Category field.
- **Displays** — cycle trees that pick a layout by trigger / journey state and rotate over time
  (the MatrixRenderer *Display → Cycles → Layout* contract). Same list controls: search, group by
  sign type or usage, sort, and bulk actions.
- **Vehicles** — the hardware roster: Ultima model picker, resolution, display type, position,
  FF address and colour mode; "Assign default templates" wires a standard setup in one click.
  A fleet carries many vehicle configurations and each one opened out is a page of its own, so the tab
  is a **list**: one row per vehicle — name, *In use*, sign count, the faces it covers, and a warning
  chip counting signs with no display bound (they show nothing) — and the full roster only for the
  rows you open. It opens on the vehicle **in use**; putting another one in use opens it too. Filter
  by name, position or resolution, and expand / collapse all.
- **Vehicle faces** — bind each physical sign to a **Template display** or to **Announcement text**
  (mirror what is spoken on the interior).
- **Symbols** — map a live value (e.g. line code) to a pictogram image on the sign.
- **Route colours** — the GTFS `route_color` / `route_text_color` per line, as a table the vehicle
  carries; full-colour panels honour it, single-colour panels ignore it, and a layout's own colour
  rule still wins so legibility is never overridden by branding.
- **Abbreviations** — text-fitting rules shared with the fit checker: whole-value or per-word
  replacements, optionally applied *only when tight* so the full text keeps appearing wherever it
  fits.

![LED layout templates](/docs/media/creator-led-templates.jpg)

*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](/docs/media/creator-led-vehicles.jpg)

*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](/docs/media/creator-led-abbreviations.jpg)

*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](/docs/media/creator-led-symbols.jpg)

*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](/docs/media/creator-led-route-colours.jpg)

*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**:

- Pick a code on the left; the vehicle-in-use's panel fills the editor, rendered by the same
  rasteriser the engine publishes with: the sign's real resolution, its FNT font, its colour mode, its
  symbols and abbreviation rules. Not a mock-up of the sign — the sign.
- **Click any text on the panel and type.** The editable areas are outlined and labelled (Destination,
  Row 2, Via, Line number…); the caret sits over the pixels and every keystroke re-renders them. Tab
  and Shift+Tab walk the fields, Enter keeps, Esc puts the value back. A newly added destination opens
  straight into typing. Areas the author cannot change — the template's own words, the live
  "Stopping at:" row, a value the template pins — say so instead of pretending to be editable.
- Which field sits behind which area is discovered by probing the template, so it works for plain
  `{token}` text, raw expressions and priority chains alike: on a destination that has a via, the
  via-priority area edits the **via**, because that is what the sign is showing.
- **Arrange it freely.** Drag any area to move it, pull its handles to resize, and use the toolbar to
  set horizontal and vertical alignment, the **font size** (Auto fits the biggest that will go —
  pinning a size is how two signs are made to match), what happens when the text is too long (scale /
  scroll / clip / wrap), the exact X / Y / W / H, the front-to-back order, or to remove it.
- **All text settings** opens the *same inspector the template editor uses*, on the selected area:
  content and `{variables}`, the raw expression, the text rules (letter case, show-only-part, rotate
  the parts of a value, symbol slot), the font ladder and character spacing, the scrolling switches
  and speed, alternations, blink, and the text / outline colours — plus the element type itself, which
  keeps the name and box it occupied. An area driven by a destination field says so, so its *Content*
  (the `{token}` that fetches the value) is never mistaken for the value: that is still typed on the
  sign. Because arranging already forks the template to this destination, none of it can disturb the
  others.
- **Add text** puts another line on the sign: a destination field, or **static text** you type in
  place. **Add shape** draws a **line**, **box** or **circle** — separators, frames and highlights,
  with a thickness and a solid/outline switch. **Add symbol** offers the built-in **public-transport
  pictograms** — accessibility (wheelchair, priority seat, pram), on-board facilities (bicycles,
  Wi-Fi, air conditioning, power, toilet, luggage, CCTV), vehicle modes (bus, **school bus**, **rail
  replacement bus**, tram, train, metro, ferry), wayfinding (arrows, **Park & Ride**, exit, lift, stop
  requested) and notices (no smoking, no entry, info, warning, time, ticket, tick, cross). **Add image**
  places your own PNG. Everything lands where there is room and is then dragged and sized like
  anything else.
- **A pictogram per panel height.** The picker has a size switch — *this sign*, or 8 / 12 / 16 / 24 px —
  and each height is a separately drawn glyph, previewed exactly as the panel will show it. A curve
  rasterised down to 12 pixels is a smudge, so the sizes a fleet actually runs are drawn by hand and
  the shapes only take over on panels no hand-drawn size fits (roughly 20 px and up, where they are
  honest). The engine draws from the same definitions, so the sign and the preview agree.
- Arranging gives **that destination its own copy** of the template, bound to that sign, and says so:
  every other destination keeps the shared one. *Use the shared template* puts it back.
- **Fit is checked as you type**: an area whose text no longer fits turns red with the exact pixels it
  needs versus what it has, and the sign tabs carry a badge — so a name that fits the front but not
  the rear is caught at the keyboard, not on the street.
- **Characters the sign font cannot draw are named, not silently changed.** The Luminator FNT set is
  latin-1 (ANSI, `dfCharSet 0`) and its substitution glyph is `@`, so Lithuanian, Latvian, Polish and
  Czech letters have no artwork: `Zuikių g.` reached the panel as `Zuiki@ g.`, and the files' blank
  placeholder cells punched 25px holes into words. The renderer now falls back to the base letter
  (`ų → u`, `ž → z`, `ė → e`) — measuring and rasterising fold identically, so nothing shifts — and
  the fit check reports the substitution on the element ("ų → u"), in the editor and in **Check fit**.
  Only a character with no latin base at all still shows the font's `@`. Proper diacritics would need
  Baltic / Central-European FNT files from Luminator; nothing else in the pipeline is in the way.
- Switch panels with the sign tabs, or glance at **On the other signs** below to see the same
  destination on every face at once. Zoom is 2×–8×.
- Code, line code and out-of-service sit beside the panel; rows, symbols, notes and an optional
  **template per sign** for one destination live under **More**. Duplicate codes are flagged — only
  the first would ever be reachable.
- **Check fit** runs the whole list through the vehicle's templates in one pass, with the same report
  (and one-click abbreviations) the LED Signs screen uses.
- **Review sheet** (`/destinations/review/:id`) — a printable page of every code, its text and its
  rendered signs, with an approval block: Print → Save as PDF, or send it for sign-off.

![Destination editor](/docs/media/creator-destinations-editor.jpg)

*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](/docs/media/creator-destination-symbols.jpg)

*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:

- **Signs win, feed for speech** (default) — the stored text takes priority on the SIGNS; spoken
  announcements follow the live feed (using the stored name only when the feed sends none, so a
  code-only journey still speaks a destination).
- **Signs & speech win** — the stored text takes priority on both the signs and the announcements.
- **Feed always wins** — the live feed always wins; the stored list is only a lookup fallback for what
  the feed doesn't send.

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](/docs/media/creator-led-layout-editor.jpg)

*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](/docs/media/creator-led-displays.jpg)

*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](/docs/media/creator-simulator-route26.jpg)

*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](/docs/media/creator-settings-pronunciation.jpg)

*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](/docs/media/creator-settings-voice.jpg)

*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](/docs/media/creator-settings-audio.jpg)

*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](/docs/media/creator-settings-suppliers.jpg)

*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](/docs/media/creator-settings-general.jpg)

*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](/docs/media/creator-history-rollback.jpg)

*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](/docs/media/engine-monitor-cockpit.jpg)

*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](/docs/media/engine-monitor-trigger-inputs.jpg)

*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](/docs/media/engine-monitor-fleet.jpg)

*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](/docs/media/engine-monitor-display-previews.jpg)

*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](/docs/media/engine-performance-timing.jpg)

*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](/docs/media/engine-health-live.jpg)

*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](/docs/media/engine-history.jpg)

*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](/docs/media/engine-proof-journeys.jpg)

*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](/docs/media/engine-proof-records.jpg)

*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](/docs/media/engine-proof-trail.jpg)

*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](/docs/media/engine-proof-replay-live.jpg)

*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](/docs/media/engine-play-device-qr.jpg)

*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](/docs/media/engine-published-triggers.jpg)

*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](/docs/media/player-boarding.jpg)

*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](/docs/media/player-passenger-info.jpg)

*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](/docs/media/player-led-signs.jpg)

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.*

---

<a id="product-requirements-prd"></a>

# 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`](FUNCTIONAL-SPEC.md) | The baseline functional description (v1.0, 2026-07-14) with detailed acceptance criteria, data model and gap register |
| [`SCREENS.md`](SCREENS.md) | Screen-by-screen feature tour with screenshots |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How it is built — modules, dependencies, patterns |
| [`SCALING.md`](SCALING.md) | How it holds up — measured limits, phased plan to 2000 vehicles |
| [`SECURITY.md`](SECURITY.md) | Security posture, open risks |
| [`TEST-REPORT.md`](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, **energy** — `stateOfCharge`/`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. The imported **GTFS feed's stops overlay the map** (name, code and coordinates on hover; toggleable, on by default) and **clicking a stop snaps** the circle centre or next polygon corner to it. | 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. | FR-LED-01…09 |
| 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 `status` ∈ `played` / `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.yml` — `RENDER_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 `clipKey`s 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:

- **Transport hardening** (D-16/17 — wss, credentials, ACLs, QoS-≥1 acks) is deferred to Luminator
  Suite integration and **blocks real-vehicle evidentiary use** of proof-of-play.
- **Exterior loudspeaker** is not driven by any player (needs unit-specific amplifier enablement).
- `requiresDirection` gate and `bus-type` trigger await upstream feed signals.
- Custom triggers bypass the universal gates/settle/debounce (D-13).
- Audio ducking/cross-fade on interrupt; ffmpeg-gapless MP3 concatenation; multi-clip `audio[]`
  handling in players — accepted gaps.
- Geofence `entering`/`leaving` collapse to `within`/`outside` semantics (D-05).
- Creator sign-in awaits a suite identity provider; until then publish identity rows are honestly
  marked unverified.

---

<a id="functional-specification"></a>

# 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`](ARCHITECTURE.md) | How it is built — modules, dependencies, patterns |
| [`SCALING.md`](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).

```mermaid
flowchart TB
  subgraph author["AUTHORING PLANE — design time"]
    FB1["FB-1 Content Authoring<br/>playlists · voices · lexicon"]
    FB2["FB-2 Rule Authoring<br/>triggers · conditions · geofences"]
    FB3["FB-3 Config Lifecycle<br/>projects · publish · version · rollback"]
  end
  subgraph run["EXECUTION PLANE — run time"]
    FB4["FB-4 Situation Awareness<br/>ingest · aggregate · derive state"]
    FB5["FB-5 Decision<br/>trigger evaluation · gates · priority"]
    FB6["FB-6 Realisation<br/>TTS · cache · LED · FF encode"]
    FB7["FB-7 Dispatch & Playback<br/>schedule · publish · play · ack"]
  end
  subgraph assure["ASSURANCE PLANE — after the fact"]
    FB8["FB-8 Evidence & Operations<br/>proof of play · reports · health"]
  end
  FB1 --> FB3
  FB2 --> FB3
  FB3 -- "published config" --> FB5
  FB4 --> FB5 --> FB6 --> FB7
  FB7 -- "played ack" --> FB8
  FB5 & FB6 & FB7 --> FB8
```

| 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. `status` ∈ `played` \| `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

```mermaid
erDiagram
  PROJECT ||--|| ANNOUNCEMENT_CONFIG : contains
  ANNOUNCEMENT_CONFIG ||--o{ PLAYLIST : has
  ANNOUNCEMENT_CONFIG ||--o{ TRIGGER : has
  ANNOUNCEMENT_CONFIG ||--o{ CUSTOM_TRIGGER : has
  ANNOUNCEMENT_CONFIG ||--o{ GEOFENCE : has
  ANNOUNCEMENT_CONFIG ||--o{ LEXICON_ENTRY : has
  ANNOUNCEMENT_CONFIG ||--o{ VOLUME_RULE : has
  PLAYLIST ||--o{ ELEMENT : contains
  TRIGGER }o--|| PLAYLIST : "fires"
  CUSTOM_TRIGGER }o--|| PLAYLIST : "fires"
  CUSTOM_TRIGGER }o--o{ GEOFENCE : "tests"
  PROJECT ||--o{ CONFIG_VERSION : "published as"
```

| 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 report** — `format=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`](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.~~ **Fixed** — `exit-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 **`entering` ≡ `within`** and **`leaving` ≡ `outside`** 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. | Low — authoring-side surface, no engine contract. |

---

## 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/`. |

---

<a id="architecture"></a>

# CreatorStudio — Architecture & System Documentation

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

- the **CreatorStudio authoring app** (`web/creator/`, **Angular 20 + PrimeNG**) where operators
  *design* announcements — organised into **Projects** (playlists, triggers, voices, lexicon), and
- the **CreatorStudio Engine** (`src/`, Node/TS) — an always-on backend that *executes* that config
  live, turning transit trip data into **spoken passenger announcements** and **LED sign content**
  for an entire fleet — plus the Angular **dashboard** (`web/`) to operate, monitor, and audit it.

> 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](https://mermaid.js.org) 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.**

```mermaid
flowchart LR
  FEED["Live trip data<br/>(position, stops, doors…)"] --> ENGINE
  AUTHOR["CreatorStudio app<br/>(playlists · triggers · voices)<br/><i>in-repo · /creator/</i>"] -. "publishes config" .-> ENGINE
  ENGINE["CreatorStudio Engine"] --> AUDIO["🔊 Spoken announcements"]
  ENGINE --> INT["▦ Interior LED sign"]
  ENGINE --> EXT["▦ Exterior destination signs"]
  ENGINE --> PROOF["🧾 Proof-of-play audit"]
  ENGINE -. "telemetry" .-> DASH["📡 Dashboard<br/><i>/monitor/</i>"]
```

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**
- **Automated, consistent announcements** — no driver action; every stop announced the same way.
- **Multilingual** — the same trigger speaks in the configured language with the right stop-name variant.
- **Multi-surface** — audio, interior scrolling sign, and front/side/rear destination signs stay in sync.
- **Authored, not coded** — operators change wording/voices/rules in CreatorStudio and **publish live**;
  the engine hot-swaps in ~1 s with no redeploy.
- **Fleet-scale & cost-aware** — identical phrases are synthesized once and reused across every bus.
- **Auditable** — a durable *proof-of-play* log records what was played/shown, where and when, for
  compliance and dispute resolution.

---

## 2. Tech stack

| Layer | Technology |
| --- | --- |
| **Backend (engine)** | Node.js ≥ 20 (dev on 24), TypeScript 5.9 (ESM / NodeNext), run via `tsx` (dev) or `tsc`→`node` (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`](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

```mermaid
flowchart LR
  subgraph fleet["Fleet"]
    BRIDGE["GTFS-RT → PIS-PT bridge<br/>(per tenant)"]
  end
  subgraph veh["On the vehicle — the client side (§5.2)"]
    PLAYER["Vehicle player<br/><i>Go · Python · browser</i><br/>→ loudspeaker"]
    SIGNS["Sign controller<br/><i>RS-485 · Mobitec FF / Hanover HCPS</i>"]
  end
  AUTH["CreatorStudio app<br/>authors playlists + LED signs + triggers<br/><i>in-repo · portal /creator/</i>"]
  SB[("Azure PostgreSQL<br/>config")]
  BLOB[("Azure Blob<br/>MP3 cache")]
  BROKER[["MQTT Broker"]]
  ENGINE["CreatorStudio Engine<br/>(Node/TS)"]
  DASH["Dashboard (Angular)<br/><i>portal /monitor/</i>"]
  TTS["Azure AI Speech (REST)"]

  BRIDGE -- "{tenant}/{vehicleId}/pis/0/#" --> BROKER
  BROKER -- "PIS-PT" --> ENGINE
  AUTH -- "Publish to engine (portal /api/publish)" --> SB
  SB -- "pg_notify: engine_config_changed" --> ENGINE
  ENGINE -- "synth (HTTPS)" --> TTS
  ENGINE <-- "MP3 cache" --> BLOB
  ENGINE -- "tts · display · display/ff" --> BROKER
  BROKER -- "…/pis/0/tts (ADT audio)" --> PLAYER
  BROKER -- "…/display/ff (sign frames)" --> SIGNS
  PLAYER -- "engine/played" --> BROKER
  BROKER -- "played-ack" --> ENGINE
  ENGINE -- "metrics · health" --> BROKER
  DASH <-- "ws:// MQTT + HTTP" --> BROKER
```

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

```mermaid
flowchart TB
  index["index.ts (composition root)"]
  config["config.ts · ConfigStore"]
  cfgsrc["config/pgConfigSource.ts<br/>· httpConfigSource.ts"]
  bus["mqtt/client.ts · MqttBus"]
  ctx["pis/ptContext.ts"]
  eng["pis/ptEngine.ts"]
  cust["engine/customTriggers.ts<br/>(fact snapshot + expression)"]
  pipe["pipeline.ts"]
  sched["engine/scheduler.ts"]
  rend["engine/playlistRenderer.ts"]
  vars["engine/variableResolver.ts"]
  vol["engine/volume.ts · lexicon.ts"]
  adt["engine/adtPayload.ts"]
  hist["engine/history.ts"]
  sink["engine/proofSink.ts<br/>(durable mirror)"]
  pre["engine/prerender.ts"]
  led["engine/ledRender.ts · exteriorSign.ts"]
  matrix["engine/matrix/*<br/>globalState · publish · layoutRenderer"]
  dest["engine/destinationList.ts"]
  ff["engine/ff/ffEncoder.ts · ffRgbEncoder.ts"]
  ttsf["tts/index.ts (factory)"]
  res["tts/resilient.ts"]
  prov["tts/provider.ts"]
  cache["tts/cache.ts"]
  fleet["engine/fleet.ts"]
  shared["shared/payloads.ts"]
  sled["shared/led*.ts<br/>expression · cycle · layout · destinations<br/><i>shared with the browser</i>"]

  index --> config & cfgsrc & bus & ctx & eng & cust & pipe & sched & pre & hist & sink & led & matrix & dest & ff & ttsf & cache & fleet
  config --> cfgsrc
  pipe --> rend & adt & led & ff & vol
  rend --> vars & prov & cache
  matrix --> sled & ff
  dest --> sled
  ttsf --> res --> prov
  led & ff & adt --> shared
```

### 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 **LISTEN**s 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](https://adt.transhub.io/4.x/asyncapi/)): 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:

- **The ack is sent when the clip *finishes*, not when it arrives.** That is what turns a dispatch
  log into proof of play (§1) — the difference an accessibility audit cares about.
- **Clips are queued and played strictly one at a time.** An announcement must never talk over the
  next one. (The dashboard's own auto-play is a *monitor*, not a vehicle, and does not queue.)
- **Every received clip is cached on the vehicle** under the payload's `clipKey` (a content hash
  the engine stamps on every message; repeats of an announcement share a key). A payload may then
  arrive **key-only** — `audio[0]` with no `content` — and the player replays the cached bytes: a
  few hundred bytes over the link instead of a few hundred kilobytes. Go/Python keep the cache on
  disk (`CLIP_CACHE_DIR`, LRU-bounded); the browser player uses IndexedDB. The **closed-corpus
  pre-push** (`PREPUSH_ENABLED=true`, §15) fills this cache ahead of need: cache-only clip
  deliveries arrive on `…/tts/cache` (`kind: "clip-cache"`) — stored, never played, never acked.
- **`expiryDateTime` is enforced.** A clip past its expiry is dropped and acked **`expired`**,
  never played — a late "next stop Central" minutes after Central is worse than silence, and the
  proof trail must record the drop rather than a false play.

**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
- **MQTT topics as the API.** The engine, dashboard, bridge, and vehicle units are fully decoupled;
  anything that speaks the topic contract can join. Enables simulation, multi-consumer, and
  language-agnostic integration.
- **Multi-topic aggregation → edge-detected events.** PIS-PT spreads trip state across many retained
  topics; `VehicleContext` merges them into one `JourneyState`, and `PtTriggerEngine` fires events
  only on meaningful *transitions* (debounced), not on every message.
- **Pluggable TTS + resilience decorator.** `TtsProvider` is one interface; `ResilientTtsProvider`
  wraps any provider with retry/backoff/circuit-breaker/fallback while preserving cache keys — and
  counts each supplier's synths/latency/characters for the per-provider health surface. While the
  primary's breaker is open, the switchable provider renders through a **configured failover
  supplier** (`config.ttsFallbackProvider` / `TTS_FALLBACK_PROVIDER`) — a vendor outage speaks in
  another vendor's voice instead of the fallback beep. The failover decision happens at `pin()`
  time, before the cache key is computed, so key and audio always belong to the same supplier.
- **Layered, content-addressed cache.** Cache key = hash of the exact synthesis inputs, so an
  identical phrase is generated **once for the whole fleet** and reused across memory → disk →
  Azure Blob. Pre-recorded clips share the same cache keyed by URL.
- **Priority scheduler.** One announcement plays per vehicle at a time; the scheduler enforces
  interrupt / queue / drop from authored (or tier-default) priority — safety announcements never
  wait behind ambient ones. Interrupting a still-rendering announcement **aborts it** (`AbortSignal`)
  so its audio is never published on top of the higher-priority one.
- **Single source of truth for wire types.** `shared/payloads.ts` is imported by both the engine and
  the Angular app; a contract change is a compile error on both sides.
- **Deterministic sign rendering.** The browser rasterizes signs with the *same font* the engine
  ships (`/led-font.json`), so previews and proof-of-play replays match the hardware exactly.

### 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
triggers* — `scripts/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)
- **Interior:** 16 rows × 144 columns amber dot-matrix. Text centered when it fits, marked `scroll`
  when wider; the sign/dashboard pans a 144-wide window.
- **Exterior:** front (192) / side (160) / rear (48) px wide, 24 tall. A fixed route number (≤ 25 %
  of width, never scrolls) + destination that **shrinks to fit**, scrolling only as a last resort.
- **FF/Mobitec:** the same bitmap re-encoded as graphic-font-`w` column bytes (each byte = a 5-pixel
  vertical slice) inside an addressed 1463-L frame for RS-485 hardware.
- **Hanover:** the same bitmap again as a SuperX `{\pic}` vertical raster inside an addressed HCPS
  frame — sign-vendor independence: Luminator and Hanover panels show identical pixels from one
  render pass.

---

## 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

```mermaid
sequenceDiagram
  participant B as Broker
  participant I as index.ts
  participant Ctx as VehicleContext
  participant E as PtTriggerEngine
  participant S as Scheduler
  participant P as pipeline
  participant R as renderPlaylist
  participant T as TTS
  participant C as Cache
  participant H as History
  participant V as Vehicle player (§5.2)

  B->>I: …/pis/0/linkprogress
  I->>Ctx: update(sub, payload)
  I->>E: ingest(ctx)  (state-changing topics only)
  E-->>I: [approaching-stop]  (condition + prerequisite gates pass)
  I->>S: offer(event, priority)
  S->>P: play (or interrupt/queue/drop)
  P->>R: renderPlaylist (per-element voice/volume)
  R->>C: get(cacheKey)
  alt cache miss
    R->>T: synthesize (SSML, HTTPS)
    T-->>R: MP3
    R->>C: put(cacheKey)
  end
  R-->>P: audio + transcript + timing
  P->>B: publish …/tts (ADT) · …/display · …/display/ff
  P->>B: publish engine/metrics
  P->>H: append proof record (dispatched)
  B->>V: …/pis/0/tts (ADT audio)
  V->>V: decode → ALSA → loudspeaker (queued, never overlapping)
  V->>B: engine/played (AFTER the clip finishes)
  B->>H: 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

```mermaid
flowchart LR
  subgraph host["Back office — host / container"]
    E["engine — node dist/src/index.js"]
    D["dashboard-server :8080"]
  end
  subgraph unit["Each vehicle — onboard unit (x86 or ARM)"]
    P["player (Go or Python)<br/><i>docker · --device /dev/snd</i>"]
  end
  BR[["MQTT broker :1883 tcp · :9001 ws"]]
  BROWSER["Browser — dashboard · /player"]
  SB[("Azure PostgreSQL<br/>+ Azure Blob")]
  E <--> BR
  E <--> SB
  D <--> SB
  BROWSER <--> BR
  BROWSER <--> D
  P <--> BR
```

- **Engine** — single long-running process (per-vehicle state in memory), one replica per inbound
  stream. `Dockerfile` builds it; runs with `--use-system-ca`. Waits for the broker (never crashes on
  a missing broker).
- **Dashboard server** — static file + data server (ops/monitoring); also serves the browser player.
- **Vehicle player** (§5.2) — **one per vehicle**, on the onboard unit, not in the back office. Go
  (single static binary) or Python; both as multi-arch images for **x86-64 and ARM64**
  (`docker buildx build --platform linux/amd64,linux/arm64`). Needs `--device /dev/snd` to reach the
  speaker, and only outbound MQTT — no inbound ports, so it sits behind a vehicle's NAT unchanged.
  `restart: unless-stopped` and it reconnects on its own when the link drops.
- **Broker** — Mosquitto (prod) / `aedes` (dev); WebSocket listener required for the browser
  (dashboard **and** the `/player` page).
- **Dev stacks** (`concurrently`): `stack:ui` (broker+engine+dashboard), `stack:live` (adds the
  upstream bridge), `stack:dev` (broker+bridge+engine+dashboard+`ng serve` live-reload). Demos:
  `proof:demo`, `ff:demo`, `led:demo`, `multi:demo`, `bridge:test`,
  `swedish`, `simulate`, `config:test`, `tts:test`.
- **Fully local backend** (stock Postgres): `db:start` / `db:stop` start/remove a plain
  `postgres:16` docker container; `db:migrate` applies `db/migrations/` (the whole schema builds
  from empty — no cloud dependency); **`stack:local`** runs broker+bridge+engine+dashboard together
  with **every process** pointed at the local Postgres — it sets
  `DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/creatorstudio` (and runs the migrations
  first), so a hosted database in `.env` is neither read nor
  written while it runs. Use `stack:dev` to keep the engine on the `.env` target. **`stack:vm`**
  is the deployed variant: same four processes, everything read from `.env` (the Azure pipeline
  runs `db:migrate` before starting it).

---

## 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**
- **Content-addressed dedup** — a phrase like "Now arriving at Central Station" is synthesized once
  and reused by every bus on that route (observed: miss → `cache 0/1`, then `cache 1/1` hits).
- **Predictive pre-render** warms the cache for upcoming stops, so most live announcements are hits.
- **Ingest gating** — trigger detection runs only on state-changing topics; high-frequency GNSS and
  the large static `shape` don't trigger work unless a speed/geofence trigger is enabled.
- **Render backpressure** — a global `acquire`/`release` counting semaphore bounds concurrent TTS
  renders (`MAX_CONCURRENT_RENDERS`); duplicate concurrent synths for the same phrase are collapsed
  by the cache's single-flight `getOrSynth` (surfaced as `synthCoalesced` in health).
- **Bounded memory** — LRU MP3 cache (`TTS_MEM_CACHE_MAX`) + on-disk FS cap (`CACHE_MAX_FILES`,
  oldest pruned by mtime) + idle `VehicleContext` eviction (`CONTEXT_TTL_MS`) keep RSS flat under
  fleet load.
- Health telemetry (`engine/health`) exposes msg/s, events/s, event-loop lag, RSS, cache hit-rate,
  in-flight, dropped, interrupted for live observation.

---

## 11. Scalability

- **Multi-vehicle, opt-in by default.** A single engine can serve **many vehicles concurrently**:
  per-vehicle contexts + trigger snapshots + scheduler slots, a global render semaphore
  (`MAX_CONCURRENT_RENDERS`) bounding total TTS concurrency, and per-vehicle serialization (one
  announcement per vehicle). Two render scopes:
  - **`RENDER_SCOPE=selection` (default, production-safe)** — the engine renders only the vehicles
    the dashboard opts in via `engine/control/select` (the *served set*). It serves nothing until an
    operator picks vehicles; an empty set = silent. Multi-select in the dashboard builds this set.
  - **`RENDER_SCOPE=all` (load-test only)** — serves every **fully-tracked** forwarded vehicle at
    once, ignoring the selection (discovery-only vehicles are still skipped). Only the exact literal
    `all` enables it; the dashboard surfaces a visible **"ALL mode"** warning when the engine reports
    this scope in its health beat.
  Verified with `npm run multi:demo -- N` (load-test/`all`) and `npm run multi:demo -- N select`
  (served-set/`selection`) — 10 selected of 15 driven, decoys stay silent, render concurrency bounded,
  fair (no starvation), **zero cross-talk** even when every phrase is a unique synth.
- **Per-vehicle state is in memory** → the natural horizontal unit is **one engine replica per
  inbound stream / shard**.
- **Horizontal sharding** — split the fleet by MQTT topic filter (e.g. per tenant or vehicle-id hash),
  one replica per shard; or externalize per-vehicle state to Redis to scale statelessly.
- **Selection control channel** — the dashboard can scope rendering to specific vehicles
  (`engine/control/select`) to bound TTS cost during operation/debugging.
- **Cost scales with unique phrases, not vehicles** — the shared cache plus **single-flight**
  `getOrSynth` (concurrent identical synths collapse to one) mean fleet growth adds near-zero TTS
  cost for repeated announcements.
- **Broker** is the throughput backbone; Mosquitto handles fleet-scale fan-in/out. Phase-0 hardening
  (bounded caches, eviction, ingest gating, backpressure, health metrics) lets one instance degrade
  gracefully; the full multi-vehicle / ~2000-bus plan is in [`SCALING.md`](SCALING.md).
- **Lean upstream bridge.** The bridge (`scripts/bridge.ts`) mirrors the live PIS feed to the local
  broker and is kept deliberately quiet: it **follows the selection** (full `#` only for served
  vehicles), keeps fleet **discovery minimal** (route+destination+phase only — `list/stops` and other
  heavy/high-rate topics are *not* bridged fleet-wide), **tenant-scopes** discovery (once a tenant is
  selected via `engine/control/tenant` it narrows from `+/+/…` to `{tenant}/+/…`, so it stops pulling
  the whole broker), and **dedups** byte-identical re-publishes so
  retained-value churn doesn't ripple through trigger detection and the dashboard. It publishes
  `engine/bridge` health (forwarded/s, deduped/s, bridged vehicles, upstream subs) which the dashboard
  surfaces. Verified with `npm run bridge:test`.

---

## 12. Security & operational concerns

- **Secrets** live in a gitignored `.env` (Azure key, upstream broker creds, `DATABASE_URL`,
  `CACHE_BLOB_SAS_URL`). **No key of any kind ships in a browser bundle** — the Creator talks
  same-origin to the portal, and every credential stays server-side.
- **Database access**: the Postgres server is **private** (VM allow-list / VNet, `sslmode=require`);
  browsers never reach it. All reads and writes go through the portal API, where every query is
  **parameterized** and tenant-scoped (tenant is an explicit request parameter today — deriving it
  from an authenticated session is the follow-up below).
- **Publish identity** — the Creator has no login; it forwards a host-shell-supplied token
  (`SessionService`) as the bearer when there is one, and `config_versions.published_by_verified`
  defaults **false** — the row is honestly marked unverified. The follow-up is **Entra ID sign-in**
  with a server-verified `published_by`; the seams (SessionService bearer, the
  `published_by_verified` column, the tenant columns) are already in place.
- **Portal endpoints** — the mutating / cost-bearing HTTP routes (config import, TTS preview, GTFS
  upload, played-ack, and the Creator API writes: project save/delete, publish, GTFS expand) are
  behind a constant-time `Authorization: Bearer` check when `DASHBOARD_TOKEN`
  is set; unset means an open lab portal, which is the default and must not be the deployed one.
- **TLS inspection** — `--use-system-ca` trusts the corporate root so HTTPS works behind proxies.
- **Outstanding:** the Azure Speech key was shared in plaintext during development and **should be
  rotated** in the Azure portal.
- **Proof-of-play** provides tamper-evident evidence (append-only log) for compliance/disputes.

---

## 13. Testing & quality

- **581 engine tests across 67 files** (`node --test`, `tsx`) at **96.5 % line coverage**, covering:
  trigger detection & prerequisite gates, custom fact triggers & geofence geometry, priority
  resolution & scheduling, the full matrix-LED pipeline (FNT parsing, layout render, cycles, colour
  rules, mono + RGB FF frames, browser↔engine parity), pre-programmed destination lists, project
  CRUD/migration/validation, ADT payload conformance, multilingual variable resolution, per-element
  voice/volume, SSML, TTS resilience & the three supplier HTTP clients, pre-recorded audio
  caching/retry, config env permutations + Postgres LISTEN reconnect, GTFS parsing & expected
  coverage, proof-of-play correlation/report/sink — plus two **real-broker** tests (`aedes`): a
  MqttBus round-trip and a full `startEngine()` boot.
- **758 front-end tests across 67 files** (Vitest + jsdom via `@angular/build:unit-test`): every
  dashboard and Creator component rendered under TestBed, every service, and all pure logic.
- Exact per-suite counts, coverage and the full case catalog: [`TEST-REPORT.md`](TEST-REPORT.md).
- **Trigger-parity guard** (`src/domain/triggerParity.ts`) — a strict, bidirectional contract
  between the engine's trigger set and the CreatorStudio manifest (same types both ways, matching
  `local` flags, every MQTT trigger wired to an emitted event). Enforced by the `manifestSync`
  contract test AND `npm run trigger:parity` (which also accepts `--file`/`--url` to check a live/
  freshly-exported manifest). This is what catches `service-disruption`-style drift automatically.
- **Typecheck** (`tsc --noEmit`) gates the shared contract across engine + Angular.
- **End-to-end demos** double as living verification: `proof:demo`, `ff:demo`, `led:demo`, `swedish`.
- **CI** (`azure-pipelines.yml`) runs typecheck + tests + the trigger-parity guard + the dashboard
  build on every PR/push to `main`, blocking merges on drift or a broken build.

---

## 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`](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.
7. **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`](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). |

---

<a id="scaling"></a>

# 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:
- Subscribes `+/+/pis/0/#` (whole fleet) — or a bridge feeds it a selected/tenant-scoped subset.
- Holds each vehicle's `VehicleContext` **in memory**; `PtTriggerEngine` holds a
  per-vehicle snapshot **in memory** — both now idle-TTL-evicted (Phase 0).
- On inbound message: `parseTopic` → update context → `engine.ingest` (edge detection, **only
  on state-changing topics**) → on event: `deriveState` → `scheduler.offer` (per-vehicle
  priority `AnnouncementScheduler`) → bounded render semaphore → `renderPlaylist` → TTS
  (single-flight + layered cache) → publish ADT + metrics. A predictive pre-render warms
  upcoming stops on the same path.

### 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

- [x] In-memory MP3 cache (`TtsCache.mem`) is an **unbounded `Map`** → LRU cap
      (`TTS_MEM_CACHE_MAX`, `stats()` for hit-rate).
- [x] On-disk FS cache layer was unbounded (one MP3/phrase forever) → oldest-mtime prune to
      `CACHE_MAX_FILES` (`TtsCache.pruneFs`, amortised off the hot path).
- [x] `VehicleContext`s are **never evicted** → idle-TTL sweep (`CONTEXT_TTL_MS`) releasing
      contexts, scheduler slots, prerenderer, and exterior-sign state.
- [x] `getCondition()` did `triggers.find()` **per message** → cached condition map,
      rebuilt only on config `version()` change.
- [x] Threshold conditions evaluated on **every** message → only computed when that
      trigger is enabled; full detection skipped for non-state topics (GNSS/`shape`).
- [x] Fire-and-forget renders with **no limit** → a counting render semaphore
      (`MAX_CONCURRENT_RENDERS`, `acquire`/`release` in `index.ts`) + a per-vehicle priority
      `AnnouncementScheduler` (interrupt/queue/drop; interrupt aborts the superseded render).
- [x] Concurrent identical synths stampeded the provider → **single-flight** `getOrSynth`
      collapses same-key (text+voice+format) synths to one in-flight render (`synthCoalesced`).
- [x] Cold synths at each stop → **predictive pre-rendering** (`src/engine/prerender.ts`) warms
      the next N stops so real triggers hit cache; shares the global render semaphore.
- [x] No runtime health metrics → `engine/health` every `HEALTH_INTERVAL_MS` (vehicles, served,
      renderScope, msg/s, events/s, inFlight, queued, dropped, interrupted, RSS, loop lag, cache
      hit-rate/size, synthCoalesced, prerendered, TTS breaker/fallbacks/retries), plus a separate
      retained `engine/bridge` health topic.

---

## 3. Phased plan

### Phase 0 — Measure & harden a single instance — ✅ DONE & VERIFIED
- [x] **Metrics**: `engine/health` publishes msg/s, events/s, event-loop lag, RSS,
      cache hit-rate/size, in-flight, dropped.
- [x] **Load generator**: `scripts/load-gen.ts` (`npm run load -- <count>`) — N
      synthetic vehicles (GNSS firehose + stop advances).
- [x] **Multi-vehicle proof**: `scripts/multi-demo.ts` (`npm run multi:demo -- N [select]`)
      drives N vehicles concurrently against one engine and asserts per-vehicle correctness, no
      cross-talk, bounded concurrency, and served-set isolation.
- [x] **Bound caches**: LRU on the in-memory MP3 map; oldest-mtime FS prune to `CACHE_MAX_FILES`;
      idle-TTL eviction of contexts.
- [x] **Optimize ingest**: cached enabled-trigger map; full detection only on
      state-changing topics (GNSS gated to speed/geofence triggers); threshold math
      only when enabled.
- [x] **Backpressure**: a counting semaphore (`MAX_CONCURRENT_RENDERS`) + per-vehicle priority
      scheduler + single-flight synth dedup (`getOrSynth`).
- [x] **Serving scope**: `RENDER_SCOPE` (default `selection` — only dashboard-selected vehicles,
      silent until opted in; `all` = every fully-tracked vehicle, load-test) bounds how many
      vehicles the engine renders for. Reported as `renderScope`/`served` on health.
- [x] **Upstream load reduction**: the bridge (`scripts/bridge.ts`) follows the selection
      (subscribes upstream to only selected vehicles), tenant-scopes fleet discovery, and drops
      byte-identical retained re-publishes (`UPSTREAM_DEDUP`) — a large cut to the inbound firehose.

**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:
- **Retained-topic startup flood**: when the engine (re)connects against a large fleet,
  the broker replays ~5 retained topics × N vehicles at once — multi-second lag spikes
  at 2000+. Worth throttling initial backfill (paced subscribe or ingest queue).
- **`MaxListenersExceededWarning`** at 4000: >1000 `drain` listeners piled up on the
  MQTT socket — publish backpressure needs a shared drain wait, not one listener per
  pending publish.
- **`engine/fleet` payload is O(fleet)** (~940 KB at 4000, republished every second) —
  should be gated off or decimated when no dashboard is subscribed.

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)
- [ ] Move off Azure **F0** to a tier with adequate TPS, **or self-host Piper** for
      unlimited local throughput (no per-char cost, no rate limit). *(the only remaining gap)*
- [x] Shared **warm cache** keyed by sha256(`text+voice+model+format`): an **Azure Blob Storage**
      layer already backs the mem/FS cache (`CACHE_BLOB_SAS_URL`), and **pre-warming** is
      implemented (`prerender.ts` warms upcoming-stop phrases). Redis/S3 remain optional future
      layers. Stop-name phrases are finite per route, so the cache converges to cheap hits.
- [x] Retry with backoff on HTTP 429 (`ResilientTtsProvider` retries transient failures incl.
      429 with exponential backoff + jitter and a half-open circuit breaker; only 401/403 skip
      retry); provider concurrency is bounded by `MAX_CONCURRENT_RENDERS`.

**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:

- **(a) Deterministic shard by key** — run N engines, each owning a slice of vehicles
  (by tenant, or a `shard` topic level the bridge adds:
  `mta-maryland/{shard}/{vehicleId}/pis/0/#`, each worker subscribing its shard).
  Sticky, simple, no shared state. **Cleanest if the bridge can add a shard level.**
- **(b) Stateless workers + Redis** — move `VehicleContext` + snapshots into Redis so
  any worker can process any message, and use **MQTT 5 shared subscriptions**
  (`$share/group/+/+/pis/0/#`) so the broker load-balances the fleet across the group.
  Elastic / auto-scaling, at the cost of a Redis read/write per message.

**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
- [ ] Broker: **EMQX** (native clustering + shared subscriptions) instead of a single
      Mosquitto for 2000+.
- [ ] Kubernetes `Deployment` per shard (or HPA in the stateless model, scaling on
      event-loop lag / backlog).
- [ ] Observability: Prometheus + Grafana; alert on render latency, 429s, loop lag.
- [ ] Dedup / idempotency; at-least-once handling; graceful drain on shutdown.

---

## 4. Capacity estimate for 2000 vehicles

- **Compute:** 2–4 sharded/stateless workers give ample headroom for ~3–6k msg/s
  ingest and ~15–30 renders/s.
- **TTS:** the real sizing lever — a paid Azure tier *or* a couple of Piper instances;
  warm cache keeps it cheap.
- **Memory:** ~100–300 MB per worker **with bounded caches** (mem LRU + FS cap + context TTL — all in place). Measured RSS ~250 MB at 2000 vehicles.
- **Broker:** EMQX cluster (or a well-sized Mosquitto) + WebSocket listener for dashboards.

**Verdict:** achievable with Phase 0 hardening + a proper TTS tier + Phase 2a sharding
+ EMQX. **Not** by pointing today's single process at 2000 buses.

---

## 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.

---

<a id="on-vehicle-deployment"></a>

# 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](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`](docker-compose.vehicle.yml) and the multi-arch
[`Dockerfile`](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):

```bash
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:

```bash
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:

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

Convenience scripts:

```bash
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

- **File-first (default).** The mounted `./config/announcement-config.json`. Fully offline-capable;
  edit it and restart to change what the bus says.
- **Live (opt-in).** Set `CONFIG_API_URL` to the portal's base URL (plus `CONFIG_API_TOKEN` if the
  API requires one). The engine fetches `/api/engine-config` and refreshes on the retained MQTT
  `config/updated` nudge, so a **Publish** in the Creator hot-swaps the bus's config in
  ~1 s. If the bus loses connectivity the engine retries with capped backoff at flat memory — it
  never blocks announcements and never crashes.

### 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`](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:

```bash
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.

---

<a id="hanover-led-signs"></a>

# 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/`](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

```mermaid
flowchart LR
  subgraph Authoring
    CR["Creator<br/>LED templates · playlists"]
  end
  subgraph Engine
    REN["Render<br/>one 1-bpp bitmap per sign"]
    FF["Mobitec FF encoder<br/>engine/ff"]
    HAN["Hanover HCPS/SuperX encoder<br/>engine/hanover"]
  end
  subgraph Vehicle
    GW["RS-485 gateway<br/>or Hanover infohub"]
    SIGN["Hanover sign<br/>DIP address 0–F"]
  end
  CR --> REN
  REN --> FF
  REN --> HAN
  FF -- "…/display/ff topics" --> GW
  HAN -- "…/display/hanover topics" --> GW
  GW --> SIGN
```

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, `0`–`F`).
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:

- **A SuperX picture message** (what the engine emits for all graphic output):
  `{\mode0[\sl|\sr\repN]{\pic\picwW\pichH HEX}}` — the bitmap packed as a **vertical raster**
  (column-major, `ceil(rows/8)` bands per column, LSB = top row of each band) and hex-encoded.
  A 2×5 bitmap packs to two bytes, e.g. `{\mode0{\pic\picw2\pich5 1506}}`. `\sl\rep0` scrolls
  left forever. A picture over SuperX's **32 767-byte** `\bin` cap makes the encoder throw rather
  than ship a frame the sign would truncate.
- **Hilde text** (`encodeHanoverText`) — plain CP1252 text with `<` line separators, for simple
  text-mode signs and for clearing (an all-spaces body is the documented "blank the sign" frame).
  Unmappable characters degrade to `?`; the engine's graphic path never needs this (it ships
  pixels, so RTL/Arabic/diacritics work exactly as rendered).

## 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/`](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:

```ts
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

- **Announcement sign text** (interior) and the **exterior destination faces** need no authoring
  change at all: with Hanover output enabled they are twinned onto the Hanover topics
  automatically.
- **LED-template signs**: set `protocol: 'hanover'` on the sign's roster entry (alongside its
  `address`) and that face publishes HCPS frames instead of FF. Signs without the field default to
  `ff`, so a roster can mix vendors face by face.

## 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:

- **Byte-exact against the vendor docs**: both checksum examples from the Hilde/HCPS note and the
  16×16 raster worked example from *Guide to SuperX* §9 reproduce exactly.
- **Round-trip**: any rendered bitmap encodes → decodes to identical pixels; a flipped byte on the
  wire is detected (`checksumOk: false`), which is the checksum's whole job on a shared RS-485 line.
- **Pipeline parity**: the published interior Hanover frame decodes to the *same pixels* as the
  published sign bitmap, scroll flags mirrored.
- **Protocol split**: a `protocol: 'hanover'` roster sign gets an HCPS frame and no FF, and the
  concatenated exterior payload splits cleanly on ETX (`matrixParity`, `hanoverEncoder` suites).
- **Limits enforced**: the 32 767-byte SuperX cap throws instead of truncating; out-of-range
  addresses are rejected at encode time.

**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.

---

<a id="security"></a>

# 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

- **No secret leakage.** No `.env` is tracked or has ever been committed, and — since the Azure
  migration — **no key of any kind ships in the repo or in a browser bundle**: the Creator talks
  same-origin to the portal API, and every credential (`DATABASE_URL`, `CACHE_BLOB_SAS_URL`, TTS
  keys, `DASHBOARD_TOKEN`) is read from the environment server-side only and never reaches the
  browser (verified `withCreatorEnv`, `GET /config`, and the Angular bundles). `.gitignore` now also
  blocks any future `.env.*` variant.
- The real risks are **cross-tenant authorization gaps in the portal API** (tenant is still a
  client-supplied parameter until real sign-in lands) and an **unauthenticated LAN
  server** — see Open Risks.

## 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 disclosure** — `GET /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)
- **Portal writes** gated by the `DASHBOARD_TOKEN` bearer (constant-time compare).
- **Database private**: Azure Database for PostgreSQL reachable only from the VM
  (allow-list / VNet), `sslmode=require`; browsers and vehicles never connect to it — vehicles use
  the portal's `GET /api/engine-config` (`CONFIG_API_URL` + optional `CONFIG_API_TOKEN`).
- **Secrets** live in the VM's `$HOME/.env` (gitignored pattern; never in the repo, never in a
  bundle).

### 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.

---

<a id="licensing-packaging"></a>

# 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 |

---

## 3. Recommended bundles

| 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):

- Average selling price (ASP) on **Full PIS** = **€20 / vehicle / month** (rounded blend).
- Gross margin after cloud TTS/hosting ≈ **70%** (TTS cache keeps COGS flat at steady state).
- Year-1 attach: 60% Starter → upsell 40% to Full PIS by month 18.
- No professional services in the recurring table (services quoted separately).

### 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

- [ ] Finalize currency and region (EUR / USD / SEK).  
- [ ] Choose seat-based vs fleet-included policy for L2/L6.  
- [ ] Define TTS included pool and overage metering.  
- [ ] Map SKUs to Suite price book / CPQ.  
- [ ] Pilot commercial terms (discounted Full PIS for 4–6 weeks).

---

<a id="test-report"></a>

# 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:
> ```bash
> 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 clients** — `readspeaker` (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 side** — `fnt.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 parsers** — `matrix/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.

---

## 5. Additional test cases not run (recommended)

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`](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:**
```bash
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.**
- **Every component** is rendered under TestBed and has its public methods, computeds and (via
  dispatched DOM events) its template event-listeners exercised — not just constructed. The Leaflet
  components (`map`, `monitor-cockpit`, `geofences`, `simulator`) are driven method-by-method without
  rendering the map (jsdom has no layout for Leaflet).
- **Every service** has behavioural tests: `ThemeService`, `UiStateService`, `AlertsService`,
  `MqttService` (dashboard); `Tenant`, `Config`, `Project`, `Publish`, `Session`, `Gtfs`,
  `TtsCatalog` services (creator) — covering load/fallback, persistence, fetch success/error paths,
  undo/redo, derived signals, and the per-supplier capability set behind the Voices badges and the
  playlist editor's volume warning.
- **All pure logic**: `led-render`, `led-layout-render`, `led-expression`, `led-validation`,
  `led-trigger-override`, `led-vehicle-state`, `triggers` (dashboard); `lint`, `preview`, `gtfs` and
  the `announcement` / `custom-trigger` / `led-template` models (creator) — including the geofence
  geometry (`haversineM`, `pointInPolygon`, `pointInGeofence`) and the full custom-trigger evaluator.

**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`
- spoken is included only when it differs from transcript
- speaker routing follows outputType (integer 0–100)
- audio content is base64 of the mp3, encoding is the ADT enum "MP3"
- clipKey is a content hash: stable for identical audio, different for different audio
- conforms to the ADT 4.x message shape

### `test/assistantAi.test.ts`
- defaults to a local Ollama when nothing is configured
- a bare host is completed to the chat-completions path
- a remote provider is labelled by host, not called "local"
- a nonsense timeout falls back to the default rather than disabling the timeout
- the request carries the brief, the catalogue and the question, at a low temperature
- a per-request model overrides the configured one
- parseAssistantJson reads plain JSON, fenced JSON, and JSON with chatter around it
- returns the plan and who produced it
- sends a bearer token only when one is configured
- an empty question never reaches the provider
- a refused connection explains that no model is running, and is retryable (503)
- a missing model says how to pull it
- bad credentials and rate limits are named as such
- a reply that is not a plan fails the turn instead of returning an empty one
- a JSON array is rejected — the plan must be an object
- a slow provider gives up rather than hanging the request

### `test/audioFile.test.ts`
- a preset MP3 is fetched once, then served from cache
- a transient 500 is retried, then succeeds
- a permanent 404 is not retried; the clip is dropped but the announcement still plays

### `test/audioFormat.test.ts`
- every bundled preset clip matches the TTS output format (24 kHz mono)
- the engine’s default TTS format is mono — a stereo default would break every preset

### `test/breaker.test.ts`
- circuit breaker: open fast-fails, then half-open admits exactly one probe

### `test/cache.test.ts`
- in-memory cache is a bounded LRU with hit/miss stats
- a key evicted from the memory LRU is still served from disk, not re-synthesised
- a THROWING shared-cache layer degrades to a miss — it must never silence the fleet
- the on-disk cache is pruned to fsMaxFiles — a 24/7 worker must not fill the disk

### `test/configEnv.test.ts`
- required: MQTT_URL is mandatory
- Hanover output: on by default with the documented DIP addresses; disable + remap via env
- TTS_FALLBACK_PROVIDER: unset → absent, valid → carried, typo → throws at boot (not mid-outage)
- tts provider: only the selected provider’s credentials are enforced
- RENDER_SCOPE: unset → selection, all → all, typo → throws
- num: unset/empty/non-numeric fall back; a valid number is parsed
- configSource.enabled: on only when not disabled AND a url AND a key are present
- SKIP_INCOMPLETE_ANNOUNCEMENTS: default true; only "false" disables
- STOPS_STRICT: default false; only "true" enables
- default-on toggles (resilience, audio cache, display, exterior, ff) flip on "false"
- display format / text source enum-ish env vars
- optional paths and creds are undefined when unset, populated when set
- sensible defaults for topics and tuning knobs

### `test/configStore.test.ts`
- remote-first: a published config wins over the on-disk file
- a THROWING remote falls back to the last-known-good file — the fleet keeps announcing
- a remote that returns nothing (not published yet) also falls back to the file
- no config file at all → boots EMPTY rather than crashing the engine
- a CORRUPT config file throws — it must not be mistaken for "empty"
- a config missing playlists/triggers is rejected, not half-loaded
- concurrent loads are serialised — a SLOW STALE fetch cannot overwrite a newer config

### `test/creatorApi.test.ts`
- GET /api/projects: scoped query, rows straight through
- GET /api/engine-config: row mapped to the wire shape; no row → null
- POST /api/publish: one transaction — upsert, monotonic version, commit — then notify
- POST /api/publish: a failing version insert rolls back and does NOT notify
- POST /api/publish: body validation — missing fleet/config → 400, proto keys refused
- writes are gated by authorized(); reads are open
- DELETE /api/projects/:id is tenant-scoped and validates the uuid
- unknown /api paths fall through unhandled (dashboard-server keeps routing)

### `test/customTriggers.test.ts`
- comparators coerce the authored value to the fact’s type
- a numeric comparator on a non-numeric fact is false, never NaN-true
- `changed` fires only against the PREVIOUS snapshot — and never on the first ingest
- groups: all/any/negate, and an empty group is not accidentally true
- geofences: a point inside a polygon, and `outside` is the true inverse
- becomes-true fires on the RISING EDGE only — not on every ingest the condition holds
- while-true fires on every ingest — throttled ONLY by the cooldown
- a flapping condition cannot machine-gun a becomes-true trigger — the cooldown still applies
- edge state is PER VEHICLE — one bus firing must not suppress another
- a disabled trigger (or one with no playlist) never fires — and forgets its edge
- forget(vehicle) drops that vehicle’s state — memory must not grow with a churning fleet
- connectionPlatformKnown reports whether the feed actually gave us a platform
- viaPassed + mainStops facts come from the stop list against the upcoming stop
- a departed stop leaves the sign at once — even while linkprogress still points at it
- lineCode + destinationNumber facts carry the driver-keyed code and its line code
- customTriggersUseGnss only reports true when a geofence block is actually present
- tripId / boarding / extraText / countdownMinutes facts, and their absent-feed defaults
- countdownMinutes rounds up, and is null rather than 0 when nothing is counting down

### `test/customerByConfiguration.test.ts`
- a different case rule: Title Case, from the same textTransform primitive
- a different split: on the word "via", keeping either side
- a journey with no "via" leaves the rules inert rather than blanking the sign
- a different symbol rule: exact line CODE, not a keyword in the destination
- a different inverted-sign rule: a REGEX on the line number, not a contains
- a different trigger: a numeric comparator on occupancy takes the front sign
- the cycle picks the calling-points arrangement only when the journey has calls
- the whole operator publishes real frames on the wire

### `test/destinationList.test.ts`
- a code-only selection on pis/0/destination resolves to the stored destination
- a manual destination/override selection wins over the destination topic
- a PRE-PROGRAMMED destination wins over the feed name / line the code also carried
- a partial pre-programmed entry only overrides the fields it defines; the feed keeps the rest
- applyDestination in FILL mode lets the feed win, filling only what the feed left blank
- destinationModes maps the three priorities to per-consumer override/fill
- no list, no active pointer, or an unknown code changes nothing
- a line-bound code falls back to the any-line entry when its line does not match
- a string-typed number from a loose feed still resolves

### `test/diagnostics.test.ts`
- redactUrl strips broker credentials
- hostPort applies each scheme default port
- browserWsUrl advertises a host-relative default, and honours the overrides
- resolveWsUrl substitutes the caller-visible host
- probeTcp reports a listening port as reachable
- probeTcp reports a closed port as unreachable rather than throwing
- engineAlive fails when nothing at all is retained
- engineAlive passes on a fresh beat, and says how fresh
- engineAlive fails on a STALE retained beat rather than reporting a dead engine as alive
- engineAlive tolerates a few missed beats before calling it dead
- engineAlive never reports ok when the beat has no usable timestamp
- summariseConfig counts the file fallback, and flags an unreadable one instead of throwing
- probeMqtt captures the retained heartbeat even behind a large retained backlog
- probeMqtt still returns promptly when the engine topics are genuinely absent

### `test/engineBoot.test.ts`
- startEngine boots against a real broker, ingests a journey into the fleet directory, and stops cleanly
- publishes engine/health RETAINED, so a late subscriber learns the engine is alive immediately

### `test/engineDestinations.test.ts`
- default list priority: the pre-programmed destination wins on the SIGNS over the feed name
- list priority "fill": the feed wins on the SIGNS; the stored list is only a lookup fallback

### `test/expectedCoverage.test.ts`
- tripOverlap = fraction of announced stops found in the trip (name-normalized)
- pickBestTrip chooses the highest-overlap trip and requires a real match
- pickBestTrip breaks ties toward the tighter trip and lets headsign nudge
- journeyCoverage flags expected stops with no confirmed announcement as missed
- computeExpectedCoverage: a stop never announced shows up as missed against the GTFS trip
- computeExpectedCoverage: a journey with no route match is reported unmatched, not mis-scored

### `test/exteriorSign.test.ts`
- face text: front/side show route + destination, rear shows route only
- each face is 24px high with the configured width; rear has no destination zone
- the route zone never exceeds 25% of the sign width
- the route renders at full height and the standard advance (no size-shrink)
- short destination is full size and static (no scroll)
- a destination that fits its zone renders full size and static (no condensing)
- a destination too wide for its zone scrolls (full size — never condensed tighter)
- returns null when there is neither route nor destination; route alone is enough
- signature changes only when route or destination changes

### `test/fallbackCache.test.ts`
- resilient fallback buffer is tagged; real synth output is not
- fallback audio plays but is NEVER written to the (fleet-wide) cache

### `test/ffEncoder.test.ts`
- checksum matches protocol §4 / example 1 (0x3FF → 0xFF → FE 01)
- graphic font w: a full 5-dot column encodes as 0x3F at y=4 (font doc example 1)
- an empty column is 0x20, a top-dot-only column is 0x21 (LSB = top)
- frame is well-formed and the checksum validates
- round-trips an arbitrary rendered bitmap (encode → decode → identical pixels)
- address is carried verbatim (1 front, 2 side, 3 rear, 10 interior)
- scroll frame: 0xD5 carries 4 area coordinates + 4 settings, immediately after 0xA5
- scroll defaults: continuous right-to-left, scrolling until the next message
- scroll frame round-trips: settings and pixels both survive encode → decode
- scroll speed rounds to a rate the 77 Hz panel honours
- decode rejects a scroll frame whose 0xD5 does not immediately follow 0xA5
- an over-limit scroll frame throws rather than being silently dropped by the sign
- a non-scroll frame is untouched by the scroll support (still 0xA2, no 0xD5)

### `test/ffRgb.test.ts`
- rgbTo444 reproduces the spec examples
- escapeRgbData applies §7.6.4: fd→fd00, fe→fd01, ff→fd02
- colour table: 0xD6, count, 16-bit big-endian RGB444 entries (colour 0 = background)
- text colour: 0xD7 + table index
- colour bitmap: mode 1, column-wise pairs, even sizeY, escaped wire vs raw sum
- odd-height bitmaps are padded to an even sizeY with black
- encodeFfRgb frames COLORTEXT + table + bitmap with a pre-escape checksum
- a sign too wide for the one-byte size field is rejected, not silently truncated

### `test/fleet.test.ts`
- empty map yields an empty fleet
- derives every FleetVehicle field from the aggregated topics
- missing optional signals collapse to empty/null, not crashes
- stopsLeft is null and totalStops null when the stop list is not current (strict + no refs)
- has stops but no nextStopSeq → stopsLeft null while totalStops still counts
- phase maps door-open to AT_STOP and off-route to OFF_ROUTE
- in-service vehicles sort ahead of NOT_IN_TRAFFIC, then by freshness
- ties within the same phase and freshness break by numeric vehicle id
- maxAgeMs drops ghosts that stopped publishing; 0 keeps every context
- fleetPayload under the cap passes through untouched — no total, nothing dropped
- fleetPayload over the cap drops the TAIL (least relevant), stays under maxBytes, and says so
- fleetPayload cap 0 disables capping entirely

### `test/fnt.test.ts`
- parses every Luminator .fnt file with sane metrics
- glyph shape sanity: the 7px A has a hole (not solid, not empty)
- fntMeasure sums widths + spacing; fntGlyph falls back to uppercase
- fontLadder sorts tallest → smallest; ladderFor honours the user order and falls back
- Lithuanian text falls back to base letters, never to the @ substitution glyph
- a character with no latin base still shows the font substitution glyph
- the blank placeholder cells are not drawn as wide holes
- measuring and rasterising agree about a folded string
- fntUnsupported reports what had to be substituted, in order, once each

### `test/geofencePolygon.test.ts`
- moving a vertex reshapes the ring, and the original array is left alone
- a moved vertex actually changes what is inside the fence
- an out-of-range vertex index is ignored, not appended
- inserting a vertex puts it exactly where the edge was clicked
- the last edge wraps back to the first vertex
- adding a vertex on an edge does not change the enclosed area
- removing a vertex drops it and closes the ring
- a triangle refuses to give up a vertex — two points enclose nothing
- the guard is what keeps a collapsed fence from silently matching nobody
- a square may lose a vertex, becoming a triangle
- editing is immutable throughout — no helper writes to its input
- the centroid of a square is its middle
- moving a polygon translates it without deforming it
- a moved polygon takes its containment with it
- moving a circle relocates its centre and keeps its radius
- a cloned polygon does not share its vertex array with the original
- a cloned circle is a real copy too

### `test/gtfsFeed.test.ts`
- routes() parses fields and sorts by short name naturally
- status() reports the feed name and entity counts
- values() returns distinct, naturally-sorted lines, destinations and stops
- trip() returns stops in sequence with names, coords and formatted times
- trip() returns null for an unknown trip id
- a feed missing shapes.txt does not crash; shape falls back to stop coordinates
- tripsForRoute() lists a route's trips ordered by direction then start time
- an empty / missing routes.txt yields no routes rather than throwing

### `test/hanoverEncoder.test.ts`
- Hilde doc example 1: [STX]01345<Køge St.<Øster Banevej[ETX]F3, byte-exact
- Hilde doc example 2: [STX]01345%Øster Banevej[ETX]6A checksum
- checksum is the two’s complement of the content sum (00 at the wrap)
- SuperX §9 worked example: 16×16 triangle packs to the documented ascii-hex
- graphic frame: SuperX picture page wrapped in a valid HCPS frame
- vertical raster: LSB is the top row of each 8-row band, top band first
- round-trips an arbitrary rendered bitmap (encode → decode → identical pixels)
- scroll option adds \sl/\sr + \rep to the page header
- address is one uppercase hex digit; out-of-range addresses throw
- CP1252: high-block specials map to single bytes, unmappable chars become ?
- infohub bridge: topic and JSON envelope match the Go SDK
- decode rejects malformed frames and FLAGS a corrupted byte (checksumOk=false)
- a picture over the 32767-byte SuperX \bin cap throws instead of shipping a broken frame
- exterior topic contract: concatenated self-addressed frames split cleanly on ETX
- an all-spaces Hilde body is a valid frame (the documented way to clear a sign)

### `test/historyLog.test.ts`
- appends are serialised — 200 rapid records produce 200 intact JSON lines, in order
- the file is trimmed to `max` — a 24/7 worker must not fill the disk
- the trim is atomic — the file is never left empty or half-written
- a THROWING durable mirror must not break the local audit trail
- flush() waits for queued writes — the tail is not lost on shutdown

### `test/ledAbbrev.test.ts`
- value mode replaces the whole value, case/space-insensitive
- word mode replaces every occurrence at word boundaries only
- phase: only `always` rules apply in the "always" phase; all apply in "all"
- value rules run before word rules; collapses double spaces
- regex metacharacters in the match are treated literally
- text no rule touched is returned verbatim, spacing included
- suggestAbbreviations offers the last word then the initials
- empty/absent rules are a no-op

### `test/ledDestinations.test.ts`
- an exact (number, lineCode) match wins over the any-line entry for the same number
- a number nothing covers resolves to null (the live feed keeps the sign)
- a line-bound code is not served to another line when there is no wildcard
- destination numbers parse from the Integer and the string shapes a feed may send
- rows drop trailing blanks so a one-row destination stays one row
- globalState feeds the tokens the renderer already reads
- an unsupplied row 2 still falls back to the via place (the existing token rule)
- empty fields are omitted rather than written as blanks (they must not mask live data)
- the face override is only reported for faces the destination actually binds
- the list payload is the spec shape (number, name, lineNumber only when set)
- duplicate codes for the same line are reported (the later one is unreachable)
- the active list is the one the pointer names, and a dangling pointer resolves to none

### `test/ledEncoder.test.ts`
- bitmap encoder produces a self-describing base64 payload
- ff encoder is a documented, not-yet-implemented extension point

### `test/ledMatch.test.ts`
- matchValue: eq is case- and whitespace-insensitive, contains looks anywhere
- matchValue: regex — the deck depot patterns against real trip ids
- matchValue: an empty expected value never matches — a half-filled rule claims nothing
- matchValue: an absent live value is just an empty string, never a crash
- a broken or oversized pattern fails CLOSED and never throws
- an exponentially backtracking pattern is REFUSED, not run
- the shape screen does not refuse the patterns real sign rules are written with
- the subject a pattern is tested against is capped
- compilePattern: caches by pattern AND flags, and stays usable after the cache is cleared
- a cached pattern gives the same answer every time (no lastIndex carry-over)
- matchParts: capture groups, falling back to the whole match
- the expression evaluator exposes Matches() — and still no other globals
- the custom-trigger `matches` comparator reads the same rule
- matchParts: EVERY match contributes, so a list of unknown length splits fully
- matchParts: a zero-width pattern terminates instead of spinning forever
- matchParts and matchValue do not share regex STATE across calls

### `test/ledMideastDemo.test.ts`
- the trilingual demo vehicle carries the low-res panels (front, colour, side, rear)
- every RTL scenario resolves its expected layout on the demo vehicle and lights the panel
- no mono panel renders as a solid block — text is never swallowed by its own field
- the line badge punches the digits out of its filled cell (an inverted badge, on mono)
- the front destination ROTATES Arabic → Hebrew → English (three different frames)
- the no-boarding notice shapes the lam-alef ligature (لا) and renders it
- the route simulation walks every stop; each state lights every exterior panel
- the client draws each mono RTL scenario pixel-identically to the engine's published frame

### `test/ledRender.test.ts`
- short text renders a static, centered 16×144 frame
- text wider than the viewport is marked scroll and keeps full width
- scroll threshold is at the viewport width (6px advance)
- characters advance by 6 → exactly one blank column between them
- 'I' rasterizes to the expected doubled glyph rows (MSB packing)
- Swedish Ö composes an umlaut in the top rows (not the fallback box)
- a space is entirely unlit

### `test/ledRouteColors.test.ts`
- routeColorFor: exact, case-insensitive, line number first then the technical code
- routeColorFor: a lookup is EXACT — line 1 must not match line 11
- routeColorsFromRoutes: lifts a GTFS feed, tolerating the spec's hash-less hex
- routeColorsFromRoutes: a feed with no colours yields NO rows, not rows of black
- routeColorsFromRoutes: one row per line — a repeated short name does not duplicate
- withRouteColors publishes the matched row into the state, and nothing else
- the SIGN background is not touched by the table — that was the bug
- a layout CAN still take the whole panel in the route colour — by saying so
- a layout colour RULE still wins over anything the element does with the livery
- an element wearing the livery colours ONLY its own box on the published frame
- all three colours are published — background, text AND outline
- the outline reaches the drawn text — a light brand colour stays readable

### `test/ledRtl.test.ts`
- isRtlChar / isRtlText detect Hebrew and Arabic, not Latin
- shapeArabic picks the correct contextual forms
- shapeArabic fuses lam-alef into a single ligature
- reorderVisual reverses Hebrew to visual order
- reorderVisual keeps a European number LTR inside RTL text
- toVisualForRender is a no-op for pure Latin (LTR path unchanged)
- fntChars/fntMeasure resolve Arabic + Hebrew to drawable glyphs with non-zero width
- a Text element renders Arabic and Hebrew with lit pixels (not blank, not placeholders)
- RTL and LTR of the same length differ in pixels (proves direction is applied)

### `test/ledShapes.test.ts`
- line: horizontal, vertical and diagonal runs are continuous and inclusive
- line: stroke width thickens the run
- rectangle: filled covers the box; an outline leaves the middle dark
- circle: an outline is a closed ring, and filling it fills the disc
- polygon: a filled triangle is solid and stays inside its bounds
- every built-in public-transport symbol draws ink at sign sizes, and stays in its box
- a hand-drawn size is used for the panel heights it covers, primitives elsewhere
- the drawn art is what actually lands on the panel, centred in the element
- every hand-drawn grid is rectangular and fits the height it claims
- the public-transport set covers park & ride, rail replacement and the school bus
- symbol ids are unique and every one has a label and a category
- the renderer draws Line / Ellipse / Symbol elements, tinting them with the text colour
- an existing Rectangle stays FILLED (no `filled` flag = the old behaviour)

### `test/ledSignClient.test.ts`
- the HTML client renders pixel-identically to the engine's published front frame
- parity holds across live values (the sign follows the same content the engine publishes)
- parity through a colour rule that INVERTS a mono sign (background compositing agrees)
- parity when a size-mismatched layout is re-canvassed to a bigger panel
- the client output is strictly binary — monochrome, never a grey value
- signSpecs: skips interior/announcement panels; pickSign selects by face or id
- signSpecs shows the FOLLOWED vehicle's own panels, falling back to the active vehicle
- a colour panel resolves as rgb; a mono panel as mono

### `test/ledSignVilnius.test.ts`
- the demo vehicle carries every Vilnius panel: front, colour, side, left, rear
- every deck scenario resolves its expected layout on the demo vehicle, through the CLIENT path
- all 17 documented cases cover all 13 preset layouts — nothing is unreachable in the demo
- the client draws each mono scenario pixel-identically to the engine's published frame
- the vehicle board resolves EVERY panel from one shared state, engine-identical, for every case

### `test/ledStops.test.ts`
- routeStopsFromList: call order, timing flags under any spelling, junk rows dropped
- mainStops: timing-point-flagged stops win when the feed marks any
- mainStops: no timing flags → every remaining stop is a main stop
- mainStops: no journey progress yet → the whole route; junk rows are skipped
- firstStopAheadSeq: a DEPARTURE/PASSAGE for the pointed-at stop moves the cut past it
- firstStopAheadSeq: the vehicle is still AT the stop it has arrived at
- firstStopAheadSeq: only stopinfo about the POINTED-AT stop counts — a retained one can’t skip stops
- mainStops: a stop the vehicle has left is gone from the row before linkprogress catches up
- mainStopsText: sign-ready comma line, empty when nothing remains
- STOPPING_AT_TEXT_EXPR: prefixes the label only while main stops remain
- {mainStops} token: resolves as a plain globalState fact for text-mode elements
- bottom-row layout: ink in the stops row while stops remain, none once the list is empty
- routeStopsFromList reads the boarding flag under each spelling the feeds use
- routeStopsFromList leaves the flag ABSENT when the feed never mentions boarding
- boardingAllowedAt: only an explicit restriction says no
- NO_BOARDING_EXPR gates on the explicit false only — never on a missing fact

### `test/ledTemplate.test.ts`
- evaluator: optional chaining, object-map idiom, comparisons
- evaluator: contains via compiled string method, and && / || / ??
- evaluator: cannot reach dangerous globals (no eval)
- interpolateTemplate: mixes static text with live MQTT/PIS variables
- interpolateTemplate: destination rows and destination symbols
- cycle: first enabled cycle wins
- cycle: rotation advances by elapsed time
- cycle: a sub-cycle left at duration 0 still gets a slot in the rotation
- cycle: switch/case selection
- layout: renders text into the correct regions
- layout: static text with an inline {token} renders the resolved value
- layout: empty expression renders nothing lit; packed frame matches dims
- Scale: text too wide for every font falls back to SCROLLING (readable head at t=0, moves later)
- Scale: fitting text stays static; an abbreviation that fits beats scrolling
- Scale: scaleScrollFallback:false restores clip-at-smallest (static, no scrolling)
- Scale readable floor: static text never renders below the min height; forced floors scroll instead
- textFrames: the pipeline is resolve → split → transform → rotate
- textFrames: an element with no rules behaves exactly as before
- an inverted colour rule produces a real inverted FRAME, not just preview colours
- a Lithuanian destination rasterises as its base letters, not as @

### `test/ledTextRules.test.ts`
- applyTextTransform: upper/lower/title, including Lithuanian diacritics
- applyTextTransform: unset or "none" leaves the text exactly as written
- splitTextParts: bracket — the deck Req 2 headsign
- splitTextParts: comma and dash — deck Req 3 and Req 7
- splitTextParts: via delegates to the one via rule, and regex uses its capture groups
- splitTextParts: empty input yields no parts; no rule yields the whole value
- pickTextPart: first/rest/last/index, and an index past the end shows NOTHING
- applyTextSplit: deck Req 2 rows, and no rule is a no-op
- dynamicAlternations: a value that does not split gives ONE frame (the rule stays inert)
- DESTINATION_HAS_BRACKET_EXPR gates exactly on a bracketed headsign

### `test/ledVia.test.ts`
- splitViaDestination: infix "via" splits into main + via (any case, extra spaces)
- splitViaDestination: no infix via → whole text is the main part
- isViaPassed: true only once every call at the via stop is behind the upcoming stop
- isViaPassed: anything unknowable keeps the via visible (false)
- HAS_VIA_DESTINATION_EXPR: true on infix via or a viaName fact, false otherwise
- HAS_VIA_DESTINATION_EXPR: the viaPassed fact turns the gate off once the via stop is behind
- {destinationMain}/{destinationVia}: split tokens resolve; viaName fact wins for the via row
- cycle swap: via in the destination selects the two-row layout, plain destination the default
- cycle swap: once the via stop is passed the display falls back to the default layout
- two-row via layout renders ink in BOTH rows (destination above, via below)

### `test/lexicon.test.ts`
- respells whole words, case-insensitive
- longer entries win over sub-words
- does not touch partial matches
- no lexicon leaves text unchanged
- ssml: an IPA entry becomes a <phoneme> tag
- ssml: a say-as entry wraps the match
- ssml: a plain phonetic entry respells exactly like applyLexicon (raw text, builder escapes it)
- ssml: entries are scoped by language
- ssml: attribute + inner text are XML-escaped
- ssml: empty/undefined lexicon leaves text unchanged
- matches non-ASCII names that ASCII \b never could (Malmö, Örebro)
- non-ASCII match still respects whole-word boundaries
- ssml: an IPA entry fires for a non-ASCII name
- normalizeIpa strips surrounding /…/ and […] delimiters
- validateIpa accepts good IPA and normalizes delimiters
- validateIpa rejects the classic authoring mistakes
- ssml: an unusable IPA degrades to the respelling instead of emitting broken SSML
- ssml: IPA wrapped in slashes still becomes a clean <phoneme>

### `test/liveVehicles.test.ts`
- reports one entry per vehicle inside the window, newest first
- the route and destination reported are the vehicle's LATEST, not its first
- drops vehicles that went quiet before the window
- an unparseable timestamp is skipped, not treated as the end of the window
- older vehicleKey-only records resolve to the same identity as newer split fields
- a tenant-less deployment yields an empty tenant, not a bogus one
- records with no vehicle at all are ignored
- an empty log is quiet rather than an error

### `test/manifestSync.test.ts`
- engine ↔ CreatorStudio manifest are in strict bidirectional parity
- the engine implements the expected 33 CreatorStudio trigger types
- service-disruption is NOT a trigger type (removed to match CreatorStudio)

### `test/matrixParity.test.ts`
- png: decodes 8-bit RGBA with colours + alpha mask; rejects non-PNG
- engine colours: colour rule (line 5 → blue bg / black outline / white text) resolves + renders
- engine FNT: text renders with the real font ladder (ink fits the 16px region, taller than 6px)
- engine symbols: {lineNumber} draws the mapped PNG when the value matches
- vertical alignment: Top / Middle / Bottom land the INK exactly (leading font, 24px region)
- vertical alignment: exact in a snug region equal to the ink height
- vertical alignment: Wrap block Bottom reaches the region bottom (ink-based)
- buildLedSigns: renders display-mode signs, skips announcement-mode, emits FF + signature
- buildLedSigns: the active vehicle roster wins; colour signs get rgb bytes + COLORTEXT FF
- buildLedSigns: a protocol 'hanover' sign gets an HCPS frame instead of FF
- buildLedSigns: a firing custom trigger overrides the front layout; display is the fallback
- buildLedSigns: the selected destination's own template beats the display, loses to a trigger
- buildLedSigns: built-in not-in-traffic trigger overrides bound faces and BLANKS unbound ones
- buildLedSigns: any held-state built-in trigger overrides (welcome aboard on journey-activated)
- png: a corrupt chunk length (high bit set) fails the decode instead of looping forever
- cycleEvaluator: condition-gated rotation entries are skipped while their condition is false
- buildLedSigns: a size-mismatched bound layout is re-canvassed to the PANEL resolution
- buildGlobalState: geo:<id> facts surface as __geofences[<id>] for cycle geofence blocks
- ledOverride: the level-type default priorities equal DEFAULT_TRIGGER_PRIORITIES (no drift)
- buildLedSigns: per-sign subtopic entries — kebab position slugs (deduped) + per-sign FF
- buildLedSigns: a "when tight" abbreviation is applied only where the full text does not fit
- buildLedSigns: null payload when nothing resolves
- a coloured BACKGROUND reaches the wire — the field is published, not just the text

### `test/mergeSamplePlaylists.test.ts`
- the triggers are left exactly as they were — nothing new announces
- every added playlist is unreferenced — no trigger fires one
- all 22 sample playlists land, alongside the ones already there
- re-running is a no-op — a dashboard import can be topped up, not doubled
- the added playlists speak in a voice THIS config has, not the sample's ElevenLabs one
- names are prefixed, so the 8 that clash with live playlists stay tellable apart
- a missing live config is a clear error, not a half-written file

### `test/mqttBusIntegration.test.ts`
- MqttBus connects, subscribes, and receives a parsed inbound message over a real broker
- MqttBus.publish delivers a payload to a broker subscriber
- MqttBus.publish rejects when never connected (no unbounded queue)

### `test/mqttClient.test.ts`
- a throwing handler is contained — ONE bad message must not kill the engine
- non-JSON payloads are dropped, not parsed into a crash
- a JSON scalar is not delivered — only objects are
- the /shape payload is skipped BEFORE parsing (it is the biggest message on the bus)
- topicFor substitutes tenant and vehicle, and tolerates a tenant-less topic
- publish before connect rejects rather than throwing into the caller

### `test/mqttWsProxy.test.ts`
- topicAllowed admits what the dashboard uses
- topicAllowed refuses everything else — the server holds fleet-wide credentials
- frames round-trip, preserving binary payloads
- decodeFrame rejects a truncated frame instead of inventing a topic
- relays subscribe/publish upstream, and drops what is out of bounds
- forwards broker messages as binary frames, and upstream state as status
- closing the browser socket ends its upstream connection
- a wrong token is refused when the dashboard is gated

### `test/multilingual.test.ts`
- event stopNameML picks the playlist language variant (by code)
- falls back to default name when the language is missing
- name_Multilanguage keyed by language name (Swedish) also resolves
- destination and following stops use their ML variants from state.extra
- when no ML is present the default names are still used
- {currentStop} speaks the departed stop on departing-stop, not the upcoming one
- {currentStop} still falls back to the event stop when no distinct current stop is carried
- {occupancy}, {countdown} and {geofenceName} resolve from the event payload
- unset event payloads resolve to empty, never a literal token
- service/situation body variables resolve under both CreatorStudio names

### `test/pgConfigSource.test.ts`
- subscribe: a connection loss schedules exactly ONE reconnect despite the synchronous end
- subscribe: consecutive failures back off exponentially (1s, 2s, 4s…)
- subscribe: clients grow linearly (one per reconnect), never unbounded
- subscribe: notifications are scope-filtered; an unparseable payload still refetches
- fetch: returns the config only when it has playlists + triggers
- fetch: tenant scoping adds the second filter

### `test/offlineBundle.test.ts`
- bundle round-trip: manifest, config envelope, verifiable clips and tts-cache entries

### `test/offlineExport.test.ts`
- enumerateCorpus: per-stop × stops, constant once, per-destination × destinations, volatile excluded with its tokens
- enumerateCorpus: the clip cap stops enumeration and is reported
- parity: exported clipKeys cover everything the pre-push delivers, byte-for-byte
- a {destination} job with no destination is skipped as unresolved, never exported
- RecordingTtsCache records exactly the segment entries the corpus touches

### `test/perElementVoice.test.ts`
- consecutive same-voice elements still merge into ONE tts request
- an element with its own voice splits the run (3 runs, voices in order)
- dynamic-text resolves name variants in the ELEMENT voice language
- per-element volume splits the run and flows to the voice (default 100 stays merged)
- no prosody-volume capability: authored volume is dropped and the runs STAY MERGED
- volume defaults to full (100%): unset + legacy 0-1 fractions play full; only a deliberate 1-99 percent attenuates
- SSML provider: an IPA lexicon reaches the synth text but never the transcript/spoken
- non-SSML provider: the same lexicon respells in plain text (no tags)
- a pause at a voice boundary folds into the preceding run as a trailing break

### `test/pipeline.test.ts`
- publishes ADT audio on the vehicle’s tts topic, with the transcript and speaker volumes
- exterior playlist routes to the EXTERNAL speaker and publishes no interior sign
- interior announcement also publishes the LED sign and the raw Mobitec FF frame
- the Hanover twin carries the SAME pixels as the published sign bitmap (and scrolls when wide)
- publishes engine/metrics and appends a proof-of-play record with location + trigger
- SKIP_INCOMPLETE: an unresolved variable drops the announcement entirely — nothing is published
- skipIncomplete=false: the same announcement is spoken anyway (opt-out honoured)
- superseded mid-render (abort): the audio is NOT published — no talking over the interrupt
- a trigger whose playlist does not exist publishes nothing and does not throw
- a TTS failure is contained: renderAndPublish resolves, the fleet keeps running
- repetitions: the clip is repeated and the estimated duration scales with it
- an interior+exterior trigger renders both, and each goes to its own speaker

### `test/playerBridge.test.ts`
- prefixFor builds the pis topic, with and without a tenant
- topicMatchesFilter matches a wildcard filter by prefix, not by accident
- isValidId rejects MQTT wildcards, spaces and slashes
- sseMessage is a single well-formed default-event frame
- stream subscribes once, forwards matching messages, and cleans up on close
- two players on one vehicle share a single subscription (ref-counted)
- stream rejects an invalid vehicle id before touching the broker
- stream caps total concurrent connections (503 at capacity)
- stream caps distinct vehicle subscriptions but allows more clients on an existing one
- isReplayableStateTopic keeps journey state, never announcements or rendered frames
- a second client asking for replay gets the vehicle state it missed — but no stale announcement
- a player that does not ask for replay is unaffected
- the state cache dies with its last listener, so a snapshot can never go stale
- the replay cache is bounded per vehicle
- publishAck publishes the record to the server-chosen played topic
- the played topic is configurable (browser cannot choose it)

### `test/prepush.test.ts`
- pushes the WHOLE remaining journey per-stop, journey-constant clips once, volatile never
- the pushed clipKey is byte-identical to what the live pipeline later publishes
- re-running onProgress de-dupes; a new journey re-pushes; the per-journey cap holds

### `test/prerender.test.ts`
- pre-renders the next N stops and skips volatile playlists
- a real render after warming is a cache hit (no extra synth)
- re-running onProgress de-dupes (no re-warm) until the journey changes

### `test/prerequisites.test.ts`
- requiresDoorOpen suppresses the trigger until the door is open
- requiresStopButtonPressed gates on the stop-request signal
- requiresVelocityBelow gates on GPS speed (km/h)
- no gates (or no condition) → trigger fires normally
- requiresDoorClosed is the mirror gate — blocked while the door is open
- requiresVelocityAbove gates the other way, and below+above form a window
- a velocity gate is read in the authored unit (mph)
- a gate whose signal is absent fails closed (no GPS speed → no announcement)

### `test/priority.test.ts`
- unset priority → per-type default level; safety tier interrupts, ambient does not
- flat shape: bare number + explicit flags win over tier defaults
- nested shape (trigger-manifest style) is accepted and wins
- interrupt tier is configurable
- triggerPriority returns the effective numeric level (both shapes)
- end-to-end: a high-priority alarm interrupts a playing ambient message with NO config flags
- interrupt aborts the superseded render so it can skip publishing (no double audio)
- forget() releases a vehicle slot and aborts its in-flight render

### `test/projects.test.ts`
- migrateLegacyToProject wraps the legacy config as a "Default" project
- newProject deep-clones the config (no aliasing of the source)
- duplicateProject gets a fresh id, a "(copy)" name and an independent config
- validateProjectRefs flags enabled triggers referencing a missing playlist
- validateProjectRefs ignores disabled triggers and checks sequence references
- selectPublishConfig returns the selected project config, or null for an unknown id
- upsertProject / removeProject are pure array ops
- projects carry tenant + fleet, and duplicate keeps them (multitenant scoping)
- nextSelectedId prefers the current selection, else the first project, else null

### `test/proof.test.ts`
- coverage correlates played acks to dispatched audio by traceId
- a failed ack counts as failed, not confirmed
- a later played ack supersedes an earlier failed one (retry that succeeded)
- exterior records and untraceable audio are excluded from the rate
- no dispatches → coverage is 1 (nothing owed)

### `test/proofReport.test.ts`
- report summary + per-record outcomes correlate dispatch to playback
- breakdown by route computes per-group coverage
- scope filters by route + time window (played rows correlate across the window)
- integrity hash is stable for the same data and changes when an outcome changes
- CSV has one row per dispatched announcement with its status
- HTML report is self-contained and shows the coverage headline + integrity hash

### `test/proofSink.test.ts`
- record() buffers without throwing and does NOT flush until the batch fills / flush() is called
- reaching maxBatch triggers a flush automatically
- flush() runs one parameterized multi-row INSERT into the configured table with the mapped columns
- null-able columns are emitted as null when the record omits them
- flush() on an empty buffer is a no-op (no query)
- a failing insert is contained: neither record() nor flush() reject into the caller
- an invalid table name fails loudly at construction (identifier guard)
- createProofSink() returns null unless PROOF_DB_ENABLED=true AND DATABASE_URL is set
- createProofSink() returns a ProofSink when enabled with a database

### `test/ptContext.test.ts`
- parseTopic splits {tenant}/{vehicleId}/pis/0/<sub>
- fullyTracked: discovery-only vehicles are not served; full data flips it on
- routeStops: call-ordered rows with timing flags (any spelling), staleness-aware
- deriveState resolves next stop, destination and phase
- onward connections are enriched with delay / platform / cancellation
- a sub-minute connection delay is not announced (rounds to nothing)
- stopName looks up by call sequence number
- a stale stop list (different journey ref) is not used
- countdownSeconds derives from the spec departureDateTime (not a countdownSeconds field)
- {distanceToStop} is spoken in metres — the feed unit, by product decision
- {distanceToStop} is empty (not "0 meters") when the feed omits the distance

### `test/ptEngine.test.ts`
- ARRIVAL fires arrived-at-stop naming the arrived stop
- DEPARTURE fires departing-stop naming the upcoming stop
- PASSAGE fires stop-skipped (a skipped stop), NOT departing-stop
- DEPARTURE fires departing-stop (not stop-skipped)
- consecutive ARRIVAL at a new stop re-fires
- door sensor edges fire doors-open (at an approached stop) then doors-close
- doors-open is suppressed at a stop that was never approached (depot / mid-trip connect)
- trip-start fires ONLY at the genuine start, never for a vehicle first seen already running
- approaching-stop fires when the next stop changes
- connection-info names the connections stop (its own callSequenceNumber) + destination
- detour carries stop + destination so the message is not empty (chime-only bug)
- a service alert fires situation-message (NOT detour) with text + stop
- situation-message re-fires for a REPLACEMENT alert (count stays 1, heading/body change)
- situation-message does NOT re-fire when the alert array is merely reordered
- an empty-text situation message does NOT fire situation-message
- geofence does NOT re-fire on a brief GNSS signalQuality flicker while still inside
- journey-state transitions each fire their own trigger
- the final stop announces once when both last-stop triggers are enabled
- journey-approaching-last-stop still fires when last-stop is not enabled
- stop-skipped fires when the next-stop sequence jumps forward
- one physical skip reported BOTH ways announces once
- a skip reported both ways in ONE ingest still announces once
- a PASSAGE with no callSequenceNumber still announces the skip
- a seq-less PASSAGE announces once per stopinfo edge, not on every ingest
- consecutive skips of DIFFERENT stops each announce
- serving a stop closes the skip chapter, so a later skip of it announces again
- stop-request fires on the button rising edge only
- exit-side fires on a real side, skips Unknown
- exit-side honours the authored exitSides filter
- exit-side filtered to one side still fires when the feed says Both
- exit-side with no authored filter fires on every real side
- connections and situation messages fire their own triggers
- alarm activation and destination override fire
- passenger-load fires when occupancy crosses the threshold

### `test/resilienceHardening.test.ts`
- a provider hot-swap MID-RENDER cannot cache one supplier’s audio under another’s key
- a publish that never settles must not pin a render slot forever
- publish rejects (rather than hanging) when the client is not connected at all

### `test/resilientTts.test.ts`
- retries transient failures then succeeds
- returns the fallback audio when all attempts fail
- throws when synth fails and no fallback is configured
- does NOT retry auth errors (401/403) — fails fast
- circuit breaker opens after the threshold and fast-fails without calling the provider
- describe + name delegate to the inner provider (cache key unchanged)

### `test/sampleConfig.test.ts`
- every playlist id is unique
- every trigger points at a playlist that exists
- every trigger type is one the engine can actually fire
- no two ENABLED triggers share a type — only the first would ever fire
- every {variable} used is one the resolver knows
- every audio-file element points at a preset clip that exists
- every playlist renders to real speech with live journey data
- the connection examples cover the real-time cases a feed reports
- the platform announcement is gated on the platform actually being known

### `test/scheduler.test.ts`
- serialises per vehicle with priority interrupt / queue / drop
- expired queue entries are purged

### `test/stopRecordings.test.ts`
- matchStopRecording prefers stopId, then name, and honours language
- renderPlaylist plays the recorded clip for a matched stop name (and TTSs the rest)
- renderPlaylist falls back to TTS when no recording matches
- renderPlaylist plays an inline data: URI recording without any network fetch
- renderPlaylist falls back to TTS when the recording fetch fails

### `test/synthDedup.test.ts`
- concurrent identical synths collapse into a single synth (single-flight)
- a later identical request is a cache hit, not a second synth
- non-cacheable (fallback) synths are de-duplicated but not stored

### `test/triggerHardening.test.ts`
- strict-stops distrusts an unverifiable retained list only when enabled
- settle window suppresses the retained-topic burst, then fires real changes
- gate-hold: a door-gated arrival fires when the door opens on a later ingest
- gate-hold: a held edge expires and never fires after the hold window
- name-hold: connection-info waits for the new journey stop list, then names the stop
- name-hold: expires and fires nameless (late) when no stop list ever arrives
- name-hold + gate: a gated, nameless connection-info re-resolves the name when the gate clears
- name-hold: drops (no announcement) if the connections clear before the name resolves
- name-hold off (gateHoldMs 0): connection-info fires immediately, nameless (old behavior)
- with gate-hold off (0), a gated fire is simply dropped (no hold)

### `test/triggerJobsParity.test.ts`
- the Creator resolves a trigger to the same audio jobs the engine renders
- an interior or exterior playlist retires the legacy single playlist

### `test/ttsCapabilities.test.ts`
- every supplier declares the expected capability descriptor
- capabilitiesOf defaults an undeclared provider to plain text
- the resilience wrapper and the switchable provider pass capabilities through unchanged

### `test/ttsClients.test.ts`
- azure.synthesize(): 200 returns the audio buffer and builds the SSML POST with key + format headers
- azure.synthesize(): honours an explicit Azure voice id
- azure.synthesize(): a non-OK status throws a TtsError carrying res.status
- azure.listVoices(): 200 maps the catalogue to TtsVoice[]
- azure.listVoices(): a non-OK status throws a TtsError
- elevenlabs.synthesize(): 200 returns the buffer and posts to the voice URL with xi-api-key
- elevenlabs.synthesize(): uses an explicit non-Azure voice id in the URL
- elevenlabs.synthesize(): a non-OK status throws a plain Error (not TtsError)
- elevenlabs.listVoices(): 200 maps the catalogue to TtsVoice[]
- elevenlabs.listVoices(): a plain non-OK status throws a generic Error
- elevenlabs.listVoices(): a 403 HTML block page throws the proxy/firewall message
- elevenlabs.synthesize(): with proxyUrl set, POSTs the edge function and decodes base64 audioContent
- elevenlabs.synthesize(): proxy path never calls api.elevenlabs.io even for the default voice
- elevenlabs.synthesize(): proxy non-OK status throws a proxy-specific error
- elevenlabs.synthesize(): proxy 200 with no audioContent throws, surfacing the error field
- elevenlabs.describe(): proxy vs direct produce different cache keys for the same input
- acapela.synthesize(): with no token first logs in (POST /api/login/) then commands (POST /api/command/)
- acapela.synthesize(): a preconfigured token skips the login step
- acapela.listVoices(): logs in then GETs /api/nvoices/ and maps the rows
- acapela.listVoices(): also accepts the paginated { results: [...] } envelope
- acapela.listVoices(): a non-OK voices response throws the documented error
- acapela: a failed login throws "Acapela login failed"
- acapela: a login response missing the token throws "login response missing token"
- acapela.ensureToken(): with no token and no credentials throws

### `test/ttsFailover.test.ts`
- selectFallback guards: unconfigured refused, primary-equals-fallback clears, valid sticks
- switching the primary onto the fallback name clears the fallback
- an open primary breaker routes pin/describe/synth to the fallback — and the cache key follows
- without a fallback an open breaker still fails (or beeps) exactly as before

### `test/ttsNewClients.test.ts`
- google.synthesize(): posts SSML + MP3 config with the key in the query, decodes base64 audioContent
- google.synthesize(): honours an explicit Google voice id and derives its languageCode
- google.synthesize(): a non-OK status throws; a 200 without audioContent throws
- google.listVoices(): maps the catalogue to TtsVoice[]
- polly.synthesize(): signs the request, targets the regional host, returns the MP3 stream
- polly.synthesize(): speed becomes an SSML prosody rate wrapper
- polly.synthesize(): a non-OK status throws
- polly.listVoices(): GETs /v1/voices (signed) and maps the rows
- readspeaker.synthesize(): POSTs form fields with the key/voice/lang and returns audio
- readspeaker.synthesize(): a JSON body is treated as an error, not audio
- openai-speech.synthesize(): POSTs /v1/audio/speech with model/voice/format and returns audio
- openai-speech: name reflects the provider, and an auth key becomes a bearer header
- openai-speech.listVoices(): accepts both string ids and objects
- http-tts.synthesize(): POSTs text/voice/format with the configured auth header, accepts raw audio
- http-tts.synthesize(): also decodes a base64 JSON envelope (audioContent / audio)
- http-tts.synthesize(): a JSON body with no audio throws (surfacing the error)
- http-tts.synthesize(): a non-OK status throws with the provider name

### `test/ttsSsml.test.ts`
- speed → prosody rate (signed percentage)
- per-element volume → prosody volume (absolute 0–100)
- defaults (speed 1, volume 100) emit no prosody wrapper
- pitch is never rendered — any authored pitch emits no prosody pitch
- pitch does not affect the cache key (it does not affect the audio)
- lexicon SSML tags pass through unescaped while surrounding text is escaped
- plain author angle brackets are still escaped (no SSML injection)
- break tags still convert seconds → ms

### `test/ttsSwitch.test.ts`
- TTS_PROVIDER_NAMES lists every selectable supplier from the market research
- createProviderByName instantiates a usable TtsProvider for every name (no network at construction)
- isProviderConfigured gates strictly on the required credentials
- SwitchableTtsProvider.select switches only to a configured, different provider
- a published default voice replaces the env one — and re-keys the cache

### `test/variableParity.test.ts`
- every variable the Creator offers is one the engine resolver can fill
- the Creator ENGINE_VARIABLES mirror matches the resolver exactly
- the picker is a curated subset — aliases stay resolvable but unoffered

### `test/vilniusRequirements.test.ts`
- Req 1: the destination is drawn in capitals, whatever case the feed sends
- Req 2: an opening bracket puts everything after it on row 2
- Req 4: with BOTH a bracket and a comma, only the bracketed text alternates
- Req 3: a comma-separated destination alternates its parts
- Req 3: a destination with no comma shows ONE frame — the rule stays inert
- Req 5a/5b: a keyword anywhere in the destination selects the right pictogram
- Req 5a: the symbol cell is a SLOT — it draws nothing of its own when no keyword matches
- Req 5b: the station layout adds the English name to the rotation
- Req 6: the trip-id pattern takes the front and rear over
- Req 6: no trigger binds the SIDE face — two panels of different sizes live there
- Req 6: an ordinary trip id takes nothing over, and the return leg shows the destination
- Req 6: the depot sign names the depot, with the street from the brackets below it
- Req 7: row 1 is the primary destination; row 2 walks the dash-separated via places
- Req 7: a journey with a single via place gives a single, non-rotating frame
- Req 8: the rear sign is the front mirrored — line number right, symbol left
- Req 9: a boarding restriction takes the front sign, keeping the line number
- Req 9: boarding allowed — or a feed that never mentions boarding — puts no notice up
- Req 10: the countdown prints whole minutes, rounded up
- Req 10: the cycle holds the countdown to the two-minute floor
- Req G: a route number containing G inverts the sign, and no other route is touched
- Extra text: the message shows 3 s against 6 s of route information, and only while it is set
- the front display resolves a layout for every state, and prefers the countdown
- every display cycle and trigger points at a layout that exists
- every layout element fits inside its panel
- the worked destinations render on the layouts they were written for
- the preset vehicle is coherent: each sign bound to a display of its own size, addresses unique
- buildLedSigns publishes the right layout on every face, for every deck scenario
- buildLedSigns: an inverted route lights the field and punches the letters out of it
- every preset cycle binds its layout through a rotation, not a bare layoutId
- every gated cycle uses a rule the Creator can show as a visual block, not raw JS
- the side depot cycle compiles to exactly what the Creator's `matches` operator emits
- Req 5a/5b: the keyword pictograms light the symbol cell with NO artwork uploaded
- every preset symbol rule names a pictogram that exists
- a rule with an uploaded image still prefers the image over the built-in drawing
- the colour panel publishes RGB bytes and a COLORTEXT FF frame, the mono panels do not
- the colour panel wears each line's livery, straight from the route-colour table
- the G line inverts even though the table gives it a livery — legibility beats branding
- the colour layout leaves the SIGN's colours to its rules, and names none of its own

### `test/volume.test.ts`
- disabled adaptation returns null (use env default)
- priorityOrder: stop wins over route
- route match when no stop rule matches
- time-of-day range wraps midnight

---

<a id="setup-reference"></a>

# CreatorStudio — Authoring App + Engine

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

- **CreatorStudio** (`web/creator/`) — the **Angular + PrimeNG** web app where operators *author*
  passenger announcements: playlists, **LED sign templates** (MatrixRenderer layouts / displays /
  Ultima vehicle faces), trigger rules + conditions, multilingual TTS voices, prosody, speaker
  routing, and the pronunciation lexicon (incl. GTFS import), with a route simulator and
  publish-to-engine. (The original React/Vite version, migrated from, remains under `audiocreator/`
  for standalone/Lovable use — see the *migration note* below.)
- **CreatorStudio Engine** (`src/`) — the always-on backend that *executes* that config live across
  an entire vehicle fleet. It subscribes to live **MQTT-PT-PIS** journey data, runs the **same
  trigger engine** the authoring app defines, renders playlists to **MP3 via a pluggable
  text-to-speech provider** (Azure AI Speech, Acapela, or ElevenLabs), rasterises **matrix LED
  templates** (FNT fonts, mono/RGB Mobitec FF and Hanover HCPS — sign-vendor independent), and publishes **ADT 4.x audio payloads** back to
  each vehicle over MQTT — while keeping a tamper-evident **proof-of-play** record.
- **Engine dashboard** (`web/`) — an Angular monitor/operate/audit UI for the running engine.

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 dashboard` → `http://localhost:8080`): `/` landing menu · `/creator/` authoring app ·
`/monitor/` engine dashboard — see [Unified portal](#unified-portal-one-app-one-menu).

> **📖 Documentation Center — the one entry point for all docs.** In the running portal:
> [`http://localhost:8080/docs`](http://localhost:8080/docs) (consolidated handbook, decks,
> leave-behinds, raw sources). In the repo: [`docs/README.md`](docs/README.md) is the same index.
>
> **Software architecture** (modules, services, APIs, diagrams) —
> see [`ARCHITECTURE.md`](ARCHITECTURE.md).
> **Scaling to a large fleet** (2000-vehicle plan, bottlenecks, phases) —
> see [`SCALING.md`](SCALING.md).
> **Requirement specification** (the complete as-built requirement catalogue) —
> see [`PRD.md`](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](#unified-portal-one-app-one-menu).

## How it works

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

```mermaid
flowchart TB
  FEED["🛰️ Live PIS-PT trip data<br/>{tenant}/{vehicleId}/pis/0/#<br/>(retained · multi-topic)"]

  subgraph ENGINE["CreatorStudio Engine — one always-on process"]
    direction TB
    BUS["MqttBus · subscribe"]
    CTX["VehicleContext<br/>merge retained topics → JourneyState<br/><i>per-vehicle · in memory</i>"]
    ENG["PtTriggerEngine<br/>edge-detect on state change<br/>debounce · gates · thresholds"]
    PIPE["pipeline<br/>trigger → playlist(s)<br/>priority · sequences · repetitions"]
    REND["renderPlaylist<br/>resolve {variables} · merge speech runs<br/>→ MP3 + transcript"]
    OUT["buildAdtPayload · ledRender · ffEncoder<br/>ADT 4.x audio + LED-sign bitmaps"]
  end

  TTS["☁️ TTS provider<br/>Azure · Acapela · ElevenLabs"]
  CACHE[("Layered MP3 cache<br/>memory → disk → Azure Blob<br/><i>one synth per phrase, fleet-wide</i>")]

  AUDIO["🔊 …/pis/0/tts<br/>ADT audio → vehicle player"]
  SIGNS["▦ …/display · …/display/ff<br/>interior + exterior LED"]
  PROOF["🧾 proof-of-play<br/>durable audit trail"]

  FEED --> BUS --> CTX --> ENG
  ENG -->|"PisTriggerEvent<br/>approaching-stop · doors-open · detour…"| PIPE --> REND --> OUT
  REND <-->|"key = hash(text + voice)"| CACHE
  CACHE -.->|"miss → synthesize"| TTS
  OUT --> AUDIO
  OUT --> SIGNS
  OUT --> PROOF
  AUDIO -.->|"engine/played · ack when the clip finishes"| PROOF
```

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:
```json
{ "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/`](FF/)) for
a serial (RS-485) gateway, on **separate topics**:

- Interior → `{tenant}/{vehicleId}/pis/0/display/ff` (sign **address 10**)
- Exterior → `{tenant}/{vehicleId}/pis/0/display/exterior/ff` — the three faces concatenated,
  each self-addressed: **front 1, side 2, rear 3**.

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/`](Hanover/)),
published **in parallel with FF** on separate topics so mixed Luminator + Hanover fleets run
from one pipeline:

- Interior → `{tenant}/{vehicleId}/pis/0/display/hanover` (default address **4**)
- Exterior → `{tenant}/{vehicleId}/pis/0/display/exterior/hanover` — three self-addressed
  frames concatenated: **front 0, side 1, rear 2**.
- Every raw-frame topic (FF and Hanover, interior/exterior/template) also fans out
  **per address** — e.g. `…/display/exterior/ff/1` carries only the front face — so a
  sign controller subscribes to exactly its own bus address.
- Template signs → a rostered sign with `protocol: 'hanover'` publishes
  `…/display/signs/{slug}/hanover` + the `…/display/signs/hanover` aggregate instead of FF,
  plus the address-specific `…/display/signs/hanover/{address}` (FF signs likewise get
  `…/display/signs/ff/{address}`) so a controller subscribes to exactly its own bus address.

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`](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.

```bash
# 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/`](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/`](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`](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 |

```bash
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`](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:

- **Live sync (recommended).** Click **Publish to engine** in CreatorStudio. That upserts
  the config into the Postgres `engine_config` row (keyed by `(tenant, fleet)`) via the portal's
  `POST /api/publish`; a DB trigger fires `pg_notify('engine_config_changed')`, the engine
  **LISTEN**s on a dedicated connection and **hot-swaps** the config in ~1s — no export/import,
  no redeploy. Enabled by setting `DATABASE_URL` (back office; vehicles that can't reach the DB
  use `CONFIG_API_URL` + the retained MQTT notify instead — see below).
  For a multitenant deployment set `CONFIG_DB_TENANT_VALUE` so the engine reads only its own
  `(tenant, fleet)` row (empty = single-tenant; deliberately **not** derived from `UPSTREAM_TENANT`).
  Smoke-test the read/subscribe path with `npm run config:test`.
- **File (offline fallback).** Export the JSON with the app's **Export/Import** dialog and
  drop it at `CONFIG_PATH` (default `./config/announcement-config.json`). It is
  **hot-reloaded** on change, and is the automatic fallback whenever the live source is
  disabled or unreachable.

### 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`](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.

```bash
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**:

```bash
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.

- **Dry-run by default.** The two remote scripts print the diff and write nothing until you pass
  `--apply`. Check *which* database you are pointed at first (see the `stack:local` warning above).
- **Idempotent.** Playlists already present (by `id`) are skipped, so a re-run after a dashboard
  config import tops the library back up instead of duplicating it.
- **Renamed and re-voiced.** Each is prefixed `Example — ` (eight names collide exactly with the
  stock playlists), and its voice is remapped to the target config's default so **Preview** works.
  The element content — the actual worked example — is untouched.

`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:

- **Playlists / elements** — `static-text`, `dynamic-text`, `pause`, `audio-file`, `audio-ref`;
  interior/exterior/both routing; per-trigger interior/exterior/legacy playlists + sequences +
  repetitions.
- **Voice** — per-playlist **and** per-element voice; **speed** and **pitch** (SSML prosody);
  28 dynamic variables (next/current/skipped stop, destination, occupancy, countdown, connections,
  service alerts, …); multilingual name variants resolved in each element's own language.
- **Per-element volume** — applied where the supplier's capability descriptor declares
  prosody-volume support (Azure/Google/Polly; splits the TTS run like a voice change); on
  plain-text suppliers the volume is dropped predictably with a log line, and the Creator warns
  while authoring.
- **Trigger conditions** — time/distance thresholds (before/after stop, `meters`/`feet`),
  geofence (within/outside/entering/leaving + radius/centre), speed (below/above/between,
  `kmh`/`mph`), passenger-load, exit-side, and the **universal prerequisite gates**
  (`requiresDoorOpen`/`DoorClosed`/`StopButtonPressed`/`VelocityBelow`/`Above`) — a trigger is
  suppressed unless every set gate holds.
- **Priority** — level + interrupt/queue (both the flat and nested authored shapes).
- **Volume adaptation** (rules combining time-of-day, weekdays, route, stop and circle/polygon geofences) and the **pronunciation lexicon**.

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

```bash
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](https://mosquitto.org/) 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](https://github.com/moscajs/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/`](db/migrations/) (plain SQL, applied by
[`scripts/db-migrate.ts`](scripts/db-migrate.ts)), so the backend runs 100% locally against a stock
Postgres container — no dependency on any hosted project:

```bash
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

```bash
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)

```bash
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](http://localhost:8080/docs), linked from the suite rail:
- **Proof of Play** — a fleet-manager **analytics + audit** tool that proves *what was
  played and shown, and where*. One timeline unifies **audio** announcements, the
  **interior LED** mirror (`displayed` flag), and **exterior sign** changes (route →
  destination on front/side/rear); every record carries vehicle, GPS, time, **route,
  destination and journey ref**. Four views over one filtered set:
  - **Groups** — fast server-side aggregation (`/history?groupBy=…`): slice by
    **journey / route / destination / date / hour / vehicle** with per-group counts
    (records · audio · interior · exterior · journeys · vehicles) and a time span;
    click a group to **drill down** into the Table.
  - **Table** — surface-badged rows with route→destination, content, stop, location.
  - **Map** — every event at its lat/lon, coloured by surface, with a time-ordered trail.
  - **Replay** — a ▶/scrubber that steps the timeline, re-showing the interior +
    exterior signs and re-voicing the transcript (Web Speech).

  Filter by **date presets** (1h · Today · 24h · 7d · All) or route / destination /
  vehicle / surface / trigger / text, always **scoped to the selected tenant** (toggle
  “all tenants”), with a KPI summary bar and **CSV / JSON proof-pack export**. Records
  come from the `/history` log (`type: audio | exterior`); `npm run proof:demo` seeds a
  sample. Replay audio is a reconstruction — the persisted record is the proof.
- **Health** — live engine telemetry from `engine/health` (vehicles, msg/s, events/s,
  event-loop lag, memory, cache hit-rate, in-flight/queued/dropped renders, and
  **pre-rendered** count/queue) as stat cards with sparklines + a rolling history
  table; flags the engine stale/offline.
- **History** — the durable **announcement audit trail**: every announcement the
  engine has spoken (across restarts), with vehicle, trigger, stop, transcript +
  lexicon-applied spoken text, language/voice, volume and timing. Filter by vehicle /
  trigger / free-text, auto-refresh, and **export CSV**. Backed by an append-only
  JSON-lines log (`HISTORY_PATH`, default `./.data/history.jsonl`, bounded by
  `HISTORY_MAX`) that the engine writes and the dashboard serves at `/history`.
- **Map** — for the selected vehicle, plots its live GPS track and a marker at every
  place an announcement was played (colour = trigger kind; click for the transcript).
  Uses Leaflet (served locally) with OpenStreetMap tiles (best-effort — if the
  network blocks tiles, markers/track still show on a blank background). Engine
  includes `lat`/`lon` in the `engine/metrics` payload for this.
- **Performance & timing** — lead time from trigger fired → MP3 published, broken
  into resolve / render / TTS / publish per announcement, with a stacked timeline
  bar, cache hit/miss, and summary stats (avg/min/max lead time, avg TTS, hit rate).
  Fed by metrics the engine publishes to `engine/metrics` (topic set by `METRICS_TOPIC`).
- **Displays** (in the cockpit drawer) — the interior LED, the exterior front/side/rear signs, the
  matrix template signs and the interior TFT, all rendered live from the engine's output with the
  same font and the same shared LED core the engine uses.
- **Triggers → Playlists** — loaded live from `announcement-config.json` (served at
  `/config`, re-read on every ↻ reload): every trigger with its resolved playlist(s)
  and each element (static text, `{variables}`, pauses, `♪` audio) so you can see
  exactly what will be spoken. Cards flash when a matching announcement fires.
- **Simulator** — drive a vehicle: set line/destination/stops, click **Run journey**
  (or publish a single state manually).
- **Inbound PT-PIS** — live feed of journey messages.
- **Interior LED sign** — an amber 16×144 dot-matrix **canvas** that mirrors the interior
  speaker: it renders the same text on the same font as the physical sign and scrolls long
  messages. Each interior announcement in the feed also gets a small LED thumbnail. Rendered
  in the browser from the shared font (`/led-font.json`) so it matches the engine exactly.
- **Announcements out** — live feed of ADT payloads the engine publishes, with the
  trigger kind and speaker routing, and **plays the audio in the browser**
  (auto-play toggle, or ▶ per message).
- **Vehicle states** — latest state per vehicle.

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)

```bash
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:

```powershell
.\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`](clients/README.md)):

| Client | Runs on | Use it when |
| --- | --- | --- |
| **Go** — `clients/go` | x86-64 · ARM64, headless | The onboard unit. Single static binary, no runtime. |
| **Python** — `clients/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:

- **Monitor → “Announcements out” → `play on a device`.** You are already watching a vehicle, so
  this shows a **QR code** for *that* vehicle — scan it and the phone starts playing it. No typing.
- **The portal (`/`) → Vehicle Player card.** Pick the tenant/vehicle by hand. On a desktop it also
  offers a QR to hand the vehicle to a phone.
- Or straight to the URL:

```
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:
- Tap **Start** once. Browsers block audio until a user gesture — after that it plays hands-free.
- Settings are remembered, and the page is deep-linkable two ways: the query string
  (`?tenant=&vehicle=&broker=`), or the path form `/player/v/<tenant>/<vehicle>` — the one the QR
  codes use, because camera apps and proxies have been seen stripping query parameters on the way
  to the phone (a path segment survives anything that keeps the URL at all).
- It requests a screen wake-lock, so a phone left running in a cab won't sleep.
- `broker` defaults to `ws://<the page's host>:9001`; override it for a remote broker.

### 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:

- **`npm run stop`** kills the previous engine, player, simulator, and dashboard
  but never the broker — so connected clients (dashboard tabs, MQTT Explorer) keep
  their sessions. `npm run stop -- engine` targets just one kind.
- Each app also **auto-clears its own previous instance on start** (a `pre` hook
  runs `stop` for that app), so re-running `npm run dev` / `dashboard` / `play` /
  `simulate` won't double up.
- Keep one broker running (`npm run broker`) and use **`npm run stack:apps`**
  (engine + dashboard, no broker) to recycle just the apps against it.

### 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`](.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:

```bash
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:

- **Served set empty.** With the default `RENDER_SCOPE=selection` the engine renders for exactly the
  vehicles on `engine/control/select`. Nothing selected = nothing announced, no matter how much data
  arrives. Fix from the dashboard, or `node scripts/pub-select.cjs <tenant>/<vehicleId>` (retained).
- **Dashboard shows no broker.** By default the dashboard reaches the broker through a **same-origin
  relay** on its own port (`/mqtt-proxy`), so the browser needs nothing but port 8080 and the broker
  credentials stay on the server — a failure here is the *server's* broker connection (`MQTT_URL`,
  `MQTT_USERNAME`/`MQTT_PASSWORD`), and the relay forwards the broker's own words, e.g.
  `Bad username or password`. If you've opted into the direct path (`DASHBOARD_WS_URL` set, or
  `MQTT_WS_PROXY=false`), then the broker's WebSocket port must be reachable from the operator's
  network and an https page can only use `wss://`. Either way the broker chip in the header names the
  transport, the URL it tried, and why it failed.

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):

```bash
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`](ON-VEHICLE.md).** [`docker-compose.vehicle.yml`](docker-compose.vehicle.yml)
wires it together:

```bash
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).

- **Config is file-first** (offline-capable): drop this fleet's exported config at
  `./config/announcement-config.json`. For **live over-the-air updates** from the Creator,
  set `CONFIG_API_URL` (+ `CONFIG_API_TOKEN`) to the back-office portal — the engine fetches
  `GET /api/engine-config` and hot-swaps config in ~1 s when the retained MQTT config notify
  arrives. Left off, a bus that loses connectivity keeps running its file
  config with no reconnect churn.
- **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 vehicle,
  pre-warm the cache on the bench (run a journey once while online) — the volume carries it
  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.
- **Durable proof-of-play:** the audit log is a persistent volume (`engine-data`).
- **Resource-bounded:** `mem_limit` (default 512 MB) + `NODE_OPTIONS=--max-old-space-size`
  keep the always-on engine from ever hogging a constrained onboard unit (steady state is
  ~90 MB).
- Outbound-only MQTT and no inbound ports — it sits behind the vehicle's NAT unchanged and
  reconnects on its own when the link drops.

## Known limitations / next steps

- **MP3 concatenation** is binary (segments joined byte-wise). Every common decoder
  plays this fine; for sample-accurate gapless output, pipe segments through ffmpeg.
- **Pre-recorded audio** (`audio-file` / `audio-ref` elements) is resolved by URL
  (`AUDIO_BASE_URL` for relative refs), **cached** in the layered TTS cache keyed by URL
  (fetched once, reused fleet-wide), and fetched with **retry + backoff**; transient
  failures retry, a permanent 4xx (or exhausted retries) drops just that clip so the rest
  of the announcement still plays. Tuning: `AUDIO_CACHE`, `AUDIO_FETCH_RETRIES`,
  `AUDIO_FETCH_RETRY_BASE_MS`.
- **Per-element voices** *are* honoured: consecutive elements that share a voice still
  merge into one TTS request (fleet-wide cache stays effective), but an element with its
  own `voiceSettings` splits the run and renders under that voice — and its dynamic-text
  variables resolve in that voice's language (so multilingual name variants match the
  voice speaking them).
- **Priority / interrupt / queue** *are* enforced: one announcement plays per vehicle
  at a time and a higher-priority trigger interrupts (safety/urgent tiers by default,
  `PRIORITY_INTERRUPT_TIER`), queues (priority-ordered), or drops. Both authored shapes —
  a bare `priority` number and the nested `{level,interruptLower,queueIfBlocked}` — are
  accepted, with tier-based defaults when unset. The `interrupted` count is on the Health
  panel. *Remaining:* actual audio ducking/cross-fade on interrupt (today the superseded
  clip simply stops).
- **Acapela API contract**: `src/tts/acapela.ts` implements the *Acapela Cloud*
  REST flow (`/api/login/` → token → `/api/command/`). Acapela's on-prem HTTP
  server and embedded SDK use different shapes — confirm the endpoints/field names
  against your Acapela product docs and adjust the two request builders if needed.
  Voice names are Acapela-specific (`WillFromAfar22k`, `Rhona22k`, …); set
  `ACAPELA_DEFAULT_VOICE` and, per language, override the voice in the UI's playlist
  voice settings.
