Compare commits

..

7 Commits

Author SHA1 Message Date
duffyduck cc3fac8142 release: bump version to 0.0.6.1 2026-04-25 01:24:31 +02:00
duffyduck cd89e36ec2 fix: alte APKs im Cache werden jetzt aufgeraeumt
Die heruntergeladenen Update-APKs (~20-30MB pro Release) landeten in
CachesDirectoryPath und wurden nie geloescht. Bei regelmaessigen
Updates sammelt sich das auf mehrere 100MB an.

Fix: cleanupOldApks() wird gerufen
  - einmal beim App-Start (Constructor) — alte APKs sind sowieso nicht
    mehr relevant, die aktuelle Version laeuft ja aus dem System
  - vor jedem neuen Download — falls jemand zwei Updates in einer
    Session zieht

Loescht alle *.apk Dateien im CachesDirectoryPath und loggt die
freigemachte Groesse pro Datei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:22:22 +02:00
duffyduck f5b4285d15 release: bump version to 0.0.6.0 2026-04-25 01:13:42 +02:00
duffyduck 248e7c9ae4 fix: preroll=0 wirklich sofort + Trailing-Silence gegen Wort-Cutoff
Zwei Bugs die zusammen dafuer sorgen dass Worte "verschluckt" werden:

1) play() wurde bei preroll=0 erst beim ersten echten Chunk aufgerufen
   — nicht schon nach der Leading-Silence. Dadurch musste AudioTrack
   gleichzeitig Startup UND Audio abspielen, die Hardware-Anfahr-Latenz
   schluckt die ersten Samples.

   Fix: Bei prerollBytes==0 direkt nach dem silence-write play() rufen.
   AudioTrack haelt den Play-State und wartet auf mehr Samples — die
   naechsten Chunks kommen in den bereits laufenden Stream rein.

2) Nach letztem Chunk ging der Writer via return@Thread in den finally-
   Block. Der wartete zwar auf playbackHeadPosition >= totalFrames, aber
   Android's Hardware-Pipeline puffert oft noch ein paar Samples nach —
   stop() kam, Samples futsch.

   Fix: 300ms TRAILING_SILENCE am Ende schreiben. playbackHeadPosition
   erreicht echt bis zum Ende der echten Samples bevor die Stille abspielt.
   Loop umgeschrieben auf mainLoop-Label (break statt return@Thread) damit
   Trailing-Silence garantiert laeuft.

LEADING_SILENCE auf 300ms erhoeht fuer bessere AudioTrack-Warmup-Toleranz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:11:23 +02:00
duffyduck 7058cc8d8d release: bump version to 0.0.5.9 2026-04-25 01:04:00 +02:00
duffyduck 7919489543 feat: Pre-Roll-Buffer kann jetzt auf 0 (sofort abspielen)
F5-TTS ist schnell genug dass der Puffer bei kurzen Saetzen eher
schadet als nuetzt — er verzoegert den play()-Start fuer Sekunden die
dann als Wartezeit auffallen.

Aenderungen:
- audio.ts: TTS_PREROLL_MIN_SEC 1.0 → 0 (Einstellbar in Settings)
- PcmStreamPlayerModule.kt: MIN_PREROLL_SECONDS auf 0.0, Fallback-
  Logic respektiert jetzt 0 als gueltigen Wert (vorher hat der
  .let { if (it > 0) it else DEFAULT } 0 zu 3.5s umgebogen).

Bei preroll=0 greift der Leading-Silence von 200ms immer noch, d.h.
AudioTrack-Startup bleibt sauber. play() wird dann beim allerersten
echten PCM-Chunk aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:02:48 +02:00
duffyduck feac7f2479 feat(diagnostic): Speed-Slider im Voice-Preview-Modal (nicht persistiert)
Neue −0.1 / +0.1 Buttons im Preview-Modal mit aktuellem Wert-Label.
Bei jedem Oeffnen wird der Speed auf 1.0 zurueckgesetzt (bewusst kein
persist — nur zum Experimentieren waehrend das Modal offen ist).

- Range 0.1-5.0, gleiche wie in App-Settings
- Wird beim Play an f5tts-bridge als speed-Param mitgegeben
- Server clampt auf 0.1-5.0, Fallback 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:54:40 +02:00
7 changed files with 128 additions and 22 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 508
versionName "0.0.5.8"
versionCode 601
versionName "0.0.6.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -32,11 +32,17 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
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
// 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
// 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
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"
@@ -59,9 +65,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
// 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 }
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
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 encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -103,9 +112,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
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)
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (leadingBytes > 0) {
val silence = ByteArray(leadingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
@@ -114,8 +123,23 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
bytesBuffered += silence.size
}
while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run {
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
// 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) {
// Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
if (!playbackStarted) {
@@ -127,10 +151,10 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
Log.w(TAG, "play() fallback failed: ${e.message}")
}
}
return@Thread
break@mainLoop
}
null
} ?: continue
continue@mainLoop
}
// Pre-Roll Check: play() erst wenn genug gepuffert
if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) {
@@ -151,6 +175,19 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
}
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) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.5.8",
"version": "0.0.6.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+1 -1
View File
@@ -143,7 +143,7 @@ const MAX_RECORDING_MS = 120000;
// 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_MIN_SEC = 0; // 0 = sofort abspielen (F5-TTS ist schnell genug)
export const TTS_PREROLL_MAX_SEC = 6.0;
export const TTS_PREROLL_STORAGE_KEY = 'aria_tts_preroll_sec';
+33
View File
@@ -29,6 +29,11 @@ class UpdateService {
private downloading = false;
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
rvs.onMessage((msg: RVSMessage) => {
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 */
checkForUpdate(): void {
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
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64');
+34 -1
View File
@@ -145,6 +145,15 @@
</div>
<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>
<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;">
<button id="voice-preview-play" onclick="playVoicePreview()" class="btn primary" style="padding:8px 16px;">
▶ Abspielen
@@ -1630,10 +1639,29 @@
// ── Voice Preview Modal ─────────────────────────
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 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) {
currentPreviewVoice = name;
// Speed bei jedem Oeffnen zuruecksetzen — bewusst kein persist
currentPreviewSpeed = PREVIEW_SPEED_DEFAULT;
_refreshPreviewSpeedLabel();
document.getElementById('voice-preview-name').textContent = name;
// Text bei jedem Oeffnen zuruecksetzen
document.getElementById('voice-preview-text').value = VOICE_PREVIEW_DEFAULT;
@@ -1658,7 +1686,12 @@
}
document.getElementById('voice-preview-status').textContent = '⏳ Rendere...';
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) {
+7 -4
View File
@@ -1469,7 +1469,7 @@ wss.on("connection", (ws) => {
} else if (msg.action === "test_tts") {
handleTestTTS(ws, msg.text || "Test");
} 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") {
handleCheckTTS(ws);
} 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 {
// 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();
_previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
// Timeout safety net
@@ -1720,10 +1723,10 @@ async function handleVoicePreview(clientWs, voice, text) {
}
}
}, 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({
type: "xtts_request",
payload: { text, language: "de", requestId, voice, speed: 1.0 },
payload: { text, language: "de", requestId, voice, speed: spd },
timestamp: Date.now(),
});
} catch (err) {