From d674ed1c698a6fab0b0b2c43615a3d841c4d5846 Mon Sep 17 00:00:00 2001 From: Zach Guo Date: Wed, 15 Jul 2026 11:29:56 +0800 Subject: [PATCH] chore(voice): report real wav duration, ignoring a streaming placeholder header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fish.audio streams the wav and writes a placeholder/oversized data-chunk size in the header (the final length isn't known up front), so wav_seconds trusting getnframes() reported a bogus constant (~48695s) — pinning the synth log's rtf to 0.00 and making "is TTS real-time?" unreadable. Measure the PCM actually on disk (readframes stops at EOF) so the duration is real regardless of the header. Verified: a real fish.audio clip now reports ~3.7s. Co-Authored-By: Claude Opus 4.8 --- src/murmur/voice/_wav.py | 18 ++++++++++++++---- tests/test_wav.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/murmur/voice/_wav.py b/src/murmur/voice/_wav.py index bc663ef..7f92ab6 100644 --- a/src/murmur/voice/_wav.py +++ b/src/murmur/voice/_wav.py @@ -26,12 +26,22 @@ def estimate_seconds(text: str) -> float: def wav_seconds(path: Path | str) -> float: - """Duration of a wav in seconds (frames / framerate). Used to report the - spoken length of a synthesized clip so callers can compute a real-time - factor (generation time / audio duration).""" + """Duration of a wav in seconds. Used to report the spoken length of a + synthesized clip so callers can compute a real-time factor (generation time + / audio duration). + + Measures the PCM actually on disk rather than trusting ``getnframes()``: a + streaming source (e.g. fish.audio) can't know the final length up front, so + it writes a placeholder/oversized data-chunk size in the header — trusting it + reports a bogus, constant duration. ``readframes`` stops at EOF, so the byte + count it returns is the real audio regardless of the header's claim.""" with wave.open(str(path), "rb") as wav: rate = wav.getframerate() - return wav.getnframes() / rate if rate else 0.0 + framesize = wav.getsampwidth() * wav.getnchannels() + if not rate or not framesize: + return 0.0 + actual_frames = len(wav.readframes(wav.getnframes())) // framesize + return actual_frames / rate def write_silent_wav( diff --git a/tests/test_wav.py b/tests/test_wav.py index c69bc0f..f7fe4f4 100644 --- a/tests/test_wav.py +++ b/tests/test_wav.py @@ -43,6 +43,25 @@ def test_wav_seconds_reads_the_clip_duration(tmp_path): assert wav_seconds(path) == pytest.approx(2.0, abs=0.01) +def test_wav_seconds_ignores_a_streaming_placeholder_header(tmp_path): + # A streaming source (e.g. fish.audio) can't know the final length up front, + # so it writes a placeholder/oversized data-chunk size in the header. Trusting + # getnframes() then reports a bogus, constant duration (~48695s seen live). + # wav_seconds must report the ACTUAL clip length from the bytes on disk. + import struct + + path = tmp_path / "streamy.wav" + write_silent_wav(path, seconds=2.0) # 2.0s of 16k mono 16-bit + raw = bytearray(path.read_bytes()) + # Canonical PCM header (what `wave` writes): RIFF size at [4:8], data-chunk + # size at [40:44]. Overwrite both with the uint32 max placeholder. + struct.pack_into("