Files
OpenSquawk/shared/utils/voicePool.ts
itsrubberduck 4f367a67aa fix(tts): back to fast Piper controllers, classroom voice setting
Kokoro-82M generates several times slower than Piper — since the controller
pool moved onto it, every ATC reply arrived seconds late. Controllers return
to distinct Piper speakers with the standard US voice (ryan) as the product
default, so replies are fast again and the default sounds like before.

The classroom gets an instructor-voice setting: standard US voice by default,
'random per module' (stable instructor per module), or one of ten named
US/GB voices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:36:19 +02:00

157 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The one voice pool for the whole product — shared infrastructure for both
* `ai-traffic` (a stable voice per simulated aircraft) and the `multi-voice`
* roadmap item (a rotating voice per ATC position). Two disjoint partitions, one
* assignment function; deliberately not two systems (architecture design § 7).
*
* Assignment is a pure hash of the callsign / position, so the same "DLH472"
* sounds the same all session — and across sessions — without persisting any
* assignment state anywhere.
*/
/** Voices for ATC positions. `alloy` is today's hard-coded controller voice. */
export const CONTROLLER_VOICES: readonly string[] = ['alloy', 'echo', 'onyx', 'sage']
/** Voices for simulated pilots' readbacks. Disjoint from CONTROLLER_VOICES. */
export const PILOT_VOICES: readonly string[] = ['ash', 'ballad', 'coral', 'fable', 'nova', 'shimmer']
/**
* Never hand these to a simulated pilot: `verse` already belongs to the user's
* own readback (`speakPilotReadback`), and the live controller voice must stay
* distinguishable from the traffic around it.
*/
export const RESERVED_VOICES: readonly string[] = ['verse', 'alloy']
/** FNV-1a, 32-bit. Small, stable, and dependency-free — good enough to bucket strings. */
export function fnv1a(input: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
// hash * 16777619, kept in 32-bit unsigned range without BigInt.
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash >>> 0
}
/**
* Murmur3 finalizer (fmix32). FNV-1a's low bits barely avalanche: for the
* live-atc persona keys (shared session prefix, `:TYPE:freq` suffix) the raw
* hash mod 4 was identical for every position of a session — one voice for
* Delivery, Ground and Tower, 100% of the time. Mixing before the modulo
* makes every input bit reach the low bits.
*/
export function mix32(hash: number): number {
let h = hash >>> 0
h ^= h >>> 16
h = Math.imul(h, 0x85ebca6b) >>> 0
h ^= h >>> 13
h = Math.imul(h, 0xc2b6ae35) >>> 0
h ^= h >>> 16
return h >>> 0
}
/**
* Pick a voice from `pool` for `key`, skipping anything in `reserved`.
* Walks forward from the hashed index so a collision with a reserved voice
* degrades to the next pool entry instead of failing.
*/
export function voiceFromPool(
key: string,
pool: readonly string[],
reserved: readonly string[] = RESERVED_VOICES,
): string {
const usable = pool.filter(v => !reserved.includes(v))
const candidates = usable.length ? usable : pool
if (!candidates.length) throw new Error('voiceFromPool: empty voice pool')
return candidates[mix32(fnv1a(key)) % candidates.length]!
}
/**
* The stable voice of a simulated aircraft. `reserved` lets a caller exclude the
* controller voice that is live right now (relevant once `multi-voice` rotates
* it), on top of the permanently reserved ones.
*/
export function pilotVoiceFor(callsign: string, reserved: readonly string[] = RESERVED_VOICES): string {
return voiceFromPool(callsign.toUpperCase(), PILOT_VOICES, reserved)
}
/**
* The voice of an ATC position (`multi-voice`). Traffic doesn't call this yet —
* it exists so the future feature reuses this pool rather than inventing a
* second assignment path.
*/
export function controllerVoiceFor(position: string): string {
return voiceFromPool(position.toUpperCase(), CONTROLLER_VOICES, [])
}
export type ControllerPersona = {
voice: string
/** Controllers talk fast — base pace per position, 1.11.3. */
baseSpeed: number
}
/**
* The persona of an ATC position for one session. Key convention:
* `<sessionSeed>:<airport>:<positionType>[:<frequency>]` — the session seed
* makes each session sound like a different shift while the position keeps
* one consistent controller within the session.
*/
export function controllerPersonaFor(positionKey: string): ControllerPersona {
const key = positionKey.toUpperCase()
const voice = voiceFromPool(key, CONTROLLER_VOICES, [])
// Independent hash stream for speed so voice and pace don't correlate.
const baseSpeed = 1.1 + (mix32(fnv1a(`speed:${key}`)) % 21) / 100
return { voice, baseSpeed: Math.round(baseSpeed * 100) / 100 }
}
/**
* Per-transmission pace: the persona's base speed ±0.05 — real controllers
* don't hit the exact same tempo twice.
*/
export function transmissionSpeed(baseSpeed: number, rng: () => number = Math.random): number {
const jitter = (rng() * 2 - 1) * 0.05
return Math.round((baseSpeed + jitter) * 100) / 100
}
/**
* Selectable classroom instructor voices. The instructor is a single voice
* (not a live position), so it may draw from any pool voice. Labels/accents
* mirror the Speaches mapping in server/utils/voiceRegistry.ts.
*/
export type ClassroomVoiceOption = {
id: string
label: string
accent: 'US' | 'GB'
gender: 'male' | 'female'
}
export const CLASSROOM_VOICE_OPTIONS: readonly ClassroomVoiceOption[] = [
{ id: 'alloy', label: 'Ryan', accent: 'US', gender: 'male' },
{ id: 'onyx', label: 'John', accent: 'US', gender: 'male' },
{ id: 'coral', label: 'Joe', accent: 'US', gender: 'male' },
{ id: 'nova', label: 'Bryce', accent: 'US', gender: 'male' },
{ id: 'fable', label: 'Amy', accent: 'US', gender: 'female' },
{ id: 'shimmer', label: 'Kristin', accent: 'US', gender: 'female' },
{ id: 'sage', label: 'Hannah', accent: 'US', gender: 'female' },
{ id: 'echo', label: 'Jenny', accent: 'GB', gender: 'female' },
{ id: 'ash', label: 'Alan', accent: 'GB', gender: 'male' },
{ id: 'ballad', label: 'Alba', accent: 'GB', gender: 'female' },
]
/** Default classroom instructor voice — the standard US speaker (Ryan). */
export const CLASSROOM_DEFAULT_VOICE = 'alloy'
/** Settings sentinel: pick a stable random voice per module instead of one fixed voice. */
export const CLASSROOM_RANDOM_VOICE = 'random'
const CLASSROOM_VOICE_POOL: readonly string[] = CLASSROOM_VOICE_OPTIONS.map(o => o.id)
/**
* A stable random classroom instructor voice for `key` (e.g. a module id),
* drawn from every selectable classroom voice — one instructor per lesson,
* consistent while it lasts.
*/
export function classroomVoiceFor(key: string): string {
return voiceFromPool(key, CLASSROOM_VOICE_POOL, [])
}