diff --git a/app/pages/learn.vue b/app/pages/learn.vue index 276fd15..ed65932 100644 --- a/app/pages/learn.vue +++ b/app/pages/learn.vue @@ -1,26 +1,41 @@ - - - - - - - - diff --git a/app/pages/say.vue b/app/pages/say.vue new file mode 100644 index 0000000..2e8356c --- /dev/null +++ b/app/pages/say.vue @@ -0,0 +1,287 @@ + + + + + + diff --git a/composables/radioTts.ts b/composables/radioTts.ts new file mode 100644 index 0000000..2bc6d65 --- /dev/null +++ b/composables/radioTts.ts @@ -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); diff --git a/composables/radioTtsNew.ts b/composables/radioTtsNew.ts new file mode 100644 index 0000000..265a404 --- /dev/null +++ b/composables/radioTtsNew.ts @@ -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 }; +} diff --git a/out.ogg b/out.ogg new file mode 100644 index 0000000..50f0e83 --- /dev/null +++ b/out.ogg @@ -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"} \ No newline at end of file diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts new file mode 100644 index 0000000..95e0922 --- /dev/null +++ b/server/api/atc/say.post.ts @@ -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((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, + }, + }; +}); diff --git a/server/api/atc/say.stream.get.ts b/server/api/atc/say.stream.get.ts new file mode 100644 index 0000000..72bd710 --- /dev/null +++ b/server/api/atc/say.stream.get.ts @@ -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((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); + } +});