mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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(', ')}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user