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/app/pages/learn.vue b/app/pages/learn.vue index 0812e11..11b53bf 100644 --- a/app/pages/learn.vue +++ b/app/pages/learn.vue @@ -306,20 +306,68 @@ import learnModules, {Lesson, ModuleDef} from "../../composables/learnModules"; /** AUDIO **/ const tts = useRadioTTS() +// Server TTS verwenden function speak(text: string) { if (cfg.value.tts) { tts.speakBrowser(text) } else { - tts.speakServer(text, {level: cfg.value.radioLevel, voice: cfg.value.voice || 'alloy'}) + tts.speakServer(text, { + level: cfg.value.radioLevel, + voice: cfg.value.voice || 'alloy', + moduleId: current.value?.id, + lessonId: activeLesson.value?.id + }) } } +// PTT-Button hinzufügen (optional) +const handlePTT = async () => { + if (tts.isRecording.value) { + const audioBlob = await tts.stopRecording() + if (audioBlob && activeLesson.value) { + const result = await tts.submitPTT(audioBlob, { + expectedText: activeLesson.value.target, + moduleId: current.value!.id, + lessonId: activeLesson.value.id + }) + + // Bewertung anzeigen + console.log('PTT Score:', result.evaluation.score) + if (result.playAgain) { + speak(activeLesson.value.target) // Nochmal abspielen + } + } + } else { + await tts.startRecording() + } +} + +// Zufällige Phrase für Lektion generieren +const generateRandomPhrase = async () => { + if (current.value && activeLesson.value) { + const response = await tts.generatePhrase({ + moduleId: current.value.id, + lessonId: activeLesson.value.id, + type: 'instruction' + }) + + // Generierte Phrase abspielen + if (response.phrases[0]) { + speak(response.phrases[0].original) + } + } +} + +onMounted(() => { + // Browser benötigt Mikrofon-Berechtigung + navigator.mediaDevices.getUserMedia({audio: true}) +}) + function stopAudio() { tts.stop() } - /** STATE **/ const panel = ref<'hub' | 'module' | 'progress'>('hub') const current = ref(null) @@ -617,6 +665,7 @@ function testBeep() { display: inline-flex; align-items: center } + .icon-btn:hover { background: color-mix(in srgb, var(--text) 6%, transparent); transform: scale(1.05); diff --git a/composables/radioTtsNew.ts b/composables/radioTtsNew.ts index 265a404..8b93a71 100644 --- a/composables/radioTtsNew.ts +++ b/composables/radioTtsNew.ts @@ -1,43 +1,346 @@ -export type RadioOpts = { - level?: number; // 1..5 - voice?: string; // z.B. "alloy" -}; +// composables/radioTtsNew.ts +import { ref, computed } from 'vue' + +interface TTSOptions { + level?: number + voice?: string + speed?: number + moduleId?: string + lessonId?: string + tag?: string +} + +interface GenerateOptions { + moduleId: string + lessonId: string + phraseId?: string + customVariables?: Record + type?: 'instruction' | 'clearance' | 'information' | 'request' + count?: number +} + +interface PTTOptions { + expectedText: string + moduleId: string + lessonId: string + format?: 'wav' | 'mp3' | 'ogg' | 'webm' +} + +interface AudioCache { + [key: string]: { + blob: Blob + url: string + timestamp: number + } +} export default function useRadioTTS() { - let audio: HTMLAudioElement | null = null; + const isLoading = ref(false) + const isRecording = ref(false) + const error = ref(null) + const audioCache = ref({}) - function stop() { - if (audio) { - audio.pause(); - audio.src = ""; - audio.load(); - audio = null; + let currentAudio: HTMLAudioElement | null = null + let mediaRecorder: MediaRecorder | null = null + let recordedChunks: Blob[] = [] + + // Cache Management + const cacheKey = (text: string, options: TTSOptions) => + `${text}-${options.level || 4}-${options.voice || 'alloy'}-${options.speed || 1.0}` + + const getCachedAudio = (key: string) => { + const cached = audioCache.value[key] + if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache + return cached + } + if (cached) { + URL.revokeObjectURL(cached.url) + delete audioCache.value[key] + } + return null + } + + const setCachedAudio = (key: string, blob: Blob) => { + const url = URL.createObjectURL(blob) + audioCache.value[key] = { + blob, + url, + timestamp: Date.now() + } + return url + } + + // Enhanced Server TTS with caching + const speakServer = async (text: string, options: TTSOptions = {}) => { + error.value = null + + const key = cacheKey(text, options) + const cached = getCachedAudio(key) + + if (cached) { + return playAudio(cached.url) + } + + isLoading.value = true + + try { + const response = await $fetch('/api/atc/say', { + method: 'POST', + body: { + text, + level: options.level || 4, + voice: options.voice || 'alloy', + speed: options.speed || 1.0, + moduleId: options.moduleId, + lessonId: options.lessonId, + tag: options.tag + } + }) + + if (!response.success) { + throw new Error('TTS generation failed') + } + + // Convert base64 to blob and cache + const audioData = atob(response.audio.base64) + const audioArray = new Uint8Array(audioData.length) + for (let i = 0; i < audioData.length; i++) { + audioArray[i] = audioData.charCodeAt(i) + } + const blob = new Blob([audioArray], { type: response.audio.mime }) + + const audioUrl = setCachedAudio(key, blob) + await playAudio(audioUrl) + + return response + + } catch (err) { + error.value = err instanceof Error ? err.message : 'TTS failed' + throw err + } finally { + isLoading.value = false } } - 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; + // Browser TTS (fallback) + const speakBrowser = (text: string, options: { voice?: string; rate?: number; pitch?: number } = {}) => { + if (!window.speechSynthesis) { + error.value = 'Browser TTS not supported' + return + } + + stop() // Stop any current speech + + const utterance = new SpeechSynthesisUtterance(text) + + if (options.voice) { + const voices = speechSynthesis.getVoices() + const voice = voices.find(v => v.name.includes(options.voice!)) + if (voice) utterance.voice = voice + } + + utterance.rate = options.rate || 0.9 + utterance.pitch = options.pitch || 1.0 + utterance.volume = 1.0 + + utterance.onerror = (event) => { + error.value = `TTS error: ${event.error}` + } + + speechSynthesis.speak(utterance) } - 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; + // Generate ATC phrases + const generatePhrase = async (options: GenerateOptions) => { + error.value = null + isLoading.value = true + + try { + const response = await $fetch('/api/atc/generate', { + method: 'POST', + body: options + }) + + if (!response.success) { + throw new Error('Phrase generation failed') + } + + return response + + } catch (err) { + error.value = err instanceof Error ? err.message : 'Phrase generation failed' + throw err + } finally { + isLoading.value = false + } } - return { speakBrowser, speakServer, stop }; + // PTT (Push-to-Talk) Recording + const startRecording = async () => { + if (isRecording.value) return + + error.value = null + recordedChunks = [] + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true + } + }) + + mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm;codecs=opus' + }) + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + recordedChunks.push(event.data) + } + } + + mediaRecorder.start() + isRecording.value = true + + } catch (err) { + error.value = err instanceof Error ? err.message : 'Recording failed' + throw err + } + } + + const stopRecording = async (): Promise => { + if (!isRecording.value || !mediaRecorder) return null + + return new Promise((resolve) => { + mediaRecorder!.onstop = () => { + const blob = new Blob(recordedChunks, { type: 'audio/webm' }) + + // Stop all tracks + mediaRecorder!.stream.getTracks().forEach(track => track.stop()) + mediaRecorder = null + isRecording.value = false + + resolve(blob) + } + + mediaRecorder!.stop() + }) + } + + // Submit PTT for evaluation + const submitPTT = async (audioBlob: Blob, options: PTTOptions) => { + error.value = null + isLoading.value = true + + try { + // Convert blob to base64 + const arrayBuffer = await audioBlob.arrayBuffer() + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))) + + const response = await $fetch('/api/atc/ptt', { + method: 'POST', + body: { + audio: base64, + expectedText: options.expectedText, + moduleId: options.moduleId, + lessonId: options.lessonId, + format: options.format || 'webm' + } + }) + + if (!response.success) { + throw new Error('PTT evaluation failed') + } + + return response + + } catch (err) { + error.value = err instanceof Error ? err.message : 'PTT evaluation failed' + throw err + } finally { + isLoading.value = false + } + } + + // Audio playback + const playAudio = async (audioUrl: string): Promise => { + return new Promise((resolve, reject) => { + stop() // Stop any current audio + + currentAudio = new Audio(audioUrl) + currentAudio.volume = 1.0 + + currentAudio.addEventListener('ended', () => resolve()) + currentAudio.addEventListener('error', (e) => { + error.value = 'Audio playback failed' + reject(e) + }) + + currentAudio.play().catch(reject) + }) + } + + const stop = () => { + // Stop TTS + if (window.speechSynthesis) { + speechSynthesis.cancel() + } + + // Stop audio playback + if (currentAudio) { + currentAudio.pause() + currentAudio.currentTime = 0 + currentAudio = null + } + + // Stop recording + if (isRecording.value && mediaRecorder) { + mediaRecorder.stop() + } + } + + // Cleanup + const cleanup = () => { + stop() + + // Clear cache URLs + Object.values(audioCache.value).forEach(cached => { + URL.revokeObjectURL(cached.url) + }) + audioCache.value = {} + } + + // Auto-cleanup on unmount + onUnmounted(() => { + cleanup() + }) + + return { + // State + isLoading: readonly(isLoading), + isRecording: readonly(isRecording), + error: readonly(error), + + // TTS Methods + speakServer, + speakBrowser, + + // Phrase Generation + generatePhrase, + + // PTT Methods + startRecording, + stopRecording, + submitPTT, + + // Control + stop, + cleanup, + + // Utilities + playAudio + } } 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/server/utils/atcPhrases.ts b/server/utils/atcPhrases.ts index e69de29..f0b1db4 100644 --- a/server/utils/atcPhrases.ts +++ b/server/utils/atcPhrases.ts @@ -0,0 +1,269 @@ +// server/utils/atcPhrases.ts +export interface ATCPhrase { + id: string; + moduleId: string; + lessonId: string; + type: 'instruction' | 'clearance' | 'information' | 'request'; + template: string; + variables?: Record; + context?: { + airport?: string; + runway?: string; + frequency?: string; + callsign?: string; + }; +} + +export const ATC_PHRASES: ATCPhrase[] = [ + // ICAO Alphabet Module + { + id: 'icao_alpha_drill', + moduleId: 'icao', + lessonId: 'alpha', + type: 'instruction', + template: 'Spell your callsign using phonetic alphabet from {start} to {end}', + variables: { + start: ['Alpha', 'Bravo', 'Charlie'], + end: ['Lima', 'Mike', 'November'] + } + }, + { + id: 'icao_numbers_drill', + moduleId: 'icao', + lessonId: 'numbers', + type: 'instruction', + template: 'Read back transponder code {squawk}', + variables: { + squawk: ['1234', '4567', '7321', '2156', '6543'] + } + }, + { + id: 'icao_callsign_spell', + moduleId: 'icao', + lessonId: 'callsign-icao', + type: 'instruction', + template: '{callsign}, spell your callsign', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789', 'KLM321', 'RYR654'] + } + }, + + // Basics Module + { + id: 'ground_checkin', + moduleId: 'basics', + lessonId: 'checkin', + type: 'clearance', + template: '{callsign}, {ground_station}, stand {stand} available, taxi when ready', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + ground_station: ['Frankfurt Ground', 'Munich Ground', 'Berlin Ground'], + stand: ['A12', 'B24', 'C15', 'V155', 'G23'] + }, + context: { + airport: 'EDDF' + } + }, + { + id: 'basic_readback', + moduleId: 'basics', + lessonId: 'readback', + type: 'instruction', + template: '{callsign}, contact Tower on {frequency}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + frequency: ['118.500', '119.900', '121.700', '124.850'] + } + }, + + // Ground Operations + { + id: 'taxi_clearance_simple', + moduleId: 'ground', + lessonId: 'taxi1', + type: 'clearance', + template: '{callsign}, taxi to runway {runway} via {taxiway}, hold short runway {runway}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789', 'EZY234'], + runway: ['25R', '25L', '07R', '07L', '18', '36'], + taxiway: ['A A5 B2', 'C C3 A', 'A A7 N N4', 'B B1 A3'] + } + }, + { + id: 'taxi_clearance_complex', + moduleId: 'ground', + lessonId: 'taxi1', + type: 'clearance', + template: '{callsign}, taxi to runway {runway} via {route}, hold short runway {hold_runway}', + variables: { + callsign: ['DLH359', 'BAW12A', 'AFR567'], + runway: ['25R', '25L', '07R'], + route: ['A A3 B B1', 'C C5 A A7', 'M M1 A A5 B'], + hold_runway: ['25R', '25L', '07R', '18'] + } + }, + { + id: 'handoff_tower', + moduleId: 'ground', + lessonId: 'handoff', + type: 'instruction', + template: '{callsign}, contact Tower on {frequency}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + frequency: ['118.500', '119.900', '121.700', '124.850', '132.025'] + } + }, + + // Departure Operations + { + id: 'lineup_wait', + moduleId: 'departure', + lessonId: 'lineup', + type: 'clearance', + template: '{callsign}, line up and wait runway {runway}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + runway: ['25R', '25L', '07R', '07L', '18', '36'] + } + }, + { + id: 'takeoff_clearance', + moduleId: 'departure', + lessonId: 'lineup', + type: 'clearance', + template: '{callsign}, runway {runway}, cleared for takeoff', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + runway: ['25R', '25L', '07R', '07L'] + } + }, + { + id: 'departure_instructions', + moduleId: 'departure', + lessonId: 'lineup', + type: 'instruction', + template: '{callsign}, after takeoff turn {direction} heading {heading}, contact Departure on {frequency}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + direction: ['left', 'right'], + heading: ['090', '180', '270', '360', '045', '135', '225', '315'], + frequency: ['121.200', '125.750', '127.275', '135.725'] + } + }, + + // Arrival Operations + { + id: 'landing_clearance', + moduleId: 'arrival', + lessonId: 'vacate', + type: 'clearance', + template: '{callsign}, runway {runway}, cleared to land', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + runway: ['25R', '25L', '07R', '07L'] + } + }, + { + id: 'vacate_instruction', + moduleId: 'arrival', + lessonId: 'vacate', + type: 'instruction', + template: '{callsign}, vacate runway {runway} via {taxiway}, contact Ground on {frequency}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + runway: ['25R', '25L', '07R', '07L'], + taxiway: ['A6', 'A7', 'B3', 'C4', 'N2'], + frequency: ['121.800', '121.900', '129.725'] + } + }, + + // VATSIM Operations + { + id: 'ifr_clearance', + moduleId: 'vatsim', + lessonId: 'checkin', + type: 'clearance', + template: '{callsign}, cleared to {destination} via {sid}, initial climb {altitude}, squawk {squawk}', + variables: { + callsign: ['DLH359', 'BAW12A', 'AFR567'], + destination: ['EHAM', 'EGLL', 'LFPG', 'LEMD', 'LIRF'], + sid: ['MARUN7F', 'BIBTI7F', 'CHA7F', 'SOBRA7F'], + altitude: ['5000 feet', '6000 feet', 'FL070', 'FL080'], + squawk: ['4723', '1234', '5647', '7321'] + } + }, + { + id: 'startup_clearance', + moduleId: 'vatsim', + lessonId: 'checkin', + type: 'clearance', + template: '{callsign}, startup approved, {atis_info} current, expect runway {runway}', + variables: { + callsign: ['DLH359', 'BAW12A', 'AFR567'], + atis_info: ['information Alpha', 'information Bravo', 'information Charlie'], + runway: ['25R', '25L', '07R', '07L'] + } + }, + + // Emergency/Special Situations + { + id: 'traffic_info', + moduleId: 'ground', + lessonId: 'taxi1', + type: 'information', + template: '{callsign}, traffic {direction}, {aircraft_type} {distance}', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'], + direction: ['ahead', 'behind', 'left', 'right', 'crossing'], + aircraft_type: ['A320', 'B737', 'A380', 'B777'], + distance: ['100 meters', '200 meters', '500 meters'] + } + }, + { + id: 'hold_position', + moduleId: 'ground', + lessonId: 'taxi1', + type: 'instruction', + template: '{callsign}, hold position, traffic crossing', + variables: { + callsign: ['DLH123', 'BAW456', 'AFR789'] + } + } +]; + +// Hilfsfunktionen für Template-Verarbeitung +export function generateATCPhrase(phraseId: string, customVariables?: Record): string { + const phrase = ATC_PHRASES.find(p => p.id === phraseId); + if (!phrase) { + throw new Error(`ATC phrase with id "${phraseId}" not found`); + } + + let result = phrase.template; + const variables = phrase.variables || {}; + + // Ersetze Variablen im Template + for (const [key, values] of Object.entries(variables)) { + const placeholder = `{${key}}`; + if (result.includes(placeholder)) { + // Verwende custom value oder wähle zufällig + const value = customVariables?.[key] || values[Math.floor(Math.random() * values.length)]; + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value); + } + } + + return result; +} + +export function getPhrasesForLesson(moduleId: string, lessonId: string): ATCPhrase[] { + return ATC_PHRASES.filter(p => p.moduleId === moduleId && p.lessonId === lessonId); +} + +export function getRandomPhraseForLesson(moduleId: string, lessonId: string): string { + const phrases = getPhrasesForLesson(moduleId, lessonId); + if (phrases.length === 0) { + throw new Error(`No phrases found for module "${moduleId}", lesson "${lessonId}"`); + } + + const randomPhrase = phrases[Math.floor(Math.random() * phrases.length)]; + return generateATCPhrase(randomPhrase.id); +} 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"