mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 01:55:51 +08:00
communcations verbessern
This commit is contained in:
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<v-container class="py-8" max-width="800">
|
||||
<h1 class="text-h5 mb-4">ATC Demo (ohne PTT)</h1>
|
||||
|
||||
<v-card class="pa-4">
|
||||
<div class="mb-4">
|
||||
<div class="mb-2">
|
||||
<strong>Verständigungslevel:</strong>
|
||||
<span class="ml-1">Stufe {{ radioLevel }}</span>
|
||||
</div>
|
||||
<v-slider
|
||||
v-model="radioLevel"
|
||||
:min="1"
|
||||
:max="5"
|
||||
:step="1"
|
||||
show-ticks
|
||||
tick-size="3"
|
||||
thumb-label
|
||||
class="mt-2"
|
||||
/>
|
||||
<div class="text-caption mt-1">
|
||||
1 = sehr schlecht (Rauschen/Dropouts) · 4 = gut (Default) · 5 = klar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-btn :loading="loading" color="primary" @click="generate">
|
||||
ATC erzeugen & abspielen (Radio, Stufe {{ radioLevel }})
|
||||
</v-btn>
|
||||
|
||||
<div class="mt-4 text-body-2">
|
||||
<div><strong>Status:</strong> {{ status }}</div>
|
||||
<div v-if="returnedLevel" class="mt-1 text-caption">
|
||||
Server-Level bestätigt: {{ returnedLevel }}
|
||||
</div>
|
||||
<div v-if="atcText" class="mt-2">
|
||||
<strong>ATC Text:</strong> {{ atcText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="audioClean || audioRadio" class="mt-4">
|
||||
<div v-if="audioClean" class="mb-3">
|
||||
<div class="mb-1">Clean (TTS):</div>
|
||||
<audio ref="cleanRef" :src="audioClean" controls></audio>
|
||||
<v-btn class="ml-2" size="small" @click="play('clean')">Play Clean</v-btn>
|
||||
</div>
|
||||
<div v-if="audioRadio">
|
||||
<div class="mb-1">Radio (Funk-Effekt):</div>
|
||||
<audio ref="radioRef" :src="audioRadio" controls></audio>
|
||||
<v-btn class="ml-2" size="small" @click="play('radio')">Play Radio</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const status = ref('Bereit');
|
||||
const atcText = ref('');
|
||||
const audioClean = ref<string | null>(null);
|
||||
const audioRadio = ref<string | null>(null);
|
||||
const cleanRef = ref<HTMLAudioElement | null>(null);
|
||||
const radioRef = ref<HTMLAudioElement | null>(null);
|
||||
|
||||
// Neues State: Verständigungslevel (Default 4)
|
||||
const radioLevel = ref(4);
|
||||
const returnedLevel = ref<number | null>(null);
|
||||
|
||||
function b64ToUrl(b64: string, mime = 'audio/wav'): string {
|
||||
const u8 = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||
return URL.createObjectURL(new Blob([u8], { type: mime }));
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
loading.value = true;
|
||||
status.value = 'Erzeuge…';
|
||||
atcText.value = '';
|
||||
audioClean.value = null;
|
||||
audioRadio.value = null;
|
||||
returnedLevel.value = null;
|
||||
|
||||
// Übergabe als Query (?level=1..5). GET reicht (Handler akzeptiert i.d.R. GET/POST).
|
||||
const res = await fetch(`/api/atc/generate?level=${radioLevel.value}`, { method: 'POST' });
|
||||
if (!res.ok) { status.value = `Fehler: ${res.status}`; loading.value = false; return; }
|
||||
const data = await res.json();
|
||||
|
||||
atcText.value = data.atcText || '';
|
||||
returnedLevel.value = Number(data.level) || null;
|
||||
|
||||
if (data.audio?.clean?.base64) {
|
||||
audioClean.value = b64ToUrl(data.audio.clean.base64, data.audio.clean.mime || 'audio/wav');
|
||||
}
|
||||
if (data.audio?.radio?.base64) {
|
||||
audioRadio.value = b64ToUrl(data.audio.radio.base64, data.audio.radio.mime || 'audio/wav');
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
try { radioRef.value?.play(); } catch {}
|
||||
status.value = 'OK';
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function play(which: 'clean' | 'radio') {
|
||||
if (which === 'clean') cleanRef.value?.play();
|
||||
else radioRef.value?.play();
|
||||
}
|
||||
</script>
|
||||
@@ -1,116 +0,0 @@
|
||||
<template>
|
||||
<v-container class="py-8" max-width="800">
|
||||
<h1 class="text-h5 mb-4">ATC Push-to-Talk</h1>
|
||||
|
||||
<v-card class="pa-4 mb-4">
|
||||
<v-btn
|
||||
:color="recording ? 'red' : 'primary'"
|
||||
@mousedown="startRec" @mouseup="stopRec"
|
||||
@touchstart.prevent="startRec" @touchend.prevent="stopRec"
|
||||
>
|
||||
{{ recording ? 'Recording…' : 'Push-to-Talk' }}
|
||||
</v-btn>
|
||||
|
||||
<div class="mt-4">
|
||||
<div><strong>Status:</strong> {{ status }}</div>
|
||||
<div v-if="pilotText"><strong>Pilot:</strong> {{ pilotText }}</div>
|
||||
<div v-if="replyText"><strong>ATC:</strong> {{ replyText }}</div>
|
||||
</div>
|
||||
|
||||
<audio v-if="audioUrl" class="mt-3" :src="audioUrl" controls></audio>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const recording = ref(false);
|
||||
const status = ref('Bereit');
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let chunks: BlobPart[] = [];
|
||||
const lastBlob = ref<Blob | null>(null);
|
||||
const pilotText = ref('');
|
||||
const replyText = ref('');
|
||||
const audioUrl = ref<string | null>(null);
|
||||
|
||||
function pickMime(): string {
|
||||
const c = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4', // iOS Safari fallback
|
||||
'audio/ogg;codecs=opus'
|
||||
];
|
||||
for (const m of c) if (MediaRecorder.isTypeSupported(m)) return m;
|
||||
return 'audio/webm';
|
||||
}
|
||||
|
||||
async function initMedia() {
|
||||
if (mediaRecorder) return;
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mime = pickMime();
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
|
||||
chunks = [];
|
||||
lastBlob.value = blob;
|
||||
status.value = `Aufnahme fertig (${Math.round(blob.size/1024)} KB)`;
|
||||
// direkt senden (ohne Text-Button)
|
||||
void sendTTS();
|
||||
};
|
||||
}
|
||||
|
||||
async function startRec() {
|
||||
await initMedia();
|
||||
if (!mediaRecorder) return;
|
||||
pilotText.value = '';
|
||||
replyText.value = '';
|
||||
audioUrl.value = null;
|
||||
recording.value = true;
|
||||
status.value = 'Aufnahme…';
|
||||
chunks = [];
|
||||
mediaRecorder.start(10);
|
||||
}
|
||||
|
||||
function stopRec() {
|
||||
if (!mediaRecorder || !recording.value) return;
|
||||
recording.value = false;
|
||||
status.value = 'Verarbeite…';
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
function extFromType(t: string): string {
|
||||
if (t.includes('mp4')) return 'mp4';
|
||||
if (t.includes('ogg')) return 'ogg';
|
||||
if (t.includes('webm')) return 'webm';
|
||||
if (t.includes('wav')) return 'wav';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
async function sendTTS() {
|
||||
if (!lastBlob.value) return;
|
||||
status.value = 'Sende… (TTS)';
|
||||
const ext = extFromType(lastBlob.value.type || '');
|
||||
const fd = new FormData();
|
||||
fd.append('file', lastBlob.value, `ptt.${ext}`);
|
||||
|
||||
const res = await fetch('/api/ptt/reply', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
status.value = `Fehler: ${res.status}`;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
pilotText.value = data.pilotText || '';
|
||||
replyText.value = data.replyText || '';
|
||||
|
||||
if (data.audio?.base64) {
|
||||
const buf = Uint8Array.from(atob(data.audio.base64), c => c.charCodeAt(0));
|
||||
const blob = new Blob([buf], { type: data.audio.mime || 'audio/wav' });
|
||||
audioUrl.value = URL.createObjectURL(blob);
|
||||
} else {
|
||||
audioUrl.value = null;
|
||||
}
|
||||
status.value = 'OK';
|
||||
}
|
||||
</script>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<v-container class="py-8" max-width="900">
|
||||
<v-row class="mb-4" align="center" justify="space-between">
|
||||
<h1 class="text-h5">ATC Generator</h1>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-broom" @click="resetForm">Zurücksetzen</v-btn>
|
||||
</v-row>
|
||||
|
||||
<v-card class="pa-4 mb-6">
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="form.text"
|
||||
label="ATC-Text (z. B. „D-ABCD ready for taxi…“)"
|
||||
rows="4"
|
||||
auto-grow
|
||||
:counter="500"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-slider
|
||||
v-model="form.level"
|
||||
:min="1" :max="5" :step="1"
|
||||
label="Radio-Level (1–5)"
|
||||
show-ticks tick-size="3" thumb-label
|
||||
/>
|
||||
<div class="text-caption mt-1">
|
||||
1 = stark verrauscht · 4 = gut (Default) · 5 = klar
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="form.voice"
|
||||
:items="voices"
|
||||
label="Stimme"
|
||||
item-title="label" item-value="value"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="form.tag" label="Tag (optional)" />
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" class="d-flex gap-3">
|
||||
<v-btn color="primary" :loading="loading" @click="generate">
|
||||
<v-icon start>mdi-play</v-icon> Erzeugen & Abspielen
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" :disabled="!audioUrl" @click="download">
|
||||
<v-icon start>mdi-download</v-icon> OGG herunterladen
|
||||
</v-btn>
|
||||
<v-btn variant="text" :disabled="!result" @click="copyMeta">
|
||||
<v-icon start>mdi-content-copy</v-icon> Meta kopieren
|
||||
</v-btn>
|
||||
<v-spacer/>
|
||||
<v-switch v-model="autoPlay" inset label="Auto-Play" hide-details/>
|
||||
<v-switch v-model="persistHistory" inset label="History speichern" hide-details/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-4 mb-6" v-if="result">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<audio ref="player" :src="audioUrl || undefined" controls style="width:100%"></audio>
|
||||
<div class="text-caption mt-2">MIME: audio/ogg; codecs=opus · 16 kHz mono</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-list density="compact">
|
||||
<v-list-item title="ID" :subtitle="result.id"/>
|
||||
<v-list-item title="Erstellt" :subtitle="new Date(result.createdAt || Date.now()).toLocaleString()"/>
|
||||
<v-list-item title="Level" :subtitle="String(result.level)"/>
|
||||
<v-list-item title="Voice" :subtitle="result.voice"/>
|
||||
<v-list-item title="Eingabe" :subtitle="result.text"/>
|
||||
<v-list-item title="Normalisiert" :subtitle="result.normalized"/>
|
||||
<v-list-item v-if="result.tag" title="Tag" :subtitle="result.tag"/>
|
||||
</v-list>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card v-if="history.length" class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<h2 class="text-subtitle-1">History</h2>
|
||||
<v-btn size="small" variant="text" @click="clearHistory"><v-icon>mdi-delete</v-icon> leeren</v-btn>
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Level</th><th>Voice</th><th>Text</th><th>Play</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in history" :key="h.id">
|
||||
<td>{{ new Date(h.createdAt).toLocaleString() }}</td>
|
||||
<td>{{ h.level }}</td>
|
||||
<td>{{ h.voice }}</td>
|
||||
<td class="truncate">{{ h.text }}</td>
|
||||
<td>
|
||||
<v-btn size="small" @click="playFromHistory(h)"><v-icon>mdi-play</v-icon></v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-snackbar v-model="snack.show" :timeout="3000" color="error">{{ snack.msg }}</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
type Json = any
|
||||
|
||||
const form = ref({ text: '', level: 4, voice: 'alloy', tag: '' })
|
||||
const loading = ref(false)
|
||||
const result = ref<Json | null>(null)
|
||||
const rawJson = ref<Json | null>(null)
|
||||
const audioUrl = ref<string | null>(null)
|
||||
const player = ref<HTMLAudioElement | null>(null)
|
||||
const autoPlay = ref(true)
|
||||
const persistHistory = ref(true)
|
||||
const snack = ref({ show: false, msg: '' })
|
||||
const debugMsg = ref<string>('')
|
||||
|
||||
function err(msg: string) { snack.value = { show: true, msg }; debugMsg.value = msg }
|
||||
const nextTick = (fn: () => void) => Promise.resolve().then(fn)
|
||||
|
||||
// --- Robust: erste Base64 + zugehöriges MIME irgendwo im Objekt finden ---
|
||||
function findFirstAudio(o: Json): { base64: string; mime: string } | null {
|
||||
let found: { base64: string; mime?: string } | null = null
|
||||
function walk(x: Json, path: string[] = []) {
|
||||
if (!x || found) return
|
||||
if (typeof x === 'object') {
|
||||
// Kandidat: {base64, mime}
|
||||
if (typeof x.base64 === 'string') {
|
||||
found = { base64: x.base64, mime: typeof x.mime === 'string' ? x.mime : undefined }
|
||||
}
|
||||
// tiefer gehen
|
||||
for (const k of Object.keys(x)) walk(x[k], path.concat(k))
|
||||
}
|
||||
}
|
||||
walk(o)
|
||||
if (!found) return null
|
||||
const mime = found.mime || guessMimeFromObject(o) || 'audio/ogg'
|
||||
return { base64: found.base64, mime }
|
||||
}
|
||||
|
||||
function guessMimeFromObject(o: Json): string | null {
|
||||
// Häufige Felder prüfen
|
||||
if (o?.audio?.radio?.mime) return o.audio.radio.mime
|
||||
if (o?.audio?.mime) return o.audio.mime
|
||||
if (o?.audio?.clean?.mime) return o.audio.clean.mime
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Base64 normalisieren ---
|
||||
function normalizeBase64(b64: string) {
|
||||
if (!b64) throw new Error('leer')
|
||||
// dataURL → payload
|
||||
const i = b64.indexOf(',')
|
||||
if (i !== -1 && b64.slice(0, 40).includes('base64')) b64 = b64.slice(i + 1)
|
||||
b64 = b64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
|
||||
const mod = b64.length % 4
|
||||
if (mod === 2) b64 += '=='
|
||||
else if (mod === 3) b64 += '='
|
||||
else if (mod === 1) throw new Error('Base64-Länge ungültig')
|
||||
return b64
|
||||
}
|
||||
|
||||
function base64ToBlobUrl(b64: string, mime = 'application/octet-stream') {
|
||||
const clean = normalizeBase64(b64)
|
||||
const bin = atob(clean)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mime })
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const text = form.value.text.trim()
|
||||
if (!text) return err('Text fehlt.')
|
||||
|
||||
loading.value = true
|
||||
debugMsg.value = ''
|
||||
try {
|
||||
const res = await $fetch<Json>('/api/atc/generate', {
|
||||
method: 'POST',
|
||||
body: { text, level: form.value.level, voice: form.value.voice, tag: form.value.tag || undefined }
|
||||
})
|
||||
rawJson.value = res
|
||||
console.log('ATC response:', res)
|
||||
|
||||
// kompatible Anzeigenamen
|
||||
const displayText = res.text ?? res.atcText ?? text
|
||||
result.value = { ...res, text: displayText, createdAt: res.createdAt ?? new Date().toISOString() }
|
||||
|
||||
// Audio extrahieren (egal wo)
|
||||
const audio = findFirstAudio(res)
|
||||
if (!audio) {
|
||||
err('Kein Audio in Response gefunden.')
|
||||
console.warn('Response ohne erkennbares Audio:', res)
|
||||
audioUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// Blob-URL bauen
|
||||
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = base64ToBlobUrl(audio.base64, audio.mime)
|
||||
|
||||
if (autoPlay.value) nextTick(() => player.value?.play().catch(() => {}))
|
||||
if (persistHistory.value) pushHistory(result.value)
|
||||
debugMsg.value = `Audio OK · mime=${audio.mime} · base64Len=${audio.base64.length}`
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
err(e?.data?.message || e?.message || 'Fehler beim Generieren')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!audioUrl.value || !result.value) return
|
||||
const mime =
|
||||
result.value?.audio?.radio?.mime ||
|
||||
result.value?.audio?.mime ||
|
||||
result.value?.audio?.clean?.mime ||
|
||||
'audio/ogg'
|
||||
const ext = mime.includes('ogg') ? 'ogg' : mime.includes('wav') ? 'wav' : 'bin'
|
||||
const a = document.createElement('a')
|
||||
a.href = audioUrl.value
|
||||
a.download = `${result.value?.id || 'atc'}.${ext}`
|
||||
a.click()
|
||||
}
|
||||
|
||||
async function copyMeta() {
|
||||
if (!result.value) return
|
||||
const meta = {
|
||||
id: result.value.id ?? null,
|
||||
createdAt: result.value.createdAt,
|
||||
level: result.value.level,
|
||||
voice: result.value.voice,
|
||||
text: result.value.text ?? '',
|
||||
normalized: result.value.normalized ?? null,
|
||||
stored: result.value.stored ?? null
|
||||
}
|
||||
await navigator.clipboard.writeText(JSON.stringify(meta, null, 2))
|
||||
}
|
||||
|
||||
const history = ref<Json[]>([])
|
||||
function pushHistory(e: Json) {
|
||||
const list = [e, ...history.value].slice(0, 50)
|
||||
history.value = list
|
||||
localStorage.setItem('atc-history', JSON.stringify(list))
|
||||
}
|
||||
function loadHistory() {
|
||||
const raw = localStorage.getItem('atc-history')
|
||||
if (raw) { try { history.value = JSON.parse(raw) } catch {} }
|
||||
}
|
||||
function clearHistory() { history.value = []; localStorage.removeItem('atc-history') }
|
||||
function playFromHistory(h: Json) {
|
||||
try {
|
||||
const audio = findFirstAudio(h)
|
||||
if (!audio) return err('History-Eintrag ohne Audio.')
|
||||
if (audioUrl.value) URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = base64ToBlobUrl(audio.base64, audio.mime)
|
||||
result.value = h
|
||||
nextTick(() => player.value?.play().catch(() => {}))
|
||||
} catch (e: any) { err(e.message) }
|
||||
}
|
||||
|
||||
onMounted(loadHistory)
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.truncate {
|
||||
max-width: 420px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,142 +0,0 @@
|
||||
// audioRadioTTS.ts
|
||||
/** AUDIO: English TTS with radio ambience (noise + bandlimit + PTT click) **/
|
||||
declare const cfg:
|
||||
| { value?: { tts?: boolean } }
|
||||
| undefined; // optional global (Nuxt/Pinia o.ä.)
|
||||
|
||||
let _ctx: AudioContext | null = null;
|
||||
let _masterGain: GainNode;
|
||||
let _noiseGain: GainNode;
|
||||
let _noiseFilterBand: BiquadFilterNode;
|
||||
let _noiseFilterHP: BiquadFilterNode;
|
||||
let _noiseFilterLP: BiquadFilterNode;
|
||||
let _compressor: DynamicsCompressorNode;
|
||||
|
||||
function ensureAudioGraph() {
|
||||
if (_ctx) return;
|
||||
_ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
|
||||
_masterGain = _ctx.createGain();
|
||||
_masterGain.gain.value = 0.9;
|
||||
|
||||
// Background "radio" noise
|
||||
const noiseBuffer = _ctx.createBuffer(1, _ctx.sampleRate * 2, _ctx.sampleRate);
|
||||
const data = noiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
data[i] = (data[i - 1] || 0) * 0.97 + w * 0.03; // cheap pink-ish
|
||||
}
|
||||
const noise = _ctx.createBufferSource();
|
||||
noise.buffer = noiseBuffer;
|
||||
noise.loop = true;
|
||||
|
||||
_noiseFilterHP = _ctx.createBiquadFilter();
|
||||
_noiseFilterHP.type = "highpass";
|
||||
_noiseFilterHP.frequency.value = 300;
|
||||
|
||||
_noiseFilterBand = _ctx.createBiquadFilter();
|
||||
_noiseFilterBand.type = "bandpass";
|
||||
_noiseFilterBand.frequency.value = 1600;
|
||||
_noiseFilterBand.Q.value = 0.6;
|
||||
|
||||
_noiseFilterLP = _ctx.createBiquadFilter();
|
||||
_noiseFilterLP.type = "lowpass";
|
||||
_noiseFilterLP.frequency.value = 3200;
|
||||
|
||||
_compressor = _ctx.createDynamicsCompressor();
|
||||
_compressor.threshold.value = -24;
|
||||
_compressor.knee.value = 20;
|
||||
_compressor.ratio.value = 6;
|
||||
_compressor.attack.value = 0.003;
|
||||
_compressor.release.value = 0.1;
|
||||
|
||||
_noiseGain = _ctx.createGain();
|
||||
_noiseGain.gain.value = 0.07;
|
||||
|
||||
noise
|
||||
.connect(_noiseFilterHP)
|
||||
.connect(_noiseFilterBand)
|
||||
.connect(_noiseFilterLP)
|
||||
.connect(_noiseGain)
|
||||
.connect(_compressor)
|
||||
.connect(_masterGain)
|
||||
.connect(_ctx.destination);
|
||||
|
||||
noise.start();
|
||||
}
|
||||
|
||||
function pttClick(durationMs = 25, gain = 0.5) {
|
||||
if (!_ctx) return;
|
||||
const osc = _ctx.createOscillator();
|
||||
const g = _ctx.createGain();
|
||||
osc.type = "square";
|
||||
osc.frequency.value = 1200 + Math.random() * 600;
|
||||
g.gain.value = gain;
|
||||
osc.connect(g).connect(_masterGain);
|
||||
osc.start();
|
||||
const now = _ctx.currentTime;
|
||||
g.gain.setValueAtTime(gain, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, now + durationMs / 1000);
|
||||
osc.stop(now + durationMs / 1000 + 0.02);
|
||||
}
|
||||
|
||||
/** Helpers */
|
||||
function isTtsEnabled(): boolean {
|
||||
// Wenn cfg fehlt → default: true (nicht crashen, trotzdem sprechen)
|
||||
try {
|
||||
// @ts-ignore – cfg evtl. global
|
||||
const val = (cfg as any)?.value?.tts;
|
||||
return val ?? true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Main speak with radio ambience */
|
||||
export default function speak(text: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("speechSynthesis" in window)) return;
|
||||
if (!isTtsEnabled()) return;
|
||||
|
||||
ensureAudioGraph();
|
||||
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.lang = "en-US"; // or "en-GB"
|
||||
u.rate = 1.0;
|
||||
u.pitch = 1.0;
|
||||
|
||||
u.onstart = () => {
|
||||
_ctx?.resume();
|
||||
pttClick(18, 0.35);
|
||||
if (_noiseGain) {
|
||||
const now = _ctx!.currentTime;
|
||||
_noiseGain.gain.cancelScheduledValues(now);
|
||||
_noiseGain.gain.setValueAtTime(_noiseGain.gain.value, now);
|
||||
_noiseGain.gain.linearRampToValueAtTime(0.1, now + 0.05);
|
||||
}
|
||||
};
|
||||
|
||||
u.onend = () => {
|
||||
pttClick(22, 0.28);
|
||||
if (_noiseGain) {
|
||||
const now = _ctx!.currentTime;
|
||||
_noiseGain.gain.cancelScheduledValues(now);
|
||||
_noiseGain.gain.setValueAtTime(_noiseGain.gain.value, now);
|
||||
_noiseGain.gain.linearRampToValueAtTime(0.07, now + 0.2);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(u);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
|
||||
export function createRadioTTS(isEnabled: () => boolean = () => true) {
|
||||
return (text: string) => {
|
||||
if (!isEnabled()) return;
|
||||
speak(text);
|
||||
};
|
||||
}
|
||||
// usage: const speakRadio = createRadioTTS(() => cfg?.value?.tts ?? true);
|
||||
@@ -1,198 +1,185 @@
|
||||
{
|
||||
"meta": {
|
||||
"filename": "vatsim_communication_template.json",
|
||||
"source": ":contentReference[oaicite:0]{index=0}"
|
||||
[
|
||||
{
|
||||
"frequency": "Delivery",
|
||||
"action": "Flight Plan",
|
||||
"pilot": "Delivery, good day, ${callsign} at stand ${gate}, with information ${atis}, requesting clearance to ${destination}",
|
||||
"atc": "${callsign}, ${airport} Delivery, good day, clearance is to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}",
|
||||
"pilotResponse": "Cleared to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}, ${callsign}"
|
||||
"atcResponse": "${callsign}, readback is correct, report ready for startup"
|
||||
},
|
||||
"columns": ["frequency", "action", "pilot", "atc", "pilotResponse"],
|
||||
"rows": [
|
||||
{
|
||||
"frequency": "Delivery",
|
||||
"action": "Flight Plan",
|
||||
"pilot": "Delivery, good day, ${callsign} at stand ${gate}, with information ${atis}, requesting clearance to ${destination}",
|
||||
"atc": "${callsign}, ${airport} Delivery, good day, clearance is to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}",
|
||||
"pilotResponse": "Cleared to ${destination}, ${departureRoute} departure, runway ${runway}, squawk ${squawk}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Delivery",
|
||||
"action": "Flight Plan – ATC Reply",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, readback is correct, report ready for startup",
|
||||
"pilotResponse": "Wilco, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Startup",
|
||||
"action": "Clearance",
|
||||
"pilot": "${callsign} is ready for startup.",
|
||||
"atc": "${callsign}, roger, startup is approved, for the pushback contact ground on ${groundFreq}",
|
||||
"pilotResponse": "Startup approved and contact ground on ${groundFreq} for the pushback, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Pushback",
|
||||
"pilot": "Ground, good day, ${callsign} at stand ${gate}, requesting pushback",
|
||||
"atc": "${callsign}, ${airport} Ground, good day, pushback is approved.",
|
||||
"pilotResponse": "Pushback approved, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Taxi",
|
||||
"action": "Taxiing",
|
||||
"pilot": "${callsign}, request taxi",
|
||||
"atc": "${callsign}, taxi to ${holdingPointOrRunway} via ${taxiRoute}",
|
||||
"pilotResponse": "Taxi to ${holdingPointOrRunway} via ${taxiRoute}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Give way to airplane",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, give way to the ${otherAirline} ${otherType} from the ${direction}",
|
||||
"pilotResponse": "Give way to the ${otherAirline} ${otherType} from the ${direction}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, at ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "At ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Lineup and Wait",
|
||||
"pilot": "${airport} Tower, good day, ${callsign} at ${holdingPoint}, ready for departure",
|
||||
"atc": "${callsign}, ${airport} Tower, good day, line up and wait runway ${runway}",
|
||||
"pilotResponse": "Line up and wait runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Takeoff Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared for takeoff.",
|
||||
"pilotResponse": "Cleared for takeoff runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Handoff to Departure",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${airport} Departure on ${depFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${airport} Departure on ${depFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Contacting Departure",
|
||||
"pilot": "${airport} Departure, good day, ${callsign}, passing ${altitude}, ${depRouteOrHeading}",
|
||||
"atc": "${callsign}, ${airport} Departure, identified, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Direct Route",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, direct to ${vorFix}",
|
||||
"pilotResponse": "Direct ${vorFix}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Handoff to Nearest Center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nearestCenter} on ${centerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nearestCenter} on ${centerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting Center",
|
||||
"pilot": "${nearestCenter}, good day, ${callsign}, passing ${altitude}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, ${nearestCenter}, radar contact, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Descent",
|
||||
"action": "Request Descent",
|
||||
"pilot": "${callsign}, request descent",
|
||||
"atc": "${callsign}, descend to ${flightLevel}",
|
||||
"pilotResponse": "Descending to ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Handoff to another center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nextCenter} on ${nextCenterFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nextCenter} on ${nextCenterFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting another center and getting an approach",
|
||||
"pilot": "${nextCenter}, good day, ${callsign}, passing ${altitude} for ${flightLevel}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, good day, radar contact, ${star}, expect ${approachType} runway ${runway}, descend ${flightLevel}",
|
||||
"pilotResponse": "${star}, ${approachType} runway ${runway} and descend ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Handoff to Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Approach on ${approachFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Approach on ${approachFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Contacting Approach",
|
||||
"pilot": "${arrivalAirport} Approach, good day, ${callsign}, ${flightLevel}, ${star}",
|
||||
"atc": "${callsign}, ${arrivalAirport} Approach, good day, continue approach",
|
||||
"pilotResponse": "Continue approach, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, descend ${flightLevel} and after ${vorFix} fly heading ${heading}",
|
||||
"pilotResponse": "Descend ${flightLevel} and after ${vorFix} fly heading ${heading}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 2",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, fly heading ${heading}, descend ${flightLevel}, QNH ${qnh}",
|
||||
"pilotResponse": "Fly heading ${heading}, descend ${flightLevel} on QNH ${qnh}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 3",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, speed ${speed}",
|
||||
"pilotResponse": "Left heading ${heading} and speed ${speed}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Cleared Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, cleared ${approachType} runway ${runway}",
|
||||
"pilotResponse": "Turn left heading ${heading}, cleared ${approachType} runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Tower on ${towerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Contacting Tower",
|
||||
"pilot": "${arrivalAirport} Tower, ${callsign}, ${approachType} runway ${runway}",
|
||||
"atc": "${callsign}, good day, ${sequenceInfo}",
|
||||
"pilotResponse": ""
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Landing Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared to land",
|
||||
"pilotResponse": "Cleared to land runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Taxiing (Arrival)",
|
||||
"pilot": "${callsign}, ${arrivalAirport} Ground, taxi to stand ${stand} via ${taxiRoute}",
|
||||
"atc": "Taxi to stand ${stand} via ${taxiRoute}, ${callsign}",
|
||||
"pilotResponse": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"frequency": "Startup",
|
||||
"action": "Clearance",
|
||||
"pilot": "${callsign} is ready for startup.",
|
||||
"atc": "${callsign}, roger, startup is approved, for the pushback contact ground on ${groundFreq}",
|
||||
"pilotResponse": "Startup approved and contact ground on ${groundFreq} for the pushback, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Pushback",
|
||||
"pilot": "Ground, good day, ${callsign} at stand ${gate}, requesting pushback",
|
||||
"atc": "${callsign}, ${airport} Ground, good day, pushback is approved.",
|
||||
"pilotResponse": "Pushback approved, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Taxi",
|
||||
"action": "Taxiing",
|
||||
"pilot": "${callsign}, request taxi",
|
||||
"atc": "${callsign}, taxi to ${holdingPointOrRunway} via ${taxiRoute}",
|
||||
"pilotResponse": "Taxi to ${holdingPointOrRunway} via ${taxiRoute}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Give way to airplane",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, give way to the ${otherAirline} ${otherType} from the ${direction}",
|
||||
"pilotResponse": "Give way to the ${otherAirline} ${otherType} from the ${direction}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, at ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "At ${holdingPointOrRunway} hold short and contact Tower on ${towerFreq}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Lineup and Wait",
|
||||
"pilot": "${airport} Tower, good day, ${callsign} at ${holdingPoint}, ready for departure",
|
||||
"atc": "${callsign}, ${airport} Tower, good day, line up and wait runway ${runway}",
|
||||
"pilotResponse": "Line up and wait runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Takeoff Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared for takeoff.",
|
||||
"pilotResponse": "Cleared for takeoff runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Handoff to Departure",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${airport} Departure on ${depFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${airport} Departure on ${depFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Departure",
|
||||
"action": "Contacting Departure",
|
||||
"pilot": "${airport} Departure, good day, ${callsign}, passing ${altitude}, ${depRouteOrHeading}",
|
||||
"atc": "${callsign}, ${airport} Departure, identified, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Direct Route",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, direct to ${vorFix}",
|
||||
"pilotResponse": "Direct ${vorFix}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Enroute",
|
||||
"action": "Handoff to Nearest Center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nearestCenter} on ${centerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nearestCenter} on ${centerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting Center",
|
||||
"pilot": "${nearestCenter}, good day, ${callsign}, passing ${altitude}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, ${nearestCenter}, radar contact, climb ${flightLevel}",
|
||||
"pilotResponse": "Climb ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Descent",
|
||||
"action": "Request Descent",
|
||||
"pilot": "${callsign}, request descent",
|
||||
"atc": "${callsign}, descend to ${flightLevel}",
|
||||
"pilotResponse": "Descending to ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Handoff to another center",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${nextCenter} on ${nextCenterFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${nextCenter} on ${nextCenterFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Center",
|
||||
"action": "Contacting another center and getting an approach",
|
||||
"pilot": "${nextCenter}, good day, ${callsign}, passing ${altitude} for ${flightLevel}, inbound ${vorFixOrHeading}",
|
||||
"atc": "${callsign}, good day, radar contact, ${star}, expect ${approachType} runway ${runway}, descend ${flightLevel}",
|
||||
"pilotResponse": "${star}, ${approachType} runway ${runway} and descend ${flightLevel}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Handoff to Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Approach on ${approachFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Approach on ${approachFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Contacting Approach",
|
||||
"pilot": "${arrivalAirport} Approach, good day, ${callsign}, ${flightLevel}, ${star}",
|
||||
"atc": "${callsign}, ${arrivalAirport} Approach, good day, continue approach",
|
||||
"pilotResponse": "Continue approach, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, descend ${flightLevel} and after ${vorFix} fly heading ${heading}",
|
||||
"pilotResponse": "Descend ${flightLevel} and after ${vorFix} fly heading ${heading}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 2",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, fly heading ${heading}, descend ${flightLevel}, QNH ${qnh}",
|
||||
"pilotResponse": "Fly heading ${heading}, descend ${flightLevel} on QNH ${qnh}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Vectoring 3",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, speed ${speed}",
|
||||
"pilotResponse": "Left heading ${heading} and speed ${speed}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Approach",
|
||||
"action": "Cleared Approach",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, turn left heading ${heading}, cleared ${approachType} runway ${runway}",
|
||||
"pilotResponse": "Turn left heading ${heading}, cleared ${approachType} runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Handoff to Tower",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, contact ${arrivalAirport} Tower on ${towerFreq}, bye bye",
|
||||
"pilotResponse": "Contact ${arrivalAirport} Tower on ${towerFreq}, ${callsign}, bye bye"
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Contacting Tower",
|
||||
"pilot": "${arrivalAirport} Tower, ${callsign}, ${approachType} runway ${runway}",
|
||||
"atc": "${callsign}, good day, ${sequenceInfo}",
|
||||
"pilotResponse": ""
|
||||
},
|
||||
{
|
||||
"frequency": "Tower",
|
||||
"action": "Landing Clearance",
|
||||
"pilot": "",
|
||||
"atc": "${callsign}, wind ${wind}, runway ${runway}, cleared to land",
|
||||
"pilotResponse": "Cleared to land runway ${runway}, ${callsign}"
|
||||
},
|
||||
{
|
||||
"frequency": "Ground",
|
||||
"action": "Taxiing (Arrival)",
|
||||
"pilot": "${callsign}, ${arrivalAirport} Ground, taxi to stand ${stand} via ${taxiRoute}",
|
||||
"atc": "Taxi to stand ${stand} via ${taxiRoute}, ${callsign}",
|
||||
"pilotResponse": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { getQuery, createError } from "h3";
|
||||
import { spawn } from "node:child_process";
|
||||
import { openai, TTS_MODEL, normalizeATC } from "../../utils/openai";
|
||||
|
||||
function buildRadioFilter(level: number) {
|
||||
const L = Math.max(1, Math.min(5, Math.floor(level || 4)));
|
||||
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 dropout = (period: number, dur: number) =>
|
||||
`volume=enable='lt(mod(t\\,${period})\\,${dur})':volume=0`;
|
||||
const tail = `aecho=0.6:0.7:8:0.08,acompressor=threshold=0.6:ratio=6:attack=20:release=200`;
|
||||
|
||||
// helpers (ACHTUNG: weights **gequotet** oder mit | getrennt)
|
||||
const mixCrush = (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 mixNoise = (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 dropAndTail = (inLabel: string, period?: number, dur?: number) => {
|
||||
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;
|
||||
};
|
||||
|
||||
if (L === 5)
|
||||
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]`,
|
||||
...mixNoise(0.03, "pre", "mix"),
|
||||
`[mix]${tail}[out]`,
|
||||
].join(";");
|
||||
|
||||
if (L === 3)
|
||||
return [
|
||||
`[0:a]${band(350,3200)},volume=1.1[pre]`,
|
||||
...mixCrush(12, 0.22, "pre", "mix1"),
|
||||
...mixNoise(0.05, "mix1", "mix2"),
|
||||
...dropAndTail("mix2", 6, 0.06),
|
||||
].join(";");
|
||||
|
||||
if (L === 2)
|
||||
return [
|
||||
`[0:a]${band(400,3000)},volume=1.05[pre]`,
|
||||
...mixCrush(10, 0.32, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.08[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights='1 0.6':duration=shortest[mix2]`,
|
||||
...dropAndTail("mix2", 4.5, 0.12),
|
||||
].join(";");
|
||||
|
||||
// L === 1
|
||||
return [
|
||||
`[0:a]${band(500,2600,5)},volume=1.0[pre]`,
|
||||
...mixCrush(8, 0.45, "pre", "mix1"),
|
||||
`anoisesrc=color=white:amplitude=0.12[ns]`,
|
||||
`[mix1][ns]amix=inputs=2:weights='1 0.8':duration=shortest[mix2]`,
|
||||
...dropAndTail("mix2", 3.5, 0.2),
|
||||
].join(";");
|
||||
}
|
||||
|
||||
// super-simpler Fallback ohne Mix/Noise (falls Filter scheitert)
|
||||
const SIMPLE_FILTER = `[0:a]highpass=f=350,lowpass=f=3000,acompressor=threshold=0.6:ratio=6:attack=20:release=200[out]`;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const q = getQuery(event);
|
||||
const raw = String(q.text || "").trim();
|
||||
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
||||
|
||||
const level = Math.max(1, Math.min(5, parseInt(String(q.level ?? "4"), 10) || 4));
|
||||
const voice = String(q.voice || "alloy");
|
||||
const normalized = normalizeATC(raw) || raw;
|
||||
|
||||
// 1) TTS → WAV
|
||||
const tts = await openai.audio.speech.create({
|
||||
model: TTS_MODEL,
|
||||
voice,
|
||||
format: "wav",
|
||||
input: normalized,
|
||||
});
|
||||
const wav = Buffer.from(await tts.arrayBuffer());
|
||||
if (wav.byteLength < 100) throw createError({ statusCode: 500, statusMessage: "TTS empty" });
|
||||
|
||||
async function runFfmpeg(filter: string) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const ff = spawn("ffmpeg", [
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "wav", "-i", "pipe:0",
|
||||
"-filter_complex", filter,
|
||||
"-map", "[out]",
|
||||
"-ac", "1", "-ar", "16000",
|
||||
"-c:a", "libopus", "-b:a", "12k", "-application", "voip",
|
||||
"-f", "ogg", "pipe:1",
|
||||
], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
const res = event.node.res;
|
||||
let started = false;
|
||||
let ffErr = "";
|
||||
|
||||
ff.stderr.on("data", d => { ffErr += d.toString(); });
|
||||
|
||||
ff.stdout.once("data", (chunk: Buffer) => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "audio/ogg");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Accept-Ranges", "none");
|
||||
(res as any).flushHeaders?.();
|
||||
}
|
||||
res.write(chunk);
|
||||
ff.stdout.pipe(res);
|
||||
});
|
||||
|
||||
ff.stdin.on("error", () => {});
|
||||
ff.stdin.write(wav);
|
||||
ff.stdin.end();
|
||||
|
||||
ff.on("close", (code) => {
|
||||
if (!started) {
|
||||
// Fehlerpfad → JSON-Fehler zurück
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
}
|
||||
res.end(JSON.stringify({ error: true, message: ffErr || `ffmpeg exit ${code}` }));
|
||||
} else {
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// erst komplexer Filter; wenn der fehlschlägt → SIMPLE_FILTER
|
||||
await runFfmpeg(buildRadioFilter(level));
|
||||
if (!event.node.res.headersSent) {
|
||||
// zweiter Versuch
|
||||
await runFfmpeg(SIMPLE_FILTER);
|
||||
}
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { readMultipartFormData, createError } from "h3";
|
||||
import { openai, LLM_MODEL, TTS_MODEL, atcReplyPrompt } from "../../utils/openai";
|
||||
import { applyRadioEffect } from "../../utils/radio";
|
||||
import { writeFile, rm, readFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { toFile } from "openai/uploads";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parts = await readMultipartFormData(event);
|
||||
if (!parts) throw createError({ statusCode: 400, statusMessage: "No form-data" });
|
||||
|
||||
// Default ohne Text-Input: immer TTS
|
||||
const audio = parts.find(p => p.type && p.data);
|
||||
if (!audio) throw createError({ statusCode: 400, statusMessage: "No audio file" });
|
||||
|
||||
const file = await toFile(
|
||||
audio.data,
|
||||
audio.filename || "ptt.webm",
|
||||
{ type: audio.type || "audio/webm" }
|
||||
);
|
||||
|
||||
// 1) Transkription (Whisper)
|
||||
const tr = await openai.audio.transcriptions.create({ model: "whisper-1", file });
|
||||
const pilotText = (tr.text || "").trim();
|
||||
if (!pilotText) throw createError({ statusCode: 400, statusMessage: "Empty transcription" });
|
||||
|
||||
// 2) ATC-Antwort (LLM)
|
||||
const resp = await openai.responses.create({
|
||||
model: LLM_MODEL,
|
||||
input: atcReplyPrompt(pilotText),
|
||||
});
|
||||
const replyText = (resp.output_text || "").trim();
|
||||
if (!replyText) throw createError({ statusCode: 500, statusMessage: "LLM empty" });
|
||||
|
||||
// 3) TTS + Funk-Effekt
|
||||
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 readFile(radio);
|
||||
const b64 = Buffer.from(data).toString("base64");
|
||||
rm(clean).catch(() => {});
|
||||
rm(radio).catch(() => {});
|
||||
|
||||
return { pilotText, replyText, audio: { mime: "audio/wav", base64: b64 } };
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { readMultipartFormData, createError } from "h3";
|
||||
import { openai } from "../../utils/openai";
|
||||
import { toFile } from "openai/uploads"; // ⟵ NEU
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parts = await readMultipartFormData(event);
|
||||
if (!parts) throw createError({ statusCode: 400, statusMessage: "No form-data" });
|
||||
|
||||
const audio = parts.find(p => p.type && p.data);
|
||||
if (!audio) throw createError({ statusCode: 400, statusMessage: "No audio file" });
|
||||
|
||||
const file = await toFile(
|
||||
audio.data, // Buffer
|
||||
audio.filename || "ptt.webm", // ⟵ mit Endung
|
||||
{ type: audio.type || "audio/webm" } // ⟵ korrekter MIME
|
||||
);
|
||||
|
||||
const tr = await openai.audio.transcriptions.create({
|
||||
model: "whisper-1",
|
||||
file
|
||||
});
|
||||
|
||||
return { text: tr.text };
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export default defineEventHandler(() => ({status:'ok'}));
|
||||
Reference in New Issue
Block a user