Compare commits

..

14 Commits

Author SHA1 Message Date
duffyduck c8881f9e4d release: bump version to 0.0.4.9 2026-04-22 23:02:28 +02:00
duffyduck 028e3b2240 fix: Voice-Auswahl funktioniert endlich + Diagnostic setzt alle Apps zurueck
XTTS-Bridge: im daswer123 local-Mode erwartet der Server speaker_wav als
Basename (z.B. "Maia"), nicht als Pfad. Wir haben bisher "/voices/Maia.wav"
geschickt, was der Server stumm verwirft und Default nimmt. Jetzt: speaker
name pur senden + Warnlog wenn File fehlt.

App: ChatScreen + SettingsScreen horchen auf type "config" vom RVS —
wenn in Diagnostic die globale XTTS-Voice gewechselt wird, werden alle
Apps auf den neuen Wert zurueckgesetzt (wie vom User gewuenscht).
Lokale App-Wahl bleibt sonst intakt und gewinnt pro Request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:40 +02:00
duffyduck c042f27106 feat: generisches Buchstabieren fuer unbekannte Akronyme
Nach der expliziten _UNIT_WORDS-Liste greift eine Fallback-Regel:
alle verbleibenden 2-5-Zeichen-Grossbuchstaben-Woerter werden
buchstabiert. XTTS → X T T S, USB → U S B, DNS → D N S, JSON → J S O N.

Spezielle Faelle (WLAN, NATO — als Wort gesprochen) koennen bei
Bedarf in _UNIT_WORDS explizit ueberschrieben werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:04 +02:00
duffyduck 4ceadf8be5 release: bump version to 0.0.4.8 2026-04-22 19:08:00 +02:00
duffyduck ddd30b3059 feat: Pre-Roll-Buffer fuer TTS einstellbar in App-Settings
- Kotlin start() nimmt jetzt prerollSeconds als dritten Parameter
  (1.0-6.0s geclampt, Fallback 3.5s bei ungueltigem Wert)
- audio.ts liest Wert aus AsyncStorage vor jedem Stream-Start,
  exportiert Default/Min/Max/Key als Konstanten
- SettingsScreen: +/- Buttons direkt unter dem TTS-Toggle,
  Default auf 3.5s (von 2.5s) angehoben

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:06:55 +02:00
duffyduck 6c8ba5fe2d fix: Fade-In auf ersten PCM-Chunk — maskiert XTTS-Warmup-Glitches
XTTS daswer123 hat am Anfang jedes Renders Warmup-Artefakte — die
ersten autoregressiv generierten Tokens haben wenig Kontext und klingen
verzerrt. Ein 120ms Linear-Fade-In auf den ersten ausgehenden PCM-Chunk
blendet das sanft auf und versteckt die Glitches, ohne dass das echte
Audio danach leiser klingt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:01:36 +02:00
duffyduck 32ddac002f fix: stream_chunk_size auf 250 erhoeht — weniger Render-Artefakte
XTTS daswer123 erzeugt an Chunk-Grenzen oft Glitches in den Worten
die ueber die Grenze gehen. 100 → 250 = weniger Grenzen pro Satz =
sauberere Sprachausgabe. Erste-Audio-Latenz steigt um ein paar Sekunden,
was aber OK ist seit die App Pre-Roll gepuffert ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:56:00 +02:00
duffyduck bbbe69d928 release: bump version to 0.0.4.7 2026-04-22 18:46:25 +02:00
duffyduck 23c39d5bba feat: Dezimalzahlen fuer TTS ausschreiben + Leading-Silence im Stream
- aria_bridge clean_text_for_tts: "0.1" / "0,5" / "1,25" wird jetzt als
  "null komma eins" / "null komma fuenf" / "eins komma zwei fuenf"
  ausgeschrieben. Lookahead verhindert Match auf IP-artige Strings.
- PcmStreamPlayer: 200ms Stille am Stream-Anfang, damit AudioTrack
  sauber anfaehrt und die ersten Worte nicht verschluckt werden.
  (XTTS-Warmup + play()-Startup-Latenz)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:44:38 +02:00
duffyduck 5328dc8595 release: bump version to 0.0.4.6 2026-04-22 18:32:31 +02:00
duffyduck 0c03b4f161 fix: Stream-Ende wartet auf playbackHeadPosition vor release()
AudioTrack.stop() + release() direkt nach dem letzten write() killt die
letzten Sekunden Audio — die Samples sind zwar im Buffer, aber noch
nicht durch die Hardware rausgespielt. Deshalb brach die Sprachausgabe
mitten im Satz ab (z.B. bei "diesmal").

Fix: Writer-Thread wartet im finally-Block bis playbackHeadPosition die
Anzahl geschriebener Frames erreicht, dann erst stop()/release().
Safety: 2s Stall-Detection, falls AudioTrack haengen bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:31:12 +02:00
duffyduck 31fe70bab5 release: bump version to 0.0.4.5 2026-04-22 18:18:20 +02:00
duffyduck 39251b3d32 feat: AudioTrack Pre-Roll — Playback startet erst nach 2.5s Vorrat
User-Diagnose: Erneutes Abspielen aus Cache funktioniert komplett, aber
Live-Stream bricht ab. Bedeutet: PCM kommt an, Cache ist okay — Problem
ist Buffer-Underrun im AudioTrack wenn XTTS (RTF 1.48 auf RTX 3060)
langsamer rendert als Echtzeit-Playback konsumiert.

Fix: AudioTrack.play() wird NICHT mehr sofort beim start() aufgerufen.
Stattdessen:
- start() baut AudioTrack, Writer-Thread startet, spielt aber noch nicht
- writeChunk() fuellt queue, Writer schreibt in AudioTrack-internen Buffer
  (blocked wenn der voll ist)
- Sobald bytesBuffered >= 2.5s Audio im Buffer: play() aufrufen
- Falls end() kommt bevor Pre-Roll erreicht (kurze Texte): trotzdem play()

Das gibt dem Stream Zeit Vorrat aufzubauen. XTTS kann dann pausieren
zwischen Text-Chunks ohne dass Playback stottert.

Pre-Roll 2.5s reicht fuer typische Render-Pausen zwischen Chunks.
Buffer groesse = 2x Pre-Roll damit wir auch extrem bursty Delivery
puffern koennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:16:02 +02:00
duffyduck 0623de32a0 tune: stream_chunk_size 200 -> 100 gegen 6s Initial-Latenz
Mit RTF 1.48 (RTX 3060) rechnet XTTS fuer 200 chars ca. 6s bis erster
PCM-Chunk rauskommt — User wartet nach ARIA-Antwort 6s auf Sprachausgabe.

stream_chunk_size=100: Erster Chunk in ~3s bereit, reduziert
Initial-Latenz um ~50%. 100 chars sind auch noch gross genug dass
der AudioTrack-Buffer (128KB ≈ 2.7s Audio) zwischen Render-Chunks
nicht leerlaeuft → kein mid-sentence Abbruch wie bei 40.

Falls bei bestimmten Texten doch Gaps: stream_chunk_size zurueck auf
150, oder pre-roll im Android PcmStreamPlayer einbauen (nur starten
wenn X ms gepuffert sind).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:08:10 +02:00
8 changed files with 282 additions and 31 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 404
versionName "0.0.4.4"
versionCode 409
versionName "0.0.4.9"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -13,22 +13,30 @@ import com.facebook.react.bridge.ReactMethod
import java.util.concurrent.LinkedBlockingQueue
/**
* Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM.
* Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM mit Pre-Roll.
*
* Pre-Roll: AudioTrack wird zwar direkt gebaut und gefuttert, aber play()
* wird erst aufgerufen wenn PREROLL_SECONDS Audio im Buffer ist. So hat
* der Stream Zeit einen Vorrat aufzubauen — wenn XTTS mit RTF>1 rendert
* (langsamer als Echtzeit), laeuft der Buffer trotzdem nicht leer.
*
* Flow:
* JS: start(sampleRate, channels) → öffnet AudioTrack und startet Writer-Thread
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt non-blocking
* JS: end() → wartet bis Queue leer, schließt AudioTrack
* JS: stop() → Hart stoppen, Queue leeren (Cancel)
*
* Vorteil gegenüber Sound-File-Queue:
* - Keine Gap zwischen Chunks (AudioTrack puffert intern)
* - Erste Samples beginnen zu spielen sobald der erste Chunk da ist
* - Kein WAV-Header-Parsing pro Chunk
* JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play())
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt
* Writer: spielt los sobald PREROLL erreicht ist
* JS: end() wartet bis Queue leer, schließt
* JS: stop() → Hart stoppen (Cancel)
*/
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.5
private const val MIN_PREROLL_SECONDS = 0.5
private const val MAX_PREROLL_SECONDS = 10.0
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
private const val LEADING_SILENCE_SECONDS = 0.2
}
override fun getName() = "PcmStreamPlayer"
@@ -38,22 +46,34 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
private var writerThread: Thread? = null
@Volatile private var writerShouldStop = false
@Volatile private var endRequested = false
@Volatile private var prerollBytes: Int = 0
@Volatile private var playbackStarted = false
@Volatile private var bytesBuffered: Long = 0
@Volatile private var streamBytesPerFrame: Int = 2 // mono s16le default
// ── Lifecycle ──
@ReactMethod
fun start(sampleRate: Int, channels: Int, promise: Promise) {
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
try {
// Alte Session beenden falls vorhanden
stopInternal()
val prerollSec = prerollSeconds
.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
.let { if (it.isFinite() && it > 0) it else DEFAULT_PREROLL_SECONDS }
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
// Grosszuegiger Buffer: 32x MinSize — tolerant gegen Netzwerk-Jitter und
// bursty XTTS-Delivery (Render dauert 1-3s, dann kommen alle Samples
// auf einmal). Bei 24kHz mono s16 entspricht 128KB ca. 2.7 Sekunden.
val bufferSize = (minBuf * 32).coerceAtLeast(128 * 1024)
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)
prerollBytes = prerollTarget
bytesBuffered = 0
playbackStarted = false
streamBytesPerFrame = channels * 2 // s16 = 2 bytes per sample
val newTrack = AudioTrack.Builder()
.setAudioAttributes(
@@ -73,7 +93,7 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
newTrack.play()
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
track = newTrack
queue.clear()
writerShouldStop = false
@@ -82,27 +102,83 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
writerThread = Thread({
val t = track ?: return@Thread
try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (silenceBytes > 0) {
val silence = ByteArray(silenceBytes)
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
}
while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
if (endRequested) return@Thread
if (endRequested) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) {
try { t.play() } catch (_: Exception) {}
playbackStarted = true
}
return@Thread
}
null
} ?: continue
// Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes")
} catch (e: Exception) {
Log.w(TAG, "play() failed: ${e.message}")
}
}
var offset = 0
while (offset < data.size && !writerShouldStop) {
val written = t.write(data, offset, data.size - offset)
if (written <= 0) break
offset += written
}
bytesBuffered += data.size
}
} catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally {
// Warten bis alle geschriebenen Samples tatsaechlich abgespielt sind,
// sonst cuttet t.release() die letzten Sekunden ab.
try {
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
var lastPos = -1
var stalledCount = 0
while (!writerShouldStop) {
val pos = t.playbackHeadPosition
if (pos >= totalFrames) break
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
if (pos == lastPos) {
stalledCount++
if (stalledCount > 40) {
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
break
}
} else {
stalledCount = 0
lastPos = pos
}
Thread.sleep(50)
}
Log.i(TAG, "Playback fertig: frames=$totalFrames pos=${t.playbackHeadPosition}")
} catch (_: Exception) {}
try { t.stop() } catch (_: Exception) {}
try { t.release() } catch (_: Exception) {}
}
}, "PcmStreamWriter").apply { start() }
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B")
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.4.4",
"version": "0.0.4.9",
"private": true,
"scripts": {
"android": "react-native run-android",
+9
View File
@@ -325,6 +325,15 @@ const ChatScreen: React.FC = () => {
const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool });
}
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
// gerade in Diagnostic gewaehlten Wert zurueck. User-Wahl in der App
// wird dadurch ueberschrieben.
if (message.type === ('config' as any)) {
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
localXttsVoiceRef.current = newVoice;
AsyncStorage.setItem('aria_xtts_voice', newVoice);
}
});
const unsubState = rvs.onStateChange((state) => {
+86
View File
@@ -20,6 +20,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import DocumentPicker from 'react-native-document-picker';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import {
TTS_PREROLL_DEFAULT_SEC,
TTS_PREROLL_MIN_SEC,
TTS_PREROLL_MAX_SEC,
TTS_PREROLL_STORAGE_KEY,
} from '../services/audio';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal';
@@ -73,6 +79,7 @@ const SettingsScreen: React.FC = () => {
const [autoDownload, setAutoDownload] = useState(true);
const [storageSize, setStorageSize] = useState('...');
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
@@ -99,6 +106,14 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
if (saved !== null) setTtsEnabled(saved === 'true');
});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
setTtsPrerollSec(n);
}
}
});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved);
});
@@ -250,6 +265,13 @@ const SettingsScreen: React.FC = () => {
}
rvs.send('xtts_list_voices' as any, {});
}
// Diagnostic-Voice-Wechsel → lokale App-Stimme auf den neuen Default zuruecksetzen
if (message.type === ('config' as any)) {
const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
setXttsVoice(newVoice);
AsyncStorage.setItem('aria_xtts_voice', newVoice);
}
});
return () => {
@@ -527,6 +549,42 @@ const SettingsScreen: React.FC = () => {
/>
</View>
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Puffer vor Wiedergabestart</Text>
<Text style={styles.toggleHint}>
Wie viel Audio gesammelt wird bevor die Wiedergabe startet.
Hoeher = robuster gegen Render-Pausen, aber mehr Startverzoegerung.
Default: {TTS_PREROLL_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(TTS_PREROLL_MIN_SEC, Math.round((ttsPrerollSec - 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec <= TTS_PREROLL_MIN_SEC}
>
<Text style={styles.prerollButtonText}>0.5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{ttsPrerollSec.toFixed(1)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(TTS_PREROLL_MAX_SEC, Math.round((ttsPrerollSec + 0.5) * 10) / 10);
setTtsPrerollSec(next);
AsyncStorage.setItem(TTS_PREROLL_STORAGE_KEY, String(next));
}}
disabled={ttsPrerollSec >= TTS_PREROLL_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+0.5</Text>
</TouchableOpacity>
</View>
</View>
)}
{ttsEnabled && (
<View style={{marginTop: 20}}>
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
@@ -1118,6 +1176,34 @@ const styles = StyleSheet.create({
bottomSpacer: {
height: 40,
},
prerollRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
gap: 16,
},
prerollButton: {
backgroundColor: '#2A2A3E',
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 8,
minWidth: 72,
alignItems: 'center',
},
prerollButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
prerollValue: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
minWidth: 80,
textAlign: 'center',
},
});
export default SettingsScreen;
+24 -2
View File
@@ -9,6 +9,7 @@
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
import Sound from 'react-native-sound';
import RNFS from 'react-native-fs';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AudioRecorderPlayer, {
AudioEncoderAndroidType,
AudioSourceAndroidType,
@@ -41,7 +42,7 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
release: () => Promise<boolean>;
};
PcmStreamPlayer?: {
start: (sampleRate: number, channels: number) => Promise<boolean>;
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
writeChunk: (base64Pcm: string) => Promise<boolean>;
end: () => Promise<boolean>;
stop: () => Promise<boolean>;
@@ -80,6 +81,26 @@ const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — l
// Max-Dauer einer Aufnahme in Gespraechsmodus (Notbremse gegen Runaway-Loops)
const MAX_RECORDING_MS = 30000;
// Pre-Roll: Wie lange Audio im AudioTrack-Buffer liegt bevor play() startet.
// Einstellbar via Diagnostic/Settings (Key: aria_tts_preroll_sec).
export const TTS_PREROLL_DEFAULT_SEC = 3.5;
export const TTS_PREROLL_MIN_SEC = 1.0;
export const TTS_PREROLL_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
async function loadPrerollSec(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= TTS_PREROLL_MIN_SEC && n <= TTS_PREROLL_MAX_SEC) {
return n;
}
}
} catch {}
return TTS_PREROLL_DEFAULT_SEC;
}
// --- Audio-Service ---
class AudioService {
@@ -373,8 +394,9 @@ class AudioService {
this.pcmBuffer = [];
this.pcmBytesCollected = 0;
if (!silent) {
const prerollSec = await loadPrerollSec();
try {
await PcmStreamPlayer!.start(sampleRate, channels);
await PcmStreamPlayer!.start(sampleRate, channels, prerollSec);
} catch (err) {
console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err);
this.pcmStreamActive = false;
+20
View File
@@ -150,6 +150,15 @@ def _small_range_to_words(m):
return f"{_num_to_words_de(a)} bis {_num_to_words_de(b)}"
def _decimal_to_words(m):
"""'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1))
dec_part = m.group(2)
int_word = _num_to_words_de(int_part) if 0 <= int_part <= 59 else str(int_part)
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
return f"{int_word} komma {dec_words}"
_UNIT_WORDS = [
(r'\bTB\b', 'Terabyte'),
(r'\bGB\b', 'Gigabyte'),
@@ -236,6 +245,11 @@ def clean_text_for_tts(text: str) -> str:
# Kleine Zahlen-Bereiche ohne "Uhr": "5-6" → "fuenf bis sechs"
t = _re_tts.sub(r'\b(\d{1,2})\s*[-]\s*(\d{1,2})\b', _small_range_to_words, t)
# Dezimalzahlen: "0.1" / "0,5" / "1,25" → "null komma eins" / "null komma fuenf" / ...
# Muss vor "Zahl+Einheit" laufen, sonst frisst die Unit-Regel den Nachkommaanteil.
# Lookahead verhindert Match auf IP-artigen Strings wie 192.168.1.1.
t = _re_tts.sub(r'\b(\d+)[.,](\d+)(?![.,\d])', _decimal_to_words, t)
# Zahlen + Einheit: "22GB" → "22 Gigabyte" (Leerzeichen einfuegen)
t = _re_tts.sub(r'(\d+)([A-Za-z]{1,4})\b', r'\1 \2', t)
@@ -243,6 +257,12 @@ def clean_text_for_tts(text: str) -> str:
for pat, repl in _UNIT_WORDS:
t = _re_tts.sub(pat, repl, t)
# Generisches Buchstabieren: alle verbleibenden 2-5-Zeichen-Grossbuchstaben-Woerter
# (XTTS, USB, DNS, JSON, HTML, ...) → "X T T S". Laeuft NACH der expliziten Liste,
# damit TTS/GPU/... schon aufgeloest sind. "WLAN"-artige, die als Wort gesprochen
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
# Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t)
+46 -8
View File
@@ -95,6 +95,25 @@ function connectRVS(forcePlain) {
// ── TTS Request Handler ─────────────────────────────
/**
* Linearer Fade-In auf einen base64-PCM-Chunk (s16le).
* Mascht XTTS-Warmup-Glitches am Anfang eines Renders.
*/
function applyFadeIn(base64Pcm, sampleRate, channels, fadeMs) {
const buf = Buffer.from(base64Pcm, "base64");
const totalSamples = buf.length / 2; // s16le
const fadeSamples = Math.min(
Math.floor((fadeMs / 1000) * sampleRate) * channels,
totalSamples
);
for (let i = 0; i < fadeSamples; i++) {
const sample = buf.readInt16LE(i * 2);
const gain = i / fadeSamples;
buf.writeInt16LE(Math.round(sample * gain), i * 2);
}
return buf.toString("base64");
}
// ── TTS-Queue ──────────────────────────────────────
// XTTS verarbeitet Requests sequenziell, damit Streams sich nicht ueberlappen.
// Ohne Queue wuerden parallele Requests parallel streamen → App bekommt
@@ -132,20 +151,38 @@ async function _runTTSRequest(payload) {
log(`TTS-Request (streaming): "${cleanText.slice(0, 80)}..." (${cleanText.length} chars, voice: ${voice || "default"})`);
try {
const voiceSample = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null;
const hasCustomVoice = voiceSample && fs.existsSync(voiceSample);
// Im local-Mode erwartet daswer123 XTTS speaker_wav als Basename (ohne .wav,
// ohne Pfad) — der Server prefixt EXAMPLE_FOLDER selbst. Wir checken hier
// nur das physische File ab um Warnungen zu loggen; runter ans API geht
// nur der Name.
const voiceFilePath = voice ? path.join(VOICES_DIR, `${voice}.wav`) : null;
const hasCustomVoice = voiceFilePath && fs.existsSync(voiceFilePath);
const speakerName = hasCustomVoice ? voice : "";
if (voice && !hasCustomVoice) {
log(`WARNUNG: Voice "${voice}" angefordert, aber ${voiceFilePath} existiert nicht — nehme Default`);
} else if (hasCustomVoice) {
log(`Voice "${voice}" verwendet (speaker_wav="${speakerName}")`);
}
let chunkIndex = 0;
let pcmMeta = null;
let firstChunkSeen = false;
const onChunk = (pcmBase64, meta) => {
if (!pcmMeta) pcmMeta = meta;
let outBase64 = pcmBase64;
// Fade-In auf den ersten Chunk — maskiert XTTS-Warmup-Glitches
// (autoregressiver Generator hat am Anfang wenig Kontext → Artefakte).
if (!firstChunkSeen && pcmBase64) {
firstChunkSeen = true;
outBase64 = applyFadeIn(pcmBase64, meta.sampleRate, meta.channels, 120);
}
sendToRVS({
type: "audio_pcm",
payload: {
requestId: requestId || "",
messageId: messageId || "",
base64: pcmBase64,
base64: outBase64,
format: "pcm_s16le",
sampleRate: meta.sampleRate,
channels: meta.channels,
@@ -163,7 +200,7 @@ async function _runTTSRequest(payload) {
await streamXTTSAsPCM(
cleanText,
language || "de",
hasCustomVoice ? voiceSample : null,
speakerName,
onChunk,
);
} catch (streamErr) {
@@ -171,7 +208,7 @@ async function _runTTSRequest(payload) {
await streamXTTSBatch(
cleanText,
language || "de",
hasCustomVoice ? voiceSample : null,
speakerName,
onChunk,
);
}
@@ -216,13 +253,14 @@ function streamXTTSAsPCM(text, language, speakerWav, onPcmChunk) {
return new Promise((resolve, reject) => {
// Wichtig: speaker_wav MUSS als Query-Key dabei sein (Pydantic required) —
// auch bei default-voice mit leerem Wert. Sonst gibt's HTTP 422.
// stream_chunk_size=200: XTTS rendert groessere Text-Happen, d.h. weniger
// Pausen zwischen Chunks (wenn RTF > 1 ist der Buffer sonst oft leer).
// stream_chunk_size=250: grosse Chunks = wenige Chunk-Grenzen = wenig
// Render-Artefakte. daswer123 erzeugt an Chunk-Boundaries haeufig Glitches
// in den Worten die ueber die Grenze gehen. Hoehere Latenz ist OK.
const qs = new URLSearchParams();
qs.set("text", text);
qs.set("language", language || "de");
qs.set("speaker_wav", speakerWav || "");
qs.set("stream_chunk_size", "200");
qs.set("stream_chunk_size", "250");
const url = new URL(XTTS_API_URL);
const fullPath = `/tts_stream?${qs.toString()}`;