diff --git a/nuxt.config.ts b/nuxt.config.ts index 996c978..d186a23 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,7 +1,13 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ - compatibilityDate: '2025-07-15', - devtools: { enabled: true }, - modules: ['vuetify-nuxt-module', '@nuxtjs/tailwindcss', 'nuxt-aos'], - aos: { once: true, duration: 600, easing: 'ease-out' } + compatibilityDate: '2025-07-15', + devtools: {enabled: true}, + modules: ['vuetify-nuxt-module', '@nuxtjs/tailwindcss', 'nuxt-aos'], + aos: {once: true, duration: 600, easing: 'ease-out'}, + runtimeConfig: { + openaiKey: process.env.OPENAI_API_KEY, // server-only + llmModel: process.env.LLM_MODEL || 'gpt-5-nano', + ttsModel: process.env.TTS_MODEL || 'tts-1', + public: {} + } }) diff --git a/package.json b/package.json index c94055d..d884dfc 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,6 @@ "vuetify": "3.9.0-beta.1", "vuetify-nuxt-module": "0.18.7" }, + "engines": { "node": "22.x" }, "packageManager": "yarn@4.9.4" } diff --git a/server/api/atc/generate.post.ts b/server/api/atc/generate.post.ts new file mode 100644 index 0000000..cde5dc1 --- /dev/null +++ b/server/api/atc/generate.post.ts @@ -0,0 +1,161 @@ +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()) + ) + ); +} + +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)); + + // 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 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" }); + + const normalized = normalizeATC(atcText); + if (!normalized) throw createError({ statusCode: 500, statusMessage: "ATC text empty" }); + console.log("ATC Text (normalized):", normalized); + + // return { atcText: normalized, level: lvl }; + + // 2) TTS (clean) + const cleanPath = join(tmpdir(), `tts-${randomUUID()}.wav`); + const radioPath = join(tmpdir(), `radio-${randomUUID()}.wav`); + + const tts = await openai.audio.speech.create({ + model: TTS_MODEL, + voice: "alloy", + format: "wav", + input: normalized, + }); + await writeFile(cleanPath, Buffer.from(await tts.arrayBuffer())); + + // 3) Funk-Effekt (mit stufe) + await applyRadioEffect(cleanPath, radioPath, lvl); + + // 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(() => {}); + + return { + atcText, + level: lvl, + audio: { + clean: { mime: "audio/wav", base64: cleanB64 }, + radio: { mime: "audio/wav", base64: radioB64 }, + }, + }; +}); diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 42733c2..ea13b2c 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,8 +1,294 @@ +// yarn add openai dotenv import OpenAI from "openai"; -export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); +import dotenv from "dotenv"; +import fs from "node:fs"; + +dotenv.config(); + +export const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY_PROD!, + project: process.env.OPENAI_PROJECT, // optional +}); + export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano"; export const TTS_MODEL = process.env.TTS_MODEL || "tts-1"; -export function atcReplyPrompt(userText: string) { - return `You are an ICAO-compliant ATC controller. Reply concisely. -Pilot said: "${userText}"`; + +/* ========================= + LLM PROMPTS (überarbeitet) + ========================= + Ziel: LLM liefert kompakte, maschinenfreundliche ICAO-Zeile, die unser Normalizer→TTS perfekt erweitert. + WICHTIG: Zahlen/Marker exakt im unten definierten Output-Format, keine ausgeschriebenen Wörter. +*/ + +export const ATC_OUTPUT_SPEC = ` +OUTPUT RULES (STRICT): +- ONE instruction only. No chit-chat. No readback. No explanations. +- English ICAO phraseology; keep it concise. +- Use the following tokens exactly; numbers as digits: + * Callsign: AAA123[Letter] (e.g., DLH359, BAW12A). + * Runway: "RWY" + two digits + optional L/C/R (e.g., RWY 25R, RWY 08L). + * Heading: "HDG" + 3 digits (e.g., HDG 270). + * Flight level: "FL" + 2–3 digits (e.g., FL120, FL90). + * Altitude (feet): " ft" (e.g., 2000 ft, 5000 ft). + * Squawk: "squawk" + 4 digits (e.g., squawk 4723). + * QNH: "QNH" + 3–4 digits (e.g., QNH 1013). + * Frequency: 3 digits "." 3 digits (e.g., 112.955, 121.800). Use 3+3 format. + * ICAO airport: 4 capital letters (e.g., EDDF, EHAM). + * Taxi: "via" + space-separated TWY designators (e.g., via A3 A N2). +- Separate segments by ". " (period + space). Keep line to <= 220 chars. +- Only include items relevant to current phase (clearance/taxi/line-up/dep/approach/landing). +- If you must give a contact instruction: "Contact ." +- Use standard order for the phase (e.g., taxi: destination RWY first, then route, then hold short). +`.trim(); + +/** System-Prompt: legt Rolle/Regeln fest */ +export function atcSystemPrompt(opts?: { + regionHint?: "EUR" | "US" | "INTL"; // nur als Soft-Hinweis, default INTL +}) { + const region = opts?.regionHint ?? "INTL"; + return [ + `You are an ICAO-compliant ATC controller for ${region}.`, + `Adhere to standard phraseology, brevity, and safety-critical ordering.`, + `Assume you have access to current airport config (active runway, SIDs), unless contradicted by user context.`, + ATC_OUTPUT_SPEC, + ].join("\n\n"); } + +/** Seed-ATC ohne Pilot-Input (rückwärtskompatible Signatur, aber reicherer Prompt) */ +export function atcSeedPrompt(s: { + airport: string; // e.g., "EDDF" + aircraft: string; // e.g., "A320" + type: string; // e.g., "IFR" + stand: string; // e.g., "V155" + dep: string; // destination ICAO, e.g., "EHAM" + sid?: string; // e.g., "MARUN 7F" + squawk?: string; // "4723" + freq?: string; // "121.800" + runway?: string; // "25R" (optional: falls bekannt) + phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; + notes?: string; // z.B. "TWY N closed between N2–N4" +}) { + // Default-Phase: clearance + const phase = s.phase || "clearance"; + const ctx = [ + `Airport ${s.airport}`, + `${s.aircraft} ${s.type} at stand ${s.stand}`, + `IFR departure ${s.dep}`, + s.runway ? `planned RWY ${s.runway}` : null, + s.sid ? `planned SID ${s.sid}` : null, + s.squawk ? `preassigned squawk ${s.squawk}` : null, + s.freq ? `next frequency ${s.freq}` : null, + s.notes ? `NOTAM/ATC notes: ${s.notes}` : null, + ].filter(Boolean).join(", "); + + const need = phase === "clearance" + ? "Issue an IFR clearance (route/SID if given, initial altitude, squawk, and current QNH if applicable)." + : phase === "taxi" + ? "Issue a taxi instruction to the departure RWY with a realistic taxi route and 'hold short'." + : phase === "lineup" + ? "Issue line-up and wait (or immediate takeoff if appropriate)." + : phase === "departure" + ? "Issue initial heading/speed/altitude or 'climb via SID' as appropriate." + : phase === "handoff" + ? "Issue handoff to next frequency." + : phase === "approach" + ? "Issue approach clearance with runway, altitude/FL, QNH if appropriate." + : "Issue landing clearance with runway and any exit/roll-out instructions."; + + return [ + `Generate ONE realistic ICAO ATC instruction in English. No extra commentary.`, + `Context: ${ctx}`, + `Phase: ${phase}`, + `Task: ${need}`, + ATC_OUTPUT_SPEC, + ].join("\n"); +} + +/** Pilot→ATC (rückwärtskompatibler Name, aber mit robustem Rahmen) */ +export function atcReplyPrompt(userText: string, state?: { + airport?: string; runway?: string; sid?: string; dep?: string; + lastSquawk?: string; lastFreq?: string; lastQNH?: string; + phase?: "clearance" | "taxi" | "lineup" | "departure" | "handoff" | "approach" | "landing"; + constraints?: string; // z.B. "TWY N closed", "no intersection deps on 25C" +}) { + const ctx = [ + state?.airport ? `Airport ${state.airport}` : null, + state?.runway ? `Active RWY ${state.runway}` : null, + state?.dep ? `Destination ${state.dep}` : null, + state?.sid ? `SID ${state.sid}` : null, + state?.lastSquawk ? `Last squawk ${state.lastSquawk}` : null, + state?.lastFreq ? `Next/last freq ${state.lastFreq}` : null, + state?.lastQNH ? `QNH ${state.lastQNH}` : null, + state?.constraints ? `Constraints: ${state.constraints}` : null, + ].filter(Boolean).join(", "); + + const phase = state?.phase ?? "clearance"; + + return [ + `You are an ICAO-compliant ATC controller. Reply concisely in standard phraseology.`, + ctx ? `Context: ${ctx}` : null, + `Pilot said: "${userText}"`, + `Phase: ${phase}`, + `Respond with ONE instruction following the rules.`, + ATC_OUTPUT_SPEC, + ].filter(Boolean).join("\n"); +} + +/* ========================= + Normalizer → TTS (wie zuvor) + ========================= */ + +const DIGIT: Record = { + "0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower", + "5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner", +}; + +const NATO: Record = { + A:"Alfa",B:"Bravo",C:"Charlie",D:"Delta",E:"Echo",F:"Foxtrot",G:"Golf",H:"Hotel", + I:"India",J:"Juliett",K:"Kilo",L:"Lima",M:"Mike",N:"November",O:"Oscar",P:"Papa", + Q:"Quebec",R:"Romeo",S:"Sierra",T:"Tango",U:"Uniform",V:"Victor",W:"Whiskey", + X:"X-ray",Y:"Yankee",Z:"Zulu" +}; + +// Airline-Telephony (erweiterbar) +export const CALLSIGN_MAP: Record = { + DLH: "Lufthansa", + BAW: "Speedbird", + AFR: "Air France", + KLM: "KLM", + AAL: "American", + UAL: "United", + DAL: "Delta", + RYR: "Ryanair", + EZY: "Easy", +}; + +const spellDigits = (s: string) => + s.split("").map(ch => DIGIT[ch] ?? ch).join(" "); + +const toNato = (s: string) => + s.toUpperCase().split("").map(ch => NATO[ch] ?? ch).join("-"); + +const runwaySpeak = (rw: string) => { + const m = rw.match(/^(\d{2})([LCR])?$/i); + if (!m) return rw; + const num = spellDigits(m[1]); + const side = m[2]?.toUpperCase() === "L" ? "left" + : m[2]?.toUpperCase() === "C" ? "center" + : m[2]?.toUpperCase() === "R" ? "right" : ""; + return `runway ${num}${side ? " " + side : ""}`; +}; + +const headingSpeak = (hdg: string) => `heading ${spellDigits(hdg.padStart(3, "0"))}`; +const squawkSpeak = (code: string) => `squawk ${spellDigits(code)}`; + +const freqSpeak = (f: string) => { + const [a,b] = f.split("."); + const left = spellDigits(a); + const right = b ? spellDigits(b) : ""; + return `${left}${b ? " decimal " + right : ""}`; +}; + +const altitudeSpeak = (ft: number) => { + if (!Number.isFinite(ft)) return `${ft} feet`; + const thousands = Math.floor(ft/1000); + const hundreds = Math.round((ft % 1000)/100)*100; + const parts: string[] = []; + if (thousands) parts.push(`${spellDigits(String(thousands))} thousand`); + if (hundreds) { + const h = hundreds === 900 ? "nine hundred" + : hundreds === 800 ? "eight hundred" + : hundreds === 700 ? "seven hundred" + : hundreds === 600 ? "six hundred" + : hundreds === 500 ? "five hundred" + : hundreds === 400 ? "fower hundred" + : hundreds === 300 ? "tree hundred" + : hundreds === 200 ? "too hundred" + : hundreds === 100 ? "wun hundred" + : spellDigits(String(hundreds)); + parts.push(h); + } + return `${parts.join(" ")} feet`.trim(); +}; + +const flightLevelSpeak = (fl: string) => + `flight level ${spellDigits(fl.replace(/^0+/, ""))}`; + +const qnhSpeak = (q: string) => `QNH ${spellDigits(q)}`; + +const callsignSpeak = (raw: string, map: Record) => { + const up = raw.toUpperCase(); + const m = up.match(/^([A-Z]{2,3})(\d{1,4}[A-Z]?)$/); + if (!m) return raw; + const telephony = map[m[1]] ?? toNato(m[1]).replace(/-/g," "); + const suffix = spellDigits(m[2].replace(/[A-Z]$/, (l) => " " + (NATO[l] ?? l))); + return `${telephony} ${suffix}`; +}; + +const icaoAirportSpeak = (code: string) => + /^[A-Z]{4}$/.test(code) ? toNato(code) : code; + +// Public Normalizer +export function normalizeATC( + text: string, + opts?: { airlineMap?: Record; } +) { + let out = text; + + out = out.replace(/\b(\d{3})\.(\d{3})\b/g, (_,a,b)=> `${freqSpeak(`${a}.${b}`)}`); + out = out.replace(/\b(?:HDG|heading)\s*(\d{2,3})\b/gi, (_,h)=> headingSpeak(h)); + out = out.replace(/\b(?:RWY|runway)\s*(\d{2}[LCR]?)\b/gi, (_,rw)=> runwaySpeak(rw)); + out = out.replace(/\b(?:squawk|code)\s*(\d{4})\b/gi, (_,c)=> squawkSpeak(c)); + out = out.replace(/\bFL\s*(\d{2,3})\b/gi, (_,fl)=> flightLevelSpeak(fl)); + out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_,ft)=> altitudeSpeak(Number(ft))); + out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_,q)=> qnhSpeak(q)); + out = out.replace(/\b([A-Z]{4})\b/g, (_,code)=> icaoAirportSpeak(code)); + out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (m)=> callsignSpeak(m, opts?.airlineMap ?? CALLSIGN_MAP)); + + return out.replace(/\s+/g," ").trim(); +} + +// TTS Wrapper (mp3) +export async function speakATC(text: string, filePath = "atc.mp3") { + const input = normalizeATC(text); + const resp = await (openai as any).audio.speech.create({ + model: TTS_MODEL, + voice: "alloy", + input, + format: "mp3", + }); + const buf = Buffer.from(await resp.arrayBuffer()); + fs.writeFileSync(filePath, buf); + return { filePath, spoken: input }; +} + +/* ========================= + Beispiele + ========================= + +— Seed (Clearance): +const sys = atcSystemPrompt(); +const usr = atcSeedPrompt({ + airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", + dep: "EHAM", sid: "MARUN 7F", runway: "25R", freq: "121.800" +}); +// → LLM antwortet z.B.: +// "DLH359, cleared to EHAM via MARUN 7F, initial 5000 ft, squawk 4723. QNH 1013." + +— Taxi: +const usrTaxi = atcSeedPrompt({ + airport: "EDDF", aircraft: "A320", type: "IFR", stand: "V155", + dep: "EHAM", runway: "25R", phase: "taxi", + notes: "TWY N closed between N2–N4" +}); +// → "DLH359, taxi to RWY 25R via A3 A N2, hold short." + +— Pilot→ATC: +const usrReply = atcReplyPrompt( + "DLH359 ready for departure RWY 25R", + { airport: "EDDF", runway: "25R", phase: "lineup", lastFreq: "121.800" } +); +// → "DLH359, line up and wait RWY 25R." + +Nach dem LLM-Output: `speakATC(llmText)` ruft Normalizer→TTS. +*/