File size: 6,638 Bytes
e3a3acf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bd5d19
e3a3acf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os

os.environ.setdefault("NUMBA_DISABLE_CUDA", "1")

import spaces  # noqa: E402  (must precede torch)

import json
import subprocess
import sys
import sysconfig
import time
from pathlib import Path

import gradio as gr
import numpy as np
import torch
from huggingface_hub import snapshot_download

MODEL_ID = "LocalDoc/speakwrite-azerbaijani"
HERE = Path(__file__).resolve().parent
BALBAL = HERE / "balbal"  # balbal @ 1be9f88b77c63fbafa7713c6e46e79847f6c390e (Apache-2.0), refiner.json from the model repo
NATIVE = BALBAL / "inference" / "native"
MAX_SECONDS = 300
CHUNK_SECONDS = 30.0

# ---- build balbal's native C++/pybind11 KenLM decoders once (flashlight-text 0.0.7 ABI) ----
if not (NATIVE / ("ctc_fast_decoder" + sysconfig.get_config_var("EXT_SUFFIX"))).exists():
    print("Building balbal native decoders ...", flush=True)
    subprocess.run([sys.executable, str(BALBAL / "inference" / "build_native.py")], check=True,
                   env=dict(os.environ, CXXFLAGS="-O3"))

sys.path[:0] = [str(BALBAL / "inference"), str(BALBAL)]
from balbal import models as M  # noqa: E402
from balbal.audio import read_audio  # noqa: E402
from decoder import KenLMDecoder  # noqa: E402

# ---- weights ----
model_dir = Path(snapshot_download(MODEL_ID))
settings = dict(M.decoder_config("balbal-v2-manas"), **json.loads((model_dir / "decoder.json").read_text()))

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
net = M.build_model("balbal-v2-manas", model_dir / "tokenizer", model_dir / "model.pt", device="cpu")
net = net.to(DEVICE).eval()
torch.set_float32_matmul_precision("highest")

lm_decoder = KenLMDecoder(model_dir / "lm", settings, fast=True)
sp = lm_decoder.sp
VOCAB = lm_decoder.vocab_size
print(f"Model + KenLM loaded (device={DEVICE}).", flush=True)


def _forward(wave: np.ndarray) -> np.ndarray:
    """Mono float32 16 kHz waveform (<= 30 s) -> [frames, V + 1] natural-log CTC probabilities (blank last)."""
    wave = np.ascontiguousarray(wave, dtype=np.float32)
    if len(wave) < 4000:  # the conv subsampler needs >= 0.25 s
        wave = np.pad(wave, (0, 4000 - len(wave)))
    with torch.inference_mode():
        lp, n, _ = net(input_signal=torch.from_numpy(wave)[None].to(DEVICE),
                       input_signal_length=torch.tensor([len(wave)], device=DEVICE))
    return lp[0, : int(n[0])].float().cpu().numpy()


def _greedy(lp: np.ndarray) -> str:
    ids = lp.argmax(axis=1)
    out, prev = [], -1
    for t in ids:
        t = int(t)
        if t != prev and t < VOCAB:
            out.append(t)
        prev = t
    return sp.decode(out)


@spaces.GPU(duration=20)
def transcribe(audio_path: str, use_lm: bool = True, hotwords: str = ""):
    """Transcribe Azerbaijani speech to text with speakwrite-azerbaijani (FastConformer-CTC + 6-gram KenLM).

    Args:
        audio_path: path to an audio file (any format ffmpeg can read); resampled to 16 kHz mono. Max 5 minutes.
        use_lm: decode with the 6-gram token KenLM beam search (lower WER). If False, greedy CTC argmax.
        hotwords: optional comma-separated names/words expected in the audio; they get a small bonus during LM decoding.

    Returns:
        The transcript (cased, punctuated, numbers as words), word timestamps, and timing info.
    """
    if not audio_path:
        raise gr.Error("Please upload or record an audio clip.")
    wave = read_audio(audio_path)
    duration = len(wave) / 16000
    if duration > MAX_SECONDS:
        raise gr.Error(f"Audio is {duration:.0f} s long; please keep it under {MAX_SECONDS // 60} minutes.")
    hot = [w.strip() for w in (hotwords or "").split(",") if w.strip()] or None
    t0 = time.perf_counter()
    step = int(CHUNK_SECONDS * 16000)
    texts, words = [], []
    for i in range(0, len(wave), step):
        lp = _forward(wave[i : i + step])
        if use_lm:
            out = lm_decoder.decode_words(lp, hot, i / 16000)
            words += [[w["word"], round(w["start"], 2), round(w["end"], 2)] for w in out["words"]]
            text = out["text"]
        else:
            text = _greedy(lp)
        if text:
            texts.append(text)
    elapsed = time.perf_counter() - t0
    info = (f"Audio: {duration:.1f} s · inference: {elapsed:.2f} s "
            f"({duration / elapsed if elapsed else 0:.0f}× real time) · decoder: {'KenLM beam search' if use_lm else 'greedy CTC'}")
    return " ".join(texts), words, info


CSS = """
#col-container { max-width: 1000px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks() as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            "# 🎙️ speakwrite-azerbaijani\n"
            "Azerbaijani speech recognition: FastConformer-CTC with a 3-pass loop refiner, product-key acoustic memory "
            "and a 6-gram token KenLM. Output is cased and punctuated, with numbers written as words.  \n"
            "[Model card](https://huggingface.co/LocalDoc/speakwrite-azerbaijani) · "
            "[balbal](https://github.com/erayyap/balbal) · "
            "*speakwrite-azerbaijani by LocalDoc* — CC BY-NC 4.0 (non-commercial use only)."
        )
        with gr.Row():
            with gr.Column():
                audio = gr.Audio(sources=["upload", "microphone"], type="filepath", label="Azerbaijani speech")
                run = gr.Button("Transcribe", variant="primary")
                with gr.Accordion("Advanced settings", open=False):
                    use_lm = gr.Checkbox(value=True, label="Use KenLM beam search (uncheck for greedy CTC)")
                    hotwords = gr.Textbox(value="", label="Hotwords (comma-separated names/words to favour; LM only)",
                                          placeholder="e.g. Bakı, Nizami")
            with gr.Column():
                text = gr.Textbox(label="Transcript", lines=6, buttons=["copy"])
                info = gr.Markdown()
                words = gr.Dataframe(headers=["word", "start (s)", "end (s)"], label="Word timestamps", interactive=False)

        run.click(transcribe, inputs=[audio, use_lm, hotwords], outputs=[text, words, info])

        gr.Examples(
            examples=[["examples/suni_zeka.wav"], ["examples/honkonq.wav"],
                      ["examples/medeni_sok.wav"], ["examples/quslar.wav"]],
            inputs=[audio],
            outputs=[text, words, info],
            fn=transcribe,
            cache_examples=True,
            cache_mode="lazy",
            label="Examples (FLEURS Azerbaijani test set, CC BY 4.0)",
        )

if __name__ == "__main__":
    demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)