/** * Audio-Service fuer Sprach-Ein-/Ausgabe * * Verwaltet Mikrofon-Aufnahme (mit VAD/Auto-Stop bei Stille), * TTS-Audiowiedergabe und Metering fuer visuelle Feedback. * Nutzt react-native-audio-recorder-player fuer Aufnahme. */ import { Platform, PermissionsAndroid } from 'react-native'; import Sound from 'react-native-sound'; import RNFS from 'react-native-fs'; import AudioRecorderPlayer, { AudioEncoderAndroidType, AudioSourceAndroidType, AVEncodingOption, OutputFormatAndroidType, } from 'react-native-audio-recorder-player'; // --- Typen --- export interface RecordingResult { /** Base64-kodierte Audiodaten */ base64: string; /** Dauer in Millisekunden */ durationMs: number; /** MIME-Type (z.B. audio/wav) */ mimeType: string; } export type RecordingState = 'idle' | 'recording' | 'processing'; type RecordingStateCallback = (state: RecordingState) => void; type MeterCallback = (db: number) => void; type SilenceCallback = () => void; // --- Konstanten --- const AUDIO_SAMPLE_RATE = 16000; const AUDIO_CHANNELS = 1; const AUDIO_ENCODING = 'audio/wav'; // VAD (Voice Activity Detection) — Stille-Erkennung const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt const VAD_SILENCE_DURATION_MS = 1800; // ms Stille bevor Auto-Stop // --- Audio-Service --- class AudioService { private recordingState: RecordingState = 'idle'; private recordingStartTime: number = 0; private stateListeners: RecordingStateCallback[] = []; private meterListeners: MeterCallback[] = []; private silenceListeners: SilenceCallback[] = []; private currentSound: Sound | null = null; private recorder: AudioRecorderPlayer; private recordingPath: string = ''; // VAD State private vadEnabled: boolean = false; private lastSpeechTime: number = 0; private vadTimer: ReturnType | null = null; constructor() { this.recorder = new AudioRecorderPlayer(); this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates } // --- Berechtigungen --- async requestMicrophonePermission(): Promise { if (Platform.OS !== 'android') { return true; } try { const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, { title: 'ARIA Cockpit - Mikrofon', message: 'ARIA benoetigt Zugriff auf das Mikrofon fuer Spracheingabe.', buttonPositive: 'Erlauben', buttonNegative: 'Ablehnen', }, ); return granted === PermissionsAndroid.RESULTS.GRANTED; } catch (err) { console.error('[Audio] Fehler bei Berechtigungsanfrage:', err); return false; } } // --- Aufnahme --- /** Mikrofon-Aufnahme starten */ async startRecording(autoStop: boolean = false): Promise { if (this.recordingState !== 'idle') { console.warn('[Audio] Aufnahme laeuft bereits'); return false; } const hasPermission = await this.requestMicrophonePermission(); if (!hasPermission) { console.warn('[Audio] Keine Mikrofon-Berechtigung'); return false; } try { // Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert) this.stopPlayback(); this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`; // Aufnahme mit Metering starten await this.recorder.startRecorder(this.recordingPath, { AudioEncoderAndroid: AudioEncoderAndroidType.AAC, AudioSourceAndroid: AudioSourceAndroidType.MIC, OutputFormatAndroid: OutputFormatAndroidType.MPEG_4, }, true); // meteringEnabled = true // Metering-Callback this.recorder.addRecordBackListener((e) => { const db = e.currentMetering ?? -160; this.meterListeners.forEach(cb => cb(db)); // VAD: Stille erkennen if (this.vadEnabled) { if (db > VAD_SILENCE_THRESHOLD_DB) { this.lastSpeechTime = Date.now(); } } }); this.recordingStartTime = Date.now(); this.lastSpeechTime = Date.now(); this.setState('recording'); // VAD aktivieren this.vadEnabled = autoStop; if (autoStop) { this.vadTimer = setInterval(() => { const silenceDuration = Date.now() - this.lastSpeechTime; if (silenceDuration >= VAD_SILENCE_DURATION_MS) { console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`); this.silenceListeners.forEach(cb => cb()); } }, 200); } console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop); return true; } catch (err) { console.error('[Audio] Fehler beim Starten der Aufnahme:', err); this.setState('idle'); return false; } } /** Aufnahme stoppen und Ergebnis zurueckgeben */ async stopRecording(): Promise { if (this.recordingState !== 'recording') { console.warn('[Audio] Keine aktive Aufnahme'); return null; } this.setState('processing'); this.vadEnabled = false; if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; } try { await this.recorder.stopRecorder(); this.recorder.removeRecordBackListener(); const durationMs = Date.now() - this.recordingStartTime; // Audio-Datei als Base64 lesen const base64Data = await RNFS.readFile(this.recordingPath, 'base64'); // Temp-Datei aufraeumen RNFS.unlink(this.recordingPath).catch(() => {}); this.setState('idle'); console.log(`[Audio] Aufnahme beendet (${durationMs}ms, ${Math.round(base64Data.length / 1024)}KB)`); return { base64: base64Data, durationMs, mimeType: 'audio/mp4', // AAC in MP4 Container }; } catch (err) { console.error('[Audio] Fehler beim Stoppen der Aufnahme:', err); this.setState('idle'); return null; } } // --- Wiedergabe --- /** Base64-kodiertes Audio abspielen (z.B. TTS-Antwort von ARIA) */ async playAudio(base64Data: string): Promise { if (!base64Data) return; // Laufende Wiedergabe stoppen this.stopPlayback(); try { // Base64 -> temporaere WAV-Datei -> Sound abspielen const tmpPath = `${RNFS.CachesDirectoryPath}/aria_tts_${Date.now()}.wav`; await RNFS.writeFile(tmpPath, base64Data, 'base64'); this.currentSound = new Sound(tmpPath, '', (error) => { if (error) { console.error('[Audio] Fehler beim Laden:', error); RNFS.unlink(tmpPath).catch(() => {}); return; } this.currentSound?.play((success) => { if (success) { console.log('[Audio] Wiedergabe abgeschlossen'); } else { console.warn('[Audio] Wiedergabe fehlgeschlagen'); } this.currentSound?.release(); this.currentSound = null; RNFS.unlink(tmpPath).catch(() => {}); }); }); } catch (err) { console.error('[Audio] Wiedergabefehler:', err); } } /** Laufende Wiedergabe stoppen */ stopPlayback(): void { if (this.currentSound) { this.currentSound.stop(); this.currentSound.release(); this.currentSound = null; } } // --- Status & Callbacks --- getRecordingState(): RecordingState { return this.recordingState; } /** Callback fuer Aufnahmestatus-Aenderungen */ onStateChange(callback: RecordingStateCallback): () => void { this.stateListeners.push(callback); return () => { this.stateListeners = this.stateListeners.filter(cb => cb !== callback); }; } /** Callback fuer Metering-Updates (dB Werte waehrend Aufnahme) */ onMeterUpdate(callback: MeterCallback): () => void { this.meterListeners.push(callback); return () => { this.meterListeners = this.meterListeners.filter(cb => cb !== callback); }; } /** Callback wenn VAD Stille erkennt (Auto-Stop) */ onSilenceDetected(callback: SilenceCallback): () => void { this.silenceListeners.push(callback); return () => { this.silenceListeners = this.silenceListeners.filter(cb => cb !== callback); }; } private setState(state: RecordingState): void { if (this.recordingState !== state) { this.recordingState = state; this.stateListeners.forEach(cb => cb(state)); } } } // Singleton const audioService = new AudioService(); export default audioService;