From f03355daff4b82c67becb7603acc1751b4402660 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 19 Jul 2026 11:50:30 +0200 Subject: [PATCH] feat(tts): curated Kokoro+Piper radio voices, classroom instructor from pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controllers now speak with the four Kokoro speakers (bm_george, bf_emma, am_michael, af_heart — heard most, best quality), pilots with six distinct Piper speakers (alan, alba, joe, amy, bryce, kristin); ATIS moves to the dedicated ljspeech broadcast voice. The classroom instructor draws a stable controller-pool voice per module when the user hasn't configured one, and the TTS cache key follows the effective voice. Co-Authored-By: Claude Fable 5 --- app/pages/classroom.vue | 18 +++++++++++--- server/utils/voiceRegistry.test.ts | 34 ++++++++++++++++--------- server/utils/voiceRegistry.ts | 40 +++++++++++++++++------------- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 116814f..2ef84bd 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1537,6 +1537,7 @@ import {looksLikeCallsignKey, matchTranscriptionToFields, type SttFieldDef} from import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite' import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite' import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects' +import {controllerVoiceFor} from '~~/shared/utils/voicePool' import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech' definePageMeta({middleware: ['require-auth', 'require-classroom-intro']}) @@ -4649,8 +4650,19 @@ async function ensurePizzicato(ctx: AudioContext | null): Promise { - it('maps every pool voice to a matching piper model/voice pair', () => { + it('maps every pool voice to a consistent model/voice pair', () => { for (const [logical, mapped] of Object.entries(SPEACHES_VOICE_MAP)) { const result = resolveSpeachesVoice(logical, fallback) - assert.equal(result.voice, mapped.voice) - assert.equal(result.model, `speaches-ai/piper-${mapped.voice}`) + assert.deepEqual(result, mapped) + if (result.model === KOKORO_MODEL) { + // Kokoro voice ids look like af_heart / bm_george. + assert.match(result.voice, /^[abehijpz][fm]_[a-z_]+$/) + } else { + assert.equal(result.model, `speaches-ai/piper-${result.voice}`) + assert.match(result.voice, /^en_/) + } } }) - it('maps the default controller voice to an English speaker', () => { - const result = resolveSpeachesVoice('alloy', fallback) - assert.match(result.voice, /^en_/) - }) - it('is case-insensitive and trims', () => { const result = resolveSpeachesVoice(' Alloy ', fallback) - assert.equal(result.voice, SPEACHES_VOICE_MAP.alloy!.voice) + assert.deepEqual(result, SPEACHES_VOICE_MAP.alloy) }) it('falls back to the configured pair for unknown ids', () => { @@ -30,8 +36,7 @@ describe('resolveSpeachesVoice', () => { it('gives the ATIS broadcast its own dedicated speaker', () => { const atis = resolveSpeachesVoice('verse', fallback, 'atis') - assert.match(atis.voice, /^en_/) - assert.notEqual(atis.voice, resolveSpeachesVoice('verse', fallback).voice) + assert.deepEqual(atis, ATIS_SPEACHES_VOICE) const allPoolVoices = Object.values(SPEACHES_VOICE_MAP).map(v => v.voice) assert.equal(allPoolVoices.includes(atis.voice), false, 'ATIS voice must not be in any pool') }) @@ -44,4 +49,9 @@ describe('resolveSpeachesVoice', () => { assert.equal(pilots.includes(voice), false, `${voice} used for both controller and pilot`) } }) + + it('assigns no speaker twice across the whole registry', () => { + const voices = [...Object.values(SPEACHES_VOICE_MAP).map(v => v.voice), ATIS_SPEACHES_VOICE.voice] + assert.equal(new Set(voices).size, voices.length, `duplicate speaker in ${voices.join(', ')}`) + }) }) diff --git a/server/utils/voiceRegistry.ts b/server/utils/voiceRegistry.ts index 1a48a76..77b2f24 100644 --- a/server/utils/voiceRegistry.ts +++ b/server/utils/voiceRegistry.ts @@ -1,36 +1,42 @@ /** * Maps the product-wide logical voice ids (shared/utils/voicePool.ts — OpenAI - * voice names) to Speaches/Piper model+voice pairs. The client keeps sending - * pool names; only the Speaches branch of /api/atc/say resolves them here, so - * the same ids work unchanged on the OpenAI provider. + * voice names) to Speaches model+voice pairs. The client keeps sending pool + * names; only the Speaches branch of /api/atc/say resolves them here, so the + * same ids work unchanged on the OpenAI provider. * - * Speaches auto-downloads missing `speaches-ai/piper-*` models on first use. - * Controller voices (alloy/echo/onyx/sage) and pilot voices (verse + the - * PILOT_VOICES pool) map to disjoint speakers so traffic never sounds like - * the controller talking to itself. + * Speaches auto-downloads missing `speaches-ai/piper-*` models on first use; + * the Kokoro voices all live in one multi-voice model. Controllers get the + * Kokoro speakers (heard most, best quality), pilots the distinct Piper + * speakers — the pools stay disjoint so traffic never sounds like the + * controller talking to itself. Selection follows the curated radio set + * (see docs/plans/2026-07-19-phraseology-taxi-finetune-design.md). */ export type SpeachesVoice = { model: string; voice: string } +export const KOKORO_MODEL = 'speaches-ai/Kokoro-82M-v1.0-ONNX' + const piper = (voice: string): SpeachesVoice => ({ model: `speaches-ai/piper-${voice}`, voice, }) +const kokoro = (voice: string): SpeachesVoice => ({ model: KOKORO_MODEL, voice }) + export const SPEACHES_VOICE_MAP: Record = { // Controller pool (CONTROLLER_VOICES) - alloy: piper('en_US-ryan-medium'), - echo: piper('en_GB-alan-medium'), - onyx: piper('en_US-john-medium'), - sage: piper('en_GB-jenny_dioco-medium'), + alloy: kokoro('bm_george'), + echo: kokoro('bf_emma'), + onyx: kokoro('am_michael'), + sage: kokoro('af_heart'), // The user's own readback voice (speakPilotReadback) verse: piper('en_US-lessac-medium'), // Simulated pilot pool (PILOT_VOICES) - ash: piper('en_US-joe-medium'), - ballad: piper('en_GB-northern_english_male-medium'), - coral: piper('en_US-amy-medium'), - fable: piper('en_US-danny-low'), - nova: piper('en_US-hfc_female-medium'), + ash: piper('en_GB-alan-medium'), + ballad: piper('en_GB-alba-medium'), + coral: piper('en_US-joe-medium'), + fable: piper('en_US-amy-medium'), + nova: piper('en_US-bryce-medium'), shimmer: piper('en_US-kristin-medium'), } @@ -38,7 +44,7 @@ export const SPEACHES_VOICE_MAP: Record = { * ATIS is a robotic broadcast in the real world — it gets a dedicated speaker * outside every pool so it never sounds like a controller or a pilot. */ -export const ATIS_SPEACHES_VOICE: SpeachesVoice = piper('en_GB-alba-medium') +export const ATIS_SPEACHES_VOICE: SpeachesVoice = piper('en_US-ljspeech-high') export function resolveSpeachesVoice( logical: string,