fix(app): Spotify-Bounce zwischen ARIA-Antworten + Wake-Word-Doku

AudioFocus wird jetzt mit 800ms Verzoegerung freigegeben — wenn ARIA
direkt eine zweite Antwort hinterherschickt oder das Recording ins TTS
uebergeht, wird das Release abgebrochen. Spotify/YouTube haben damit
keine Mikro-Sekunden-Luecke mehr zum Hochkommen waehrend ARIA spricht.

README: neue Sektion zur Wake-Word-Einrichtung mit Picovoice
(7-Tage-Trial, Console-Link, Anleitung fuer eigene Keywords) und
veraltete Wake-Word-Limitation entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-25 22:49:45 +02:00
parent 0309c95aa5
commit 44d2c6b4fe
2 changed files with 90 additions and 12 deletions

View File

@ -380,6 +380,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- Text-Chat mit ARIA - Text-Chat mit ARIA
- **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille) - **Sprachaufnahme**: Push-to-Talk (halten) oder Tap-to-Talk (tippen, Auto-Stop bei Stille)
- **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her - **Gespraechsmodus** (Ohr-Button): Nach jeder ARIA-Antwort startet automatisch die Aufnahme — wie ein natuerliches Gespraech hin und her
- **Wake-Word** (optional, Picovoice Porcupine on-device): "Jarvis", "Computer" usw. — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Eigene Wake-Words ueber die Picovoice Console moeglich. Ohne API-Key faellt der Ohr-Button auf Direkt-Aufnahme zurueck.
- **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s. - **VAD (Voice Activity Detection)**: Konfigurierbare Stille-Toleranz (1.08.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme 120s.
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt - **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. - **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit.
@ -398,6 +399,49 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session`
- GPS-Position (optional) - GPS-Position (optional)
- QR-Code Scanner fuer Token-Pairing - QR-Code Scanner fuer Token-Pairing
### Wake-Word einrichten (Picovoice Porcupine)
Das Wake-Word laeuft komplett **on-device** in der App — kein Audio verlaesst dein Telefon
fuer die Erkennung. Picovoice bietet aktuell einen **7-Tage Free Trial** ohne Kreditkarte
und ohne Auto-Renewal an, danach kostenpflichtig (siehe [picovoice.ai/pricing](https://picovoice.ai/pricing)).
Wer das Wake-Word ueberspringen will: der Ohr-Button funktioniert auch ohne AccessKey
(Direkt-Aufnahme statt passivem Lauschen — siehe unten).
**1) AccessKey holen** (einmalig, ~2 Minuten):
1. Auf [console.picovoice.ai](https://console.picovoice.ai) registrieren (Email + Passwort, keine Kreditkarte fuer den Trial).
2. Nach dem Login auf dem Dashboard → **AccessKey** kopieren (langer Base64-String).
**2) AccessKey in der App eintragen:**
- App → **Einstellungen** → Abschnitt **Wake-Word**
- AccessKey einfuegen, **Keyword** auswaehlen (Default: `jarvis`)
- Speichern → die App initialisiert Porcupine automatisch
**Eingebaute Keywords** (sofort verfuegbar, kein Training noetig):
`jarvis`, `computer`, `picovoice`, `porcupine`, `bumblebee`, `terminator`,
`alexa`, `hey google`, `ok google`, `hey siri`
**3) Eigenes Wake-Word erstellen** ("ARIA", "Hey Stefan", was du willst):
1. [console.picovoice.ai](https://console.picovoice.ai) → **Porcupine** → **Train Wake Word**
2. Wort eingeben (z.B. `ARIA`), Sprache `German` waehlen, Plattform `Android`
3. **Train** druecken — Picovoice trainiert das Modell in ~12 Minuten
4. Die fertige `.ppn`-Datei runterladen
5. *(Custom-Upload in der App ist Phase 2 — aktuell nur eingebaute Keywords.
`.ppn`-Dateien koennen schon manuell ins App-Bundle gelegt werden, die UI
dafuer kommt mit dem naechsten Diagnostic-Update.)*
**Bedienung:**
- **Ohr-Button (👂)** in der Statusleiste tippen → Wake-Word ist scharf, App hoert passiv mit
- Wake-Word sagen → Symbol wechselt auf 🎙️, normale Konversation laeuft
- Nach jeder ARIA-Antwort oeffnet sich das Mikro nochmal — Stille → zurueck zu 👂
- Erneut tippen → Ohr aus (🔇)
**Ohne AccessKey:** Der Ohr-Button startet stattdessen die Direkt-Aufnahme (Mikro
ist sofort aktiv, kein passives Lauschen). Auch ein gueltiger Modus, nur halt ohne
"Hands-free" via Schluesselwort.
### Ersteinrichtung (Dev-Maschine, einmalig) ### Ersteinrichtung (Dev-Maschine, einmalig)
```bash ```bash
@ -744,8 +788,9 @@ docker exec aria-core ssh aria-wohnung hostname
- **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess. - **Proxy Cold Start**: Jede Nachricht spawnt einen neuen `claude --print` Prozess.
Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min). Dadurch ist ARIA langsamer als die direkte Claude CLI. Timeout ist auf 900s (15 Min).
- **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens. - **Kein Streaming zur App**: Die App zeigt erst die fertige Antwort, keine Streaming-Tokens.
- **Wake Word nur auf VM**: Die Bridge hoert auf "ARIA" ueber das lokale Mikrofon der VM. - **Wake-Word in der App nur eingebaute Keywords**: `jarvis`, `computer` etc. funktionieren
In der App gibt es Energy-basierte Erkennung (Phase 1). On-device "ARIA"-Keyword (Porcupine) ist Phase 2. sofort, eigene Wake-Words (`.ppn` aus der Picovoice Console) muessen aktuell noch manuell
ins App-Bundle. Die Upload-UI in Diagnostic ist Phase 2.
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM. - **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung. - **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request. Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
@ -800,6 +845,7 @@ docker exec aria-core ssh aria-wohnung hostname
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s) - [x] VAD-Stille-Toleranz und Max-Aufnahme einstellbar (1-8s, 120s)
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen - [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
- [x] Porcupine Wake-Word on-device in der App (eingebaute Keywords + State-Icon)
### Phase 2 — ARIA wird produktiv ### Phase 2 — ARIA wird produktiv
@ -815,5 +861,5 @@ docker exec aria-core ssh aria-wohnung hostname
- [ ] STARFACE Telefonie-Skill - [ ] STARFACE Telefonie-Skill
- [ ] Desktop Client (Tauri) - [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support - [ ] bKVM Remote IT-Support
- [ ] Porcupine Wake Word (on-device "ARIA" in der App) - [ ] Custom-`.ppn`-Upload fuer Wake-Word ueber Diagnostic (eigene Trigger-Worte)
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg) - [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)

View File

@ -191,6 +191,13 @@ class AudioService {
private pcmBytesCollected: number = 0; private pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
// AudioFocus wird verzoegert freigegeben — wenn ARIA eine zweite Antwort
// direkt hinterherschickt (oder ein neuer Stream startet), bleibt Spotify
// pausiert. Ohne diese Verzoegerung springt Spotify im Mikro-Sekunden-Gap
// zwischen zwei Streams kurz wieder an.
private focusReleaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
// VAD State // VAD State
private vadEnabled: boolean = false; private vadEnabled: boolean = false;
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
@ -205,6 +212,24 @@ class AudioService {
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
} }
/** AudioFocus mit kleiner Verzoegerung freigeben Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
* eine zweite Antwort direkt hinterherschickt) kurz wieder an. */
private _releaseFocusDeferred(): void {
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
private _cancelDeferredFocusRelease(): void {
if (this.focusReleaseTimer) {
clearTimeout(this.focusReleaseTimer);
this.focusReleaseTimer = null;
}
}
// --- Berechtigungen --- // --- Berechtigungen ---
async requestMicrophonePermission(): Promise<boolean> { async requestMicrophonePermission(): Promise<boolean> {
@ -305,6 +330,7 @@ class AudioService {
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
// VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar). // VAD aktivieren — Stille-Dauer aus AsyncStorage (Settings-konfigurierbar).
@ -387,8 +413,9 @@ class AudioService {
await this.recorder.stopRecorder(); await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener(); this.recorder.removeRecordBackListener();
// Audio-Focus freigeben — andere Apps duerfen wieder // Audio-Focus verzoegert freigeben — gleich kommt die TTS-Antwort,
AudioFocus?.release().catch(() => {}); // im Gap soll Spotify nicht hochkommen.
this._releaseFocusDeferred();
const durationMs = Date.now() - this.recordingStartTime; const durationMs = Date.now() - this.recordingStartTime;
const hadSpeech = this.speechDetected; const hadSpeech = this.speechDetected;
@ -535,6 +562,7 @@ class AudioService {
this.pcmStreamActive = false; this.pcmStreamActive = false;
return ''; return '';
} }
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
} }
@ -553,11 +581,12 @@ class AudioService {
if (isFinal) { if (isFinal) {
if (!silent) { if (!silent) {
// end() resolved jetzt erst wenn der native Writer-Thread fertig // end() resolved jetzt erst wenn der native Writer-Thread fertig
// ist (alle Samples ausgespielt) — danach erst AudioFocus freigeben, // ist (alle Samples ausgespielt) — danach AudioFocus verzoegert
// damit Spotify/YouTube nicht waehrend des Pre-Roll-Ausklangs // freigeben, damit Spotify/YouTube nicht im Mikro-Gap zwischen zwei
// wieder aufdrehen. // ARIA-Antworten wieder hochdrehen. Wenn ein neuer Stream innerhalb
// FOCUS_RELEASE_DELAY_MS startet, wird das Release abgebrochen.
try { await PcmStreamPlayer!.end(); } catch {} try { await PcmStreamPlayer!.end(); } catch {}
AudioFocus?.release().catch(() => {}); this._releaseFocusDeferred();
} }
this.pcmStreamActive = false; this.pcmStreamActive = false;
@ -661,8 +690,9 @@ class AudioService {
private async _playNext(): Promise<void> { private async _playNext(): Promise<void> {
if (this.audioQueue.length === 0) { if (this.audioQueue.length === 0) {
this.isPlaying = false; this.isPlaying = false;
// Audio-Focus abgeben → andere Apps volle Lautstaerke // Audio-Focus verzoegert abgeben → wenn gleich noch eine Antwort kommt,
AudioFocus?.release().catch(() => {}); // bleibt Spotify pausiert.
this._releaseFocusDeferred();
// Alle Audio-Teile abgespielt → Listener benachrichtigen // Alle Audio-Teile abgespielt → Listener benachrichtigen
this.playbackFinishedListeners.forEach(cb => cb()); this.playbackFinishedListeners.forEach(cb => cb());
return; return;
@ -670,6 +700,7 @@ class AudioService {
// Beim ersten Playback-Start: andere Apps ducken // Beim ersten Playback-Start: andere Apps ducken
if (!this.isPlaying) { if (!this.isPlaying) {
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
} }
this.isPlaying = true; this.isPlaying = true;
@ -755,7 +786,8 @@ class AudioService {
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
this.pcmMessageId = ''; this.pcmMessageId = '';
} }
// Audio-Focus freigeben // Audio-Focus sofort freigeben — User hat explizit abgebrochen
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
} }