From d71f0ca8b43d00f5fe9e54c2c459089ae00a002c Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 14 Sep 2025 23:20:01 +0200 Subject: [PATCH] kann ein bisschen normalized sprechen --- app/pages/atc.vue | 109 ++++ app/pages/ptt.vue | 106 +++- nuxt.config.ts | 14 +- package.json | 1 + public/docs/opensquawk-ci-and-product-spec.md | 477 ++++++++++++++++++ server/api/atc/generate.post.ts | 161 ++++++ server/api/ptt/reply.post.ts | 52 +- server/utils/openai.ts | 294 ++++++++++- 8 files changed, 1153 insertions(+), 61 deletions(-) create mode 100644 app/pages/atc.vue create mode 100644 public/docs/opensquawk-ci-and-product-spec.md create mode 100644 server/api/atc/generate.post.ts diff --git a/app/pages/atc.vue b/app/pages/atc.vue new file mode 100644 index 0000000..8245fa2 --- /dev/null +++ b/app/pages/atc.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/pages/ptt.vue b/app/pages/ptt.vue index 0a9b74e..ee5f4b5 100644 --- a/app/pages/ptt.vue +++ b/app/pages/ptt.vue @@ -1,58 +1,116 @@ + 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/public/docs/opensquawk-ci-and-product-spec.md b/public/docs/opensquawk-ci-and-product-spec.md new file mode 100644 index 0000000..d790da6 --- /dev/null +++ b/public/docs/opensquawk-ci-and-product-spec.md @@ -0,0 +1,477 @@ +# OpenSquawk – CI-Guidelines & Produkt-Spezifikation + +## 1) Brand Basics + +* **Name:** OpenSquawk +* **Claim:** Open-Source AI-ATC für Simulatorpiloten +* **Ton:** technisch, knapp, vertrauenswürdig. Keine Superlative, kein Marketing-Overkill. + +### Farbpalette (Brand + UI Tokens) + +Primär basiert auf dunklem Navy mit Cyan-Akzenten. + +```txt +--brand.bg = #0b1020 // Grundfläche (Hero/Body) +--brand.bg-2 = #0a0f1c // Sektionen/Alternation +--brand.accent = #22d3ee // Cyan 400 (Tailwind) – Highlights +--brand.accent-2 = #0ea5e9 // Sky 500 – Buttons/Links +--brand.text = #ffffff +--brand.text-2 = rgba(255,255,255,.80) +--brand.text-3 = rgba(255,255,255,.60) +--brand.border = rgba(255,255,255,.10) +--brand.glow = rgba(34,211,238,.25) + +Neutral & Status: +--neutral-100 = #111317 +--neutral-200 = #1f2430 +--success = #4caf50 +--info = #2196f3 +--warning = #fb8c00 +--error = #b00020 +``` + +### Typografie + +* **Font:** System-UI oder Inter (Fallback: `ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif`) +* **Größen:** + + * H1: 36–60px (Hero 48–60) + * H2: 28–36px + * H3: 18–20px + * Body: 16–18px + * Caption/Meta: 12–14px +* **Gewichte:** 600 für Headlines, 400–500 für Fließtext. +* **Zeilenhöhe:** 1.2 Headlines, 1.6 Body. +* **Zahlen:** Tabular Lining (wenn verfügbar). + +### Spacing & Layout + +* **Container:** `.container-outer` max-width 1200px, Padding X 16–24px. +* **Grid:** 12-Spalten responsiv; Gap 16–24px. +* **Spacing-Scale:** 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64px. + +### Ecken, Border, Schatten + +* **Radius:** Karten/Inputs/Buttons `12–16px` (Tailwind `rounded-xl`). +* **Border:** 1px `--brand.border`. +* **Shadow (Akzent):** weiches Cyan-Glow für Callouts/Primary Cards. + +### Motion + +* **AOS:** `once: true; duration: 600; easing: ease-out`. + Verwenden für „in view“ (fade, zoom, slide). Keine übertriebenen Parallax-Effekte. +* **Transitions:** 150–250ms für Hover/Focus/Expand. + +### Bildsprache + +* Dunkle Cockpit-/Radar-Anmutung. Unsplash-Platzhalter ok, Kennzeichnung „Symbolbild“. +* **Overlays:** dezente Gradients in Cyan/Blue mit geringer Opazität. + +--- + +## 2) Design-Bausteine (Tailwind + Hilfsklassen) + +### Utilities / Helper + +* `.gradient-hero`: radial/linear Mix mit Cyan/Blue weichen Flecken (Blur 2–3xl). +* `.card`: `rounded-xl bg-white/5 border border-white/10 p-5 md:p-6 backdrop-blur-sm`. +* `.glass`: `rounded-xl border border-white/10 bg-white/5 backdrop-blur`. +* `.chip`: `inline-flex items-center h-7 px-3 rounded-full border border-white/10 bg-white/5 text-sm`. + +### Buttons + +* **Primary:** Cyan/Sky, solider Hintergrund. +* **Ghost:** Transparenter Hintergrund, Border sichtbar, Text in `--brand.text`. +* **Disabled:** 50% Opazität, Cursor not-allowed. + +**Tailwind-Snippets:** + +```html +... + +.btn{ @apply inline-flex items-center justify-center rounded-xl px-4 py-2 font-medium transition; } +.btn-primary{ @apply bg-sky-500 hover:bg-sky-400 text-white shadow; } +.btn-ghost{ @apply bg-white/5 hover:bg-white/10 border border-white/10 text-white; } +``` + +### Links + +* Farbe Sky/Cyan, `hover:opacity-80` oder leichte Unterstreichung. + +### Chips/Badges + +* Klein, abgerundet, niedrige Sättigung (bg-white/5), für Status/Filter. + +### Cards + +* Inhaltliche Blöcke, Header (Icon + H3), Body (Text/Listen), optionale Footer-Actions. + +### Listen + +* Bullets: `list-disc list-inside`, Farbe `text-white/70`. + +### Tabellen + +* Sehr sparsam; sonst Cards + Definition Lists bevorzugen. + +--- + +## 3) Barrierefreiheit + +* **Kontrast:** Buttons/Links ≥ WCAG AA. +* **Focus States:** gut sichtbar (Outline/Shadow in Cyan). +* **ARIA:** semantische Landmarken (`header/nav/main/section/footer`), `aria-label` bei Icons. +* **Motion-Respect:** `prefers-reduced-motion` → AOS deaktivieren/verkürzen. +* **Alt-Texte:** Beschreibend, keine redundanten Wörter. + +--- + +## 4) SEO/Meta + +* **Title:** `OpenSquawk – Open-Source AI-ATC` +* **Description:** aus Landing übernommen (\~155 Zeichen). +* **OpenGraph/Twitter:** Cover 1200×630, konsistent. +* **robots.txt:** indexierbar; Disallow ausschließlich Dev-/Internes. +* **Sitemap:** `/sitemap.xml`. + +--- + +## 5) Nuxt / Tailwind / AOS / Vuetify – Setup + +### Nuxt-Module + +* `@nuxtjs/tailwindcss`, `@nuxt/image`, AOS-Plugin, Vuetify (SSR-ready), Iconfont MDI. + +### Tailwind Basiskonfig (Auszug) + +```js +// tailwind.config.cjs +module.exports = { + content: ['app.vue','components/**/*.{vue,js}','pages/**/*.vue'], + theme: { + extend: { + colors: { + brand: { + bg: '#0b1020', + bg2: '#0a0f1c', + accent: '#22d3ee', + accent2: '#0ea5e9', + border: 'rgba(255,255,255,.10)' + } + }, + borderRadius: { xl: '12px', '2xl': '16px' }, + boxShadow: { glow: '0 0 40px rgba(34,211,238,.25)' } + } + } +} +``` + +### AOS (client-only) + +```ts +// plugins/aos.client.ts +import AOS from 'aos' +import 'aos/dist/aos.css' +export default defineNuxtPlugin(() => { + AOS.init({ once: true, duration: 600, easing: 'ease-out' }) +}) +``` + +### Vuetify – Konfiguration & Defaults + +Ziel: Vuetify nur für komplexe Controls (Dialogs, Menüs, Loaders, Grid wenn nötig). Stil an Tailwind anpassen. + +```ts +// plugins/vuetify.ts +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import { aliases, mdi } from 'vuetify/iconsets/mdi' + +export default defineNuxtPlugin((nuxtApp) => { + const vuetify = createVuetify({ + theme: { + defaultTheme: 'dark', + themes: { + dark: { + colors: { + background: '#121212', + surface: '#212121', + primary: '#2196f3', // Info/Sky + secondary: '#54b6b2', // Akzent weich + error: '#b00020', + warning: '#fb8c00', + success: '#4caf50', + info: '#2196f3', + onBackground: '#ffffff', + onSurface: '#ffffff' + } + } + } + }, + icons: { defaultSet: 'mdi', aliases, sets: { mdi } }, + defaults: { + VBtn: { + rounded: 'xl', + height: 44, + class: 'font-medium', + color: 'primary', + variant: 'flat' + }, + VCard: { + rounded: 'xl', + elevation: 0, + class: 'border border-white/10 bg-white/5 backdrop-blur' + }, + VTextField: { + rounded: 'xl', + variant: 'outlined', + color: 'primary', + class: 'bg-white/5' + }, + VProgressCircular: { color: 'primary' } + } + }) + nuxtApp.vueApp.use(vuetify) +}) +``` + +**Empfehlung:** Tailwind für Layout/Look, Vuetify gezielt für komplexe Interaktionen. Keine gemischten Button-Varianten im selben Screen. + +--- + +## 6) Komponenten-Inventar (Landing & App) + +### Global + +* **AppNav** (Logo, Sektionen, CTA) +* **AppFooter** (Produkt/Links/Recht) +* **Section** Wrapper (Hintergründe: bg, gradient) +* **Card**, **Chip**, **CTAForm** (E-Mail Invite) + +### Landing-Spezifisch + +* **Hero** (Headline, Unterzeile, CTAs, Social Proof Avatare) +* **LogoBar** (MSFS/X-Plane/VATSIM/IVAO Hinweise) +* **FeaturesGrid** (Icon + H3 + Body) +* **LearnPath** (Schritte 1–5 mit Chips) +* **Pricing** (3 Pläne, Toggle jährlich/monatlich, „Empfohlen“) +* **OpenSource** (Bullets + Code-Block) +* **HowItWorks** (4-Schritte) +* **FAQ** (Cards) + +### Web-App/Software (Core) + +* **PTTButton** (Push-to-Talk, Zustand: idle/listening/processing) +* **TranscriptPane** (ASR-Streams, Zeitstempel, Confidence) +* **ATCPanel** (aktuelle Clearance, Readback-Hints) +* **TaxiMap** (Ground-Overlay, A\* Route, Hotspots) +* **FlightStrip** (Callsign, Type, DEP/ARR, SSR) +* **ATISWidget** (Frequenz/Info/Runways) +* **LearningCoach** (Step-Prompts, Feedback, Score) +* **Settings** (Audio I/O, TTS-Voice, Sensitivität, Netzwerke) +* **SessionLog** (JSON-Export, Replays) +* **StatusBar** (Conn, Latency, CPU/GPU, Model) + +--- + +## 7) Produkt-Spezifikation (High Level) + +### Ziele + +* Latenzarmes AI-ATC für MSFS/X-Plane, Lernmodus mit progressivem Schwierigkeitsgrad, sanfte Brücke Richtung VATSIM/IVAO. + +### Architektur (vereinfacht) + +* **Client (Nuxt 4, WebRTC/WS):** Audio-Capture (Opus/PCM), UI, Map Overlay, Local Cache. +* **Edge/API (Node 20/22):** + + * **ASR Service:** Whisper (lokal/hosted) + Alternativen; Streaming; VAD. + * **NLU/LLM Orchestrator:** Prompt-Builder (ATC-State, Airfield DB, ATIS/NOTAM optional), Tool-Use (Routing). + * **Logic Engine:** State Machine (GROUND/DEP/ENR/ARR), Validierung (Charts/Constraints), Readback-Checker. + * **TTS:** Neural Voices (multi-voice, rate/pitch). + * **Routing Service:** apt.dat/OSM Parser → Taxiway-Graph → A\*/Dijkstra → Segmente/Anweisungen. + * **Simulator Bridges:** MSFS SimConnect / X-Plane UDP/SDK Plugins. + * **Persistence:** MongoDB/Postgres (Sessions, User, Progress). + * **Telemetry:** Prometheus + Loki/Grafana. + +### Datenmodelle (Kurz) + +* **User** { email, plan, settings, progress } +* **Session** { id, callsign, icao, phase, events\[] } +* **Transcript** { sessionId, role: pilot/atc, text, ts, conf } +* **TaxiGraph** { icao, nodes\[], edges\[], hotspots\[] } +* **Clearance** { type, constraints, validity } +* **ATIS** { icao, info, rwys, wind, qnh, ts } + +### State Machine (Ausschnitt) + +* `GROUND_IDLE → REQUEST_TAXI → TAXI_ASSIGNED → TAXI_PROGRESS → HOLD_SHORT → LINE_UP → DEPARTURE_HANDOFF` +* Ereignisse: `PTT_START`, `ASR_PARTIAL`, `ASR_FINAL`, `NLU_INTENT`, `ROUTE_OK`, `VIOLATION`, `HANDOFF`. + +### Lernpfad + +* **Module:** Basics, Ground, Departure, Arrival, VATSIM. +* **Mechanik:** Prompt → Nachsprechen → Auto-Bewertung (Keywords + Fuzzy + Prosodie) → Feedback → Score/Badges. +* **Progress-Save:** pro Modul/Skill. + +### Limits / Pläne + +* **Self-host:** alles frei, eigene Keys. +* **Hosted Basic:** Fair-Use Audio-Minuten, Standard-Voices, Lernpfad. +* **Hosted Pro:** höhere Limits, Custom Voices, API, Team-Seats. + +--- + +## 8) Interaktions-Spezifikation (UI/UX Kern) + +### PTT-Flow + +1. Idle → User hält Taste/Btn. +2. `listening`: ASR Partial im TranscriptPane (grau), Levelmeter sichtbar. +3. `processing`: Spinner (VProgressCircular), „Verstehe…“. +4. `reply`: TTS spielt, Transcript ATC (blau) + Readback-Vorschlag. + +### Taxi-Overlay + +* Route in Cyan, Knoten/Gates beschriftet, Hotspots rot. +* Schrittweise Anweisungen („via A, A5, B2…“), „Next turn“ Callouts. +* Zoom-Preset: Ground 17–18, Smooth Pan. + +### Fehler/Violations + +* Soft-Warn (gelb) mit kurzer Guidance, bei Hard-Violations klare Stop-Anweisung. + +--- + +## 9) Vuetify-Komponenten – Einsatzrichtlinien + +* **VBtn:** Primary Aktionen. Größe konsistent (`height:44`, `rounded:'xl'`). Nur `color='primary'` oder `variant='outlined'` für sekundäre. +* **VCard:** Container für Inhalte/Lists/Forms. Keine erhöhte Elevation; stattdessen Border+Glass. +* **VIcon:** MDI einheitlich. Größe 20–28px je nach Kontext. +* **VTextField / VSelect:** abgerundet, `variant="outlined"`, dezente Hintergründe (`bg-white/5`). +* **VDialog / VMenu / VSheet:** sparsam, fokussiert. +* **VProgressCircular / VProgressLinear:** Status/Ladeanzeige, Primary-Farbe. +* **VGrid:** nur wenn komplexere Layouts nötig sind; sonst Tailwind Grid/Flex. + +**Wichtig:** Tailwind verantwortet Layout und visuelle Tokens; Vuetify liefert Verhalten/Accessibility der komplexen Controls. Keine konkurrierenden Styles (entweder über Defaults oder Utility-Klassen, nicht beides pro Eigenschaft). + +--- + +## 10) Karten & Icons + +* **Icon-Set:** Material Design Icons (mdi) – konsistente Semantik: + + * ATC/Funk: `mdi-radar`, `mdi-microphone`, `mdi-headset` + * Routing: `mdi-routes`, `mdi-map-marker-path` + * Lernen: `mdi-school`, `mdi-clipboard-check` + * System: `mdi-cog`, `mdi-console`, `mdi-docker` + +--- + +## 11) Textbausteine (Deutsch, kurz) + +* Disclaimer: „Nicht für reale Luftfahrt. Nur Flugsimulator/Training.“ +* VATSIM/IVAO: „Marken der jeweiligen Eigentümer.“ +* CTA: „Jetzt ausprobieren“, „Einladung anfordern“, „Repository ansehen“. + +--- + +## 12) Qualität & Testing + +* **Visual Regression:** Playwright + Screenshot-Baselines (Dark Mode). +* **A11y:** axe-checks in CI. +* **Perf:** Lighthouse ≥ 90, Bilder `nuxt/image` mit `format=webp,avif`, `loading=lazy`. +* **i18n:** En/De Keys, kein Hardcode in Komponenten. + +--- + +## 13) Beispiel: Section-Pattern (Vue) + +```vue +
+
+
+

Titel

+

Unterzeile…

+
+
+
+
+
+
+
+
+``` + +--- + +## 14) Tech-Stack & Envs + +* **Node:** 20/22, **Nuxt 4**, **Tailwind**, **Vuetify**, **AOS**. +* **Build:** Nitro SSR, Edge-friendly. +* **ENV:** `ASR_PROVIDER`, `LLM_PROVIDER`, `TTS_PROVIDER`, `MAPBOX_TOKEN` (optional), `DB_URI`, `SIM_BRIDGE_PORT`. +* **Self-host:** Docker Compose (api, router, asr, tts, db, web). + +--- + +## 15) API-Skizzen + +```http +POST /api/ptt/start // Session + Audio stream init (WebRTC/WS) +POST /api/route/taxi // { icao, from:{type,ref}, to:{type,ref} } → Segmente + Readback +GET /api/atis/:icao +GET /api/sessions/:id/transcripts +POST /api/learn/evaluate // { text, target } → score, hints +``` + +--- + +## 16) Lizenz & Recht + +* OSS: MIT oder Apache-2.0 (tbd). +* Marken/Disclaimer sichtbar im Footer. +* Datenschutz: keine persönlichen Sprachaufnahmen speichern ohne Opt-In; falls nötig → Pseudonymisierung. + +--- + +## 17) Roadmap (Kurz) + +1. **MVP:** PTT → ASR → NLU → TTS (Ground only) + Taxi-Routing EDDF/EGLL Demo. +2. Lernpfad **Basics/Ground**, Progress Save. +3. **Bridges** (MSFS/X-Plane), ATIS-Integration. +4. **Hosted Plan** mit Fair-Use, Billing. +5. **Pro**: Custom Voices, Team, API Tokens. + +--- + +## 18) Copy-Standards + +* Zahlen im Funk: „Tree“, „Fife“, „Niner“ optional als Lernmodus-Übersetzung. +* Numerik im UI normal (123), im TTS/Transcript ATC-konform. + +--- + +## 19) Beispiel: Pricing Card (Tailwind + Vuetify Defaults kompatibel) + +```html +
+
Empfohlen
+

Hosted – Basic

+

Alles fertig eingerichtet. Ideal zum Lernen & Üben.

+
4,00€ / Monat
+ + Kostenlos testen +
+``` + +--- + +## 20) Do’s & Don’ts + +* **Do:** wenige, klare Akzentfarben; konsistente Abstände; AOS sparsam. +* **Don’t:** Misch-Buttons (Vuetify & Tailwind Styles gleichzeitig überschreiben), harte Drop-Shadows, knallige Gradients ohne Blur. + 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/api/ptt/reply.post.ts b/server/api/ptt/reply.post.ts index 9cdfd12..2cb6270 100644 --- a/server/api/ptt/reply.post.ts +++ b/server/api/ptt/reply.post.ts @@ -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 } }; }); 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. +*/