File size: 14,450 Bytes
77506e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
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
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
"""Run the pipeline over a HuggingFace speech dataset and score it.

    python scripts/evaluate_dataset.py --dataset espnet/yodas-granary \
        --data_files data/en001/asr_only/00000000.parquet \
        --split train --count 3000 --save ours_en001.jsonl

Each utterance goes audio -> f0 + intensity -> conformer -> mel -> Whisper,
and the hypothesis is scored against the dataset's own transcript. A
microphone tells you the pipeline runs; a dataset tells you whether it is
right, because the reference is there to compare against.

WER is reported over the corpus rather than averaged per utterance: it is
total errors over total reference words, so averaging would weight a
three-word reference the same as a thirty-word one and give a different,
wrong number. The normalisation matches the published inference runs --
punctuation and case alone would otherwise count as errors.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np

# Columns HuggingFace speech datasets use for the reference transcript, in
# the order worth trying. No single name is standard: yodas uses "text",
# common_voice "sentence", and several others "transcription".
TEXT_COLUMNS = (
    "text", "sentence", "transcription", "transcript", "normalized_text",
)


def find_text_column(columns) -> str | None:
    for name in TEXT_COLUMNS:
        if name in (columns or []):
            return name
    return None


def wer_transform():
    """The published runs' WER normalisation, copied exactly.

    The number is only comparable with them if this matches: removing
    punctuation and folding case is the difference between a WER that
    measures the model and one that measures its typography.
    """
    import jiwer

    return jiwer.Compose([
        jiwer.RemovePunctuation(),
        jiwer.ToLowerCase(),
        jiwer.RemoveMultipleSpaces(),
        jiwer.Strip(),
        jiwer.ReduceToListOfListOfWords(),
    ])


def word_errors(reference: str, hypothesis: str) -> dict:
    """Per-utterance WER plus the substitution/deletion/insertion split.

    The breakdown is worth having: on this pipeline a hypothesis is usually
    the right *length* with the wrong words, which is substitutions, and that
    reads very differently from a model dropping or inventing half the
    utterance.
    """
    import jiwer

    transform = wer_transform()
    out = jiwer.process_words(
        reference, hypothesis,
        reference_transform=transform, hypothesis_transform=transform,
    )
    return {
        "wer": out.wer,
        "substitutions": out.substitutions,
        "deletions": out.deletions,
        "insertions": out.insertions,
        "hits": out.hits,
    }


def normalise(text: str) -> str:
    """Enough to compare two transcripts without calling a near-miss a miss."""
    return text.strip().lower().strip(".,!?;:\"'")


def audio_from_row(row, column: str = "audio", audio_dir=None):
    """(audio, sample_rate) from a datasets row, as mono float64.

    Handles the shapes the column arrives in: the {"array", "sampling_rate"}
    dict older `datasets` gives, the torchcodec AudioDecoder 5.x returns, and
    a bare filename -- which is how the yodas splits store it, with the audio
    in a sibling directory rather than inside the dataset.
    """
    item = row[column]

    # Audio(decode=False) hands over {"bytes": ..., "path": ...}: the file's
    # own bytes, which soundfile reads without any system codec.
    if isinstance(item, dict) and ("bytes" in item or "path" in item):
        import io

        import soundfile

        source = (io.BytesIO(item["bytes"]) if item.get("bytes")
                  else str(item["path"]))
        audio, sr = soundfile.read(source, dtype="float64", always_2d=False)
        if getattr(audio, "ndim", 1) > 1:
            audio = audio.mean(axis=1)
        return np.asarray(audio, dtype=np.float64), int(sr)

    if isinstance(item, str):
        path = Path(item)
        if not path.is_absolute() and audio_dir is not None:
            path = Path(audio_dir) / path
        if not path.exists():
            raise FileNotFoundError(
                f"{path} not found. This dataset names its audio by "
                f"filename; pass --audio_dir pointing at the directory "
                f"holding them."
            )
        import soundfile

        audio, sr = soundfile.read(str(path), dtype="float64", always_2d=False)
        if getattr(audio, "ndim", 1) > 1:
            audio = audio.mean(axis=1)
        return np.asarray(audio, dtype=np.float64), int(sr)

    # datasets 5.x: a decoder object rather than the samples.
    if hasattr(item, "get_all_samples"):
        samples = item.get_all_samples()
        audio = np.asarray(samples.data, dtype=np.float64)
        if audio.ndim > 1:
            audio = audio.mean(axis=0 if audio.shape[0] <= 2 else 1)
        return audio, int(samples.sample_rate)

    audio = np.asarray(item["array"], dtype=np.float64)
    if audio.ndim > 1:
        audio = audio.mean(axis=1)
    return audio, int(item["sampling_rate"])


def load(args):
    """The dataset, however it was asked for."""
    from datasets import Audio, load_dataset, load_from_disk

    print(f"loading {args.dataset} (split={args.split})...", flush=True)
    kwargs = {"split": args.split}
    if args.config:
        kwargs["name"] = args.config
    if args.data_files:
        kwargs["data_files"] = args.data_files
    # Streaming by default: these sets run to hundreds of gigabytes, and
    # scoring a few thousand utterances needs the first few thousand.
    if args.stream:
        kwargs["streaming"] = True

    local = Path(args.dataset)
    # A save_to_disk tree has to go through load_from_disk; load_dataset
    # globs the directory and tries to merge the .indices files beside the
    # data, which fails on mismatched schemas rather than saying what is
    # wrong.
    if (local / "dataset_info.json").exists():
        data = load_from_disk(str(local))
        print(f"  from disk: {len(data)} rows")
    else:
        data = load_dataset(args.dataset, **kwargs)

    # Decode the audio here rather than letting `datasets` do it.
    #
    # Its 5.x decoder goes through torchcodec, which dlopens system FFmpeg
    # shared libraries -- so a plain `pip install` leaves it raising
    # "libavutil.so not found" on any machine without FFmpeg development
    # libraries, which is most of them. soundfile reads the bytes directly
    # and ships its own libsndfile, so it works from a wheel alone.
    #
    # Decoding at the file's own rate also matters: asking for 16 kHz hands
    # the resampling to whichever backend is active, and which filter runs
    # changes the samples enough to move yaapt's voicing decisions at gap
    # edges. extract_features resamples itself.
    columns = data.column_names or []
    if args.audio_column in columns:
        try:
            data = data.cast_column(args.audio_column, Audio(decode=False))
        except Exception:
            # An older `datasets`, or a column that is already undecoded.
            pass

    return data


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--dataset", required=True,
                        help="HuggingFace dataset id, or a local "
                             "save_to_disk directory")
    parser.add_argument("--config", default=None, help="dataset config name")
    parser.add_argument("--split", default="train")
    parser.add_argument("--data_files", default=None,
                        help="a single file inside the repo, e.g. "
                             "data/en001/asr_only/00000000.parquet")
    parser.add_argument("--audio_column", default="audio")
    parser.add_argument("--text_column", default=None,
                        help=f"reference transcript; tried in order from "
                             f"{', '.join(TEXT_COLUMNS)}")
    parser.add_argument("--id_column", default=None,
                        help="carried into --save so a diff is a join on id")
    parser.add_argument("--audio_dir", type=Path, default=None,
                        help="where the audio lives, when the column holds "
                             "filenames rather than samples")
    parser.add_argument("--skip", type=int, default=0)
    parser.add_argument("--count", type=int, default=0,
                        help="stop after this many (0 = the whole split)")
    parser.add_argument("--no_stream", dest="stream", action="store_false",
                        help="download the split instead of streaming it")
    parser.add_argument("--save", type=Path, default=None,
                        help="write one JSON object per utterance here")

    parser.add_argument("--model", default="Daporte/prosody2text-weights",
                        help="model repo id, or a local directory holding a "
                             "checkout of one")
    parser.add_argument("--fp16", action="store_true",
                        help="half precision: ~1.8x faster on a GPU, and it "
                             "transcribed every template identically")
    parser.add_argument("--cpu", action="store_true",
                        help="force the conformer onto CPU")
    parser.add_argument("--beams", type=int, default=None,
                        help="beam width (default 10, as published)")
    parser.add_argument("--no_trim", action="store_true",
                        help="keep the quiet lead-in and tail. Dataset clips "
                             "are usually trimmed already, so this is the "
                             "closer match to the published runs")
    args = parser.parse_args(argv)

    try:
        import jiwer  # noqa: F401
    except ImportError:
        print("jiwer is needed for scoring: pip install jiwer", file=sys.stderr)
        return 1

    from transformers import AutoModel

    try:
        data = load(args)
    except Exception as error:
        print(f"could not load {args.dataset}: {error}", file=sys.stderr)
        return 1

    columns = data.column_names or []
    text_column = args.text_column or find_text_column(columns)
    print(f"  columns: {', '.join(columns) if columns else '(streaming)'}")
    if text_column:
        print(f"  reference from {text_column!r}")
    else:
        print("  no transcript column found -- predictions will be unscored")

    if args.skip:
        print(f"  skipping the first {args.skip}")
        data = (data.skip(args.skip) if args.stream
                else data.select(range(args.skip, len(data))))

    print(f"loading {args.model}...", flush=True)
    model = AutoModel.from_pretrained(
        args.model, trust_remote_code=True, half=args.fp16, cpu=args.cpu
    )
    if args.beams:
        model.config.num_beams = args.beams
    print(f"  {model.device_summary}", flush=True)

    transform = wer_transform()
    references: list[str] = []
    predictions: list[str] = []
    exact = scored = 0
    handle = None
    if args.save:
        # Make the directory rather than failing on it. This opens *after*
        # the models have loaded, so a missing parent threw away a 3 GB load
        # and several seconds for a mkdir.
        args.save.parent.mkdir(parents=True, exist_ok=True)
        handle = open(args.save, "w", encoding="utf-8")

    try:
        for index, row in enumerate(data, start=args.skip):
            if args.audio_column not in row:
                print(f"no {args.audio_column!r} column; available: "
                      f"{', '.join(row)}", file=sys.stderr)
                return 1

            reference = row.get(text_column) if text_column else None
            try:
                audio, sr = audio_from_row(
                    row, args.audio_column,
                    row.get("_audio_base_path") or args.audio_dir,
                )
            except Exception as error:
                print(f"[{index:>5}] skipped: {error}", file=sys.stderr)
                continue

            result = model.transcribe(
                audio, sr, trim=not args.no_trim
            )

            if reference and result.ranked:
                scored += 1
                exact += normalise(result.text) == normalise(reference)
                references.append(reference)
                predictions.append(result.text)
                import jiwer

                running = 100 * jiwer.wer(
                    references, predictions,
                    reference_transform=transform,
                    hypothesis_transform=transform,
                )
                stats = word_errors(reference, result.text)
                print(f"[{index:>5}] WER {100 * stats['wer']:5.1f}%  "
                      f"running {running:5.1f}%  | {result.text[:50]}",
                      flush=True)
            else:
                print(f"[{index:>5}] {result.text[:60]}", flush=True)

            if handle is not None:
                # Same field names and order as the published runs'
                # predictions jsonl, so a comparison is a join on id rather
                # than a translation.
                handle.write(json.dumps({
                    "prediction": result.text,
                    "candidates": [text for text, _ in result.ranked],
                    "reference": reference or "",
                    "id": (str(row[args.id_column]) if args.id_column
                           else str(index)),
                }) + "\n")
                handle.flush()

            if args.count and index + 1 - args.skip >= args.count:
                break
    except KeyboardInterrupt:
        print("\ninterrupted", file=sys.stderr)
    finally:
        if handle is not None:
            handle.close()

    if scored:
        import jiwer

        final = 100 * jiwer.wer(
            references, predictions,
            reference_transform=transform, hypothesis_transform=transform,
        )
        print(f"\n{scored} utterance(s) scored")
        print(f"  WER          {final:.2f}%")
        print(f"  exact match  {exact}/{scored} ({100 * exact / scored:.1f}%)")
    else:
        print("\nnothing scored")
    if args.save:
        print(f"  written to   {args.save}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())