Spaces:
Running on Zero
Running on Zero
File size: 7,561 Bytes
5294eec | 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 | """Forced alignment: when each line was said.
Published as https://huggingface.co/spaces/espnet/forced-alignment; the source
lives in espnet, at egs2/owsm_ctc_v4/s2t1/demo_align.
The other demos are one input and one output - audio in, text out - and the
page for them is built from the checkpoint by espnet2.bin.demo. This one takes
audio *and* the text that goes with it, and answers with a table, so it has a
page of its own.
The model is only a CTC head here: espnet2.bin.align aligns on whatever
checkpoint it is handed, and this Space happens to hand it OWSM-CTC v4.
"""
# The ZeroGPU package patches torch as it is imported, so it has to come
# first - before torch, and before anything that imports torch.
try: # only Hugging Face's runners have it
import spaces # isort: skip
except ImportError: # running elsewhere: the decorator does nothing
class spaces: # noqa: N801 - stands in for the module
@staticmethod
def GPU(func=None, **kwargs):
return func if func is not None else (lambda f: f)
import os # noqa: E402
import gradio as gr # noqa: E402
import librosa # noqa: E402
import matplotlib # noqa: E402
import torch # noqa: E402
from espnet2.bin.align import ForcedAligner # noqa: E402
matplotlib.use("Agg") # a Space has no display, and gradio wants the figure
import matplotlib.pyplot as plt # noqa: E402
# ZeroGPU gives a decorated call a fixed slice of GPU time and kills it at the
# end, so the demo asks for a slice and refuses audio it could not finish in
# one. The same two minutes the other demos take.
MAX_SECS = 120
GPU_SECONDS = 120
MODEL_TAG = os.environ.get("ALIGN_MODEL_TAG", "espnet/owsm_ctc_v4_1B")
# A score is a probability under the checkpoint that produced it, so this is
# a heuristic rather than a calibrated confidence: 0.3 is where a line that
# was not said sits, on OWSM-CTC v4 and read English. ALIGN_WARN_BELOW moves
# it for a checkpoint that scores differently.
WARN_BELOW = float(os.environ.get("ALIGN_WARN_BELOW", "0.3"))
# ZeroGPU attaches the GPU only while a @spaces.GPU function runs, so
# torch.cuda.is_available() is False here and asking it would pin the models to
# the CPU on the very hardware bought to run them. SPACES_ZERO_GPU is the
# runtime's own marker; `spaces` being importable is not, since anyone can
# install it.
ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
if os.environ.get("DEVICE"):
DEVICE = os.environ["DEVICE"]
elif ZERO_GPU or torch.cuda.is_available():
DEVICE = "cuda"
else:
DEVICE = "cpu"
EXAMPLE_WAV = (
"https://github.com/espnet/espnet/raw/master/test_utils/ctc_align_test.wav"
)
EXAMPLE_TEXT = """The sale of the hotels
is part of Holiday's strategy
to sell off assets
and concentrate on property management"""
TITLE = "Forced alignment"
DESCRIPTION = """# Forced alignment
You have a recording and the text of what was said. This says *when*: a start
and an end for every line, and a score for how well the line and the audio
agree. Subtitles come from the times; data cleaning comes from the score.
Nothing is trained here. The alignment is a Viterbi path through the CTC head
of a model that already exists - [OWSM-CTC
v4](https://huggingface.co/espnet/owsm_ctc_v4_1B) - which is why it needs no
model of its own and works in any language that one covers.
**Write the text the way the model writes it.** The score is a probability
under this model: the same words in capitals, which a reference transcript
often uses, score near zero while still landing in roughly the right place.
The app says so when it sees it.
"""
ARTICLE = """One line an utterance, in the order they were said - the times are
what you are asking for, so they are not needed. The score is the mean
probability of the line's tokens: 1.0 is a perfect match, and a line that does
not belong to this audio scores near zero, which is what alignment-score
filtering uses.
`espnet align audio.wav --text "..."` is the same thing from a terminal, and
`espnet2.bin.align.ForcedAligner` is the class behind both. Source of this
Space: [`egs2/owsm_ctc_v4/s2t1/demo_align`](https://github.com/espnet/espnet/tree/master/egs2/owsm_ctc_v4/s2t1/demo_align).
""" # noqa: E501 - one markdown link, and breaking a URL breaks the link
aligner = ForcedAligner.from_pretrained(MODEL_TAG, device=DEVICE)
# the rate the checkpoint wants, not this file's idea of it: ALIGN_MODEL_TAG
# can point at a model trained at another rate
SAMPLE_RATE = aligner.sample_rate
def _read(path):
if path is None:
raise gr.Error("Record or upload some audio first.")
speech, _ = librosa.load(path, sr=SAMPLE_RATE)
seconds = len(speech) / SAMPLE_RATE
if seconds > MAX_SECS:
raise gr.Error(
f"This demo takes up to {MAX_SECS} s; that file is {seconds:.0f} s. "
"Run the app yourself for longer audio - the model has no such limit."
)
return speech
def _figure(speech, segments):
"""The waveform with each segment marked, which is the answer to look at."""
figure, axes = plt.subplots(figsize=(11, 2.6))
seconds = [i / SAMPLE_RATE for i in range(len(speech))]
axes.plot(seconds, speech, linewidth=0.4, color="#888")
for index, segment in enumerate(segments):
axes.axvspan(segment.start, segment.end, color=f"C{index % 10}", alpha=0.25)
axes.text(
(segment.start + segment.end) / 2,
0.85 * max(abs(speech.max()), 1e-6),
segment.text.split()[0] if segment.text.split() else "",
ha="center",
fontsize=8,
)
axes.set_xlabel("seconds")
axes.set_yticks([])
figure.tight_layout()
return figure
@spaces.GPU(duration=GPU_SECONDS)
def predict(audio_path, text):
speech = _read(audio_path)
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
if not lines:
raise gr.Error("Type the lines that were said, one to a line.")
try:
segments = aligner(speech, lines)
except ValueError as error:
# "this text cannot fit in this recording", and the like
raise gr.Error(str(error)) from error
rows = [
[f"{s.start:.2f}", f"{s.end:.2f}", f"{s.score:.3f}", s.text] for s in segments
]
worst = min(s.score for s in segments)
if worst < WARN_BELOW:
gr.Warning(
f"The weakest line scores {worst:.2f}, under {WARN_BELOW:.2f}. "
"Either it was not said, or the text is spelled a way this model "
"does not use."
)
return rows, _figure(speech, segments)
with gr.Blocks(title=TITLE) as demo:
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column():
audio = gr.Audio(
sources=["microphone", "upload"], type="filepath", label="Speech"
)
text = gr.Textbox(
label="What was said", lines=6, placeholder="One utterance a line"
)
button = gr.Button("Align", variant="primary")
with gr.Column():
table = gr.Dataframe(
headers=["start", "end", "score", "text"],
label="Segments",
wrap=True,
)
drawing = gr.Plot(label="Where they fall")
button.click(predict, [audio, text], [table, drawing])
gr.Examples(
[[EXAMPLE_WAV, EXAMPLE_TEXT]],
inputs=[audio, text],
outputs=[table, drawing],
fn=predict,
cache_examples=False,
)
gr.Markdown(ARTICLE)
if __name__ == "__main__":
demo.launch()
|