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