feat(tts): map logical pool voices to distinct Piper speakers on Speaches

Until now the Speaches branch sent every request to the single env-configured
model (de_DE-thorsten — a German voice for English radio), so the voice pool
had no audible effect. A server-side registry now resolves the logical voice
ids to disjoint controller/pilot Piper speakers; unknown ids keep the env
fallback and the cache key follows the resolved pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-19 10:45:49 +02:00
parent b87884a608
commit b359670d47
3 changed files with 95 additions and 5 deletions

View File

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

View File

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

View File

@@ -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<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'),
// 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
}