diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index f941c1a..d586b24 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/package.json b/package.json index 4b74b3e..fadae47 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@nuxtjs/tailwindcss": "6.14.0", "dotenv": "^17.2.2", + "fluent-ffmpeg": "^2.1.3", "nuxt": "^4.1.1", "nuxt-aos": "1.2.5", "openai": "^4.66.0", @@ -22,6 +23,11 @@ "vuetify": "3.9.0-beta.1", "vuetify-nuxt-module": "0.18.7" }, - "engines": { "node": "22.x" }, - "packageManager": "yarn@4.9.4" + "engines": { + "node": "22.x" + }, + "packageManager": "yarn@4.9.4", + "devDependencies": { + "@types/fluent-ffmpeg": "^2" + } } diff --git a/server/api/atc/audio/[...path].get.ts b/server/api/atc/audio/[...path].get.ts index e69de29..8de5432 100644 --- a/server/api/atc/audio/[...path].get.ts +++ b/server/api/atc/audio/[...path].get.ts @@ -0,0 +1,110 @@ +// server/api/atc/audio/[...path].get.ts +import { createError } from "h3"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; + +function outDir() { + const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc"); + return base; +} + +export default defineEventHandler(async (event) => { + const path = getRouterParam(event, 'path'); + + if (!path || typeof path !== 'string') { + throw createError({ + statusCode: 400, + statusMessage: "Invalid path" + }); + } + + // Sicherheitscheck: verhindere Directory Traversal + if (path.includes('..') || path.includes('/./') || path.startsWith('/')) { + throw createError({ + statusCode: 400, + statusMessage: "Invalid path" + }); + } + + const filePath = join(outDir(), path); + + if (!existsSync(filePath)) { + throw createError({ + statusCode: 404, + statusMessage: "Audio file not found" + }); + } + + try { + const stats = await stat(filePath); + + if (!stats.isFile()) { + throw createError({ + statusCode: 400, + statusMessage: "Path is not a file" + }); + } + + // Nur Audio-Dateien servieren + const allowedExtensions = ['.ogg', '.wav', '.mp3']; + const hasValidExtension = allowedExtensions.some(ext => filePath.toLowerCase().endsWith(ext)); + + if (!hasValidExtension) { + throw createError({ + statusCode: 400, + statusMessage: "Not an audio file" + }); + } + + const fileBuffer = await readFile(filePath); + + // MIME Type basierend auf Dateiendung + let mimeType = 'audio/ogg'; + if (filePath.endsWith('.wav')) { + mimeType = 'audio/wav'; + } else if (filePath.endsWith('.mp3')) { + mimeType = 'audio/mpeg'; + } else if (filePath.endsWith('.ogg')) { + mimeType = 'audio/ogg; codecs=opus'; + } + + // HTTP Headers für Audio-Streaming + setHeader(event, 'Content-Type', mimeType); + setHeader(event, 'Content-Length', stats.size.toString()); + setHeader(event, 'Accept-Ranges', 'bytes'); + setHeader(event, 'Cache-Control', 'public, max-age=3600'); // 1 Stunde Cache + setHeader(event, 'Access-Control-Allow-Origin', '*'); + + // Range-Request Support für Audio-Seeking + const range = getHeader(event, 'range'); + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + + if (start >= stats.size || end >= stats.size) { + setResponseStatus(event, 416); // Range Not Satisfiable + setHeader(event, 'Content-Range', `bytes */${stats.size}`); + return ''; + } + + const chunkSize = (end - start) + 1; + const chunk = fileBuffer.slice(start, end + 1); + + setResponseStatus(event, 206); // Partial Content + setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`); + setHeader(event, 'Content-Length', chunkSize.toString()); + + return chunk; + } + + return fileBuffer; + + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: `Failed to serve audio file: ${error}` + }); + } +}); diff --git a/server/api/atc/generate.post.ts b/server/api/atc/generate.post.ts index cde5dc1..2f193f7 100644 --- a/server/api/atc/generate.post.ts +++ b/server/api/atc/generate.post.ts @@ -1,161 +1,96 @@ -import { createError, getQuery } from "h3"; -import {openai, LLM_MODEL, TTS_MODEL, atcSeedPrompt, normalizeATC} from "../../utils/openai"; -import { writeFile, readFile, rm } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { execFile } from "node:child_process"; - -// ffmpeg-Filter je Qualitätsstufe (1..5) -> final label [out] -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`; - - // Hilfsbausteine als vollständige Kettenglieder mit Semikolons/Labels - 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 = []; - 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) { - // Dein Original, nur gelabelt bis [out] - 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"), - // mehr Noise-Gewichtung beim zweiten Mix - `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(";"); -} - -// Funk-Effekt mit ffmpeg (fix: -map [out], kein "[post]?0:a") -async function applyRadioEffect(input: string, output: string, level = 4) { - const filter = buildRadioFilter(level); - await new Promise((res, rej) => - execFile( - "ffmpeg", - ["-y", "-i", input, "-filter_complex", filter, "-map", "[out]", "-ar", "16000", output], - (err, _o, stderr) => (err ? rej(new Error(stderr || String(err))) : res()) - ) - ); -} +// server/api/atc/generate.post.ts +import { createError, readBody } from "h3"; +import { generateATCPhrase, getRandomPhraseForLesson, getPhrasesForLesson } from "../../utils/atcPhrases"; +import { normalizeATC } from "../../utils/openai"; export default defineEventHandler(async (event) => { - // level aus query (?level=1..5), default 4 - const { level } = getQuery(event); - const lvl = Math.max(1, Math.min(5, parseInt(String(level ?? "4"), 10) || 4)); + const body = await readBody<{ + moduleId: string; + lessonId: string; + phraseId?: string; + customVariables?: Record; + type?: 'instruction' | 'clearance' | 'information' | 'request'; + count?: number; + }>(event); - // 1) ATC-Text erzeugen (ohne Pilot-Input) - const scenario = { - airport: "EDDF", - aircraft: "A320", - type: "IFR", - stand: "V155", - dep: "EHAM", - sid: "MARUN 7F", - squawk: "4723", - freq: "121.800", - runway: "25R", - phase: "taxi", - notes: "Taxiway N closed between N2–N4", - }; + const { moduleId, lessonId, phraseId, customVariables, type, count = 1 } = body; - // const resp = await openai.responses.create({ - // model: LLM_MODEL, - // input: atcSeedPrompt(scenario), - // }); - // const atcText = (resp.output_text || "").trim(); - const atcText = "DLH39A taxi to RWY 25R via V A, hold short of RWY 25R."; - if (!atcText) throw createError({ statusCode: 500, statusMessage: "LLM empty" }); + if (!moduleId || !lessonId) { + throw createError({ + statusCode: 400, + statusMessage: "moduleId and lessonId are required" + }); + } - const normalized = normalizeATC(atcText); - if (!normalized) throw createError({ statusCode: 500, statusMessage: "ATC text empty" }); - console.log("ATC Text (normalized):", normalized); + try { + let phrases: string[] = []; - // return { atcText: normalized, level: lvl }; + if (phraseId) { + // Spezifische Phrase generieren + const phrase = generateATCPhrase(phraseId, customVariables); + phrases.push(phrase); + } else { + // Zufällige Phrasen für das Modul/Lektion generieren + const availablePhrases = getPhrasesForLesson(moduleId, lessonId); - // 2) TTS (clean) - const cleanPath = join(tmpdir(), `tts-${randomUUID()}.wav`); - const radioPath = join(tmpdir(), `radio-${randomUUID()}.wav`); + if (availablePhrases.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: `No phrases found for module "${moduleId}", lesson "${lessonId}"` + }); + } - const tts = await openai.audio.speech.create({ - model: TTS_MODEL, - voice: "alloy", - format: "wav", - input: normalized, - }); - await writeFile(cleanPath, Buffer.from(await tts.arrayBuffer())); + // Filter nach Typ wenn angegeben + const filteredPhrases = type + ? availablePhrases.filter(p => p.type === type) + : availablePhrases; - // 3) Funk-Effekt (mit stufe) - await applyRadioEffect(cleanPath, radioPath, lvl); + if (filteredPhrases.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: `No phrases of type "${type}" found for module "${moduleId}", lesson "${lessonId}"` + }); + } - // 4) Payload zurück (Debug: Text + beide Audios) - const cleanB64 = (await readFile(cleanPath)).toString("base64"); - const radioB64 = (await readFile(radioPath)).toString("base64"); - rm(cleanPath).catch(() => {}); - rm(radioPath).catch(() => {}); + // Generiere die gewünschte Anzahl von Phrasen + for (let i = 0; i < Math.min(count, 10); i++) { // Max 10 Phrasen pro Request + const randomPhrase = filteredPhrases[Math.floor(Math.random() * filteredPhrases.length)]; + const generated = generateATCPhrase(randomPhrase.id, customVariables); + phrases.push(generated); + } + } - return { - atcText, - level: lvl, - audio: { - clean: { mime: "audio/wav", base64: cleanB64 }, - radio: { mime: "audio/wav", base64: radioB64 }, - }, - }; + // Normalisiere alle Phrasen für TTS + const normalizedPhrases = phrases.map(phrase => ({ + original: phrase, + normalized: normalizeATC(phrase), + length: phrase.length + })); + + return { + success: true, + moduleId, + lessonId, + type: type || 'any', + count: phrases.length, + phrases: normalizedPhrases, + availableTypes: getPhrasesForLesson(moduleId, lessonId) + .map(p => p.type) + .filter((type, index, arr) => arr.indexOf(type) === index), // Unique types + meta: { + totalAvailablePhrases: getPhrasesForLesson(moduleId, lessonId).length, + generatedAt: new Date().toISOString() + } + }; + + } catch (error) { + if (error.statusCode) { + throw error; // Re-throw HTTP errors + } + + throw createError({ + statusCode: 500, + statusMessage: `Phrase generation failed: ${error}` + }); + } }); diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 95e0922..cee4cef 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -1,3 +1,4 @@ +// server/api/atc/say.post.ts import { createError, readBody } from "h3"; import { writeFile, readFile, mkdir, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; @@ -7,78 +8,90 @@ 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) { +// Enhanced Radio Filter für besseren ATC-Sound +function buildEnhancedRadioFilter(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}]`, + // 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` ]; - 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}]`, - ]; + // Dropout-Simulation (Funkaussetzer) + const dropout = (frequency: number, duration: number) => + `volume=enable='lt(mod(t\\,${frequency})\\,${duration})':volume=0.1`; - 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; - }; + // 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]${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(";"); + `[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]${band(320,3300)},volume=1.15[pre]`, - ...mkNoiseMix(0.03, "pre", "mix"), - `[mix]${tail}[out]`, - ].join(";"); + `[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]${band(350,3200)},volume=1.1[pre]`, - ...mkCrushMix(12, 0.22, "pre", "mix1"), - ...mkNoiseMix(0.05, "mix1", "mix2"), - ...mkDropTail("mix2", 6, 0.06), - ].join(";"); + `[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]${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(";"); + `[0:a]${baseProcessor(350, 2900, 6)}[clean]`, + ...radioArtefacts(10, 0.07, 0.5), + `${dropout(5, 0.15)}`, + `${spatialEffect},${pttSimulation}[out]` + ].join(';'); } - // L === 1 + // L === 1: Sehr schlechte Verbindung, starke Störungen 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(";"); + `[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[]) { @@ -87,16 +100,21 @@ async function sh(cmd: string, args: string[]) { ); } -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]); +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() { - // Persistenz: env > ./storage/atc > tmp - const base = - process.env.ATC_OUT_DIR?.trim() || - join(process.cwd(), "storage", "atc"); + const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc"); return base; } @@ -105,12 +123,14 @@ async function ensureDir(p: string) { } 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; + speed?: number; + moduleId?: string; + lessonId?: string; }>(event); const raw = (body?.text || "").trim(); @@ -118,82 +138,114 @@ export default defineEventHandler(async (event) => { const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4))); const voice = (body?.voice || "alloy") as string; + const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0)); - // Wortstamm/Normalisierung: ICAO-Formatierung, Zahl-/Buchstabierlogik etc. + // ATC-Normalisierung für realistischen Funkspruch 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 + // Eindeutige ID und Pfade const id = randomUUID(); - const baseDir = join(outDir(), new Date().toISOString().slice(0, 10)); // YYYY-MM-DD + const timestamp = new Date().toISOString(); + const dateFolder = timestamp.slice(0, 10); // YYYY-MM-DD + + const baseDir = join(outDir(), dateFolder); 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, - ]); + // Temp-Dateien + const tmpClean = join(tmpdir(), `tts-${id}.wav`); + const tmpRadio = join(tmpdir(), `radio-${id}.wav`); - // 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"); + try { + // 1) OpenAI TTS - Clean Audio + const tts = await openai.audio.speech.create({ + model: TTS_MODEL, + voice, + format: "wav", + input: normalized, + speed + }); - // 5) Response (Audio als Base64 + Meta). Optional: nur Pfad zurückgeben, wenn du sparen willst. - const oggB64 = (await readFile(fileOgg)).toString("base64"); + await writeFile(tmpClean, Buffer.from(await tts.arrayBuffer())); - // Cleanup tmp - rm(tmpClean).catch(() => {}); - rm(tmpRadio).catch(() => {}); + // 2) Enhanced Radio-Effekt anwenden + await applyEnhancedRadioEffect(tmpClean, tmpRadio, level); - return { - ok: true, - id, - level, - voice, - text: raw, - normalized, - audio: { - mime: "audio/ogg", - base64: oggB64, - }, - stored: { - audioPath: fileOgg, - jsonPath: fileJson, - }, - }; + // 3) Zu hochwertigem OGG/Opus komprimieren + 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, + ]); + + // 4) Metadaten speichern + const meta = { + id, + createdAt: timestamp, + level, + voice, + speed, + text: raw, + normalized, + tag: body?.tag || null, + moduleId: body?.moduleId || null, + lessonId: body?.lessonId || null, + files: { + ogg: fileOgg, + }, + model: TTS_MODEL, + format: "audio/ogg; codecs=opus", + sampleRate: 22050, + channels: 1, + }; + + 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(() => {}); + + return { + success: true, + id, + level, + voice, + speed, + text: raw, + normalized, + audio: { + mime: "audio/ogg; codecs=opus", + base64: audioBase64, + size: oggBuffer.length + }, + stored: { + audioPath: fileOgg, + jsonPath: fileJson, + url: `/api/atc/audio/${dateFolder}/${id}.ogg` // Zum späteren Abrufen + }, + meta + }; + + } catch (error) { + // Cleanup bei Fehler + await rm(tmpClean).catch(() => {}); + await rm(tmpRadio).catch(() => {}); + + throw createError({ + statusCode: 500, + statusMessage: `TTS generation failed: ${error}` + }); + } }); diff --git a/yarn.lock b/yarn.lock index 8461dcf..9aa8fb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2059,6 +2059,15 @@ __metadata: languageName: node linkType: hard +"@types/fluent-ffmpeg@npm:^2": + version: 2.1.27 + resolution: "@types/fluent-ffmpeg@npm:2.1.27" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/2362e7d240d4d8a0b775b5abf9ab5f26a85b17dc335276f0c3ff3bf935f913ccc9e1e10e756a6a3cebf78c1cd1eff835a253f73a3cd1bbcb343b28089a9957f3 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.6.4": version: 2.6.13 resolution: "@types/node-fetch@npm:2.6.13" @@ -2627,6 +2636,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^0.2.9": + version: 0.2.10 + resolution: "async@npm:0.2.10" + checksum: 10c0/714d284dc6c3ae59f3e8b347083e32c7657ba4ffc4ff945eb152ad4fb08def27e768992fcd4d9fd3b411c6b42f1541862ac917446bf2a1acfa0f302d1001f7d2 + languageName: node + linkType: hard + "async@npm:^3.2.4, async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -4152,6 +4168,16 @@ __metadata: languageName: node linkType: hard +"fluent-ffmpeg@npm:^2.1.3": + version: 2.1.3 + resolution: "fluent-ffmpeg@npm:2.1.3" + dependencies: + async: "npm:^0.2.9" + which: "npm:^1.1.1" + checksum: 10c0/0397379ec3237c10b2389edeef26fdaf93f36d1b20b0f28f8945fe6d9121dcee9b0c615bf7d44edb7abd37233e0d24f0db39389668d3c86a1a2a0d59e3f4457b + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" @@ -6204,7 +6230,9 @@ __metadata: resolution: "opensquawk@workspace:." dependencies: "@nuxtjs/tailwindcss": "npm:6.14.0" + "@types/fluent-ffmpeg": "npm:^2" dotenv: "npm:^17.2.2" + fluent-ffmpeg: "npm:^2.1.3" nuxt: "npm:^4.1.1" nuxt-aos: "npm:1.2.5" openai: "npm:^4.66.0" @@ -8816,6 +8844,17 @@ __metadata: languageName: node linkType: hard +"which@npm:^1.1.1": + version: 1.3.1 + resolution: "which@npm:1.3.1" + dependencies: + isexe: "npm:^2.0.0" + bin: + which: ./bin/which + checksum: 10c0/e945a8b6bbf6821aaaef7f6e0c309d4b615ef35699576d5489b4261da9539f70393c6b2ce700ee4321c18f914ebe5644bc4631b15466ffbaad37d83151f6af59 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2"