Compare commits

...

14 Commits

Author SHA1 Message Date
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
duffyduck cc3fac8142 release: bump version to 0.0.6.1 2026-04-25 01:24:31 +02:00
duffyduck cd89e36ec2 fix: alte APKs im Cache werden jetzt aufgeraeumt
Die heruntergeladenen Update-APKs (~20-30MB pro Release) landeten in
CachesDirectoryPath und wurden nie geloescht. Bei regelmaessigen
Updates sammelt sich das auf mehrere 100MB an.

Fix: cleanupOldApks() wird gerufen
  - einmal beim App-Start (Constructor) — alte APKs sind sowieso nicht
    mehr relevant, die aktuelle Version laeuft ja aus dem System
  - vor jedem neuen Download — falls jemand zwei Updates in einer
    Session zieht

Loescht alle *.apk Dateien im CachesDirectoryPath und loggt die
freigemachte Groesse pro Datei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:22:22 +02:00
duffyduck f5b4285d15 release: bump version to 0.0.6.0 2026-04-25 01:13:42 +02:00
duffyduck 248e7c9ae4 fix: preroll=0 wirklich sofort + Trailing-Silence gegen Wort-Cutoff
Zwei Bugs die zusammen dafuer sorgen dass Worte "verschluckt" werden:

1) play() wurde bei preroll=0 erst beim ersten echten Chunk aufgerufen
   — nicht schon nach der Leading-Silence. Dadurch musste AudioTrack
   gleichzeitig Startup UND Audio abspielen, die Hardware-Anfahr-Latenz
   schluckt die ersten Samples.

   Fix: Bei prerollBytes==0 direkt nach dem silence-write play() rufen.
   AudioTrack haelt den Play-State und wartet auf mehr Samples — die
   naechsten Chunks kommen in den bereits laufenden Stream rein.

2) Nach letztem Chunk ging der Writer via return@Thread in den finally-
   Block. Der wartete zwar auf playbackHeadPosition >= totalFrames, aber
   Android's Hardware-Pipeline puffert oft noch ein paar Samples nach —
   stop() kam, Samples futsch.

   Fix: 300ms TRAILING_SILENCE am Ende schreiben. playbackHeadPosition
   erreicht echt bis zum Ende der echten Samples bevor die Stille abspielt.
   Loop umgeschrieben auf mainLoop-Label (break statt return@Thread) damit
   Trailing-Silence garantiert laeuft.

LEADING_SILENCE auf 300ms erhoeht fuer bessere AudioTrack-Warmup-Toleranz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:11:23 +02:00
duffyduck 7058cc8d8d release: bump version to 0.0.5.9 2026-04-25 01:04:00 +02:00
duffyduck 7919489543 feat: Pre-Roll-Buffer kann jetzt auf 0 (sofort abspielen)
F5-TTS ist schnell genug dass der Puffer bei kurzen Saetzen eher
schadet als nuetzt — er verzoegert den play()-Start fuer Sekunden die
dann als Wartezeit auffallen.

Aenderungen:
- audio.ts: TTS_PREROLL_MIN_SEC 1.0 → 0 (Einstellbar in Settings)
- PcmStreamPlayerModule.kt: MIN_PREROLL_SECONDS auf 0.0, Fallback-
  Logic respektiert jetzt 0 als gueltigen Wert (vorher hat der
  .let { if (it > 0) it else DEFAULT } 0 zu 3.5s umgebogen).

Bei preroll=0 greift der Leading-Silence von 200ms immer noch, d.h.
AudioTrack-Startup bleibt sauber. play() wird dann beim allerersten
echten PCM-Chunk aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:02:48 +02:00
duffyduck feac7f2479 feat(diagnostic): Speed-Slider im Voice-Preview-Modal (nicht persistiert)
Neue −0.1 / +0.1 Buttons im Preview-Modal mit aktuellem Wert-Label.
Bei jedem Oeffnen wird der Speed auf 1.0 zurueckgesetzt (bewusst kein
persist — nur zum Experimentieren waehrend das Modal offen ist).

- Range 0.1-5.0, gleiche wie in App-Settings
- Wird beim Play an f5tts-bridge als speed-Param mitgegeben
- Server clampt auf 0.1-5.0, Fallback 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:54:40 +02:00
duffyduck b80b813703 release: bump version to 0.0.5.8 2026-04-25 00:51:13 +02:00
duffyduck e7bb6c37cb feat: Sprechgeschwindigkeit-Range auf 0.1-5.0 erweitert
TTS_SPEED_MIN 0.5 → 0.1, TTS_SPEED_MAX 2.0 → 5.0.
Bridge-seitige Validierungen (aria_bridge.py + f5tts/bridge.py) mit-
gezogen auf den gleichen Bereich.

Hinweis: Extremwerte (unter 0.5 oder ueber 2.0) koennen bei F5-TTS
verzerrte Ausgaben produzieren — Stefan bekommt die Freiheit zum
Experimentieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:49:05 +02:00
duffyduck d146ca92c4 fix: Aufnahme-Crashes/Double-Tap durch VAD-Multi-Fire + stale closure
Drei zusammenhaengende Bugs:

1. VAD-Timer feuerte im 200ms setInterval WEITER nachdem die Stille-
   Schwelle erreicht war — listeners wurden pro Aufnahme bis zu 5x
   getriggert. Parallel laufende stopRecording()-Calls lieferten
   audio-recorder-player's nativen Layer OOM / Crash.

   Fix: silenceFired-Latch + Timer-Clear SOFORT beim ersten Feuer
   (fireSilenceOnce-Helper). Gleiche Logik fuer Max-Dauer + Conv-Window.

2. VoiceButton silence-listener re-registrierte bei jedem isRecording-
   Flip (deps [isRecording, onRecordingComplete]). Closure-State war
   stale, und bei schnellen flips gabs register/unregister-Races.

   Fix: empty deps, state direkt vom audioService via getRecordingState()
   lesen. onRecordingComplete via Ref (damit der Callback aktuell bleibt
   ohne re-register).

3. handleTap las den Button-State aus React (isRecording), der bei
   schnellen Taps stale sein konnte — "erst zweiter Tap geht" Symptom.

   Fix: audioService.getRecordingState() als Source-of-Truth, plus
   tapBusy-Ref als Anti-Doppel-Tap-Guard waehrend asyncer start/stop.
   'processing'-State wird korrekt ignoriert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:47:53 +02:00
duffyduck fd95af2c40 debug: Log wenn Pre-Roll-Fallback bei kurzem Text greift
Stefan hat aufgeklaert: Auto-Playback geht nur bei LANGEN Saetzen, bei
kurzen nicht. Das passt zur Pre-Roll-Logik: wenn weniger als pre-roll
Bytes gepuffert werden, soll eigentlich der Fallback in end() greifen,
der nach queue-Timeout play() aufruft.

Neuer Log-Eintrag zeigt ob der Fallback ausgeloest wurde:
  "Playback gestartet VOR Pre-Roll (kurzer Text, NNNNB gepuffert)"

Beim naechsten Test mit adb logcat sehen wir direkt:
  * Fallback-Log kommt → play() wurde aufgerufen, Problem liegt woanders
  * Fallback-Log kommt NICHT → endRequested wird nicht rechtzeitig
    erkannt oder Race mit concurrent handlePcmChunk-Calls

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:42:28 +02:00
duffyduck 9e12e0001c debug: Logs fuer Auto-Playback-Bug — canPlay + silent-state sichtbar
Stefan berichtet dass Auto-Playback trotz Closure-Fix nicht greift.
Zwei neue Log-Zeilen die beim naechsten Test direkt zeigen was schief
laeuft:
  - ChatScreen: "[Chat] audio-msg canPlay=X (enabled=Y muted=Z)"
  - audio.ts:   "[Audio] PCM-Stream start: silent=X messageId=Y ..."

Ausreichend um zu unterscheiden:
  * canPlay=false trotz Mund-an → ttsMuted bleibt im State haengen
  * canPlay=true aber silent=true in audio.ts → Ref-Bug oder race
  * silent=false aber nichts hoerbar → native-module oder audio-routing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:38:22 +02:00
11 changed files with 239 additions and 60 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 507 versionCode 602
versionName "0.0.5.7" versionName "0.0.6.2"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -32,11 +32,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
private const val TAG = "PcmStreamPlayer" private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt. // Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.5 private const val DEFAULT_PREROLL_SECONDS = 3.5
private const val MIN_PREROLL_SECONDS = 0.5 // 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk.
// Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer
// unnoetig ist und bei kurzen Saetzen sogar stoeren kann.
private const val MIN_PREROLL_SECONDS = 0.0
private const val MAX_PREROLL_SECONDS = 10.0 private const val MAX_PREROLL_SECONDS = 10.0
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die // Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz). // ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
private const val LEADING_SILENCE_SECONDS = 0.2 private const val LEADING_SILENCE_SECONDS = 0.3
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
private const val TRAILING_SILENCE_SECONDS = 0.3
} }
override fun getName() = "PcmStreamPlayer" override fun getName() = "PcmStreamPlayer"
@@ -59,9 +65,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
// Alte Session beenden falls vorhanden // Alte Session beenden falls vorhanden
stopInternal() stopInternal()
val prerollSec = prerollSeconds // Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS) val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
.let { if (it.isFinite() && it > 0) it else DEFAULT_PREROLL_SECONDS } prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
} else {
DEFAULT_PREROLL_SECONDS
}
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -103,9 +112,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
val t = track ?: return@Thread val t = track ?: return@Thread
try { try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren. // Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (silenceBytes > 0) { if (leadingBytes > 0) {
val silence = ByteArray(silenceBytes) val silence = ByteArray(leadingBytes)
var silOff = 0 var silOff = 0
while (silOff < silence.size && !writerShouldStop) { while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff) val w = t.write(silence, silOff, silence.size - silOff)
@@ -114,18 +123,38 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += silence.size bytesBuffered += silence.size
} }
while (!writerShouldStop) { // Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run { // nicht erst bei Ankunft des ersten echten Chunks. Android's
// AudioTrack haelt den Play-State und wartet auf neue Samples.
// So verschluckt es keine Worte wenn der erste Chunk erst
// nach play()-Startup-Latenz eintrifft.
if (prerollBytes == 0 && !playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)")
} catch (e: Exception) {
Log.w(TAG, "play() sofort failed: ${e.message}")
}
}
mainLoop@ while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) {
if (endRequested) { if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen // Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) { if (!playbackStarted) {
try { t.play() } catch (_: Exception) {} try {
playbackStarted = true t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
} catch (e: Exception) {
Log.w(TAG, "play() fallback failed: ${e.message}")
}
} }
return@Thread break@mainLoop
} }
null continue@mainLoop
} ?: continue }
// Pre-Roll Check: play() erst wenn genug gepuffert // Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
@@ -146,6 +175,19 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
} }
bytesBuffered += data.size bytesBuffered += data.size
} }
// Trailing-Silence damit die letzten echten Samples garantiert
// durch das Hardware-Buffering kommen bevor stop() sie abschneidet
val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (trailingBytes > 0 && !writerShouldStop) {
val silence = ByteArray(trailingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}") Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally { } finally {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.0.5.7", "version": "0.0.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+38 -20
View File
@@ -93,18 +93,24 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
} }
}, [isRecording]); }, [isRecording]);
// VAD Silence Callback — Auto-Stop // 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(() => { useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => { const unsubSilence = audioService.onSilenceDetected(async () => {
if (!isRecording) return; if (audioService.getRecordingState() !== 'recording') return;
setIsRecording(false);
const result = await audioService.stopRecording(); const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 500) { if (result && result.durationMs > 500) {
onRecordingComplete(result); onCompleteRef.current(result);
} }
}); });
return unsubSilence; return unsubSilence;
}, [isRecording, onRecordingComplete]); }, []);
// Auto-Start fuer Wake Word (extern getriggert) // Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => { const startAutoRecording = useCallback(async () => {
@@ -136,23 +142,35 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
} }
}; };
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop // Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => { const handleTap = async () => {
if (disabled) return; if (disabled || tapBusy.current) return;
if (isRecording) { tapBusy.current = true;
// Aufnahme manuell stoppen try {
setIsRecording(false); // Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
const result = await audioService.stopRecording(); const svcState = audioService.getRecordingState();
if (result && result.durationMs > 300) { if (svcState === 'recording') {
onRecordingComplete(result); // Aufnahme manuell stoppen
} const result = await audioService.stopRecording();
} else { setIsRecording(false);
// Aufnahme mit Auto-Stop starten if (result && result.durationMs > 300) {
const started = await audioService.startRecording(true); onRecordingComplete(result);
if (started) { }
isLongPress.current = false; } else if (svcState === 'idle') {
setIsRecording(true); // Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
} }
// 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.
} finally {
tapBusy.current = false;
} }
}; };
+4
View File
@@ -316,6 +316,10 @@ const ChatScreen: React.FC = () => {
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
// WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug). // WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug).
const canPlay = ttsCanPlayRef.current; const canPlay = ttsCanPlayRef.current;
if (message.type === 'audio_pcm' || (message.type === 'audio' && message.payload.base64)) {
console.log('[Chat] audio-msg canPlay=%s (enabled=%s muted=%s)',
canPlay, ttsDeviceEnabled, ttsMuted);
}
if (message.type === 'audio' && message.payload.base64) { if (message.type === 'audio' && message.payload.base64) {
const b64 = message.payload.base64 as string; const b64 = message.payload.base64 as string;
const refId = (message.payload.messageId as string) || ''; const refId = (message.payload.messageId as string) || '';
+59 -13
View File
@@ -95,8 +95,8 @@ export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die // TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal. // Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0; export const TTS_SPEED_DEFAULT = 1.0;
export const TTS_SPEED_MIN = 0.5; export const TTS_SPEED_MIN = 0.1;
export const TTS_SPEED_MAX = 2.0; export const TTS_SPEED_MAX = 5.0;
export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed'; export const TTS_SPEED_STORAGE_KEY = 'aria_tts_speed';
export async function loadTtsSpeed(): Promise<number> { export async function loadTtsSpeed(): Promise<number> {
@@ -143,7 +143,7 @@ const MAX_RECORDING_MS = 120000;
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet. // Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec). // Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
export const TTS_PREROLL_DEFAULT_SEC = 3.5; export const TTS_PREROLL_DEFAULT_SEC = 3.5;
export const TTS_PREROLL_MIN_SEC = 1.0; 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_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec'; export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
@@ -196,6 +196,8 @@ class AudioService {
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
private vadTimer: ReturnType<typeof setInterval> | null = null; private vadTimer: ReturnType<typeof setInterval> | null = null;
private maxDurationTimer: ReturnType<typeof setTimeout> | 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; private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
@@ -305,33 +307,46 @@ class AudioService {
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar) // 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.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) { if (autoStop) {
const vadSilenceMs = await loadVadSilenceMs(); const vadSilenceMs = await loadVadSilenceMs();
console.log('[Audio] VAD-Stille:', vadSilenceMs, 'ms'); console.log('[Audio] VAD-Stille:', vadSilenceMs, 'ms');
this.vadTimer = setInterval(() => { this.vadTimer = setInterval(() => {
const silenceDuration = Date.now() - this.lastSpeechTime; const silenceDuration = Date.now() - this.lastSpeechTime;
if (silenceDuration >= vadSilenceMs) { if (silenceDuration >= vadSilenceMs) {
console.log(`[Audio] VAD: ${silenceDuration}ms Stille — Auto-Stop`); fireSilenceOnce(`VAD ${silenceDuration}ms Stille`);
this.silenceListeners.forEach(cb => cb());
} }
}, 200); }, 200);
// Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen // Notbremse: Nach MAX_RECORDING_MS zwangsweise stoppen
this.maxDurationTimer = setTimeout(() => { this.maxDurationTimer = setTimeout(() => {
console.warn(`[Audio] Max-Dauer ${MAX_RECORDING_MS}ms erreicht — Zwangs-Stop`); fireSilenceOnce(`Max-Dauer ${MAX_RECORDING_MS}ms`);
this.silenceListeners.forEach(cb => cb());
}, MAX_RECORDING_MS); }, MAX_RECORDING_MS);
} }
// Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht // Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht
// anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie), // anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie).
// ChatScreen erkennt das und beendet die Konversation.
if (noSpeechTimeoutMs > 0) { if (noSpeechTimeoutMs > 0) {
this.noSpeechTimer = setTimeout(() => { this.noSpeechTimer = setTimeout(() => {
if (!this.speechDetected && this.recordingState === 'recording') { if (!this.speechDetected && this.recordingState === 'recording') {
console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`); fireSilenceOnce(`Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache`);
this.silenceListeners.forEach(cb => cb());
} }
}, noSpeechTimeoutMs); }, noSpeechTimeoutMs);
} }
@@ -444,7 +459,13 @@ class AudioService {
/** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen. /** Einen PCM-Chunk aus einer audio_pcm Nachricht empfangen.
* silent=true → nur cachen, nicht abspielen (z.B. wenn TTS geraetelokal gemutet). * 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: { async handlePcmChunk(payload: {
base64: string; base64: string;
sampleRate?: number; sampleRate?: number;
@@ -453,12 +474,37 @@ class AudioService {
chunk?: number; chunk?: number;
final?: boolean; final?: boolean;
silent?: 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> { }): Promise<string> {
const silent = !!payload.silent; const silent = !!payload.silent;
if (!silent && !PcmStreamPlayer) { if (!silent && !PcmStreamPlayer) {
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
return ''; 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 messageId = payload.messageId || '';
const sampleRate = payload.sampleRate || 24000; const sampleRate = payload.sampleRate || 24000;
+33
View File
@@ -29,6 +29,11 @@ class UpdateService {
private downloading = false; private downloading = false;
constructor() { constructor() {
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
// laeuft, sind frueher heruntergeladene APKs entweder schon installiert
// oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy.
this.cleanupOldApks().catch(() => {});
// Auf update_available Nachrichten lauschen // Auf update_available Nachrichten lauschen
rvs.onMessage((msg: RVSMessage) => { rvs.onMessage((msg: RVSMessage) => {
if (msg.type === 'update_available' as any) { if (msg.type === 'update_available' as any) {
@@ -45,6 +50,30 @@ class UpdateService {
}); });
} }
/** Raeumt alte heruntergeladene APK-Dateien aus dem Cache auf. */
private async cleanupOldApks(): Promise<void> {
try {
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
const apks = files.filter(f => /\.apk$/i.test(f.name));
let freed = 0;
for (const f of apks) {
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
freed += size;
console.log(`[Update] Alte APK geloescht: ${f.name} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.name} (${err?.message || err})`);
}
}
if (apks.length > 0) {
console.log(`[Update] Cleanup fertig: ${apks.length} APKs entfernt, ${(freed / 1024 / 1024).toFixed(1)}MB freigegeben`);
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler: ${err?.message || err}`);
}
}
/** Bei App-Start Update pruefen */ /** Bei App-Start Update pruefen */
checkForUpdate(): void { checkForUpdate(): void {
if (this.checking) return; if (this.checking) return;
@@ -111,6 +140,10 @@ class UpdateService {
}); });
}); });
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
// Updates in einer Session gezogen werden
await this.cleanupOldApks();
// Base64 als APK-Datei speichern // Base64 als APK-Datei speichern
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`; const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64'); await RNFS.writeFile(destPath, apkData.base64, 'base64');
+3 -3
View File
@@ -1176,7 +1176,7 @@ class ARIABridge:
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) # Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
try: try:
speed = float(payload.get("speed", 0) or 0) speed = float(payload.get("speed", 0) or 0)
if 0.25 <= speed <= 4.0: if 0.1 <= speed <= 5.0:
self._next_speed_override = speed self._next_speed_override = speed
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
@@ -1236,7 +1236,7 @@ class ARIABridge:
xtts_voice = payload.get("voice", "") or getattr(self, 'xtts_voice', '') xtts_voice = payload.get("voice", "") or getattr(self, 'xtts_voice', '')
try: try:
xtts_speed = float(payload.get("speed", 0) or 0) xtts_speed = float(payload.get("speed", 0) or 0)
if not (0.25 <= xtts_speed <= 4.0): if not (0.1 <= xtts_speed <= 5.0):
xtts_speed = 1.0 xtts_speed = 1.0
except (TypeError, ValueError): except (TypeError, ValueError):
xtts_speed = 1.0 xtts_speed = 1.0
@@ -1450,7 +1450,7 @@ class ARIABridge:
logger.info("[rvs] Voice-Override (via Audio): %s", voice_override) logger.info("[rvs] Voice-Override (via Audio): %s", voice_override)
try: try:
speed = float(payload.get("speed", 0) or 0) speed = float(payload.get("speed", 0) or 0)
if 0.25 <= speed <= 4.0: if 0.1 <= speed <= 5.0:
self._next_speed_override = speed self._next_speed_override = speed
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
+34 -1
View File
@@ -145,6 +145,15 @@
</div> </div>
<textarea id="voice-preview-text" rows="4" <textarea id="voice-preview-text" rows="4"
style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px;color:#fff;font-size:13px;resize:vertical;"></textarea> style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px;color:#fff;font-size:13px;resize:vertical;"></textarea>
<div style="display:flex;align-items:center;gap:10px;font-size:12px;color:#8888AA;">
<span style="min-width:120px;">Geschwindigkeit:</span>
<button onclick="adjustPreviewSpeed(-0.1)" class="btn secondary" style="padding:4px 10px;font-size:12px;">0.1</button>
<span id="voice-preview-speed-value" style="min-width:52px;text-align:center;color:#fff;font-weight:600;">1.0 x</span>
<button onclick="adjustPreviewSpeed(0.1)" class="btn secondary" style="padding:4px 10px;font-size:12px;">+0.1</button>
<span style="color:#555570;font-size:11px;">(nur fuer dieses Modal, wird nicht gespeichert)</span>
</div>
<div style="display:flex;gap:8px;align-items:center;"> <div style="display:flex;gap:8px;align-items:center;">
<button id="voice-preview-play" onclick="playVoicePreview()" class="btn primary" style="padding:8px 16px;"> <button id="voice-preview-play" onclick="playVoicePreview()" class="btn primary" style="padding:8px 16px;">
▶ Abspielen ▶ Abspielen
@@ -1630,10 +1639,29 @@
// ── Voice Preview Modal ───────────────────────── // ── Voice Preview Modal ─────────────────────────
const VOICE_PREVIEW_DEFAULT = 'Hallo, ich bin ARIA. Das hier ist ein kleiner Test damit du meine Stimme beurteilen kannst.'; const VOICE_PREVIEW_DEFAULT = 'Hallo, ich bin ARIA. Das hier ist ein kleiner Test damit du meine Stimme beurteilen kannst.';
const PREVIEW_SPEED_DEFAULT = 1.0;
const PREVIEW_SPEED_MIN = 0.1;
const PREVIEW_SPEED_MAX = 5.0;
let currentPreviewVoice = ''; let currentPreviewVoice = '';
let currentPreviewSpeed = PREVIEW_SPEED_DEFAULT;
function _refreshPreviewSpeedLabel() {
const el = document.getElementById('voice-preview-speed-value');
if (el) el.textContent = currentPreviewSpeed.toFixed(1) + ' x';
}
function adjustPreviewSpeed(delta) {
const next = Math.round((currentPreviewSpeed + delta) * 10) / 10;
if (next < PREVIEW_SPEED_MIN || next > PREVIEW_SPEED_MAX) return;
currentPreviewSpeed = next;
_refreshPreviewSpeedLabel();
}
function openVoicePreview(name) { function openVoicePreview(name) {
currentPreviewVoice = name; currentPreviewVoice = name;
// Speed bei jedem Oeffnen zuruecksetzen — bewusst kein persist
currentPreviewSpeed = PREVIEW_SPEED_DEFAULT;
_refreshPreviewSpeedLabel();
document.getElementById('voice-preview-name').textContent = name; document.getElementById('voice-preview-name').textContent = name;
// Text bei jedem Oeffnen zuruecksetzen // Text bei jedem Oeffnen zuruecksetzen
document.getElementById('voice-preview-text').value = VOICE_PREVIEW_DEFAULT; document.getElementById('voice-preview-text').value = VOICE_PREVIEW_DEFAULT;
@@ -1658,7 +1686,12 @@
} }
document.getElementById('voice-preview-status').textContent = '⏳ Rendere...'; document.getElementById('voice-preview-status').textContent = '⏳ Rendere...';
document.getElementById('voice-preview-play').disabled = true; document.getElementById('voice-preview-play').disabled = true;
send({ action: 'preview_voice', voice: currentPreviewVoice, text }); send({
action: 'preview_voice',
voice: currentPreviewVoice,
text,
speed: currentPreviewSpeed,
});
} }
function deleteXttsVoice(name) { function deleteXttsVoice(name) {
+7 -4
View File
@@ -1469,7 +1469,7 @@ wss.on("connection", (ws) => {
} else if (msg.action === "test_tts") { } else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test"); handleTestTTS(ws, msg.text || "Test");
} else if (msg.action === "preview_voice") { } else if (msg.action === "preview_voice") {
handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo."); handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed);
} else if (msg.action === "check_tts") { } else if (msg.action === "check_tts") {
handleCheckTTS(ws); handleCheckTTS(ws);
} else if (msg.action === "check_desktop") { } else if (msg.action === "check_desktop") {
@@ -1704,8 +1704,11 @@ function _handlePreviewChunk(payload) {
} }
} }
async function handleVoicePreview(clientWs, voice, text) { async function handleVoicePreview(clientWs, voice, text, speed) {
try { try {
// Speed clampen — Browser-Slider ist 0.1-5.0
let spd = parseFloat(speed);
if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0;
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 }); _previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
// Timeout safety net // Timeout safety net
@@ -1720,10 +1723,10 @@ async function handleVoicePreview(clientWs, voice, text) {
} }
} }
}, 60000); }, 60000);
log("info", "server", `Voice-Preview: voice="${voice}" text="${text.slice(0, 60)}"`); log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`);
sendToRVS_raw({ sendToRVS_raw({
type: "xtts_request", type: "xtts_request",
payload: { text, language: "de", requestId, voice, speed: 1.0 }, payload: { text, language: "de", requestId, voice, speed: spd },
timestamp: Date.now(), timestamp: Date.now(),
}); });
} catch (err) { } catch (err) {
+1 -1
View File
@@ -762,7 +762,7 @@ async def run_loop(runner: F5Runner) -> None:
speed = float(payload.get("speed") or 1.0) speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError): except (TypeError, ValueError):
speed = 1.0 speed = 1.0
if not (0.25 <= speed <= 4.0): if not (0.1 <= speed <= 5.0):
speed = 1.0 speed = 1.0
await _tts_queue.put(( await _tts_queue.put((
payload.get("text", ""), payload.get("text", ""),