mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
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:
@@ -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}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user