iwas was nich tut dazutun

This commit is contained in:
itsrubberduck
2025-09-15 11:20:30 +02:00
parent 37759c040e
commit fe8b7629e5
7 changed files with 1108 additions and 618 deletions

File diff suppressed because it is too large Load Diff

287
app/pages/say.vue Normal file
View File

@@ -0,0 +1,287 @@
<template>
<v-container class="py-8" max-width="900">
<v-row class="mb-4" align="center" justify="space-between">
<h1 class="text-h5">ATC Generator</h1>
<v-btn variant="outlined" prepend-icon="mdi-broom" @click="resetForm">Zurücksetzen</v-btn>
</v-row>
<v-card class="pa-4 mb-6">
<v-row dense>
<v-col cols="12">
<v-textarea
v-model="form.text"
label="ATC-Text (z. B. „D-ABCD ready for taxi…“)"
rows="4"
auto-grow
:counter="500"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-slider
v-model="form.level"
:min="1" :max="5" :step="1"
label="Radio-Level (15)"
show-ticks tick-size="3" thumb-label
/>
<div class="text-caption mt-1">
1 = stark verrauscht · 4 = gut (Default) · 5 = klar
</div>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="form.voice"
:items="voices"
label="Stimme"
item-title="label" item-value="value"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field v-model="form.tag" label="Tag (optional)" />
</v-col>
<v-col cols="12" class="d-flex gap-3">
<v-btn color="primary" :loading="loading" @click="generate">
<v-icon start>mdi-play</v-icon> Erzeugen & Abspielen
</v-btn>
<v-btn variant="tonal" :disabled="!audioUrl" @click="download">
<v-icon start>mdi-download</v-icon> OGG herunterladen
</v-btn>
<v-btn variant="text" :disabled="!result" @click="copyMeta">
<v-icon start>mdi-content-copy</v-icon> Meta kopieren
</v-btn>
<v-spacer/>
<v-switch v-model="autoPlay" inset label="Auto-Play" hide-details/>
<v-switch v-model="persistHistory" inset label="History speichern" hide-details/>
</v-col>
</v-row>
</v-card>
<v-card class="pa-4 mb-6" v-if="result">
<v-row>
<v-col cols="12" md="6">
<audio ref="player" :src="audioUrl || undefined" controls style="width:100%"></audio>
<div class="text-caption mt-2">MIME: audio/ogg; codecs=opus · 16 kHz mono</div>
</v-col>
<v-col cols="12" md="6">
<v-list density="compact">
<v-list-item title="ID" :subtitle="result.id"/>
<v-list-item title="Erstellt" :subtitle="new Date(result.createdAt || Date.now()).toLocaleString()"/>
<v-list-item title="Level" :subtitle="String(result.level)"/>
<v-list-item title="Voice" :subtitle="result.voice"/>
<v-list-item title="Eingabe" :subtitle="result.text"/>
<v-list-item title="Normalisiert" :subtitle="result.normalized"/>
<v-list-item v-if="result.tag" title="Tag" :subtitle="result.tag"/>
</v-list>
</v-col>
</v-row>
</v-card>
<v-expand-transition>
<v-card v-if="history.length" class="pa-4">
<div class="d-flex align-center justify-space-between mb-2">
<h2 class="text-subtitle-1">History</h2>
<v-btn size="small" variant="text" @click="clearHistory"><v-icon>mdi-delete</v-icon> leeren</v-btn>
</div>
<v-table density="compact">
<thead>
<tr>
<th>Datum</th><th>Level</th><th>Voice</th><th>Text</th><th>Play</th>
</tr>
</thead>
<tbody>
<tr v-for="h in history" :key="h.id">
<td>{{ new Date(h.createdAt).toLocaleString() }}</td>
<td>{{ h.level }}</td>
<td>{{ h.voice }}</td>
<td class="truncate">{{ h.text }}</td>
<td>
<v-btn size="small" @click="playFromHistory(h)"><v-icon>mdi-play</v-icon></v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-expand-transition>
<v-snackbar v-model="snack.show" :timeout="3000" color="error">{{ snack.msg }}</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
type Json = any
const form = ref({ text: '', level: 4, voice: 'alloy', tag: '' })
const loading = ref(false)
const result = ref<Json | null>(null)
const rawJson = ref<Json | null>(null)
const audioUrl = ref<string | null>(null)
const player = ref<HTMLAudioElement | null>(null)
const autoPlay = ref(true)
const persistHistory = ref(true)
const snack = ref({ show: false, msg: '' })
const debugMsg = ref<string>('')
function err(msg: string) { snack.value = { show: true, msg }; debugMsg.value = msg }
const nextTick = (fn: () => void) => Promise.resolve().then(fn)
// --- Robust: erste Base64 + zugehöriges MIME irgendwo im Objekt finden ---
function findFirstAudio(o: Json): { base64: string; mime: string } | null {
let found: { base64: string; mime?: string } | null = null
function walk(x: Json, path: string[] = []) {
if (!x || found) return
if (typeof x === 'object') {
// Kandidat: {base64, mime}
if (typeof x.base64 === 'string') {
found = { base64: x.base64, mime: typeof x.mime === 'string' ? x.mime : undefined }
}
// tiefer gehen
for (const k of Object.keys(x)) walk(x[k], path.concat(k))
}
}
walk(o)
if (!found) return null
const mime = found.mime || guessMimeFromObject(o) || 'audio/ogg'
return { base64: found.base64, mime }
}
function guessMimeFromObject(o: Json): string | null {
// Häufige Felder prüfen
if (o?.audio?.radio?.mime) return o.audio.radio.mime
if (o?.audio?.mime) return o.audio.mime
if (o?.audio?.clean?.mime) return o.audio.clean.mime
return null
}
// --- Base64 normalisieren ---
function normalizeBase64(b64: string) {
if (!b64) throw new Error('leer')
// dataURL → payload
const i = b64.indexOf(',')
if (i !== -1 && b64.slice(0, 40).includes('base64')) b64 = b64.slice(i + 1)
b64 = b64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
const mod = b64.length % 4
if (mod === 2) b64 += '=='
else if (mod === 3) b64 += '='
else if (mod === 1) throw new Error('Base64-Länge ungültig')
return b64
}
function base64ToBlobUrl(b64: string, mime = 'application/octet-stream') {
const clean = normalizeBase64(b64)
const bin = atob(clean)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
const blob = new Blob([bytes], { type: mime })
return URL.createObjectURL(blob)
}
async function generate() {
const text = form.value.text.trim()
if (!text) return err('Text fehlt.')
loading.value = true
debugMsg.value = ''
try {
const res = await $fetch<Json>('/api/atc/generate', {
method: 'POST',
body: { text, level: form.value.level, voice: form.value.voice, tag: form.value.tag || undefined }
})
rawJson.value = res
console.log('ATC response:', res)
// kompatible Anzeigenamen
const displayText = res.text ?? res.atcText ?? text
result.value = { ...res, text: displayText, createdAt: res.createdAt ?? new Date().toISOString() }
// Audio extrahieren (egal wo)
const audio = findFirstAudio(res)
if (!audio) {
err('Kein Audio in Response gefunden.')
console.warn('Response ohne erkennbares Audio:', res)
audioUrl.value = null
return
}
// Blob-URL bauen
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
audioUrl.value = base64ToBlobUrl(audio.base64, audio.mime)
if (autoPlay.value) nextTick(() => player.value?.play().catch(() => {}))
if (persistHistory.value) pushHistory(result.value)
debugMsg.value = `Audio OK · mime=${audio.mime} · base64Len=${audio.base64.length}`
} catch (e: any) {
console.error(e)
err(e?.data?.message || e?.message || 'Fehler beim Generieren')
} finally {
loading.value = false
}
}
function download() {
if (!audioUrl.value || !result.value) return
const mime =
result.value?.audio?.radio?.mime ||
result.value?.audio?.mime ||
result.value?.audio?.clean?.mime ||
'audio/ogg'
const ext = mime.includes('ogg') ? 'ogg' : mime.includes('wav') ? 'wav' : 'bin'
const a = document.createElement('a')
a.href = audioUrl.value
a.download = `${result.value?.id || 'atc'}.${ext}`
a.click()
}
async function copyMeta() {
if (!result.value) return
const meta = {
id: result.value.id ?? null,
createdAt: result.value.createdAt,
level: result.value.level,
voice: result.value.voice,
text: result.value.text ?? '',
normalized: result.value.normalized ?? null,
stored: result.value.stored ?? null
}
await navigator.clipboard.writeText(JSON.stringify(meta, null, 2))
}
const history = ref<Json[]>([])
function pushHistory(e: Json) {
const list = [e, ...history.value].slice(0, 50)
history.value = list
localStorage.setItem('atc-history', JSON.stringify(list))
}
function loadHistory() {
const raw = localStorage.getItem('atc-history')
if (raw) { try { history.value = JSON.parse(raw) } catch {} }
}
function clearHistory() { history.value = []; localStorage.removeItem('atc-history') }
function playFromHistory(h: Json) {
try {
const audio = findFirstAudio(h)
if (!audio) return err('History-Eintrag ohne Audio.')
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
audioUrl.value = base64ToBlobUrl(audio.base64, audio.mime)
result.value = h
nextTick(() => player.value?.play().catch(() => {}))
} catch (e: any) { err(e.message) }
}
onMounted(loadHistory)
</script>
<style scoped>
.truncate {
max-width: 420px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

142
composables/radioTts.ts Normal file
View File

@@ -0,0 +1,142 @@
// audioRadioTTS.ts
/** AUDIO: English TTS with radio ambience (noise + bandlimit + PTT click) **/
declare const cfg:
| { value?: { tts?: boolean } }
| undefined; // optional global (Nuxt/Pinia o.ä.)
let _ctx: AudioContext | null = null;
let _masterGain: GainNode;
let _noiseGain: GainNode;
let _noiseFilterBand: BiquadFilterNode;
let _noiseFilterHP: BiquadFilterNode;
let _noiseFilterLP: BiquadFilterNode;
let _compressor: DynamicsCompressorNode;
function ensureAudioGraph() {
if (_ctx) return;
_ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
_masterGain = _ctx.createGain();
_masterGain.gain.value = 0.9;
// Background "radio" noise
const noiseBuffer = _ctx.createBuffer(1, _ctx.sampleRate * 2, _ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
const w = Math.random() * 2 - 1;
data[i] = (data[i - 1] || 0) * 0.97 + w * 0.03; // cheap pink-ish
}
const noise = _ctx.createBufferSource();
noise.buffer = noiseBuffer;
noise.loop = true;
_noiseFilterHP = _ctx.createBiquadFilter();
_noiseFilterHP.type = "highpass";
_noiseFilterHP.frequency.value = 300;
_noiseFilterBand = _ctx.createBiquadFilter();
_noiseFilterBand.type = "bandpass";
_noiseFilterBand.frequency.value = 1600;
_noiseFilterBand.Q.value = 0.6;
_noiseFilterLP = _ctx.createBiquadFilter();
_noiseFilterLP.type = "lowpass";
_noiseFilterLP.frequency.value = 3200;
_compressor = _ctx.createDynamicsCompressor();
_compressor.threshold.value = -24;
_compressor.knee.value = 20;
_compressor.ratio.value = 6;
_compressor.attack.value = 0.003;
_compressor.release.value = 0.1;
_noiseGain = _ctx.createGain();
_noiseGain.gain.value = 0.07;
noise
.connect(_noiseFilterHP)
.connect(_noiseFilterBand)
.connect(_noiseFilterLP)
.connect(_noiseGain)
.connect(_compressor)
.connect(_masterGain)
.connect(_ctx.destination);
noise.start();
}
function pttClick(durationMs = 25, gain = 0.5) {
if (!_ctx) return;
const osc = _ctx.createOscillator();
const g = _ctx.createGain();
osc.type = "square";
osc.frequency.value = 1200 + Math.random() * 600;
g.gain.value = gain;
osc.connect(g).connect(_masterGain);
osc.start();
const now = _ctx.currentTime;
g.gain.setValueAtTime(gain, now);
g.gain.exponentialRampToValueAtTime(0.001, now + durationMs / 1000);
osc.stop(now + durationMs / 1000 + 0.02);
}
/** Helpers */
function isTtsEnabled(): boolean {
// Wenn cfg fehlt → default: true (nicht crashen, trotzdem sprechen)
try {
// @ts-ignore cfg evtl. global
const val = (cfg as any)?.value?.tts;
return val ?? true;
} catch {
return true;
}
}
/** Main speak with radio ambience */
export default function speak(text: string) {
if (typeof window === "undefined") return;
if (!("speechSynthesis" in window)) return;
if (!isTtsEnabled()) return;
ensureAudioGraph();
const u = new SpeechSynthesisUtterance(text);
u.lang = "en-US"; // or "en-GB"
u.rate = 1.0;
u.pitch = 1.0;
u.onstart = () => {
_ctx?.resume();
pttClick(18, 0.35);
if (_noiseGain) {
const now = _ctx!.currentTime;
_noiseGain.gain.cancelScheduledValues(now);
_noiseGain.gain.setValueAtTime(_noiseGain.gain.value, now);
_noiseGain.gain.linearRampToValueAtTime(0.1, now + 0.05);
}
};
u.onend = () => {
pttClick(22, 0.28);
if (_noiseGain) {
const now = _ctx!.currentTime;
_noiseGain.gain.cancelScheduledValues(now);
_noiseGain.gain.setValueAtTime(_noiseGain.gain.value, now);
_noiseGain.gain.linearRampToValueAtTime(0.07, now + 0.2);
}
};
setTimeout(() => {
window.speechSynthesis.cancel();
window.speechSynthesis.speak(u);
}, 60);
}
export function createRadioTTS(isEnabled: () => boolean = () => true) {
return (text: string) => {
if (!isEnabled()) return;
speak(text);
};
}
// usage: const speakRadio = createRadioTTS(() => cfg?.value?.tts ?? true);

View File

@@ -0,0 +1,43 @@
export type RadioOpts = {
level?: number; // 1..5
voice?: string; // z.B. "alloy"
};
export default function useRadioTTS() {
let audio: HTMLAudioElement | null = null;
function stop() {
if (audio) {
audio.pause();
audio.src = "";
audio.load();
audio = null;
}
}
function speakBrowser(text: string) {
stop();
const u = new SpeechSynthesisUtterance(text);
u.lang = "en-US";
u.rate = 1;
u.pitch = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(u);
return u;
}
function speakServer(text: string, opts: RadioOpts = {}) {
stop();
const params = new URLSearchParams({
text,
level: String(opts.level ?? 4),
voice: String(opts.voice ?? "alloy"),
});
audio = new Audio(`/api/atc/say.stream?${params.toString()}`);
audio.autoplay = true;
audio.play().catch(() => {/* ignore */});
return audio;
}
return { speakBrowser, speakServer, stop };
}

1
out.ogg Normal file
View File

@@ -0,0 +1 @@
{"error":true,"message":"[wav @ 0x14e704c40] invalid start code [255][243][228][196] in RIFF header\n[in#0 @ 0x600001370900] Error opening input: Invalid data found when processing input\nError opening input file pipe:0.\nError opening input files: Invalid data found when processing input\n"}

199
server/api/atc/say.post.ts Normal file
View 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,
},
};
});

View 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);
}
});