diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 772e466..9407047 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -10,6 +10,7 @@ import { TransmissionLog } from "../../models/TransmissionLog"; import { requireUserSession } from "../../utils/auth"; import { enforceRateLimit } from "../../utils/rateLimit"; import { recordUsage } from "../../utils/usage"; +import { resolveSpeachesVoice } from "../../utils/voiceRegistry"; function outDir() { @@ -204,8 +205,18 @@ export default defineEventHandler(async (event) => { const useSpeaches = runtimeConfig.useSpeaches; const usePiper = !useSpeaches && runtimeConfig.usePiper; const provider = resolveTtsProvider(useSpeaches, usePiper); - const providerModel = provider === "speaches" - ? (runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low") + // Speaches: logical pool voices (alloy, verse, …) resolve to Piper + // model+voice pairs; anything unknown keeps the env-configured pair. + const speachesFallback = { + model: runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low", + voice, + }; + const speachesVoice = provider === "speaches" + ? resolveSpeachesVoice(voice, speachesFallback) + : null; + const ttsVoice = speachesVoice?.voice ?? voice; + const providerModel = speachesVoice + ? speachesVoice.model : provider === "piper" ? "piper-local" : TTS_MODEL; @@ -221,7 +232,7 @@ export default defineEventHandler(async (event) => { ? buildFlightLabCacheKey({ normalized, level, - voice, + voice: ttsVoice, speed, format: fmt, provider, @@ -263,11 +274,11 @@ export default defineEventHandler(async (event) => { if (!audioBuffer && useSpeaches) { // Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM) const baseUrl = runtimeConfig.speachesBaseUrl || ""; - const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low"; + const model = providerModel; if (!baseUrl) { throw new Error("SPEACHES_BASE_URL not set"); } - audioBuffer = await speachesTTS(normalized, voice, model, fmt, baseUrl, speed); + audioBuffer = await speachesTTS(normalized, ttsVoice, model, fmt, baseUrl, speed); modelUsed = model; // Server returns the correct format according to response_format actualMime = fmtToMime(fmt); diff --git a/server/utils/voiceRegistry.test.ts b/server/utils/voiceRegistry.test.ts new file mode 100644 index 0000000..dffe9ef --- /dev/null +++ b/server/utils/voiceRegistry.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { 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', () => { + 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}`) + } + }) + + 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) + }) + + it('falls back to the configured pair for unknown ids', () => { + assert.deepEqual(resolveSpeachesVoice('somepipervoice', fallback), fallback) + assert.deepEqual(resolveSpeachesVoice('', fallback), fallback) + }) + + it('keeps controller and pilot voices audibly disjoint', () => { + const controller = ['alloy', 'echo', 'onyx', 'sage'].map(v => resolveSpeachesVoice(v, fallback).voice) + const pilots = ['verse', 'ash', 'ballad', 'coral', 'fable', 'nova', 'shimmer'] + .map(v => resolveSpeachesVoice(v, fallback).voice) + for (const voice of controller) { + assert.equal(pilots.includes(voice), false, `${voice} used for both controller and pilot`) + } + }) +}) diff --git a/server/utils/voiceRegistry.ts b/server/utils/voiceRegistry.ts new file mode 100644 index 0000000..5cc2775 --- /dev/null +++ b/server/utils/voiceRegistry.ts @@ -0,0 +1,40 @@ +/** + * 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. + * + * 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. + */ + +export type SpeachesVoice = { model: string; voice: string } + +const piper = (voice: string): SpeachesVoice => ({ + model: `speaches-ai/piper-${voice}`, + voice, +}) + +export const SPEACHES_VOICE_MAP: Record = { + // 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'), + // 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'), + shimmer: piper('en_US-kristin-medium'), +} + +export function resolveSpeachesVoice(logical: string, fallback: SpeachesVoice): SpeachesVoice { + const key = (logical || '').trim().toLowerCase() + return SPEACHES_VOICE_MAP[key] ?? fallback +}