"""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()