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
This commit is contained in:
itsrubberduck
2025-09-16 16:42:01 +02:00
parent e155434b57
commit 86c887e87e

View File

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