Skip to content

Text-to-speech (TTS)

Out of the box a voice alarm is spoken by the telephony provider: Twilio’s <Say>, or whatever TTS application or prompt files your Asterisk dialplan uses. A TTS profile moves speech into Northplane: the alarm text is normalised the way an operator would read it aloud (“CPU load high on np-01” → C P U load high on N P zero one), its language is detected, it is synthesised with the engine and voice of your choice, finished for the phone line and handed to the provider as an audio clip — <Play> for Twilio, a channel variable for the Asterisk dialplan, STREAM FILE for FastAGI menus, {audioUrl} for HTTP gateways. If every engine in the chain fails, the provider’s own speech is used, so an alarm call is never silent.

Profiles are resources of kind tts-profile (bundle kind TTSProfile), edited under Alerting → Text-to-speech (Sprachausgabe) or at /api/v1/tts-profiles (objects:read / config:write). A profile is selected by

  1. the alert label np.ttsProfile (set by a rule’s setLabels, an IVR option or the manual trigger), else
  2. the ttsProfile config key of the voice channel / voice-inbound / asterisk-inbound source, else
  3. the profile named default, if one exists.

Without any of these the provider speaks as before — existing installations keep their behaviour until a default profile is created.

Engine Where speech happens Auth Notes
command a local executable — piper, espeak-ng, flite, mimic3, pico2wave, macOS say, any wrapper script none fully on-prem; no network. Requires the binary on the Northplane host — not inside the distroless container image, so run the binary natively or put an HTTP front-end (Piper server) in front and use http
edge Microsoft Edge “read aloud” neural voices none free, the same catalogue as Azure — but unofficial; Microsoft can change the handshake. Fine for labs and small sites; give production lines a fallback
openai OpenAI audio/speech — or any compatible server (Kokoro-FastAPI, LocalAI, openedai-speech, Speaches) API key gpt-4o-mini-tts accepts an instructions style prompt (“urgent, clear, like a dispatcher”); compatible local servers make this a second on-prem path
elevenlabs ElevenLabs API key eleven_flash_v2_5 for lowest latency, eleven_multilingual_v2 for quality
azure Azure AI Speech key + region enterprise version of the Edge voices, with SLA; style (e.g. serious) for voices that support it
google Google Cloud Text-to-Speech API key Neural2 / WaveNet voices
polly Amazon Polly access key (SigV4) neural voices; `engine: neural
http any HTTP endpoint that takes text and returns audio headers Piper server, MaryTTS, Coqui, a company TTS gateway

Every engine’s audio (WAV, MP3, raw PCM, G.711) is decoded in Northplane with pure Go — no ffmpeg or sox on the host — so output formats are free to choose; the defaults are sensible.

All keys live in config of the profile; secret-bearing values take $SECRET:name$ references like channel configs. GET /api/v1/tts/engines returns this table for the UI.

Key Notes
command required; executable plus arguments, split by Northplane (quotes allowed), never run through a shell. Placeholders per argument: {text} {lang} {voice} {rate} {out}. The text is additionally written to stdin; audio is read from the {out} temp file when that placeholder is used, otherwise from stdout
format hint for headerless output: wav (default, sniffed), pcm16:22050, mp3, ulaw:8000
outExt extension of the {out} temp file (default wav)
env KEY=VALUE;KEY2=VALUE2
workDir, timeoutSeconds (default 30)
piper --model /opt/piper/{voice}.onnx --output_file {out} # voices map: de → de_DE-thorsten-medium, en → en_US-amy-medium
espeak-ng -v {lang} -s 160 --stdout
flite -voice slt -o {out}
say -v Anna -o {out} --data-format=LEI16@22050
Field Default Notes
engine, config see above
fallback another profile tried when this engine fails (chain, loop-safe, max 4); after the chain the provider’s own speech is used
language en-US default BCP-47 tag: used when detection is off/undecided and for the fixed IVR phrases
voice engine default default voice id/name
voices map language → voice: {de: de-DE-KatjaNeural, en: en-US-AriaNeural}; keys are full tags or 2-letter prefixes. A detected English sentence in a German message is then read by the English voice. For engines with language-specific voices (edge/azure/google/polly/command/http) a language without an entry falls back to the engine’s default voice for that language, not to voice
rate engine default speaking-rate multiplier 0.5–2
detect.mode message off (always language), message (detect once per message), segments (per sentence — see below)
detect.languages language + the voices keys candidate languages; detection only ever chooses among these. With a single candidate detection is effectively off
detect.minConfidence 0.35 below it the default language is kept
normalize.* see below pronunciation and normalisation
audio.* see below audio finishing
cacheDisabled false per-profile switch for the clip cache
tts-profile.yaml
kind: TTSProfile
metadata: { name: default }
spec:
engine: edge
language: de-DE
voices: { de: de-DE-KatjaNeural, en: en-US-AriaNeural }
detect: { mode: segments, languages: [de-DE, en-US] }
fallback: piper-local
normalize:
lexicon:
- { from: np-01, to: "Server eins" }
- { from: k8s, to: Kubernetes }
regex:
- { pattern: 'srv(\d+)', replace: 'Server $1' }
spellOut: [acme]
numbers: auto
audio: { preroll: chime, sampleRate: 8000 }
---
kind: TTSProfile
metadata: { name: piper-local }
spec:
engine: command
language: de-DE
voices: { de: de_DE-thorsten-medium, en: en_US-amy-medium }
config: { command: "piper --model /opt/piper/{voice}.onnx --output_file {out}" }

Alarm texts are short, so two signals are combined: a stop-word / monitoring-vocabulary scorer (decisive for “Festplatte voll” vs. “Disk full”) and trigram language profiles restricted to the candidate languages. Keep the candidate list short — choosing between German and English is reliable, choosing among eighty languages on a ten-word title is not.

detect.mode: segments splits the message into sentences, detects each, lets short sentences (“Schweregrad kritisch.”) inherit their neighbour’s language, merges runs of one language and synthesises every run with that language’s voice; the clips are stitched with a short pause. The default outbound template “Northplane alert. Severity CRITICAL. <title>. Press 4 to acknowledge” with an English title in a German installation is therefore read in German with the title in English.

Detected languages are completed to a region using the candidates (dede-AT when the profile lists de-AT), else to common defaults (en-US, de-DE, fr-FR, …). The alert label np.ttsLang forces a language and skips detection; np.ttsVoice forces the voice.

Plain text in, plain text out — no SSML — so every engine, including a local flite, benefits equally. Steps in order (each switchable in normalize):

  1. markup/emoji cleanup (HTML tags, markdown, control characters; line breaks become sentence breaks)
  2. lexicon — your literal replacements, whole-word and case-insensitive unless matchCase / substring: np-01Server eins, SQLsequel
  3. regex — RE2 rewrites with $1 groups: srv(\d+)Server $1
  4. the built-in IT-operations lexicon (disable with noBuiltinLexicon): k8s → Kubernetes, OOMKilled → out of memory killed, CRIT → critical / kritisch, z.B. → zum Beispiel, HTTPH T T P, ok → okay, gw → gateway, nr. → number / Nummer, …
  5. spellOut — tokens always spelled letter by letter
  6. URLs (urls: hostgrafana dot example dot net | drop | keep), e-mail addresses (ops at example dot com)
  7. token pass:
    • IP addresses and dotted versions (ipAddresses: dot|native): 10.0.0.1210 dot 0 dot 0 dot 12 / Punkt, v1.2.3v 1 dot 2 dot 3, CIDR /24slash 24
    • identifiers (identifiers: split|keep): web01web 0 1, db-master-02D B master 0 2, vm104V M 1 0 4; leading zeros and ≥ 3 digits are spelled
    • acronyms (acronyms: auto|off): ALL-CAPS tokens of 2–6 letters are spelled when they have no vowel (SSH, DHCP, SNMP) or are known IT acronyms (CPUC P U, API, URL, VMsV M s); other upper-case words (DISK, ERROR, FEHLER, RAID, JSON) are left to the engine, which reads shouted words as words; lower-case tokens without a vowel (dhcp, pbx, xml) are spelled too
    • units (units: expand|native), glued or separate: 250ms250 milliseconds / Millisekunden, 2 GB2 gigabytes / Gigabyte, 21.5°C21.5 degrees Celsius / 21,5 Grad Celsius, errors/minerrors per minute
    • numbers (numbers): auto (default) reads integers with ≥ digitsFrom digits (default 5) and leading-zero numbers digit by digit (#47110number 4 7 1 1 0) and leaves the rest to the engine (4711four thousand seven hundred eleven); digits spells every integer; words writes integers out (English and German, other languages fall back to auto); native leaves everything to the engine. Decimals get the language’s separator (12.512,5 in German), ranges 5-105 to 10 / 5 bis 10, +49 171 1234567plus 49 171 1 2 3 4 5 6 7, times and ISO dates stay as they are
    • symbols (symbols: expand|native): % → percent/Prozent, & → and/und, = → equals/gleich, -> → to/nach, < >, #, @, ~, $ £, °, _ → space, /var/logslash var slash log, (…) / | / ; → pauses
  8. whitespace/punctuation tidy, final full stop

Symbol and unit words exist for English, German, French, Spanish, Italian, Dutch, Portuguese and Polish (other languages use English). normalize.disabled: true skips everything except the markup cleanup. POST /api/v1/tts:normalize (objects:read) shows the result without spending engine credits — the editor’s Normalise only button.

Examples (default settings, English profile):

Input Spoken
Northplane alert. Severity CRITICAL. CPU load high on np-01. Press 4 to acknowledge, 6 to resolve. Northplane alert. Severity CRITICAL. C P U load high on N P 0 1. Press 4 to acknowledge, 6 to resolve.
Disk /var/log 95% full on web01 (10.0.0.12) Disk slash var slash log 95 percent full on web 0 1, 10 dot 0 dot 0 dot 12.
Latency 250ms > 200ms threshold; errors=12 Latency 250 milliseconds greater than 200 milliseconds threshold, errors equals 12.
k8s pod OOMKilled in prod Kubernetes pod out of memory killed in production.
Temperatur 21.5°C im Serverraum (de) Temperatur 21,5 Grad Celsius im Serverraum.
audio. Default Notes
sampleRate 8000 8000 (telephony), 16000, 22050, 24000 — engines are resampled with a windowed-sinc low-pass (no aliasing)
format wav wav (16-bit PCM) or ulaw (G.711 µ-law WAV, 8 kHz only)
noNormalize false by default every clip is peak-normalised to −3 dBFS so all voices are equally loud on the line
gainDb 0 extra gain after normalisation
keepSilence false by default leading/trailing silence below −45 dBFS is trimmed
leadSilenceMs / trailSilenceMs 300 / 200 padding so the first word is not swallowed while the far end answers
preroll none attention signal before outbound announcements (not IVR prompts): chime (two-tone), alert (three rising beeps), gong

Multi-language segments are joined with a 200 ms pause. The clip is served as audio/wav on a signed, expiring URL (/api/v1/tts/audio/<id>.<exp>.<sig>.wav, 24 h — the outbox may retry a call for a day; no login, the signature is the credential).

Synthesised clips are cached on disk (<dataDir>/tts-cache, config tts.cacheDir, tts.cacheMaxMb default 256, tts.cacheTtl default 168h) keyed by engine + config + voice + rate + normalised text + audio settings. IVR prompts, menu options and repeated announcements are rendered once; least-recently-used clips are evicted over the size cap, unused ones after the TTL. cacheDisabled per profile, or NoCache in previews of unsaved edits, bypass it.

Outbound: the TwiML becomes <Gather …><Play loop="2">https://…/api/v1/tts/audio/….wav</Play></Gather><Say>…</Say>; the closing “No input received” phrase follows the detected language (German, French, Spanish, Italian, Dutch, else English). Inbound IVR menus (voice-inbound sources): every prompt, option, alert title and say text is <Play>ed when the source (or default) profile applies; <Say> stays the fallback per prompt. Twilio must be able to fetch baseUrl — without baseUrl there is no clip URL and <Say> is used.

Label Effect
np.tts replaces the spoken text entirely (unchanged behaviour)
np.ttsProfile selects the profile for this alert
np.ttsLang forces the language (no detection)
np.ttsVoice forces the voice id

Set them with a rule’s setLabels, an IVR option’s labels or the manual trigger — e.g. an English-speaking on-call team: setLabels: { np.ttsLang: en-US }.

Route Permission Purpose
GET/POST/PUT/DELETE /api/v1/tts-profiles[/{name}] objects:read / config:write resource CRUD (ETag / If-Match)
GET /api/v1/tts/engines objects:read engines, their config keys (with secret/required flags) and pre-roll names
POST /api/v1/tts:normalize objects:read {text, profileName?, profile?, language?} → detected language and normalised segments, no engine call
POST /api/v1/tts:preview config:write {text, profileName?, profile?, language?, voice?, preroll?}{audio (base64 WAV), lang, text, segments, engine, cached, durationMs}; an inline profile previews unsaved edits and bypasses the cache
POST /api/v1/tts:voices config:write {profileName?, profile?, language?} → the engine’s voice catalogue ([] for engines without one)
GET /api/v1/tts/audio/{token} public (signed) the clip

Validation (422 np:validation/tts-profile): unknown engine, uncompilable regex, enumerations, sample rate, a fallback pointing at itself, an unbalanced command quote.

northplane.yaml
tts:
commands: [piper, espeak-ng, flite, say] # "command" engine allowlist; ["*"] = any
cacheDir: "" # default <dataDir>/tts-cache
cacheMaxMb: 256
cacheTtl: 168h

Environment: NORTHPLANE_TTS_COMMANDS (comma-separated), NORTHPLANE_TTS_CACHE_DIR. Cloud engines go through the same SSRF-guarded HTTP client as webhooks (link-local / metadata addresses are refused).

  • baseUrl is set — Twilio <Play> and NP_AUDIO_URL need a public clip URL; Asterisk installations without HTTP access to Northplane use ttsDir instead.
  • Keep detect.languages to the languages you really receive, and give each a voice in voices.
  • Use Normalise only before Listen: most pronunciation issues are fixed with a lexicon entry, not a different voice.
  • Production lines: a fallback profile (e.g. edge → local command), and a channel test (Admin → Channels → Test) after every change — the test call is synthesised through the profile like a real one.
  • np-agent, hostnames and ticket numbers read oddly? Check identifiers, digitsFrom and the built-in lexicon; spellOut forces letters.