From 0e9adeee5cd98fbf858d0f124d07a3a5db7ed25c Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 15 Aug 2026 14:43:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(speaker-id):=20Enrollment=20akzeptiert=20An?= =?UTF-8?q?droid-MP4/AAC=20=E2=80=94=20kein=20"zu=20kurz"=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice-ID-Enrollment scheiterte immer mit "zu kurz". Ursache: die App nimmt die Samples mit dem Legacy-Recorder als AAC im MP4-Container auf (16kHz mono), die Bridge dekodierte das base64 aber als ROHES int16-PCM. 4s AAC sind stark komprimiert (< 32KB = MIN_SAMPLE_BYTES) → faelschlich als "zu kurz" verworfen, und selbst darueber waere das Embedding Muell. Fix (bridge-seitig, kein APK): _normalize_audio_bytes erkennt jetzt den MP4/M4A/ AAC-Container ('ftyp' bei Offset 4) und dekodiert ihn via ffmpeg (im Container) auf 16kHz mono int16 PCM — zusaetzlich zu rohem PCM und WAV. enroll_from_samples dekodiert erst, prueft DANN die Laenge aufs dekodierte PCM (nicht die komprimierten Bytes). Input via Temp-Datei, da Androids moov-Atom am Ende seekbaren Input braucht. Gleich in voxtral + whisper (identische Kopien). Deploy: docker compose up -d --build voxtral-bridge; danach in Einstellungen → Voice-ID neu einlernen. Co-Authored-By: Claude Opus 4.8 --- xtts/voxtral/speaker_id.py | 55 +++++++++++++++++++++++++++++++++----- xtts/whisper/speaker_id.py | 55 +++++++++++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/xtts/voxtral/speaker_id.py b/xtts/voxtral/speaker_id.py index 7ed82f2..38e6985 100644 --- a/xtts/voxtral/speaker_id.py +++ b/xtts/voxtral/speaker_id.py @@ -61,10 +61,40 @@ def _ensure_loaded(): return _model +def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes: + """Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg + (im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber + eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans + ENDE, das braucht seekbaren Input, sonst 'moov atom not found'.""" + import os + import subprocess + import tempfile + tmp = None + try: + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf: + tf.write(audio_bytes) + tmp = tf.name + proc = subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp, + "-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + if proc.returncode != 0 or not proc.stdout: + raise ValueError( + f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}") + return proc.stdout + finally: + if tmp: + try: + os.unlink(tmp) + except Exception: + pass + + def _normalize_audio_bytes(audio_bytes: bytes) -> bytes: - """Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE). - Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16). - Ergebnis: rohes PCM.""" + """Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen + komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen + + Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM.""" if (len(audio_bytes) >= 44 and audio_bytes[:4] == b"RIFF" and audio_bytes[8:12] == b"WAVE"): @@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes: if sw != 2: raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)") return wav.readframes(wav.getnframes()) + # MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an. + if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp": + return _decode_compressed_to_pcm(audio_bytes) return audio_bytes @@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict: except Exception as exc: rejected.append({"index": idx, "reason": f"base64: {exc}"}) continue - if len(raw) < MIN_SAMPLE_BYTES: - rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"}) + # Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der + # Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts + # ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz"). + try: + pcm = _normalize_audio_bytes(raw) + except Exception as exc: + rejected.append({"index": idx, "reason": f"decode: {exc}"}) + continue + if len(pcm) < MIN_SAMPLE_BYTES: + rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"}) continue try: - emb = embed(raw) + emb = embed(pcm) embeddings.append(emb) - durations.append(len(raw) / 2 / 16000.0) + durations.append(len(pcm) / 2 / 16000.0) except Exception as exc: rejected.append({"index": idx, "reason": f"embed: {exc}"}) if not embeddings: diff --git a/xtts/whisper/speaker_id.py b/xtts/whisper/speaker_id.py index 7ed82f2..38e6985 100644 --- a/xtts/whisper/speaker_id.py +++ b/xtts/whisper/speaker_id.py @@ -61,10 +61,40 @@ def _ensure_loaded(): return _model +def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes: + """Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg + (im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber + eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans + ENDE, das braucht seekbaren Input, sonst 'moov atom not found'.""" + import os + import subprocess + import tempfile + tmp = None + try: + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf: + tf.write(audio_bytes) + tmp = tf.name + proc = subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp, + "-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + if proc.returncode != 0 or not proc.stdout: + raise ValueError( + f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}") + return proc.stdout + finally: + if tmp: + try: + os.unlink(tmp) + except Exception: + pass + + def _normalize_audio_bytes(audio_bytes: bytes) -> bytes: - """Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE). - Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16). - Ergebnis: rohes PCM.""" + """Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen + komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen + + Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM.""" if (len(audio_bytes) >= 44 and audio_bytes[:4] == b"RIFF" and audio_bytes[8:12] == b"WAVE"): @@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes: if sw != 2: raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)") return wav.readframes(wav.getnframes()) + # MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an. + if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp": + return _decode_compressed_to_pcm(audio_bytes) return audio_bytes @@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict: except Exception as exc: rejected.append({"index": idx, "reason": f"base64: {exc}"}) continue - if len(raw) < MIN_SAMPLE_BYTES: - rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"}) + # Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der + # Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts + # ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz"). + try: + pcm = _normalize_audio_bytes(raw) + except Exception as exc: + rejected.append({"index": idx, "reason": f"decode: {exc}"}) + continue + if len(pcm) < MIN_SAMPLE_BYTES: + rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"}) continue try: - emb = embed(raw) + emb = embed(pcm) embeddings.append(emb) - durations.append(len(raw) / 2 / 16000.0) + durations.append(len(pcm) / 2 / 16000.0) except Exception as exc: rejected.append({"index": idx, "reason": f"embed: {exc}"}) if not embeddings: