Skip to content

Audio Modules

convert_audio_to_wav

convert_audio_to_wav(
    input_path,
    *,
    output_path=None,
    output_dir=None,
    sample_rate=16000,
    bit_depth=16,
    channels=1,
    overwrite_existing=False,
    verbose=True
)

Convert any FFmpeg-readable audio/video file to a linear PCM WAV.

Parameters:

Name Type Description Default
input_path str | Path

Source media file (audio or video container). FFmpeg must be able to read it.

required
output_path str | Path | None

Target WAV path. If None, <output_dir>/<input_stem>.wav.

None
output_dir str | Path | None

Where the WAV goes when output_path is not given. Defaults to <cwd>/audio.

None
sample_rate int

Desired sample rate (Hz).

16000
bit_depth (16, 24, 32)

Output PCM bit depth; maps to pcm_s{bit_depth}le codec.

16,24,32
channels int

Output channels: 1 for mono, 2 for stereo.

1
overwrite_existing bool

Overwrite output_path if it already exists.

False
verbose bool

Print incidental notices (such as "already exists, skipping"). The pipeline runner passes False when a live display owns the screen.

True

Returns:

Type Description
Path

Path to the written WAV file.

Raises:

Type Description
FileNotFoundError

If input_path does not exist.

RuntimeError

If FFmpeg/FFprobe are missing or the conversion fails.

Notes
  • Video inputs are supported: the audio stream is extracted and converted.
  • For multi-channel sources and channels is None, channel layout is preserved.
  • We run FFmpeg with -nostdin to avoid TTY issues in pipelines.
Source code in src\taters\audio\convert_to_wav.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def convert_audio_to_wav(
    input_path: Union[str, Path],
    *,
    output_path: Optional[Union[str, Path]] = None,
    output_dir: Optional[Union[str, Path]] = None,
    sample_rate: int = 16000,          # common for ASR
    bit_depth: int = 16,               # 16/24/32 signed PCM
    channels: int = 1,                 # 1=mono, 2=stereo
    overwrite_existing: bool = False,  # if the file already exists, let's not overwrite by default
    verbose: bool = True,
) -> Path:
    """
    Convert any FFmpeg-readable audio/video file to a linear PCM WAV.

    Parameters
    ----------
    input_path : str | Path
        Source media file (audio or video container). FFmpeg must be able to read it.
    output_path : str | Path | None, optional
        Target WAV path. If None, ``<output_dir>/<input_stem>.wav``.
    output_dir : str | Path | None, optional
        Where the WAV goes when ``output_path`` is not given. Defaults to
        ``<cwd>/audio``.
    sample_rate : int, default 16000
        Desired sample rate (Hz).
    bit_depth : {16,24,32}, default 16
        Output PCM bit depth; maps to ``pcm_s{bit_depth}le`` codec.
    channels : int, default 1
        Output channels: 1 for mono, 2 for stereo.
    overwrite_existing : bool, default False
        Overwrite `output_path` if it already exists.
    verbose : bool, default True
        Print incidental notices (such as "already exists, skipping"). The
        pipeline runner passes False when a live display owns the screen.

    Returns
    -------
    Path
        Path to the written WAV file.

    Raises
    ------
    FileNotFoundError
        If `input_path` does not exist.
    RuntimeError
        If FFmpeg/FFprobe are missing or the conversion fails.

    Notes
    -----
    - Video inputs are supported: the audio stream is extracted and converted.
    - For multi-channel sources and `channels is None`, channel layout is preserved.
    - We run FFmpeg with ``-nostdin`` to avoid TTY issues in pipelines.
    """

    _check_ffmpeg()

    in_path = Path(input_path).resolve()
    if not in_path.exists():
        raise FileNotFoundError(f"Input file not found: {in_path}")

    if output_path and output_dir:
        raise ValueError("Provide at most one of output_path or output_dir.")

    if output_path:
        out_path = Path(output_path).resolve()
    else:
        base = in_path.stem + ".wav"
        out_dir = Path(output_dir).resolve() if output_dir else Path.cwd() / "audio"
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / base

    if not overwrite_existing and Path(out_path).is_file():
        if verbose:
            print("WAV file already exists; returning existing file.")
        return out_path

    pcm_map = PCM_CODECS
    if bit_depth not in pcm_map:
        raise ValueError("bit_depth must be one of {16, 24, 32}.")
    if channels not in (1, 2):
        raise ValueError("channels must be 1 (mono) or 2 (stereo).")

    cmd = [
        "ffmpeg",
        "-nostdin",
        "-hide_banner", "-loglevel", "error",
        "-y" if overwrite_existing else "-n",
        "-i", str(in_path),
        "-vn",                        # ignore video
        "-acodec", pcm_map[bit_depth],
        "-ar", str(sample_rate),
        "-ac", str(channels),
        str(out_path),
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL)
    if result.returncode != 0:
        if not overwrite_existing and out_path.exists():
            raise FileExistsError(f"Target exists (use overwrite_existing=True): {out_path}")
        raise RuntimeError(f"ffmpeg failed: {result.stderr.strip()}")

    return out_path

Thin CLI shim for the vendored Whisper diarization wrapper.

This module exists so you can run:

python -m taters.audio.diarize_with_thirdparty --audio_path ...

It simply delegates to the real implementation in taters/audio/diarizer/whisper_diar_wrapper.py.

Extract all audio streams from a video/container into standalone WAV files.

This utility probes the container with ffprobe, lists audio streams (with index and tags), and then maps each stream with ffmpeg to a separate PCM WAV. It is useful for multi-track recordings (e.g., Zoom, OBS, ProRes with stems).

split_audio_streams_to_wav

split_audio_streams_to_wav(
    input_path,
    output_dir=None,
    sample_rate=48000,
    bit_depth=16,
    overwrite_existing=False,
    *,
    overwrite=None,
    verbose=True
)

Extract each audio stream in a container to its own WAV file.

Parameters:

Name Type Description Default
input_path str | PathLike

Video or audio container readable by FFmpeg.

required
output_dir str | PathLike | None

Destination directory. If None, defaults to ./audio in the current working directory (predictable write location).

None
sample_rate int

Target sample rate for the output WAVs (Hz).

48000
bit_depth (16, 24, 32)

Output PCM bit depth (little-endian).

16,24,32
overwrite_existing bool

If False (default) and a target WAV already exists, that stream is left alone and the existing path is returned, matching the rest of Taters. Set True to re-extract and replace.

False
verbose bool

Print each stream as it is extracted. The pipeline runner turns this off under its live display.

True
overwrite bool

Deprecated alias for overwrite_existing. Passing it emits a :class:DeprecationWarning. Note that this function used to default to overwriting; it now preserves existing files like every other writer.

None

Returns:

Type Description
list[str]

Absolute paths to the WAVs for every audio stream, whether freshly written or already present.

Behavior
  • Output file names are constructed from the input base name and stream metadata: <stem>_a<index>[_<lang>][_<title>].wav with safe slugs.
  • Existing outputs are decided in Python rather than by handing ffmpeg -n: some ffmpeg builds refuse to overwrite but still exit 0, which would report a stale file as freshly written.
  • Uses -map 0:a:<N> to select the N-th audio stream in the container.
  • Runs FFmpeg with -nostdin and quiet loglevel to avoid TTY lockups.

Examples:

>>> split_audio_streams_to_wav("session.mp4")
['.../audio/session_a0_eng.wav', '.../audio/session_a1_eng.wav']
Source code in src\taters\audio\extract_wav_from_video.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def split_audio_streams_to_wav(
    input_path: str | os.PathLike,
    output_dir: str | os.PathLike | None = None,     # <-- now optional
    sample_rate: int = 48000,
    bit_depth: int = 16,
    overwrite_existing: bool = False,
    *,
    overwrite: bool | None = None,   # deprecated alias for overwrite_existing
    verbose: bool = True,
) -> List[str]:
    """
    Extract each audio stream in a container to its own WAV file.

    Parameters
    ----------
    input_path : str | os.PathLike
        Video or audio container readable by FFmpeg.
    output_dir : str | os.PathLike | None, optional
        Destination directory. If None, defaults to ``./audio`` in the current
        working directory (predictable write location).
    sample_rate : int, default 48000
        Target sample rate for the output WAVs (Hz).
    bit_depth : {16,24,32}, default 16
        Output PCM bit depth (little-endian).
    overwrite_existing : bool, default False
        If False (default) and a target WAV already exists, that stream is left
        alone and the existing path is returned, matching the rest of Taters.
        Set True to re-extract and replace.
    verbose : bool, default True
        Print each stream as it is extracted. The pipeline runner turns this
        off under its live display.
    overwrite : bool, optional
        Deprecated alias for `overwrite_existing`. Passing it emits a
        :class:`DeprecationWarning`. Note that this function used to default to
        overwriting; it now preserves existing files like every other writer.

    Returns
    -------
    list[str]
        Absolute paths to the WAVs for every audio stream, whether freshly
        written or already present.

    Behavior
    --------
    - Output file names are constructed from the input base name and stream
      metadata: ``<stem>_a<index>[_<lang>][_<title>].wav`` with safe slugs.
    - Existing outputs are decided in Python rather than by handing ffmpeg
      ``-n``: some ffmpeg builds refuse to overwrite but still exit 0, which
      would report a stale file as freshly written.
    - Uses ``-map 0:a:<N>`` to select the N-th audio stream in the container.
    - Runs FFmpeg with ``-nostdin`` and quiet loglevel to avoid TTY lockups.

    Examples
    --------
    >>> split_audio_streams_to_wav("session.mp4")
    ['.../audio/session_a0_eng.wav', '.../audio/session_a1_eng.wav']
    """

    if overwrite is not None:
        warnings.warn(
            "split_audio_streams_to_wav(overwrite=...) is deprecated; use "
            "overwrite_existing=... instead. Note the default also changed: "
            "existing WAVs are now kept rather than overwritten.",
            DeprecationWarning,
            stacklevel=2,
        )
        overwrite_existing = bool(overwrite)

    _check_ffmpeg()

    in_path = Path(input_path)
    if not in_path.exists():
        raise FileNotFoundError(f"Input file not found: {in_path}")

    # no output dir given? we fall back to a predictable spot: ./audio
    if output_dir is None:
        out_dir = Path.cwd() / "audio"
    else:
        out_dir = Path(output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    if verbose:
        print(f"Extracting audio streams from {in_path} to {out_dir} at "
              f"{sample_rate} Hz, bit depth: {bit_depth}")

    streams = _probe_audio_streams(in_path)
    if not streams:
        raise ValueError("No audio streams found in input.")

    if bit_depth not in PCM_CODECS:
        raise ValueError("bit_depth must be one of {16, 24, 32}.")
    pcm_codec = PCM_CODECS[bit_depth]

    created_files: List[str] = []
    base = in_path.stem

    for s in streams:
        idx = s.get("index")
        tags = s.get("tags", {}) or {}
        lang = tags.get("language")
        title = tags.get("title")

        if verbose:
            print(f"Extracting audio stream:\n"
                  f"index: {idx}\n"
                  f"tags: {tags}\n"
                  f"language: {lang}\n"
                  f"title: {title}\n")

        out_name = _build_wav_name(base, idx, lang, title)
        out_path = out_dir / out_name

        # we decide about existing files ourselves rather than leaning on
        # ffmpeg's "-n": some ffmpeg builds refuse to overwrite but still exit
        # 0, so we'd end up reporting a stale file as freshly written.
        if out_path.exists() and not overwrite_existing:
            if verbose:
                print(f"WAV already exists; returning existing file: {out_path}")
            created_files.append(str(out_path))
            continue

        ffmpeg_cmd = [
            "ffmpeg",
            "-nostdin",
            "-hide_banner",
            "-loglevel", "error",
            "-y",
            "-i", str(in_path),
            "-map", f"0:a:{streams.index(s)}",  # Nth audio stream
            "-acodec", pcm_codec,
            "-ar", str(sample_rate),
            str(out_path),
        ]

        result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL)
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg failed for stream {idx}: {result.stderr.strip()}")
        if not out_path.is_file():
            # ffmpeg can claim success without ever producing a file; we never
            # want to hand back a path that isn't there.
            raise RuntimeError(
                f"ffmpeg reported success but no file was written for stream {idx}: "
                f"{out_path}\n{result.stderr.strip()}"
            )

        created_files.append(str(out_path))

    return created_files

Acoustic feature extraction (Praat/Parselmouth-based) with optional per-turn analysis and OpenWillis-style "simple / tremor / advanced" modes.

This module targets parity with the OpenWillis vocal acoustics stack: - Framewise tracks: f0, formants (F1–F4), loudness (intensity), HNR. - Summary stats of those tracks (mean, std, range), with an option to summarize only on voiced segments longer than 100 ms. - Phonation metrics via Praat (jitter/shimmer families, GNE). - Pause metrics (SPIR, DurMED, DurMAD) using energy-based VAD. - Cepstral features (MFCC mean/var; CPPS via Praat PowerCepstrogram). - Optional tremor metrics (requires the "tremor.praat" script). - Optional glottal features (HRF, NAQ, OQ) via DisVoice (if installed).

It also supports "per-turn" analysis using a transcript CSV, so you can compute speaker-level or utterance-level acoustics aligned with your diarized segments.

Outputs

1) Framewise CSV (optional): one row per analysis frame (or per frame per turn). 2) Summary CSV: one row per file (or per speaker/turn grouping), including pass-through metadata (e.g., source, speaker) if provided.

Dependencies
  • Required: parselmouth (Praat), numpy, pandas (for CSV I/O only), librosa
  • Optional: DisVoice (glottal metrics), pysptk (DisVoice dependency)
  • Optional: Praat tremor script file if you want tremor metrics
Notes on design choices
  • f0 range (75–500 Hz) matches OpenWillis defaults. Out-of-range f0 frames are set to 0 for "framewise", and are excluded from summary calculations (like OpenWillis).
  • Voiced frames are derived from Praat/Parselmouth tracks. A "voiced-segment >=100ms" filter is available for summary statistics (again, matching OpenWillis semantics).
  • Pause metrics (SPIR, DurMED, DurMAD) follow OpenWillis thresholds (50 ms < pause < 2 s).
  • CPPS is computed via Praat PowerCepstrogram calls; if Praat/Parselmouth lacks the function in your local build, we skip with a warning.
  • Tremor metrics require a Praat script (tremor.praat). Provide its path if you want them.
CLI

python -m taters.audio.analyze_acoustics --wav audio/speaker.wav --out-dir features/acoustics --mode simple --voiced-segments true --transcript-csv transcripts/X/X.csv --time-unit ms --group-by speaker --pass-through source speaker

FramewiseTracks dataclass

FramewiseTracks(
    times, f0, f1, f2, f3, f4, loudness_db, hnr_db
)

Aligned framewise series at a fixed hop (e.g., 10 ms).

analyze_acoustics

analyze_acoustics(
    *,
    wav_path=None,
    transcript_csv=None,
    time_unit="ms",
    group_by=None,
    extra_id_cols=("source", "speaker"),
    out_dir=None,
    out_framewise_csv=None,
    out_summary_csv=None,
    overwrite_existing=False,
    include_framewise=True,
    mode="simple",
    summarize_on_voiced_segments_ms=100,
    f0_min=75.0,
    f0_max=500.0,
    n_mfcc=14,
    tremor_script=None,
    preprocess=True,
    target_sr=44100,
    target_dbfs=-20.0,
    remove_dc=True,
    pause_top_db=30,
    pause_frame_length=2048,
    pause_hop_length=512,
    verbose=True,
    on_progress=None
)

Extract acoustic features and write a summary CSV and (by default) a framewise CSV.

This function computes a battery of speech/voice features using Praat/Parselmouth-style workflows with optional cepstral, tremor, and glottal measures. It supports two operating modes:

1) Whole-file analysis Features are derived across the entire WAV. Summary statistics can be restricted to voiced segments longer than a threshold.

2) Per-turn analysis (transcript-guided) The WAV is segmented using start_time/end_time from a transcript CSV, features are computed per segment, and (optionally) per-segment rows are aggregated via group_by (e.g., one row per speaker).

Two artifacts can be written: • Summary CSV (always): means/SDs/ranges of framewise series; silence ratio; jitter/shimmer; MFCC means/variances; CPP (if available); optional tremor/glottal. With a transcript and group_by, the summary is aggregated per group. • Framewise CSV (default): one row per short-time frame (f0, F1–F4, loudness, HNR). Disable with include_framewise=False or set a custom path.

Parameters:

Name Type Description Default
wav_path str or Path

Path to a WAV file (mono or stereo, PCM). Required for both whole-file and per-turn modes. If both wav_path and transcript_csv are None, a ValueError is raised.

None
transcript_csv str or Path

Path to a transcript CSV with at least start_time, end_time (and typically speaker). Intervals with non-positive duration are skipped. When provided, per-turn analysis is performed.

None
time_unit ('ms', 's')

Units for start_time and end_time in transcript_csv.

"ms"
group_by sequence of str

Column names from transcript_csv used to aggregate per-turn summaries into higher-level rows (e.g., ["speaker"]). If omitted, per-turn rows are written without aggregation.

None
extra_id_cols sequence of str

Identifier/metadata columns to pass through when present (and to use as grouping keys where applicable). These are not numerically aggregated.

("source", "speaker")
out_dir str or Path

Base directory for outputs if file paths are not given. Defaults to ./features/acoustics (created if missing).

None
out_framewise_csv str or Path

Path for the framewise CSV. If omitted and include_framewise=True, defaults to <out_dir>/<stem>_framewise.csv.

None
out_summary_csv str or Path

Path for the summary CSV. If omitted, defaults to <out_dir>/<stem>_summary.csv (or an equivalent name in per-turn mode).

None
overwrite_existing bool

If False and an output already exists, returns existing paths without recomputation. If True, outputs are recomputed and overwritten.

False
include_framewise bool

If True, also write the framewise table. Set to False to write only the summary.

True
mode ('simple', 'tremor', 'advanced')

Feature families to compute: - "simple": framewise f0, formants (F1–F4), loudness, HNR; summary stats; silence ratio; jitter/shimmer; MFCC means/variances; CPP (if available). - "tremor": everything in simple plus tremor metrics via a Praat script (requires tremor_script). - "advanced": everything in tremor plus glottal features (requires DisVoice and dependencies).

"simple"
summarize_on_voiced_segments_ms int or None

If an integer, summary statistics for framewise series are computed only on voiced segments whose duration is at least this many milliseconds. If None, all frames are used.

100
f0_min float

Minimum fundamental frequency (Hz) for pitch tracking. Out-of-range f0 values are treated as unvoiced (0 in framewise; excluded from voiced summaries).

75.0
f0_max float

Maximum fundamental frequency (Hz) for pitch tracking.

500.0
n_mfcc int

Number of MFCC coefficients to summarize (means and variances).

14
tremor_script str or Path

Path to a Praat tremor script. Required when mode in {"tremor","advanced"}.

None
preprocess bool

If True (whole-file mode), resample to target_sr, optionally remove DC offset (remove_dc), and normalize level toward target_dbfs with headroom protection. In per-turn mode, slices are analyzed with consistent parameters and are not re-normalized per slice.

True
target_sr int

Target sample rate for preprocessing (whole-file mode).

44100
target_dbfs float

Target loudness (dBFS) for level normalization (whole-file mode).

-20.0
remove_dc bool

If True, attempt to remove DC offset during preprocessing (whole-file mode).

True
pause_top_db int

Non-silence threshold for pause detection (higher → fewer speech segments). Passed to librosa.effects.split.

30
pause_frame_length int

Frame length (samples) for pause detection.

2048
pause_hop_length int

Hop length (samples) for pause detection.

512
verbose bool

Print what the step is doing. The pipeline runner turns this off under its live display; without the parameter the skip-path message landed in the middle of that display.

True
on_progress callable

on_progress(done, total, message). Per-turn analysis is the longest CPU-only step in a conversation pipeline, and it reported nothing while it ran; now every measured turn ticks.

None

Returns:

Type Description
dict

Mapping with: {"framewise_csv": pathlib.Path or None, "summary_csv": pathlib.Path}.

Raises:

Type Description
ValueError

If neither wav_path nor transcript_csv is provided; if transcript_csv is provided without wav_path; if required transcript columns are missing; or if mode requires unavailable dependencies (e.g., tremor_script for "tremor", DisVoice for "advanced").

FileNotFoundError

If provided paths do not exist.

RuntimeError

If feature extraction fails due to decoding errors, invalid audio, or downstream library issues.

Notes

Framewise CSV (written when include_framewise=True)
One row per short-time frame with: frame_index, time_s, f0_hz, f1_hz–f4_hz, loudness_db, hnr_db. In per-turn mode, also includes segment_index, start_s, end_s, and any extra_id_cols present.

Summary CSV (always written)
Whole-file: one row.
Per-turn (no group_by): one row per interval.
Per-turn with group_by: one row per group (e.g., per speaker).
Columns include summary stats of framewise series (on all frames or voiced segments ≥ summarize_on_voiced_segments_ms), silence ratio, jitter/shimmer variants, MFCC means/variances, CPP (if available), optional tremor/glottal metrics, and any extra_id_cols/group_by columns.

Performance

Per-turn analysis can be I/O intensive for long files with dense transcripts. Tremor/glottal metrics are substantially more expensive than simple mode.

Examples:

Whole-file analysis with framewise output:

>>> analyze_acoustics(
...     wav_path="session.wav",
...     out_dir="features/acoustics",
... )

Per-turn analysis aggregated by speaker:

>>> analyze_acoustics(
...     wav_path="session.wav",
...     transcript_csv="transcripts/session.csv",
...     time_unit="ms",
...     group_by=["speaker"],
...     extra_id_cols=["source", "speaker"],
...     out_summary_csv="features/acoustics/session_by_speaker.csv",
...     summarize_on_voiced_segments_ms=100,
...     mode="simple",
... )
Source code in src\taters\audio\analyze_vocal_acoustics.py
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
def analyze_acoustics(
    *,
    # Inputs (choose ONE of these two paths)
    wav_path: Optional[Union[str, Path]] = None,
    transcript_csv: Optional[Union[str, Path]] = None,  # if provided, we do per-turn analysis
    # Transcript options
    time_unit: Literal["ms","s"] = "ms",
    group_by: Optional[Sequence[str]] = None,       # e.g., ["speaker"]
    extra_id_cols: Sequence[str] = ("source","speaker"),
    # Output
    out_dir: Optional[Union[str, Path]] = None,
    out_framewise_csv: Optional[Union[str, Path]] = None,
    out_summary_csv: Optional[Union[str, Path]] = None,
    overwrite_existing: bool = False,
    include_framewise: bool = True,
    # Analysis options
    mode: Mode = "simple",
    summarize_on_voiced_segments_ms: Optional[int] = 100,
    f0_min: float = 75.0,
    f0_max: float = 500.0,
    n_mfcc: int = 14,
    tremor_script: Optional[Union[str, Path]] = None,
    # preprocessing controls
    preprocess: bool = True,
    target_sr: int = 44100,
    target_dbfs: float = -20.0,
    remove_dc: bool = True,
    # VAD/"pause" tuning
    pause_top_db: int = 30,
    pause_frame_length: int = 2048,
    pause_hop_length: int = 512,
    verbose: bool = True,
    on_progress: Optional[Callable[..., None]] = None,
) -> Dict[str, Optional[Path]]:
    """
    Extract acoustic features and write a summary CSV and (by default) a framewise CSV.

    This function computes a battery of speech/voice features using
    Praat/Parselmouth-style workflows with optional cepstral, tremor, and glottal
    measures. It supports two operating modes:

    1) Whole-file analysis
       Features are derived across the entire WAV. Summary statistics can be
       restricted to voiced segments longer than a threshold.

    2) Per-turn analysis (transcript-guided)
       The WAV is segmented using `start_time`/`end_time` from a transcript CSV,
       features are computed per segment, and (optionally) per-segment rows are
       aggregated via `group_by` (e.g., one row per speaker).

    Two artifacts can be written:
      • Summary CSV (always): means/SDs/ranges of framewise series; silence ratio;
        jitter/shimmer; MFCC means/variances; CPP (if available); optional tremor/glottal.
        With a transcript and `group_by`, the summary is aggregated per group.
      • Framewise CSV (default): one row per short-time frame (f0, F1–F4, loudness, HNR).
        Disable with `include_framewise=False` or set a custom path.

    Parameters
    ----------
    wav_path : str or pathlib.Path, optional
        Path to a WAV file (mono or stereo, PCM). Required for both whole-file
        and per-turn modes. If both `wav_path` and `transcript_csv` are ``None``,
        a ``ValueError`` is raised.
    transcript_csv : str or pathlib.Path, optional
        Path to a transcript CSV with at least `start_time`, `end_time` (and
        typically `speaker`). Intervals with non-positive duration are skipped.
        When provided, per-turn analysis is performed.
    time_unit : {"ms", "s"}, default "ms"
        Units for `start_time` and `end_time` in `transcript_csv`.
    group_by : sequence of str, optional
        Column names from `transcript_csv` used to aggregate per-turn summaries
        into higher-level rows (e.g., `["speaker"]`). If omitted, per-turn rows
        are written without aggregation.
    extra_id_cols : sequence of str, default ("source", "speaker")
        Identifier/metadata columns to pass through when present (and to use as
        grouping keys where applicable). These are not numerically aggregated.
    out_dir : str or pathlib.Path, optional
        Base directory for outputs if file paths are not given. Defaults to
        ``./features/acoustics`` (created if missing).
    out_framewise_csv : str or pathlib.Path, optional
        Path for the framewise CSV. If omitted and `include_framewise=True`,
        defaults to ``<out_dir>/<stem>_framewise.csv``.
    out_summary_csv : str or pathlib.Path, optional
        Path for the summary CSV. If omitted, defaults to
        ``<out_dir>/<stem>_summary.csv`` (or an equivalent name in per-turn mode).
    overwrite_existing : bool, default False
        If ``False`` and an output already exists, returns existing paths without
        recomputation. If ``True``, outputs are recomputed and overwritten.
    include_framewise : bool, default True
        If ``True``, also write the framewise table. Set to ``False`` to write
        only the summary.
    mode : {"simple", "tremor", "advanced"}, default "simple"
        Feature families to compute:
          - ``"simple"``: framewise f0, formants (F1–F4), loudness, HNR; summary stats;
            silence ratio; jitter/shimmer; MFCC means/variances; CPP (if available).
          - ``"tremor"``: everything in *simple* plus tremor metrics via a Praat script
            (requires `tremor_script`).
          - ``"advanced"``: everything in *tremor* plus glottal features (requires
            DisVoice and dependencies).
    summarize_on_voiced_segments_ms : int or None, default 100
        If an integer, summary statistics for framewise series are computed only
        on voiced segments whose duration is at least this many milliseconds.
        If ``None``, all frames are used.
    f0_min : float, default 75.0
        Minimum fundamental frequency (Hz) for pitch tracking. Out-of-range f0
        values are treated as unvoiced (0 in framewise; excluded from voiced summaries).
    f0_max : float, default 500.0
        Maximum fundamental frequency (Hz) for pitch tracking.
    n_mfcc : int, default 14
        Number of MFCC coefficients to summarize (means and variances).
    tremor_script : str or pathlib.Path, optional
        Path to a Praat tremor script. Required when ``mode in {"tremor","advanced"}``.
    preprocess : bool, default True
        If ``True`` (whole-file mode), resample to `target_sr`, optionally remove
        DC offset (`remove_dc`), and normalize level toward `target_dbfs` with
        headroom protection. In per-turn mode, slices are analyzed with consistent
        parameters and are not re-normalized per slice.
    target_sr : int, default 44100
        Target sample rate for preprocessing (whole-file mode).
    target_dbfs : float, default -20.0
        Target loudness (dBFS) for level normalization (whole-file mode).
    remove_dc : bool, default True
        If ``True``, attempt to remove DC offset during preprocessing (whole-file mode).
    pause_top_db : int, default 30
        Non-silence threshold for pause detection (higher → fewer speech segments).
        Passed to ``librosa.effects.split``.
    pause_frame_length : int, default 2048
        Frame length (samples) for pause detection.
    pause_hop_length : int, default 512
        Hop length (samples) for pause detection.
    verbose : bool, default True
        Print what the step is doing. The pipeline runner turns this off
        under its live display; without the parameter the skip-path message
        landed in the middle of that display.
    on_progress : callable, optional
        ``on_progress(done, total, message)``. Per-turn analysis is the
        longest CPU-only step in a conversation pipeline, and it reported
        nothing while it ran; now every measured turn ticks.

    Returns
    -------
    dict
        Mapping with:
        ``{"framewise_csv": pathlib.Path or None, "summary_csv": pathlib.Path}``.

    Raises
    ------
    ValueError
        If neither `wav_path` nor `transcript_csv` is provided; if `transcript_csv`
        is provided without `wav_path`; if required transcript columns are missing; or
        if `mode` requires unavailable dependencies (e.g., `tremor_script` for
        ``"tremor"``, DisVoice for ``"advanced"``).
    FileNotFoundError
        If provided paths do not exist.
    RuntimeError
        If feature extraction fails due to decoding errors, invalid audio, or
        downstream library issues.

    Notes
    -----
    **Framewise CSV** (written when `include_framewise=True`)  
    One row per short-time frame with: `frame_index`, `time_s`, `f0_hz`,
    `f1_hz`–`f4_hz`, `loudness_db`, `hnr_db`. In per-turn mode, also includes
    `segment_index`, `start_s`, `end_s`, and any `extra_id_cols` present.

    **Summary CSV** (always written)  
    Whole-file: one row.  
    Per-turn (no `group_by`): one row per interval.  
    Per-turn with `group_by`: one row per group (e.g., per speaker).  
    Columns include summary stats of framewise series (on all frames or voiced
    segments ≥ `summarize_on_voiced_segments_ms`), silence ratio, jitter/shimmer
    variants, MFCC means/variances, CPP (if available), optional tremor/glottal
    metrics, and any `extra_id_cols`/`group_by` columns.

    Performance
    -----------
    Per-turn analysis can be I/O intensive for long files with dense transcripts.
    Tremor/glottal metrics are substantially more expensive than *simple* mode.

    Examples
    --------
    Whole-file analysis with framewise output:

    >>> analyze_acoustics(
    ...     wav_path="session.wav",
    ...     out_dir="features/acoustics",
    ... )

    Per-turn analysis aggregated by speaker:

    >>> analyze_acoustics(
    ...     wav_path="session.wav",
    ...     transcript_csv="transcripts/session.csv",
    ...     time_unit="ms",
    ...     group_by=["speaker"],
    ...     extra_id_cols=["source", "speaker"],
    ...     out_summary_csv="features/acoustics/session_by_speaker.csv",
    ...     summarize_on_voiced_segments_ms=100,
    ...     mode="simple",
    ... )
    """

    if mode in ("tremor", "advanced") and not tremor_script:
        # we check this before touching any audio. the docstring has always
        # said the script is required for these modes, but the per-clip code
        # used to quietly return the simple set instead: someone who picked
        # "tremor" got no tremor columns and not a word about it, after the
        # whole run.
        raise ValueError(
            f"mode={mode!r} needs tremor_script=<path to a Praat tremor "
            f"script>; without one there are no tremor metrics to compute. "
            f"Use mode='simple' or supply the script.")
    if wav_path is None:
        raise ValueError("wav_path is required")

    wav_path = Path(wav_path)
    if out_dir is None:
        out_dir = Path("features") / "acoustics"
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # default output paths
    stem = wav_path.stem
    if out_framewise_csv is None:
        out_framewise_csv = out_dir / f"{stem}_framewise.csv"
    else:
        out_framewise_csv = Path(out_framewise_csv)

    if out_summary_csv is None:
        suffix = "_by_" + "_".join(group_by) if (transcript_csv and group_by) else ""
        out_summary_csv = out_dir / f"{stem}_summary{suffix}.csv"
    else:
        out_summary_csv = Path(out_summary_csv)

    # if it's already there and we weren't told to overwrite, we're done
    if (not overwrite_existing) and out_summary_csv.exists():
        if verbose:
            print(f"[acoustics] Summary output already exists; returning "
                  f"existing file: {out_summary_csv}")
        return {"framewise_csv": out_framewise_csv if out_framewise_csv.exists() else None,
                "summary_csv": out_summary_csv}

    # this is where the magic happens
    if transcript_csv:
        framewise_df, summary_df = _analyze_turns(
            wav_path=wav_path,
            transcript_csv=transcript_csv,
            time_unit=time_unit,
            group_by=group_by,
            extra_id_cols=extra_id_cols,
            mode=mode,
            summarize_on_voiced_segments_ms=summarize_on_voiced_segments_ms,
            include_framewise=include_framewise,
            tremor_script=tremor_script,
            f0_min=f0_min,
            f0_max=f0_max,
            n_mfcc=n_mfcc,
            preprocess=preprocess,
            target_sr=target_sr,
            target_dbfs=target_dbfs,
            remove_dc=remove_dc,
            pause_top_db=pause_top_db,
            pause_frame_length=pause_frame_length,
            pause_hop_length=pause_hop_length,
            on_progress=on_progress,
        )
    else:
        announce(on_progress, "measuring the recording")
        fdf, summ = _analyze_clip(
            wav_path,
            mode=mode,
            summarize_on_voiced_segments_ms=summarize_on_voiced_segments_ms,
            include_framewise=include_framewise,
            tremor_script=tremor_script,
            f0_min=f0_min,
            f0_max=f0_max,
            n_mfcc=n_mfcc,
            preprocess=preprocess,
            target_sr=target_sr,
            target_dbfs=target_dbfs,
            remove_dc=remove_dc,
            pause_top_db=pause_top_db,
            pause_frame_length=pause_frame_length,
            pause_hop_length=pause_hop_length,
        )
        # build the summary DF; extra_id_cols could ride along here later if we
        # ever pull them out of the filename
        summary_df = pd.DataFrame([summ])
        framewise_df = fdf

    # write the outputs -- atomically, like every other skip-if-exists table: a
    # Ctrl-C mid-write used to leave a truncated summary behind that the next
    # run then handed back as finished.
    frame_path_out: Optional[Path] = None
    if include_framewise and framewise_df is not None:
        with atomic_write(out_framewise_csv, mode="w", newline="",
                          encoding="utf-8-sig") as fh:
            framewise_df.to_csv(fh, index=False)
        frame_path_out = out_framewise_csv

    with atomic_write(out_summary_csv, mode="w", newline="",
                      encoding="utf-8-sig") as fh:
        summary_df.to_csv(fh, index=False)

    return {"framewise_csv": frame_path_out, "summary_csv": out_summary_csv}

High-level, environment-safe wrapper for exporting Whisper encoder embeddings.

This module provides a single entry point, :func:extract_whisper_embeddings, which (by default) launches a subprocess to extract embeddings using a dedicated worker module. The subprocess approach avoids CUDA/Torch collisions with other parts of your pipeline.

Two modes are supported:

1) Transcript-driven mode Pass transcript_csv to compute one embedding vector per transcript row (e.g., per diarized segment). The output is a CSV with columns start_time,end_time,speaker,e0..e{D-1}.

2) General-audio mode Omit transcript_csv to analyze the raw WAV. You can segment by fixed windows or by non-silent regions; optionally aggregate to a single mean row.

extract_whisper_embeddings

extract_whisper_embeddings(
    *,
    source_wav,
    transcript_csv=None,
    time_unit="auto",
    strategy="windows",
    window_s=30.0,
    hop_s=15.0,
    min_seg_s=1.0,
    top_db=30.0,
    aggregate="none",
    output_dir=None,
    overwrite_existing=False,
    model_name="base",
    device="auto",
    compute_type="float16",
    run_in_subprocess=True,
    extra_env=None,
    verbose=True,
    extractor_module="taters.audio.extract_whisper_embeddings_subproc"
)

Export Whisper encoder embeddings to a CSV file, using a subprocess by default.

Parameters:

Name Type Description Default
source_wav str | Path

Path to the input WAV. Must be readable by librosa.

required
transcript_csv str | Path | None

If provided, enables transcript-driven mode. The CSV is expected to contain timestamp columns and (optionally) a speaker column. A row is emitted per transcript segment.

None
time_unit ('auto', 'ms', 's', 'samples')

How to interpret timestamps in transcript_csv. In "auto", the worker heuristically infers the unit from max end time vs audio duration.

"auto","ms","s","samples"
strategy ('windows', 'nonsilent')

General-audio mode only. "windows" uses fixed sized windows with overlap; "nonsilent" uses an energy-based splitter (librosa.effects.split).

"windows","nonsilent"
window_s float

General-audio mode only. Window length and hop (seconds).

30.0, 15.0
hop_s float

General-audio mode only. Window length and hop (seconds).

30.0, 15.0
min_seg_s float

General-audio mode only. Skip segments shorter than this many seconds.

1.0
top_db float

General-audio mode only ("nonsilent"). Threshold (dB) below reference to consider as silence. Smaller → more segments; larger → fewer.

30.0
aggregate ('none', 'mean')

General-audio mode only. If "mean", a single pooled row is written covering the entire file; otherwise one row per segment.

"none","mean"
output_dir str | Path | None

Directory for the output CSV. If None, defaults to ./features/whisper-embeddings.

None
model_name ('tiny', 'tiny.en', 'base', 'base.en', 'small', 'small.en', 'medium', 'medium.en', 'large-v2', 'large-v3', 'large-v3-turbo', 'distill-large-v3')

Model identifier passed through to the worker: a faster-whisper model name, or the path of a local CTranslate2 model directory.

"tiny"
device ('auto', 'cuda', 'cpu')

Runtime device. If "cpu", environment variables are set to disable CUDA in the child process.

"auto","cuda","cpu"
compute_type ('int8', 'int8_float16', 'int8_bfloat16', 'int16', 'float16', 'bfloat16', 'float32')

CTranslate2 compute type, passed to the worker module.

"int8"
run_in_subprocess bool

If True (recommended), runs extraction in a separate Python process to isolate Torch/CUDA state from the parent process.

True
extra_env dict | None

Additional environment variables to inject into the child process.

None
verbose bool

If True, print the launched command and the child's stdout.

True
extractor_module str

Dotted module path whose __main__ implements the extractor CLI.

"chopshop.audio.extract_whisper_embeddings_subproc"
overwrite_existing bool

If False and the output already exists, skip the work and return the existing path.

False

Returns:

Type Description
Path

Path to the written embeddings CSV. Pattern: <output_dir>/<source_stem>_embeddings.csv.

Notes
  • The subprocess writes and exits. The parent returns once the file exists.
  • If transcript_csv is supplied, the worker runs in transcript mode; otherwise general-audio mode is used with the given segmentation strategy.
  • Failures in the child process are re-raised with the captured stdout/stderr to ease debugging.

Examples:

Transcript per-segment embeddings:

>>> extract_whisper_embeddings(
...     source_wav="audio/session.wav",
...     transcript_csv="transcripts/session.csv",
...     time_unit="ms",
...     model_name="small",
...     device="cuda",
... )

Whole-file mean embedding:

>>> extract_whisper_embeddings(
...     source_wav="audio/session.wav",
...     strategy="nonsilent",
...     aggregate="mean",
...     output_dir="features/whisper-embeddings",
... )
Source code in src\taters\audio\extract_whisper_embeddings.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def extract_whisper_embeddings(
    *,
    # required
    source_wav: Union[str, Path],

    # optional transcript-driven mode
    transcript_csv: Optional[Union[str, Path]] = None,
    time_unit: Literal["auto", "ms", "s", "samples"] = "auto",

    # general-audio mode (used when transcript_csv is None)
    strategy: Literal["windows", "nonsilent"] = "windows",
    window_s: float = 30.0,
    hop_s: float = 15.0,
    min_seg_s: float = 1.0,
    top_db: float = 30.0,
    aggregate: Literal["none", "mean"] = "none",

    # outputs
    output_dir: Optional[Union[str, Path]] = None,
    overwrite_existing: bool = False,  # if the file already exists, let's not overwrite by default

    # model/runtime
    model_name: str = "base",
    device: Literal["auto", "cuda", "cpu"] = "auto",
    compute_type: str = "float16",

    # execution strategy
    run_in_subprocess: bool = True,
    extra_env: Optional[dict] = None,
    verbose: bool = True,

    # where the extractor lives (python -m <module>)
    extractor_module: str = "taters.audio.extract_whisper_embeddings_subproc",
) -> Path:
    """
    Export Whisper encoder embeddings to a CSV file, using a subprocess by default.

    Parameters
    ----------
    source_wav : str | Path
        Path to the input WAV. Must be readable by `librosa`.
    transcript_csv : str | Path | None, optional
        If provided, enables transcript-driven mode. The CSV is expected to contain
        timestamp columns and (optionally) a speaker column. A row is emitted per
        transcript segment.
    time_unit : {"auto","ms","s","samples"}, default "auto"
        How to interpret timestamps in `transcript_csv`. In "auto", the worker
        heuristically infers the unit from max end time vs audio duration.
    strategy : {"windows","nonsilent"}, default "windows"
        General-audio mode only. "windows" uses fixed sized windows with overlap;
        "nonsilent" uses an energy-based splitter (librosa.effects.split).
    window_s, hop_s : float, default 30.0, 15.0
        General-audio mode only. Window length and hop (seconds).
    min_seg_s : float, default 1.0
        General-audio mode only. Skip segments shorter than this many seconds.
    top_db : float, default 30.0
        General-audio mode only ("nonsilent"). Threshold (dB) below reference to
        consider as silence. Smaller → more segments; larger → fewer.
    aggregate : {"none","mean"}, default "none"
        General-audio mode only. If "mean", a single pooled row is written covering
        the entire file; otherwise one row per segment.
    output_dir : str | Path | None, optional
        Directory for the output CSV. If None, defaults to
        ``./features/whisper-embeddings``.
    model_name : {"tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large-v2", "large-v3", "large-v3-turbo", "distill-large-v3"} or str, default "base"
        Model identifier passed through to the worker: a faster-whisper model
        name, or the path of a local CTranslate2 model directory.
    device : {"auto","cuda","cpu"}, default "auto"
        Runtime device. If "cpu", environment variables are set to disable CUDA
        in the child process.
    compute_type : {"int8", "int8_float16", "int8_bfloat16", "int16", "float16", "bfloat16", "float32"}, default "float16"
        CTranslate2 compute type, passed to the worker module.
    run_in_subprocess : bool, default True
        If True (recommended), runs extraction in a separate Python process to
        isolate Torch/CUDA state from the parent process.
    extra_env : dict | None, optional
        Additional environment variables to inject into the child process.
    verbose : bool, default True
        If True, print the launched command and the child's stdout.
    extractor_module : str, default "chopshop.audio.extract_whisper_embeddings_subproc"
        Dotted module path whose ``__main__`` implements the extractor CLI.

    overwrite_existing : bool, default=False
        If ``False`` and the output already exists, skip the work and return
        the existing path.
    Returns
    -------
    Path
        Path to the written embeddings CSV. Pattern:
        ``<output_dir>/<source_stem>_embeddings.csv``.

    Notes
    -----
    - The subprocess writes and exits. The parent returns once the file exists.
    - If `transcript_csv` is supplied, the worker runs in transcript mode; otherwise
      general-audio mode is used with the given segmentation strategy.
    - Failures in the child process are re-raised with the captured stdout/stderr
      to ease debugging.

    Examples
    --------
    Transcript per-segment embeddings:

    >>> extract_whisper_embeddings(
    ...     source_wav="audio/session.wav",
    ...     transcript_csv="transcripts/session.csv",
    ...     time_unit="ms",
    ...     model_name="small",
    ...     device="cuda",
    ... )

    Whole-file mean embedding:

    >>> extract_whisper_embeddings(
    ...     source_wav="audio/session.wav",
    ...     strategy="nonsilent",
    ...     aggregate="mean",
    ...     output_dir="features/whisper-embeddings",
    ... )
    """

    source_wav = Path(source_wav).resolve()
    # no output dir given? we default to ./features/whisper-embeddings
    out_dir_final = (
        Path(output_dir).resolve()
        if output_dir
        else (Path.cwd() / "features" / "whisper-embeddings")
    )

    out_dir_final.mkdir(parents=True, exist_ok=True)
    output_csv = out_dir_final / f"{source_wav.stem}_embeddings.csv"

    if not overwrite_existing and Path(output_csv).is_file():
        print("Whisper embedding feature output file already exists; returning existing file.")
        return output_csv

    if not run_in_subprocess:
        # ---- in-process path (only if you're sure there are no Torch/CUDA clashes) --
        from .extract_whisper_embeddings_subproc import (  # type: ignore
            export_segment_embeddings_csv,
            export_audio_embeddings_csv,
            EmbedConfig,
        )
        cfg = EmbedConfig(model_name=model_name, device=device, compute_type=compute_type, time_unit=time_unit)
        if transcript_csv is not None:
            transcript_csv = Path(transcript_csv).resolve()
            return Path(
                export_segment_embeddings_csv(
                    transcript_csv=transcript_csv,
                    source_wav=source_wav,
                    output_dir=out_dir_final,
                    config=cfg,
                )
            )
        else:
            return Path(
                export_audio_embeddings_csv(
                    source_wav=source_wav,
                    output_dir=out_dir_final,
                    config=cfg,
                    strategy=strategy,
                    window_s=window_s,
                    hop_s=hop_s,
                    min_seg_s=min_seg_s,
                    top_db=top_db,
                    aggregate=aggregate,
                )
            )

    # ---- subprocess path (the one we recommend) ----
    env = os.environ.copy()
    # keep Transformers from dragging in the heavy backends in the child
    env.setdefault("TRANSFORMERS_NO_TORCH", "1")
    env.setdefault("TRANSFORMERS_NO_TF", "1")
    env.setdefault("TRANSFORMERS_NO_FLAX", "1")

    if extra_env:
        env.update({k: str(v) for k, v in extra_env.items()})

    if device == "cpu":
        # make sure the child doesn't go trying CUDA on us
        env.update({"CUDA_VISIBLE_DEVICES": "", "USE_CUDA": "0", "FORCE_CPU": "1"})
    else:
        # best effort: if the cuDNN wheel is installed, put its lib dir up front
        try:
            import pathlib

            import nvidia.cudnn  # type: ignore
            cudnn_lib = str(pathlib.Path(nvidia.cudnn.__file__).with_name("lib"))
            env["LD_LIBRARY_PATH"] = cudnn_lib + ":" + env.get("LD_LIBRARY_PATH", "")
        except Exception:
            pass

    cmd = [
        sys.executable, "-m", extractor_module,
        "--source_wav", str(source_wav),
        "--output_dir", str(out_dir_final),
        "--model_name", model_name,
        "--device", device,
        "--compute_type", compute_type,
    ]

    if transcript_csv is not None:
        transcript_csv = Path(transcript_csv).resolve()
        cmd += ["--transcript_csv", str(transcript_csv), "--time_unit", time_unit]
    else:
        cmd += [
            "--strategy", strategy,
            "--window_s", str(window_s),
            "--hop_s", str(hop_s),
            "--min_seg_s", str(min_seg_s),
            "--top_db", str(top_db),
            "--aggregate", aggregate,
        ]

    if verbose:
        print("Launching embedding subprocess:")
        print(" ", shlex.join(cmd))

    # stream output as it runs so long extractions aren't silent; we hang onto
    # the tail for the error message if it fails.
    returncode, tail = run_and_stream(
        cmd,
        env=env,
        prefix=f"[whisper-embed:{source_wav.stem}] ",
        stream=verbose,
    )
    if returncode != 0:
        raise RuntimeError(
            f"Embedding subprocess failed with code {returncode}\n"
            f"CMD: {shlex.join(cmd)}\n"
            f"Last output:\n{tail.strip()}"
        )

    if not output_csv.exists():
        raise FileNotFoundError(f"Expected embeddings CSV not found: {output_csv}")

    if verbose:
        print(f"Embeddings CSV written to: {output_csv}")

    return output_csv

Subprocess worker that computes Whisper encoder embeddings.

This module is meant to be executed with python -m ... by the wrapper in extract_whisper_embeddings.py. It avoids importing heavyweight torch packages in the parent process and keeps CUDA state isolated.

Two entry functions implement I/O and shape-handling:

  • :func:export_segment_embeddings_csv — transcript-driven, one vector per row.
  • :func:export_audio_embeddings_csv — general WAVs; segmentation + optional pooling.

Both functions use faster-whisper (CTranslate2 backend) and WhisperFeatureExtractor to produce encoder features, then pool the encoder outputs into fixed-length vectors.

export_audio_embeddings_csv

export_audio_embeddings_csv(
    source_wav,
    output_dir=None,
    *,
    config=EmbedConfig(),
    sr=16000,
    strategy="windows",
    window_s=30.0,
    hop_s=15.0,
    min_seg_s=1.0,
    top_db=30.0,
    apply_l2_normalization=False,
    aggregate="none"
)

Compute Whisper encoder embeddings for an arbitrary WAV (no transcript).

Parameters:

Name Type Description Default
source_wav str | Path

Input audio (any format librosa can read).

required
output_dir str | Path | None

Directory for the output CSV. Defaults to the WAV's parent if None.

None
config (EmbedConfig, keyword - only)

Model/device/compute configuration.

EmbedConfig()
sr int

Resample rate used by the feature extractor.

16000
strategy ('windows', 'nonsilent')
  • "windows": fixed windows with hop (overlap allowed).
  • "nonsilent": energy-based voice activity detection via librosa.
"windows","nonsilent"
window_s float

Window length and hop size (seconds). Used by both strategies.

30.0
hop_s float

Window length and hop size (seconds). Used by both strategies.

30.0
min_seg_s float

Discard segments shorter than this length (seconds).

1.0
top_db float

Silence threshold for "nonsilent". Higher → fewer segments.

30.0
aggregate ('none', 'mean')

If "mean", write a single pooled vector over the whole file.

"none","mean"

Returns:

Type Description
Path

CSV path: <output_dir>/<wav_stem>_embeddings.csv.

Notes
  • When aggregate="none", rows are start_time,end_time,SEGMENT_i,e0...
  • When aggregate="mean", a single row 0.000,<dur>,GLOBAL_MEAN,e0.. is written.
Source code in src\taters\audio\extract_whisper_embeddings_subproc.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def export_audio_embeddings_csv(
    source_wav: str | Path,
    output_dir: Optional[str | Path] = None,
    *,
    config: EmbedConfig = EmbedConfig(),
    sr: int = 16000,
    strategy: Literal["windows", "nonsilent"] = "windows",
    window_s: float = 30.0,
    hop_s: float = 15.0,
    min_seg_s: float = 1.0,
    top_db: float = 30.0,
    apply_l2_normalization: bool = False,
    aggregate: Literal["none", "mean"] = "none",
) -> Path:
    """
    Compute Whisper encoder embeddings for an arbitrary WAV (no transcript).

    Parameters
    ----------
    source_wav : str | Path
        Input audio (any format `librosa` can read).
    output_dir : str | Path | None, optional
        Directory for the output CSV. Defaults to the WAV's parent if None.
    config : EmbedConfig, keyword-only
        Model/device/compute configuration.
    sr : int, default 16000
        Resample rate used by the feature extractor.
    strategy : {"windows","nonsilent"}, default "windows"
        - "windows": fixed windows with hop (overlap allowed).
        - "nonsilent": energy-based voice activity detection via librosa.
    window_s, hop_s : float
        Window length and hop size (seconds). Used by both strategies.
    min_seg_s : float
        Discard segments shorter than this length (seconds).
    top_db : float
        Silence threshold for "nonsilent". Higher → fewer segments.
    aggregate : {"none","mean"}, default "none"
        If "mean", write a single pooled vector over the whole file.

    Returns
    -------
    Path
        CSV path: ``<output_dir>/<wav_stem>_embeddings.csv``.

    Notes
    -----
    - When `aggregate="none"`, rows are ``start_time,end_time,SEGMENT_i,e0..``.
    - When `aggregate="mean"`, a single row ``0.000,<dur>,GLOBAL_MEAN,e0..`` is written.
    """

    source_wav = Path(source_wav)
    if output_dir is None:
        output_dir = source_wav.parent
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    out_csv = output_dir / f"{source_wav.stem}_embeddings.csv"

    # 1) load the audio
    y, in_sr = librosa.load(str(source_wav), sr=sr, mono=True)
    n = len(y)
    if n == 0:
        # nothing to do, so we write a header-only file and bail
        with out_csv.open("w", encoding="utf-8", newline="") as f:
            csv.writer(f).writerow(["start_time", "end_time", "speaker"])
        return out_csv

    # 2) load faster-whisper + ct2 + the feature extractor (same as transcript mode)
    fw, resolved_device, resolved_compute = _load_whisper(config)
    try:
        ct2_model: ctranslate2.models.Whisper = fw.model  # type: ignore[attr-defined]
    except AttributeError:
        model_dir = getattr(fw, "model_dir", None) or getattr(fw, "_model_dir", None)
        if not model_dir:
            raise RuntimeError(
                "Could not access the underlying CTranslate2 model from faster-whisper. "
                "Consider passing a local CTranslate2 model directory as model_name."
            )
        ct2_model = ctranslate2.models.Whisper(str(model_dir), device=resolved_device,
                                              compute_type=resolved_compute)

    fe = WhisperFeatureExtractor.from_pretrained(_hf_repo_for(config.model_name))

    # 3) build up the segments (in samples)
    segs: list[tuple[int, int]] = []
    win = max(1, int(round(window_s * sr)))
    hop = max(1, int(round(hop_s * sr)))
    min_len = max(1, int(round(min_seg_s * sr)))

    if strategy == "windows":
        if n <= win:
            segs = [(0, n)]
        else:
            s = 0
            while s < n:
                e = min(n, s + win)
                segs.append((s, e))
                if e >= n:
                    break
                s += hop
    elif strategy == "nonsilent":
        # basic energy-based VAD; no torch needed, and it's fast
        intervals = librosa.effects.split(y, top_db=top_db)
        for s, e in intervals:
            if e - s < min_len:
                continue
            # chop very long spans down into ~window_s chunks
            cur = s
            while cur < e:
                nxt = min(e, cur + win)
                if nxt - cur >= min_len:
                    segs.append((cur, nxt))
                cur = nxt
        if not segs:
            # found nothing? then the whole file is one segment
            segs = [(0, n)]
    else:
        raise ValueError("strategy must be 'windows' or 'nonsilent'")

    # 4) encode each segment
    rows_out: list[list[Any]] = []
    embed_dim: Optional[int] = None
    vectors: list[np.ndarray] = []

    for i, (s, e) in enumerate(segs):
        clip = y[s:e]
        feats = fe(clip, sampling_rate=sr, return_tensors="np")["input_features"]
        vec = _encode_features_any_layout(ct2_model, feats)
        if vec is None:
            continue
        if embed_dim is None:
            embed_dim = int(vec.shape[-1])
        vectors.append(vec)
        # keep a row per chunk unless we're aggregating
        if aggregate == "none":
            t0 = s / float(sr)
            t1 = e / float(sr)
            rows_out.append([f"{t0:.3f}", f"{t1:.3f}", f"SEGMENT_{i}"] + vec.tolist())

    # 5) aggregate, if we were asked to
    if vectors and aggregate == "mean":
        vec = np.vstack(vectors).mean(axis=0)
        if apply_l2_normalization:
            vec = l2_normalize(vec)
        embed_dim = int(vec.shape[-1])
        rows_out = [["0.000", f"{n/float(sr):.3f}", "GLOBAL_MEAN"] + vec.tolist()]

    # 6) write the CSV (header even if we've got no rows)
    if embed_dim is None:
        header = ["start_time", "end_time", "speaker"]
    else:
        header = ["start_time", "end_time", "speaker"] + [f"e{i}" for i in range(embed_dim)]

    with out_csv.open("w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(header)
        writer.writerows(rows_out)

    if _os.environ.get("TATERS_DEBUG") == "1":
        print(f"[emb-any] segments={len(segs)}, kept={len(rows_out)}, aggregate={aggregate}")
        print(f"[emb-any] wrote: {out_csv}")

    return out_csv

export_segment_embeddings_csv

export_segment_embeddings_csv(
    transcript_csv,
    source_wav,
    output_dir=None,
    *,
    config=EmbedConfig(),
    start_col="start_time",
    end_col="end_time",
    speaker_col="speaker",
    apply_l2_normalization=False,
    sr=16000
)

Compute Whisper encoder embeddings for each transcript segment and write a CSV.

Expected transcript columns (auto-resolved with fallbacks): - start_time (or: start, from, t0, start_ms, start_sec) - end_time (or: end, to, t1, end_ms, end_sec) - speaker (optional; fallbacks include speaker_label, spk, speaker_id, ...)

Parameters:

Name Type Description Default
transcript_csv str | Path

CSV with segment timings (and optionally speaker labels).

required
source_wav str | Path

Audio file to slice. Will be resampled to sr.

required
output_dir str | Path | None

Directory for the output CSV. If None, defaults to the WAV's parent.

None
config (EmbedConfig, keyword - only)

Configuration for model name, device, compute type, and time unit.

EmbedConfig()
start_col str

Column name hints. The function will fall back to common aliases if the exact names are not present.

'start_time'
end_col str

Column name hints. The function will fall back to common aliases if the exact names are not present.

'start_time'
speaker_col str

Column name hints. The function will fall back to common aliases if the exact names are not present.

'start_time'
sr int

Sample rate for feature extraction (audio is resampled as needed).

16000

Returns:

Type Description
Path

Path to the written CSV: <output_dir>/<wav_stem>_embeddings.csv

Behavior
  • Attempts to infer time units ("s", "ms", "samples") when config.time_unit == "auto".
  • Skips invalid or tiny segments (< 2 samples after rounding).
  • Pools encoder outputs to a fixed-length vector (mean over time).
  • Writes header even if no valid segments remain (empty payload).
See Also

export_audio_embeddings_csv : transcript-free embeddings.

Source code in src\taters\audio\extract_whisper_embeddings_subproc.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
def export_segment_embeddings_csv(
    transcript_csv: str | Path,
    source_wav: str | Path,
    output_dir: Optional[str | Path] = None,
    *,
    config: EmbedConfig = EmbedConfig(),
    start_col: str = "start_time",
    end_col: str = "end_time",
    speaker_col: str = "speaker",
    apply_l2_normalization: bool = False,
    sr: int = 16000,
) -> Path:
    """
    Compute Whisper encoder embeddings for each transcript segment and write a CSV.

    Expected transcript columns (auto-resolved with fallbacks):
    - start_time (or: start, from, t0, start_ms, start_sec)
    - end_time   (or: end, to, t1, end_ms, end_sec)
    - speaker    (optional; fallbacks include speaker_label, spk, speaker_id, ...)

    Parameters
    ----------
    transcript_csv : str | Path
        CSV with segment timings (and optionally speaker labels).
    source_wav : str | Path
        Audio file to slice. Will be resampled to `sr`.
    output_dir : str | Path | None, optional
        Directory for the output CSV. If None, defaults to the WAV's parent.
    config : EmbedConfig, keyword-only
        Configuration for model name, device, compute type, and time unit.
    start_col, end_col, speaker_col : str
        Column name hints. The function will fall back to common aliases if the
        exact names are not present.
    sr : int, default 16000
        Sample rate for feature extraction (audio is resampled as needed).

    Returns
    -------
    Path
        Path to the written CSV: ``<output_dir>/<wav_stem>_embeddings.csv``

    Behavior
    --------
    - Attempts to infer time units ("s", "ms", "samples") when config.time_unit == "auto".
    - Skips invalid or tiny segments (< 2 samples after rounding).
    - Pools encoder outputs to a fixed-length vector (mean over time).
    - Writes header even if no valid segments remain (empty payload).

    See Also
    --------
    export_audio_embeddings_csv : transcript-free embeddings.
    """

    transcript_csv = Path(transcript_csv)
    source_wav = Path(source_wav)

    # figure out where the output goes (next to the WAV unless told otherwise)
    if output_dir is None:
        output_dir = source_wav.parent
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # and the final output path
    output_csv = output_dir / f"{source_wav.stem}_embeddings.csv"

    # 1) load the audio (mono, at sr)
    audio, in_sr = librosa.load(str(source_wav), sr=sr, mono=True)
    n_samples = len(audio)
    dur_s = n_samples / float(sr)

    # 2) load faster-whisper and the ct2 model
    fw, resolved_device, resolved_compute = _load_whisper(config)
    try:
        ct2_model: ctranslate2.models.Whisper = fw.model  # type: ignore[attr-defined]
    except AttributeError:
        model_dir = getattr(fw, "model_dir", None) or getattr(fw, "_model_dir", None)
        if not model_dir:
            raise RuntimeError(
                "Could not access the underlying CTranslate2 model from faster-whisper. "
                "Consider passing a local CTranslate2 model directory as model_name."
            )
        ct2_model = ctranslate2.models.Whisper(str(model_dir), device=resolved_device,
                                              compute_type=resolved_compute)

    # 3) the feature extractor
    fe = WhisperFeatureExtractor.from_pretrained(_hf_repo_for(config.model_name))

    # 4) read the transcript and work out what time unit it's in
    if not transcript_csv.exists():
        raise FileNotFoundError(f"Transcript CSV not found: {transcript_csv}")

    rows_out: list[list[Any]] = []
    embed_dim: Optional[int] = None

    # first pass: peek at the header and a few rows so we can guess the units
    with transcript_csv.open("r", encoding="utf-8", newline="") as f:
        reader = csv.DictReader(f)
        fields = reader.fieldnames or []
        sc, ec, pc = _resolve_columns(fields, start_col, end_col, speaker_col)

        # up to 100 rows is plenty to find a reasonable max end time
        sample_vals: List[float] = []
        for i, row in enumerate(reader):
            try:
                sample_vals.append(float(row[ec]))
            except Exception:
                pass
            if i >= 99:
                break

        # we'll re-open the file for the real pass
    # now we settle on a unit
    if config.time_unit not in {"auto", "ms", "s", "samples"}:
        raise ValueError("config.time_unit must be 'auto', 'ms', 's', or 'samples'")

    guessed_unit = None
    if config.time_unit == "auto":
        max_end = max(sample_vals) if sample_vals else 0.0
        guessed_unit = _guess_time_unit(max_end, dur_s, n_samples)
        unit = guessed_unit
    else:
        unit = config.time_unit

    if _os.environ.get("TATERS_DEBUG") == "1":
        print(f"[emb] audio duration: {dur_s:.3f}s @ {sr}Hz (samples={n_samples})")
        if guessed_unit:
            print(f"[emb] time unit guessed -> {guessed_unit}")
        print(f"[emb] time unit in use -> {unit}")

    # conversion lambdas: to seconds, and to a sample index
    if unit == "s":
        to_sec = lambda x: float(x)
        to_idx = lambda t: int(round(float(t) * sr))
    elif unit == "ms":
        to_sec = lambda x: float(x) * 0.001
        to_idx = lambda t: int(round(float(t) * sr * 0.001))
    elif unit == "samples":
        to_sec = lambda x: float(x) / float(sr)
        to_idx = lambda t: int(round(float(t)))
    else:
        raise RuntimeError("Unexpected time unit.")

    # the real pass
    n_total = n_parsed = n_kept = 0
    n_oob = n_too_short = n_shape_skip = 0

    with transcript_csv.open("r", encoding="utf-8", newline="") as f:
        reader = csv.DictReader(f)
        fields = reader.fieldnames or []
        sc, ec, pc = _resolve_columns(fields, start_col, end_col, speaker_col)

        for row in reader:
            n_total += 1
            try:
                t0_sec = to_sec(row[sc])
                t1_sec = to_sec(row[ec])
            except Exception:
                continue
            if not (t1_sec > t0_sec):
                continue
            n_parsed += 1

            s = max(0, min(n_samples, to_idx(row[sc])))
            e = max(0, min(n_samples, to_idx(row[ec])))
            if e <= s:
                n_oob += 1
                continue

            # slice it out; skip anything ultra tiny after rounding (< 2 samples)
            if e - s < 2:
                n_too_short += 1
                continue

            clip = audio[s:e]

            # build the input features (float32, no torch)
            feats = fe(clip, sampling_rate=sr, return_tensors="np")["input_features"]

            # encode with CT2, trying both layouts, then pool down to [D]
            vec = _encode_features_any_layout(ct2_model, feats)

            # --- purely for debugging: raw candidate shapes for the first few rows ---
            if _os.environ.get("TATERS_DEBUG") == "1" and n_parsed <= 3:
                try:
                    a = np.ascontiguousarray(feats.astype("float32", copy=False))
                    a1 = a if a.ndim == 3 else a[None, ...]
                    a2 = np.transpose(a1, (0, 2, 1))
                    print(f"[emb] feats shapes tried: {getattr(a1, 'shape', None)} and {getattr(a2, 'shape', None)}")
                except Exception:
                    pass
            # ----------------------------------------------------------------

            if vec is None:
                n_shape_skip += 1
                continue

            if apply_l2_normalization:
                vec = l2_normalize(vec)

            if embed_dim is None:
                embed_dim = int(vec.shape[-1])

            speaker = row.get(pc, "SPEAKER_0")
            rows_out.append([row[sc], row[ec], speaker] + vec.tolist())
            n_kept += 1


    # 5) write the CSV (header even if we've got no rows)
    if embed_dim is None:
        header = ["start_time", "end_time", "speaker"]
    else:
        header = ["start_time", "end_time", "speaker"] + [f"e{i}" for i in range(embed_dim)]

    with output_csv.open("w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(header)
        writer.writerows(rows_out)

    if _os.environ.get("TATERS_DEBUG") == "1":
        print(f"[emb] rows: total={n_total}, parsed={n_parsed}, kept={n_kept}, oob={n_oob}, tiny={n_too_short}, shape_skip={n_shape_skip}")
        print(f"[emb] columns: {header}")
        print(f"[emb] wrote: {output_csv}")

    return output_csv

make_speaker_wavs_from_csv

make_speaker_wavs_from_csv(
    source_wav,
    transcript_csv_path,
    output_dir=None,
    *,
    overwrite_existing=False,
    start_col="start_time",
    end_col="end_time",
    speaker_col="speaker",
    time_unit="ms",
    silence_ms=1000,
    pre_silence_ms=None,
    post_silence_ms=None,
    sr=16000,
    mono=True,
    min_dur_ms=50,
    merge_consecutive=True
)

Concatenate speaker-specific segments into per-speaker WAV files.

If merge_consecutive=True (default), adjacent transcript rows with the same speaker are merged into a single, longer segment spanning from the first start to the last end — including any silence between those turns. If you need the strict per-row behavior, set merge_consecutive=False.

Parameters:

Name Type Description Default
source_wav str | Path

Path to the source WAV.

required
transcript_csv_path str | Path

CSV with timing and speaker columns (e.g., diarization output).

required
output_dir str | Path | None

Where to write the per-speaker files. If None, defaults to ./audio_split/<source_stem>/.

None
start_col str

Column names in the transcript CSV.

'start_time'
end_col str

Column names in the transcript CSV.

'start_time'
speaker_col str

Column names in the transcript CSV.

'start_time'
time_unit ('ms', 's')

Units for start/end columns.

"ms","s"
silence_ms int

If pre_silence_ms/post_silence_ms are None, use this for both sides.

1000
pre_silence_ms int | None

Explicit padding (ms) before/after each segment; overrides silence_ms.

None
post_silence_ms int | None

Explicit padding (ms) before/after each segment; overrides silence_ms.

None
sr int | None

Resample output to this rate. If None, keep original rate.

16000
mono bool

Downmix to mono if True.

True
min_dur_ms int

Skip segments shorter than this duration (ms).

50
merge_consecutive bool

Merge back-to-back turns for the same speaker into one segment span (including any inter-turn silence). If False, emit one clip per row.

True
overwrite_existing bool

If False and the output already exists, skip the work and return the existing path.

False

Returns:

Type Description
dict[str, Path]

Mapping from friendly speaker label → output WAV path.

Behavior
  • Input speaker labels are sanitized for filenames but a more readable label (without path-hostile characters) is preserved for naming.
  • Segments are sorted by start time per speaker before concatenation.
  • If a speaker ends up with zero valid segments, no file is written.

Examples:

>>> make_speaker_wavs_from_csv(
...     source_wav="audio/session.wav",
...     transcript_csv_path="transcripts/session.csv",
...     time_unit="ms",
...     silence_ms=0,  # no padding
...     sr=16000,
...     mono=True,
... )
Source code in src\taters\audio\split_wav_by_speaker.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def make_speaker_wavs_from_csv(
    source_wav: Union[str, Path],
    transcript_csv_path: Union[str, Path],
    output_dir: Union[str, Path, None] = None,
    *,
    overwrite_existing: bool = False,
    start_col: str = "start_time",
    end_col: str = "end_time",
    speaker_col: str = "speaker",
    time_unit: str = "ms",             # "ms" or "s"
    silence_ms: int = 1000,
    pre_silence_ms: Optional[int] = None,
    post_silence_ms: Optional[int] = None,
    sr: Optional[int] = 16000,
    mono: bool = True,
    min_dur_ms: int = 50,
    merge_consecutive: bool = True,    # merge back-to-back turns by the same speaker
) -> Dict[str, Path]:
    """
    Concatenate speaker-specific segments into per-speaker WAV files.

    If `merge_consecutive=True` (default), adjacent transcript rows with the same
    speaker are merged into a single, longer segment spanning from the first
    start to the last end — including any silence between those turns. If you
    need the strict per-row behavior, set `merge_consecutive=False`.

    Parameters
    ----------
    source_wav : str | Path
        Path to the source WAV.
    transcript_csv_path : str | Path
        CSV with timing and speaker columns (e.g., diarization output).
    output_dir : str | Path | None, optional
        Where to write the per-speaker files. If None, defaults to
        ``./audio_split/<source_stem>/``.
    start_col, end_col, speaker_col : str
        Column names in the transcript CSV.
    time_unit : {"ms","s"}, default "ms"
        Units for start/end columns.
    silence_ms : int, default 1000
        If `pre_silence_ms`/`post_silence_ms` are None, use this for both sides.
    pre_silence_ms, post_silence_ms : int | None
        Explicit padding (ms) before/after each segment; overrides `silence_ms`.
    sr : int | None, default 16000
        Resample output to this rate. If None, keep original rate.
    mono : bool, default True
        Downmix to mono if True.
    min_dur_ms : int, default 50
        Skip segments shorter than this duration (ms).
    merge_consecutive : bool, default True
        Merge back-to-back turns for the same speaker into one segment span
        (including any inter-turn silence). If False, emit one clip per row.

    overwrite_existing : bool, default=False
        If ``False`` and the output already exists, skip the work and return
        the existing path.
    Returns
    -------
    dict[str, Path]
        Mapping from friendly speaker label → output WAV path.

    Behavior
    --------
    - Input speaker labels are sanitized for filenames but a more readable label
      (without path-hostile characters) is preserved for naming.
    - Segments are sorted by start time per speaker before concatenation.
    - If a speaker ends up with zero valid segments, no file is written.

    Examples
    --------
    >>> make_speaker_wavs_from_csv(
    ...     source_wav="audio/session.wav",
    ...     transcript_csv_path="transcripts/session.csv",
    ...     time_unit="ms",
    ...     silence_ms=0,  # no padding
    ...     sr=16000,
    ...     mono=True,
    ... )
    """
    if time_unit not in ("ms", "s"):
        raise ValueError("time_unit must be 'ms' or 's'")

    def _friendly_filename_label(name: str) -> str:
        s = (name or "").strip()
        s = s.replace("/", "_").replace("\\", "_")
        s = re.sub(r'[<>:"|?*]', "", s)
        s = re.sub(r"\s+", " ", s)
        return s or "SPEAKER_0"

    source_wav = Path(source_wav)
    transcript_csv_path = Path(transcript_csv_path)
    out_dir = Path(output_dir) if output_dir is not None else (Path.cwd() / "audio_split" / source_wav.stem)
    out_dir.mkdir(parents=True, exist_ok=True)
    base_stem = source_wav.stem

    audio = AudioSegment.from_file(source_wav)
    if sr:
        audio = audio.set_frame_rate(sr)
    if mono:
        audio = audio.set_channels(1)

    factor = 1000.0 if time_unit == "s" else 1.0
    audio_len_ms = len(audio)

    with transcript_csv_path.open(newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    segs_by_spk: Dict[str, List[tuple[int, int]]] = {}
    label_for_key: Dict[str, str] = {}

    # now we build up the segments, walking the rows in their original order so
    # that we can merge back-to-back turns from the same speaker if asked to.
    prev_spk_key: Optional[str] = None
    for row in rows:
        try:
            start_raw = float(row[start_col])
            end_raw   = float(row[end_col])
            raw_spk   = str(row.get(speaker_col, "SPEAKER_0"))
        except Exception:
            continue

        start_ms = int(round(start_raw * factor))
        end_ms   = int(round(end_raw   * factor))
        if end_ms <= start_ms:
            continue

        start_ms = _clamp(start_ms, 0, audio_len_ms)
        end_ms   = _clamp(end_ms,   0, audio_len_ms)
        if end_ms <= start_ms:
            continue

        spk_key = _sanitize_speaker(raw_spk)
        label_for_key.setdefault(spk_key, _friendly_filename_label(raw_spk))

        if merge_consecutive and prev_spk_key == spk_key and segs_by_spk.get(spk_key):
            # same speaker as last time, so we stretch their last segment
            s0, e0 = segs_by_spk[spk_key][-1]
            # keep the earliest start, extend out to the latest end
            s_new = min(s0, start_ms)
            e_new = max(e0, end_ms)
            segs_by_spk[spk_key][-1] = (s_new, e_new)
        else:
            # otherwise this is a brand new segment
            segs_by_spk.setdefault(spk_key, []).append((start_ms, end_ms))

        prev_spk_key = spk_key

    # lastly, toss any segments that are still too short even after merging
    for spk_key, segs in list(segs_by_spk.items()):
        segs_by_spk[spk_key] = [(s, e) for (s, e) in segs if (e - s) >= min_dur_ms]

    pre_ms  = silence_ms if pre_silence_ms  is None else pre_silence_ms
    post_ms = silence_ms if post_silence_ms is None else post_silence_ms
    pre_sil  = AudioSegment.silent(duration=max(0, pre_ms),  frame_rate=audio.frame_rate)
    post_sil = AudioSegment.silent(duration=max(0, post_ms), frame_rate=audio.frame_rate)
    if mono:
        pre_sil  = pre_sil.set_channels(1)
        post_sil = post_sil.set_channels(1)

    results: Dict[str, Path] = {}
    for spk_key, segs in segs_by_spk.items():
        if not segs:
            continue

        friendly = label_for_key.get(spk_key, spk_key)
        out_path = out_dir / f"{base_stem}_{friendly}.wav"

        if (not overwrite_existing) and out_path.is_file():
            results[friendly] = out_path
            continue

        out = AudioSegment.silent(duration=0, frame_rate=audio.frame_rate)
        if mono:
            out = out.set_channels(1)

        for (s, e) in segs:
            clip = audio[s:e]
            if len(clip) < min_dur_ms:
                continue
            out += pre_sil + clip + post_sil

        if len(out) == 0:
            continue

        out.export(out_path, format="wav", codec="pcm_s16le")
        results[friendly] = out_path

    return results

Single-speaker transcription with faster-whisper.

This is the lightweight counterpart to :func:taters.audio.diarizer.whisper_diar_wrapper.run_whisper_diarization_repo. Both produce the same artifact — a timestamped utterance CSV with columns start_time,end_time,speaker,text in milliseconds — so anything downstream (per-speaker WAVs, acoustics, Whisper embeddings, the text analyzers) accepts either one without modification.

The difference is what they cost and what they can tell you:

============ ========================== ================================== transcribe_with_whisper diarize_with_thirdparty ============ ========================== ================================== Speakers One (a fixed label) Many, clustered automatically Install Base pip install taters [diarization] + three git installs Runtime faster-whisper only Demucs, forced alignment, punctuation restoration, NeMo MSDD Execution In-process Subprocess against a vendored repo ============ ========================== ==================================

Reach for this module when the recording has one voice — a lecture, an interview recorded on a lapel mic, a voice memo, a podcast monologue — or when you simply want a transcript and do not care who said what. Reach for the diarizer when "who spoke when" is part of the question.

Unlike the embedding extractor, nothing here imports torch or transformers (faster-whisper sits on CTranslate2), so there is no CUDA/Torch state to collide with and no subprocess is needed to isolate it.

TranscriptionOutputFiles dataclass

TranscriptionOutputFiles(
    work_dir,
    raw_files=dict(),
    language=None,
    duration=None,
    device=None,
    compute_type=None,
)

Where the transcription artifacts landed.

Deliberately the same shape as :class:~taters.audio.diarizer.whisper_diar_wrapper.DiarizationOutputFiles so pipeline steps can pick the CSV out of either with the identical {{pick:<step>.raw_files.csv}} expression.

Attributes:

Name Type Description
work_dir Path

Per-file directory holding the artifacts (<out_dir>/<stem>/).

raw_files dict[str, Path]

Written outputs keyed by extension: "csv", and "srt"/"txt" when those were requested.

language str | None

Language Whisper detected (or the one that was forced), if known.

duration float | None

Audio duration in seconds, as reported by Whisper.

device str | None

The device transcription actually ran on -- "cuda" or "cpu", never "auto". Recorded because device="auto" resolving to the CPU is the difference between a one-minute run and an hour-long one, and it is otherwise invisible after the fact. None when the CSV already existed and nothing was run.

compute_type str | None

The CTranslate2 compute type used, for the same reason.

english_only

english_only(whisper_model)

Whether a model name is one of Whisper's English-only variants.

Those are the .en names (base.en, small.en). A local model directory is taken at its word: a folder called my-model.en is presumably one too, and a folder without the suffix is assumed multilingual, because there is no way to ask without loading it.

Source code in src\taters\audio\transcribe_with_whisper.py
110
111
112
113
114
115
116
117
118
119
def english_only(whisper_model: str) -> bool:
    """
    Whether a model name is one of Whisper's English-only variants.

    Those are the ``.en`` names (``base.en``, ``small.en``). A local model
    directory is taken at its word: a folder called ``my-model.en`` is
    presumably one too, and a folder without the suffix is assumed
    multilingual, because there is no way to ask without loading it.
    """
    return str(whisper_model).rstrip("/\\").endswith(".en")

transcribe_with_whisper

transcribe_with_whisper(
    audio_path,
    out_dir=None,
    *,
    overwrite_existing=False,
    whisper_model="base.en",
    language=None,
    translate=False,
    device="auto",
    compute_type=None,
    beam_size=5,
    vad_filter=True,
    word_timestamps=True,
    initial_prompt=None,
    speaker_label="Speaker 0",
    write_srt=True,
    write_txt=True,
    verbose=True,
    on_progress=None
)

Transcribe an audio file with faster-whisper, treating it as one speaker.

Produces the same start_time,end_time,speaker,text CSV (in milliseconds) that the diarizer produces, so the result is a drop-in substitute anywhere a transcript is consumed. Every row carries the same speaker_label, because no speaker clustering is performed — if you need to know who spoke when, use :func:taters.audio.diarize_with_thirdparty instead.

Parameters:

Name Type Description Default
audio_path str | Path

Input audio. Anything faster-whisper can decode works; a 16 kHz mono WAV (what :func:taters.audio.convert_to_wav produces) is the safe choice.

required
out_dir str | Path | None

Base output directory. Artifacts land in <out_dir>/<stem>/, matching the diarizer's layout. Defaults to ./transcripts.

None
overwrite_existing bool

If False and the CSV already exists, return the existing artifacts without re-running the model.

False
whisper_model ('tiny', 'tiny.en', 'base', 'base.en', 'small', 'small.en', 'medium', 'medium.en', 'large-v2', 'large-v3', 'large-v3-turbo', 'distill-large-v3')

A faster-whisper model name -- the .en variants are English-only and a little better at it -- or the path of a local CTranslate2 model directory.

"tiny"
language str | None

Force a language code (e.g. "en"). None auto-detects. Ignored in practice by .en models, which are English-only. With translate it names the language being spoken; the output is English either way.

None
translate bool

Write the transcript in English whatever language is spoken. Whisper's multilingual models were trained to do this as a second task, so it costs nothing extra -- but only those models can: an English-only .en model has no other language to translate from, and asking one to is refused before anything is loaded. The timestamps still belong to the spoken audio, so the transcript is a translation of what was said when, not a transcript of what was said.

False
device ('auto', 'cuda', 'cpu')

Where to run. "auto" picks CUDA when CTranslate2 reports a usable device.

"auto","cuda","cpu"
compute_type ('int8', 'int8_float16', 'int8_bfloat16', 'int16', 'float16', 'bfloat16', 'float32')

CTranslate2 compute type. None resolves to float16 on CUDA and int8 on CPU. Pass explicitly (e.g. "float32") to override.

"int8"
beam_size int

Decoder beam width. 1 is greedy and noticeably faster; 5 is the faster-whisper default and generally more accurate.

5
vad_filter bool

Run Silero VAD first and skip silent regions. Usually improves both speed and quality on long recordings, and avoids the well-known Whisper habit of hallucinating text during silence.

True
word_timestamps bool

Align the decoded text to the audio and use the result for the segment boundaries. On by default because without it the boundaries are close to fiction: Whisper's decoder ends each segment where the next one starts, so a transcript reports near-continuous speech whatever the recording actually contains. Measured on a 771-second lecture, 126 of 133 segment gaps were exactly zero and the transcript claimed 750 seconds of speech; with alignment the same file yields 138 segments and 713 seconds, and the words underneath account for 681.

That matters beyond tidiness. split_wav_by_speaker cuts the WAV on these times and analyze_vocal_acoustics measures the pieces -- pause features included -- so boundaries that overshoot mean acoustics measured over silence the segmentation invented.

The cost is the alignment pass: roughly 20% slower (13.7s to 16.7s on that file). Turn it off when throughput matters more than knowing when anyone was actually speaking.

True
initial_prompt str | None

Optional context string to bias decoding — useful for seeding proper nouns, jargon, or spellings the model would otherwise mangle.

None
speaker_label str

Value written to the speaker column of every row. Matches the diarizer's naming convention so grouped features line up.

"Speaker 0"
write_srt bool

Also write <stem>.srt.

True
write_txt bool

Also write <stem>.txt.

True
verbose bool

Print progress as segments are decoded. Transcription is streamed, so this is the only feedback on a long file.

True
on_progress callable

Structured progress sink, on_progress(done, total, message), with both figures in seconds of audio. Injected automatically by the pipeline runner. This is the machine-readable counterpart to verbose: a UI owning the screen cannot let a step print into it, but a transcription is the longest thing in most pipelines and needs to be seen moving.

None

Returns:

Type Description
TranscriptionOutputFiles

Work directory, written artifact paths, and the detected language and duration.

Raises:

Type Description
FileNotFoundError

If audio_path does not exist.

Examples:

>>> outs = transcribe_with_whisper("audio/lecture.wav", whisper_model="small.en")
>>> outs.raw_files["csv"]
PosixPath('.../transcripts/lecture/lecture.csv')
See Also

taters.audio.diarizer.whisper_diar_wrapper.run_whisper_diarization_repo : Multi-speaker alternative producing the same CSV schema. taters.audio.extract_whisper_embeddings : Turn the resulting transcript into per-segment encoder embeddings.

Source code in src\taters\audio\transcribe_with_whisper.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
def transcribe_with_whisper(
    audio_path: Union[str, Path],
    out_dir: Optional[Union[str, Path]] = None,
    *,
    overwrite_existing: bool = False,  # if the file already exists, let's not overwrite by default
    whisper_model: str = "base.en",
    language: Optional[str] = None,
    translate: bool = False,
    device: Optional[str] = "auto",
    compute_type: Optional[str] = None,
    beam_size: int = 5,
    vad_filter: bool = True,
    word_timestamps: bool = True,
    initial_prompt: Optional[str] = None,
    speaker_label: str = "Speaker 0",
    write_srt: bool = True,
    write_txt: bool = True,
    verbose: bool = True,
    on_progress: Optional[Callable[..., None]] = None,
) -> TranscriptionOutputFiles:
    """
    Transcribe an audio file with faster-whisper, treating it as one speaker.

    Produces the same ``start_time,end_time,speaker,text`` CSV (in milliseconds)
    that the diarizer produces, so the result is a drop-in substitute anywhere a
    transcript is consumed. Every row carries the same `speaker_label`, because
    no speaker clustering is performed — if you need to know who spoke when, use
    :func:`taters.audio.diarize_with_thirdparty` instead.

    Parameters
    ----------
    audio_path : str | Path
        Input audio. Anything faster-whisper can decode works; a 16 kHz mono WAV
        (what :func:`taters.audio.convert_to_wav` produces) is the safe choice.
    out_dir : str | Path | None, optional
        Base output directory. Artifacts land in ``<out_dir>/<stem>/``, matching
        the diarizer's layout. Defaults to ``./transcripts``.
    overwrite_existing : bool, default False
        If False and the CSV already exists, return the existing artifacts
        without re-running the model.
    whisper_model : {"tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large-v2", "large-v3", "large-v3-turbo", "distill-large-v3"} or str, default "base.en"
        A faster-whisper model name -- the ``.en`` variants are English-only
        and a little better at it -- or the path of a local CTranslate2 model
        directory.
    language : str | None, optional
        Force a language code (e.g. ``"en"``). None auto-detects. Ignored in
        practice by ``.en`` models, which are English-only. With ``translate``
        it names the language being *spoken*; the output is English either way.
    translate : bool, default False
        Write the transcript in English whatever language is spoken. Whisper's
        multilingual models were trained to do this as a second task, so it
        costs nothing extra -- but only those models can: an English-only
        ``.en`` model has no other language to translate from, and asking one
        to is refused before anything is loaded. The timestamps still belong
        to the spoken audio, so the transcript is a translation of what was
        said when, not a transcript of what was said.
    device : {"auto","cuda","cpu"} | None, default "auto"
        Where to run. "auto" picks CUDA when CTranslate2 reports a usable device.
    compute_type : {"int8", "int8_float16", "int8_bfloat16", "int16", "float16", "bfloat16", "float32"} | None, optional
        CTranslate2 compute type. None resolves to ``float16`` on CUDA and
        ``int8`` on CPU. Pass explicitly (e.g. ``"float32"``) to override.
    beam_size : int, default 5
        Decoder beam width. 1 is greedy and noticeably faster; 5 is the
        faster-whisper default and generally more accurate.
    vad_filter : bool, default True
        Run Silero VAD first and skip silent regions. Usually improves both
        speed and quality on long recordings, and avoids the well-known Whisper
        habit of hallucinating text during silence.
    word_timestamps : bool, default True
        Align the decoded text to the audio and use the result for the segment
        boundaries. On by default because without it the boundaries are close to
        fiction: Whisper's decoder ends each segment where the next one starts,
        so a transcript reports near-continuous speech whatever the recording
        actually contains. Measured on a 771-second lecture, 126 of 133 segment
        gaps were exactly zero and the transcript claimed 750 seconds of speech;
        with alignment the same file yields 138 segments and 713 seconds, and
        the words underneath account for 681.

        That matters beyond tidiness. `split_wav_by_speaker` cuts the WAV on
        these times and `analyze_vocal_acoustics` measures the pieces -- pause
        features included -- so boundaries that overshoot mean acoustics
        measured over silence the segmentation invented.

        The cost is the alignment pass: roughly 20% slower (13.7s to 16.7s on
        that file). Turn it off when throughput matters more than knowing when
        anyone was actually speaking.
    initial_prompt : str | None, optional
        Optional context string to bias decoding — useful for seeding proper
        nouns, jargon, or spellings the model would otherwise mangle.
    speaker_label : str, default "Speaker 0"
        Value written to the ``speaker`` column of every row. Matches the
        diarizer's naming convention so grouped features line up.
    write_srt : bool, default True
        Also write ``<stem>.srt``.
    write_txt : bool, default True
        Also write ``<stem>.txt``.
    verbose : bool, default True
        Print progress as segments are decoded. Transcription is streamed, so
        this is the only feedback on a long file.
    on_progress : callable, optional
        Structured progress sink, ``on_progress(done, total, message)``, with
        both figures in **seconds of audio**. Injected automatically by the
        pipeline runner. This is the machine-readable counterpart to `verbose`:
        a UI owning the screen cannot let a step print into it, but a
        transcription is the longest thing in most pipelines and needs to be
        seen moving.

    Returns
    -------
    TranscriptionOutputFiles
        Work directory, written artifact paths, and the detected language and
        duration.

    Raises
    ------
    FileNotFoundError
        If `audio_path` does not exist.

    Examples
    --------
    >>> outs = transcribe_with_whisper("audio/lecture.wav", whisper_model="small.en")
    >>> outs.raw_files["csv"]
    PosixPath('.../transcripts/lecture/lecture.csv')

    See Also
    --------
    taters.audio.diarizer.whisper_diar_wrapper.run_whisper_diarization_repo :
        Multi-speaker alternative producing the same CSV schema.
    taters.audio.extract_whisper_embeddings :
        Turn the resulting transcript into per-segment encoder embeddings.
    """
    audio_path = Path(audio_path).resolve()
    if not audio_path.is_file():
        raise FileNotFoundError(f"Audio file not found: {audio_path}")
    if translate and english_only(whisper_model):
        # we refuse here, in plain words, rather than leave it to the decoder:
        # an English-only model has no translate token, and what comes back is
        # either an obscure tokenizer error or an English transcript of foreign
        # speech -- in other words, nonsense that looks like output.
        raise ValueError(
            f"translate=True needs a multilingual Whisper model, and "
            f"{whisper_model!r} is English-only. Use the same size without "
            f".en -- {whisper_model[:-3]!r} -- or any of tiny, base, small, "
            f"medium, large-v3.")

    out_dir = Path(out_dir).resolve() if out_dir is not None else (Path.cwd() / "transcripts")
    work_dir = out_dir / audio_path.stem
    work_dir.mkdir(parents=True, exist_ok=True)

    csv_path = work_dir / f"{audio_path.stem}.csv"
    if not overwrite_existing and csv_path.is_file():
        if verbose:
            print("Transcript output file already exists; returning existing file.")
        return TranscriptionOutputFiles(
            work_dir=work_dir,
            raw_files=_collect_existing(work_dir, audio_path.stem),
        )

    resolved_device, fallback_reason = resolve_device(device, backend="ctranslate2")
    resolved_compute = _resolve_compute_type(compute_type, resolved_device)
    if verbose:
        print(
            f"Transcribing with faster-whisper "
            f"(model={whisper_model}, device={resolved_device}, compute_type={resolved_compute})"
        )
        if fallback_reason:
            print(f"[transcribe] {fallback_reason}")

    # we announce this before touching the model, not after. loading is where a
    # first run downloads the weights and where a broken CUDA install hangs or
    # stalls, and until this line there's nothing on screen to say which of
    # those is happening -- or that we picked the CPU.
    announce(on_progress,
             f"loading {whisper_model} on {resolved_device} ({resolved_compute})")
    model, resolved_device, resolved_compute = _get_model(
        whisper_model, resolved_device, resolved_compute)
    # and we say it again, because it may have changed: `_get_model` proves the
    # GPU works before handing it over and quietly moves to the CPU when it
    # doesn't, and a row still reading "cuda" after that is the wrong answer to
    # the only question anyone ever asks about a slow transcription.
    announce(on_progress,
             f"loaded {whisper_model} on {resolved_device} ({resolved_compute})")

    # VAD runs over the whole file inside `transcribe()`, before we get a single
    # segment back, so from the outside this phase is dead silent.
    announce(on_progress, "finding speech" if vad_filter else "starting")

    # `transcribe` hands back a lazy generator; the work happens as we iterate.
    segments, info = model.transcribe(
        str(audio_path),
        language=language,
        task="translate" if translate else "transcribe",
        beam_size=beam_size,
        vad_filter=vad_filter,
        initial_prompt=initial_prompt,
        word_timestamps=word_timestamps,
    )

    total = float(getattr(info, "duration", 0.0) or 0.0)
    # Whisper's segment end times can overrun the actual audio -- a 15.0 s clip
    # routinely reports a final segment ending at 16.9 s. downstream consumers
    # (`split_wav_by_speaker`, `analyze_vocal_acoustics`) slice the WAV by these
    # numbers, so an out-of-range end quietly gives us a truncated or empty
    # segment. so we clamp to the known duration rather than pass the overrun on.
    limit_ms = total * 1000.0 if total > 0 else None

    rows: List[dict] = []
    for seg in segments:
        text = (seg.text or "").strip()
        if not text:
            continue
        start_ms = float(seg.start) * 1000.0
        end_ms = float(seg.end) * 1000.0
        if limit_ms is not None:
            if start_ms >= limit_ms:
                continue
            end_ms = min(end_ms, limit_ms)
        if end_ms <= start_ms:
            continue
        rows.append({"start_ms": start_ms, "end_ms": end_ms, "text": text})
        # we report position in the recording, not segment count: we know the
        # denominator from the very start (Whisper reports the duration up
        # front), whereas we only know the number of segments once decoding is
        # done.
        if on_progress is not None:
            on_progress(int(min(seg.end, total) if total > 0 else seg.end),
                        int(total) if total > 0 else None,
                        None,
                        "seconds")
        if verbose:
            pct = f" ({min(100.0, 100.0 * seg.end / total):5.1f}%)" if total > 0 else ""
            print(f"[transcribe:{audio_path.stem}]{pct} {seg.end:8.2f}s  {text}")

    raw_files: Dict[str, Path] = {"csv": _write_utterance_csv(csv_path, rows, speaker_label)}
    if write_srt:
        raw_files["srt"] = _write_srt(work_dir / f"{audio_path.stem}.srt", rows)
    if write_txt:
        raw_files["txt"] = _write_txt(work_dir / f"{audio_path.stem}.txt", rows, speaker_label)

    if verbose:
        print(f"Transcript CSV written to: {csv_path}  ({len(rows)} segments)")

    return TranscriptionOutputFiles(
        work_dir=work_dir,
        raw_files=raw_files,
        language=getattr(info, "language", None),
        duration=total or None,
        device=resolved_device,
        compute_type=resolved_compute,
    )