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>
This commit is contained in:
itsrubberduck
2026-07-19 12:36:19 +02:00
parent f03355daff
commit 4f367a67aa
5 changed files with 139 additions and 28 deletions

View File

@@ -1414,6 +1414,23 @@
/>
</div>
<div class="set-row">
<div class="set-info">
<span>Instructor voice</span>
<small class="muted">Standard US voice, one instructor per module, or a fixed voice.</small>
</div>
<v-select
v-model="cfg.voice"
:items="instructorVoiceOptions"
item-title="title"
item-value="value"
density="compact"
hide-details
color="cyan"
style="max-width: 220px"
/>
</div>
<div class="set-row">
<span>Test TTS</span>
<div class="row">
@@ -1537,7 +1554,12 @@ 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 {
CLASSROOM_DEFAULT_VOICE,
CLASSROOM_RANDOM_VOICE,
CLASSROOM_VOICE_OPTIONS,
classroomVoiceFor,
} from '~~/shared/utils/voicePool'
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech'
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
@@ -3156,6 +3178,15 @@ const globalAccuracy = computed(() => {
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2))
const instructorVoiceOptions = [
{ title: 'Standard (Ryan · US)', value: '' },
{ title: 'Random per module', value: CLASSROOM_RANDOM_VOICE },
...CLASSROOM_VOICE_OPTIONS.map(option => ({
title: `${option.label} · ${option.accent} ${option.gender === 'female' ? '♀' : '♂'}`,
value: option.id,
})),
]
const requiresFlightPlan = computed(() => Boolean(current.value?.meta?.flightPlan))
const currentPlan = computed(() => (current.value ? missionPlans[current.value.id] ?? null : null))
const currentPlanRoute = computed(() => scenarioRoute(currentPlan.value?.scenario || null))
@@ -4651,14 +4682,17 @@ async function ensurePizzicato(ctx: AudioContext | null): Promise<PizzicatoLite
}
/**
* 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.
* The instructor's voice. Default is the standard US speaker; the settings
* offer "random" (a stable random voice per module — each module sounds like
* its own instructor) or one fixed named voice.
*/
function instructorVoice(): string {
const configured = cfg.value.voice?.trim()
if (configured) return configured
return controllerVoiceFor(`classroom:${current.value?.id || 'learn'}`)
if (!configured) return CLASSROOM_DEFAULT_VOICE
if (configured === CLASSROOM_RANDOM_VOICE) {
return classroomVoiceFor(`classroom:${current.value?.id || 'learn'}`)
}
return configured
}
function buildSayCacheKey(text: string, rate: number): string {

View File

@@ -2,7 +2,6 @@ import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
ATIS_SPEACHES_VOICE,
KOKORO_MODEL,
resolveSpeachesVoice,
SPEACHES_VOICE_MAP,
} from '~~/server/utils/voiceRegistry'
@@ -10,20 +9,20 @@ import {
const fallback = { model: 'speaches-ai/piper-de_DE-thorsten-medium', voice: 'de_DE-thorsten-medium' }
describe('resolveSpeachesVoice', () => {
it('maps every pool voice to a consistent model/voice pair', () => {
it('maps every pool voice to a fast English Piper speaker', () => {
for (const [logical, mapped] of Object.entries(SPEACHES_VOICE_MAP)) {
const result = resolveSpeachesVoice(logical, fallback)
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_/)
}
// Piper only — Kokoro generation was slow enough to delay ATC replies.
assert.equal(result.model, `speaches-ai/piper-${result.voice}`)
assert.match(result.voice, /^en_/)
}
})
it('keeps the default controller voice on the standard US speaker', () => {
assert.equal(resolveSpeachesVoice('alloy', fallback).voice, 'en_US-ryan-medium')
})
it('is case-insensitive and trims', () => {
const result = resolveSpeachesVoice(' Alloy ', fallback)
assert.deepEqual(result, SPEACHES_VOICE_MAP.alloy)

View File

@@ -4,16 +4,20 @@
* 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;
* 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).
* Speaches auto-downloads missing `speaches-ai/piper-*` models on first use.
* Every pool voice is a distinct Piper speaker — Piper synthesizes fast
* enough for radio latency, and the pools stay disjoint so traffic never
* sounds like the controller talking to itself. Selection follows the
* curated radio set (docs/plans/2026-07-19-phraseology-taxi-finetune-design.md).
*/
export type SpeachesVoice = { model: string; voice: string }
/**
* Kokoro's shared multi-voice model. Not used for any pool voice: it
* generates several times slower than Piper, which showed up directly as
* multi-second ATC reply latency. Kept for explicit opt-in mappings only.
*/
export const KOKORO_MODEL = 'speaches-ai/Kokoro-82M-v1.0-ONNX'
const piper = (voice: string): SpeachesVoice => ({
@@ -21,14 +25,15 @@ const piper = (voice: string): SpeachesVoice => ({
voice,
})
const kokoro = (voice: string): SpeachesVoice => ({ model: KOKORO_MODEL, voice })
export const SPEACHES_VOICE_MAP: Record<string, SpeachesVoice> = {
// Controller pool (CONTROLLER_VOICES)
alloy: kokoro('bm_george'),
echo: kokoro('bf_emma'),
onyx: kokoro('am_michael'),
sage: kokoro('af_heart'),
// Controller pool (CONTROLLER_VOICES). Piper only: Kokoro-82M generates
// several times slower and made every ATC reply audibly late — controllers
// are the latency-critical voices. `alloy` is the product-wide default and
// stays the standard US speaker.
alloy: piper('en_US-ryan-medium'),
echo: piper('en_GB-jenny_dioco-medium'),
onyx: piper('en_US-john-medium'),
sage: piper('en_US-hfc_female-medium'),
// The user's own readback voice (speakPilotReadback)
verse: piper('en_US-lessac-medium'),
// Simulated pilot pool (PILOT_VOICES)

View File

@@ -112,3 +112,45 @@ export function transmissionSpeed(baseSpeed: number, rng: () => number = Math.ra
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, [])
}

View File

@@ -2,9 +2,13 @@ import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
CLASSROOM_DEFAULT_VOICE,
CLASSROOM_RANDOM_VOICE,
CLASSROOM_VOICE_OPTIONS,
CONTROLLER_VOICES,
PILOT_VOICES,
RESERVED_VOICES,
classroomVoiceFor,
controllerPersonaFor,
controllerVoiceFor,
fnv1a,
@@ -131,6 +135,33 @@ describe('voicePool — controller personas', () => {
})
})
describe('voicePool — classroom voices', () => {
it('defaults to the standard US speaker', () => {
assert.equal(CLASSROOM_DEFAULT_VOICE, 'alloy')
const option = CLASSROOM_VOICE_OPTIONS.find(o => o.id === CLASSROOM_DEFAULT_VOICE)
assert.ok(option, 'default voice must be selectable')
assert.equal(option!.accent, 'US')
})
it('offers unique selectable ids and the random sentinel is not one of them', () => {
const ids = CLASSROOM_VOICE_OPTIONS.map(o => o.id)
assert.equal(new Set(ids).size, ids.length)
assert.equal(ids.includes(CLASSROOM_RANDOM_VOICE), false)
})
it('assigns a stable random voice per module from the selectable set', () => {
const ids = CLASSROOM_VOICE_OPTIONS.map(o => o.id)
const seen = new Set<string>()
for (const module of ['fundamentals', 'readbacks', 'atc-advanced', 'full-flight', 'metar']) {
const voice = classroomVoiceFor(`classroom:${module}`)
assert.equal(classroomVoiceFor(`classroom:${module}`), voice, 'must be deterministic')
assert.ok(ids.includes(voice), `${voice} must be selectable`)
seen.add(voice)
}
assert.ok(seen.size > 1, 'different modules should not all share one voice')
})
})
describe('voicePool — fnv1a', () => {
it('matches the reference vectors', () => {
assert.equal(fnv1a(''), 0x811c9dc5)