Compare commits

..

10 Commits

Author SHA1 Message Date
duffyduck 710e7c88d8 release: bump version to 0.1.8.7 2026-05-30 23:23:52 +02:00
duffyduck b6ee5552f0 fix(app): Dateimanager Einzel-Download landet jetzt im Downloads-Ordner
Bug: '⬇ Download' im Dateimanager schickte file_request raus, aber kein
SettingsScreen-Handler nahm das file_response auf. ChatScreen fing es
zwar global ab, versuchte aber nur Chat-Bubble-Attachments zu
patchen — kein Match, also passierte sichtbar nichts.

Fix: Handler in SettingsScreen fuer file_response mit requestId-Praefix
'single-' (aus bulkDownload-1-Datei-Pfad). Schreibt nach
RNFS.DownloadDirectoryPath, mit Suffix-Inkrement bei Namens-Konflikt
damit nichts ueberschrieben wird.

Multi-Datei-Download (ZIP) lief schon ueber file_zip_response,
unangetastet.
2026-05-30 23:22:44 +02:00
duffyduck 570eb031e0 release: bump version to 0.1.8.6 2026-05-30 23:20:01 +02:00
duffyduck e9615d987e fix(audio): playbackFinished-Listener feuern erst wenn AudioTrack wirklich durch ist
Race-Condition entdeckt im Log: nach jeder ARIA-Antwort lief
endConversation 5s nach TTS-Start (= "letzter Chunk eingetroffen"),
nicht wenn der AudioTrack-Hardware-Buffer wirklich am Ende war. ARIA
sprach also noch hoerbar, waehrend OpenWakeWord schon re-armte.

Folge: ARIAs eigene Stimme ging direkt nach AudioRecord.startRecording
ins Mikro. Die OpenWakeWord-Sessions von AudioRecord und AudioTrack
sind verschieden → AcousticEchoCanceler kann den Output nicht
subtrahieren (kein gemeinsamer Reference-Stream). Threshold +
Patience-State der Wake-Word-Inferenz wird durch ARIAs konstante
Audio-Eingabe verwirrt, der naechste echte "Computer"-Trigger geht
unter.

Fix: Listener-Fire aus handlePcmChunk(isFinal=true) raus, dafuer in
den schon existierenden PcmPlaybackFinished-Native-Event-Handler
rein. Die Kotlin-Seite emittiert das Event aus dem Writer-Thread-
finally-Block — also genau dann wenn AudioTrack alle Samples
durchgeschrieben hat.

Side-Effect: UI-Konsumenten von onPlaybackFinished sehen den
"finished"-State jetzt 1-2s spaeter (= ehrlicher zur Realitaet,
ist eigentlich eine UX-Verbesserung).
2026-05-30 23:18:53 +02:00
duffyduck 5e95eacd11 release: bump version to 0.1.8.5 2026-05-30 23:11:16 +02:00
duffyduck ece08f0f2f debug(wake): RVS-Log in endConversation — sichtbar machen ob re-arm greift
Stefan beobachtet dass Wake-Word nach Conversation manchmal nicht
re-armt. endConversation hatte bisher kein RVS-Logging — wir waren
beim Diagnose blind.

Loggt jetzt:
  - 'endConversation called but state=X → noop' (state-Mismatch)
  - 'endConversation called, calling OpenWakeWord.start()' (Eintritt)
  - 'OpenWakeWord.start() OK → state=armed' (Erfolg)
  - 'OpenWakeWord.start() FAIL: ... → state=off' (Native-Fehler)
  - 'fallback: nativeReady=false → state=off' (kein Native-Modul)

Damit sehen wir im naechsten Test welcher Pfad gegriffen hat und ob
das Native-Modul ueberhaupt aufgerufen wurde.
2026-05-30 23:09:11 +02:00
duffyduck 31fd0d7f7a release: bump version to 0.1.8.4 2026-05-30 23:02:41 +02:00
duffyduck 263835ad74 fix(wake): Conversation-Window nur im Foreground, Background → direkt re-armen
Symptom: Wake-Word laeuscht nach erfolgreicher Konversation im
Hintergrund nicht wieder — erst beim App-Vorholen wird's wieder
armed. Grund: nach TTS-Ende laeuft wakeWordService.resume() in
einen setTimeout(800ms) der im Doze stark verzoegert wird. Der
verspaetete Timer findet dann delay > 2800 und ruft endConversation
(re-arm) — aber eben erst beim App-Resume.

Fix: in onPlaybackFinished AppState pruefen:
  active     → resume() wie bisher (Multi-Turn-Conversation-Window)
  background → endConversation() direkt — kein setTimeout, native
               OpenWakeWord.start() greift sofort.

Begruendung fuer das Verhalten:
- Foreground: User ist aktiv, Multi-Turn-Dialog ohne erneutes
  "Computer"-Sagen ist nuetzlich.
- Background: User nutzt das Handy anderweitig, automatisches Mikro-
  Oeffnen ist nicht erwartet und droht durch Doze-Verzoegerung in
  ein Phantom-Trigger-Mismatch zu kippen. Direkt re-armen ist
  robust + erwartungskonform.

Eng verwandt mit dem 0.1.7.0-Fix (kein setTimeout zwischen
wake.detect und Callback) — selbes Doze-Throttling-Pattern, andere
Stelle in der Pipeline.
2026-05-30 23:01:12 +02:00
duffyduck ab7e9801ee release: bump version to 0.1.8.3 2026-05-30 22:33:13 +02:00
duffyduck 3d001a1d03 feat(app): manueller Aufnahme-Knopf nutzt jetzt auch Streaming-STT
VoiceButton rewrite — dB/VAD-Pfad endgueltig raus. Knopf ist jetzt nur
noch UI-Trigger:
  - onTapStart   (ChatScreen baut Bubble + startStreamingRecording)
  - onTapStop    (ChatScreen ruft stopStreamingRecording)
  - audioService.onStateChange treibt die Animation (statt internem
    isRecording-Flag)
  - onSilenceDetected-Subscription weg

ChatScreen:
  - handleVoiceRecording (Legacy) → handleVoiceButtonStart +
    handleVoiceButtonStop
  - Bubble wird beim Tap SOFORT gebaut (vorher: erst nach Stop), Text
    landet via audioRequestId-Match im chat-Handler-Update-Pfad
  - noSpeechTimeoutMs=0 (manueller Modus, User kontrolliert via Tap),
    hardCapMs=300_000 (5 Minuten Notbremse)
  - Wake-Word-conversing + manueller Stop = endConversation (User
    will nicht in Multi-Turn-Modus)
  - RecordingResult-Import entfaellt (nicht mehr genutzt)

Damit ist die komplette App-seitige Aufnahme auf Streaming + ML-
Endpointer. Der ganze dB/VAD-Apparat (vadEnabled, vadBaselineSamples,
loadVadSilenceDbOverride, vadTimer, noSpeechTimer, etc.) ist jetzt
nur noch Dead-Code — wird in einem Folge-Commit gemeinsam mit dem
zugehoerigen Settings-Slider abgeraeumt.
2026-05-30 22:31:26 +02:00
7 changed files with 190 additions and 109 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10802
versionName "0.1.8.2"
versionCode 10807
versionName "0.1.8.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.8.2",
"version": "0.1.8.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+51 -72
View File
@@ -1,12 +1,19 @@
/**
* VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf
* VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante).
*
* Zwei Modi:
* 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden
* 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille
* (auch genutzt fuer Wake-Word-getriggerte Aufnahme)
* Push-to-Talk gibt's nicht mehr. Tap startet Streaming-Aufnahme an die
* Whisper-Bridge. Tap nochmal sendet stt_stream_end → Whisper liefert den
* finalen Text → aria-bridge forwardet direkt an Brain. Keine dB/VAD-
* Stille-Erkennung mehr — Whisper hoert auf semantische Stille (kein
* neuer Text mehr).
*
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
* Diese Komponente ist absichtlich "dumm": sie kapselt nur den
* Tap-Lifecycle + die Animation. Recording-Optionen (voice/speed/
* location/interrupted) baut ChatScreen, die User-Bubble ebenfalls.
*
* Visuelles Feedback: pulsierende Animation + Dauer + dB-Pegel via
* audioService.onMeterUpdate (das macht audio.ts noch fuer alte Records;
* neu kommt der Pegel via NativeEventEmitter (PcmStreamMeter) — folgt).
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
@@ -17,25 +24,28 @@ import {
StyleSheet,
Easing,
TouchableOpacity,
Pressable,
} from 'react-native';
import audioService, { RecordingResult } from '../services/audio';
import audioService, { RecordingState } from '../services/audio';
// --- Typen ---
interface VoiceButtonProps {
/** Wird aufgerufen wenn die Aufnahme fertig ist */
onRecordingComplete: (result: RecordingResult) => void;
/** User hat getippt — ChatScreen soll Bubble bauen + startStreamingRecording.
* Returns true wenn die Aufnahme tatsaechlich gestartet ist. */
onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>;
/** Button deaktivieren */
disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt Indikator) */
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
wakeWordActive?: boolean;
}
// --- Komponente ---
const VoiceButton: React.FC<VoiceButtonProps> = ({
onRecordingComplete,
onTapStart,
onTapStop,
disabled = false,
wakeWordActive = false,
}) => {
@@ -45,6 +55,21 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// State via audioService.onStateChange spiegeln — der Service ist die
// Quelle der Wahrheit (Streaming-Session, Wake-Word-Multi-Turn, etc.
// koennen den Recording-State von extern aendern). isStreamingRecording
// ist auch true wenn die Wake-Word-Konversation gerade aufzeichnet —
// dann zeigt der Button "stop"-Symbol, und Tap stoppt die laufende
// Aufnahme (egal ob via Wake-Word oder Knopf gestartet).
useEffect(() => {
const unsub = audioService.onStateChange((next: RecordingState) => {
setIsRecording(next === 'recording');
});
// Initial-State synchronisieren
setIsRecording(audioService.getRecordingState() === 'recording');
return unsub;
}, []);
// Puls-Animation starten/stoppen
useEffect(() => {
if (isRecording) {
@@ -71,14 +96,13 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [isRecording, pulseAnim]);
// Aufnahmedauer zaehlen + Metering
// Aufnahmedauer zaehlen + Metering (Pegel-Bar)
useEffect(() => {
if (isRecording) {
setDurationMs(0);
durationTimer.current = setInterval(() => {
setDurationMs(prev => prev + 100);
}, 100);
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
return () => {
unsubMeter();
@@ -89,74 +113,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
clearInterval(durationTimer.current);
durationTimer.current = null;
}
setMeterDb(-160);
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop.
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
// feuert (silenceFired-Latch).
const onCompleteRef = useRef(onRecordingComplete);
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => {
if (audioService.getRecordingState() !== 'recording') return;
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 500) {
onCompleteRef.current(result);
}
});
return unsubSilence;
}, []);
// Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => {
if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true
if (started) {
setIsRecording(true);
}
}, [disabled, isRecording]);
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
// Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => {
const handleTap = useCallback(async () => {
if (disabled || tapBusy.current) return;
tapBusy.current = true;
try {
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
// Service-State fragen statt React-State (Closure koennte stale sein)
const svcState = audioService.getRecordingState();
if (svcState === 'recording') {
// Aufnahme manuell stoppen
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
await onTapStop();
} else if (svcState === 'idle') {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
setIsRecording(true);
}
await onTapStart();
}
// svcState === 'processing': Stopp in progress — nichts tun, User
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
// kurz damit der User's UI-Feedback synchron bleibt.
// 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen
} finally {
tapBusy.current = false;
}
};
// Expose startAutoRecording via ref fuer Wake Word
React.useImperativeHandle(
React.createRef(),
() => ({ startAutoRecording }),
[startAutoRecording],
);
}, [disabled, onTapStart, onTapStop]);
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
@@ -164,7 +142,11 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
return `${seconds}.${tenths}s`;
};
// Meter-Visualisierung (0-1 Skala)
// Meter-Visualisierung (-60..0 dB → 0..1). Bei Streaming-Mode liefert
// audio.ts (noch) keinen Pegel, also bleibt der Balken leer — wird in
// einem Folge-Commit nachgerueckt (PcmStreamRecorder-Module muss dafuer
// einen RMS-Wert mit-emitten). Tut der Streaming-Funktion keinen Abbruch,
// ist reines UI-Beiwerk.
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
return (
@@ -198,9 +180,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
);
};
// Expose startAutoRecording fuer externe Aufrufe (Wake Word)
export type VoiceButtonHandle = { startAutoRecording: () => Promise<void> };
// --- Styles ---
const styles = StyleSheet.create({
+56 -26
View File
@@ -47,7 +47,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
@@ -1263,11 +1263,30 @@ const ChatScreen: React.FC = () => {
return () => { unsubUpdate(); clearTimeout(timer); };
}, []);
// Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten
// Gespraechsmodus: Nach TTS-Wiedergabe weiter im Multi-Turn (Conversation-
// Window) oder zurueck zu armed (Wake-Word lauscht wieder)?
//
// Foreground → resume() oeffnet das Mikro fuer N Sekunden Follow-Up
// (natuerlicher Dialog moeglich ohne erneutes "Computer")
// Background → endConversation() — Wake-Word direkt wieder armed.
//
// Grund: der setTimeout(800ms) in resume() wird im Doze stark verzoegert
// (siehe Wake-Detect-Bug von 0.1.7.0). Das hat zwei nervige Folgen:
// 1) Wake-Word ist solange "tot" — User kann ARIA nicht mehr triggern
// bis er die App vorholt
// 2) Wenn er die App dann vorholt, oeffnet der verspaetete Timer das
// Mikro — sieht aus wie ein Phantom-Wake-Word-Trigger
// Background = User nutzt das Handy anderweitig, das Multi-Turn-Konzept
// ist da eh nicht nuetzlich. Direkt re-armen ist robust und erwartungs-
// konform.
useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => {
if (wakeWordService.isActive()) {
if (!wakeWordService.isActive()) return;
if (AppState.currentState === 'active') {
wakeWordService.resume();
} else {
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
wakeWordService.endConversation().catch(() => {});
}
});
return () => unsubPlayback();
@@ -1761,49 +1780,59 @@ const ChatScreen: React.FC = () => {
return true;
}, [agentActivity]);
// Sprachaufnahme abgeschlossen
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
// Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen
// die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt.
// Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const cmid = nextClientMsgId();
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
audioRequestId,
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
};
setMessages(prev => capMessages([...prev, userMsg]));
dispatchWithAck(cmid, 'audio', {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
const { ok } = await audioService.startStreamingRecording({
audioRequestId,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
audioRequestId,
...(location && { location }),
location: location || null,
// Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum-
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
// die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0,
endpointMs: 1500,
hardCapMs: 300000,
});
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
if (!ok) {
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
return false;
}
scheduleStaleAudioCleanup(audioRequestId, 60000);
return true;
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
// Knopf.
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
await audioService.stopStreamingRecording('user');
if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation();
}
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
}, []);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
@@ -2572,7 +2601,8 @@ const ChatScreen: React.FC = () => {
) : (
<>
<VoiceButton
onRecordingComplete={handleVoiceRecording}
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
+43
View File
@@ -497,6 +497,49 @@ const SettingsScreen: React.FC = () => {
})();
}
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
// passt. Hier behandeln wir die Manager-initiierten Downloads
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
// ~/Download/ wie der ZIP-Pfad.
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage
if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
if (!b64) return;
const fileName = (p.name as string) ||
(p.serverPath as string || '').split('/').pop() ||
'aria-download';
(async () => {
try {
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
const dot = fileName.lastIndexOf('.');
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
const ext = dot > 0 ? fileName.slice(dot) : '';
target = `${dir}/${base} (${i})${ext}`;
i++;
}
await RNFS.writeFile(target, b64, 'base64');
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
+20 -6
View File
@@ -341,8 +341,21 @@ class AudioService {
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
this._releaseFocusDeferred();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
// braucht nach end() noch 1-2s zum Drainen seines Hardware-Buffers.
// Wenn wir die Listener zu frueh feuern, re-armt OpenWakeWord
// waehrend ARIA noch hoerbar spricht → ARIAs Stimme verwirrt die
// Wake-Word-Detection (kein gemeinsames AEC zwischen AudioTrack-
// und AudioRecord-Session). Stefan-Reproduktion: nach jeder ARIA-
// Antwort schluckte das Wake-Word den naechsten Trigger.
import('./logger').then(m => m.reportAppDebug('audio.playback',
'PcmPlaybackFinished native event → fire listeners')).catch(()=>{});
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
});
} catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
@@ -1368,12 +1381,13 @@ class AudioService {
// 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).
// wirklich am Ende ist (siehe Constructor-PcmPlaybackFinished-Handler).
//
// playbackFinishedListeners feuern AUCH erst dort — frueher feuerten
// sie hier (beim Eintreffen des letzten Chunks), das fuehrte zu
// einem Race: OpenWakeWord re-armte waehrend AudioTrack noch hoerbar
// ARIAs Stimme abspielte → naechstes Wake-Word ging unter.
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;
+17 -2
View File
@@ -346,19 +346,34 @@ class WakeWordService {
* Ohne: zurueck zu 'off'.
*/
async endConversation(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.state !== 'conversing') {
// Nicht in conversing — typ. nach App-Resume bevor Streaming endete.
// Trotzdem loggen damit wir's im Diagnostic sehen.
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
return;
}
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, nativeReady=${this.nativeReady}, calling OpenWakeWord.start()`)).catch(()=>{});
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, keyword=${this.keyword}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
} catch (err: any) {
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
)).catch(()=>{});
}
}
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
import('./logger').then(m => m.reportAppDebug('wake.end',
`fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off');
}