语音唤醒技术选型与实践:从开源方案到自主训练
引言
语音唤醒(Keyword Spotting, KWS)是语音交互的"守门员"------设备在低功耗持续监听中,精准识别特定唤醒词,从而触发后续指令。在智慧指挥调度、智能语音、智能音箱、车载助手、可穿戴设备等端侧场景中,KWS 面临三大硬指标:低延迟 (≤200ms)、低功耗 (模型需轻量)、高隐私(数据不出设备)。
本文将从工程角度,系统梳理四条主流技术路线:
- sherpa‑onnx‑kws ------ 生产级端侧语音工具包
- Vosk ------ 通用离线语音识别引擎
- 阿里"小云" ------ 专用固定唤醒词模型
- 自主训练 ------ 完全定制的"高级玩家"路线
一、四条技术路线速览
| 路线 | 核心定位 | 模型大小 | 关键词定制 | 部署难度 | 典型场景 |
|---|---|---|---|---|---|
| sherpa‑onnx‑kws | 全栈端侧语音工具包(含KWS) | 3.3MB | 任意关键词,无需重训 | 中等,跨平台 | 智慧指挥调度、智能语音、智能音箱、车载、机器人 |
| Vosk | 离线通用ASR引擎 | 40~50MB(中文小模型) | 基于文本匹配,受限于语言模型 | 简单(纯前端可用) | 快速原型、多语言转录 |
| 阿里"小云" | 专为"小云小云"优化的KWS | <3MB | 固定"小云小云" | 极低(开箱即用) | 资源极度受限的嵌入式设备 |
| 自主训练 | 完全自定义模型 | 可极小(<1MB) | 完全自由 | 高(需数据与调参) | 特定硬件、冷门唤醒词 |
侧重说明:本文会重点展开前两条路线(已验证),后两条提供简要指引,方便你横向比较。
二、sherpa‑onnx‑kws:手术刀级别的端侧 KWS
2.1 项目概况
sherpa‑onnx 由新一代 Kaldi 团队开发,基于 ONNX Runtime,提供 ASR、TTS、KWS、说话人识别 等全套离线语音能力。其 KWS 模块采用 Zipformer 架构,模型仅 3.3MB(int8 量化),在单核 CPU 上即可实时运行。
2.2 核心优势
- 极轻量:模型 <4MB,内存占用 <20MB。
- 自定义关键词零成本 :仅需编辑
keywords.txt文件,无需重新训练。 - 低延迟:实测唤醒响应 <200ms,误唤醒率 <1%。
- 多语言/多平台:支持 Python、C++、Java、Swift,覆盖 Linux/Windows/Android/iOS/树莓派。
2.3 模型架构简析
Zipformer 是 Conformer 的高效变体,采用 RNN‑Transducer 框架:
- Encoder:将 80 维 FBank 特征编码为深层表示。
- Decoder:预测下一 token。
- Joiner:融合 encoder/decoder 输出,计算关键词概率。
int8 量化进一步压缩体积并提升推理速度。
2.4 环境准备
bash
后端:python、sherpa-onnx
前端:node、vue3
# 下载中文模型(3.3MB)
sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01.tar.bz2
tar xf sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01.tar.bz2
2.5 服务端(Python)实现
我们采用 WebSocket 服务端接收前端音频流,实时检测唤醒词。
python
#!/usr/bin/env python3
# kws_server.py
class KWSWebSocketServer:
"""KWS WebSocket 服务器(多线程推理版)"""
def __init__(self, host: str = config.HOST, port: int = config.PORT):
self.host = host
self.port = port
self.max_connections = config.MAX_CONNECTIONS
self.clients: dict = {} # websocket -> engine
self.connection_count = 0
logger.info(f"Server initialized: {host}:{port}")
logger.info(f"Keywords: {config.KWS_KEYWORDS}")
logger.info(f"Max connections: {self.max_connections}")
async def _handle_client(self, websocket):
"""处理客户端连接:创建独立引擎+推理线程,协调接收/发送协程"""
client_id = str(id(websocket))
# 连接数限制
if len(self.clients) >= self.max_connections:
logger.warning(f"Connection rejected: max ({self.max_connections}) reached")
await websocket.send(json.dumps({
"type": "error",
"message": "Server is at maximum capacity"
}))
await websocket.close()
return
engine = None
inference_thread = None
stop_event = threading.Event()
# 每个客户端独立的队列
audio_queue = queue.Queue(maxsize=200) # 接收协程 → 推理线程
result_queue = asyncio.Queue(maxsize=50) # 推理线程 → 发送协程
try:
# 1. 创建引擎 + 加载模型(放入线程池,避免阻塞事件循环)
engine = SherpaKWSEngine(
model_dir=config.SHERPA_MODEL_DIR,
keywords=config.KWS_KEYWORDS,
threshold=config.SHERPA_KEYWORDS_THRESHOLD,
score=config.SHERPA_KEYWORDS_SCORE,
sample_rate=config.SAMPLE_RATE,
num_threads=config.SHERPA_NUM_THREADS,
provider=config.SHERPA_PROVIDER,
max_active_paths=config.SHERPA_MAX_ACTIVE_PATHS,
trailing_blanks=config.SHERPA_TRAILING_BLANKS,
instance_id=client_id
)
await asyncio.get_running_loop().run_in_executor(None, engine.load_model)
self.clients[websocket] = engine
self.connection_count += 1
logger.info(f"Client connected: {client_id} (total: {self.connection_count})")
# 2. 发送就绪消息
await websocket.send(json.dumps({
"type": "ready",
"keywords": config.KWS_KEYWORDS.split(","),
"timestamp": datetime.now().isoformat()
}))
# 3. 启动独立推理线程
loop = asyncio.get_running_loop()
inference_thread = threading.Thread(
target=self._inference_worker,
args=(engine, audio_queue, result_queue, stop_event, loop, client_id),
daemon=True
)
inference_thread.start()
# 4. 并发运行接收 + 发送协程
receiver_task = asyncio.create_task(
self._receiver(websocket, audio_queue, client_id)
)
sender_task = asyncio.create_task(
self._sender(websocket, result_queue, client_id)
)
# 等待接收协程结束(连接断开),再通知发送协程退出
await receiver_task
await result_queue.put(_STOP)
await sender_task
except Exception as e:
logger.error(f"Error handling client {client_id}: {e}", exc_info=True)
try:
await websocket.send(json.dumps({
"type": "error",
"message": str(e)
}))
except Exception:
pass
finally:
# 清理:停止推理线程 → 注销客户端
stop_event.set()
if inference_thread and inference_thread.is_alive():
inference_thread.join(timeout=5)
if websocket in self.clients:
del self.clients[websocket]
self.connection_count -= 1
logger.info(f"Client disconnected: {client_id} (total: {self.connection_count})")
async def _receiver(self, websocket, audio_queue: queue.Queue, client_id: str):
"""接收协程:从 WebSocket 读音频 → 分块 → 放入 queue.Queue"""
audio_buffer = bytearray()
chunk_size = 512 * 2 # 32ms @ 16kHz = 512 samples × 2 bytes
chunk_count = 0
last_log_time = datetime.now()
try:
async for message in websocket:
if isinstance(message, bytes):
audio_buffer.extend(message)
while len(audio_buffer) >= chunk_size:
chunk = bytes(audio_buffer[:chunk_size])
del audio_buffer[:chunk_size]
try:
audio_queue.put_nowait(chunk)
except queue.Full:
logger.warning(
f"[{client_id}] audio queue full, dropping oldest chunk"
)
try:
audio_queue.get_nowait()
audio_queue.put_nowait(chunk)
except queue.Empty:
pass
chunk_count += 1
now = datetime.now()
if (now - last_log_time).total_seconds() >= 1.0:
logger.info(
f"[{client_id}] chunks={chunk_count}, "
f"buffered={len(audio_buffer)}, qsize={audio_queue.qsize()}"
)
last_log_time = now
elif isinstance(message, str):
try:
cmd = json.loads(message)
if cmd.get("type") == "ping":
await websocket.send(json.dumps({"type": "pong"}))
except Exception:
pass
except websockets.exceptions.ConnectionClosed:
pass
async def _sender(self, websocket, result_queue: asyncio.Queue, client_id: str):
"""发送协程:从 asyncio.Queue 取结果 → WebSocket 发送"""
try:
while True:
result = await result_queue.get()
if result is _STOP:
break
try:
await websocket.send(json.dumps(result))
except websockets.exceptions.ConnectionClosed:
break
except asyncio.CancelledError:
pass
@staticmethod
def _inference_worker(engine, audio_queue, result_queue, stop_event, loop, client_id):
"""推理线程:从 queue.Queue 取音频 → ONNX 推理 → 结果通过
call_soon_threadsafe 安全写回 asyncio.Queue"""
logger.info(f"[{client_id}] Inference thread started")
try:
while not stop_event.is_set():
try:
chunk = audio_queue.get(timeout=0.5)
except queue.Empty:
continue
if stop_event.is_set():
break
samples = np.frombuffer(chunk, dtype=np.int16)
detected = engine.process_audio_debug(samples)
if detected:
logger.info(f"[{client_id}] *** WAKEUP! ***")
wakeup_msg = {
"type": "wakeup",
"keyword": "detected",
"timestamp": datetime.now().isoformat()
}
try:
loop.call_soon_threadsafe(
result_queue.put_nowait, wakeup_msg
)
except asyncio.QueueFull:
logger.warning(
f"[{client_id}] result queue full, dropping wakeup"
)
engine.reset()
except Exception as e:
logger.error(f"[{client_id}] Inference thread error: {e}", exc_info=True)
finally:
logger.info(f"[{client_id}] Inference thread stopped")
async def _run_server(self):
"""运行服务器"""
logger.info(f"Starting server on {self.host}:{self.port}")
async with websockets.serve(self._handle_client, self.host, self.port):
logger.info("Server started, waiting for connections...")
await asyncio.Future()
def start(self):
"""启动服务器"""
logger.info("Starting KWS Server...")
try:
asyncio.run(self._run_server())
except KeyboardInterrupt:
logger.info("Server stopped")
keywords.txt 示例(支持多个关键词):
小度小度
2.6 前端(Vue)完整实现
前端负责录音、编码并发送音频,同时接收唤醒事件。
vue
/**
* useKWS - KWS 唤醒词检测 Composable
* 使用 AudioWorkletNode(独立音频线程),避免主线程阻塞导致丢帧
*
* @param {Object} options - 配置选项
* @param {string} options.wsUrl - WebSocket 地址,默认 ws://localhost:10096
* @param {Function} options.onWakeup - 唤醒成功回调
* @param {Function} options.onReady - 服务就绪回调
* @param {Function} options.onError - 错误回调
* @param {Function} options.onStatusChange - 状态变化回调
*/
import { ref, onUnmounted } from 'vue'
export function useKWS(options = {}) {
// 配置
const wsUrl = options.wsUrl || `ws://${location.hostname}:10099`
// 状态
const isConnected = ref(false)
const isListening = ref(false)
const keywords = ref([])
const error = ref(null)
// 内部变量
let ws = null
let audioContext = null
let mediaStream = null
/** @type {AudioWorkletNode|null} */
let workletNode = null
// 回调
const onWakeup = options.onWakeup || (() => {})
const onReady = options.onReady || (() => {})
const onError = options.onError || ((err) => console.error('KWS Error:', err))
const onStatusChange = options.onStatusChange || (() => {})
// 状态变化处理
function updateStatus(status) {
onStatusChange(status)
if (status === 'connected') {
isConnected.value = true
error.value = null
} else if (status === 'disconnected') {
isConnected.value = false
isListening.value = false
} else if (status === 'listening') {
isListening.value = true
} else if (status === 'stopped') {
isListening.value = false
}
}
// 连接 WebSocket
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) return
try {
ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
updateStatus('connected')
}
ws.onclose = () => {
updateStatus('disconnected')
stopListening()
// 自动重连
setTimeout(connect, 3000)
}
ws.onerror = (e) => {
error.value = 'WebSocket 连接错误'
onError(new Error('WebSocket 连接错误'))
}
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
if (data.type === 'wakeup') {
onWakeup(data)
} else if (data.type === 'ready') {
keywords.value = data.keywords || []
onReady(data)
}
} catch (err) {
console.error('Failed to parse message:', err)
}
}
} catch (err) {
error.value = err.message
onError(err)
}
}
// 断开连接
function disconnect() {
stopListening()
if (ws) {
ws.close()
ws = null
}
isConnected.value = false
isListening.value = false
}
// 获取麦克风并开始录音
async function startListening() {
if (isListening.value) return
try {
// 创建 AudioContext(16kHz,KWS 模型的标准采样率)
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000,
})
// 动态加载 AudioWorklet 处理器(独立音频线程)
// 使用 Blob URL 避免 Vite ESM 兼容性问题
const workletCode = await fetch(
new URL('../kws-audio-processor.js', import.meta.url)
).then(r => r.text())
const workletBlob = new Blob([workletCode], { type: 'application/javascript' })
const workletUrl = URL.createObjectURL(workletBlob)
await audioContext.audioWorklet.addModule(workletUrl)
URL.revokeObjectURL(workletUrl)
// 获取麦克风(禁用所有处理,保留原始音频)
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
})
const actualRate = audioContext.sampleRate
console.log(`AudioContext actual sample rate: ${actualRate}`)
// 创建 AudioWorkletNode(独立线程处理音频),传入真实采样率用于重采样
workletNode = new AudioWorkletNode(audioContext, 'kws-audio-processor', {
processorOptions: { actualSampleRate: actualRate }
})
// 接收 worklet 线程发来的 PCM 数据,直接发送到 WebSocket
workletNode.port.onmessage = (e) => {
if (!isListening.value) return
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(e.data)
}
}
// 连接音频链路:麦克风 → worklet 处理器
const source = audioContext.createMediaStreamSource(mediaStream)
source.connect(workletNode)
updateStatus('listening')
} catch (err) {
error.value = err.message
onError(err)
throw err
}
}
// 停止录音
function stopListening() {
if (workletNode) {
workletNode.port.onmessage = null
workletNode.disconnect()
workletNode = null
}
if (mediaStream) {
const tracks = mediaStream.getTracks()
tracks.forEach((track) => track.stop())
mediaStream = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
audioContext = null
}
if (isListening.value) {
updateStatus('stopped')
}
isListening.value = false
}
// 切换录音状态
async function toggleListening() {
if (isListening.value) {
stopListening()
} else {
await startListening()
}
return isListening.value
}
// 播放提示音
function playBeep(frequency = 880, duration = 0.3) {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)()
const osc = audioCtx.createOscillator()
const gain = audioCtx.createGain()
osc.connect(gain)
gain.connect(audioCtx.destination)
osc.frequency.value = frequency
gain.gain.setValueAtTime(0.3, audioCtx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration)
osc.start()
osc.stop(audioCtx.currentTime + duration)
}
// 清理
onUnmounted(() => {
disconnect()
})
// 返回
return {
// 状态
isConnected,
isListening,
keywords,
error,
// 方法
connect,
disconnect,
startListening,
stopListening,
toggleListening,
playBeep,
}
}
2.7 参数调优建议
- 阈值
threshold:默认 0.25。嘈杂环境可调至 0.30~0.35 降低误唤醒;安静环境可降至 0.15~0.20 提高召回。 - 音频分块大小:示例用 0.5s 块 + 0.25s 重叠,可根据实时性要求调整(更小块延迟更低,但可能增加计算开销)。
三、Vosk:纯前端离线的"通用翻译官"
3.1 项目概况
Vosk 基于 Kaldi,提供 离线语音识别(ASR) ,并通过 WebAssembly 支持浏览器纯前端运行。其 KWS 能力并非原生,而是通过识别文本后匹配关键词来实现。
3.2 核心特点
- API 极简:几行代码即可实现语音转文字。
- 纯前端部署:隐私保护,无需后端。
- 多语言:支持 20+ 种语言,模型可替换。
- 模型大小:中文小模型约 40MB,中等模型 ~1GB。
3.3 局限提醒
- 唤醒本质是后处理,延迟和误唤醒率不如专用 KWS 模型。
- 关键词需被语言模型覆盖,否则可能识别不准。
3.4 Vue 纯前端实现
vue
<template>
<el-card class="wakeup-card" shadow="hover">
<div class="card-header">
<el-icon class="header-icon">
<Mic />
</el-icon>
<h3>小度小度 - 语音唤醒</h3>
</div>
<div class="status-area">
<el-tag :type="statusType" class="status-tag">{{ statusText }}</el-tag>
<p class="tip-text">{{ tipText }}</p>
</div>
<div class="control-area">
<el-button :type="isListening ? 'danger' : 'primary'" :disabled="isLoading" @click="toggleListening"
icon="el-icon-microphone">
{{ isListening ? '停止监听' : '开始监听' }}
</el-button>
<el-switch v-model="isAutoWake" active-text="自动唤醒" inactive-text="手动模式" class="auto-switch"
:disabled="isListening" />
</div>
<!-- 唤醒成功弹窗 -->
<el-dialog title="唤醒成功" v-model="isWakeupSuccess" width="30%" :show-close="false" :modal-closable="false">
<div class="wakeup-success">
<el-icon class="success-icon">
<CircleCheckFilled />
</el-icon>
<p>已成功唤醒"小度小度"</p>
</div>
<template #footer>
<el-button type="primary" @click="isWakeupSuccess = false">确认</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { ref, onUnmounted, onMounted } from 'vue';
import { createModel, KaldiRecognizer } from 'vosk-browser';
import {
Mic, CircleCheckFilled
} from '@element-plus/icons-vue';
import { ElCard, ElTag, ElButton, ElSwitch, ElDialog, ElIcon } from 'element-plus';
// 状态管理
const isLoading = ref(false); // 模型加载中
const isListening = ref(false); // 是否正在监听
const isWakeupSuccess = ref(false); // 唤醒成功弹窗
const isAutoWake = ref(true); // 是否自动唤醒
const statusText = ref('未就绪'); // 状态文本
const statusType = ref('info'); // 状态标签类型
const tipText = ref('请点击开始监听,说出"小度小度"唤醒'); // 提示文本
const synth = ref(window.speechSynthesis)
// 核心实例
let recognizer = null;
let model = null;
let audioContext = null;
let mediaStream = null;
// 初始化模型
const initVoskModel = async () => {
try {
isLoading.value = true;
statusText.value = '加载模型中...';
statusType.value = 'processing';
// 加载本地模型(public目录下)
const modelUrl = '/vosk-model-small-cn-0.22.zip';
model = await createModel(modelUrl);
// 配置识别器(16kHz采样率为vosk推荐)
const sampleRate = 16000;
const json = JSON.stringify(['小度小度', '小度', '小', '度'])
recognizer = new model.KaldiRecognizer(sampleRate, json);
recognizer.setWords(true)
recognizer.on("result", (message) => {
if (message.result.text.replace(/ /g, '').includes( '小度小度')) {
const utterance = new SpeechSynthesisUtterance('在呢!');
synth.value.speak(utterance);
console.log(`Result: ${message}`, message.result);
}
});
recognizer.on("partialresult", (message) => {
if (message.result.partial)
console.log(`Partial result: ${message}`, message.result);
});
// 关键词识别模式(仅关注"小度小度",提高唤醒效率)
recognizer.setWords(true);
statusText.value = '就绪';
statusType.value = 'success';
tipText.value = '请点击开始监听,说出"小度小度"唤醒';
} catch (error) {
statusText.value = '初始化失败';
statusType.value = 'danger';
tipText.value = `错误原因:${error.message}`;
console.error('Vosk模型初始化失败:', error);
} finally {
isLoading.value = false;
}
};
// 开始/停止监听
const toggleListening = async () => {
if (isListening.value) {
// 停止监听
stopListening();
return;
}
// 未初始化模型则先初始化
if (!recognizer) await initVoskModel();
if (!recognizer) return;
try {
// 获取麦克风权限
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true // 开启回声消除
}
});
// 初始化音频上下文
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
// 创建音频节点,连接到识别器
const source = audioContext.createMediaStreamSource(mediaStream);
const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
// 实时处理音频数据
scriptProcessor.onaudioprocess = (e) => {
try {
recognizer.acceptWaveform(event.inputBuffer)
} catch (error) {
console.error('acceptWaveform failed', error)
}
};
// 连接音频流
source.connect(scriptProcessor);
scriptProcessor.connect(audioContext.destination);
// 更新状态
isListening.value = true;
statusText.value = '监听中...';
statusType.value = 'warning';
tipText.value = '请对着麦克风说出"小度小度"';
} catch (error) {
statusText.value = '权限获取失败';
statusType.value = 'danger';
tipText.value = '请允许麦克风权限后重试';
console.error('麦克风权限获取失败:', error);
}
};
// 检测唤醒词
const checkWakeupWord = (text) => {
if (!text) return;
// 模糊匹配(忽略空格、大小写)
const wakeupWord = '小度小度';
const matched = text.replace(/\s+/g, '').includes(wakeupWord);
if (matched) {
isWakeupSuccess.value = true; // 显示唤醒成功弹窗
if (isAutoWake.value) stopListening(); // 自动模式下唤醒后停止监听
// 唤醒成功回调(可在此添加业务逻辑,如打开语音助手)
console.log('唤醒成功,执行后续操作...');
}
};
// 停止监听
const stopListening = () => {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
}
if (audioContext) audioContext.close();
if (recognizer) console.log(567, recognizer);
isListening.value = false;
statusText.value = '已停止';
statusType.value = 'info';
tipText.value = '请点击开始监听,说出"小度小度"唤醒';
};
// 组件挂载时初始化模型(可选,也可点击开始时初始化)
onMounted(() => {
initVoskModel();
});
// 组件卸载时清理资源
onUnmounted(() => {
if (isListening.value) stopListening();
if (recognizer) recognizer.remove();
if (model) model.terminate()
});
</script>
<style scoped>
.wakeup-card {
max-width: 400px;
margin: 20px auto;
border-radius: 12px;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.header-icon {
font-size: 20px;
color: #409eff;
}
.status-area {
margin-bottom: 25px;
text-align: center;
}
.status-tag {
font-size: 16px;
padding: 6px 20px;
margin-bottom: 10px;
}
.tip-text {
color: #666;
font-size: 14px;
margin: 0;
}
.control-area {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.auto-switch {
margin-top: 5px;
}
.wakeup-success {
text-align: center;
padding: 20px 0;
}
.success-icon {
font-size: 48px;
color: #67c23a;
margin-bottom: 15px;
}
</style>
四、阿里"小云":极致的专用唤醒模型
阿里 iic 实验室开源的"小云"模型(iic/speech_dfsmn_kws_ps_zh-cn_16k)专为唤醒词 "小云小云" 设计,具有以下特点:
- 模型 < 3MB,内存 < 8MB,推理耗时 < 12ms(RTX 4090D)。
- 高抗噪:在 85dB 噪声下唤醒率 > 95%。
- 开箱即用:提供 Docker 镜像,一行命令即可测试。
局限 :唤醒词完全固定,无法更改,适合硬件资源极度受限且唤醒词恰好匹配的场景。
五、自主训练:终极定制之路
当现有模型无法满足特定唤醒词或硬件要求时,可走自主训练路线。
5.1 典型流程
- 数据准备:收集正样本(包含唤醒词的语音)和负样本(噪音、其他语音)。
- 模型设计 :选用轻量网络如 TC-ResNet 、CRNN 或 TDNN,参数量可控制在 100k 以内。
- 训练与部署:使用 PyTorch/TensorFlow 训练,导出为 ONNX 或 TFLite Micro,部署到目标硬件。
- 优化:量化、剪枝、知识蒸馏等进一步提升端侧性能。
5.2 开源工具推荐
- openWakeWord:提供数据合成、训练、评估流水线。
- customWakeWord:聚焦于少量样本的快速定制。
自主训练成本较高,适合产品有独特唤醒词或需要极致性能的场景。
六、综合对比与选型建议
| 维度 | sherpa‑onnx‑kws | Vosk | 阿里"小云" | 自主训练 |
|---|---|---|---|---|
| 模型大小 | 3.3MB | 40~50MB | <3MB | 可控(可 <1MB) |
| 唤醒词定制 | 任意,无需重训 | 受限语言模型 | 固定 | 任意,需重训 |
| 延迟 | ~200ms | 较高 | ~12ms | 取决于设计 |
| 误唤醒率 | <1% | 偏高 | 优秀 | 可优化 |
| 开发成本 | 低(已有集成) | 极低(几行代码) | 极低(开箱) | 高 |
| 推荐场景 | 生产级端侧产品 | 快速原型、多语言 | 资源受限+固定词 | 特殊需求 |
我的建议
- 首选 sherpa‑onnx‑kws :如果你需要产品化的端侧唤醒,且希望保留未来扩展 ASR/TTS 的可能性,这是最均衡的选择------轻量、低误唤醒、自定义灵活。
- 快速验证选 Vosk :如果你只想在浏览器中几分钟跑通唤醒演示,且不介意模型大小和延迟,Vosk 能最快达成。
- 特殊情况用阿里"小云":当你的唤醒词恰巧是"小云小云"且硬件资源极其紧张时,这是最优解。
- 终极定制走自主训练:当以上方案都无法满足你的唤醒词、硬件或性能要求时,再投入资源自主训练。
附录:完整源码
所有已验证的源码已整理(公开):
- sherpa‑onnx‑kws 完整项目:python + Vue
- Vosk 纯前端唤醒: Vue 前端工程,模型已经下载
源码获取 :发送您的邮箱至
1049630498@qq.com,获取源码
本文从四条技术路线出发,重点剖析了已经过工程验证的 sherpa‑onnx‑kws 和 Vosk,并补充了阿里"小云"与自主训练两种可选方案。希望这份梳理能帮助你根据项目阶段、资源约束和功能需求,做出最适合的语音唤醒技术选型。如果你在具体实现中遇到问题,欢迎留言交流。