mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
kann ein bisschen normalized sprechen
This commit is contained in:
109
app/pages/atc.vue
Normal file
109
app/pages/atc.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<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,58 +1,116 @@
|
||||
<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">
|
||||
<v-btn
|
||||
:color="recording ? 'red' : 'primary'"
|
||||
@mousedown="startRec" @mouseup="stopRec"
|
||||
@touchstart.prevent="startRec" @touchend.prevent="stopRec"
|
||||
>
|
||||
{{ recording ? 'Recording…' : 'Push-to-Talk' }}
|
||||
</v-btn>
|
||||
<v-btn class="ml-3" :disabled="!lastBlob" @click="send('text')">→ Text</v-btn>
|
||||
<v-btn class="ml-3" :disabled="!lastBlob" @click="send('tts')">→ TTS</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), status = ref('Bereit');
|
||||
let mediaRecorder: MediaRecorder | null = null; let chunks: BlobPart[] = [];
|
||||
const lastBlob = ref<Blob|null>(null), pilotText = ref(''), replyText = ref(''), audioUrl = ref<string|null>(null);
|
||||
|
||||
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 = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
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.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;
|
||||
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;
|
||||
recording.value=true; status.value='Aufnahme…'; chunks=[]; mediaRecorder.start(10);
|
||||
|
||||
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 stopRec() {
|
||||
if (!mediaRecorder || !recording.value) return;
|
||||
recording.value = false;
|
||||
status.value = 'Verarbeite…';
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
async function send(mode:'text'|'tts'){ if(!lastBlob.value)return;
|
||||
status.value=`Sende (${mode})…`;
|
||||
const fd = new FormData(); fd.append('mode', mode); fd.append('file', lastBlob.value, 'ptt.webm');
|
||||
const res = await fetch('/api/ptt/reply',{ method:'POST', body:fd });
|
||||
if(!res.ok){ status.value=`Fehler ${res.status}`; return; }
|
||||
|
||||
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 || '';
|
||||
pilotText.value = data.pilotText || '';
|
||||
replyText.value = data.replyText || '';
|
||||
|
||||
if (data.audio?.base64) {
|
||||
const buf = Uint8Array.from(atob(data.audio.base64), c => c.charCodeAt(0));
|
||||
audioUrl.value = URL.createObjectURL(new Blob([buf], { type: 'audio/wav' }));
|
||||
const blob = new Blob([buf], { type: data.audio.mime || 'audio/wav' });
|
||||
audioUrl.value = URL.createObjectURL(blob);
|
||||
} else {
|
||||
audioUrl.value = null;
|
||||
}
|
||||
status.value='OK';
|
||||
status.value = 'OK';
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user