Files
ARIA-AGENT/android/src/services/audio.ts
T
duffyduck b1eaf42fef fix(audio): Spotify resumed nach Mute — RNSound's haengenden Focus loesen
Logs zeigten: react-native-sound requestet beim Sound.play() einen
EIGENEN AudioFocus mit USAGE_MEDIA, released den aber bei Sound.stop()/
release() NICHT (bekanntes RN-sound-Bug). Spotify sieht den haengenden
Media-Focus → bleibt pausiert.

Workaround: Native-Methode kickReleaseMedia() macht einen request+abandon-
Cycle mit USAGE_MEDIA, das System raeumt damit den Focus-Stack auf und
Spotify bekommt sauberen GAIN-Event. stopPlayback ruft das jetzt nach
Sound.release() wenn vorher ein RNSound aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:57:52 +02:00

1368 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, NativeModules, ToastAndroid, NativeEventEmitter } from 'react-native';
import Sound from 'react-native-sound';
import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio';
import AudioRecorderPlayer, {
AudioEncoderAndroidType,
AudioSourceAndroidType,
AVEncodingOption,
OutputFormatAndroidType,
} from 'react-native-audio-recorder-player';
// Base64-Encoder fuer Binary-Strings (Header-Bytes → Base64)
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function btoaSafe(bin: string): string {
let out = '';
const len = bin.length;
for (let i = 0; i < len; i += 3) {
const b1 = bin.charCodeAt(i) & 0xff;
const b2 = i + 1 < len ? bin.charCodeAt(i + 1) & 0xff : 0;
const b3 = i + 2 < len ? bin.charCodeAt(i + 2) & 0xff : 0;
out += B64_CHARS[b1 >> 2];
out += B64_CHARS[((b1 & 0x03) << 4) | (b2 >> 4)];
out += i + 1 < len ? B64_CHARS[((b2 & 0x0f) << 2) | (b3 >> 6)] : '=';
out += i + 2 < len ? B64_CHARS[b3 & 0x3f] : '=';
}
return out;
}
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
AudioFocus?: {
requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>;
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
};
PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
writeChunk: (base64Pcm: string) => Promise<boolean>;
end: () => Promise<boolean>;
stop: () => Promise<boolean>;
};
};
// --- 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.
// Fallback-Werte falls die adaptive Baseline-Messung fehlschlaegt (z.B. weil
// das Mikro keine metering-Updates liefert). Adaptive Werte werden zur
// Laufzeit aus den ersten BASELINE_SAMPLES gemessen und auf baseline+offset
// gesetzt — funktioniert in lauten wie leisen Umgebungen.
const VAD_SILENCE_FALLBACK_DB = -38; // Fallback Stille-Schwelle
const VAD_SPEECH_FALLBACK_DB = -22; // Fallback Sprach-Schwelle
const VAD_SILENCE_OFFSET_DB = 6; // Sprache = Baseline + 6dB
const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
// Override fuer die Stille-Schwelle — wenn gesetzt, wird die adaptive Baseline
// ignoriert. Nuetzlich wenn die adaptive Logik in spezifischen Umgebungen
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
export const VAD_SILENCE_DB_MIN = -85; // extrem empfindlich, praktisch alles gilt als Sprache
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
/** Liefert den manuellen Override-Wert oder null wenn "automatisch". */
export async function loadVadSilenceDbOverride(): Promise<number | null> {
try {
const raw = await AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY);
if (raw == null || raw === '') return null;
const n = parseFloat(raw);
if (!isFinite(n)) return null;
if (n < VAD_SILENCE_DB_MIN || n > VAD_SILENCE_DB_MAX) return null;
return n;
} catch {
return null;
}
}
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
export const VAD_SILENCE_DEFAULT_SEC = 2.8;
export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0;
export const TTS_SPEED_MIN = 0.1;
export const TTS_SPEED_MAX = 5.0;
export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed';
export async function loadTtsSpeed(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= TTS_SPEED_MIN && n <= TTS_SPEED_MAX) return n;
}
} catch {}
return TTS_SPEED_DEFAULT;
}
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= VAD_SILENCE_MIN_SEC && n <= VAD_SILENCE_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(VAD_SILENCE_DEFAULT_SEC * 1000);
}
// Max-Dauer einer Aufnahme (Notbremse gegen Runaway-Loops). Auf 2 Minuten
// hochgezogen damit auch laengere Erklaerungen durchgehen.
// Default 5 Minuten — konfigurierbar in den App-Settings (1-30 Minuten).
export const MAX_RECORDING_DEFAULT_SEC = 300;
export const MAX_RECORDING_MIN_SEC = 60;
export const MAX_RECORDING_MAX_SEC = 1800;
export const MAX_RECORDING_STORAGE_KEY = 'aria_max_recording_sec';
export async function loadMaxRecordingMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(MAX_RECORDING_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= MAX_RECORDING_MIN_SEC && n <= MAX_RECORDING_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return MAX_RECORDING_DEFAULT_SEC * 1000;
}
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
export const TTS_PREROLL_DEFAULT_SEC = 3.5;
export const TTS_PREROLL_MIN_SEC = 0; // 0 = sofort abspielen (F5-TTS ist schnell genug)
export const TTS_PREROLL_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
async function loadPrerollSec(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
return n;
}
}
} catch {}
return TTS_PREROLL_DEFAULT_SEC;
}
// --- 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 = '';
// Audio-Queue fuer sequentielle TTS-Wiedergabe
private audioQueue: string[] = [];
private isPlaying: boolean = false;
private preloadedSound: Sound | null = null;
private preloadedPath: string = '';
// Sprach-Gate: Aufnahme erst senden wenn tatsaechlich gesprochen wurde
private speechDetected: boolean = false;
private speechStartTime: number = 0;
// PCM-Stream (XTTS): aktive Session + Cache-Puffer pro messageId
private pcmStreamActive: boolean = false;
private pcmMessageId: string = '';
private pcmSampleRate: number = 24000;
private pcmChannels: number = 1;
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build
private pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
// zwischen zwei Streams kurz wieder an.
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
// wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
// den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
private vadTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
private silenceFired: boolean = false;
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
// Adaptive Schwellen — werden in den ersten 500ms aus dem Mikro-Pegel
// gemessen. baseline = avg dB der ersten 5 Samples, dann:
// silence = baseline + VAD_SILENCE_OFFSET_DB (6dB ueber ambient)
// speech = baseline + VAD_SPEECH_OFFSET_DB (12dB ueber ambient = klares Reden)
// Funktioniert sowohl im stillen Buero als auch im lauten Cafe.
private vadBaselineSamples: number[] = [];
private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB;
private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB;
// Interruption-Tracking fuer Auto-Resume nach Anruf:
// - playbackStartTime: ms-Timestamp wenn AudioTrack tatsaechlich anfing
// abzuspielen (= _firePlaybackStarted)
// - currentPlaybackMsgId: welche Antwort lief gerade
// - pausedPosition / pausedMessageId: bei captureInterruption gemerkt
private playbackStartTime: number = 0;
private currentPlaybackMsgId: string = '';
private pausedPosition: number = 0; // Sekunden in der Audio-Datei
private pausedMessageId: string = '';
private resumeSound: Sound | null = null; // halten damit GC nicht zuschlaegt
// Leading-Silence wird im Native vor den Chunks geschrieben — beim
// Position-Berechnen vom playbackStarted abziehen
private readonly LEADING_SILENCE_SEC = 0.3;
constructor() {
this.recorder = new AudioRecorderPlayer();
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
// Native Event: AudioTrack hat alle Samples wirklich durchgespielt (nach
// dem finally{}-Block im Writer-Thread). ERST jetzt darf AudioFocus
// freigegeben werden — sonst spielt Spotify schon waehrend ARIA noch
// redet (PcmStreamPlayer.end() returnt mit 15s-Cap viel zu frueh).
if (PcmStreamPlayer) {
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
this._releaseFocusDeferred();
});
} catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
}
}
// App-Start: orphaned aria_tts_*.wav / aria_recording_*.mp4 aus dem Cache
// wegraeumen. Sammeln sich an wenn Sound mid-playback gestoppt wird (Anruf,
// Mute, Barge-In) — der completion-callback feuert dann nicht und die Datei
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
}
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an.
* Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
* unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) {
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
return;
}
console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
private _cancelDeferredFocusRelease(): void {
if (this.focusReleaseTimer) {
clearTimeout(this.focusReleaseTimer);
this.focusReleaseTimer = null;
}
}
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
if (this._conversationFocusActive) return;
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
}
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
* (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
releaseConversationFocus(): void {
if (!this._conversationFocusActive) return;
this._conversationFocusActive = false;
console.log('[Audio] Conversation-Focus inaktiv');
this._releaseFocusDeferred();
}
/** TTS-Wiedergabe haart stoppen — z.B. fuer Barge-In. Buffer wird geleert,
* kein Auto-Resume. Released auch sofort den AudioFocus. */
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this.stopPlayback();
}
/** Speziell fuer Anrufe: AudioTrack stoppen + Focus releasen, ABER pcm-
* Buffer + messageId behalten damit weitere Chunks der unterbrochenen
* Antwort weiter gesammelt werden. isFinal schreibt dann die WAV trotz
* Anruf — und resumeFromInterruption findet sie. */
pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false;
this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
this.audioQueue = [];
this.isPlaying = false;
// Foreground-Service stoppen — Notification waere sonst irrefuehrend
stopBackgroundAudio().catch(() => {});
// SoundPool/RNSound (Resume-Sound, Play-Button) stoppen — nicht relevant fuer Auto-Resume
if (this.currentSound) {
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
this.currentSound = null;
}
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
// AudioTrack hart stoppen damit nichts mehr aus dem Lautsprecher kommt.
// pcmStreamActive bleibt true, pcmBuffer/pcmMessageId BLEIBEN — damit
// weitere Chunks gesammelt werden und isFinal die WAV schreiben kann.
PcmStreamPlayer?.stop().catch(() => {});
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
}
/** Anruf vorbei → weitere Chunks duerfen wieder abgespielt werden.
* resumeFromInterruption uebernimmt die Wiedergabe ab gemerkter Position. */
endCallPause(): void {
if (!this._pausedForCall) return;
this._pausedForCall = false;
console.log('[Audio] endCallPause');
}
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
* oder 0 wenn nichts spielte.
*
* Idempotent: bei mehrfachem Aufruf (ringing → offhook) wird die Position
* vom ersten Mal NICHT ueberschrieben. playbackStartTime laeuft stumpf
* weiter obwohl das Audio gestoppt ist — der erste Halt ist der echte. */
captureInterruption(): number {
if (this.pausedMessageId) {
console.log('[Audio] captureInterruption: bereits erfasst (msgId=%s pos=%ss) — skip',
this.pausedMessageId, this.pausedPosition.toFixed(2));
return this.pausedPosition;
}
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
console.log('[Audio] captureInterruption: nichts spielte (startTime=%s, msgId=%s)',
this.playbackStartTime, this.currentPlaybackMsgId || '(leer)');
this.pausedPosition = 0;
this.pausedMessageId = '';
return 0;
}
const elapsedMs = Date.now() - this.playbackStartTime;
const positionSec = Math.max(0, elapsedMs / 1000 - this.LEADING_SILENCE_SEC);
this.pausedPosition = positionSec;
this.pausedMessageId = this.currentPlaybackMsgId;
console.log('[Audio] captureInterruption: msgId=%s pos=%ss',
this.pausedMessageId, positionSec.toFixed(2));
return positionSec;
}
/** Nach Anruf-Ende: ab gemerkter Position weiterspielen. Wenn Cache noch
* nicht geschrieben (final kam waehrend Anruf vielleicht doch nicht),
* warten bis maxWaitMs und dann probieren. Returnt true wenn gestartet. */
async resumeFromInterruption(maxWaitMs: number = 30000): Promise<boolean> {
const msgId = this.pausedMessageId;
const position = this.pausedPosition;
if (!msgId) {
console.log('[Audio] resumeFromInterruption: kein gemerkter Stand — skip');
return false;
}
console.log('[Audio] resumeFromInterruption: starte fuer msgId=%s pos=%ss',
msgId, position.toFixed(2));
this.pausedMessageId = ''; // konsumieren
const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
if (await RNFS.exists(cachePath)) {
return await this._playFromPathAtPosition(cachePath, position);
}
} catch {}
await new Promise(r => setTimeout(r, 500));
}
console.warn('[Audio] resumeFromInterruption: WAV %s nicht binnen %dms verfuegbar',
msgId, maxWaitMs);
return false;
}
private async _playFromPathAtPosition(path: string, positionSec: number): Promise<boolean> {
try {
// Bestehende laufende Wiedergabe abbrechen damit wir sauber starten
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
const sound = await new Promise<Sound>((resolve, reject) => {
const s = new Sound(path.replace(/^file:\/\//, ''), '', (err) =>
err ? reject(err) : resolve(s));
});
// Audio-Focus anfordern damit Spotify pausiert
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
this.isPlaying = true;
this.resumeSound = sound;
// Tracking auch fuer den Resume-Sound aktualisieren — sonst kann
// captureInterruption bei einem zweiten Anruf die Position nicht
// mehr ermitteln (playbackStartTime waere von der ersten Wiedergabe).
const msgIdMatch = path.match(/([^/\\]+)\.wav$/i);
if (msgIdMatch) this.currentPlaybackMsgId = msgIdMatch[1];
// Virtuelle Start-Zeit so setzen, dass captureInterruption (das den
// Leading-Silence-Offset wieder abzieht) die korrekte Position liefert.
this.playbackStartTime = Date.now() - (positionSec + this.LEADING_SILENCE_SEC) * 1000;
console.log('[Audio] Resume von Position %ss aus %s',
positionSec.toFixed(2), path);
sound.setCurrentTime(Math.max(0, positionSec));
sound.play((success) => {
if (!success) console.warn('[Audio] Resume-Wiedergabe fehlgeschlagen');
try { sound.release(); } catch {}
if (this.resumeSound === sound) this.resumeSound = null;
this.isPlaying = false;
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] cb err:', e); }
});
this._releaseFocusDeferred();
});
return true;
} catch (err: any) {
console.warn('[Audio] _playFromPathAtPosition fehlgeschlagen:', err?.message || err);
return false;
}
}
/** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream.
* Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht,
* soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet
* werden ("ach vergiss es, mach lieber X"). */
isPlayingAudio(): boolean {
return this.isPlaying || this.pcmStreamActive;
}
// --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> {
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.
*
* @param autoStop VAD aktivieren — Auto-Stop bei Stille
* @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt,
* wird Stille gemeldet (Recording wird verworfen).
* Fuer Conversation-Window: nach ARIA's Antwort
* hast du nur N Sekunden um anzufangen, sonst
* Gespraech zu Ende.
*/
async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise<boolean> {
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();
// Aufraeumen: Alte aria_recording_ und aria_tts_ Files loeschen
// (Schutz gegen Cache-Ueberlauf im Gespraechsmodus bei vielen Zyklen)
this._cleanupStaleCacheFiles().catch(() => {});
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
// Foreground-Service VOR dem AudioRecord starten — sonst blockt Android
// den Background-Mic-Zugriff (foregroundServiceType=microphone muss zum
// Zeitpunkt des startRecorder() schon aktiv sein, sonst greifen die
// Background-Mic-Restrictions ab Android 11+).
await acquireBackgroundAudio('rec');
// Aufnahme mit Metering starten
await this.recorder.startRecorder(this.recordingPath, {
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
AudioSourceAndroid: AudioSourceAndroidType.MIC,
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
AudioSamplingRateAndroid: 16000,
AudioChannelsAndroid: 1,
}, true); // meteringEnabled = true
// Metering-Callback
this.recorder.addRecordBackListener((e) => {
const db = e.currentMetering ?? -160;
this.meterListeners.forEach(cb => cb(db));
// Adaptive Baseline: erste 5 Samples (~500ms) sammeln, dann Schwellen
// anpassen. -160 (kein Metering) ignorieren — sonst wird die Baseline
// sinnlos niedrig.
if (this.vadBaselineSamples.length < VAD_BASELINE_SAMPLES) {
if (db > -100) {
this.vadBaselineSamples.push(db);
if (this.vadBaselineSamples.length === VAD_BASELINE_SAMPLES) {
// Minimum statt Mittelwert: robust gegen Spike-Samples (z.B. wenn
// der User direkt nach Wake-Word sofort spricht oder das Wake-Word-
// Echo noch im Mikro ist). Min ist der ruhigste Moment.
const lowest = Math.min(...this.vadBaselineSamples);
const rawSilence = lowest + VAD_SILENCE_OFFSET_DB;
const rawSpeech = lowest + VAD_SPEECH_OFFSET_DB;
// Cap auf einen vernuenftigen Bereich:
// - Silence-Schwelle nicht ueber -28dB (sonst zaehlt Hintergrund-
// geraeusch dauerhaft als "Sprache" → VAD feuert nie)
// - Silence-Schwelle nicht unter -50dB (sonst zu strikt)
this.vadAdaptiveSilenceDb = Math.max(-50, Math.min(rawSilence, -28));
this.vadAdaptiveSpeechDb = Math.max(-40, Math.min(rawSpeech, -18));
const msg = `VAD: ambient=${lowest.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
console.log('[Audio] %s speech>%s (raw silence=%s speech=%s)',
msg, this.vadAdaptiveSpeechDb.toFixed(1),
rawSilence.toFixed(1), rawSpeech.toFixed(1));
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
}
}
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
if (db > this.vadAdaptiveSpeechDb) {
if (!this.speechDetected && this.speechStartTime === 0) {
this.speechStartTime = Date.now();
}
if (this.speechStartTime > 0 && Date.now() - this.speechStartTime >= VAD_SPEECH_MIN_MS) {
this.speechDetected = true;
}
} else {
if (!this.speechDetected) {
this.speechStartTime = 0; // Reset wenn noch nicht als Sprache erkannt
}
}
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
if (this.vadEnabled) {
if (db > this.vadAdaptiveSilenceDb) {
this.lastSpeechTime = Date.now();
}
}
});
this.recordingStartTime = Date.now();
this.lastSpeechTime = Date.now();
this.speechDetected = false;
this.speechStartTime = 0;
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
// gemessen. Bis dahin gelten die Fallback-Schwellen.
this.vadBaselineSamples = [];
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
// Manueller Override aus Settings — wenn gesetzt, wird die adaptive
// Baseline-Messung uebersteuert. User-Wahl gewinnt vor Auto-Magic.
const dbOverride = await loadVadSilenceDbOverride();
if (dbOverride != null) {
this.vadAdaptiveSilenceDb = dbOverride;
this.vadAdaptiveSpeechDb = dbOverride + 10; // Speech klar ueber Stille
this.vadBaselineSamples = new Array(VAD_BASELINE_SAMPLES).fill(0); // Baseline-Sammeln deaktivieren
const msg = `VAD: manuell stille>${dbOverride}dB`;
console.log('[Audio] %s', msg);
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
}
this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar).
// WICHTIG: jeder Trigger (VAD-Stille / Max-Dauer / No-Speech-Window)
// disable SOFORT den VAD-Flag und clear den Timer, BEVOR die Listener
// gefeuert werden. Sonst feuert das setInterval weiter alle 200ms und
// ruft stopRecording parallel auf → audio-recorder-player crasht.
this.vadEnabled = autoStop;
this.silenceFired = false;
const fireSilenceOnce = (reason: string) => {
if (this.silenceFired) return;
this.silenceFired = true;
this.vadEnabled = false;
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
console.log('[Audio] Silence-Fire: %s', reason);
this.silenceListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] silence listener err:', e); }
});
};
if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs();
const maxRecordingMs = await loadMaxRecordingMs();
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
vadSilenceMs, maxRecordingMs);
this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
}
}, 200);
// Notbremse: Nach maxRecordingMs zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => {
fireSilenceOnce(`Max-Dauer ${maxRecordingMs}ms`);
}, maxRecordingMs);
}
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie).
if (noSpeechTimeoutMs > 0) {
this.noSpeechTimer = setTimeout(() => {
if (!this.speechDetected && this.recordingState === 'recording') {
fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`);
}
}, noSpeechTimeoutMs);
}
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<RecordingResult | null> {
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;
}
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
if (this.noSpeechTimer) {
clearTimeout(this.noSpeechTimer);
this.noSpeechTimer = null;
}
try {
await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener();
// Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort,
// im Gap soll Spotify nicht hochkommen.
this._releaseFocusDeferred();
const durationMs = Date.now() - this.recordingStartTime;
const hadSpeech = this.speechDetected;
// Sprach-Gate: Wenn keine Sprache erkannt → Aufnahme verwerfen
if (!hadSpeech) {
RNFS.unlink(this.recordingPath).catch(() => {});
this.setState('idle');
console.log('[Audio] Aufnahme verworfen — keine Sprache erkannt (nur Umgebungsgeraeusche)');
return null;
}
// 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, Sprache erkannt)`);
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 in die Queue stellen und abspielen */
async playAudio(base64Data: string): Promise<void> {
if (!base64Data) return;
// Mute-Flag respektieren — robust gegen Race-Conditions zwischen User-
// Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft.
if (this._muted) {
console.log('[Audio] playAudio: muted=true → skip');
return;
}
this.audioQueue.push(base64Data);
console.log('[Audio] playAudio: queued (queue=%d isPlaying=%s pausedForCall=%s)',
this.audioQueue.length, this.isPlaying, this._pausedForCall);
if (!this.isPlaying) {
this._playNext();
}
}
/** Base64-Audio persistent speichern. Gibt file:// Pfad zurueck (oder leer bei Fehler). */
async cacheAudio(base64Data: string, messageId: string): Promise<string> {
if (!base64Data || !messageId) return '';
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
await RNFS.mkdir(dir).catch(() => {});
const path = `${dir}/${messageId}.wav`;
// Wenn Datei schon existiert (z.B. XTTS Chunks) → anhaengen statt ueberschreiben
const exists = await RNFS.exists(path);
if (exists) {
// Bestehende + neue Base64 laden, zusammenkleben (fuer jetzt: ueberschreiben)
// XTTS sendet mehrere Chunks — bei mehrfacher Ueberschreibung bleibt nur der letzte
// Fuer eine echte Konkatenation muesste WAV-Header gemerged werden
await RNFS.writeFile(path, base64Data, 'base64');
} else {
await RNFS.writeFile(path, base64Data, 'base64');
}
return `file://${path}`;
} catch (err) {
console.warn('[Audio] cacheAudio fehlgeschlagen:', err);
return '';
}
}
/** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen.
* silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet).
* Gibt bei final=true den Cache-Pfad zurueck (file://) oder '' wenn nicht gecached.
*
* Wrapper serialisiert aufeinanderfolgende Chunk-Calls via Promise-Queue —
* sonst gabs bei kurzen Streams einen Race: final-Chunk konnte `end()` rufen
* BEVOR der vorherige `start()` im Native-Modul fertig war. Der Writer-
* Thread sah dann endRequested=true ohne jemals Chunks zu verarbeiten. */
private _pcmChunkQueue: Promise<any> = Promise.resolve();
async handlePcmChunk(payload: {
base64: string;
sampleRate?: number;
channels?: number;
messageId?: string;
chunk?: number;
final?: boolean;
silent?: boolean;
}): Promise<string> {
const p = this._pcmChunkQueue.then(() => this._handlePcmChunkImpl(payload)).catch(err => {
console.warn('[Audio] handlePcmChunk queued err:', err);
return '';
});
// Chain only on the side effect — callers still get the per-call result
this._pcmChunkQueue = p;
return p;
}
private async _handlePcmChunkImpl(payload: {
base64: string;
sampleRate?: number;
channels?: number;
messageId?: string;
chunk?: number;
final?: boolean;
silent?: boolean;
}): Promise<string> {
// Globaler Mute-Flag uebersteuert das per-Call silent — verhindert
// Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt.
// _pausedForCall: AudioTrack ist gestoppt waehrend Anruf — Chunks weiter
// sammeln (fuer WAV-Cache), aber NICHT in den Player schicken.
const silent = !!payload.silent || this._muted || this._pausedForCall;
if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return '';
}
// Debug-Log bei Chunk 0 eines neuen Streams — damit man im adb logcat
// sieht warum der Auto-Playback greift oder nicht.
if ((payload.chunk ?? 0) === 0 && !this.pcmStreamActive) {
console.log('[Audio] PCM-Stream start: silent=%s messageId=%s sr=%s ch=%s',
silent, payload.messageId || '(none)',
payload.sampleRate, payload.channels);
}
const messageId = payload.messageId || '';
const sampleRate = payload.sampleRate || 24000;
const channels = payload.channels || 1;
const base64 = payload.base64 || '';
const isFinal = !!payload.final;
// Neuer Stream? (messageId Wechsel oder nicht aktiv)
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
if (this.pcmStreamActive && !silent) {
try { await PcmStreamPlayer!.stop(); } catch {}
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
}
// Resume-Sound stoppen falls noch aktiv (User hat nach Anruf eine
// neue Frage gestellt — die alte interruptierte Antwort ist obsolet).
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
// Pending Auto-Resume verwerfen wenn die neue Antwort eine andere
// messageId hat. Sonst spielt nach 30s-Wartezeit der Resume die
// ueberholte Antwort ab.
if (this.pausedMessageId && this.pausedMessageId !== messageId) {
console.log('[Audio] Neue TTS-Antwort (msgId=%s) — Auto-Resume fuer %s verworfen',
messageId, this.pausedMessageId);
this.pausedMessageId = '';
this.pausedPosition = 0;
}
this.pcmStreamActive = true;
this.pcmMessageId = messageId;
this.pcmSampleRate = sampleRate;
this.pcmChannels = channels;
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
if (!silent) {
const prerollSec = await loadPrerollSec();
try {
await PcmStreamPlayer!.start(sampleRate, channels, prerollSec);
} catch (err) {
console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err);
this.pcmStreamActive = false;
return '';
}
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
}
}
// Chunk — immer cachen, nur bei !silent auch abspielen
if (base64) {
if (!silent) {
try { await PcmStreamPlayer!.writeChunk(base64); } catch (err) { console.warn('[Audio] writeChunk', err); }
}
if (messageId && this.pcmBytesCollected < this.PCM_MAX_CACHE_BYTES) {
this.pcmBuffer.push(base64);
this.pcmBytesCollected += Math.floor(base64.length * 0.75);
}
}
if (isFinal) {
if (!silent) {
// end() signalisiert dem Writer "keine weiteren Chunks". Aber WIR
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
try { await PcmStreamPlayer!.end(); } catch {}
// playbackFinished-Listener informieren (UI-Logik)
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
}
this.pcmStreamActive = false;
if (messageId && this.pcmBuffer.length > 0) {
const audioPath = await this._savePcmBufferAsWav(messageId);
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
return audioPath;
}
this.pcmMessageId = '';
}
return '';
}
/** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
private async _savePcmBufferAsWav(messageId: string): Promise<string> {
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
await RNFS.mkdir(dir).catch(() => {});
const path = `${dir}/${messageId}.wav`;
// WAV-Header fuer PCM s16le
const sampleRate = this.pcmSampleRate;
const channels = this.pcmChannels;
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = this.pcmBytesCollected;
const fileSize = 36 + dataSize;
// Header als Base64 (44 bytes)
const header = new Uint8Array(44);
const dv = new DataView(header.buffer);
// "RIFF"
header[0] = 0x52; header[1] = 0x49; header[2] = 0x46; header[3] = 0x46;
dv.setUint32(4, fileSize, true);
// "WAVE"
header[8] = 0x57; header[9] = 0x41; header[10] = 0x56; header[11] = 0x45;
// "fmt "
header[12] = 0x66; header[13] = 0x6d; header[14] = 0x74; header[15] = 0x20;
dv.setUint32(16, 16, true); // fmt chunk size
dv.setUint16(20, 1, true); // PCM format
dv.setUint16(22, channels, true);
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, byteRate, true);
dv.setUint16(32, blockAlign, true);
dv.setUint16(34, bitsPerSample, true);
// "data"
header[36] = 0x64; header[37] = 0x61; header[38] = 0x74; header[39] = 0x61;
dv.setUint32(40, dataSize, true);
// Header als base64
let headerB64 = '';
const chunk = 1024;
for (let i = 0; i < header.length; i += chunk) {
headerB64 += String.fromCharCode(...Array.from(header.slice(i, i + chunk)));
}
headerB64 = btoaSafe(headerB64);
// Datei schreiben: Header + alle PCM-Chunks
await RNFS.writeFile(path, headerB64, 'base64');
for (const b64 of this.pcmBuffer) {
await RNFS.appendFile(path, b64, 'base64');
}
console.log(`[Audio] PCM-Cache geschrieben: ${path} (${(dataSize / 1024).toFixed(0)}KB, ${this.pcmBuffer.length} chunks)`);
return `file://${path}`;
} catch (err) {
console.warn('[Audio] _savePcmBufferAsWav fehlgeschlagen:', err);
return '';
}
}
/** Audio aus lokaler Datei (file:// Pfad) in die Queue und abspielen.
* Setzt zusaetzlich playbackStartTime + currentPlaybackMsgId damit ein
* Anruf waehrend dieses Playbacks korrekt erfasst wird (ohne dieses
* Tracking liefert captureInterruption nichts → kein Auto-Resume). */
async playFromPath(filePath: string): Promise<void> {
if (!filePath) return;
try {
const cleanPath = filePath.replace(/^file:\/\//, '');
if (!(await RNFS.exists(cleanPath))) {
console.warn('[Audio] Cache-Datei existiert nicht mehr:', cleanPath);
return;
}
// Dateiname ohne .wav als messageId nehmen (egal ob UUID oder andere ID)
const fileMatch = cleanPath.match(/([^/\\]+)\.wav$/i);
const msgId = fileMatch ? fileMatch[1] : '';
console.log('[Audio] playFromPath: cleanPath=%s → msgId=%s', cleanPath, msgId || '(leer)');
if (msgId) {
this.currentPlaybackMsgId = msgId;
this.playbackStartTime = Date.now() - this.LEADING_SILENCE_SEC * 1000;
}
const b64 = await RNFS.readFile(cleanPath, 'base64');
this.playAudio(b64);
} catch (err) {
console.warn('[Audio] playFromPath fehlgeschlagen:', err);
}
}
// Callback wenn alle Audio-Teile abgespielt sind
private playbackFinishedListeners: (() => void)[] = [];
private playbackStartedListeners: (() => void)[] = [];
onPlaybackFinished(callback: () => void): () => void {
this.playbackFinishedListeners.push(callback);
return () => {
this.playbackFinishedListeners = this.playbackFinishedListeners.filter(cb => cb !== callback);
};
}
/** Callback wenn ARIAs TTS-Wiedergabe startet — fuer Wake-Word-parallel-
* Listening waehrend ARIA spricht (Barge-In via "Computer" sagen). */
onPlaybackStarted(callback: () => void): () => void {
this.playbackStartedListeners.push(callback);
return () => {
this.playbackStartedListeners = this.playbackStartedListeners.filter(cb => cb !== callback);
};
}
private _firePlaybackStarted(): void {
// Tracking fuer Auto-Resume nach Anruf-Pause: NUR setzen wenn ein
// PCM-Stream laeuft (Live-TTS). Bei Play-Button / Resume-Sound hat der
// Caller (playFromPath / _playFromPathAtPosition) das Tracking schon
// korrekt mit der msgId aus dem Pfad gesetzt — sonst wuerden wir hier
// mit leerem pcmMessageId ueberschreiben.
if (this.pcmMessageId) {
this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId;
}
this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
});
}
/** Naechstes Audio aus der Queue abspielen */
private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) {
this.isPlaying = false;
// Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt,
// bleibt Spotify pausiert.
this._releaseFocusDeferred();
// Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb());
return;
}
// Beim ersten Playback-Start: andere Apps ducken + Listener informieren
if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted();
}
this.isPlaying = true;
// Preloaded Sound verwenden wenn verfuegbar, sonst neu laden
let sound: Sound;
let soundPath: string;
if (this.preloadedSound) {
sound = this.preloadedSound;
soundPath = this.preloadedPath;
this.preloadedSound = null;
this.preloadedPath = '';
// Daten aus Queue entfernen (wurde schon preloaded)
this.audioQueue.shift();
} else {
const base64Data = this.audioQueue.shift()!;
try {
soundPath = `${RNFS.CachesDirectoryPath}/aria_tts_${Date.now()}.wav`;
await RNFS.writeFile(soundPath, base64Data, 'base64');
sound = await new Promise<Sound>((resolve, reject) => {
const s = new Sound(soundPath, '', (err) => err ? reject(err) : resolve(s));
});
} catch (err) {
console.error('[Audio] Laden fehlgeschlagen:', err);
this._playNext();
return;
}
}
this.currentSound = sound;
console.log('[Audio] Sound.play startet (path=%s)', soundPath);
// Naechstes Audio schon vorbereiten waehrend dieses abspielt
this._preloadNext();
sound.play((success) => {
console.log('[Audio] Sound.play callback: success=%s queue=%d', success, this.audioQueue.length);
if (!success) console.warn('[Audio] Wiedergabe fehlgeschlagen');
sound.release();
this.currentSound = null;
RNFS.unlink(soundPath).catch(() => {});
this._playNext();
});
}
/** Naechstes Audio im Hintergrund vorladen (verhindert Stottern) */
private async _preloadNext(): Promise<void> {
if (this.audioQueue.length === 0 || this.preloadedSound) return;
const base64Data = this.audioQueue[0]; // Nicht shift — bleibt in Queue
try {
const tmpPath = `${RNFS.CachesDirectoryPath}/aria_tts_pre_${Date.now()}.wav`;
await RNFS.writeFile(tmpPath, base64Data, 'base64');
this.preloadedSound = await new Promise<Sound>((resolve, reject) => {
const s = new Sound(tmpPath, '', (err) => err ? reject(err) : resolve(s));
});
this.preloadedPath = tmpPath;
} catch {
this.preloadedSound = null;
this.preloadedPath = '';
}
}
/** Mute: alle eingehenden TTS-Chunks/WAVs werden ignoriert bis wieder
* unmuted. Robuster als ein React-Ref weil hier kein Re-Render-Race ist
* — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der
* User Mute geklickt hat. */
private _muted: boolean = false;
/** Anruf laeuft → Chunks werden nur in den Cache-Buffer gepusht, nicht
* abgespielt. Wird in pauseForCall gesetzt, in endCallPause/resumeFrom-
* Interruption zurueckgenommen. */
private _pausedForCall: boolean = false;
setMuted(muted: boolean): void {
console.log('[Audio] setMuted: %s (currentSound=%s pcmStreamActive=%s)',
muted, this.currentSound ? 'aktiv' : 'null', this.pcmStreamActive);
this._muted = muted;
if (muted) this.stopPlayback();
}
isMuted(): boolean { return this._muted; }
/** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void {
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
stopBackgroundAudio().catch(() => {});
this.audioQueue = [];
this.isPlaying = false;
// Merken: war ein react-native-sound-Sound aktiv? Dann muessen wir nach
// release() den Focus-Stack aufmischen (RNSound-Bug: stop+release laesst
// den AudioFocusRequest haengen, Spotify resumed sonst nicht).
const hadRnSound = !!(this.currentSound || this.resumeSound || this.preloadedSound);
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.release();
this.currentSound = null;
}
if (this.resumeSound) {
this.resumeSound.stop();
this.resumeSound.release();
this.resumeSound = null;
}
if (this.preloadedSound) {
this.preloadedSound.release();
this.preloadedSound = null;
if (this.preloadedPath) RNFS.unlink(this.preloadedPath).catch(() => {});
this.preloadedPath = '';
}
// PCM-Stream ebenfalls hart stoppen (Cancel/Abbruch).
// pcmStreamActive wird beim isFinal-Chunk schon false gesetzt — der
// AudioTrack spielt aber noch sekundenlang aus seinem Buffer ab. Daher
// IMMER stop() aufrufen, ohne den Flag zu pruefen (ist idempotent).
PcmStreamPlayer?.stop().catch(() => {});
this.pcmStreamActive = false;
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
this.pcmMessageId = '';
// Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
if (hadRnSound) {
// RNSound's haengender USAGE_MEDIA-Focus aufloesen — sonst bleibt
// Spotify pausiert obwohl unser Focus released ist.
AudioFocus?.kickReleaseMedia?.().catch(() => {});
}
}
// --- 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));
}
}
/** Alte Aufnahme- und TTS-Files aus dem Cache loeschen.
* Default 30s — verwendet beim Mikro-Start (kurze Lebensdauer reicht).
* App-Start nutzt 5min damit gerade aktive Files nicht erwischt werden. */
private async _cleanupStaleCacheFiles(maxAgeMs: number = 30000): Promise<void> {
try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const now = Date.now();
let removed = 0;
let freedBytes = 0;
for (const f of files) {
if (!f.isFile()) continue;
if (!f.name.startsWith('aria_recording_') && !f.name.startsWith('aria_tts_')) continue;
const age = now - (f.mtime ? f.mtime.getTime() : 0);
if (age > maxAgeMs) {
freedBytes += parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {});
removed += 1;
}
}
if (removed > 0) {
console.log('[Audio] Cache-Cleanup: %d Files entfernt, %.1fMB freigegeben',
removed, freedBytes / 1024 / 1024);
}
} catch {
// silent — cleanup ist best-effort
}
}
/** Alte TTS-Cache-Dateien loeschen die nicht mehr referenziert sind (>30 Tage). */
async cleanupOldTTSCache(keepMessageIds: Set<string>, maxAgeDays = 30): Promise<void> {
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (!(await RNFS.exists(dir))) return;
const files = await RNFS.readDir(dir);
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
const now = Date.now();
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
const messageId = f.name.replace(/\.wav$/, '');
const age = now - (f.mtime ? f.mtime.getTime() : 0);
// Loeschen wenn: nicht mehr referenziert UND aelter als X Tage
if (!keepMessageIds.has(messageId) && age > maxAgeMs) {
await RNFS.unlink(f.path).catch(() => {});
}
}
} catch {
// silent
}
}
/** Aktuelle Groesse des TTS-Caches. */
async getTtsCacheSize(): Promise<{ count: number; totalMB: number }> {
let count = 0;
let total = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (await RNFS.exists(dir)) {
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
}
} catch {}
return { count, totalMB: total / 1024 / 1024 };
}
/** TTS-Cache komplett leeren (Settings-Button). */
async clearTtsCache(): Promise<{ removed: number; freedMB: number }> {
let removed = 0;
let freed = 0;
try {
const dir = `${RNFS.DocumentDirectoryPath}/tts_cache`;
if (!(await RNFS.exists(dir))) return { removed: 0, freedMB: 0 };
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.wav')) continue;
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path).catch(() => {});
removed += 1;
freed += size;
}
} catch {}
return { removed, freedMB: freed / 1024 / 1024 };
}
}
// Singleton
const audioService = new AudioService();
export default audioService;