From c605705a385522ecb84c8207ef6be6c120ec075e Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Wed, 18 Feb 2026 18:05:12 +0100 Subject: [PATCH] change default settings --- .env.example | 1 + README.md | 1 + server/api/atc/say.post.ts | 165 ++++++++++++++++++++++---- shared/data/flightlab/takeoff-eddf.ts | 6 +- 4 files changed, 145 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 5d3833a..de42235 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ VOICE_ID=alloy # ATC audio generation ATC_OUT_DIR=./storage/atc +FLIGHTLAB_TTS_CACHE_DIR=./.cache/flightlab-tts USE_SPEACHES=false USE_PIPER=false PIPER_PORT=5001 diff --git a/README.md b/README.md index be0b75b..3f0759a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ You can connect to any OpenAI-compatible (self-hosted or third-party) API by set * `OPENAI_BASE_URL`: Optional override for any OpenAI API–compatible service; defaults to the OpenAI platform when unset. * `OPENAI_API_KEY`, optional `OPENAI_PROJECT`, `LLM_MODEL`, `TTS_MODEL`, `VOICE_ID`. * `ATC_OUT_DIR`: Directory for generated audio files. + * `FLIGHTLAB_TTS_CACHE_DIR`: Disk cache directory for FlightLab TTS audio (defaults to `./.cache/flightlab-tts`, git-ignored). * `USE_PIPER`, `PIPER_PORT`: Enable local Piper TTS instance. * SMTP settings (`NOTIFY_*`) if emails should be sent. 4. Start MongoDB and ensure `ffmpeg` is on the PATH. diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index b916ea2..8d04891 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -1,14 +1,12 @@ // server/api/atc/say.post.ts import {createError, readBody} from "h3"; -import {writeFile, mkdir} from "node:fs/promises"; -import {existsSync} from "node:fs"; +import {writeFile, mkdir, readFile} from "node:fs/promises"; import {join} from "node:path"; -import {randomUUID} from "node:crypto"; +import {createHash, randomUUID} from "node:crypto"; import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize"; import { getServerRuntimeConfig } from "../../utils/runtimeConfig"; import {request} from "node:http"; import { TransmissionLog } from "../../models/TransmissionLog"; -import { requireUserSession } from "../../utils/auth"; function outDir() { @@ -16,7 +14,44 @@ function outDir() { } async function ensureDir(p: string) { - if (!existsSync(p)) await mkdir(p, { recursive: true }); + await mkdir(p, { recursive: true }); +} + +function flightLabCacheDir() { + return process.env.FLIGHTLAB_TTS_CACHE_DIR?.trim() || join(process.cwd(), ".cache", "flightlab-tts"); +} + +function isFlightLabTag(tag?: string) { + return (tag || "").trim().toLowerCase() === "flightlab"; +} + +type TTSProvider = "openai" | "speaches" | "piper"; + +function resolveTtsProvider(useSpeaches: boolean, usePiper: boolean): TTSProvider { + if (useSpeaches) return "speaches"; + if (usePiper) return "piper"; + return "openai"; +} + +function buildFlightLabCacheKey(input: { + normalized: string; + level: number; + voice: string; + speed: number; + format: AudioFmt; + provider: TTSProvider; + model: string; +}) { + return createHash("sha256") + .update(JSON.stringify(input)) + .digest("hex"); +} + +function defaultMimeForProvider(provider: TTSProvider, format: AudioFmt) { + if (provider === "openai" || provider === "piper") { + return "audio/wav"; + } + return fmtToMime(format); } function simulateRadioQuality(level: number) { @@ -148,28 +183,63 @@ export default defineEventHandler(async (event) => { // Routing const useSpeaches = runtimeConfig.useSpeaches; const usePiper = !useSpeaches && runtimeConfig.usePiper; + const provider = resolveTtsProvider(useSpeaches, usePiper); + const providerModel = provider === "speaches" + ? (runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low") + : provider === "piper" + ? "piper-local" + : TTS_MODEL; // Format const requestedFmt = (body?.format === "smallest" ? "mp3" : body?.format) as AudioFmt | undefined; const fmt: AudioFmt = requestedFmt || pickDefaultFormat(useSpeaches); - const mime = fmtToMime(fmt); const ext = fmtToExt(fmt); + const outputExt = (provider === "openai" || provider === "piper") ? "wav" : ext; + const flightlabRequest = isFlightLabTag(body?.tag); + const flightlabCacheKey = flightlabRequest + ? buildFlightLabCacheKey({ + normalized, + level, + voice, + speed, + format: fmt, + provider, + model: providerModel + }) + : null; + const flightlabCacheBaseDir = flightlabRequest ? flightLabCacheDir() : null; + const flightlabCachedAudioPath = (flightlabCacheBaseDir && flightlabCacheKey) + ? join(flightlabCacheBaseDir, `${flightlabCacheKey}.${outputExt}`) + : null; + const flightlabCachedMetaPath = (flightlabCacheBaseDir && flightlabCacheKey) + ? join(flightlabCacheBaseDir, `${flightlabCacheKey}.json`) + : null; const radioQuality = simulateRadioQuality(level); const id = randomUUID(); const timestamp = new Date().toISOString(); const dateFolder = timestamp.slice(0, 10); const baseDir = join(outDir(), dateFolder); - const fileOut = join(baseDir, `${id}.${ext}`); + const fileOut = join(baseDir, `${id}.${outputExt}`); const fileJson = join(baseDir, `${id}.json`); try { - let audioBuffer: Buffer; - let modelUsed: string; - let actualMime = mime; - let ttsProvider: 'openai' | 'speaches' | 'piper' = 'openai'; + let audioBuffer: Buffer | null = null; + let modelUsed = providerModel; + let actualMime = defaultMimeForProvider(provider, fmt); + let ttsProvider: TTSProvider = provider; + let cacheHit = false; - if (useSpeaches) { + if (flightlabCachedAudioPath) { + try { + audioBuffer = await readFile(flightlabCachedAudioPath); + cacheHit = true; + } catch { + // Cache miss: generate TTS below. + } + } + + if (!audioBuffer && useSpeaches) { // Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM) const baseUrl = runtimeConfig.speachesBaseUrl || ""; const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low"; @@ -181,14 +251,14 @@ export default defineEventHandler(async (event) => { // Server returns the correct format according to response_format actualMime = fmtToMime(fmt); ttsProvider = 'speaches'; - } else if (usePiper) { + } else if (!audioBuffer && usePiper) { // Local Piper audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort); modelUsed = "piper-local"; // Piper returns WAV actualMime = "audio/wav"; ttsProvider = 'piper'; - } else { + } else if (!audioBuffer) { // OpenAI (fallback) const tts = await normalize.audio.speech.create({ model: TTS_MODEL, @@ -202,9 +272,44 @@ export default defineEventHandler(async (event) => { ttsProvider = 'openai'; } - // Optional persistence - // await ensureDir(baseDir); - // await writeFile(fileOut, audioBuffer); + if (!audioBuffer) { + throw new Error("TTS generation returned empty audio"); + } + + if (!cacheHit && flightlabCacheBaseDir && flightlabCachedAudioPath && flightlabCachedMetaPath) { + try { + await ensureDir(flightlabCacheBaseDir); + await writeFile(flightlabCachedAudioPath, audioBuffer); + await writeFile( + flightlabCachedMetaPath, + JSON.stringify( + { + key: flightlabCacheKey, + createdAt: timestamp, + tag: "flightlab", + voice, + speed, + level, + format: outputExt, + mime: actualMime, + provider: ttsProvider, + model: modelUsed, + normalized, + text: raw + }, + null, + 2 + ) + ); + } catch (cacheWriteError) { + console.warn("FlightLab TTS cache write failed", cacheWriteError); + } + } + + const storedAudioPath = flightlabCachedAudioPath || fileOut; + const storedJsonPath = flightlabCachedMetaPath || fileJson; + const storedUrl = flightlabCachedAudioPath ? null : `/api/atc/audio/${dateFolder}/${id}.${outputExt}`; + const meta = { id, createdAt: timestamp, @@ -217,10 +322,15 @@ export default defineEventHandler(async (event) => { tag: body?.tag || null, moduleId: body?.moduleId || null, lessonId: body?.lessonId || null, - files: { audio: fileOut }, + files: { audio: storedAudioPath }, model: modelUsed, format: actualMime, - ttsProvider + ttsProvider, + cache: { + flightlab: flightlabRequest, + hit: cacheHit, + key: flightlabCacheKey + } }; try { @@ -244,7 +354,7 @@ export default defineEventHandler(async (event) => { provider: ttsProvider, model: modelUsed, format: actualMime, - extension: ext + extension: outputExt } } }) @@ -265,14 +375,19 @@ export default defineEventHandler(async (event) => { mime: actualMime, base64: audioBuffer.toString("base64"), size: audioBuffer.length, - ext + ext: outputExt }, stored: { - audioPath: fileOut, - jsonPath: fileJson, - url: `/api/atc/audio/${dateFolder}/${id}.${ext}` + audioPath: storedAudioPath, + jsonPath: storedJsonPath, + url: storedUrl }, - meta + meta, + cache: { + flightlab: flightlabRequest, + hit: cacheHit, + key: flightlabCacheKey + } }; } catch (err: any) { throw createError({ diff --git a/shared/data/flightlab/takeoff-eddf.ts b/shared/data/flightlab/takeoff-eddf.ts index 632c500..acaef3f 100644 --- a/shared/data/flightlab/takeoff-eddf.ts +++ b/shared/data/flightlab/takeoff-eddf.ts @@ -154,8 +154,8 @@ export const takeoffEddf: FlightLabScenario = { ], simConditions: { conditions: [ - { variable: 'TURB_ENG_N1_1', operator: '>=', value: 80 }, - { variable: 'TURB_ENG_N1_2', operator: '>=', value: 80 }, + { variable: 'TURB_ENG_N1_1', operator: '>=', value: 50 }, + { variable: 'TURB_ENG_N1_2', operator: '>=', value: 50 }, { variable: 'BRAKE_PARKING_POSITION', operator: '==', value: false }, ], logic: 'AND', @@ -199,7 +199,7 @@ export const takeoffEddf: FlightLabScenario = { ], simConditions: { conditions: [ - { variable: 'AIRSPEED_INDICATED', operator: '>=', value: 140 }, + { variable: 'AIRSPEED_INDICATED', operator: '>=', value: 130 }, { variable: 'SIM_ON_GROUND', operator: '==', value: true }, ], logic: 'AND',