diff --git a/environments/requirements_win_cpu.txt b/environments/requirements_win_cpu.txt index eb39fc07..722d58ed 100644 --- a/environments/requirements_win_cpu.txt +++ b/environments/requirements_win_cpu.txt @@ -9,8 +9,9 @@ CTkToolTip faster-whisper Pillow pyannote.audio>=4.0 -pyinstaller=6.14.1 +pyinstaller==6.14.1 # The bootloader in the most recent version of pyinstaller is often falsely detected as malware # by anti virus software, so, it's safer to use a version that has been released a few months ago. python-i18n PyYAML +soundfile diff --git a/environments/requirements_win_cuda.txt b/environments/requirements_win_cuda.txt index 88c0f2a4..57ad5b32 100644 --- a/environments/requirements_win_cuda.txt +++ b/environments/requirements_win_cuda.txt @@ -15,4 +15,5 @@ pyinstaller==6.14.1 # The bootloader in the most recent version of pyinstaller is often falsely detected as malware # by anti virus software, so, it's safer to use a version that has been released a few months ago. python-i18n -PyYAML \ No newline at end of file +PyYAML +soundfile diff --git a/noScribe/pyannote_mp_worker.py b/noScribe/pyannote_mp_worker.py index 7432c1f2..bf9e0d12 100644 --- a/noScribe/pyannote_mp_worker.py +++ b/noScribe/pyannote_mp_worker.py @@ -3,14 +3,52 @@ import platform import traceback -import torchaudio - if platform.system() == "Darwin" and platform.machine() == "x86_64": os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("MKL_NUM_THREADS", "1") os.environ.setdefault("MKL_THREADING_LAYER", "GNU") os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") # temp workaround for iomp5 dup +def load_waveform(audio_file): + """Load audio as the in-memory ``(channels, frames)`` float32 tensor + + sample rate that pyannote's waveform input expects. + + The input is the WAV written by ``noScribe.audio.convert.ToWav``, which + owns the format, so plain soundfile can read it -- no torchaudio/torchcodec + decoding backends needed. Passing the waveform in memory also keeps + pyannote's own decoder out of play. + """ + # Both imports are deferred so this module stays stdlib-only at import + # time, as whisper_mp_worker does. For torch that is load-bearing beyond + # tidiness: the OMP/MKL environment above must be set before torch pulls in + # OpenMP, and main.py imports this module in the GUI process just to reach + # the entrypoint. + import soundfile + import torch + try: + data, sample_rate = soundfile.read(audio_file, dtype="float32", always_2d=True) + except RuntimeError as e: + # libsndfile funnels every open failure through one exception type and + # only the code tells them apart, so a locked or unreadable file must + # not be reported as a format problem. (RuntimeError rather than + # soundfile.LibsndfileError: the latter only exists from soundfile + # 0.11, and it derives from RuntimeError anyway.) + if getattr(e, "code", None) == 1: # SF_ERR_UNRECOGNISED_FORMAT + raise RuntimeError( + f"Could not decode {audio_file}: not a WAV the diarization " + f"worker can read. {e}") from e + raise RuntimeError(f"Could not read {audio_file}: {e}") from e + if data.shape[0] == 0: + # A header with no frames: soundfile accepts it and would hand pyannote + # a (1, 0) tensor, which its own validator rejects with a message about + # tensor layout that names nothing the user can act on. + raise RuntimeError( + f"{audio_file} contains no audio. Check the start and stop times.") + # .contiguous() is a no-op for mono (the (frames, 1) transpose is already + # contiguous); it only copies in the hypothetical multichannel case. + return torch.from_numpy(data.T).contiguous(), sample_rate # (ch, frames) + + def pyannote_proc_entrypoint(args: dict, q): """Runs diarization in a child process and streams progress/logs. Messages: @@ -75,7 +113,7 @@ def __call__(self, step_name, step_artifact, file=None, total=None, completed=No with impres.as_file(impres.files("pyannote")) as mypath: pipeline = Pipeline.from_pretrained(mypath) - waveform, sample_rate = torchaudio.load(audio_file) + waveform, sample_rate = load_waveform(audio_file) pipeline.to(torch.device(device)) seg_list = [] diff --git a/tests/test_pyannote_waveform_load.py b/tests/test_pyannote_waveform_load.py new file mode 100644 index 00000000..bf4d89e5 --- /dev/null +++ b/tests/test_pyannote_waveform_load.py @@ -0,0 +1,102 @@ +"""Prove that loading the diarization waveform via soundfile is a drop-in +replacement for the previous ``torchaudio.load`` call. + +The pyannote worker only ever loads the WAV that ``noScribe.audio.convert`` +writes and hands it to the pipeline as an in-memory +``{"waveform": tensor, "sample_rate": int}`` dict, so the loader has to produce +the exact same tensor. The fixture goes through the real conversion step rather +than writing a WAV with soundfile itself: the production input is muxed by +PyAV and carries a different header, and a file written and read by the same +library would hide any quirk specific to the other one. +""" +import importlib.resources as impres + +import numpy as np +import pytest +import soundfile as sf +import torch + +from noScribe import audio +from noScribe.pyannote_mp_worker import load_waveform + + +@pytest.fixture() +def converted_wav(tmp_path): + path_input = impres.files("tests") / "data" / "interview.mp3" + path_output = tmp_path / "converted.wav" + with audio.convert.ToWav(path_input, path_output) as towav: + towav.stop_after(3000) # 3 s is plenty and keeps the test quick + while towav.convert(): + pass + return path_output + + +def test_load_waveform_shape_dtype_rate(converted_wav): + waveform, sample_rate = load_waveform(str(converted_wav)) + assert sample_rate == 16000 + assert waveform.dtype == torch.float32 + assert waveform.ndim == 2 and waveform.shape[0] == 1 # (channels, frames) + assert waveform.shape[1] > 0 + assert waveform.is_contiguous() + + +def test_multichannel_is_returned_contiguous(tmp_path): + """Mono is contiguous either way, so only a stereo input can show that the + transpose is actually made contiguous before pyannote sees it.""" + path = tmp_path / "stereo.wav" + sig = np.random.default_rng(0).uniform(-1.0, 1.0, (16000, 2)) + sf.write(path, sig, 16000, subtype="PCM_16") + waveform, _ = load_waveform(str(path)) + assert waveform.shape == (2, 16000) + assert waveform.is_contiguous() + + +def test_undecodable_input_names_the_format(tmp_path): + # Undecodable, not unconverted: libsndfile reads MP3, FLAC and Ogg quite + # happily, so the loader cannot tell whether a file went through the + # conversion step -- only whether it can read it at all. + bogus = tmp_path / "not_audio.bin" + bogus.write_bytes(b"\x00\x00\x00\x20ftypM4A this is not a wav") + with pytest.raises(RuntimeError, match="not a WAV"): + load_waveform(str(bogus)) + + +def test_unreadable_file_is_not_blamed_on_the_format(tmp_path): + """libsndfile reports a locked file through the same exception as a bad + format; only the code tells them apart. Confusing the two sends the user + to debug the conversion step over a permissions problem.""" + path = tmp_path / "locked.wav" + sf.write(path, np.zeros(16000, dtype="float32"), 16000, subtype="PCM_16") + path.chmod(0o000) + try: + with pytest.raises(RuntimeError) as excinfo: + load_waveform(str(path)) + finally: + path.chmod(0o600) + assert "not a WAV" not in str(excinfo.value) + + +def test_empty_audio_is_reported_as_empty(tmp_path): + """A WAV with a header but no frames would otherwise reach pyannote as a + (1, 0) tensor and come back as a complaint about tensor layout.""" + path = tmp_path / "empty.wav" + sf.write(path, np.zeros(0, dtype="float32"), 16000, subtype="PCM_16") + with pytest.raises(RuntimeError, match="no audio"): + load_waveform(str(path)) + + +def test_load_waveform_bit_identical_to_torchaudio(converted_wav): + # Migration-time proof: runs only while torchaudio is still installed and + # may be deleted once torchaudio leaves the tested stacks. The tests above + # keep covering the loader on its own. + torchaudio = pytest.importorskip("torchaudio") + try: + expected, expected_rate = torchaudio.load(str(converted_wav)) + except ImportError as e: + # torchaudio >= 2.9 decodes through torchcodec, which needs system + # FFmpeg libraries noScribe deliberately does not depend on. A missing + # comparison baseline is not a failure of the loader. + pytest.skip(f"torchaudio cannot decode here: {e}") + actual, actual_rate = load_waveform(str(converted_wav)) + assert actual_rate == expected_rate + assert torch.equal(actual, expected) # bit-for-bit, not just allclose