From 1272d45bf9a13a41a1a81e80a57f29c9698530b3 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 19 Jul 2026 11:22:44 +0200 Subject: [PATCH] feat(tts): dedicated ATIS broadcast voice on Speaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ATIS tag now resolves to en_GB-alba-medium — outside both voice pools — instead of sharing 'verse' with the user's own readback voice. Co-Authored-By: Claude Fable 5 --- server/api/atc/say.post.ts | 2 +- server/utils/voiceRegistry.test.ts | 8 ++++++++ server/utils/voiceRegistry.ts | 13 ++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 9407047..04c7134 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -212,7 +212,7 @@ export default defineEventHandler(async (event) => { voice, }; const speachesVoice = provider === "speaches" - ? resolveSpeachesVoice(voice, speachesFallback) + ? resolveSpeachesVoice(voice, speachesFallback, body?.tag) : null; const ttsVoice = speachesVoice?.voice ?? voice; const providerModel = speachesVoice diff --git a/server/utils/voiceRegistry.test.ts b/server/utils/voiceRegistry.test.ts index dffe9ef..8e26057 100644 --- a/server/utils/voiceRegistry.test.ts +++ b/server/utils/voiceRegistry.test.ts @@ -28,6 +28,14 @@ describe('resolveSpeachesVoice', () => { assert.deepEqual(resolveSpeachesVoice('', fallback), fallback) }) + 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) + 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') + }) + 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'] diff --git a/server/utils/voiceRegistry.ts b/server/utils/voiceRegistry.ts index 5cc2775..1a48a76 100644 --- a/server/utils/voiceRegistry.ts +++ b/server/utils/voiceRegistry.ts @@ -34,7 +34,18 @@ export const SPEACHES_VOICE_MAP: Record = { shimmer: piper('en_US-kristin-medium'), } -export function resolveSpeachesVoice(logical: string, fallback: SpeachesVoice): 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 function resolveSpeachesVoice( + logical: string, + fallback: SpeachesVoice, + tag?: string, +): SpeachesVoice { + if ((tag || '').trim().toLowerCase() === 'atis') return ATIS_SPEACHES_VOICE const key = (logical || '').trim().toLowerCase() return SPEACHES_VOICE_MAP[key] ?? fallback }