mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 22:05:34 +08:00
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:
@@ -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);
|
||||
|
||||
39
server/utils/voiceRegistry.test.ts
Normal file
39
server/utils/voiceRegistry.test.ts
Normal 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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
40
server/utils/voiceRegistry.ts
Normal file
40
server/utils/voiceRegistry.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user