@@ -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)