diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 2ef84bd..2193b41 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1414,6 +1414,23 @@ /> +
+
+ Instructor voice + Standard US voice, one instructor per module, or a fixed voice. +
+ +
+
Test TTS
@@ -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 { - 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) diff --git a/server/utils/voiceRegistry.ts b/server/utils/voiceRegistry.ts index 77b2f24..8015280 100644 --- a/server/utils/voiceRegistry.ts +++ b/server/utils/voiceRegistry.ts @@ -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 = { - // 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) diff --git a/shared/utils/voicePool.ts b/shared/utils/voicePool.ts index 717f2cc..77c9ab1 100644 --- a/shared/utils/voicePool.ts +++ b/shared/utils/voicePool.ts @@ -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, []) +} diff --git a/tests/shared/voicePool.test.ts b/tests/shared/voicePool.test.ts index 9c84adc..e29a0f8 100644 --- a/tests/shared/voicePool.test.ts +++ b/tests/shared/voicePool.test.ts @@ -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() + 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)