From 86c887e87ee99f1fcdea652ad2511c3e3d654d09 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 16 Sep 2025 16:42:01 +0200 Subject: [PATCH] feat(api): extend ATC TTS endpoint with flexible output formats & Speaches support - say.post.ts: - added AudioFmt type, format helpers (mime/ext), Speaches TTS integration - routing: Speaches > Piper > OpenAI fallback - supports dynamic formats (, =============================================================================== flac - Command-line FLAC encoder/decoder version 1.5.0 Copyright (C) 2000-2009 Josh Coalson Copyright (C) 2011-2025 Xiph.Org Foundation This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. =============================================================================== This is the short help; for all options use 'flac --help'; for more explanation and examples please consult the manual. This manual is often distributed alongside the program as a man page or an HTML file. It can also be found online at https://xiph.org/flac/documentation_tools_flac.html To encode: flac [-#] [INPUTFILE [...]] -# is -0 (fastest compression) to -8 (highest compression); -5 is the default To decode: flac -d [INPUTFILE [...]] To test: flac -t [INPUTFILE [...]], , ) or smallest - uses VOICE_ID and SPEACHES_BASE_URL from ENV - response returns correct mime, extension & base64 - meta extended with modelUsed and format - ports & defaults configurable via ENV --- server/api/atc/say.post.ts | 175 ++++++++++++++++++++++++++----------- 1 file changed, 126 insertions(+), 49 deletions(-) diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 13dc9e0..39c6798 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -1,40 +1,56 @@ // server/api/atc/say.post.ts -import {createError, readBody} from "h3"; -import {writeFile, mkdir} from "node:fs/promises"; -import {existsSync} from "node:fs"; -import {join} from "node:path"; -import {randomUUID} from "node:crypto"; -import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize"; -import {request} from "node:http"; - -// dotenv config -import {config} from "dotenv"; - +import { createError, readBody } from "h3"; +import { writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { normalize, TTS_MODEL, normalizeATC } from "../../utils/normalize"; +import { request } from "node:http"; +// dotenv +import { config } from "dotenv"; config(); - function outDir() { return process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc"); } async function ensureDir(p: string) { - if (!existsSync(p)) await mkdir(p, {recursive: true}); + if (!existsSync(p)) await mkdir(p, { recursive: true }); } function simulateRadioQuality(level: number) { switch (level) { - case 5: - return {gain: 1.0, description: "crystal clear"}; - case 4: - return {gain: 0.9, description: "very good"}; - case 3: - return {gain: 0.8, description: "good"}; - case 2: - return {gain: 0.7, description: "poor"}; - case 1: - return {gain: 0.6, description: "very poor"}; - default: - return {gain: 0.8, description: "standard"}; + case 5: return { gain: 1.0, description: "crystal clear" }; + case 4: return { gain: 0.9, description: "very good" }; + case 3: return { gain: 0.8, description: "good" }; + case 2: return { gain: 0.7, description: "poor" }; + case 1: return { gain: 0.6, description: "very poor" }; + default: return { gain: 0.8, description: "standard" }; + } +} + +// ---- Format Helpers ---- +type AudioFmt = "mp3" | "flac" | "wav" | "pcm"; +function pickDefaultFormat(useSpeaches: boolean): AudioFmt { + // kleinste Bitrate bevorzugen, wenn Speaches genutzt wird + return useSpeaches ? "mp3" : "wav"; +} +function fmtToMime(fmt: AudioFmt): string { + switch (fmt) { + case "mp3": return "audio/mpeg"; + case "flac": return "audio/flac"; + case "wav": return "audio/wav"; + case "pcm": return "audio/L16"; // raw PCM (fallback) + default: return "application/octet-stream"; + } +} +function fmtToExt(fmt: AudioFmt): string { + switch (fmt) { + case "mp3": return "mp3"; + case "flac": return "flac"; + case "wav": return "wav"; + case "pcm": return "pcm"; + default: return "bin"; } } @@ -44,12 +60,10 @@ async function piperTTS(text: string, voice: string): Promise { const req = request( { hostname: "localhost", - port: 5001, + port: Number(process.env.PIPER_PORT ?? 5001), path: "/", method: "POST", - headers: { - "Content-Type": "application/json" - } + headers: { "Content-Type": "application/json" } }, (res) => { const data: Buffer[] = []; @@ -58,11 +72,45 @@ async function piperTTS(text: string, voice: string): Promise { } ); req.on("error", reject); - req.write(JSON.stringify({text, voice})); + req.write(JSON.stringify({ text, voice })); req.end(); }); } +// ---- Speaches HTTP helper ---- +// Env: +// USE_SPEACHES=true +// SPEACHES_BASE_URL="https://..." +// SPEECH_MODEL_ID="speaches-ai/piper-en_US-ryan-low" +// VOICE_ID="en_US-ryan-low" +async function speachesTTS( + input: string, + voice: string, + model: string, + response_format: AudioFmt, + baseUrl: string +): Promise { + const url = `${baseUrl.replace(/\/+$/, "")}/v1/audio/speech`; + const body = { + input, + model, + voice, + // API erwartet "response_format": "mp3" | "flac" | "wav" | "pcm" + response_format + }; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Speaches API ${res.status}: ${text || res.statusText}`); + } + const arr = await res.arrayBuffer(); + return Buffer.from(arr); +} + export default defineEventHandler(async (event) => { const body = await readBody<{ text?: string; @@ -72,34 +120,61 @@ export default defineEventHandler(async (event) => { moduleId?: string; lessonId?: string; tag?: string; + format?: AudioFmt | "smallest"; }>(event); const raw = (body?.text || "").trim(); - if (!raw) throw createError({statusCode: 400, statusMessage: "text required"}); + if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" }); const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4))); - const voice = (body?.voice || "alloy").trim(); + const voice = (body?.voice || process.env.VOICE_ID || "alloy").trim(); const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0)); const normalized = normalizeATC(raw); - if (!normalized) throw createError({statusCode: 400, statusMessage: "normalized text empty"}); + if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" }); + + // Routing + const useSpeaches = (process.env.USE_SPEACHES || "").toLowerCase() === "true"; + const usePiper = !useSpeaches && (process.env.USE_PIPER || "").toLowerCase() === "true"; + + // Format + const requestedFmt = (body?.format === "smallest" ? "mp3" : body?.format) as AudioFmt | undefined; + const fmt: AudioFmt = requestedFmt || pickDefaultFormat(useSpeaches); + const mime = fmtToMime(fmt); + const ext = fmtToExt(fmt); const radioQuality = simulateRadioQuality(level); const id = randomUUID(); const timestamp = new Date().toISOString(); const dateFolder = timestamp.slice(0, 10); const baseDir = join(outDir(), dateFolder); - const fileWav = join(baseDir, `${id}.wav`); + const fileOut = join(baseDir, `${id}.${ext}`); const fileJson = join(baseDir, `${id}.json`); try { let audioBuffer: Buffer; + let modelUsed: string; + let actualMime = mime; - if (process.env.USE_PIPER?.toLowerCase() === "true") { - // --- Lokaler Piper-Server --- + if (useSpeaches) { + // Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM) + const baseUrl = process.env.SPEACHES_BASE_URL || ""; + const model = process.env.SPEECH_MODEL_ID || "speaches-ai/piper-en_US-ryan-low"; + if (!baseUrl) { + throw new Error("SPEACHES_BASE_URL not set"); + } + audioBuffer = await speachesTTS(normalized, voice, model, fmt, baseUrl); + modelUsed = model; + // Server liefert korrektes Format gemäß response_format + actualMime = fmtToMime(fmt); + } else if (usePiper) { + // Lokaler Piper audioBuffer = await piperTTS(normalized, voice); + modelUsed = "piper-local"; + // Piper liefert WAV + actualMime = "audio/wav"; } else { - // --- OpenAI Fallback --- + // OpenAI (Fallback) const tts = await normalize.audio.speech.create({ model: TTS_MODEL, voice, @@ -108,11 +183,13 @@ export default defineEventHandler(async (event) => { speed }); audioBuffer = Buffer.from(await tts.arrayBuffer()); + modelUsed = TTS_MODEL; + actualMime = "audio/wav"; } + // Optional speichern // await ensureDir(baseDir); - // await writeFile(fileWav, audioBuffer); - + // await writeFile(fileOut, audioBuffer); const meta = { id, createdAt: timestamp, @@ -125,11 +202,10 @@ export default defineEventHandler(async (event) => { tag: body?.tag || null, moduleId: body?.moduleId || null, lessonId: body?.lessonId || null, - files: {wav: fileWav}, - model: process.env.USE_PIPER?.toLowerCase() === "true" ? "piper-local" : TTS_MODEL, - format: "audio/wav" + files: { audio: fileOut }, + model: modelUsed, + format: actualMime }; - // await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8"); return { @@ -142,21 +218,22 @@ export default defineEventHandler(async (event) => { normalized, radioQuality: radioQuality.description, audio: { - mime: "audio/wav", + mime: actualMime, base64: audioBuffer.toString("base64"), - size: audioBuffer.length + size: audioBuffer.length, + ext }, stored: { - audioPath: fileWav, + audioPath: fileOut, jsonPath: fileJson, - url: `/api/atc/audio/${dateFolder}/${id}.wav` + url: `/api/atc/audio/${dateFolder}/${id}.${ext}` }, meta }; - } catch (err) { + } catch (err: any) { throw createError({ statusCode: 500, - statusMessage: `TTS generation failed: ${err}` + statusMessage: `TTS generation failed: ${err?.message || err}` }); } });