mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
iwas was nich tut dazutun
This commit is contained in:
199
server/api/atc/say.post.ts
Normal file
199
server/api/atc/say.post.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { createError, readBody } from "h3";
|
||||
import { writeFile, readFile, mkdir, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { openai, TTS_MODEL, normalizeATC } from "../../utils/openai";
|
||||
|
||||
// --- Radio-Filter aus deinem bestehenden Endpoint wiederverwenden ---
|
||||
function buildRadioFilter(level: number) {
|
||||
const L = Math.max(1, Math.min(5, Math.floor(level || 4)));
|
||||
const dropout = (period: number, dur: number) =>
|
||||
`volume=enable='lt(mod(t\\,${period})\\,${dur})':volume=0`;
|
||||
const band = (hp: number, lp: number, gain = 6) =>
|
||||
`highpass=f=${hp},lowpass=f=${lp},compand=attacks=0.02:decays=0.25:points=-80/-900|-70/-20|0/-10|20/-8:gain=${gain}`;
|
||||
const tail = `aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200`;
|
||||
|
||||
const mkCrushMix = (bits: number, mix = 0.25, inLabel = "pre", outLabel = "mix1") => [
|
||||
`[${inLabel}]asplit=2[clean][toCrush]`,
|
||||
`[toCrush]acrusher=bits=${bits}:mix=1[crushed]`,
|
||||
`[clean][crushed]amix=inputs=2:weights=1 ${mix}:duration=shortest[${outLabel}]`,
|
||||
];
|
||||
|
||||
const mkNoiseMix = (amp: number, inLabel = "mix1", outLabel = "mix2") => [
|
||||
`anoisesrc=color=white:amplitude=${amp}[ns]`,
|
||||
`[${inLabel}][ns]amix=inputs=2:weights=1 0.25:duration=shortest[${outLabel}]`,
|
||||
];
|
||||
|
||||
const mkDropTail = (inLabel: string, period: number | null, dur: number | null) => {
|
||||
const chain: string[] = [];
|
||||
if (period && dur) chain.push(`[${inLabel}]${dropout(period, dur)}[drop]`);
|
||||
const src = period && dur ? "drop" : inLabel;
|
||||
chain.push(`[${src}]${tail}[out]`);
|
||||
return chain;
|
||||
};
|
||||
|
||||
if (L === 5) {
|
||||
return [
|
||||
`[0:a]${band(300,3400)},volume=1.2[a]`,
|
||||
`anoisesrc=color=white:amplitude=0.02[ns]`,
|
||||
`[a][ns]amix=inputs=2:weights=1 0.25:duration=shortest[mix]`,
|
||||
`[mix]${tail}[out]`,
|
||||
].join(";");
|
||||
}
|
||||
|
||||
if (L === 4) {
|
||||
return [
|
||||
`[0:a]${band(320,3300)},volume=1.15[pre]`,
|
||||
...mkNoiseMix(0.03, "pre", "mix"),
|
||||
`[mix]${tail}[out]`,
|
||||
].join(";");
|
||||
}
|
||||
|
||||
if (L === 3) {
|
||||
return [
|
||||
`[0:a]${band(350,3200)},volume=1.1[pre]`,
|
||||
...mkCrushMix(12, 0.22, "pre", "mix1"),
|
||||
...mkNoiseMix(0.05, "mix1", "mix2"),
|
||||
...mkDropTail("mix2", 6, 0.06),
|
||||
].join(";");
|
||||
}
|
||||
|
||||
if (L === 2) {
|
||||
return [
|
||||
`[0:a]${band(400,3000)},volume=1.05[pre]`,
|
||||
...mkCrushMix(10, 0.32, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.08[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights=1 0.6:duration=shortest[mix2]`,
|
||||
...mkDropTail("mix2", 4.5, 0.12),
|
||||
].join(";");
|
||||
}
|
||||
|
||||
// L === 1
|
||||
return [
|
||||
`[0:a]${band(500,2600,5)},volume=1.0[pre]`,
|
||||
...mkCrushMix(8, 0.45, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.12[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights=1 0.8:duration=shortest[mix2]`,
|
||||
...mkDropTail("mix2", 3.5, 0.2),
|
||||
].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 applyRadioEffect(input: string, outputWav: string, level = 4) {
|
||||
const filter = buildRadioFilter(level);
|
||||
await sh("ffmpeg", ["-y", "-i", input, "-filter_complex", filter, "-map", "[out]", "-ar", "16000", "-ac", "1", outputWav]);
|
||||
}
|
||||
|
||||
function outDir() {
|
||||
// Persistenz: env > ./storage/atc > tmp
|
||||
const base =
|
||||
process.env.ATC_OUT_DIR?.trim() ||
|
||||
join(process.cwd(), "storage", "atc");
|
||||
return base;
|
||||
}
|
||||
|
||||
async function ensureDir(p: string) {
|
||||
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// POST { text: string; level?: 1..5; voice?: string; tag?: string }
|
||||
const body = await readBody<{
|
||||
text?: string;
|
||||
level?: number;
|
||||
voice?: string;
|
||||
tag?: string;
|
||||
}>(event);
|
||||
|
||||
const raw = (body?.text || "").trim();
|
||||
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") as string;
|
||||
|
||||
// Wortstamm/Normalisierung: ICAO-Formatierung, Zahl-/Buchstabierlogik etc.
|
||||
const normalized = normalizeATC(raw);
|
||||
if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" });
|
||||
|
||||
// 1) TTS (clean) -> tmp WAV
|
||||
const tmpClean = join(tmpdir(), `tts-${randomUUID()}.wav`);
|
||||
const tmpRadio = join(tmpdir(), `radio-${randomUUID()}.wav`);
|
||||
|
||||
const tts = await openai.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
format: "wav",
|
||||
input: normalized,
|
||||
});
|
||||
await writeFile(tmpClean, Buffer.from(await tts.arrayBuffer()));
|
||||
|
||||
// 2) Radio-Effekt (WAV, 16 kHz mono)
|
||||
await applyRadioEffect(tmpClean, tmpRadio, level);
|
||||
|
||||
// 3) Platzsparend transkodieren: OGG/Opus (VoIP-Profil), ~12 kbps @16 kHz mono
|
||||
const id = randomUUID();
|
||||
const baseDir = join(outDir(), new Date().toISOString().slice(0, 10)); // YYYY-MM-DD
|
||||
const fileOgg = join(baseDir, `${id}.ogg`);
|
||||
const fileJson = join(baseDir, `${id}.json`);
|
||||
|
||||
await ensureDir(baseDir);
|
||||
await sh("ffmpeg", [
|
||||
"-y",
|
||||
"-i", tmpRadio,
|
||||
"-ac", "1",
|
||||
"-ar", "16000",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "12k",
|
||||
"-application", "voip",
|
||||
fileOgg,
|
||||
]);
|
||||
|
||||
// 4) JSON-Metadaten neben die Audiodatei schreiben
|
||||
const meta = {
|
||||
id,
|
||||
createdAt: new Date().toISOString(),
|
||||
level,
|
||||
voice,
|
||||
text: raw,
|
||||
normalized,
|
||||
tag: body?.tag || null,
|
||||
files: {
|
||||
ogg: fileOgg,
|
||||
},
|
||||
model: TTS_MODEL,
|
||||
format: "audio/ogg; codecs=opus",
|
||||
};
|
||||
await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
// 5) Response (Audio als Base64 + Meta). Optional: nur Pfad zurückgeben, wenn du sparen willst.
|
||||
const oggB64 = (await readFile(fileOgg)).toString("base64");
|
||||
|
||||
// Cleanup tmp
|
||||
rm(tmpClean).catch(() => {});
|
||||
rm(tmpRadio).catch(() => {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
level,
|
||||
voice,
|
||||
text: raw,
|
||||
normalized,
|
||||
audio: {
|
||||
mime: "audio/ogg",
|
||||
base64: oggB64,
|
||||
},
|
||||
stored: {
|
||||
audioPath: fileOgg,
|
||||
jsonPath: fileJson,
|
||||
},
|
||||
};
|
||||
});
|
||||
154
server/api/atc/say.stream.get.ts
Normal file
154
server/api/atc/say.stream.get.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getQuery, createError } from "h3";
|
||||
import { spawn } from "node:child_process";
|
||||
import { openai, TTS_MODEL, normalizeATC } from "../../utils/openai";
|
||||
|
||||
function buildRadioFilter(level: number) {
|
||||
const L = Math.max(1, Math.min(5, Math.floor(level || 4)));
|
||||
const band = (hp: number, lp: number, gain = 6) =>
|
||||
`highpass=f=${hp},lowpass=f=${lp},compand=attacks=0.02:decays=0.25:points=-80/-900|-70/-20|0/-10|20/-8:gain=${gain}`;
|
||||
const dropout = (period: number, dur: number) =>
|
||||
`volume=enable='lt(mod(t\\,${period})\\,${dur})':volume=0`;
|
||||
const tail = `aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200`;
|
||||
|
||||
// helpers (ACHTUNG: weights **gequotet** oder mit | getrennt)
|
||||
const mixCrush = (bits: number, mix = 0.25, inLabel = "pre", outLabel = "mix1") => [
|
||||
`[${inLabel}]asplit=2[clean][toCrush]`,
|
||||
`[toCrush]acrusher=bits=${bits}:mix=1[crushed]`,
|
||||
`[clean][crushed]amix=inputs=2:weights='1 ${mix}':duration=shortest[${outLabel}]`,
|
||||
];
|
||||
|
||||
const mixNoise = (amp: number, inLabel = "mix1", outLabel = "mix2") => [
|
||||
`anoisesrc=color=white:amplitude=${amp}[ns]`,
|
||||
`[${inLabel}][ns]amix=inputs=2:weights='1 0.25':duration=shortest[${outLabel}]`,
|
||||
];
|
||||
|
||||
const dropAndTail = (inLabel: string, period?: number, dur?: number) => {
|
||||
const chain: string[] = [];
|
||||
if (period && dur) chain.push(`[${inLabel}]${dropout(period, dur)}[drop]`);
|
||||
const src = period && dur ? "drop" : inLabel;
|
||||
chain.push(`[${src}]${tail}[out]`);
|
||||
return chain;
|
||||
};
|
||||
|
||||
if (L === 5)
|
||||
return [
|
||||
`[0:a]${band(300,3400)},volume=1.2[a]`,
|
||||
`anoisesrc=color=white:amplitude=0.02[ns]`,
|
||||
`[a][ns]amix=inputs=2:weights='1 0.25':duration=shortest[mix]`,
|
||||
`[mix]${tail}[out]`,
|
||||
].join(";");
|
||||
|
||||
if (L === 4)
|
||||
return [
|
||||
`[0:a]${band(320,3300)},volume=1.15[pre]`,
|
||||
...mixNoise(0.03, "pre", "mix"),
|
||||
`[mix]${tail}[out]`,
|
||||
].join(";");
|
||||
|
||||
if (L === 3)
|
||||
return [
|
||||
`[0:a]${band(350,3200)},volume=1.1[pre]`,
|
||||
...mixCrush(12, 0.22, "pre", "mix1"),
|
||||
...mixNoise(0.05, "mix1", "mix2"),
|
||||
...dropAndTail("mix2", 6, 0.06),
|
||||
].join(";");
|
||||
|
||||
if (L === 2)
|
||||
return [
|
||||
`[0:a]${band(400,3000)},volume=1.05[pre]`,
|
||||
...mixCrush(10, 0.32, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.08[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights='1 0.6':duration=shortest[mix2]`,
|
||||
...dropAndTail("mix2", 4.5, 0.12),
|
||||
].join(";");
|
||||
|
||||
// L === 1
|
||||
return [
|
||||
`[0:a]${band(500,2600,5)},volume=1.0[pre]`,
|
||||
...mixCrush(8, 0.45, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.12[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights='1 0.8':duration=shortest[mix2]`,
|
||||
...dropAndTail("mix2", 3.5, 0.2),
|
||||
].join(";");
|
||||
}
|
||||
|
||||
// super-simpler Fallback ohne Mix/Noise (falls Filter scheitert)
|
||||
const SIMPLE_FILTER = `[0:a]highpass=f=350,lowpass=f=3000,acompressor=threshold=0.6:ratio=6:attack=20:release=200[out]`;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const q = getQuery(event);
|
||||
const raw = String(q.text || "").trim();
|
||||
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
||||
|
||||
const level = Math.max(1, Math.min(5, parseInt(String(q.level ?? "4"), 10) || 4));
|
||||
const voice = String(q.voice || "alloy");
|
||||
const normalized = normalizeATC(raw) || raw;
|
||||
|
||||
// 1) TTS → WAV
|
||||
const tts = await openai.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
format: "wav",
|
||||
input: normalized,
|
||||
});
|
||||
const wav = Buffer.from(await tts.arrayBuffer());
|
||||
if (wav.byteLength < 100) throw createError({ statusCode: 500, statusMessage: "TTS empty" });
|
||||
|
||||
async function runFfmpeg(filter: string) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const ff = spawn("ffmpeg", [
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "wav", "-i", "pipe:0",
|
||||
"-filter_complex", filter,
|
||||
"-map", "[out]",
|
||||
"-ac", "1", "-ar", "16000",
|
||||
"-c:a", "libopus", "-b:a", "12k", "-application", "voip",
|
||||
"-f", "ogg", "pipe:1",
|
||||
], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
const res = event.node.res;
|
||||
let started = false;
|
||||
let ffErr = "";
|
||||
|
||||
ff.stderr.on("data", d => { ffErr += d.toString(); });
|
||||
|
||||
ff.stdout.once("data", (chunk: Buffer) => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "audio/ogg");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Accept-Ranges", "none");
|
||||
(res as any).flushHeaders?.();
|
||||
}
|
||||
res.write(chunk);
|
||||
ff.stdout.pipe(res);
|
||||
});
|
||||
|
||||
ff.stdin.on("error", () => {});
|
||||
ff.stdin.write(wav);
|
||||
ff.stdin.end();
|
||||
|
||||
ff.on("close", (code) => {
|
||||
if (!started) {
|
||||
// Fehlerpfad → JSON-Fehler zurück
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
}
|
||||
res.end(JSON.stringify({ error: true, message: ffErr || `ffmpeg exit ${code}` }));
|
||||
} else {
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// erst komplexer Filter; wenn der fehlschlägt → SIMPLE_FILTER
|
||||
await runFfmpeg(buildRadioFilter(level));
|
||||
if (!event.node.res.headersSent) {
|
||||
// zweiter Versuch
|
||||
await runFfmpeg(SIMPLE_FILTER);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user