Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +102 -7
- app.py +194 -0
- requirements.txt +5 -0
README.md
CHANGED
|
@@ -1,13 +1,108 @@
|
|
| 1 |
---
|
| 2 |
-
title: Forced
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|