feat(tts): curated Kokoro+Piper radio voices, classroom instructor from pool

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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-19 11:50:30 +02:00
parent 1272d45bf9
commit f03355daff
3 changed files with 59 additions and 33 deletions

View File

@@ -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<PizzicatoLite
return pizzicatoLiteInstance
}
/**
* The instructor's voice: the user's explicit choice, or a stable
* controller-pool voice per module — each module sounds like its own
* instructor, consistent across the whole lesson.
*/
function instructorVoice(): string {
const configured = cfg.value.voice?.trim()
if (configured) return configured
return controllerVoiceFor(`classroom:${current.value?.id || 'learn'}`)
}
function buildSayCacheKey(text: string, rate: number): string {
const voice = cfg.value.voice?.trim().toLowerCase() || 'default'
const voice = instructorVoice().toLowerCase()
const radioLevel = cfg.value.radioLevel
return `${voice}|${radioLevel}|${rate.toFixed(2)}|${text}`
}
@@ -5007,9 +5019,7 @@ async function say(text: string) {
tag: 'learn-target'
}
if (cfg.value.voice) {
payload.voice = cfg.value.voice
}
payload.voice = instructorVoice()
const cacheKey = buildSayCacheKey(speakText, normalizedRate)

View File

@@ -1,26 +1,32 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { resolveSpeachesVoice, SPEACHES_VOICE_MAP } from '~~/server/utils/voiceRegistry'
import {
ATIS_SPEACHES_VOICE,
KOKORO_MODEL,
resolveSpeachesVoice,
SPEACHES_VOICE_MAP,
} from '~~/server/utils/voiceRegistry'
const fallback = { model: 'speaches-ai/piper-de_DE-thorsten-medium', voice: 'de_DE-thorsten-medium' }
describe('resolveSpeachesVoice', () => {
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(', ')}`)
})
})

View File

@@ -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<string, SpeachesVoice> = {
// 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<string, SpeachesVoice> = {
* 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,