Compare commits

...
6 Commits
Author SHA1 Message Date
duffyduck 07ccf05429 release: bump version to 0.2.4.1 2026-08-16 02:02:10 +02:00
duffyduckandClaude Opus 4.8 4ab0e68245 fix(voice): Passiv-Backstop lang genug — schneidet lange Antworten nicht mehr ab
Log-Analyse (alter Build): eine lange gesprochene Antwort wurde vom 30s-Passiv-
Timer mitten im Wort gekappt (exit reason=timeout, dann stt_endpoint reason=
stream_end mit abgeschnittenem Text). Genau das Fenster ist raus — ABER mein
Ersatz-Backstop (15s) hätte sogar früher abgeschnitten.

Der Backstop ist eine reine HANG-Notbremse und darf aktives Reden NIE kappen →
jetzt 10min (länger als der ~5min-Hardcap der Aufnahme). Das echte Ende regelt
immer die Aufnahme selbst (Stille-Toleranz / No-Speech / Hardcap). Lange
zusammenhängende Antworten laufen jetzt durch, bis du ≥ Stille-Toleranz pausierst.

Hinweis: der geloggte Fehler ist der ALTE Build — die Fixes sind noch nicht
ausgerollt. Dieser Commit korrigiert den noch-nicht-deployten Stand.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:59:45 +02:00
duffyduckandClaude Opus 4.8 9636a702d3 refactor(voice): tote Fenster-Settings ausgeräumt (Konv-Fenster/Passiv-Lauschen)
Nachdem die Stille-Toleranz jetzt überall der einzige Wert ist, sind die alten
Settings obsolet — raus damit:

- audio.ts: CONV_WINDOW_* Konstanten + loadConvWindowMs() entfernt.
- wakeword.ts: PASSIVE_LISTEN_* + load/savePassiveListenMs() entfernt; der Passiv-
  Master-Timer nutzt jetzt einen festen internen Backstop (PASSIVE_BACKSTOP_MS,
  15s) statt eines Nutzer-Werts — das echte Ende regelt die Stille-Toleranz.
- SettingsScreen: "Konversations-Fenster"- und "Weiterreden-Fenster"-Slider raus;
  Letzterer durch eine kurze Erklärung ersetzt (verweist auf Stille-Toleranz).
- ChatScreen: tote Imports weg.

Kein Verhaltensänderung ggü. ebe0e80 — nur Aufräumen. tsc grün.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:53:55 +02:00
duffyduck 5f09e2bca3 release: bump version to 0.2.4.0 2026-08-16 01:48:43 +02:00
duffyduckandClaude Opus 4.8 ebe0e8065f feat(voice): 30s-Passiv-Fenster raus — Stille-Toleranz regiert alles
Stefans Modell: es braucht keinen separaten 30s-Wert. Nach ARIAs Antwort geht das
Mikro auf; fängst du nicht innerhalb der Stille-Toleranz (z.B. 5s) an zu reden, ist
Schluss → zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
Ein Befehl ohne "fortführen" = Ende; nach einer normalen Antwort = ein Stille-
Fenster zum Weiterreden.

- Alle Aufnahme-Pfade (wake/barge/passiv): noSpeechTimeoutMs = endpointMs =
  loadSttEndpointMs() statt loadConvWindowMs()/loadPassiveListenMs(). Ein Wert.
- Passiv-Hardcap: loadMaxRecordingMs() statt fix 35s (schnitt langes Reden ab).
- Leeres Endpoint im listening-State: KEIN Re-Arm mehr → exitPassiveListening
  (ein 5s-Fenster, dann Ende). Der 30s-Master-Timer in wakeword.ts wird dadurch
  nie mehr scharf (harmloser Backstop).

Redest du weiter → Antwort → wieder ein Stille-Fenster (Multi-Turn bleibt, nur ohne
30s-Leerlauf). Die Settings "Konversations-Fenster"/"Passiv-Lauschen" sind damit
obsolet (UI-Cleanup später).

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:46:26 +02:00
duffyduckandClaude Opus 4.8 9d2c07d8d1 feat(voice): "Konversation fortführen" hält offen — auch bei Fast-Path-Befehl
Stefans Repro: "spiel Spotify auf Smartphone ab ABER Konversation fortführen" →
die Kette endete trotzdem. Grund: "spiel spotify" trifft den Fast-Path (Regex),
der den Satz-Rest nicht versteht → converse=false aus dem Skill-Manifest; ARIAs
[[WEITER]]-Marker lebt in Claude, der wurde uebersprungen.

Fix: _user_wants_conversation_continue() — Gegenstueck zu _user_wants_conversation_
end(). Erkennt "Konversation/Gespraech fortfuehren/weiterfuehren/offen halten/nicht
beenden", "weiter reden/sprechen" etc. und erzwingt converse=true an ALLEN Returns
(fast-path/local/claude), auch wenn das Manifest false sagt. [[ENDE]]/_wants_end hat
Vorrang bei Widerspruch. Getestet inkl. Negativfaelle (sofort/spotify ab).

Deploy: Brain-Neustart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:35:49 +02:00
7 changed files with 92 additions and 158 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 20309 versionCode 20401
versionName "0.2.3.9" versionName "0.2.4.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.3.9", "version": "0.2.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+25 -17
View File
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary'; import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio'; import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword'; import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser'; import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi'; import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus'; import projectFocus from '../services/projectFocus';
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText'; import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio'; import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation'; import Geolocation from '@react-native-community/geolocation';
// --- Typen --- // --- Typen ---
@@ -1679,7 +1679,11 @@ const ChatScreen: React.FC = () => {
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy(); const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs(); // EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
const sttEndpointMs = await loadSttEndpointMs();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
@@ -1697,8 +1701,8 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted, interrupted: wasInterrupted,
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale // Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen. // Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
hardCapMs: await loadMaxRecordingMs(), hardCapMs: await loadMaxRecordingMs(),
@@ -1756,12 +1760,12 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId !(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet')))); && m.text.includes('Spracheingabe wird verarbeitet'))));
} }
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv // Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende). // Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
// Sonst endConversation wie bisher. // Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
if (wakeWordService.getState() === 'listening') { if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme'); console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
startPassiveStreamingRecording(); wakeWordService.exitPassiveListening('timeout').catch(() => {});
} else { } else {
wakeWordService.endConversation(); wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false); if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1791,7 +1795,7 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs(); const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
@@ -1809,8 +1813,8 @@ const ChatScreen: React.FC = () => {
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen" interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden. // Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
hardCapMs: await loadMaxRecordingMs(), hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
@@ -1871,16 +1875,20 @@ const ChatScreen: React.FC = () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId); rememberMyRequest(audioRequestId);
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs(); // Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
const sttEndpointMs = await loadSttEndpointMs();
const { ok } = await audioService.startStreamingRecording({ const { ok } = await audioService.startStreamingRecording({
audioRequestId, audioRequestId,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: false, interrupted: false,
location: location || null, location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000), noSpeechTimeoutMs: sttEndpointMs,
endpointMs: await loadSttEndpointMs(), endpointMs: sttEndpointMs,
hardCapMs: Math.max(passiveMs + 5000, 35000), // Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
hardCapMs: await loadMaxRecordingMs(),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
if (!ok) { if (!ok) {
+6 -81
View File
@@ -63,10 +63,6 @@ import {
VAD_SILENCE_MIN_SEC, VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC, VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY, VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC,
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
STT_ENDPOINT_DEFAULT_MS, STT_ENDPOINT_DEFAULT_MS,
STT_ENDPOINT_MIN_MS, STT_ENDPOINT_MIN_MS,
STT_ENDPOINT_MAX_MS, STT_ENDPOINT_MAX_MS,
@@ -116,9 +112,6 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX, WAKE_THRESHOLD_MAX,
loadWakeThreshold, loadWakeThreshold,
saveWakeThreshold, saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -204,7 +197,6 @@ const SettingsScreen: React.FC = () => {
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler // Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad). // steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000); const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex). // Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false); const [bargeIn, setBargeIn] = useState<boolean>(false);
@@ -220,7 +212,6 @@ const SettingsScreen: React.FC = () => {
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true); const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT); const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -309,14 +300,6 @@ const SettingsScreen: React.FC = () => {
} }
} }
}); });
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
setConvWindowSec(n);
}
}
});
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => { AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) { if (saved != null) {
const n = parseInt(saved, 10); const n = parseInt(saved, 10);
@@ -353,7 +336,6 @@ const SettingsScreen: React.FC = () => {
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1721,39 +1703,6 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text> <Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet, Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
@@ -1945,38 +1894,14 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text> <Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach Nach einer gesprochenen ARIA-Antwort geht das Mikro auf du kannst ohne
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet Wake-Word weiterreden. Fängst du nicht innerhalb der Stille-Toleranz"
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort. (Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s. Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
mehr — es zählt überall derselbe Stille-Wert.
</Text> </Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(10, passiveSec - 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec <= 10}
>
<Text style={styles.prerollButtonText}>5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{passiveSec} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(60, passiveSec + 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec >= 60}
>
<Text style={styles.prerollButtonText}>+5</Text>
</TouchableOpacity>
</View>
</View> </View>
</>)} </>)}
-20
View File
@@ -143,14 +143,6 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0; export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec'; export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten // STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war // im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings // zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
@@ -208,18 +200,6 @@ export async function loadTtsSpeed(): Promise<number> {
return TTS_SPEED_DEFAULT; return TTS_SPEED_DEFAULT;
} }
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> { async function loadVadSilenceMs(): Promise<number> {
try { try {
+20 -32
View File
@@ -30,26 +30,14 @@ type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening'; export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation /** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt * die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */ * sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) ChatScreen ruft
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000; * dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms'; * deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
export async function loadPassiveListenMs(): Promise<number> { * Antworten mitten im Satz kappte, ist damit raus. */
try { const PASSIVE_BACKSTOP_MS = 10 * 60_000;
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
@@ -152,8 +140,9 @@ class WakeWordService {
* Ausnahme: bargeListening Barge-In ist ein legitimer neuer Trigger * Ausnahme: bargeListening Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */ * waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false; private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech, /** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
* beendet den listening-State und geht zurueck zu armed. */ * das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
* trotzdem, zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null; private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen ChatScreen startet /** Callbacks fuer den Eintritt in Passive-Listen ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */ * hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
@@ -489,13 +478,12 @@ class WakeWordService {
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{}); `endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen // Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID // noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute // der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
// Anrede weitersprechen. // Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
const passiveMs = await loadPassiveListenMs(); if (!skipPassive && this.nativeReady) {
if (!skipPassive && passiveMs > 0 && this.nativeReady) { this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
this.enterPassiveListening(passiveMs);
return; return;
} }
@@ -537,10 +525,10 @@ class WakeWordService {
this.cancelPassiveListenTimer(); this.cancelPassiveListenTimer();
this.setState('listening'); this.setState('listening');
const seconds = Math.round(durationMs / 1000); const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds); console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive', import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{}); `entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT); ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => { this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null; this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {}); this.exitPassiveListening('timeout').catch(() => {});
+38 -5
View File
@@ -1418,6 +1418,30 @@ def _user_wants_conversation_end(text: str) -> bool:
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text))) return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
# "weiter reden/sprechen" ohne Nomen.
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
_CONTINUE_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
re.IGNORECASE,
)
def _user_wants_conversation_continue(text: str) -> bool:
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
erzwingen auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
if not text:
return False
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str: def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower() norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm) norm = _fold_umlauts(norm)
@@ -1826,10 +1850,13 @@ class Agent:
if not user_message: if not user_message:
raise ValueError("Leere Nachricht") raise ValueError("Leere Nachricht")
# Expliziter "Konversation/Kette beenden"-Wunsch (deterministisch). Wird an # Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
# JEDEM Return auf converse=false angewendet — unabhaengig davon, ob ARIA # angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
# den [[ENDE]]-Marker setzt. Stefans fester Trigger "Konversation Ende". # _wants_end → converse=false ("Konversation Ende")
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
# End hat Vorrang bei Widerspruch.
_wants_end = _user_wants_conversation_end(user_message) _wants_end = _user_wants_conversation_end(user_message)
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
# Events vom letzten Turn weglassen # Events vom letzten Turn weglassen
self._pending_events = [] self._pending_events = []
@@ -1860,6 +1887,8 @@ class Agent:
converse = bool(getattr(self, "_fast_path_converse", False)) converse = bool(getattr(self, "_fast_path_converse", False))
if _wants_end: if _wants_end:
converse = False converse = False
elif _wants_continue:
converse = True
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False. # Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False return fast_reply, "fast-path", speak, converse, False
@@ -1881,6 +1910,8 @@ class Agent:
converse = getattr(self, "_local_turn_converse", True) converse = getattr(self, "_local_turn_converse", True)
if _wants_end: if _wants_end:
converse = False converse = False
elif _wants_continue:
converse = True
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude. # Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False return local_reply, "local", speak, converse, False
@@ -2129,10 +2160,12 @@ class Agent:
speak = _speak_ov speak = _speak_ov
if _conv_ov is not None: if _conv_ov is not None:
converse = _conv_ov converse = _conv_ov
# Expliziter User-Wunsch "Konversation beenden" gewinnt IMMER — Frage wird # Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
# noch beantwortet (speak bleibt), aber danach zurueck aufs Wake-Word. # beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
if _wants_end: if _wants_end:
converse = False converse = False
elif _wants_continue:
converse = True
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert). # awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude", speak, converse, awaiting_reply) return (final_reply, "claude", speak, converse, awaiting_reply)