sw005320 commited on
Commit
5294eec
·
verified ·
1 Parent(s): af29766

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +102 -7
  2. app.py +194 -0
  3. requirements.txt +5 -0
README.md CHANGED
@@ -1,13 +1,108 @@
1
  ---
2
- title: Forced Alignment
3
- emoji: 🐠
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Forced alignment
3
+ emoji: 📐
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
+ python_version: "3.12"
8
+ sdk_version: 6.27.0
9
  app_file: app.py
10
  pinned: false
11
+ license: cc-by-4.0
12
+ short_description: When each line was said, and how sure the model is
13
+ tags:
14
+ - espnet
15
+ - owsm
16
+ - forced-alignment
17
+ - automatic-speech-recognition
18
+ models:
19
+ - espnet/owsm_ctc_v4_1B
20
  ---
21
 
22
+ # Forced alignment
23
+
24
+ The source of [`espnet/forced-alignment`](https://huggingface.co/spaces/espnet/forced-alignment).
25
+ Audio and the text that goes with it in; a start, an end and a score for every
26
+ line out, with the waveform marked.
27
+
28
+ Nothing is trained for this. The alignment is a Viterbi path through the CTC
29
+ head of a model that already exists — [OWSM-CTC
30
+ v4](https://huggingface.co/espnet/owsm_ctc_v4_1B) here — so it works in any
31
+ language that model covers, and `ALIGN_MODEL_TAG` points it at another
32
+ checkpoint without touching this file. The sample rate comes from that
33
+ checkpoint too (`ForcedAligner.sample_rate`), so a model trained at another
34
+ rate needs no edit here either.
35
+
36
+ ## Why this one has a page of its own
37
+
38
+ The other demos in this repository are one input and one output, and their
39
+ page is built from the checkpoint by
40
+ [`espnet2.bin.demo`](../../../../espnet2/bin/demo.py): the menus, the window
41
+ and the tasks are read off the model, which is how
42
+ `egs2/powsm_ctc/s2t1/demo` needs no interface at all.
43
+
44
+ Alignment does not fit that shape. It takes audio **and** text, and answers
45
+ with a table rather than a sentence — so the page is here, and it is the only
46
+ demo that draws its own.
47
+
48
+ ## What the score is for
49
+
50
+ `score` is the mean probability of a line's tokens: 1.0 is a perfect match,
51
+ and a line that does not belong to this audio scores near zero. That is what
52
+ alignment-score filtering uses — OWSM v4 cleaned its training data by aligning
53
+ it and dropping the low scores
54
+ ([recipe](../../../owsm_v4/s2t1)).
55
+
56
+ The app warns when the weakest line falls under `ALIGN_WARN_BELOW`, which is
57
+ 0.3 by default. That number is a **heuristic, not a calibrated confidence**:
58
+ it is where a line that was not said sits on OWSM-CTC v4 and read English,
59
+ measured on the example this Space ships with (0.000 for a line that does not
60
+ belong, against 0.99, 0.77 and 0.99 for the three that do). A checkpoint that
61
+ scores differently wants a different number, and the environment variable is
62
+ there for that.
63
+
64
+ It is a probability **under this model**, so the text has to be written the
65
+ way the model writes it. Measured on `test_utils/ctc_align_test.wav` with
66
+ `owsm_ctc_v4_1B`: "The sale of the hotels" scores 0.99 and the same words in
67
+ capitals score 0.0000, because the vocabulary has no capitalised words and
68
+ each one breaks into letters. The times stay roughly right either way, which
69
+ is what makes it confusing rather than obviously wrong — so the app warns when
70
+ the weakest line scores below 0.3, and `espnet2.bin.align` names the shorter
71
+ spelling it found.
72
+
73
+ ## Running and publishing
74
+
75
+ ```sh
76
+ pip install -r requirements.txt
77
+ python app.py # http://127.0.0.1:7860
78
+ ```
79
+
80
+ `DEVICE` decides where the model runs and takes precedence over everything
81
+ else, so `DEVICE=cpu` is a local CPU test even on a machine with a GPU. With
82
+ `DEVICE` unset, the choice is CUDA when the runtime is ZeroGPU
83
+ (`SPACES_ZERO_GPU`, the marker Hugging Face sets — asking
84
+ `torch.cuda.is_available()` there answers False, because the GPU is attached
85
+ only while a `@spaces.GPU` function runs) or when torch reports a GPU, and CPU
86
+ otherwise.
87
+
88
+ To publish, from the ESPnet checkout:
89
+
90
+ ```sh
91
+ hf auth login
92
+ hf upload espnet/forced-alignment egs2/owsm_ctc_v4/s2t1/demo_align . --repo-type space
93
+ ```
94
+
95
+ **Not before the release named in `requirements.txt`.** A Space installs
96
+ espnet from PyPI, and `espnet2.bin.align` arrived in 202610.post2.
97
+
98
+ ## Citation
99
+
100
+ ```bibtex
101
+ @inproceedings{owsm-ctc,
102
+ title={{OWSM-CTC}: An Open Encoder-Only Speech Foundation Model for Speech
103
+ Recognition, Translation, and Language Identification},
104
+ author={Yifan Peng and Yui Sudo and Muhammad Shakeel and Shinji Watanabe},
105
+ booktitle={Proc. ACL},
106
+ year={2024}
107
+ }
108
+ ```
app.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forced alignment: when each line was said.
2
+
3
+ Published as https://huggingface.co/spaces/espnet/forced-alignment; the source
4
+ lives in espnet, at egs2/owsm_ctc_v4/s2t1/demo_align.
5
+
6
+ The other demos are one input and one output - audio in, text out - and the
7
+ page for them is built from the checkpoint by espnet2.bin.demo. This one takes
8
+ audio *and* the text that goes with it, and answers with a table, so it has a
9
+ page of its own.
10
+
11
+ The model is only a CTC head here: espnet2.bin.align aligns on whatever
12
+ checkpoint it is handed, and this Space happens to hand it OWSM-CTC v4.
13
+ """
14
+
15
+ # The ZeroGPU package patches torch as it is imported, so it has to come
16
+ # first - before torch, and before anything that imports torch.
17
+ try: # only Hugging Face's runners have it
18
+ import spaces # isort: skip
19
+ except ImportError: # running elsewhere: the decorator does nothing
20
+
21
+ class spaces: # noqa: N801 - stands in for the module
22
+ @staticmethod
23
+ def GPU(func=None, **kwargs):
24
+ return func if func is not None else (lambda f: f)
25
+
26
+
27
+ import os # noqa: E402
28
+
29
+ import gradio as gr # noqa: E402
30
+ import librosa # noqa: E402
31
+ import matplotlib # noqa: E402
32
+ import torch # noqa: E402
33
+
34
+ from espnet2.bin.align import ForcedAligner # noqa: E402
35
+
36
+ matplotlib.use("Agg") # a Space has no display, and gradio wants the figure
37
+ import matplotlib.pyplot as plt # noqa: E402
38
+
39
+ # ZeroGPU gives a decorated call a fixed slice of GPU time and kills it at the
40
+ # end, so the demo asks for a slice and refuses audio it could not finish in
41
+ # one. The same two minutes the other demos take.
42
+ MAX_SECS = 120
43
+ GPU_SECONDS = 120
44
+ MODEL_TAG = os.environ.get("ALIGN_MODEL_TAG", "espnet/owsm_ctc_v4_1B")
45
+ # A score is a probability under the checkpoint that produced it, so this is
46
+ # a heuristic rather than a calibrated confidence: 0.3 is where a line that
47
+ # was not said sits, on OWSM-CTC v4 and read English. ALIGN_WARN_BELOW moves
48
+ # it for a checkpoint that scores differently.
49
+ WARN_BELOW = float(os.environ.get("ALIGN_WARN_BELOW", "0.3"))
50
+ # ZeroGPU attaches the GPU only while a @spaces.GPU function runs, so
51
+ # torch.cuda.is_available() is False here and asking it would pin the models to
52
+ # the CPU on the very hardware bought to run them. SPACES_ZERO_GPU is the
53
+ # runtime's own marker; `spaces` being importable is not, since anyone can
54
+ # install it.
55
+ ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
56
+ if os.environ.get("DEVICE"):
57
+ DEVICE = os.environ["DEVICE"]
58
+ elif ZERO_GPU or torch.cuda.is_available():
59
+ DEVICE = "cuda"
60
+ else:
61
+ DEVICE = "cpu"
62
+
63
+ EXAMPLE_WAV = (
64
+ "https://github.com/espnet/espnet/raw/master/test_utils/ctc_align_test.wav"
65
+ )
66
+ EXAMPLE_TEXT = """The sale of the hotels
67
+ is part of Holiday's strategy
68
+ to sell off assets
69
+ and concentrate on property management"""
70
+
71
+ TITLE = "Forced alignment"
72
+ DESCRIPTION = """# Forced alignment
73
+
74
+ You have a recording and the text of what was said. This says *when*: a start
75
+ and an end for every line, and a score for how well the line and the audio
76
+ agree. Subtitles come from the times; data cleaning comes from the score.
77
+
78
+ Nothing is trained here. The alignment is a Viterbi path through the CTC head
79
+ of a model that already exists - [OWSM-CTC
80
+ v4](https://huggingface.co/espnet/owsm_ctc_v4_1B) - which is why it needs no
81
+ model of its own and works in any language that one covers.
82
+
83
+ **Write the text the way the model writes it.** The score is a probability
84
+ under this model: the same words in capitals, which a reference transcript
85
+ often uses, score near zero while still landing in roughly the right place.
86
+ The app says so when it sees it.
87
+ """
88
+ ARTICLE = """One line an utterance, in the order they were said - the times are
89
+ what you are asking for, so they are not needed. The score is the mean
90
+ probability of the line's tokens: 1.0 is a perfect match, and a line that does
91
+ not belong to this audio scores near zero, which is what alignment-score
92
+ filtering uses.
93
+
94
+ `espnet align audio.wav --text "..."` is the same thing from a terminal, and
95
+ `espnet2.bin.align.ForcedAligner` is the class behind both. Source of this
96
+ Space: [`egs2/owsm_ctc_v4/s2t1/demo_align`](https://github.com/espnet/espnet/tree/master/egs2/owsm_ctc_v4/s2t1/demo_align).
97
+ """ # noqa: E501 - one markdown link, and breaking a URL breaks the link
98
+
99
+ aligner = ForcedAligner.from_pretrained(MODEL_TAG, device=DEVICE)
100
+ # the rate the checkpoint wants, not this file's idea of it: ALIGN_MODEL_TAG
101
+ # can point at a model trained at another rate
102
+ SAMPLE_RATE = aligner.sample_rate
103
+
104
+
105
+ def _read(path):
106
+ if path is None:
107
+ raise gr.Error("Record or upload some audio first.")
108
+ speech, _ = librosa.load(path, sr=SAMPLE_RATE)
109
+ seconds = len(speech) / SAMPLE_RATE
110
+ if seconds > MAX_SECS:
111
+ raise gr.Error(
112
+ f"This demo takes up to {MAX_SECS} s; that file is {seconds:.0f} s. "
113
+ "Run the app yourself for longer audio - the model has no such limit."
114
+ )
115
+ return speech
116
+
117
+
118
+ def _figure(speech, segments):
119
+ """The waveform with each segment marked, which is the answer to look at."""
120
+ figure, axes = plt.subplots(figsize=(11, 2.6))
121
+ seconds = [i / SAMPLE_RATE for i in range(len(speech))]
122
+ axes.plot(seconds, speech, linewidth=0.4, color="#888")
123
+ for index, segment in enumerate(segments):
124
+ axes.axvspan(segment.start, segment.end, color=f"C{index % 10}", alpha=0.25)
125
+ axes.text(
126
+ (segment.start + segment.end) / 2,
127
+ 0.85 * max(abs(speech.max()), 1e-6),
128
+ segment.text.split()[0] if segment.text.split() else "",
129
+ ha="center",
130
+ fontsize=8,
131
+ )
132
+ axes.set_xlabel("seconds")
133
+ axes.set_yticks([])
134
+ figure.tight_layout()
135
+ return figure
136
+
137
+
138
+ @spaces.GPU(duration=GPU_SECONDS)
139
+ def predict(audio_path, text):
140
+ speech = _read(audio_path)
141
+ lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
142
+ if not lines:
143
+ raise gr.Error("Type the lines that were said, one to a line.")
144
+
145
+ try:
146
+ segments = aligner(speech, lines)
147
+ except ValueError as error:
148
+ # "this text cannot fit in this recording", and the like
149
+ raise gr.Error(str(error)) from error
150
+
151
+ rows = [
152
+ [f"{s.start:.2f}", f"{s.end:.2f}", f"{s.score:.3f}", s.text] for s in segments
153
+ ]
154
+ worst = min(s.score for s in segments)
155
+ if worst < WARN_BELOW:
156
+ gr.Warning(
157
+ f"The weakest line scores {worst:.2f}, under {WARN_BELOW:.2f}. "
158
+ "Either it was not said, or the text is spelled a way this model "
159
+ "does not use."
160
+ )
161
+ return rows, _figure(speech, segments)
162
+
163
+
164
+ with gr.Blocks(title=TITLE) as demo:
165
+ gr.Markdown(DESCRIPTION)
166
+ with gr.Row():
167
+ with gr.Column():
168
+ audio = gr.Audio(
169
+ sources=["microphone", "upload"], type="filepath", label="Speech"
170
+ )
171
+ text = gr.Textbox(
172
+ label="What was said", lines=6, placeholder="One utterance a line"
173
+ )
174
+ button = gr.Button("Align", variant="primary")
175
+ with gr.Column():
176
+ table = gr.Dataframe(
177
+ headers=["start", "end", "score", "text"],
178
+ label="Segments",
179
+ wrap=True,
180
+ )
181
+ drawing = gr.Plot(label="Where they fall")
182
+ button.click(predict, [audio, text], [table, drawing])
183
+ gr.Examples(
184
+ [[EXAMPLE_WAV, EXAMPLE_TEXT]],
185
+ inputs=[audio, text],
186
+ outputs=[table, drawing],
187
+ fn=predict,
188
+ cache_examples=False,
189
+ )
190
+ gr.Markdown(ARTICLE)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ espnet[demo]>=202610.post2
2
+ espnet_model_zoo>=0.1.11
3
+ librosa
4
+ matplotlib
5
+ torchaudio