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 @@
+
+
+ ATC Demo (ohne PTT)
+
+
+
+
+ Verständigungslevel:
+ Stufe {{ radioLevel }}
+
+
+
+ 1 = sehr schlecht (Rauschen/Dropouts) · 4 = gut (Default) · 5 = klar
+
+
+
+
+ ATC erzeugen & abspielen (Radio, Stufe {{ radioLevel }})
+
+
+
+
Status: {{ status }}
+
+ Server-Level bestätigt: {{ returnedLevel }}
+
+
+ ATC Text: {{ atcText }}
+
+
+
+
+
+
Clean (TTS):
+
+
Play Clean
+
+
+
Radio (Funk-Effekt):
+
+
Play Radio
+
+
+
+
+
+
+
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 @@
ATC Push-to-Talk
+
-
+
{{ recording ? 'Recording…' : 'Push-to-Talk' }}
- → Text
- → TTS
+
Status: {{ status }}
Pilot: {{ pilotText }}
ATC: {{ replyText }}
+
+
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
+
+```
+
+---
+
+## 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
+
+ - ✔ Fair-Use Audio-Minuten
+ - ✔ Lernpfad & Fortschritt
+ - ✔ Updates & Cloud-Scaling
+
+
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.
+*/