Spaces:
Running
Running
File size: 6,010 Bytes
a7d6ddd | 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 | """Offlisten 在线演示:把一段音频转成带标点的文字,并报出 RTF。
这不是产品。产品是本地运行的桌面端(WASAPI 采集、不联网);这里跑的是同一个
SenseVoice int8 模型在 Linux CPU 上的离线转写,用来证明模型与引擎的可用性。
设计约束:
- 免费 CPU Basic 档是 2 vCPU / 16 GB / 50 GB 非持久磁盘,容器会休眠后重建,
所以模型不写进镜像,改为运行时从 Hub 拉取并缓存在容器内。
- 只接受 16 kHz 单声道交给模型;其它采样率在本地重采样,不依赖 ffmpeg。
- 所有失败都给出能照做的下一步,不返回 500。
"""
import os
import time
import gradio as gr
import numpy as np
import sherpa_onnx
MODEL_REPO = os.environ.get("OFFLISTEN_MODEL_REPO", "ToothpasteLake/offlisten-models")
MODEL_SUBDIR = "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
TARGET_SR = 16000
_state = {"recognizer": None, "error": None, "lang": None}
def _find_model_files():
"""返回 (model_path, tokens_path)。优先用镜像内已有的目录,其次从 Hub 拉。"""
local_dir = os.environ.get("OFFLISTEN_MODEL_DIR")
if local_dir:
m = os.path.join(local_dir, "model.int8.onnx")
t = os.path.join(local_dir, "tokens.txt")
if os.path.isfile(m) and os.path.isfile(t):
return m, t
raise RuntimeError("OFFLISTEN_MODEL_DIR 指向 %s,但里面没有 model.int8.onnx / tokens.txt" % local_dir)
from huggingface_hub import hf_hub_download
m = hf_hub_download(MODEL_REPO, "%s/model.int8.onnx" % MODEL_SUBDIR)
t = hf_hub_download(MODEL_REPO, "%s/tokens.txt" % MODEL_SUBDIR)
return m, t
def get_recognizer(language):
"""按语言惰性建识别器;同语言复用,避免每次请求都加载 229 MB。"""
if _state["recognizer"] is not None and _state["lang"] == language:
return _state["recognizer"]
if _state["error"]:
raise RuntimeError(_state["error"])
try:
model, tokens = _find_model_files()
_state["recognizer"] = sherpa_onnx.OfflineRecognizer.from_sense_voice(
model=model,
tokens=tokens,
language=language,
use_itn=True,
num_threads=int(os.environ.get("OFFLISTEN_THREADS", "2")),
)
_state["lang"] = language
return _state["recognizer"]
except Exception as e:
_state["error"] = "%s: %s" % (type(e).__name__, e)
raise
def _load_audio(path):
"""读成 float32 单声道 + 16 kHz。不依赖 ffmpeg。"""
import soundfile as sf
samples, sr = sf.read(path, dtype="float32", always_2d=True)
mono = samples.mean(axis=1)
if sr != TARGET_SR and len(mono) > 1:
n = int(round(len(mono) * TARGET_SR / float(sr)))
mono = np.interp(
np.linspace(0.0, len(mono) - 1, n), np.arange(len(mono)), mono
).astype(np.float32)
return mono, TARGET_SR
def transcribe(audio_path, language):
if not audio_path:
return "", "先上传一段音频,或直接用麦克风录一句。"
try:
samples, sr = _load_audio(audio_path)
except Exception as e:
return "", (
"读不了这个文件(%s)。请换成 wav / flac / ogg / mp3;"
"如果是从会议软件导出的 m4a,先用任意工具转成 wav。" % e
)
duration = len(samples) / float(sr)
if duration < 0.2:
return "", "音频只有 %.2f 秒,太短了。" % duration
try:
recognizer = get_recognizer(language)
except Exception as e:
return "", (
"模型没加载起来:%s\n\n"
"常见原因:模型仓库还没推上去(`%s`),或该仓库是私有的而 Space 没有读权限。"
% (e, MODEL_REPO)
)
started = time.perf_counter()
stream = recognizer.create_stream()
stream.accept_waveform(sr, samples)
recognizer.decode_stream(stream)
elapsed = time.perf_counter() - started
text = (stream.result.text or "").strip()
if not text:
return "", "这段音频里没识别出语音。确认它不是纯音乐或空白录音。"
rtf = elapsed / duration if duration > 0 else float("nan")
note = "音频 %.1f 秒,转写耗时 %.2f 秒,**RTF %.3f**(CPU Basic,2 vCPU,%s 线程)" % (
duration,
elapsed,
rtf,
os.environ.get("OFFLISTEN_THREADS", "2"),
)
return text, note
INTRO = """\
## 这是模型能力演示,不是产品
产品 **Offlisten** 在本地跑:抓系统声音、写本地文件、全程不联网。
这里把同一份 SenseVoice int8 模型放在服务器 CPU 上,只有一个目的——让你先听到效果,再决定要不要装。
- 模型:SenseVoice int8(中 / 英 / 日 / 韩 / 粤),229 MiB,自带标点与逆文本正则
- 语言选 `auto` 由模型自判;已知语种时指定会更稳
- 上传的音频只用于本次转写,不留存
"""
def build():
with gr.Blocks(title="Offlisten ASR demo") as demo:
gr.Markdown(INTRO)
with gr.Row():
with gr.Column():
audio = gr.Audio(
sources=["upload", "microphone"],
type="filepath",
label="音频(wav / flac / ogg / mp3,任意采样率)",
)
language = gr.Dropdown(
["auto", "zh", "en", "ja", "ko", "yue"],
value="auto",
label="语言",
)
run = gr.Button("转写", variant="primary")
with gr.Column():
text = gr.Textbox(label="转写结果", lines=10, show_copy_button=True)
note = gr.Markdown()
run.click(transcribe, inputs=[audio, language], outputs=[text, note])
audio.change(transcribe, inputs=[audio, language], outputs=[text, note])
return demo
if __name__ == "__main__":
build().launch()
|