Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/murmur/voice/_wav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions tests/test_wav.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<I", raw, 4, 0xFFFFFFFF)
struct.pack_into("<I", raw, 40, 0xFFFFFFFF)
path.write_bytes(raw)

assert wav_seconds(path) == pytest.approx(2.0, abs=0.05)


def test_start_is_idempotent():
writer = SilentClipWriter(prefix="murmur-test-")
writer.start()
Expand Down