mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 05:45:35 +08:00
tts tut jetz
This commit is contained in:
@@ -47,7 +47,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Nur Audio-Dateien servieren
|
||||
const allowedExtensions = ['.ogg', '.wav', '.mp3'];
|
||||
const allowedExtensions = ['.wav', '.mp3', '.ogg'];
|
||||
const hasValidExtension = allowedExtensions.some(ext => filePath.toLowerCase().endsWith(ext));
|
||||
|
||||
if (!hasValidExtension) {
|
||||
|
||||
@@ -1,118 +1,11 @@
|
||||
// server/api/atc/say.post.ts
|
||||
// server/api/atc/say.post.ts - Simplified Version ohne FFmpeg
|
||||
import { createError, readBody } from "h3";
|
||||
import { writeFile, readFile, mkdir, rm } from "node:fs/promises";
|
||||
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { openai, TTS_MODEL, normalizeATC } from "../../utils/openai";
|
||||
|
||||
// Enhanced Radio Filter für besseren ATC-Sound
|
||||
function buildEnhancedRadioFilter(level: number) {
|
||||
const L = Math.max(1, Math.min(5, Math.floor(level || 4)));
|
||||
|
||||
// Basis Audio-Charakteristika für ATC Radio
|
||||
const baseProcessor = (hp: number, lp: number, gain = 8) => [
|
||||
`highpass=f=${hp}`,
|
||||
`lowpass=f=${lp}`,
|
||||
`compand=attacks=0.01:decays=0.15:points=-80/-900|-60/-25|-40/-15|-20/-10|0/-5:gain=${gain}`,
|
||||
`acompressor=threshold=0.4:ratio=8:attack=5:release=100`,
|
||||
`volume=1.1`
|
||||
].join(',');
|
||||
|
||||
// Authentische Radio-Artefakte
|
||||
const radioArtefacts = (crushLevel: number, noiseLevel: number, staticLevel: number) => [
|
||||
// Bit crushing für digitale Artefakte
|
||||
`acrusher=bits=${crushLevel}:mix=0.3`,
|
||||
// Bandpass + Resonanz für Funkcharakter
|
||||
`bandpass=frequency=1850:width_type=h:width=1200`,
|
||||
`equalizer=frequency=2300:width_type=h:width=800:gain=3`,
|
||||
// Leichtes Saturation für Röhren-Sound
|
||||
`asoftclip=param=2`,
|
||||
// Radio-typisches Rauschen
|
||||
`anoisesrc=color=brown:amplitude=${noiseLevel}:duration=10[noise]`,
|
||||
`[0][noise]amix=inputs=2:weights=1 ${staticLevel}:duration=shortest`
|
||||
];
|
||||
|
||||
// Dropout-Simulation (Funkaussetzer)
|
||||
const dropout = (frequency: number, duration: number) =>
|
||||
`volume=enable='lt(mod(t\\,${frequency})\\,${duration})':volume=0.1`;
|
||||
|
||||
// Delay/Echo für Raumklang
|
||||
const spatialEffect = `aecho=0.4:0.5:15:0.3,reverb=roomsize=0.3:damping=0.4`;
|
||||
|
||||
// PTT Click Simulation (leider schwer ohne Audio-Sample)
|
||||
const pttSimulation = `afade=t=in:d=0.05,afade=t=out:d=0.08`;
|
||||
|
||||
if (L === 5) {
|
||||
// Kristallklar, moderne digitale Funkanlage
|
||||
return [
|
||||
`[0:a]${baseProcessor(280, 3500, 7)}[clean]`,
|
||||
`anoisesrc=color=white:amplitude=0.01[ns]`,
|
||||
`[clean][ns]amix=inputs=2:weights=1 0.15:duration=shortest[mixed]`,
|
||||
`[mixed]${spatialEffect},${pttSimulation}[out]`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
if (L === 4) {
|
||||
// Gute Qualität, leichte Kompression
|
||||
return [
|
||||
`[0:a]${baseProcessor(300, 3300, 7)}[clean]`,
|
||||
...radioArtefacts(14, 0.02, 0.2),
|
||||
`${spatialEffect},${pttSimulation}[out]`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
if (L === 3) {
|
||||
// Standard ATC Qualität mit typischen Artefakten
|
||||
return [
|
||||
`[0:a]${baseProcessor(320, 3100, 6)}[clean]`,
|
||||
...radioArtefacts(12, 0.04, 0.35),
|
||||
`${dropout(8, 0.08)}`,
|
||||
`${spatialEffect},${pttSimulation}[out]`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
if (L === 2) {
|
||||
// Schlechtere Verbindung, mehr Störungen
|
||||
return [
|
||||
`[0:a]${baseProcessor(350, 2900, 6)}[clean]`,
|
||||
...radioArtefacts(10, 0.07, 0.5),
|
||||
`${dropout(5, 0.15)}`,
|
||||
`${spatialEffect},${pttSimulation}[out]`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
// L === 1: Sehr schlechte Verbindung, starke Störungen
|
||||
return [
|
||||
`[0:a]${baseProcessor(400, 2600, 5)}[clean]`,
|
||||
...radioArtefacts(8, 0.12, 0.7),
|
||||
`${dropout(3, 0.25)}`,
|
||||
`tremolo=f=0.1:d=0.3`, // Schwankungen
|
||||
`${spatialEffect},${pttSimulation}[out]`
|
||||
].join(';');
|
||||
}
|
||||
|
||||
async function sh(cmd: string, args: string[]) {
|
||||
return new Promise<void>((res, rej) =>
|
||||
execFile(cmd, args, (err, _o, stderr) => (err ? rej(new Error(stderr || String(err))) : res()))
|
||||
);
|
||||
}
|
||||
|
||||
async function applyEnhancedRadioEffect(input: string, outputWav: string, level = 4) {
|
||||
const filter = buildEnhancedRadioFilter(level);
|
||||
await sh("ffmpeg", [
|
||||
"-y", "-i", input,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[out]",
|
||||
"-ar", "22050", // Höhere Sample Rate für bessere Qualität
|
||||
"-ac", "1", // Mono
|
||||
"-q:a", "3", // Hohe Qualität
|
||||
outputWav
|
||||
]);
|
||||
}
|
||||
|
||||
function outDir() {
|
||||
const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
|
||||
return base;
|
||||
@@ -122,15 +15,27 @@ async function ensureDir(p: string) {
|
||||
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
// Einfache Radio-Effekt-Simulation über Gain-Reduktion
|
||||
function simulateRadioQuality(level: number): { gain: number; description: string } {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{
|
||||
text?: string;
|
||||
level?: number;
|
||||
voice?: string;
|
||||
tag?: string;
|
||||
speed?: number;
|
||||
moduleId?: string;
|
||||
lessonId?: string;
|
||||
tag?: string;
|
||||
}>(event);
|
||||
|
||||
const raw = (body?.text || "").trim();
|
||||
@@ -144,21 +49,20 @@ export default defineEventHandler(async (event) => {
|
||||
const normalized = normalizeATC(raw);
|
||||
if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" });
|
||||
|
||||
// Radio-Qualität simulieren
|
||||
const radioQuality = simulateRadioQuality(level);
|
||||
|
||||
// Eindeutige ID und Pfade
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const dateFolder = timestamp.slice(0, 10); // YYYY-MM-DD
|
||||
const dateFolder = timestamp.slice(0, 10);
|
||||
|
||||
const baseDir = join(outDir(), dateFolder);
|
||||
const fileOgg = join(baseDir, `${id}.ogg`);
|
||||
const fileWav = join(baseDir, `${id}.wav`);
|
||||
const fileJson = join(baseDir, `${id}.json`);
|
||||
|
||||
// Temp-Dateien
|
||||
const tmpClean = join(tmpdir(), `tts-${id}.wav`);
|
||||
const tmpRadio = join(tmpdir(), `radio-${id}.wav`);
|
||||
|
||||
try {
|
||||
// 1) OpenAI TTS - Clean Audio
|
||||
// OpenAI TTS direkt als WAV (keine weitere Verarbeitung)
|
||||
const tts = await openai.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
@@ -167,26 +71,13 @@ export default defineEventHandler(async (event) => {
|
||||
speed
|
||||
});
|
||||
|
||||
await writeFile(tmpClean, Buffer.from(await tts.arrayBuffer()));
|
||||
const audioBuffer = Buffer.from(await tts.arrayBuffer());
|
||||
|
||||
// 2) Enhanced Radio-Effekt anwenden
|
||||
await applyEnhancedRadioEffect(tmpClean, tmpRadio, level);
|
||||
|
||||
// 3) Zu hochwertigem OGG/Opus komprimieren
|
||||
// Speichern
|
||||
await ensureDir(baseDir);
|
||||
await sh("ffmpeg", [
|
||||
"-y",
|
||||
"-i", tmpRadio,
|
||||
"-ac", "1",
|
||||
"-ar", "22050",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "24k", // Höhere Bitrate für bessere Qualität
|
||||
"-application", "voip",
|
||||
"-frame_duration", "20",
|
||||
fileOgg,
|
||||
]);
|
||||
await writeFile(fileWav, audioBuffer);
|
||||
|
||||
// 4) Metadaten speichern
|
||||
// Metadaten speichern
|
||||
const meta = {
|
||||
id,
|
||||
createdAt: timestamp,
|
||||
@@ -195,27 +86,22 @@ export default defineEventHandler(async (event) => {
|
||||
speed,
|
||||
text: raw,
|
||||
normalized,
|
||||
radioQuality: radioQuality.description,
|
||||
tag: body?.tag || null,
|
||||
moduleId: body?.moduleId || null,
|
||||
lessonId: body?.lessonId || null,
|
||||
files: {
|
||||
ogg: fileOgg,
|
||||
wav: fileWav,
|
||||
},
|
||||
model: TTS_MODEL,
|
||||
format: "audio/ogg; codecs=opus",
|
||||
sampleRate: 22050,
|
||||
channels: 1,
|
||||
format: "audio/wav",
|
||||
note: "Direct OpenAI TTS without post-processing"
|
||||
};
|
||||
|
||||
await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
// 5) Audio als Base64 für sofortige Wiedergabe
|
||||
const oggBuffer = await readFile(fileOgg);
|
||||
const audioBase64 = oggBuffer.toString("base64");
|
||||
|
||||
// 6) Cleanup
|
||||
await rm(tmpClean).catch(() => {});
|
||||
await rm(tmpRadio).catch(() => {});
|
||||
// Audio als Base64 für sofortige Wiedergabe
|
||||
const audioBase64 = audioBuffer.toString("base64");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -225,24 +111,21 @@ export default defineEventHandler(async (event) => {
|
||||
speed,
|
||||
text: raw,
|
||||
normalized,
|
||||
radioQuality: radioQuality.description,
|
||||
audio: {
|
||||
mime: "audio/ogg; codecs=opus",
|
||||
mime: "audio/wav",
|
||||
base64: audioBase64,
|
||||
size: oggBuffer.length
|
||||
size: audioBuffer.length
|
||||
},
|
||||
stored: {
|
||||
audioPath: fileOgg,
|
||||
audioPath: fileWav,
|
||||
jsonPath: fileJson,
|
||||
url: `/api/atc/audio/${dateFolder}/${id}.ogg` // Zum späteren Abrufen
|
||||
url: `/api/atc/audio/${dateFolder}/${id}.wav`
|
||||
},
|
||||
meta
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Cleanup bei Fehler
|
||||
await rm(tmpClean).catch(() => {});
|
||||
await rm(tmpRadio).catch(() => {});
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `TTS generation failed: ${error}`
|
||||
|
||||
Reference in New Issue
Block a user