Voice and banner instructions in the Mapbox shape

# Voice and banner instructions in the Mapbox shape

MapMap's compatible URL endpoint returns spoken and visual turn-by-turn instructions in the exact response shape the Mapbox Directions API popularised. Ask `GET /route/v1/{profile}/{coordinates}` for `steps=true` plus `voice_instructions=true` and `banner_instructions=true`, and every step comes back carrying `voiceInstructions` (plain text, an SSML form and a distance trigger) and `bannerInstructions` (a primary line with a manoeuvre glyph, and lane diagrams where OpenStreetMap carries `turn:lanes`). We copied that shape on purpose. A navigation UI already written against Mapbox parses our JSON with no changes to its parsing layer, and we would rather be compatible than clever.

## Why we matched the Mapbox response shape instead of designing our own

Because the shape is not the product, and inventing a better one would cost our users a rewrite for no routing benefit. The interesting work in turn-by-turn guidance is deciding what to say and when to say it. The JSON that carries the decision is plumbing. If we had designed a tidier envelope, every customer arriving from another provider would have to rewrite the code that reads it, and the reward for that work would be a slightly nicer field name.

There is a harder reason too. The Mapbox instruction shape is what the ecosystem already parses. Our on-device guidance state machine is derived from Ferrostar, and Ferrostar's OSRM parser reads `voiceInstructions` and `bannerInstructions` natively, then schedules them by trigger distance. Emitting anything else would have meant writing and maintaining a translation layer on both ends: one in the gateway to produce our shape, one in the SDK to convert it back into something the state machine understands. We would have owned two extra pieces of code whose only job was to undo each other.

So the field names are Mapbox's: `distanceAlongGeometry`, `announcement`, `ssmlAnnouncement`, `primary`, `secondary`, `sub`, `components` and `voiceLocale` on the route. Match on those literal strings. They are stable because they are not ours to change.

## How do I ask for voice and banner instructions?

Three query parameters on the compatible URL endpoint, and they all require `steps=true`, because instructions attach to steps and there is nothing to attach them to otherwise.

| Param | Effect |
|---|---|
| `steps=true` | Required. Returns per-step manoeuvres, without which the other two do nothing |
| `voice_instructions=true` | Adds `voiceInstructions` to each step: plain text, SSML, distance trigger |
| `banner_instructions=true` | Adds `bannerInstructions` to each step, with lane sub-banners where OSM has `turn:lanes` |
| `language` | BCP 47 narration language, for example `en-GB` or `es-ES`. Also sets the route's `voiceLocale` |

```bash
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl -fsS -G "$BASE/route/v1/driving/-0.1276,51.5072;-1.8904,52.4862" \
-H "Authorization: Bearer $API_KEY" \
--data-urlencode "steps=true" \
--data-urlencode "voice_instructions=true" \
--data-urlencode "banner_instructions=true" \
--data-urlencode "language=en-GB"
```

The same parameters work on the `truck` profile alongside `height`, `weight`, `hazmat` and `tunnel_code`. Guidance for a legal truck route is still guidance for a legal truck route.

## What does the response actually look like?

An OSRM route with two extra arrays hung off each step, and a `voiceLocale` on the route. Truncated to one step:

```json
{
"code": "Ok",
"routes": [
{
"distance": 190843.0,
"duration": 8611.0,
"voiceLocale": "en-GB",
"legs": [
{
"steps": [
{
"distance": 1204.0,
"duration": 96.0,
"name": "Victoria Embankment",
"mode": "driving",
"maneuver": {
"location": [-0.1276, 51.5072],
"type": "depart",
"modifier": "right"
},
"voiceInstructions": [
{
"distanceAlongGeometry": 1204.0,
"announcement": "Drive north on Victoria Embankment.",
"ssmlAnnouncement": "<speak>Drive north on Victoria Embankment.</speak>"
},
{
"distanceAlongGeometry": 400.0,
"announcement": "In 400 metres, turn right onto A45.",
"ssmlAnnouncement": "<speak>In 400 metres, turn right onto A45.</speak>"
},
{
"distanceAlongGeometry": 60.0,
"announcement": "Turn right onto A45.",
"ssmlAnnouncement": "<speak>Turn right onto A45.</speak>"
}
],
"bannerInstructions": [
{
"distanceAlongGeometry": 1204.0,
"primary": {
"text": "A45",
"type": "turn",
"modifier": "right",
"components": [{ "type": "text", "text": "A45" }]
}
}
]
}
]
}
]
}
]
}
```

Both arrays describe the manoeuvre at the *end* of the step you are currently driving, and `distanceAlongGeometry` is metres before that end, not metres from the start. The `voiceInstructions` array is ordered by descending trigger distance, so the first entry fires first.

## When does each announcement fire?

At up to three points, derived from the step length. We do not invent narration text: the announcements come from the routing engine's verbal fields. Departure and pre-transition fall back to the step's written instruction when the verbal field is empty. The early alert has no fallback, so a step the engine gives no alert phrase for simply does not get one.

| Announcement | `distanceAlongGeometry` | Condition |
|---|---|---|
| Departure narration | the full step length | First step of the route only |
| Early alert | `min(400 m, 75% of the step length)` | Steps longer than 150 m, and only where the engine supplies an alert phrase |
| Pre-transition | `min(60 m, the step length)` | Every step |

Short steps get fewer prompts by design. A 40 m link road in a one-way system does not need "in 400 metres" followed by "now", it needs one instruction, and a driver being told to turn 400 metres before a turn that is 40 metres away is worse than silence.

## What does SSML actually buy you, and what does it not?

It buys you a well-formed document with the XML special characters escaped, and that is genuinely the point for road names. Our `ssmlAnnouncement` is the announcement text wrapped in `<speak>` with `&`, `<` and `>` escaped. That sounds trivial until you route past a road named after a company with an ampersand in it, at which point the difference between our SSML field and your own string concatenation is a TTS engine that speaks versus a TTS engine that throws a parse error. Engines that require SSML input get valid SSML from us. Engines that prefer plain text get `announcement`. You do not have to build either one.

What it does not buy you is pronunciation control, and we are not going to pretend otherwise. We do not emit `<phoneme>` tags, we do not emit `<say-as>`, and we do not populate OSRM's `pronunciation` field with an IPA transcription. Road names are handed to the platform TTS engine as text, and the engine applies its own rules for the locale you asked for. Set `language` correctly and an engine reading `en-GB` will handle British road names considerably better than one guessing at `en-US` defaults, because the locale is doing the work. But "Beaulieu" is at the mercy of your engine, not our markup. If phoneme hints matter to your product, that is a real gap today.

## How do the SDKs surface the same instructions?

They parse the same fields, so what the endpoint returns is what the device speaks. In `@mapmap/maps`, request the instructions as route options and use the guidance helpers:

```ts
import { extractGuidance, speak, GuidanceBanner } from "@mapmap/maps";

const route = await routes.route(from, to, {
profile: "truck",
voice: true,
banner: true,
language: "en-GB",
});

const steps = extractGuidance(route); // one StepGuidance per step
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);
banner.update(steps[0]?.banners[0] ?? null);
if (steps[0]?.voice[0]) speak(steps[0].voice[0], { lang: "en-GB" });
```

On mobile and in `@mapmap/core`, a `GuidanceSession` takes the route response and you stream location fixes in. Each update reports the current spoken prompt with its `text`, `ssml`, `trigger_distance_m` and an `utterance_id`, which exists so your platform layer can de-duplicate: the same prompt is reported on every update until it is superseded. Install details for all four SDKs are in the [SDK documentation](/docs/sdks).

## What it costs, and what it does not do

Voice and banner instructions cost nothing extra. A route is one metered call whether or not you ask for instructions, billed at the Standard rate of 0.05p per call for `driving`, `walking` and the other non-truck profiles, or the Premium rate of 1p for truck and ADR routing. There is no per-instruction charge and no separate narration endpoint.

The honest limits:

- **No phoneme markup.** SSML is a `<speak>` wrapper with XML escaping. Pronunciation is the TTS engine's problem, and the `pronunciation` field is not populated.
- **No `maneuver.instruction`.** We emit the pure OSRM shape, which drops the written-instruction extension some Mapbox clients read. The web SDK falls back to the step's final voice announcement, then the banner primary text. If your UI reads `maneuver.instruction` directly, it will find nothing there.
- **De-duplication is yours on web.** Each step's `voice` array is ordered by descending trigger distance. Speak each instruction once as its distance is crossed. The web helpers will not track that for you; the on-device `utterance_id` will.
- **Lane sub-banners depend on OSM coverage.** Lane diagrams are built from `turn:lanes`. Where the tag is missing, the sub-banner is absent rather than guessed. More on that in [lane guidance](/news/lane-guidance).
- **`voiceLocale` only appears with `voice_instructions=true`.** It describes the announcements, so with no announcements there is nothing for it to describe.

## Try it

Issue a card-free key and request instructions on a real route: the curl above runs unchanged against `https://api.mapmap.ai`, and the parameter reference is in the [API reference](/docs/api-reference). If you are moving an existing navigation UI across, start with [migrating from Mapbox](/news/migrating-from-mapbox), which covers what parses unchanged and what does not.

Routing derives from OpenStreetMap, so credit © OpenStreetMap contributors when you render it.