Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 745b4a07c0 | |||
| 23ca815cb2 | |||
| cc3fac8142 | |||
| cd89e36ec2 | |||
| f5b4285d15 | |||
| 248e7c9ae4 | |||
| 7058cc8d8d | |||
| 7919489543 | |||
| feac7f2479 | |||
| b80b813703 | |||
| e7bb6c37cb | |||
| d146ca92c4 | |||
| fd95af2c40 | |||
| 9e12e0001c |
@@ -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,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) || '';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user