Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ceadf8be5 | |||
| ddd30b3059 | |||
| 6c8ba5fe2d | |||
| 32ddac002f | |||
| bbbe69d928 | |||
| 23c39d5bba | |||
| 5328dc8595 | |||
| 0c03b4f161 | |||
| 31fe70bab5 | |||
| 39251b3d32 | |||
| 0623de32a0 | |||
| cd5e6e7ee6 | |||
| ee3e0a0af6 | |||
| 0783b1b99d | |||
| 5492c7a46f | |||
| 4cbe184faa | |||
| 647a1cb726 | |||
| 73263b69a6 | |||
| c62ceafdc2 | |||
| 9b5a35cb4a | |||
| 5ac1a0a522 | |||
| a28b46a809 | |||
| 59c8d36a3d |
@@ -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 403
|
versionCode 408
|
||||||
versionName "0.0.4.3"
|
versionName "0.0.4.8"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,30 @@ import com.facebook.react.bridge.ReactMethod
|
|||||||
import java.util.concurrent.LinkedBlockingQueue
|
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:
|
* Flow:
|
||||||
* JS: start(sampleRate, channels) → öffnet AudioTrack und startet Writer-Thread
|
* JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play())
|
||||||
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt non-blocking
|
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt
|
||||||
* JS: end() → wartet bis Queue leer, schließt AudioTrack
|
* Writer: spielt los sobald PREROLL erreicht ist
|
||||||
* JS: stop() → Hart stoppen, Queue leeren (Cancel)
|
* JS: end() → wartet bis Queue leer, schließt
|
||||||
*
|
* JS: stop() → Hart stoppen (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
|
|
||||||
*/
|
*/
|
||||||
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PcmStreamPlayer"
|
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"
|
override fun getName() = "PcmStreamPlayer"
|
||||||
@@ -38,22 +46,34 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
private var writerThread: Thread? = null
|
private var writerThread: Thread? = null
|
||||||
@Volatile private var writerShouldStop = false
|
@Volatile private var writerShouldStop = false
|
||||||
@Volatile private var endRequested = 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 ──
|
// ── Lifecycle ──
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun start(sampleRate: Int, channels: Int, promise: Promise) {
|
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
|
||||||
try {
|
try {
|
||||||
// Alte Session beenden falls vorhanden
|
// Alte Session beenden falls vorhanden
|
||||||
stopInternal()
|
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 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
|
||||||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||||
// Grosszuegiger Buffer: 32x MinSize — tolerant gegen Netzwerk-Jitter und
|
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
||||||
// bursty XTTS-Delivery (Render dauert 1-3s, dann kommen alle Samples
|
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
|
||||||
// auf einmal). Bei 24kHz mono s16 entspricht 128KB ca. 2.7 Sekunden.
|
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
||||||
val bufferSize = (minBuf * 32).coerceAtLeast(128 * 1024)
|
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()
|
val newTrack = AudioTrack.Builder()
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
@@ -73,7 +93,7 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
newTrack.play()
|
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
|
||||||
track = newTrack
|
track = newTrack
|
||||||
queue.clear()
|
queue.clear()
|
||||||
writerShouldStop = false
|
writerShouldStop = false
|
||||||
@@ -82,27 +102,83 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
writerThread = Thread({
|
writerThread = Thread({
|
||||||
val t = track ?: return@Thread
|
val t = track ?: return@Thread
|
||||||
try {
|
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) {
|
while (!writerShouldStop) {
|
||||||
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
|
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
|
null
|
||||||
} ?: continue
|
} ?: 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
|
var offset = 0
|
||||||
while (offset < data.size && !writerShouldStop) {
|
while (offset < data.size && !writerShouldStop) {
|
||||||
val written = t.write(data, offset, data.size - offset)
|
val written = t.write(data, offset, data.size - offset)
|
||||||
if (written <= 0) break
|
if (written <= 0) break
|
||||||
offset += written
|
offset += written
|
||||||
}
|
}
|
||||||
|
bytesBuffered += data.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 {
|
||||||
|
// 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.stop() } catch (_: Exception) {}
|
||||||
try { t.release() } catch (_: Exception) {}
|
try { t.release() } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
}, "PcmStreamWriter").apply { start() }
|
}, "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)
|
promise.resolve(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "start fehlgeschlagen", e)
|
Log.e(TAG, "start fehlgeschlagen", e)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.4.3",
|
"version": "0.0.4.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import DocumentPicker from 'react-native-document-picker';
|
import DocumentPicker from 'react-native-document-picker';
|
||||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
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 ModeSelector from '../components/ModeSelector';
|
||||||
import QRScanner from '../components/QRScanner';
|
import QRScanner from '../components/QRScanner';
|
||||||
import VoiceCloneModal from '../components/VoiceCloneModal';
|
import VoiceCloneModal from '../components/VoiceCloneModal';
|
||||||
@@ -73,6 +79,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [autoDownload, setAutoDownload] = useState(true);
|
const [autoDownload, setAutoDownload] = useState(true);
|
||||||
const [storageSize, setStorageSize] = useState('...');
|
const [storageSize, setStorageSize] = useState('...');
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
|
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
|
||||||
const [editingPath, setEditingPath] = useState(false);
|
const [editingPath, setEditingPath] = useState(false);
|
||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
||||||
@@ -99,6 +106,14 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
|
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
|
||||||
if (saved !== null) setTtsEnabled(saved === 'true');
|
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 => {
|
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||||
if (saved) setXttsVoice(saved);
|
if (saved) setXttsVoice(saved);
|
||||||
});
|
});
|
||||||
@@ -527,6 +542,42 @@ const SettingsScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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 && (
|
{ttsEnabled && (
|
||||||
<View style={{marginTop: 20}}>
|
<View style={{marginTop: 20}}>
|
||||||
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
<Text style={styles.toggleLabel}>Stimme (geraetelokal)</Text>
|
||||||
@@ -1118,6 +1169,34 @@ const styles = StyleSheet.create({
|
|||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: 40,
|
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;
|
export default SettingsScreen;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
||||||
import Sound from 'react-native-sound';
|
import Sound from 'react-native-sound';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import AudioRecorderPlayer, {
|
import AudioRecorderPlayer, {
|
||||||
AudioEncoderAndroidType,
|
AudioEncoderAndroidType,
|
||||||
AudioSourceAndroidType,
|
AudioSourceAndroidType,
|
||||||
@@ -41,7 +42,7 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
|||||||
release: () => Promise<boolean>;
|
release: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
PcmStreamPlayer?: {
|
PcmStreamPlayer?: {
|
||||||
start: (sampleRate: number, channels: number) => Promise<boolean>;
|
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
|
||||||
writeChunk: (base64Pcm: string) => Promise<boolean>;
|
writeChunk: (base64Pcm: string) => Promise<boolean>;
|
||||||
end: () => Promise<boolean>;
|
end: () => Promise<boolean>;
|
||||||
stop: () => 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)
|
// Max-Dauer einer Aufnahme in Gespraechsmodus (Notbremse gegen Runaway-Loops)
|
||||||
const MAX_RECORDING_MS = 30000;
|
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 ---
|
// --- Audio-Service ---
|
||||||
|
|
||||||
class AudioService {
|
class AudioService {
|
||||||
@@ -373,8 +394,9 @@ class AudioService {
|
|||||||
this.pcmBuffer = [];
|
this.pcmBuffer = [];
|
||||||
this.pcmBytesCollected = 0;
|
this.pcmBytesCollected = 0;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
|
const prerollSec = await loadPrerollSec();
|
||||||
try {
|
try {
|
||||||
await PcmStreamPlayer!.start(sampleRate, channels);
|
await PcmStreamPlayer!.start(sampleRate, channels, prerollSec);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err);
|
console.error('[Audio] PcmStreamPlayer.start fehlgeschlagen:', err);
|
||||||
this.pcmStreamActive = false;
|
this.pcmStreamActive = false;
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ def _small_range_to_words(m):
|
|||||||
return f"{_num_to_words_de(a)} bis {_num_to_words_de(b)}"
|
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 = [
|
_UNIT_WORDS = [
|
||||||
(r'\bTB\b', 'Terabyte'),
|
(r'\bTB\b', 'Terabyte'),
|
||||||
(r'\bGB\b', 'Gigabyte'),
|
(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"
|
# 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)
|
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)
|
# Zahlen + Einheit: "22GB" → "22 Gigabyte" (Leerzeichen einfuegen)
|
||||||
t = _re_tts.sub(r'(\d+)([A-Za-z]{1,4})\b', r'\1 \2', t)
|
t = _re_tts.sub(r'(\d+)([A-Za-z]{1,4})\b', r'\1 \2', t)
|
||||||
|
|
||||||
|
|||||||
+160
-49
@@ -95,6 +95,25 @@ function connectRVS(forcePlain) {
|
|||||||
|
|
||||||
// ── TTS Request Handler ─────────────────────────────
|
// ── 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 ──────────────────────────────────────
|
// ── TTS-Queue ──────────────────────────────────────
|
||||||
// XTTS verarbeitet Requests sequenziell, damit Streams sich nicht ueberlappen.
|
// XTTS verarbeitet Requests sequenziell, damit Streams sich nicht ueberlappen.
|
||||||
// Ohne Queue wuerden parallele Requests parallel streamen → App bekommt
|
// Ohne Queue wuerden parallele Requests parallel streamen → App bekommt
|
||||||
@@ -137,32 +156,52 @@ async function _runTTSRequest(payload) {
|
|||||||
|
|
||||||
let chunkIndex = 0;
|
let chunkIndex = 0;
|
||||||
let pcmMeta = null;
|
let pcmMeta = null;
|
||||||
|
let firstChunkSeen = false;
|
||||||
|
|
||||||
// EIN Request fuer den GANZEN Text — kein Gap zwischen Saetzen.
|
const onChunk = (pcmBase64, meta) => {
|
||||||
// XTTS rendert und wir streamen PCM sobald es reinkommt.
|
if (!pcmMeta) pcmMeta = meta;
|
||||||
await streamXTTSAsPCM(
|
let outBase64 = pcmBase64;
|
||||||
cleanText,
|
// Fade-In auf den ersten Chunk — maskiert XTTS-Warmup-Glitches
|
||||||
language || "de",
|
// (autoregressiver Generator hat am Anfang wenig Kontext → Artefakte).
|
||||||
hasCustomVoice ? voiceSample : null,
|
if (!firstChunkSeen && pcmBase64) {
|
||||||
(pcmBase64, meta) => {
|
firstChunkSeen = true;
|
||||||
if (!pcmMeta) pcmMeta = meta;
|
outBase64 = applyFadeIn(pcmBase64, meta.sampleRate, meta.channels, 120);
|
||||||
sendToRVS({
|
}
|
||||||
type: "audio_pcm",
|
sendToRVS({
|
||||||
payload: {
|
type: "audio_pcm",
|
||||||
requestId: requestId || "",
|
payload: {
|
||||||
messageId: messageId || "",
|
requestId: requestId || "",
|
||||||
base64: pcmBase64,
|
messageId: messageId || "",
|
||||||
format: "pcm_s16le",
|
base64: outBase64,
|
||||||
sampleRate: meta.sampleRate,
|
format: "pcm_s16le",
|
||||||
channels: meta.channels,
|
sampleRate: meta.sampleRate,
|
||||||
voice: voice || "default",
|
channels: meta.channels,
|
||||||
chunk: chunkIndex++,
|
voice: voice || "default",
|
||||||
final: false,
|
chunk: chunkIndex++,
|
||||||
},
|
final: false,
|
||||||
timestamp: Date.now(),
|
},
|
||||||
});
|
timestamp: Date.now(),
|
||||||
},
|
});
|
||||||
);
|
};
|
||||||
|
|
||||||
|
// /tts_stream fuer echtes Streaming (funktioniert im XTTS local-Mode).
|
||||||
|
// Wenn Server im apiManual/api-Mode laeuft: 400 → Fallback auf /tts_to_audio/.
|
||||||
|
try {
|
||||||
|
await streamXTTSAsPCM(
|
||||||
|
cleanText,
|
||||||
|
language || "de",
|
||||||
|
hasCustomVoice ? voiceSample : null,
|
||||||
|
onChunk,
|
||||||
|
);
|
||||||
|
} catch (streamErr) {
|
||||||
|
log(`/tts_stream fehlgeschlagen (${streamErr.message.slice(0, 100)}) — Fallback /tts_to_audio/`);
|
||||||
|
await streamXTTSBatch(
|
||||||
|
cleanText,
|
||||||
|
language || "de",
|
||||||
|
hasCustomVoice ? voiceSample : null,
|
||||||
|
onChunk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Am Ende: final-Flag damit App weiss "fertig" und Cache geschrieben werden kann
|
// Am Ende: final-Flag damit App weiss "fertig" und Cache geschrieben werden kann
|
||||||
if (pcmMeta) {
|
if (pcmMeta) {
|
||||||
@@ -195,45 +234,47 @@ async function _runTTSRequest(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruft /tts_to_audio/ auf und streamt das resultierende WAV bereits waehrend
|
* Ruft /tts_stream auf — echter Streaming-Endpoint bei daswer123.
|
||||||
* des Empfangs in PCM-Frames an den Callback. Der WAV-Header wird einmal
|
* Schickt was der Server verlangt (allow: GET), aber mit JSON-Body
|
||||||
* geparst, danach werden nur noch raw PCM-Samples weitergeleitet.
|
* als POST scheitert mit 405. Manche Versionen wollen GET + Query,
|
||||||
*
|
* andere POST + JSON. Testen was funktioniert.
|
||||||
* Warum nicht echtes /tts_stream/? daswer123 hat den Endpoint, aber die
|
|
||||||
* Audio-Quality ist dort niedriger und er produziert beim ersten Chunk
|
|
||||||
* oft Artefakte. Pragmatischer Weg: /tts_to_audio/ + Response-Stream
|
|
||||||
* chunkweise auslesen. Das ist zwar kein echtes Server-Streaming, aber
|
|
||||||
* gibt uns deutlich kleinere Netzwerk-Haeppchen und die App kann via
|
|
||||||
* AudioTrack MODE_STREAM sofort nahtlos abspielen.
|
|
||||||
*/
|
*/
|
||||||
function streamXTTSAsPCM(text, language, speakerWav, onPcmChunk) {
|
function streamXTTSAsPCM(text, language, speakerWav, onPcmChunk) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const body = JSON.stringify({
|
// Wichtig: speaker_wav MUSS als Query-Key dabei sein (Pydantic required) —
|
||||||
text,
|
// auch bei default-voice mit leerem Wert. Sonst gibt's HTTP 422.
|
||||||
language,
|
// stream_chunk_size=250: grosse Chunks = wenige Chunk-Grenzen = wenig
|
||||||
speaker_wav: speakerWav || "",
|
// 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", "250");
|
||||||
|
|
||||||
const url = new URL(`${XTTS_API_URL}/tts_to_audio/`);
|
const url = new URL(XTTS_API_URL);
|
||||||
|
const fullPath = `/tts_stream?${qs.toString()}`;
|
||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port,
|
port: url.port || 80,
|
||||||
path: url.pathname,
|
path: fullPath,
|
||||||
method: "POST",
|
method: "GET",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Length": Buffer.byteLength(body),
|
|
||||||
},
|
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log(`TTS GET /tts_stream?text=${text.slice(0, 30)}... (voice=${speakerWav ? "custom" : "default"})`);
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
const req = http.request(options, (res) => {
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
let body = "";
|
let body = "";
|
||||||
res.on("data", (d) => { body += d.toString(); });
|
res.on("data", (d) => { body += d.toString(); });
|
||||||
res.on("end", () => reject(new Error(`XTTS HTTP ${res.statusCode}: ${body.slice(0, 200)}`)));
|
res.on("end", () => {
|
||||||
|
log(`XTTS /tts_stream ${res.statusCode}: ${body.slice(0, 300)}`);
|
||||||
|
reject(new Error(`XTTS HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log(`TTS stream verbunden, empfange PCM...`);
|
||||||
|
|
||||||
let headerParsed = false;
|
let headerParsed = false;
|
||||||
let sampleRate = 24000;
|
let sampleRate = 24000;
|
||||||
@@ -285,6 +326,76 @@ function streamXTTSAsPCM(text, language, speakerWav, onPcmChunk) {
|
|||||||
|
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => { req.destroy(); reject(new Error("XTTS API Timeout (60s)")); });
|
req.on("timeout", () => { req.destroy(); reject(new Error("XTTS API Timeout (60s)")); });
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: /tts_to_audio/ (POST JSON) — rendert komplett, dann response.
|
||||||
|
* Kein echtes Streaming, aber stabil als Backup wenn /tts_stream nicht geht.
|
||||||
|
* Shared chunking-Logik mit streamXTTSAsPCM — parst WAV-Header, stueckelt PCM.
|
||||||
|
*/
|
||||||
|
function streamXTTSBatch(text, language, speakerWav, onPcmChunk) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
text,
|
||||||
|
language: language || "de",
|
||||||
|
speaker_wav: speakerWav || "",
|
||||||
|
});
|
||||||
|
const url = new URL(XTTS_API_URL);
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 80,
|
||||||
|
path: "/tts_to_audio/",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
let rb = "";
|
||||||
|
res.on("data", (d) => { rb += d.toString(); });
|
||||||
|
res.on("end", () => reject(new Error(`XTTS Batch HTTP ${res.statusCode}: ${rb.slice(0, 200)}`)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let headerParsed = false;
|
||||||
|
let sampleRate = 24000;
|
||||||
|
let channels = 1;
|
||||||
|
let leftover = Buffer.alloc(0);
|
||||||
|
let headerBuf = Buffer.alloc(0);
|
||||||
|
const HEADER_BYTES = 44;
|
||||||
|
const PCM_CHUNK_BYTES = 8192;
|
||||||
|
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
let data = chunk;
|
||||||
|
if (!headerParsed) {
|
||||||
|
headerBuf = Buffer.concat([headerBuf, data]);
|
||||||
|
if (headerBuf.length < HEADER_BYTES) return;
|
||||||
|
const header = headerBuf.slice(0, HEADER_BYTES);
|
||||||
|
try { channels = header.readUInt16LE(22); sampleRate = header.readUInt32LE(24); } catch (_) {}
|
||||||
|
headerParsed = true;
|
||||||
|
data = headerBuf.slice(HEADER_BYTES);
|
||||||
|
}
|
||||||
|
let combined = Buffer.concat([leftover, data]);
|
||||||
|
while (combined.length >= PCM_CHUNK_BYTES) {
|
||||||
|
const slice = combined.slice(0, PCM_CHUNK_BYTES);
|
||||||
|
combined = combined.slice(PCM_CHUNK_BYTES);
|
||||||
|
onPcmChunk(slice.toString("base64"), { sampleRate, channels });
|
||||||
|
}
|
||||||
|
leftover = combined;
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
if (leftover.length > 0) onPcmChunk(leftover.toString("base64"), { sampleRate, channels });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("XTTS Batch Timeout (60s)")); });
|
||||||
req.write(body);
|
req.write(body);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ services:
|
|||||||
- ./voices:/voices # Custom Voice Samples
|
- ./voices:/voices # Custom Voice Samples
|
||||||
environment:
|
environment:
|
||||||
- COQUI_TOS_AGREED=1
|
- COQUI_TOS_AGREED=1
|
||||||
|
# Local-Modus statt default "apiManual": Modell bleibt im GPU-VRAM,
|
||||||
|
# Render startet sofort, /tts_stream funktioniert.
|
||||||
|
# Default-CMD des Images liest diese ENV: -ms ${MODEL_SOURCE:-"apiManual"}
|
||||||
|
- MODEL_SOURCE=local
|
||||||
|
# Speaker-Folder auf unsere gemounteten voices zeigen lassen
|
||||||
|
- EXAMPLE_FOLDER=/voices
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ─── XTTS Bridge (verbindet zu RVS) ───────────
|
# ─── XTTS Bridge (verbindet zu RVS) ───────────
|
||||||
|
|||||||
Reference in New Issue
Block a user