mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-09 19:16:02 +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 { requireUserSession } from "../../utils/auth";
|
||||||
import { enforceRateLimit } from "../../utils/rateLimit";
|
import { enforceRateLimit } from "../../utils/rateLimit";
|
||||||
import { recordUsage } from "../../utils/usage";
|
import { recordUsage } from "../../utils/usage";
|
||||||
|
import { resolveSpeachesVoice } from "../../utils/voiceRegistry";
|
||||||
|
|
||||||
|
|
||||||
function outDir() {
|
function outDir() {
|
||||||
@@ -204,8 +205,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
const useSpeaches = runtimeConfig.useSpeaches;
|
const useSpeaches = runtimeConfig.useSpeaches;
|
||||||
const usePiper = !useSpeaches && runtimeConfig.usePiper;
|
const usePiper = !useSpeaches && runtimeConfig.usePiper;
|
||||||
const provider = resolveTtsProvider(useSpeaches, usePiper);
|
const provider = resolveTtsProvider(useSpeaches, usePiper);
|
||||||
const providerModel = provider === "speaches"
|
// Speaches: logical pool voices (alloy, verse, …) resolve to Piper
|
||||||
? (runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low")
|
// 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"
|
: provider === "piper"
|
||||||
? "piper-local"
|
? "piper-local"
|
||||||
: TTS_MODEL;
|
: TTS_MODEL;
|
||||||
@@ -221,7 +232,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
? buildFlightLabCacheKey({
|
? buildFlightLabCacheKey({
|
||||||
normalized,
|
normalized,
|
||||||
level,
|
level,
|
||||||
voice,
|
voice: ttsVoice,
|
||||||
speed,
|
speed,
|
||||||
format: fmt,
|
format: fmt,
|
||||||
provider,
|
provider,
|
||||||
@@ -263,11 +274,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!audioBuffer && useSpeaches) {
|
if (!audioBuffer && useSpeaches) {
|
||||||
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
|
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
|
||||||
const baseUrl = runtimeConfig.speachesBaseUrl || "";
|
const baseUrl = runtimeConfig.speachesBaseUrl || "";
|
||||||
const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low";
|
const model = providerModel;
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
throw new Error("SPEACHES_BASE_URL not set");
|
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;
|
modelUsed = model;
|
||||||
// Server returns the correct format according to response_format
|
// Server returns the correct format according to response_format
|
||||||
actualMime = fmtToMime(fmt);
|
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