mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
claude änderungen
This commit is contained in:
Binary file not shown.
10
package.json
10
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 N2–N4",
|
||||
};
|
||||
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}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
39
yarn.lock
39
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user