Skip to content
Open
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
3 changes: 2 additions & 1 deletion environments/requirements_win_cpu.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion environments/requirements_win_cuda.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
PyYAML
soundfile
44 changes: 41 additions & 3 deletions noScribe/pyannote_mp_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand Down
102 changes: 102 additions & 0 deletions tests/test_pyannote_waveform_load.py
Original file line number Diff line number Diff line change
@@ -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
Loading