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