kann ein bisschen normalized sprechen

This commit is contained in:
itsrubberduck
2025-09-14 23:20:01 +02:00
parent 5f78847d88
commit d71f0ca8b4
8 changed files with 1153 additions and 61 deletions

109
app/pages/atc.vue Normal file
View 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>

View File

@@ -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>