This commit is contained in:
itsrubberduck
2025-09-14 21:09:47 +02:00
commit e91c3c6aa9
17 changed files with 9313 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import { readMultipartFormData, createError } from "h3";
import { openai, LLM_MODEL, TTS_MODEL, atcReplyPrompt } from "../../utils/openai";
import { applyRadioEffect } from "../../utils/radio";
import { writeFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
const mode = parts?.find(p=>p.name==="mode")?.data?.toString("utf8") || "text";
const audio = parts?.find(p=>p.type && p.data);
if (!audio) throw createError({ statusCode:400, statusMessage:"No audio" });
const file = new File([audio.data], audio.filename || "audio.webm", { type: audio.type || "audio/webm" });
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
const pilotText = tr.text.trim();
const resp = await openai.responses.create({ model: LLM_MODEL, input: atcReplyPrompt(pilotText) });
const replyText = resp.output_text?.trim() || "";
if (mode === "tts") {
const clean = join(tmpdir(), `tts-${randomUUID()}.wav`);
const radio = join(tmpdir(), `radio-${randomUUID()}.wav`);
const tts = await openai.audio.speech.create({ model: TTS_MODEL, voice: "alloy", format: "wav", input: replyText });
await writeFile(clean, Buffer.from(await tts.arrayBuffer()));
await applyRadioEffect(clean, radio);
const data = await import("node:fs/promises").then(fs => fs.readFile(radio));
await rm(clean).catch(()=>{});
const b64 = Buffer.from(data).toString("base64");
await rm(radio).catch(()=>{});
return { pilotText, replyText, audio:{ mime:"audio/wav", base64:b64 } };
}
return { pilotText, replyText };
});

View File

@@ -0,0 +1,10 @@
import { readMultipartFormData, createError } from "h3";
import { openai } from "../../utils/openai";
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
const audio = parts?.find(p=>p.type && p.data);
if (!audio) throw createError({ statusCode:400, statusMessage:"No audio" });
const file = new File([audio.data], audio.filename || "audio.webm", { type: audio.type || "audio/webm" });
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
return { text: tr.text };
});

8
server/utils/openai.ts Normal file
View File

@@ -0,0 +1,8 @@
import OpenAI from "openai";
export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano";
export const TTS_MODEL = process.env.TTS_MODEL || "tts-1";
export function atcReplyPrompt(userText: string) {
return `You are an ICAO-compliant ATC controller. Reply concisely.
Pilot said: "${userText}"`;
}

8
server/utils/radio.ts Normal file
View File

@@ -0,0 +1,8 @@
import { execFile } from "node:child_process";
export async function applyRadioEffect(input: string, output: string) {
const filter="[0:a]highpass=f=300,lowpass=f=3400,compand=attacks=0.02:decays=0.25:points=-80/-900|-70/-20|0/-10|20/-8:gain=6,volume=1.2[a];anoisesrc=color=white:amplitude=0.02[ns];[a][ns]amix=inputs=2:weights=1 0.25:duration=shortest,volume=1.0,aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200";
await new Promise((res,rej)=>
execFile("ffmpeg",["-y","-i",input,"-filter_complex",filter,"-ar","16000",output],
(err,_,stderr)=>err?rej(new Error(stderr)):res())
);
}