Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck 0309c95aa5 release: bump version to 0.0.6.4 2026-04-25 20:58:10 +02:00
duffyduck 2aa2cc70c9 debug: explicit speed-Log direkt vor F5-TTS infer()-Call
Damit man am Logfile schwarz auf weiss sieht ob der Wert wirklich an
die Library geht — falls bei Stefan speed=0.30 ankommt aber Maia
trotzdem schnell, ist's F5-TTS-Verhalten, nicht Pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:45:16 +02:00
duffyduck 9d0776c819 fix: Text-Auswahl in MessageText — selectable an alle nested Texts
Android-Eigenheit: bei nested Text-Komponenten muss selectable=true
auch an die Kinder; der Wert auf dem Parent erbt sich nicht zuverlaessig.
Plus: dataDetectorType="all" als Fallback fuer System-Linkifizierung,
falls unsere Regex einen Match verpasst.

suppressHighlighting=false damit Long-Press auf den Link-Texten den
Selection-Mode nicht blockt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:41:20 +02:00
duffyduck f031fa159e release: bump version to 0.0.6.3 2026-04-25 20:35:25 +02:00
duffyduck be373466a3 fix: klares UI-Feedback fuer Wake-Word-State
Stefan's Verwirrung: Ohr-Button + KEIN Porcupine = Direkt-Aufnahme,
nicht passives Lauschen. Wenn er lange wartet, schnappt das Mikro
Hintergrundgeraeusche/Sprache auf, sendet ab, Ohr aus. Sah aus wie
"Wake-Word triggerte" — war aber stinknormales Recording.

Fixes fuer klares Feedback:
- Toast bei jedem State-Wechsel:
  * Direkt-Aufnahme (kein Porcupine): "Wake-Word nicht aktiv —
    direkte Aufnahme startet (Mikro hoert mit)"
  * armed: "Lausche auf X..."
  * Wake erkannt: "Wake-Word X erkannt — sprich jetzt"
  * endConversation: "Lausche wieder auf X" oder "Mikro aus"
- Ohr-Button-Icon zeigt drei States:
  🔇 off
  👂 armed (Porcupine lauscht passiv)
  🎙️ conversing (aktive Aufnahme laeuft)
- ChatScreen subscribed wakeWordService.onStateChange fuer Live-
  Updates des Icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:34:07 +02:00
duffyduck bbf9aed3ba fix: 4 Bugs — STT-Mapping, Speed-Logging, VAD-Logs, Wake-Word-Toast
Bug 2: STT-Result ueberschrieb beide noch unaufgeloeste Audio-Bubbles
mit gleichem Text. Fix: nur die ERSTE matchende Bubble aktualisieren
(findIndex + index-Update statt map). Reihenfolge ist FIFO weil Whisper
sequenziell verarbeitet.

Bug 3: Speed-Param wird nun in jedem Hop geloggt:
  - ChatScreen: "[Chat] sende mit voice=X speed=Y"
  - aria-bridge: "XTTS-Request gesendet (voice=X, speed=Y.YYx)"
  - f5tts-bridge: "F5-TTS: N Satz(e), voice=X, speed=Y.YYx"
Damit kann man im logcat/docker-logs eindeutig sehen wo speed evtl.
verloren geht oder ob die Stimme einfach von Natur aus schnell ist.

Bug 4: VAD-Trigger-Reason mit Schwelle: "VAD NNN ms Stille (Schwelle=NNN ms)".
Plus startRecording loggt jetzt VAD-Stille + MAX-Recording.

Bug 1 (Porcupine): mehr Debug + Toast-Meldungen.
  - init failure: err.name/code/stack ins Log
  - start() ohne Porcupine: Toast "Access Key in Settings setzen"
  - start() Fehler: Toast mit Fehlermeldung
  - configure(): Toast wenn init scheitert
  - Erfolgreiches arming: Toast "Lausche auf X"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:28:46 +02:00
duffyduck 745b4a07c0 release: bump version to 0.0.6.2 2026-04-25 20:20:25 +02:00
duffyduck 23ca815cb2 fix: handlePcmChunk serialisiert — fixes Race bei kurzen Streams
Bei kurzen Saetzen (nur ein paar Chunks + sofort final) konnten die
async handlePcmChunk-Calls parallel laufen. Der final-Chunk konnte
native end() aufrufen BEVOR der vorherige Chunk seinen native start()
abgeschlossen hatte. Der Writer-Thread startete dann mit endRequested
bereits true, verarbeitete keine Chunks sauber → Audio ging verloren.

Fix: Wrapper chaint alle Chunk-Calls an eine Promise-Queue:
  _pcmChunkQueue = Promise.resolve()
  handlePcmChunk → _pcmChunkQueue.then(() => _handlePcmChunkImpl(p))

So werden start/writeChunk/end garantiert in der richtigen Reihenfolge
verarbeitet. Der API-Contract bleibt (gleiches return-Promise).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:58:27 +02:00
8 changed files with 121 additions and 27 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 601
versionName "0.0.6.1"
versionCode 604
versionName "0.0.6.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.6.1",
"version": "0.0.6.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+18 -3
View File
@@ -72,13 +72,28 @@ interface Props {
const MessageText: React.FC<Props> = ({ text, style }) => {
const segments = React.useMemo(() => tokenize(text), [text]);
return (
<Text style={style} selectable>
<Text
style={style}
selectable
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
// Tokens nicht passen.
dataDetectorType="all"
>
{segments.map((seg, i) => {
if (seg.kind === 'text') {
return <Text key={i}>{seg.text}</Text>;
return <Text key={i} selectable>{seg.text}</Text>;
}
return (
<Text key={i} style={LINK_STYLE} onPress={() => onPress(seg)}>
<Text
key={i}
selectable
style={LINK_STYLE}
onPress={() => onPress(seg)}
// Long-Press soll an den Parent durch fuer Selection
onLongPress={undefined}
suppressHighlighting={false}
>
{seg.text}
</Text>
);
+26 -7
View File
@@ -104,6 +104,8 @@ const ChatScreen: React.FC = () => {
const [showCameraUpload, setShowCameraUpload] = useState(false);
const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = useState(false);
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
@@ -154,6 +156,11 @@ const ChatScreen: React.FC = () => {
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
});
return () => unsub();
}, []);
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
@@ -263,15 +270,22 @@ const ChatScreen: React.FC = () => {
if (message.type === 'chat') {
const sender = (message.payload.sender as string) || '';
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
// wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles
// den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
if (sender === 'stt') {
const sttText = (message.payload.text as string) || '';
if (sttText) {
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
? { ...m, text: `\uD83C\uDFA4 ${sttText}` }
: m
));
setMessages(prev => {
const idx = prev.findIndex(m =>
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
);
if (idx < 0) return prev;
const next = prev.slice();
next[idx] = { ...next[idx], text: `\uD83C\uDFA4 ${sttText}` };
return next;
});
}
return;
}
@@ -572,6 +586,8 @@ const ChatScreen: React.FC = () => {
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', {
text,
@@ -1000,7 +1016,10 @@ const ChatScreen: React.FC = () => {
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord}
>
<Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
<Text style={styles.wakeWordIcon}>
{wakeWordState === 'conversing' ? '🎙️' :
wakeWordState === 'armed' ? '👂' : '🔇'}
</Text>
</TouchableOpacity>
</>
)}
+28 -3
View File
@@ -328,11 +328,12 @@ class AudioService {
};
if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs();
console.log('[Audio] VAD-Stille:', vadSilenceMs, 'ms');
console.log('[Audio] startRecording: autoStop=true, VAD-Stille=%dms, MAX=%dms',
vadSilenceMs, MAX_RECORDING_MS);
this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) {
fireSilenceOnce(`VAD ${silenceDuration}ms Stille`);
fireSilenceOnce(`VAD ${silenceDuration}ms Stille (Schwelle=${vadSilenceMs}ms)`);
}
}, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
@@ -459,7 +460,13 @@ class AudioService {
/** 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. */
* 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;
@@ -468,6 +475,24 @@ class AudioService {
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> {
const silent = !!payload.silent;
if (!silent && !PcmStreamPlayer) {
+40 -9
View File
@@ -17,6 +17,7 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ToastAndroid } from 'react-native';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
@@ -80,10 +81,20 @@ class WakeWordService {
// Laufende Instanz stoppen
await this.disposePorcupine();
if (!this.accessKey) return false;
if (!this.accessKey) {
console.warn('[WakeWord] configure: kein Access Key gesetzt');
return false;
}
// Neu initialisieren
return this.initPorcupine();
const ok = await this.initPorcupine();
if (!ok) {
ToastAndroid.show(
`Wake-Word "${this.keyword}" konnte nicht initialisiert werden — Logs pruefen`,
ToastAndroid.LONG,
);
}
return ok;
}
private async initPorcupine(): Promise<boolean> {
@@ -117,10 +128,14 @@ class WakeWordService {
this.disposePorcupine().catch(() => {});
},
);
console.log('[WakeWord] Porcupine init OK (keyword=%s)', this.keyword);
console.log('[WakeWord] Porcupine init OK (keyword=%s, manager=%s)',
this.keyword, this.porcupine ? 'created' : 'NULL');
return true;
} catch (err) {
console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err);
} catch (err: any) {
console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err?.message || err);
console.warn('[WakeWord] err details:', JSON.stringify({
name: err?.name, code: err?.code, stack: err?.stack?.slice(0, 200),
}));
this.porcupine = null;
return false;
} finally {
@@ -146,14 +161,27 @@ class WakeWordService {
try {
await this.porcupine.start();
console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
ToastAndroid.show(`Lausche auf "${this.keyword}"`, ToastAndroid.SHORT);
this.setState('armed');
return true;
} catch (err) {
console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err);
} catch (err: any) {
console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:',
err?.message || err);
ToastAndroid.show(
`Wake-Word-Start failed: ${err?.message || err}`,
ToastAndroid.LONG,
);
}
} else {
// Kein Porcupine init → User explicit informieren
console.warn('[WakeWord] Porcupine nicht initialisiert — Access Key fehlt? Fallback Direkt-Aufnahme');
ToastAndroid.show(
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
ToastAndroid.LONG,
);
}
// Fallback: direkt in die Konversation
console.log('[WakeWord] Konversation startet sofort (kein Wake-Word)');
// Fallback: direkt in die Konversation (Mikro AKTIV, nicht passive)
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
@@ -175,6 +203,7 @@ class WakeWordService {
/** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
ToastAndroid.show(`Wake-Word "${this.keyword}" erkannt — sprich jetzt`, ToastAndroid.SHORT);
if (this.porcupine) {
try { await this.porcupine.stop(); } catch {}
}
@@ -197,6 +226,7 @@ class WakeWordService {
try {
await this.porcupine.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
ToastAndroid.show(`Lausche wieder auf "${this.keyword}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
@@ -204,6 +234,7 @@ class WakeWordService {
}
}
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off');
}
+2 -1
View File
@@ -942,7 +942,8 @@ class ARIABridge:
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[core] XTTS-Request gesendet (%s): '%s'", xtts_voice or "default", tts_text[:60])
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'",
xtts_voice or "default", xtts_speed, tts_text[:60])
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
+4 -1
View File
@@ -239,6 +239,8 @@ class F5Runner:
def _infer_blocking(self, gen_text: str, ref_wav: str, ref_text: str,
speed: float = 1.0) -> tuple[np.ndarray, int]:
logger.info("infer() text=%d chars, speed=%.2f, cfg=%.2f, nfe=%d",
len(gen_text), speed, self.cfg_strength, self.nfe_step)
wav, sr, _ = self.model.infer(
ref_file=ref_wav,
ref_text=ref_text,
@@ -507,7 +509,8 @@ async def _do_tts(ws, runner: F5Runner, text: str, voice: str,
ref_wav_str, ref_text = str(pair[0]), pair[1].read_text(encoding="utf-8").strip()
sentences = split_sentences(text)
logger.info("F5-TTS: %d Satz(e), voice=%s (%s)", len(sentences), voice or "default", ref_wav_str)
logger.info("F5-TTS: %d Satz(e), voice=%s, speed=%.2fx (%s)",
len(sentences), voice or "default", speed, ref_wav_str)
chunk_index = 0
pcm_sr = TARGET_SR