claude änderungen

This commit is contained in:
itsrubberduck
2025-09-15 11:53:50 +02:00
parent 4bd7a58953
commit bb2e3bd211
9 changed files with 1071 additions and 308 deletions

Binary file not shown.

View File

@@ -306,20 +306,68 @@ import learnModules, {Lesson, ModuleDef} from "../../composables/learnModules";
/** AUDIO **/
const tts = useRadioTTS()
// Server TTS verwenden
function speak(text: string) {
if (cfg.value.tts) {
tts.speakBrowser(text)
} else {
tts.speakServer(text, {level: cfg.value.radioLevel, voice: cfg.value.voice || 'alloy'})
tts.speakServer(text, {
level: cfg.value.radioLevel,
voice: cfg.value.voice || 'alloy',
moduleId: current.value?.id,
lessonId: activeLesson.value?.id
})
}
}
// PTT-Button hinzufügen (optional)
const handlePTT = async () => {
if (tts.isRecording.value) {
const audioBlob = await tts.stopRecording()
if (audioBlob && activeLesson.value) {
const result = await tts.submitPTT(audioBlob, {
expectedText: activeLesson.value.target,
moduleId: current.value!.id,
lessonId: activeLesson.value.id
})
// Bewertung anzeigen
console.log('PTT Score:', result.evaluation.score)
if (result.playAgain) {
speak(activeLesson.value.target) // Nochmal abspielen
}
}
} else {
await tts.startRecording()
}
}
// Zufällige Phrase für Lektion generieren
const generateRandomPhrase = async () => {
if (current.value && activeLesson.value) {
const response = await tts.generatePhrase({
moduleId: current.value.id,
lessonId: activeLesson.value.id,
type: 'instruction'
})
// Generierte Phrase abspielen
if (response.phrases[0]) {
speak(response.phrases[0].original)
}
}
}
onMounted(() => {
// Browser benötigt Mikrofon-Berechtigung
navigator.mediaDevices.getUserMedia({audio: true})
})
function stopAudio() {
tts.stop()
}
/** STATE **/
const panel = ref<'hub' | 'module' | 'progress'>('hub')
const current = ref<ModuleDef | null>(null)
@@ -617,6 +665,7 @@ function testBeep() {
display: inline-flex;
align-items: center
}
.icon-btn:hover {
background: color-mix(in srgb, var(--text) 6%, transparent);
transform: scale(1.05);

View File

@@ -1,43 +1,346 @@
export type RadioOpts = {
level?: number; // 1..5
voice?: string; // z.B. "alloy"
};
// 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() {
let audio: HTMLAudioElement | null = null;
const isLoading = ref(false)
const isRecording = ref(false)
const error = ref<string | null>(null)
const audioCache = ref<AudioCache>({})
function stop() {
if (audio) {
audio.pause();
audio.src = "";
audio.load();
audio = null;
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
}
}
function speakBrowser(text: string) {
stop();
const u = new SpeechSynthesisUtterance(text);
u.lang = "en-US";
u.rate = 1;
u.pitch = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(u);
return u;
// 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)
}
function speakServer(text: string, opts: RadioOpts = {}) {
stop();
const params = new URLSearchParams({
text,
level: String(opts.level ?? 4),
voice: String(opts.voice ?? "alloy"),
});
audio = new Audio(`/api/atc/say.stream?${params.toString()}`);
audio.autoplay = true;
audio.play().catch(() => {/* ignore */});
return audio;
// 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
}
}
return { speakBrowser, speakServer, stop };
// 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
}
}

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@nuxtjs/tailwindcss": "6.14.0",
"dotenv": "^17.2.2",
"fluent-ffmpeg": "^2.1.3",
"nuxt": "^4.1.1",
"nuxt-aos": "1.2.5",
"openai": "^4.66.0",
@@ -22,6 +23,11 @@
"vuetify": "3.9.0-beta.1",
"vuetify-nuxt-module": "0.18.7"
},
"engines": { "node": "22.x" },
"packageManager": "yarn@4.9.4"
"engines": {
"node": "22.x"
},
"packageManager": "yarn@4.9.4",
"devDependencies": {
"@types/fluent-ffmpeg": "^2"
}
}

View File

@@ -0,0 +1,110 @@
// 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 = ['.ogg', '.wav', '.mp3'];
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}`
});
}
});

View File

@@ -1,161 +1,96 @@
import { createError, getQuery } from "h3";
import {openai, LLM_MODEL, TTS_MODEL, atcSeedPrompt, normalizeATC} from "../../utils/openai";
import { writeFile, readFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
// ffmpeg-Filter je Qualitätsstufe (1..5) -> final label [out]
function buildRadioFilter(level: number) {
const L = Math.max(1, Math.min(5, Math.floor(level || 4)));
const dropout = (period: number, dur: number) =>
`volume=enable='lt(mod(t\\,${period})\\,${dur})':volume=0`;
const band = (hp: number, lp: number, gain = 6) =>
`highpass=f=${hp},lowpass=f=${lp},compand=attacks=0.02:decays=0.25:points=-80/-900|-70/-20|0/-10|20/-8:gain=${gain}`;
const tail = `aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200`;
// Hilfsbausteine als vollständige Kettenglieder mit Semikolons/Labels
const mkCrushMix = (bits: number, mix = 0.25, inLabel = "pre", outLabel = "mix1") => [
`[${inLabel}]asplit=2[clean][toCrush]`,
`[toCrush]acrusher=bits=${bits}:mix=1[crushed]`,
`[clean][crushed]amix=inputs=2:weights=1 ${mix}:duration=shortest[${outLabel}]`,
];
const mkNoiseMix = (amp: number, inLabel = "mix1", outLabel = "mix2") => [
`anoisesrc=color=white:amplitude=${amp}[ns]`,
`[${inLabel}][ns]amix=inputs=2:weights=1 0.25:duration=shortest[${outLabel}]`,
];
const mkDropTail = (inLabel: string, period: number | null, dur: number | null) => {
const chain = [];
if (period && dur) chain.push(`[${inLabel}]${dropout(period, dur)}[drop]`);
const src = period && dur ? "drop" : inLabel;
chain.push(`[${src}]${tail}[out]`);
return chain;
};
if (L === 5) {
// Dein Original, nur gelabelt bis [out]
return [
`[0:a]${band(300,3400)},volume=1.2[a]`,
`anoisesrc=color=white:amplitude=0.02[ns]`,
`[a][ns]amix=inputs=2:weights=1 0.25:duration=shortest[mix]`,
`[mix]${tail}[out]`,
].join(";");
}
if (L === 4) {
return [
`[0:a]${band(320,3300)},volume=1.15[pre]`,
...mkNoiseMix(0.03, "pre", "mix"),
`[mix]${tail}[out]`,
].join(";");
}
if (L === 3) {
return [
`[0:a]${band(350,3200)},volume=1.1[pre]`,
...mkCrushMix(12, 0.22, "pre", "mix1"),
...mkNoiseMix(0.05, "mix1", "mix2"),
...mkDropTail("mix2", 6, 0.06),
].join(";");
}
if (L === 2) {
return [
`[0:a]${band(400,3000)},volume=1.05[pre]`,
...mkCrushMix(10, 0.32, "pre", "mix1"),
// mehr Noise-Gewichtung beim zweiten Mix
`anoisesrc=color=white:amplitude=0.08[ns]`,
`[mix1][ns]amix=inputs=2:weights=1 0.6:duration=shortest[mix2]`,
...mkDropTail("mix2", 4.5, 0.12),
].join(";");
}
// L === 1
return [
`[0:a]${band(500,2600,5)},volume=1.0[pre]`,
...mkCrushMix(8, 0.45, "pre", "mix1"),
`anoisesrc=color=white:amplitude=0.12[ns]`,
`[mix1][ns]amix=inputs=2:weights=1 0.8:duration=shortest[mix2]`,
...mkDropTail("mix2", 3.5, 0.2),
].join(";");
}
// Funk-Effekt mit ffmpeg (fix: -map [out], kein "[post]?0:a")
async function applyRadioEffect(input: string, output: string, level = 4) {
const filter = buildRadioFilter(level);
await new Promise<void>((res, rej) =>
execFile(
"ffmpeg",
["-y", "-i", input, "-filter_complex", filter, "-map", "[out]", "-ar", "16000", output],
(err, _o, stderr) => (err ? rej(new Error(stderr || String(err))) : res())
)
);
}
// server/api/atc/generate.post.ts
import { createError, readBody } from "h3";
import { generateATCPhrase, getRandomPhraseForLesson, getPhrasesForLesson } from "../../utils/atcPhrases";
import { normalizeATC } from "../../utils/openai";
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));
const body = await readBody<{
moduleId: string;
lessonId: string;
phraseId?: string;
customVariables?: Record<string, string>;
type?: 'instruction' | 'clearance' | 'information' | 'request';
count?: number;
}>(event);
// 1) ATC-Text erzeugen (ohne Pilot-Input)
const scenario = {
airport: "EDDF",
aircraft: "A320",
type: "IFR",
stand: "V155",
dep: "EHAM",
sid: "MARUN 7F",
squawk: "4723",
freq: "121.800",
runway: "25R",
phase: "taxi",
notes: "Taxiway N closed between N2N4",
};
const { moduleId, lessonId, phraseId, customVariables, type, count = 1 } = body;
// 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" });
if (!moduleId || !lessonId) {
throw createError({
statusCode: 400,
statusMessage: "moduleId and lessonId are required"
});
}
const normalized = normalizeATC(atcText);
if (!normalized) throw createError({ statusCode: 500, statusMessage: "ATC text empty" });
console.log("ATC Text (normalized):", normalized);
try {
let phrases: string[] = [];
// return { atcText: normalized, level: lvl };
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);
// 2) TTS (clean)
const cleanPath = join(tmpdir(), `tts-${randomUUID()}.wav`);
const radioPath = join(tmpdir(), `radio-${randomUUID()}.wav`);
if (availablePhrases.length === 0) {
throw createError({
statusCode: 404,
statusMessage: `No phrases found for module "${moduleId}", lesson "${lessonId}"`
});
}
const tts = await openai.audio.speech.create({
model: TTS_MODEL,
voice: "alloy",
format: "wav",
input: normalized,
});
await writeFile(cleanPath, Buffer.from(await tts.arrayBuffer()));
// Filter nach Typ wenn angegeben
const filteredPhrases = type
? availablePhrases.filter(p => p.type === type)
: availablePhrases;
// 3) Funk-Effekt (mit stufe)
await applyRadioEffect(cleanPath, radioPath, lvl);
if (filteredPhrases.length === 0) {
throw createError({
statusCode: 404,
statusMessage: `No phrases of type "${type}" found for module "${moduleId}", lesson "${lessonId}"`
});
}
// 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(() => {});
// 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);
}
}
return {
atcText,
level: lvl,
audio: {
clean: { mime: "audio/wav", base64: cleanB64 },
radio: { mime: "audio/wav", base64: radioB64 },
},
};
// 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}`
});
}
});

View File

@@ -1,3 +1,4 @@
// server/api/atc/say.post.ts
import { createError, readBody } from "h3";
import { writeFile, readFile, mkdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
@@ -7,78 +8,90 @@ import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { openai, TTS_MODEL, normalizeATC } from "../../utils/openai";
// --- Radio-Filter aus deinem bestehenden Endpoint wiederverwenden ---
function buildRadioFilter(level: number) {
// Enhanced Radio Filter für besseren ATC-Sound
function buildEnhancedRadioFilter(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`;
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}]`,
// Basis Audio-Charakteristika für ATC Radio
const baseProcessor = (hp: number, lp: number, gain = 8) => [
`highpass=f=${hp}`,
`lowpass=f=${lp}`,
`compand=attacks=0.01:decays=0.15:points=-80/-900|-60/-25|-40/-15|-20/-10|0/-5:gain=${gain}`,
`acompressor=threshold=0.4:ratio=8:attack=5:release=100`,
`volume=1.1`
].join(',');
// Authentische Radio-Artefakte
const radioArtefacts = (crushLevel: number, noiseLevel: number, staticLevel: number) => [
// Bit crushing für digitale Artefakte
`acrusher=bits=${crushLevel}:mix=0.3`,
// Bandpass + Resonanz für Funkcharakter
`bandpass=frequency=1850:width_type=h:width=1200`,
`equalizer=frequency=2300:width_type=h:width=800:gain=3`,
// Leichtes Saturation für Röhren-Sound
`asoftclip=param=2`,
// Radio-typisches Rauschen
`anoisesrc=color=brown:amplitude=${noiseLevel}:duration=10[noise]`,
`[0][noise]amix=inputs=2:weights=1 ${staticLevel}:duration=shortest`
];
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}]`,
];
// Dropout-Simulation (Funkaussetzer)
const dropout = (frequency: number, duration: number) =>
`volume=enable='lt(mod(t\\,${frequency})\\,${duration})':volume=0.1`;
const mkDropTail = (inLabel: string, period: number | null, dur: number | null) => {
const chain: string[] = [];
if (period && dur) chain.push(`[${inLabel}]${dropout(period, dur)}[drop]`);
const src = period && dur ? "drop" : inLabel;
chain.push(`[${src}]${tail}[out]`);
return chain;
};
// Delay/Echo für Raumklang
const spatialEffect = `aecho=0.4:0.5:15:0.3,reverb=roomsize=0.3:damping=0.4`;
// PTT Click Simulation (leider schwer ohne Audio-Sample)
const pttSimulation = `afade=t=in:d=0.05,afade=t=out:d=0.08`;
if (L === 5) {
// Kristallklar, moderne digitale Funkanlage
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(";");
`[0:a]${baseProcessor(280, 3500, 7)}[clean]`,
`anoisesrc=color=white:amplitude=0.01[ns]`,
`[clean][ns]amix=inputs=2:weights=1 0.15:duration=shortest[mixed]`,
`[mixed]${spatialEffect},${pttSimulation}[out]`
].join(';');
}
if (L === 4) {
// Gute Qualität, leichte Kompression
return [
`[0:a]${band(320,3300)},volume=1.15[pre]`,
...mkNoiseMix(0.03, "pre", "mix"),
`[mix]${tail}[out]`,
].join(";");
`[0:a]${baseProcessor(300, 3300, 7)}[clean]`,
...radioArtefacts(14, 0.02, 0.2),
`${spatialEffect},${pttSimulation}[out]`
].join(';');
}
if (L === 3) {
// Standard ATC Qualität mit typischen Artefakten
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(";");
`[0:a]${baseProcessor(320, 3100, 6)}[clean]`,
...radioArtefacts(12, 0.04, 0.35),
`${dropout(8, 0.08)}`,
`${spatialEffect},${pttSimulation}[out]`
].join(';');
}
if (L === 2) {
// Schlechtere Verbindung, mehr Störungen
return [
`[0:a]${band(400,3000)},volume=1.05[pre]`,
...mkCrushMix(10, 0.32, "pre", "mix1"),
`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(";");
`[0:a]${baseProcessor(350, 2900, 6)}[clean]`,
...radioArtefacts(10, 0.07, 0.5),
`${dropout(5, 0.15)}`,
`${spatialEffect},${pttSimulation}[out]`
].join(';');
}
// L === 1
// L === 1: Sehr schlechte Verbindung, starke Störungen
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(";");
`[0:a]${baseProcessor(400, 2600, 5)}[clean]`,
...radioArtefacts(8, 0.12, 0.7),
`${dropout(3, 0.25)}`,
`tremolo=f=0.1:d=0.3`, // Schwankungen
`${spatialEffect},${pttSimulation}[out]`
].join(';');
}
async function sh(cmd: string, args: string[]) {
@@ -87,16 +100,21 @@ async function sh(cmd: string, args: string[]) {
);
}
async function applyRadioEffect(input: string, outputWav: string, level = 4) {
const filter = buildRadioFilter(level);
await sh("ffmpeg", ["-y", "-i", input, "-filter_complex", filter, "-map", "[out]", "-ar", "16000", "-ac", "1", outputWav]);
async function applyEnhancedRadioEffect(input: string, outputWav: string, level = 4) {
const filter = buildEnhancedRadioFilter(level);
await sh("ffmpeg", [
"-y", "-i", input,
"-filter_complex", filter,
"-map", "[out]",
"-ar", "22050", // Höhere Sample Rate für bessere Qualität
"-ac", "1", // Mono
"-q:a", "3", // Hohe Qualität
outputWav
]);
}
function outDir() {
// Persistenz: env > ./storage/atc > tmp
const base =
process.env.ATC_OUT_DIR?.trim() ||
join(process.cwd(), "storage", "atc");
const base = process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
return base;
}
@@ -105,12 +123,14 @@ async function ensureDir(p: string) {
}
export default defineEventHandler(async (event) => {
// POST { text: string; level?: 1..5; voice?: string; tag?: string }
const body = await readBody<{
text?: string;
level?: number;
voice?: string;
tag?: string;
speed?: number;
moduleId?: string;
lessonId?: string;
}>(event);
const raw = (body?.text || "").trim();
@@ -118,82 +138,114 @@ export default defineEventHandler(async (event) => {
const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4)));
const voice = (body?.voice || "alloy") as string;
const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0));
// Wortstamm/Normalisierung: ICAO-Formatierung, Zahl-/Buchstabierlogik etc.
// ATC-Normalisierung für realistischen Funkspruch
const normalized = normalizeATC(raw);
if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" });
// 1) TTS (clean) -> tmp WAV
const tmpClean = join(tmpdir(), `tts-${randomUUID()}.wav`);
const tmpRadio = join(tmpdir(), `radio-${randomUUID()}.wav`);
const tts = await openai.audio.speech.create({
model: TTS_MODEL,
voice,
format: "wav",
input: normalized,
});
await writeFile(tmpClean, Buffer.from(await tts.arrayBuffer()));
// 2) Radio-Effekt (WAV, 16 kHz mono)
await applyRadioEffect(tmpClean, tmpRadio, level);
// 3) Platzsparend transkodieren: OGG/Opus (VoIP-Profil), ~12 kbps @16 kHz mono
// Eindeutige ID und Pfade
const id = randomUUID();
const baseDir = join(outDir(), new Date().toISOString().slice(0, 10)); // YYYY-MM-DD
const timestamp = new Date().toISOString();
const dateFolder = timestamp.slice(0, 10); // YYYY-MM-DD
const baseDir = join(outDir(), dateFolder);
const fileOgg = join(baseDir, `${id}.ogg`);
const fileJson = join(baseDir, `${id}.json`);
await ensureDir(baseDir);
await sh("ffmpeg", [
"-y",
"-i", tmpRadio,
"-ac", "1",
"-ar", "16000",
"-c:a", "libopus",
"-b:a", "12k",
"-application", "voip",
fileOgg,
]);
// Temp-Dateien
const tmpClean = join(tmpdir(), `tts-${id}.wav`);
const tmpRadio = join(tmpdir(), `radio-${id}.wav`);
// 4) JSON-Metadaten neben die Audiodatei schreiben
const meta = {
id,
createdAt: new Date().toISOString(),
level,
voice,
text: raw,
normalized,
tag: body?.tag || null,
files: {
ogg: fileOgg,
},
model: TTS_MODEL,
format: "audio/ogg; codecs=opus",
};
await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
try {
// 1) OpenAI TTS - Clean Audio
const tts = await openai.audio.speech.create({
model: TTS_MODEL,
voice,
format: "wav",
input: normalized,
speed
});
// 5) Response (Audio als Base64 + Meta). Optional: nur Pfad zurückgeben, wenn du sparen willst.
const oggB64 = (await readFile(fileOgg)).toString("base64");
await writeFile(tmpClean, Buffer.from(await tts.arrayBuffer()));
// Cleanup tmp
rm(tmpClean).catch(() => {});
rm(tmpRadio).catch(() => {});
// 2) Enhanced Radio-Effekt anwenden
await applyEnhancedRadioEffect(tmpClean, tmpRadio, level);
return {
ok: true,
id,
level,
voice,
text: raw,
normalized,
audio: {
mime: "audio/ogg",
base64: oggB64,
},
stored: {
audioPath: fileOgg,
jsonPath: fileJson,
},
};
// 3) Zu hochwertigem OGG/Opus komprimieren
await ensureDir(baseDir);
await sh("ffmpeg", [
"-y",
"-i", tmpRadio,
"-ac", "1",
"-ar", "22050",
"-c:a", "libopus",
"-b:a", "24k", // Höhere Bitrate für bessere Qualität
"-application", "voip",
"-frame_duration", "20",
fileOgg,
]);
// 4) Metadaten speichern
const meta = {
id,
createdAt: timestamp,
level,
voice,
speed,
text: raw,
normalized,
tag: body?.tag || null,
moduleId: body?.moduleId || null,
lessonId: body?.lessonId || null,
files: {
ogg: fileOgg,
},
model: TTS_MODEL,
format: "audio/ogg; codecs=opus",
sampleRate: 22050,
channels: 1,
};
await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
// 5) Audio als Base64 für sofortige Wiedergabe
const oggBuffer = await readFile(fileOgg);
const audioBase64 = oggBuffer.toString("base64");
// 6) Cleanup
await rm(tmpClean).catch(() => {});
await rm(tmpRadio).catch(() => {});
return {
success: true,
id,
level,
voice,
speed,
text: raw,
normalized,
audio: {
mime: "audio/ogg; codecs=opus",
base64: audioBase64,
size: oggBuffer.length
},
stored: {
audioPath: fileOgg,
jsonPath: fileJson,
url: `/api/atc/audio/${dateFolder}/${id}.ogg` // Zum späteren Abrufen
},
meta
};
} catch (error) {
// Cleanup bei Fehler
await rm(tmpClean).catch(() => {});
await rm(tmpRadio).catch(() => {});
throw createError({
statusCode: 500,
statusMessage: `TTS generation failed: ${error}`
});
}
});

View File

@@ -0,0 +1,269 @@
// 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);
}

View File

@@ -2059,6 +2059,15 @@ __metadata:
languageName: node
linkType: hard
"@types/fluent-ffmpeg@npm:^2":
version: 2.1.27
resolution: "@types/fluent-ffmpeg@npm:2.1.27"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/2362e7d240d4d8a0b775b5abf9ab5f26a85b17dc335276f0c3ff3bf935f913ccc9e1e10e756a6a3cebf78c1cd1eff835a253f73a3cd1bbcb343b28089a9957f3
languageName: node
linkType: hard
"@types/node-fetch@npm:^2.6.4":
version: 2.6.13
resolution: "@types/node-fetch@npm:2.6.13"
@@ -2627,6 +2636,13 @@ __metadata:
languageName: node
linkType: hard
"async@npm:^0.2.9":
version: 0.2.10
resolution: "async@npm:0.2.10"
checksum: 10c0/714d284dc6c3ae59f3e8b347083e32c7657ba4ffc4ff945eb152ad4fb08def27e768992fcd4d9fd3b411c6b42f1541862ac917446bf2a1acfa0f302d1001f7d2
languageName: node
linkType: hard
"async@npm:^3.2.4, async@npm:^3.2.6":
version: 3.2.6
resolution: "async@npm:3.2.6"
@@ -4152,6 +4168,16 @@ __metadata:
languageName: node
linkType: hard
"fluent-ffmpeg@npm:^2.1.3":
version: 2.1.3
resolution: "fluent-ffmpeg@npm:2.1.3"
dependencies:
async: "npm:^0.2.9"
which: "npm:^1.1.1"
checksum: 10c0/0397379ec3237c10b2389edeef26fdaf93f36d1b20b0f28f8945fe6d9121dcee9b0c615bf7d44edb7abd37233e0d24f0db39389668d3c86a1a2a0d59e3f4457b
languageName: node
linkType: hard
"foreground-child@npm:^3.1.0":
version: 3.3.1
resolution: "foreground-child@npm:3.3.1"
@@ -6204,7 +6230,9 @@ __metadata:
resolution: "opensquawk@workspace:."
dependencies:
"@nuxtjs/tailwindcss": "npm:6.14.0"
"@types/fluent-ffmpeg": "npm:^2"
dotenv: "npm:^17.2.2"
fluent-ffmpeg: "npm:^2.1.3"
nuxt: "npm:^4.1.1"
nuxt-aos: "npm:1.2.5"
openai: "npm:^4.66.0"
@@ -8816,6 +8844,17 @@ __metadata:
languageName: node
linkType: hard
"which@npm:^1.1.1":
version: 1.3.1
resolution: "which@npm:1.3.1"
dependencies:
isexe: "npm:^2.0.0"
bin:
which: ./bin/which
checksum: 10c0/e945a8b6bbf6821aaaef7f6e0c309d4b615ef35699576d5489b4261da9539f70393c6b2ce700ee4321c18f914ebe5644bc4631b15466ffbaad37d83151f6af59
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"