Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20050d4077 | |||
| 79760d1b2e | |||
| 13f1103604 | |||
| 73b7a76ea8 | |||
| 17f3d8870e | |||
| 4feaacc7e4 | |||
| af7b2674f3 | |||
| 97442198ec | |||
| e3e841f2ab | |||
| 33185de42b | |||
| dbe547d4ea |
@@ -384,7 +384,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
|
||||
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
|
||||
- **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur"
|
||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||
- **Anruf-Pause**: TTS verstummt automatisch wenn das Telefon klingelt (READ_PHONE_STATE Permission)
|
||||
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
|
||||
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
|
||||
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
|
||||
@@ -527,12 +527,18 @@ App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
|
||||
| TTS spielt (auch GPU-Pausen) | bleibt pausiert | barge wenn Wake-Word |
|
||||
| TTS zu Ende | nach 800ms resumed | (Conversation-Window) |
|
||||
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert |
|
||||
| Anruf vorbei | — | Mikro wieder armed |
|
||||
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus |
|
||||
| Neue Frage waehrend Anruf | — | (Resume verworfen) |
|
||||
|
||||
Mechanismen: Underrun-Schutz im PcmStreamPlayer (Stille-Fill in Render-
|
||||
Pausen), Conversation-Focus bei Wake-Word, Foreground-Service mit
|
||||
mediaPlayback|microphone, Anruf-Erkennung ueber TelephonyManager +
|
||||
AudioFocus-Loss-Listener mit Polling-Fallback (VoIP).
|
||||
AudioFocus-Loss-Listener mit Polling-Fallback (VoIP). Bei Anruf wird
|
||||
die Wiedergabe-Position gemerkt — nach dem Auflegen spielt ARIA ab
|
||||
der genauen Stelle weiter (oder verwirft das wenn der User waehrend
|
||||
des Telefonats per Text eine neue Frage gestellt hat). PcmPlayback-
|
||||
Finished-Event vom Native sorgt dafuer dass Spotify erst pausiert
|
||||
bleibt bis ARIA wirklich verstummt ist.
|
||||
|
||||
### Datei-Pipeline (Bilder & Anhaenge)
|
||||
|
||||
@@ -864,7 +870,9 @@ docker exec aria-core ssh aria-wohnung hostname
|
||||
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
|
||||
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
|
||||
- [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint
|
||||
- [x] Anruf-Pause: TTS verstummt bei eingehendem Anruf (PhoneStateListener)
|
||||
- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort)
|
||||
- [x] PcmPlaybackFinished-Event: AudioFocus wird erst released wenn AudioTrack wirklich durch ist — kein Spotify-mid-TTS mehr
|
||||
- [x] Edge-Case: neue Frage waehrend Telefonat verwirft pending Auto-Resume, neueste Antwort gewinnt
|
||||
- [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste
|
||||
- [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB
|
||||
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 805
|
||||
versionName "0.0.8.5"
|
||||
versionCode 808
|
||||
versionName "0.0.8.8"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
|
||||
/**
|
||||
@@ -76,9 +78,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
||||
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
|
||||
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
||||
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
|
||||
// Buffer entkoppelt von Preroll — fester ~2s-Buffer reicht. Wenn er
|
||||
// an Preroll gekoppelt ist (z.B. 7s bei preroll=3.5s) und nur kurz
|
||||
// gefuettert wird, stallt AudioTrack auf manchen Geraeten (OnePlus
|
||||
// Android 12: pos bleibt 0 obwohl play() lief).
|
||||
val bufferSize = (bytesPerSecond * 2).coerceAtLeast(minBuf * 8)
|
||||
prerollBytes = prerollTarget
|
||||
bytesBuffered = 0
|
||||
playbackStarted = false
|
||||
@@ -235,12 +240,21 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
|
||||
var lastPos = -1
|
||||
var stalledCount = 0
|
||||
var retried = false
|
||||
while (!writerShouldStop) {
|
||||
val pos = t.playbackHeadPosition
|
||||
if (pos >= totalFrames) break
|
||||
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
|
||||
if (pos == lastPos) {
|
||||
stalledCount++
|
||||
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
|
||||
// Geraeten (OnePlus A12) — play() nochmal anstossen.
|
||||
if (stalledCount == 10 && pos == 0 && !retried) {
|
||||
retried = true
|
||||
Log.w(TAG, "playback nicht angefahren — retry play()")
|
||||
try { t.play() } catch (e: Exception) {
|
||||
Log.w(TAG, "retry play() failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (stalledCount > 40) {
|
||||
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
|
||||
break
|
||||
@@ -255,6 +269,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
} catch (_: Exception) {}
|
||||
try { t.stop() } catch (_: Exception) {}
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
// RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt).
|
||||
// JS released erst JETZT den AudioFocus — sonst spielt Spotify
|
||||
// beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer).
|
||||
try {
|
||||
val params = Arguments.createMap()
|
||||
reactApplicationContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("PcmPlaybackFinished", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PlaybackFinished emit failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}, "PcmStreamWriter").apply { start() }
|
||||
|
||||
@@ -311,6 +336,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
|
||||
private fun stopInternal() {
|
||||
writerShouldStop = true
|
||||
endRequested = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.8.5",
|
||||
"version": "0.0.8.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -63,6 +63,7 @@ import wakeWordService, {
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||
import updateService from '../services/updater';
|
||||
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
@@ -132,6 +133,7 @@ const SettingsScreen: React.FC = () => {
|
||||
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
@@ -220,6 +222,7 @@ const SettingsScreen: React.FC = () => {
|
||||
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
|
||||
});
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
if (saved) setXttsVoice(saved);
|
||||
});
|
||||
@@ -1194,6 +1197,37 @@ const SettingsScreen: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* === Update-Cache === */}
|
||||
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Update-Cache</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.toggleHint}>
|
||||
Heruntergeladene APK-Dateien fuer App-Updates. Werden automatisch
|
||||
beim App-Start und vor jedem neuen Download geloescht — der Button
|
||||
ist fuer den Notfall (z.B. wenn ein Download haengen geblieben ist).
|
||||
</Text>
|
||||
<Text style={[styles.storageSizeText, {marginTop: 8}]}>
|
||||
{apkCacheInfo === null ? '...' :
|
||||
apkCacheInfo.count === 0 ? 'leer' :
|
||||
`${apkCacheInfo.count} APK${apkCacheInfo.count === 1 ? '' : 's'} · ${apkCacheInfo.totalMB.toFixed(1)}MB`}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
||||
onPress={async () => {
|
||||
const res = await updateService.cleanupOldApks();
|
||||
ToastAndroid.show(
|
||||
res.removed === 0
|
||||
? 'Update-Cache war schon leer'
|
||||
: `${res.removed} APK${res.removed === 1 ? '' : 's'} geloescht (${res.freedMB.toFixed(1)}MB frei)`,
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
const info = await updateService.getApkCacheSize();
|
||||
setApkCacheInfo(info);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Update-Cache leeren</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
</>)}
|
||||
|
||||
{/* === Logs === */}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
||||
*/
|
||||
|
||||
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react-native';
|
||||
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';
|
||||
@@ -269,9 +269,38 @@ class AudioService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
||||
@@ -326,6 +355,84 @@ class AudioService {
|
||||
this.stopPlayback();
|
||||
}
|
||||
|
||||
/** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem
|
||||
* Auflegen von dort weitermachen koennen. Returnt Position in Sekunden
|
||||
* oder 0 wenn nichts spielte. */
|
||||
captureInterruption(): number {
|
||||
if (!this.playbackStartTime || !this.currentPlaybackMsgId) {
|
||||
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) return false;
|
||||
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;
|
||||
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
|
||||
@@ -707,6 +814,21 @@ class AudioService {
|
||||
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;
|
||||
@@ -741,13 +863,16 @@ class AudioService {
|
||||
|
||||
if (isFinal) {
|
||||
if (!silent) {
|
||||
// end() resolved jetzt erst wenn der native Writer-Thread fertig
|
||||
// ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
|
||||
// freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
|
||||
// ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
|
||||
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
|
||||
// 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 {}
|
||||
this._releaseFocusDeferred();
|
||||
// playbackFinished-Listener informieren (UI-Logik)
|
||||
this.playbackFinishedListeners.forEach(cb => {
|
||||
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
|
||||
});
|
||||
}
|
||||
this.pcmStreamActive = false;
|
||||
|
||||
@@ -858,6 +983,9 @@ class AudioService {
|
||||
}
|
||||
|
||||
private _firePlaybackStarted(): void {
|
||||
// Tracking fuer Auto-Resume nach Anruf-Pause
|
||||
this.playbackStartTime = Date.now();
|
||||
this.currentPlaybackMsgId = this.pcmMessageId || '';
|
||||
this.playbackStartedListeners.forEach(cb => {
|
||||
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
||||
});
|
||||
|
||||
@@ -126,13 +126,24 @@ class PhoneCallService {
|
||||
}
|
||||
}
|
||||
|
||||
/** AudioFocus-Loss = irgendeine andere App hat das Mikro/die Audio-Pipeline
|
||||
* uebernommen — typisch VoIP-Apps bei eingehendem Anruf, aber auch System-
|
||||
* Voice-Assistants etc. */
|
||||
private _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): void {
|
||||
/** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen.
|
||||
* Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen
|
||||
* Audio-Playern (anderer Player startet, Notification-Sound, sogar
|
||||
* unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den
|
||||
* AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */
|
||||
private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise<void> {
|
||||
if (type === 'loss' || type === 'loss_transient') {
|
||||
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
|
||||
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
|
||||
// Mode pruefen — nur echte Anrufe behandeln.
|
||||
let mode = -1;
|
||||
try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {}
|
||||
if (mode !== 2 && mode !== 3) {
|
||||
// NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt
|
||||
// oder Spotify hat sich neu reingedraengelt). Keine Toasts.
|
||||
console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode);
|
||||
return;
|
||||
}
|
||||
this.interruptedByFocus = true;
|
||||
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
|
||||
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
|
||||
@@ -176,6 +187,8 @@ class PhoneCallService {
|
||||
}
|
||||
|
||||
private _haltForCall(toast: string): void {
|
||||
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
|
||||
audioService.captureInterruption();
|
||||
audioService.haltAllPlayback(toast);
|
||||
wakeWordService.pauseForCall().catch(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
@@ -184,6 +197,14 @@ class PhoneCallService {
|
||||
private _resumeAfterCall(toast: string): void {
|
||||
wakeWordService.resumeFromCall().catch(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
// Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem
|
||||
// Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls
|
||||
// final-Marker erst nach dem Anruf-Ende kam).
|
||||
audioService.resumeFromInterruption(30000).then(ok => {
|
||||
if (ok) {
|
||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,28 +50,69 @@ 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}`);
|
||||
/** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
|
||||
private async _apkSearchDirs(): Promise<string[]> {
|
||||
const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
|
||||
if ((RNFS as any).ExternalCachesDirectoryPath) {
|
||||
dirs.push((RNFS as any).ExternalCachesDirectoryPath);
|
||||
}
|
||||
if (RNFS.ExternalDirectoryPath) {
|
||||
dirs.push(RNFS.ExternalDirectoryPath);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf.
|
||||
* Public damit Settings den Button "Update-Cache leeren" benutzen kann. */
|
||||
async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> {
|
||||
const dirs = await this._apkSearchDirs();
|
||||
let removed = 0;
|
||||
let freed = 0;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
if (!(await RNFS.exists(dir))) continue;
|
||||
const files = await RNFS.readDir(dir);
|
||||
const apks = files.filter(f => /\.apk$/i.test(f.name));
|
||||
for (const f of apks) {
|
||||
if (keepCurrentName && f.name === keepCurrentName) continue;
|
||||
try {
|
||||
const size = parseInt(f.size as any, 10) || 0;
|
||||
await RNFS.unlink(f.path);
|
||||
removed += 1;
|
||||
freed += size;
|
||||
console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
const freedMB = freed / 1024 / 1024;
|
||||
if (removed > 0) {
|
||||
console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`);
|
||||
}
|
||||
return { removed, freedMB };
|
||||
}
|
||||
|
||||
/** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */
|
||||
async getApkCacheSize(): Promise<{ count: number; totalMB: number }> {
|
||||
const dirs = await this._apkSearchDirs();
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
if (!(await RNFS.exists(dir))) continue;
|
||||
const files = await RNFS.readDir(dir);
|
||||
for (const f of files) {
|
||||
if (!f.isFile() || !/\.apk$/i.test(f.name)) continue;
|
||||
count += 1;
|
||||
total += parseInt(f.size as any, 10) || 0;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { count, totalMB: total / 1024 / 1024 };
|
||||
}
|
||||
|
||||
/** Bei App-Start Update pruefen */
|
||||
|
||||
+29
-2
@@ -278,6 +278,10 @@
|
||||
<input type="checkbox" id="tts-debug-toggle" onchange="toggleTtsDebug()" style="margin-right:4px;vertical-align:middle;">
|
||||
TTS-Text einblenden
|
||||
</label>
|
||||
<label style="color:#8888AA;font-size:11px;cursor:pointer;">
|
||||
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
|
||||
GPS-Position einblenden
|
||||
</label>
|
||||
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1004,7 +1008,7 @@
|
||||
if (sender === 'aria') return;
|
||||
const chatType = 'sent';
|
||||
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
|
||||
addChat(chatType, p.text || '?', label);
|
||||
addChat(chatType, p.text || '?', label, { location: p.location });
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'proxy_result') {
|
||||
@@ -1395,6 +1399,16 @@
|
||||
if (el) el.checked = showTtsDebug;
|
||||
}
|
||||
|
||||
// Debug-Toggle: GPS-Position unter User-Nachrichten einblenden (nur Diagnostic).
|
||||
// App zeigt's bewusst nicht — die Position geht nur an aria-core.
|
||||
let showGpsDebug = localStorage.getItem('aria-show-gps-debug') === '1';
|
||||
function toggleGpsDebug() {
|
||||
showGpsDebug = !showGpsDebug;
|
||||
localStorage.setItem('aria-show-gps-debug', showGpsDebug ? '1' : '0');
|
||||
const el = document.getElementById('gps-debug-toggle');
|
||||
if (el) el.checked = showGpsDebug;
|
||||
}
|
||||
|
||||
// Minimal-JS-Port von clean_text_for_tts() (Bridge) — reine Anzeige
|
||||
function previewTtsText(text) {
|
||||
if (!text) return '';
|
||||
@@ -1434,7 +1448,18 @@
|
||||
ttsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(0,150,255,0.08);border-left:2px solid #0096FF;font-size:11px;color:#88AACC;"><span style="color:#0096FF;font-weight:bold;">TTS:</span> ${escapeHtml(ttsText)}</div>`;
|
||||
}
|
||||
}
|
||||
const html = `${linked}${ttsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
||||
// Optional: GPS-Position als Block unter User-Nachrichten (nur Diagnostic)
|
||||
let gpsBlock = '';
|
||||
if (showGpsDebug && options && options.location) {
|
||||
const loc = options.location;
|
||||
const lat = typeof loc.lat === 'number' ? loc.lat.toFixed(6) : '?';
|
||||
const lon = typeof loc.lon === 'number' ? loc.lon.toFixed(6) : (typeof loc.lng === 'number' ? loc.lng.toFixed(6) : '?');
|
||||
if (lat !== '?' && lon !== '?') {
|
||||
const mapLink = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
gpsBlock = `<div style="margin-top:6px;padding:4px 8px;background:rgba(52,199,89,0.08);border-left:2px solid #34C759;font-size:11px;color:#88BB99;"><span style="color:#34C759;font-weight:bold;">📍 GPS:</span> <a href="${mapLink}" target="_blank" rel="noopener" style="color:#88BB99;text-decoration:underline;">${lat}, ${lon}</a></div>`;
|
||||
}
|
||||
}
|
||||
const html = `${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
|
||||
|
||||
// Thinking-Indikator ausblenden bei neuer Nachricht
|
||||
updateThinkingIndicator({ activity: 'idle' });
|
||||
@@ -2451,6 +2476,8 @@
|
||||
// Toggle-Checkbox initial korrekt setzen
|
||||
const ttsToggleEl = document.getElementById('tts-debug-toggle');
|
||||
if (ttsToggleEl) ttsToggleEl.checked = showTtsDebug;
|
||||
const gpsToggleEl = document.getElementById('gps-debug-toggle');
|
||||
if (gpsToggleEl) gpsToggleEl.checked = showGpsDebug;
|
||||
|
||||
// Disk-Space Banner aktualisieren (wird vom Server via disk_status gepusht)
|
||||
function updateDiskBanner(status) {
|
||||
|
||||
@@ -18,6 +18,9 @@ Wenn was anders ist, ist's ein Bug.
|
||||
| TTS zu Ende | nach 800ms resumed | (Conversation-Window)| (tts released) |
|
||||
| Eingehender Anruf (auch VoIP)| — | Mikro pausiert | aus |
|
||||
| Anruf vorbei | — | Mikro wieder armed | aktiv ('wake') |
|
||||
| Anruf vorbei (Auto-Resume) | pausiert wieder | aus | aktiv ('tts') |
|
||||
| Neue Frage waehrend Anruf | — | Mikro pausiert | (rec waehrend Anruf nicht) |
|
||||
| Anruf vorbei nach neuer Frage | (siehe TTS-Phasen) | (siehe TTS-Phasen) | (tts gewinnt, alter Resume verworfen) |
|
||||
|
||||
Wichtige Mechanismen:
|
||||
- **Underrun-Schutz** im PcmStreamPlayer fuettert Stille rein wenn die
|
||||
@@ -32,6 +35,21 @@ Wichtige Mechanismen:
|
||||
zu/bereit").
|
||||
- **Anruf-Erkennung** ueber TelephonyManager (klassisch) + AudioFocus-
|
||||
Loss-Listener mit Polling-Fallback (VoIP wie WhatsApp/Signal/Discord).
|
||||
- **Auto-Resume nach Anruf**: beim Halt wird die Wiedergabe-Position
|
||||
gemerkt (Date.now() - playbackStart - leadingSilence). Nach Auflegen
|
||||
wartet die App bis zu 30s auf den WAV-Cache und spielt dann ab der
|
||||
gemerkten Position weiter. Wenn das Telefonat länger als die Antwort
|
||||
dauerte, ist der Cache schon fertig — instant Resume.
|
||||
- **Neue Frage waehrend Anruf** (Text-Chat geht trotz Telefonat): die
|
||||
neue Antwort ueberschreibt den pending Resume. _handlePcmChunkImpl
|
||||
stoppt einen ggf. laufenden resumeSound und setzt pausedMessageId
|
||||
zurueck wenn die neue Stream-messageId abweicht. Die letzte Antwort
|
||||
gewinnt immer.
|
||||
- **Audio-Ausgabe trotz aktivem Telefonat**: ARIA antwortet auch waehrend
|
||||
eines Telefonats per Lautsprecher (Telefon-Audio geht ueber separaten
|
||||
Stream zur Gegenseite). haltAllPlayback wird nur beim STATE-WECHSEL
|
||||
ringing/offhook gerufen — wenn der Anruf schon laeuft (offhook→offhook),
|
||||
triggert eine neue Frage keinen Halt mehr.
|
||||
|
||||
## Erledigt
|
||||
|
||||
@@ -141,6 +159,12 @@ Wichtige Mechanismen:
|
||||
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
|
||||
- [x] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner
|
||||
- [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission)
|
||||
- [x] **VoIP-Anrufe** (WhatsApp/Signal/Discord/Teams) erkannt via AudioFocus-Loss-Listener + getMode-Polling-Fallback (alle 3s)
|
||||
- [x] **Auto-Resume nach Anruf**: ARIAs unterbrochene Antwort spielt nach dem Auflegen ab der gemerkten Position weiter (Date.now()-Tracking + WAV-Cache, 30s-Wartezeit auf final-Marker bei kurzem Telefonat)
|
||||
- [x] **Neue Frage waehrend Telefonat** ueberschreibt pending Auto-Resume — letzte Antwort gewinnt, alter resumeSound wird gestoppt
|
||||
- [x] **Audio-Ausgabe waehrend aktivem Telefonat** funktioniert (haltAllPlayback nur bei state-Wechsel idle→ringing/offhook, nicht bei offhook→offhook)
|
||||
- [x] **PcmPlaybackFinished-Event** im Native: AudioFocus wird erst released wenn AudioTrack wirklich durch ist (vorher: end()-Cap nach 0.5s → Spotify spielte 32s parallel zu ARIA)
|
||||
- [x] **APK-Cache-Cleanup robuster**: durchsucht jetzt CachesDirectoryPath + DocumentDirectoryPath + ExternalCachesDirectoryPath + ExternalDirectoryPath statt nur Caches. Plus manueller Button "Update-Cache leeren" in Settings → Speicher mit Live-Anzeige der aktuellen Groesse
|
||||
- [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile)
|
||||
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB
|
||||
- [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich
|
||||
@@ -165,7 +189,6 @@ Wichtige Mechanismen:
|
||||
### App Features
|
||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||
- [ ] Pause+Resume bei Anruf: aktuell wird der TTS-Stream bei Klingeln hart gestoppt, schoener waere Pause + Resume nach Auflegen
|
||||
|
||||
### Architektur
|
||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
||||
|
||||
Reference in New Issue
Block a user