mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-02 08:25:48 +08:00
refactor: update OpenAI TTS integration and cleanup imports
- index.vue: comment out cockpit simulator image - learn.vue: remove unused imports (useRadioTTS, learnModules) - atc/say.post.ts & utils/normalize.ts: rename openaiOld → normalize, adjust TTS calls, skip ensureDir/writeFile - communicationsEngine.ts: fix atcDecisionTree import path
This commit is contained in:
@@ -1,346 +0,0 @@
|
||||
// composables/radioTtsNew.ts
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface TTSOptions {
|
||||
level?: number
|
||||
voice?: string
|
||||
speed?: number
|
||||
moduleId?: string
|
||||
lessonId?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
moduleId: string
|
||||
lessonId: string
|
||||
phraseId?: string
|
||||
customVariables?: Record<string, string>
|
||||
type?: 'instruction' | 'clearance' | 'information' | 'request'
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface PTTOptions {
|
||||
expectedText: string
|
||||
moduleId: string
|
||||
lessonId: string
|
||||
format?: 'wav' | 'mp3' | 'ogg' | 'webm'
|
||||
}
|
||||
|
||||
interface AudioCache {
|
||||
[key: string]: {
|
||||
blob: Blob
|
||||
url: string
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function useRadioTTS() {
|
||||
const isLoading = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const audioCache = ref<AudioCache>({})
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let recordedChunks: Blob[] = []
|
||||
|
||||
// Cache Management
|
||||
const cacheKey = (text: string, options: TTSOptions) =>
|
||||
`${text}-${options.level || 4}-${options.voice || 'alloy'}-${options.speed || 1.0}`
|
||||
|
||||
const getCachedAudio = (key: string) => {
|
||||
const cached = audioCache.value[key]
|
||||
if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache
|
||||
return cached
|
||||
}
|
||||
if (cached) {
|
||||
URL.revokeObjectURL(cached.url)
|
||||
delete audioCache.value[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const setCachedAudio = (key: string, blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
audioCache.value[key] = {
|
||||
blob,
|
||||
url,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// Enhanced Server TTS with caching
|
||||
const speakServer = async (text: string, options: TTSOptions = {}) => {
|
||||
error.value = null
|
||||
|
||||
const key = cacheKey(text, options)
|
||||
const cached = getCachedAudio(key)
|
||||
|
||||
if (cached) {
|
||||
return playAudio(cached.url)
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/atc/say', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
text,
|
||||
level: options.level || 4,
|
||||
voice: options.voice || 'alloy',
|
||||
speed: options.speed || 1.0,
|
||||
moduleId: options.moduleId,
|
||||
lessonId: options.lessonId,
|
||||
tag: options.tag
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('TTS generation failed')
|
||||
}
|
||||
|
||||
// Convert base64 to blob and cache
|
||||
const audioData = atob(response.audio.base64)
|
||||
const audioArray = new Uint8Array(audioData.length)
|
||||
for (let i = 0; i < audioData.length; i++) {
|
||||
audioArray[i] = audioData.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([audioArray], { type: response.audio.mime })
|
||||
|
||||
const audioUrl = setCachedAudio(key, blob)
|
||||
await playAudio(audioUrl)
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'TTS failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Browser TTS (fallback)
|
||||
const speakBrowser = (text: string, options: { voice?: string; rate?: number; pitch?: number } = {}) => {
|
||||
if (!window.speechSynthesis) {
|
||||
error.value = 'Browser TTS not supported'
|
||||
return
|
||||
}
|
||||
|
||||
stop() // Stop any current speech
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
if (options.voice) {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
const voice = voices.find(v => v.name.includes(options.voice!))
|
||||
if (voice) utterance.voice = voice
|
||||
}
|
||||
|
||||
utterance.rate = options.rate || 0.9
|
||||
utterance.pitch = options.pitch || 1.0
|
||||
utterance.volume = 1.0
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
error.value = `TTS error: ${event.error}`
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
// Generate ATC phrases
|
||||
const generatePhrase = async (options: GenerateOptions) => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/atc/generate', {
|
||||
method: 'POST',
|
||||
body: options
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('Phrase generation failed')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Phrase generation failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// PTT (Push-to-Talk) Recording
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return
|
||||
|
||||
error.value = null
|
||||
recordedChunks = []
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
isRecording.value = true
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Recording failed'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = async (): Promise<Blob | null> => {
|
||||
if (!isRecording.value || !mediaRecorder) return null
|
||||
|
||||
return new Promise((resolve) => {
|
||||
mediaRecorder!.onstop = () => {
|
||||
const blob = new Blob(recordedChunks, { type: 'audio/webm' })
|
||||
|
||||
// Stop all tracks
|
||||
mediaRecorder!.stream.getTracks().forEach(track => track.stop())
|
||||
mediaRecorder = null
|
||||
isRecording.value = false
|
||||
|
||||
resolve(blob)
|
||||
}
|
||||
|
||||
mediaRecorder!.stop()
|
||||
})
|
||||
}
|
||||
|
||||
// Submit PTT for evaluation
|
||||
const submitPTT = async (audioBlob: Blob, options: PTTOptions) => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Convert blob to base64
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
|
||||
|
||||
const response = await $fetch('/api/atc/ptt', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
audio: base64,
|
||||
expectedText: options.expectedText,
|
||||
moduleId: options.moduleId,
|
||||
lessonId: options.lessonId,
|
||||
format: options.format || 'webm'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('PTT evaluation failed')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'PTT evaluation failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Audio playback
|
||||
const playAudio = async (audioUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stop() // Stop any current audio
|
||||
|
||||
currentAudio = new Audio(audioUrl)
|
||||
currentAudio.volume = 1.0
|
||||
|
||||
currentAudio.addEventListener('ended', () => resolve())
|
||||
currentAudio.addEventListener('error', (e) => {
|
||||
error.value = 'Audio playback failed'
|
||||
reject(e)
|
||||
})
|
||||
|
||||
currentAudio.play().catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
// Stop TTS
|
||||
if (window.speechSynthesis) {
|
||||
speechSynthesis.cancel()
|
||||
}
|
||||
|
||||
// Stop audio playback
|
||||
if (currentAudio) {
|
||||
currentAudio.pause()
|
||||
currentAudio.currentTime = 0
|
||||
currentAudio = null
|
||||
}
|
||||
|
||||
// Stop recording
|
||||
if (isRecording.value && mediaRecorder) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
const cleanup = () => {
|
||||
stop()
|
||||
|
||||
// Clear cache URLs
|
||||
Object.values(audioCache.value).forEach(cached => {
|
||||
URL.revokeObjectURL(cached.url)
|
||||
})
|
||||
audioCache.value = {}
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading: readonly(isLoading),
|
||||
isRecording: readonly(isRecording),
|
||||
error: readonly(error),
|
||||
|
||||
// TTS Methods
|
||||
speakServer,
|
||||
speakBrowser,
|
||||
|
||||
// Phrase Generation
|
||||
generatePhrase,
|
||||
|
||||
// PTT Methods
|
||||
startRecording,
|
||||
stopRecording,
|
||||
submitPTT,
|
||||
|
||||
// Control
|
||||
stop,
|
||||
cleanup,
|
||||
|
||||
// Utilities
|
||||
playAudio
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="hidden mt-10 md:mt-16" data-aos="zoom-in" data-aos-delay="100">
|
||||
<div class="card relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-cyan-500/10 via-blue-500/5 to-transparent"></div>
|
||||
<img src="/img/simulator.jpg" alt="Cockpit" class="rounded-xl w-full object-cover" />
|
||||
<!-- <img src="/img/simulator.jpg" alt="Cockpit" class="rounded-xl w-full object-cover" />-->
|
||||
<div class="absolute bottom-3 right-3 text-xs text-white/70 bg-black/40 px-2 py-1 rounded">Symbolbild</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -432,8 +432,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import useRadioTTS from "../composables/radioTtsNew";
|
||||
import learnModules, {Lesson, ModuleDef} from "../composables/learnModules";
|
||||
|
||||
/** AUDIO **/
|
||||
const tts = useRadioTTS()
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
[
|
||||
{
|
||||
"frequency": "Delivery",
|
||||
"action": "Flight Plan",
|
||||
"pilot": "Delivery, good day, ${callsign} at stand ${gate}, with information ${atis}, requesting clearance to ${destination}",
|
||||
"atc": "${callsign}, ${airport} Delivery, good day, clearance is to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}",
|
||||
"pilotResponse": "Cleared to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}, ${callsign}"
|
||||
"atcResponse": "${callsign}, readback is correct, report ready for startup"
|
||||
},
|
||||
{
|
||||
"frequency": "Startup",
|
||||
"action": "Clearance",
|
||||
"pilot": "${callsign} is ready for startup.",
|
||||
"atc": "${callsign}, roger, startup is approved, for the pushback contact ground on ${groundFreq}",
|
||||
"pilotResponse": "Startup approved and contact ground on ${groundFreq} for the pushback, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Pushback",
|
||||
"pilot": "Ground, good day, ${callsign} at stand ${gate}, requesting pushback",
|
||||
"atc": "${callsign}, ${airport} Ground, good day, pushback is approved.",
|
||||
"pilotResponse": "Pushback approved, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Taxi",
|
||||
"action": "Taxiing",
|
||||
"pilot": "${callsign}, request taxi",
|
||||
"atc": "${callsign}, taxi to ${holdingPointOrRunway} via ${taxiRoute}",
|
||||
"pilotResponse": "Taxi to ${holdingPointOrRunway} via ${taxiRoute}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Give way to airplane",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, give way to the ${otherAirline} ${otherType} from the ${direction}",
|
||||
"pilotResponse": "Give way to the ${otherAirline} ${otherType} from the ${direction}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, at ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "At ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Lineup and Wait",
|
||||
"pilot": "${airport} Tower, good day, ${callsign} at ${holdingPoint}, ready for departure",
|
||||
"atc": "${callsign}, ${airport} Tower, good day, line up and wait runway ${runway}",
|
||||
"pilotResponse": "Line up and wait runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Takeoff Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared for takeoff.",
|
||||
"pilotResponse": "Cleared for takeoff runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Handoff to Departure",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${airport} Departure on ${depFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${airport} Departure on ${depFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Contacting Departure",
|
||||
"pilot": "${airport} Departure, good day, ${callsign}, passing ${altitude}, ${depRouteOrHeading}",
|
||||
"atc": "${callsign}, ${airport} Departure, identified, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Direct Route",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, direct to ${vorFix}",
|
||||
"pilotResponse": "Direct ${vorFix}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Handoff to Nearest Center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nearestCenter} on ${centerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nearestCenter} on ${centerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting Center",
|
||||
"pilot": "${nearestCenter}, good day, ${callsign}, passing ${altitude}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, ${nearestCenter}, radar contact, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Descent",
|
||||
"action": "Request Descent",
|
||||
"pilot": "${callsign}, request descent",
|
||||
"atc": "${callsign}, descend to ${flightLevel}",
|
||||
"pilotResponse": "Descending to ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Handoff to another center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nextCenter} on ${nextCenterFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nextCenter} on ${nextCenterFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting another center and getting an approach",
|
||||
"pilot": "${nextCenter}, good day, ${callsign}, passing ${altitude} for ${flightLevel}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, good day, radar contact, ${star}, expect ${approachType} runway ${runway}, descend ${flightLevel}",
|
||||
"pilotResponse": "${star}, ${approachType} runway ${runway} and descend ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Handoff to Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Approach on ${approachFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Approach on ${approachFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Contacting Approach",
|
||||
"pilot": "${arrivalAirport} Approach, good day, ${callsign}, ${flightLevel}, ${star}",
|
||||
"atc": "${callsign}, ${arrivalAirport} Approach, good day, continue approach",
|
||||
"pilotResponse": "Continue approach, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, descend ${flightLevel} and after ${vorFix} fly heading ${heading}",
|
||||
"pilotResponse": "Descend ${flightLevel} and after ${vorFix} fly heading ${heading}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 2",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, fly heading ${heading}, descend ${flightLevel}, QNH ${qnh}",
|
||||
"pilotResponse": "Fly heading ${heading}, descend ${flightLevel} on QNH ${qnh}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 3",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, speed ${speed}",
|
||||
"pilotResponse": "Left heading ${heading} and speed ${speed}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Cleared Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, cleared ${approachType} runway ${runway}",
|
||||
"pilotResponse": "Turn left heading ${heading}, cleared ${approachType} runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Tower on ${towerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Contacting Tower",
|
||||
"pilot": "${arrivalAirport} Tower, ${callsign}, ${approachType} runway ${runway}",
|
||||
"atc": "${callsign}, good day, ${sequenceInfo}",
|
||||
"pilotResponse": ""
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Landing Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared to land",
|
||||
"pilotResponse": "Cleared to land runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Taxiing (Arrival)",
|
||||
"pilot": "${callsign}, ${arrivalAirport} Ground, taxi to stand ${stand} via ${taxiRoute}",
|
||||
"atc": "Taxi to stand ${stand} via ${taxiRoute}, ${callsign}",
|
||||
"pilotResponse": ""
|
||||
}
|
||||
]
|
||||
@@ -1,477 +0,0 @@
|
||||
# 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
|
||||
<a class="btn btn-primary">...</a>
|
||||
<!-- Mapping -->
|
||||
.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
|
||||
<section class="py-16 md:py-24 bg-[var(--brand-bg2)] border-t border-white/10" data-aos="fade-up">
|
||||
<div class="container-outer">
|
||||
<div class="max-w-2xl mb-10">
|
||||
<h2 class="text-3xl md:text-4xl font-semibold">Titel</h2>
|
||||
<p class="mt-3 text-white/80">Unterzeile…</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="card">…</div>
|
||||
<div class="card">…</div>
|
||||
<div class="card">…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
<div class="card border-2 border-cyan-400/40 shadow-[0_0_40px_rgba(34,211,238,.25)]">
|
||||
<div class="chip bg-cyan-500/30 border-cyan-400/50 -mt-5 float-right">Empfohlen</div>
|
||||
<h3 class="text-xl font-semibold">Hosted – Basic</h3>
|
||||
<p class="mt-2 text-white/80">Alles fertig eingerichtet. Ideal zum Lernen & Üben.</p>
|
||||
<div class="mt-5 text-3xl font-semibold">4,00€ <span class="text-white/60 text-sm font-normal">/ Monat</span></div>
|
||||
<ul class="mt-5 space-y-2 text-white/80 text-sm">
|
||||
<li>✔ Fair-Use Audio-Minuten</li>
|
||||
<li>✔ Lernpfad & Fortschritt</li>
|
||||
<li>✔ Updates & Cloud-Scaling</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary w-full mt-6">Kostenlos testen</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 266 KiB |
@@ -1,2 +0,0 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -1,110 +0,0 @@
|
||||
// server/api/atc/audio/[...path].get.ts
|
||||
import { createError } from "h3";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
function outDir() {
|
||||
const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
|
||||
return base;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = getRouterParam(event, 'path');
|
||||
|
||||
if (!path || typeof path !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid path"
|
||||
});
|
||||
}
|
||||
|
||||
// Sicherheitscheck: verhindere Directory Traversal
|
||||
if (path.includes('..') || path.includes('/./') || path.startsWith('/')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid path"
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = join(outDir(), path);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Audio file not found"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Path is not a file"
|
||||
});
|
||||
}
|
||||
|
||||
// Nur Audio-Dateien servieren
|
||||
const allowedExtensions = ['.wav', '.mp3', '.ogg'];
|
||||
const hasValidExtension = allowedExtensions.some(ext => filePath.toLowerCase().endsWith(ext));
|
||||
|
||||
if (!hasValidExtension) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Not an audio file"
|
||||
});
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath);
|
||||
|
||||
// MIME Type basierend auf Dateiendung
|
||||
let mimeType = 'audio/ogg';
|
||||
if (filePath.endsWith('.wav')) {
|
||||
mimeType = 'audio/wav';
|
||||
} else if (filePath.endsWith('.mp3')) {
|
||||
mimeType = 'audio/mpeg';
|
||||
} else if (filePath.endsWith('.ogg')) {
|
||||
mimeType = 'audio/ogg; codecs=opus';
|
||||
}
|
||||
|
||||
// HTTP Headers für Audio-Streaming
|
||||
setHeader(event, 'Content-Type', mimeType);
|
||||
setHeader(event, 'Content-Length', stats.size.toString());
|
||||
setHeader(event, 'Accept-Ranges', 'bytes');
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=3600'); // 1 Stunde Cache
|
||||
setHeader(event, 'Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Range-Request Support für Audio-Seeking
|
||||
const range = getHeader(event, 'range');
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
|
||||
|
||||
if (start >= stats.size || end >= stats.size) {
|
||||
setResponseStatus(event, 416); // Range Not Satisfiable
|
||||
setHeader(event, 'Content-Range', `bytes */${stats.size}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const chunkSize = (end - start) + 1;
|
||||
const chunk = fileBuffer.slice(start, end + 1);
|
||||
|
||||
setResponseStatus(event, 206); // Partial Content
|
||||
setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`);
|
||||
setHeader(event, 'Content-Length', chunkSize.toString());
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
return fileBuffer;
|
||||
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to serve audio file: ${error}`
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// server/api/atc/generate.post.ts
|
||||
import { createError, readBody } from "h3";
|
||||
import { generateATCPhrase, getRandomPhraseForLesson, getPhrasesForLesson } from "../../utils/atcPhrases";
|
||||
import { normalizeATC } from "../../utils/openaiOld";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
phraseId?: string;
|
||||
customVariables?: Record<string, string>;
|
||||
type?: 'instruction' | 'clearance' | 'information' | 'request';
|
||||
count?: number;
|
||||
}>(event);
|
||||
|
||||
const { moduleId, lessonId, phraseId, customVariables, type, count = 1 } = body;
|
||||
|
||||
if (!moduleId || !lessonId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "moduleId and lessonId are required"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let phrases: string[] = [];
|
||||
|
||||
if (phraseId) {
|
||||
// Spezifische Phrase generieren
|
||||
const phrase = generateATCPhrase(phraseId, customVariables);
|
||||
phrases.push(phrase);
|
||||
} else {
|
||||
// Zufällige Phrasen für das Modul/Lektion generieren
|
||||
const availablePhrases = getPhrasesForLesson(moduleId, lessonId);
|
||||
|
||||
if (availablePhrases.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `No phrases found for module "${moduleId}", lesson "${lessonId}"`
|
||||
});
|
||||
}
|
||||
|
||||
// Filter nach Typ wenn angegeben
|
||||
const filteredPhrases = type
|
||||
? availablePhrases.filter(p => p.type === type)
|
||||
: availablePhrases;
|
||||
|
||||
if (filteredPhrases.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `No phrases of type "${type}" found for module "${moduleId}", lesson "${lessonId}"`
|
||||
});
|
||||
}
|
||||
|
||||
// Generiere die gewünschte Anzahl von Phrasen
|
||||
for (let i = 0; i < Math.min(count, 10); i++) { // Max 10 Phrasen pro Request
|
||||
const randomPhrase = filteredPhrases[Math.floor(Math.random() * filteredPhrases.length)];
|
||||
const generated = generateATCPhrase(randomPhrase.id, customVariables);
|
||||
phrases.push(generated);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalisiere alle Phrasen für TTS
|
||||
const normalizedPhrases = phrases.map(phrase => ({
|
||||
original: phrase,
|
||||
normalized: normalizeATC(phrase),
|
||||
length: phrase.length
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
moduleId,
|
||||
lessonId,
|
||||
type: type || 'any',
|
||||
count: phrases.length,
|
||||
phrases: normalizedPhrases,
|
||||
availableTypes: getPhrasesForLesson(moduleId, lessonId)
|
||||
.map(p => p.type)
|
||||
.filter((type, index, arr) => arr.indexOf(type) === index), // Unique types
|
||||
meta: {
|
||||
totalAvailablePhrases: getPhrasesForLesson(moduleId, lessonId).length,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
if (error.statusCode) {
|
||||
throw error; // Re-throw HTTP errors
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Phrase generation failed: ${error}`
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {writeFile, mkdir} from "node:fs/promises";
|
||||
import {existsSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {openaiOld, TTS_MODEL, normalizeATC} from "../../utils/openaiOld";
|
||||
import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
|
||||
import {request} from "node:http";
|
||||
|
||||
// dotenv config
|
||||
@@ -100,7 +100,7 @@ export default defineEventHandler(async (event) => {
|
||||
audioBuffer = await piperTTS(normalized, voice);
|
||||
} else {
|
||||
// --- OpenAI Fallback ---
|
||||
const tts = await openaiOld.audio.speech.create({
|
||||
const tts = await normalize.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
format: "wav",
|
||||
@@ -110,7 +110,7 @@ export default defineEventHandler(async (event) => {
|
||||
audioBuffer = Buffer.from(await tts.arrayBuffer());
|
||||
}
|
||||
|
||||
await ensureDir(baseDir);
|
||||
// await ensureDir(baseDir);
|
||||
// await writeFile(fileWav, audioBuffer);
|
||||
|
||||
const meta = {
|
||||
@@ -130,7 +130,7 @@ export default defineEventHandler(async (event) => {
|
||||
format: "audio/wav"
|
||||
};
|
||||
|
||||
await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
// server/utils/atcPhrases.ts
|
||||
export interface ATCPhrase {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
type: 'instruction' | 'clearance' | 'information' | 'request';
|
||||
template: string;
|
||||
variables?: Record<string, string[]>;
|
||||
context?: {
|
||||
airport?: string;
|
||||
runway?: string;
|
||||
frequency?: string;
|
||||
callsign?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ATC_PHRASES: ATCPhrase[] = [
|
||||
// ICAO Alphabet Module
|
||||
{
|
||||
id: 'icao_alpha_drill',
|
||||
moduleId: 'icao',
|
||||
lessonId: 'alpha',
|
||||
type: 'instruction',
|
||||
template: 'Spell your callsign using phonetic alphabet from {start} to {end}',
|
||||
variables: {
|
||||
start: ['Alpha', 'Bravo', 'Charlie'],
|
||||
end: ['Lima', 'Mike', 'November']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'icao_numbers_drill',
|
||||
moduleId: 'icao',
|
||||
lessonId: 'numbers',
|
||||
type: 'instruction',
|
||||
template: 'Read back transponder code {squawk}',
|
||||
variables: {
|
||||
squawk: ['1234', '4567', '7321', '2156', '6543']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'icao_callsign_spell',
|
||||
moduleId: 'icao',
|
||||
lessonId: 'callsign-icao',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, spell your callsign',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789', 'KLM321', 'RYR654']
|
||||
}
|
||||
},
|
||||
|
||||
// Basics Module
|
||||
{
|
||||
id: 'ground_checkin',
|
||||
moduleId: 'basics',
|
||||
lessonId: 'checkin',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, {ground_station}, stand {stand} available, taxi when ready',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
ground_station: ['Frankfurt Ground', 'Munich Ground', 'Berlin Ground'],
|
||||
stand: ['A12', 'B24', 'C15', 'V155', 'G23']
|
||||
},
|
||||
context: {
|
||||
airport: 'EDDF'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'basic_readback',
|
||||
moduleId: 'basics',
|
||||
lessonId: 'readback',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, contact Tower on {frequency}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
frequency: ['118.500', '119.900', '121.700', '124.850']
|
||||
}
|
||||
},
|
||||
|
||||
// Ground Operations
|
||||
{
|
||||
id: 'taxi_clearance_simple',
|
||||
moduleId: 'ground',
|
||||
lessonId: 'taxi1',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, taxi to runway {runway} via {taxiway}, hold short runway {runway}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789', 'EZY234'],
|
||||
runway: ['25R', '25L', '07R', '07L', '18', '36'],
|
||||
taxiway: ['A A5 B2', 'C C3 A', 'A A7 N N4', 'B B1 A3']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'taxi_clearance_complex',
|
||||
moduleId: 'ground',
|
||||
lessonId: 'taxi1',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, taxi to runway {runway} via {route}, hold short runway {hold_runway}',
|
||||
variables: {
|
||||
callsign: ['DLH359', 'BAW12A', 'AFR567'],
|
||||
runway: ['25R', '25L', '07R'],
|
||||
route: ['A A3 B B1', 'C C5 A A7', 'M M1 A A5 B'],
|
||||
hold_runway: ['25R', '25L', '07R', '18']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'handoff_tower',
|
||||
moduleId: 'ground',
|
||||
lessonId: 'handoff',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, contact Tower on {frequency}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
frequency: ['118.500', '119.900', '121.700', '124.850', '132.025']
|
||||
}
|
||||
},
|
||||
|
||||
// Departure Operations
|
||||
{
|
||||
id: 'lineup_wait',
|
||||
moduleId: 'departure',
|
||||
lessonId: 'lineup',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, line up and wait runway {runway}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
runway: ['25R', '25L', '07R', '07L', '18', '36']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'takeoff_clearance',
|
||||
moduleId: 'departure',
|
||||
lessonId: 'lineup',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, runway {runway}, cleared for takeoff',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
runway: ['25R', '25L', '07R', '07L']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'departure_instructions',
|
||||
moduleId: 'departure',
|
||||
lessonId: 'lineup',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, after takeoff turn {direction} heading {heading}, contact Departure on {frequency}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
direction: ['left', 'right'],
|
||||
heading: ['090', '180', '270', '360', '045', '135', '225', '315'],
|
||||
frequency: ['121.200', '125.750', '127.275', '135.725']
|
||||
}
|
||||
},
|
||||
|
||||
// Arrival Operations
|
||||
{
|
||||
id: 'landing_clearance',
|
||||
moduleId: 'arrival',
|
||||
lessonId: 'vacate',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, runway {runway}, cleared to land',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
runway: ['25R', '25L', '07R', '07L']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vacate_instruction',
|
||||
moduleId: 'arrival',
|
||||
lessonId: 'vacate',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, vacate runway {runway} via {taxiway}, contact Ground on {frequency}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
runway: ['25R', '25L', '07R', '07L'],
|
||||
taxiway: ['A6', 'A7', 'B3', 'C4', 'N2'],
|
||||
frequency: ['121.800', '121.900', '129.725']
|
||||
}
|
||||
},
|
||||
|
||||
// VATSIM Operations
|
||||
{
|
||||
id: 'ifr_clearance',
|
||||
moduleId: 'vatsim',
|
||||
lessonId: 'checkin',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, cleared to {destination} via {sid}, initial climb {altitude}, squawk {squawk}',
|
||||
variables: {
|
||||
callsign: ['DLH359', 'BAW12A', 'AFR567'],
|
||||
destination: ['EHAM', 'EGLL', 'LFPG', 'LEMD', 'LIRF'],
|
||||
sid: ['MARUN7F', 'BIBTI7F', 'CHA7F', 'SOBRA7F'],
|
||||
altitude: ['5000 feet', '6000 feet', 'FL070', 'FL080'],
|
||||
squawk: ['4723', '1234', '5647', '7321']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'startup_clearance',
|
||||
moduleId: 'vatsim',
|
||||
lessonId: 'checkin',
|
||||
type: 'clearance',
|
||||
template: '{callsign}, startup approved, {atis_info} current, expect runway {runway}',
|
||||
variables: {
|
||||
callsign: ['DLH359', 'BAW12A', 'AFR567'],
|
||||
atis_info: ['information Alpha', 'information Bravo', 'information Charlie'],
|
||||
runway: ['25R', '25L', '07R', '07L']
|
||||
}
|
||||
},
|
||||
|
||||
// Emergency/Special Situations
|
||||
{
|
||||
id: 'traffic_info',
|
||||
moduleId: 'ground',
|
||||
lessonId: 'taxi1',
|
||||
type: 'information',
|
||||
template: '{callsign}, traffic {direction}, {aircraft_type} {distance}',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789'],
|
||||
direction: ['ahead', 'behind', 'left', 'right', 'crossing'],
|
||||
aircraft_type: ['A320', 'B737', 'A380', 'B777'],
|
||||
distance: ['100 meters', '200 meters', '500 meters']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hold_position',
|
||||
moduleId: 'ground',
|
||||
lessonId: 'taxi1',
|
||||
type: 'instruction',
|
||||
template: '{callsign}, hold position, traffic crossing',
|
||||
variables: {
|
||||
callsign: ['DLH123', 'BAW456', 'AFR789']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Hilfsfunktionen für Template-Verarbeitung
|
||||
export function generateATCPhrase(phraseId: string, customVariables?: Record<string, string>): string {
|
||||
const phrase = ATC_PHRASES.find(p => p.id === phraseId);
|
||||
if (!phrase) {
|
||||
throw new Error(`ATC phrase with id "${phraseId}" not found`);
|
||||
}
|
||||
|
||||
let result = phrase.template;
|
||||
const variables = phrase.variables || {};
|
||||
|
||||
// Ersetze Variablen im Template
|
||||
for (const [key, values] of Object.entries(variables)) {
|
||||
const placeholder = `{${key}}`;
|
||||
if (result.includes(placeholder)) {
|
||||
// Verwende custom value oder wähle zufällig
|
||||
const value = customVariables?.[key] || values[Math.floor(Math.random() * values.length)];
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getPhrasesForLesson(moduleId: string, lessonId: string): ATCPhrase[] {
|
||||
return ATC_PHRASES.filter(p => p.moduleId === moduleId && p.lessonId === lessonId);
|
||||
}
|
||||
|
||||
export function getRandomPhraseForLesson(moduleId: string, lessonId: string): string {
|
||||
const phrases = getPhrasesForLesson(moduleId, lessonId);
|
||||
if (phrases.length === 0) {
|
||||
throw new Error(`No phrases found for module "${moduleId}", lesson "${lessonId}"`);
|
||||
}
|
||||
|
||||
const randomPhrase = phrases[Math.floor(Math.random() * phrases.length)];
|
||||
return generateATCPhrase(randomPhrase.id);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const openaiOld = new OpenAI({
|
||||
export const normalize = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
project: process.env.OPENAI_PROJECT, // optional
|
||||
});
|
||||
@@ -251,7 +251,7 @@ export function normalizeATC(
|
||||
// TTS Wrapper (mp3)
|
||||
export async function speakATC(text: string, filePath = "atc.mp3") {
|
||||
const input = normalizeATC(text);
|
||||
const resp = await (openaiOld as any).audio.speech.create({
|
||||
const resp = await (normalize as any).audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice: "alloy",
|
||||
input,
|
||||
@@ -1,6 +1,6 @@
|
||||
// composables/communicationsEngine.ts
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import atcDecisionTree from "./atcDecisionTree";
|
||||
import atcDecisionTree from "../data/atcDecisionTree";
|
||||
|
||||
// --- DecisionTree-Types (aus ~/data/atcDecisionTree.json abgeleitet) ---
|
||||
type Role = 'pilot' | 'atc' | 'system'
|
||||
|
||||
Reference in New Issue
Block a user