agent-Specialization/demo/sense_voice_demo.py
JOJO d24e00b9b3 feat(voice): Android 端侧离线语音识别集成
基于 Sherpa-ONNX + SenseVoice int8 实现 APK 端侧语音识别:
- VoiceBridge: AudioRecord 录音 + 整段识别 + JS Bridge 回调
- ModelManager: 模型下载管理(自有服务器),支持断点续传/校验/删除
- 前端:语音按钮仅 APK 环境显示,识别结果回填 ProseMirror 编辑器
- 调试:文件日志 + /api/voice_debug 接收路由
- demo/sense_voice_demo.py: Python 端测 Demo

versionCode 24→36, versionName 1.0.22→1.0.34
2026-06-10 00:50:16 +08:00

468 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
SenseVoice int8 语音识别 Demo
================================
功能:麦克风录音 → Silero VAD 语音分段 → SenseVoice 识别(带标点)→ 实时显示
同时监控并输出 CPU / 内存占用
用法:
python3 demo/sense_voice_demo.py
首次运行会自动下载模型(约 228MB请耐心等待。
按 Ctrl+C 退出时会输出性能统计。
"""
import sys
import os
import time
import argparse
import threading
import queue
import signal
from pathlib import Path
import numpy as np
# ── ANSI 颜色 ─────────────────────────────────────────────────
C = {
"reset": "\033[0m",
"bold": "\033[1m",
"dim": "\033[2m",
"green": "\033[32m",
"cyan": "\033[36m",
"yellow": "\033[33m",
"red": "\033[31m",
"magenta":"\033[35m",
"blue": "\033[94m",
}
def color(s, c):
return f"{c}{s}{C['reset']}"
# ── 模型下载 ─────────────────────────────────────────────────
MODEL_DIR = Path(__file__).parent / "models"
SENSEVOICE_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
)
SILERO_VAD_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"silero_vad.onnx"
)
SENSEVOICE_DIR = MODEL_DIR / "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17"
SENSEVOICE_MODEL = SENSEVOICE_DIR / "model.int8.onnx"
SENSEVOICE_TOKENS = SENSEVOICE_DIR / "tokens.txt"
SILERO_VAD_MODEL = MODEL_DIR / "silero_vad.onnx"
def download_with_progress(url, dest):
"""下载文件并显示进度条"""
import urllib.request
import shutil
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"\n 📥 下载 {Path(dest).name} ...")
print(f" {url}")
try:
with urllib.request.urlopen(url) as resp:
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
block_size = 8192
with open(dest, "wb") as f:
while True:
chunk = resp.read(block_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total > 0:
pct = downloaded / total * 100
bar_len = 30
filled = int(bar_len * downloaded / total)
bar = "" * filled + "" * (bar_len - filled)
size_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
print(
f"\r [{bar}] {pct:5.1f}% {size_mb:.0f}/{total_mb:.0f}MB",
end="", flush=True,
)
print()
except Exception as e:
if dest.exists():
dest.unlink()
raise e
def ensure_models():
"""确保模型文件存在,不存在则下载"""
import tarfile
MODEL_DIR.mkdir(parents=True, exist_ok=True)
# SenseVoice
if not SENSEVOICE_MODEL.exists():
archive = MODEL_DIR / "sense_voice_int8.tar.bz2"
if not archive.exists():
download_with_progress(SENSEVOICE_URL, archive)
print(f" 📦 解压模型...")
with tarfile.open(archive, "r:bz2") as tar:
tar.extractall(path=MODEL_DIR)
archive.unlink()
print(f" ✅ SenseVoice 模型就绪 ({SENSEVOICE_MODEL.stat().st_size / 1024 / 1024:.0f}MB)")
# Silero VAD
if not SILERO_VAD_MODEL.exists():
download_with_progress(SILERO_VAD_URL, SILERO_VAD_MODEL)
print(f" ✅ Silero VAD 模型就绪")
print()
# ── 性能监控 ─────────────────────────────────────────────────
class PerfMonitor:
"""后台线程采集 CPU / 内存"""
def __init__(self, interval=1.0):
self.interval = interval
self._stop = threading.Event()
self._thread = None
self._lock = threading.Lock()
# 累计统计
self.cpu_percent_samples = []
self.rss_mb_samples = []
self.last_cpu = 0.0
self.last_rss_mb = 0.0
try:
import psutil
self._psutil = psutil
self._proc = psutil.Process()
self._available = True
except ImportError:
self._available = False
@property
def available(self):
return self._available
def start(self):
if not self._available:
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
def _run(self):
while not self._stop.is_set():
try:
cpu = self._proc.cpu_percent(interval=None)
rss_mb = self._proc.memory_info().rss / 1024 / 1024
except Exception:
cpu, rss_mb = 0, 0
with self._lock:
self.last_cpu = cpu
self.last_rss_mb = rss_mb
self.cpu_percent_samples.append(cpu)
self.rss_mb_samples.append(rss_mb)
self._stop.wait(self.interval)
def current(self):
with self._lock:
return self.last_cpu, self.last_rss_mb
def summary(self):
with self._lock:
if not self.cpu_percent_samples:
return "无数据"
cpu_avg = np.mean(self.cpu_percent_samples)
cpu_max = np.max(self.cpu_percent_samples)
rss_avg = np.mean(self.rss_mb_samples)
rss_max = np.max(self.rss_mb_samples)
return {
"cpu_avg": cpu_avg,
"cpu_max": cpu_max,
"rss_avg_mb": rss_avg,
"rss_max_mb": rss_max,
"samples": len(self.cpu_percent_samples),
}
# ── 识别统计 ─────────────────────────────────────────────────
class ASRStats:
"""记录每次识别的性能"""
def __init__(self):
self._lock = threading.Lock()
self.segments = [] # list of (audio_duration, asr_time, text)
def record(self, audio_duration, asr_time, text):
with self._lock:
self.segments.append((audio_duration, asr_time, text))
def summary(self):
with self._lock:
if not self.segments:
return None
total_audio = sum(s[0] for s in self.segments)
total_asr = sum(s[1] for s in self.segments)
total_text = sum(len(s[2]) for s in self.segments)
rtf = total_asr / total_audio if total_audio > 0 else 0
return {
"segments": len(self.segments),
"total_audio_s": total_audio,
"total_asr_s": total_asr,
"total_chars": total_text,
"rtf": rtf,
"speedup": 1 / rtf if rtf > 0 else float("inf"),
}
# ── 主程序 ────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="SenseVoice int8 语音识别 Demo")
parser.add_argument("--device", type=int, default=None,
help="音频输入设备编号(不指定则用默认)")
parser.add_argument("--list-devices", action="store_true",
help="列出所有音频设备后退出")
parser.add_argument("--save-audio", type=str, default=None,
help="保存最后一次识别的音频到指定文件(用于调试)")
parser.add_argument("--num-threads", type=int, default=2,
help="ONNX 推理线程数(默认 2")
parser.add_argument("--vad-threshold", type=float, default=0.5,
help="VAD 灵敏度 0~1默认 0.5,越小越敏感)")
parser.add_argument("--silence-duration", type=float, default=0.8,
help="判定为句尾的静音时长秒数(默认 0.8")
parser.add_argument("--max-speech", type=float, default=30,
help="单段最长语音秒数(默认 30")
args = parser.parse_args()
# ── 列出设备 ──
import sounddevice as sd
if args.list_devices:
print("\n🎤 可用音频设备:\n")
devices = sd.query_devices()
for i, d in enumerate(devices):
in_ch = d["max_input_channels"]
if in_ch > 0:
print(f" [{i}] {d['name']} (输入通道: {in_ch}, 默认采样率: {d['default_samplerate']:.0f})")
print()
return
# ── 下载模型 ──
print(color("\n🔧 检查模型文件...", C["cyan"]))
ensure_models()
# ── 初始化 sherpa-onnx ──
print(color("🚀 初始化识别引擎...", C["cyan"]))
import sherpa_onnx
# 通过工厂方法创建 SenseVoice 识别器
recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
model=str(SENSEVOICE_MODEL),
tokens=str(SENSEVOICE_TOKENS),
num_threads=args.num_threads,
provider="cpu",
language="auto",
use_itn=True, # 启用标点 + 逆文本正则化
)
# Silero VAD 配置
silero_vad_config = sherpa_onnx.SileroVadModelConfig(
model=str(SILERO_VAD_MODEL),
threshold=args.vad_threshold,
min_silence_duration=args.silence_duration,
min_speech_duration=0.25,
max_speech_duration=args.max_speech,
)
vad_config = sherpa_onnx.VadModelConfig(
silero_vad=silero_vad_config,
sample_rate=16000,
num_threads=1,
)
vad = sherpa_onnx.VoiceActivityDetector(vad_config, buffer_size_in_seconds=60)
print(color("✅ 引擎就绪!", C["green"]))
# ── 启动性能监控 ──
perf = PerfMonitor(interval=1.0)
perf.start()
asr_stats = ASRStats()
# ── 音频参数 ──
SAMPLE_RATE = 16000
BLOCK_SIZE = 1024 # 每次读取的采样数
# ── 优雅退出 ──
running = True
def on_sigint(sig, frame):
nonlocal running
running = False
print(f"\n{color('⏹ 正在退出...', C['yellow'])}")
signal.signal(signal.SIGINT, on_sigint)
# ── 打印标题 ──
print()
print(color("╔════════════════════════════════════════════════╗", C["bold"]))
print(color("║ 🎙️ SenseVoice 语音识别 Demo ║", C["bold"]))
print(color("║ 中英混说 · 自带标点 · 低资源运行 ║", C["bold"]))
print(color("╚════════════════════════════════════════════════╝", C["bold"]))
print()
print(f" {color('🎤', C['cyan'])} 对着麦克风说话,说完停顿即可看到结果")
print(f" {color('', C['cyan'])}{color('Ctrl+C', C['yellow'])} 退出并查看统计")
print(f" {color('📊', C['cyan'])} VAD 阈值: {args.vad_threshold} | 静音判定: {args.silence_duration}s")
print()
# 状态指示
STATUS_IDLE = f" {color('', C['dim'])} 等待语音..."
STATUS_LISTENING = f" {color('🔴', C['red'])} 正在听..."
STATUS_PROCESSING = f" {color('⚙️', C['yellow'])} 识别中..."
sys.stdout.write(STATUS_IDLE)
sys.stdout.flush()
def audio_callback(indata, frames, time_info, status):
"""麦克风回调:将音频送入 VAD"""
if status:
print(f"\n ⚠️ 音频状态: {status}")
# 转为 float32 并归一化到 [-1, 1]
if indata.dtype == np.int16:
samples = indata[:, 0].astype(np.float32) / 32768.0
else:
samples = indata[:, 0].astype(np.float32)
vad.accept_waveform(samples)
try:
with sd.InputStream(
samplerate=SAMPLE_RATE,
device=args.device,
channels=1,
dtype="float32",
blocksize=BLOCK_SIZE,
callback=audio_callback,
):
last_status = STATUS_IDLE
segment_count = 0
while running:
time.sleep(0.05) # 50ms 轮询
# 检测是否有语音段结束
while not vad.empty():
speech_segment = vad.front # sherpa_onnx.SpeechSegment
samples = np.array(speech_segment.samples, dtype=np.float32)
audio_duration = len(samples) / SAMPLE_RATE
vad.pop()
# ── 识别 ──
sys.stdout.write(f"\r{STATUS_PROCESSING} ({audio_duration:.1f}s 音频)")
sys.stdout.flush()
t0 = time.perf_counter()
stream = recognizer.create_stream()
stream.accept_waveform(SAMPLE_RATE, samples)
recognizer.decode_stream(stream)
text = stream.result.text.strip()
t1 = time.perf_counter()
asr_time = t1 - t0
asr_stats.record(audio_duration, asr_time, text)
# ── 显示结果 ──
segment_count += 1
rtf = asr_time / audio_duration if audio_duration > 0 else 0
speed = 1 / rtf if rtf > 0 else float("inf")
# 清除状态行
sys.stdout.write("\r" + " " * 60 + "\r")
# 输出识别文本
prefix = color(f"[{segment_count}]", C["dim"])
speed_info = color(
f"({asr_time:.2f}s / {audio_duration:.1f}s, RTF={rtf:.3f}, {speed:.0f}x)",
C["dim"],
)
print(f" {prefix} {color(text, C['green'])}")
print(f" {speed_info}")
# 保存音频(调试用)
if args.save_audio and segment_count == 1:
import sherpa_onnx
sherpa_onnx.write_wave(
str(args.save_audio),
samples.tolist(),
SAMPLE_RATE,
)
print(f" 💾 音频已保存到 {args.save_audio}")
# 恢复状态
sys.stdout.write(STATUS_IDLE)
sys.stdout.flush()
# 更新状态行
current_status = STATUS_LISTENING if vad.is_speech_detected() else STATUS_IDLE
if current_status != last_status:
sys.stdout.write(f"\r{current_status}")
sys.stdout.flush()
last_status = current_status
except KeyboardInterrupt:
pass
finally:
running = False
perf.stop()
# ── 输出统计 ──
print("\n")
print(color("╔════════════════════════════════════════════════╗", C["bold"]))
print(color("║ 📊 性能统计 ║", C["bold"]))
print(color("╚════════════════════════════════════════════════╝", C["bold"]))
print()
# ASR 统计
s = asr_stats.summary()
if s:
print(color(" 🎙️ 识别统计", C["cyan"]))
print(f" 识别段数: {s['segments']}")
print(f" 总音频时长: {s['total_audio_s']:.1f}s")
print(f" 总识别耗时: {s['total_asr_s']:.2f}s")
print(f" 总字符数: {s['total_chars']}")
print(f" RTF: {s['rtf']:.4f}")
print(f" 处理速度: {s['speedup']:.1f}x 实时")
print()
# 系统资源统计
ps = perf.summary()
if isinstance(ps, dict):
print(color(" 💻 系统资源", C["cyan"]))
print(f" CPU 平均: {ps['cpu_avg']:.1f}%")
print(f" CPU 峰值: {ps['cpu_max']:.1f}%")
print(f" 内存平均: {ps['rss_avg_mb']:.0f}MB")
print(f" 内存峰值: {ps['rss_max_mb']:.0f}MB")
print(f" 采样点数: {ps['samples']}")
print()
elif not perf.available:
print(color(" ⚠️ 未安装 psutil无法采集 CPU/内存数据", C["yellow"]))
print(color(" 安装: pip3 install psutil", C["dim"]))
print()
if __name__ == "__main__":
main()