mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 13:55:34 +08:00
Phase 0 of the OpenSquawk repo separation. Everything happens inside the
monorepo so that the split itself becomes a mechanical path filter — filtering
first and repairing afterwards would leave two broken repos at once.
AUTH_MODE (0.1)
New server/utils/authMode.ts, session.ts and jwt.ts (the latter extracted
from auth.ts). requireUserSession now resolves in three steps: the app's own
session cookie, an app-minted bearer token, then the website access token.
Only the last one is transitional; it is marked PHASE 1 and disappears with
the User collection. The app's session is its own JWT in a host-only cookie
plus a short-lived bearer, so the existing Authorization call sites are
unchanged.
AUTH_MODE defaults to 'sso', not 'open' as the plan proposed: while the admin
and editor surface still lives here, an unset variable would otherwise serve
it to everyone as a local admin. requireAdmin additionally refuses in open
mode. Both are one-line removals in Phase 1 and marked as such.
SSO handoff (0.2)
Issuer: /api/service/auth/sso/{authorize,exchange}. Codes are stored as
SHA-256 hashes with a TTL index and claimed by a single atomic update, so
concurrent redemption cannot succeed twice. redirect_uri is matched against
SSO_REDIRECT_ORIGINS by exact origin — a prefix check would accept
app.opensquawk.de.evil.tld. There is no default and no wildcard: an empty
allowlist disables the handoff rather than opening a redirector.
Consumer: /api/auth/sso/callback plus app/pages/auth/callback.vue. The
browser only ever carries the code; it is redeemed server-to-server.
Hardcoded values and leaks (0.3)
Hotjar ID, the dome-light webhook URL and the bug-report recipient were
compiled in. All three are env-gated and off by default now, so a foreign
instance cannot ship analytics, cockpit telemetry or its users' bug reports
to us. Setting HOTJAR_ID, DOME_LIGHT_WEBHOOK_URL and BUG_REPORT_NOTIFY_EMAIL
restores the current behaviour on opensquawk.de.
Two databases, no shared Mongo (0.6)
AppUser mirrors an identity locally. Its _id is deliberately the SSO subject,
i.e. the website's User._id, so every existing LearnProfile, PilotProfile and
BridgeToken reference keeps resolving without a migration.
telemetry.ts mirrors records to the hosted service only when TELEMETRY_URL
and SERVICE_SECRET are both set — the self-host default is that nothing ever
leaves the instance. It writes locally first, buffers with a bound, drops on
overflow and never blocks the request path.
/api/service/user-deleted purges the app's half on account deletion. Unlike
telemetry this is deliberately loud: the admin delete aborts with the user
intact if the purge fails, because their id is the only handle for retrying.
?force=true overrides it and says so in the response.
Also here
/api/service/analytics/product-session was an unauthenticated public write
endpoint; it moves to /api/analytics/product-session behind the auth guard.
The bridge no longer populates against User but resolves through the mirror,
backfilling missing rows so live bridges never have to re-pair.
.claude/worktrees was tracked and would have reached the public repo.
scripts/split-paths.txt carries the filter list, verified by
scripts/verify-split-paths.mjs: every path exists, nothing website-only is
kept, and no kept file imports a dropped one. That check found real gaps —
tests/ cannot be taken wholesale, and two shared modules were missing. Ten
remaining edges are allowlisted, each annotated PHASE 1 in the code.
Open item, flagged and not resolved: flightlabTelemetryStore is an in-process
singleton written by the bridge (app) and read by FlightLab (website). Two
repos means two processes, so that read breaks regardless of which side it
lands on. FlightLab needs an HTTP path in Phase 2/3.
Verified: 609 tests pass, vue-tsc clean. Ran against two throwaway local
MongoDBs: open mode reaches /classroom and /live-atc with no login and
persists progress; the full SSO loop works and the mirror _id matches the
website User._id; lookalike origins, code reuse, forged codes and wrong
service secrets are all rejected; ingest is idempotent on bug-report code;
deletion purges all five collections; and with the app unreachable the admin
delete fails 502 with the user still present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
// server/api/atc/say.post.ts
|
|
import {createError, readBody} from "h3";
|
|
import {writeFile, mkdir, readFile} from "node:fs/promises";
|
|
import {join} from "node:path";
|
|
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 { emit as emitTelemetry } from "../../utils/telemetry";
|
|
import { requireUserSession } from "../../utils/auth";
|
|
import { enforceRateLimit } from "../../utils/rateLimit";
|
|
import { recordUsage } from "../../utils/usage";
|
|
import { resolveSpeachesVoice } from "../../utils/voiceRegistry";
|
|
|
|
|
|
function outDir() {
|
|
return process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
|
|
}
|
|
|
|
async function ensureDir(p: string) {
|
|
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";
|
|
}
|
|
|
|
function isAtisTag(tag?: string) {
|
|
return (tag || "").trim().toLowerCase() === "atis";
|
|
}
|
|
|
|
function isCacheableTag(tag?: string) {
|
|
return isFlightLabTag(tag) || isAtisTag(tag);
|
|
}
|
|
|
|
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) {
|
|
switch (level) {
|
|
case 5: return { gain: 1.0, description: "crystal clear" };
|
|
case 4: return { gain: 0.9, description: "very good" };
|
|
case 3: return { gain: 0.8, description: "good" };
|
|
case 2: return { gain: 0.7, description: "poor" };
|
|
case 1: return { gain: 0.6, description: "very poor" };
|
|
default: return { gain: 0.8, description: "standard" };
|
|
}
|
|
}
|
|
|
|
// ---- Format Helpers ----
|
|
type AudioFmt = "mp3" | "flac" | "wav" | "pcm";
|
|
function pickDefaultFormat(useSpeaches: boolean): AudioFmt {
|
|
// kleinste Bitrate bevorzugen, wenn Speaches genutzt wird
|
|
return useSpeaches ? "mp3" : "wav";
|
|
}
|
|
function fmtToMime(fmt: AudioFmt): string {
|
|
switch (fmt) {
|
|
case "mp3": return "audio/mpeg";
|
|
case "flac": return "audio/flac";
|
|
case "wav": return "audio/wav";
|
|
case "pcm": return "audio/L16"; // raw PCM (fallback)
|
|
default: return "application/octet-stream";
|
|
}
|
|
}
|
|
function fmtToExt(fmt: AudioFmt): string {
|
|
switch (fmt) {
|
|
case "mp3": return "mp3";
|
|
case "flac": return "flac";
|
|
case "wav": return "wav";
|
|
case "pcm": return "pcm";
|
|
default: return "bin";
|
|
}
|
|
}
|
|
|
|
// ---- Piper HTTP helper ----
|
|
async function piperTTS(text: string, voice: string, port: number): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = request(
|
|
{
|
|
hostname: "localhost",
|
|
port,
|
|
path: "/",
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" }
|
|
},
|
|
(res) => {
|
|
const data: Buffer[] = [];
|
|
res.on("data", (chunk) => data.push(chunk));
|
|
res.on("end", () => resolve(Buffer.concat(data)));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
req.write(JSON.stringify({ text, voice }));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ---- Speaches HTTP helper ----
|
|
// Env:
|
|
// USE_SPEACHES=true
|
|
// SPEACHES_BASE_URL="https://..."
|
|
// SPEECH_MODEL_ID="speaches-ai/piper-en_US-ryan-low"
|
|
// VOICE_ID="en_US-ryan-low"
|
|
async function speachesTTS(
|
|
input: string,
|
|
voice: string,
|
|
model: string,
|
|
response_format: AudioFmt,
|
|
baseUrl: string,
|
|
speed: number = 1.0
|
|
): Promise<Buffer> {
|
|
const url = `${baseUrl.replace(/\/+$/, "")}/v1/audio/speech`;
|
|
const body = {
|
|
input,
|
|
model,
|
|
voice,
|
|
// API erwartet "response_format": "mp3" | "flac" | "wav" | "pcm"
|
|
response_format,
|
|
speed
|
|
};
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
throw new Error(`Speaches API ${res.status}: ${text || res.statusText}`);
|
|
}
|
|
const arr = await res.arrayBuffer();
|
|
return Buffer.from(arr);
|
|
}
|
|
|
|
const SAY_RATE_LIMIT_PER_MINUTE = 60;
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUserSession(event);
|
|
enforceRateLimit(event, 'atc-say', String(user._id), SAY_RATE_LIMIT_PER_MINUTE);
|
|
|
|
const runtimeConfig = getServerRuntimeConfig();
|
|
const body = await readBody<{
|
|
text?: string;
|
|
level?: number;
|
|
voice?: string;
|
|
speed?: number;
|
|
moduleId?: string;
|
|
lessonId?: string;
|
|
tag?: string;
|
|
format?: AudioFmt | "smallest";
|
|
sessionId?: string;
|
|
/**
|
|
* Client already ran the radiotelephony normalizer on the text.
|
|
* Skip server-side normalizeATC — double-normalizing corrupts it
|
|
* (e.g. expandAirports spells the city name "MAIN" letter-by-letter).
|
|
*/
|
|
preNormalized?: boolean;
|
|
}>(event);
|
|
|
|
const rawSessionId = typeof body?.sessionId === "string"
|
|
? body.sessionId.trim()
|
|
: "";
|
|
const sessionId = rawSessionId.length ? rawSessionId : undefined;
|
|
|
|
const raw = (body?.text || "").trim();
|
|
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
|
|
|
const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4)));
|
|
const voice = (body?.voice || runtimeConfig.voiceId).trim();
|
|
const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0));
|
|
|
|
const normalized = body?.preNormalized ? raw : normalizeATC(raw);
|
|
if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" });
|
|
|
|
// Routing
|
|
const useSpeaches = runtimeConfig.useSpeaches;
|
|
const usePiper = !useSpeaches && runtimeConfig.usePiper;
|
|
const provider = resolveTtsProvider(useSpeaches, usePiper);
|
|
// Speaches: logical pool voices (alloy, verse, …) resolve to Piper
|
|
// model+voice pairs; anything unknown keeps the env-configured pair.
|
|
const speachesFallback = {
|
|
model: runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low",
|
|
voice,
|
|
};
|
|
const speachesVoice = provider === "speaches"
|
|
? resolveSpeachesVoice(voice, speachesFallback, body?.tag)
|
|
: null;
|
|
const ttsVoice = speachesVoice?.voice ?? voice;
|
|
const providerModel = speachesVoice
|
|
? speachesVoice.model
|
|
: 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 ext = fmtToExt(fmt);
|
|
const outputExt = (provider === "openai" || provider === "piper") ? "wav" : ext;
|
|
const flightlabRequest = isFlightLabTag(body?.tag);
|
|
const cacheableRequest = isCacheableTag(body?.tag);
|
|
const flightlabCacheKey = cacheableRequest
|
|
? buildFlightLabCacheKey({
|
|
normalized,
|
|
level,
|
|
voice: ttsVoice,
|
|
speed,
|
|
format: fmt,
|
|
provider,
|
|
model: providerModel
|
|
})
|
|
: null;
|
|
const flightlabCacheBaseDir = cacheableRequest ? 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}.${outputExt}`);
|
|
const fileJson = join(baseDir, `${id}.json`);
|
|
|
|
try {
|
|
let audioBuffer: Buffer | null = null;
|
|
let modelUsed = providerModel;
|
|
let actualMime = defaultMimeForProvider(provider, fmt);
|
|
let ttsProvider: TTSProvider = provider;
|
|
let cacheHit = false;
|
|
|
|
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 = providerModel;
|
|
if (!baseUrl) {
|
|
throw new Error("SPEACHES_BASE_URL not set");
|
|
}
|
|
audioBuffer = await speachesTTS(normalized, ttsVoice, model, fmt, baseUrl, speed);
|
|
modelUsed = model;
|
|
// Server returns the correct format according to response_format
|
|
actualMime = fmtToMime(fmt);
|
|
ttsProvider = 'speaches';
|
|
} 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 if (!audioBuffer) {
|
|
// OpenAI (fallback)
|
|
const tts = await normalize.audio.speech.create({
|
|
model: TTS_MODEL,
|
|
voice,
|
|
input: normalized,
|
|
speed
|
|
});
|
|
audioBuffer = Buffer.from(await tts.arrayBuffer());
|
|
modelUsed = TTS_MODEL;
|
|
actualMime = "audio/wav";
|
|
ttsProvider = 'openai';
|
|
}
|
|
|
|
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: (body?.tag || "").trim().toLowerCase() || "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,
|
|
level,
|
|
voice,
|
|
speed,
|
|
text: raw,
|
|
normalized,
|
|
radioQuality: radioQuality.description,
|
|
tag: body?.tag || null,
|
|
moduleId: body?.moduleId || null,
|
|
lessonId: body?.lessonId || null,
|
|
files: { audio: storedAudioPath },
|
|
model: modelUsed,
|
|
format: actualMime,
|
|
ttsProvider,
|
|
cache: {
|
|
flightlab: flightlabRequest,
|
|
hit: cacheHit,
|
|
key: flightlabCacheKey
|
|
}
|
|
};
|
|
|
|
await recordUsage({
|
|
user: String(user._id),
|
|
sessionId,
|
|
kind: 'tts',
|
|
// Cache hits cost nothing regardless of which provider filled the cache.
|
|
provider: cacheHit ? 'cache' : ttsProvider,
|
|
model: modelUsed,
|
|
endpoint: '/api/atc/say',
|
|
characters: normalized.length,
|
|
});
|
|
|
|
const transmission = {
|
|
user: user._id,
|
|
role: "atc",
|
|
channel: "say",
|
|
direction: "outgoing",
|
|
text: raw,
|
|
normalized,
|
|
sessionId,
|
|
metadata: {
|
|
level,
|
|
voice,
|
|
speed,
|
|
moduleId: body?.moduleId || null,
|
|
lessonId: body?.lessonId || null,
|
|
tag: body?.tag || null,
|
|
radioQuality: radioQuality.description,
|
|
tts: {
|
|
provider: ttsProvider,
|
|
model: modelUsed,
|
|
format: actualMime,
|
|
extension: outputExt
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Local DB first — this instance's own data, always written.
|
|
await TransmissionLog.create(transmission)
|
|
} catch (logError) {
|
|
console.warn("Transmission logging failed", logError)
|
|
}
|
|
|
|
// Mirrored to the hosted service only if this instance is configured
|
|
// for it. Fire-and-forget: never awaited, never able to fail the request.
|
|
emitTelemetry('transmission-log', { ...transmission, user: String(user._id) })
|
|
|
|
return {
|
|
success: true,
|
|
id,
|
|
level,
|
|
voice,
|
|
speed,
|
|
text: raw,
|
|
normalized,
|
|
radioQuality: radioQuality.description,
|
|
audio: {
|
|
mime: actualMime,
|
|
base64: audioBuffer.toString("base64"),
|
|
size: audioBuffer.length,
|
|
ext: outputExt
|
|
},
|
|
stored: {
|
|
audioPath: storedAudioPath,
|
|
jsonPath: storedJsonPath,
|
|
url: storedUrl
|
|
},
|
|
meta,
|
|
cache: {
|
|
flightlab: flightlabRequest,
|
|
hit: cacheHit,
|
|
key: flightlabCacheKey
|
|
}
|
|
};
|
|
} catch (err: any) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: `TTS generation failed: ${err?.message || err}`
|
|
});
|
|
}
|
|
});
|