kann ein bisschen normalized sprechen

This commit is contained in:
itsrubberduck
2025-09-14 23:20:01 +02:00
parent 5f78847d88
commit d71f0ca8b4
8 changed files with 1153 additions and 61 deletions

View File

@@ -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<void>((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 N2N4",
};
// 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 },
},
};
});

View File

@@ -1,62 +1,56 @@
import { readMultipartFormData, createError } from "h3";
import { LLM_MODEL, TTS_MODEL, atcReplyPrompt, openai } from "../../utils/openai";
import { openai, LLM_MODEL, TTS_MODEL, atcReplyPrompt } from "../../utils/openai";
import { applyRadioEffect } from "../../utils/radio";
import { writeFile, rm, readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { toFile } from "openai/uploads"; // ⟵ NEU
import { toFile } from "openai/uploads";
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
if (!parts) throw createError({ statusCode: 400, statusMessage: "No form-data" });
const mode = (parts.find(p => p.name === "mode")?.data?.toString("utf8") || "text").toLowerCase();
// Default ohne Text-Input: immer TTS
const audio = parts.find(p => p.type && p.data);
if (!audio) throw createError({ statusCode: 400, statusMessage: "No audio file" });
// ✅ saubere Datei-Erzeugung
const file = await toFile(
audio.data,
audio.filename || "ptt.webm",
{ type: audio.type || "audio/webm" }
);
// 1) Transkribieren
// 1) Transkription (Whisper)
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
const pilotText = tr.text?.trim() || "";
const pilotText = (tr.text || "").trim();
if (!pilotText) throw createError({ statusCode: 400, statusMessage: "Empty transcription" });
// 2) ATC-Antwort
// 2) ATC-Antwort (LLM)
const resp = await openai.responses.create({
model: LLM_MODEL,
input: atcReplyPrompt(pilotText),
});
const replyText = resp.output_text?.trim() || "";
const replyText = (resp.output_text || "").trim();
if (!replyText) throw createError({ statusCode: 500, statusMessage: "LLM empty" });
// 3) Optional TTS + Funk
if (mode === "tts") {
const clean = join(tmpdir(), `tts-${randomUUID()}.wav`);
const radio = join(tmpdir(), `radio-${randomUUID()}.wav`);
// 3) TTS + Funk-Effekt
const clean = join(tmpdir(), `tts-${randomUUID()}.wav`);
const radio = join(tmpdir(), `radio-${randomUUID()}.wav`);
const tts = await openai.audio.speech.create({
model: TTS_MODEL,
voice: "alloy",
format: "wav",
input: replyText,
});
const tts = await openai.audio.speech.create({
model: TTS_MODEL,
voice: "alloy",
format: "wav",
input: replyText,
});
await writeFile(clean, Buffer.from(await tts.arrayBuffer()));
await applyRadioEffect(clean, radio);
await writeFile(clean, Buffer.from(await tts.arrayBuffer()));
await applyRadioEffect(clean, radio);
const data = await readFile(radio);
const b64 = Buffer.from(data).toString("base64");
rm(clean).catch(() => {});
rm(radio).catch(() => {});
const data = await readFile(radio);
const b64 = Buffer.from(data).toString("base64");
rm(clean).catch(() => {});
rm(radio).catch(() => {});
return { pilotText, replyText, audio: { mime: "audio/wav", base64: b64 } };
}
return { pilotText, replyText };
return { pilotText, replyText, audio: { mime: "audio/wav", base64: b64 } };
});

View File

@@ -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" + 23 digits (e.g., FL120, FL90).
* Altitude (feet): "<number> ft" (e.g., 2000 ft, 5000 ft).
* Squawk: "squawk" + 4 digits (e.g., squawk 4723).
* QNH: "QNH" + 34 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 <frequency>."
- 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 N2N4"
}) {
// 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<string, string> = {
"0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower",
"5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner",
};
const NATO: Record<string, string> = {
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<string,string> = {
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<string,string>) => {
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<string,string>; }
) {
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 N2N4"
});
// → "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.
*/