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: