语音唤醒技术选型与实践:从开源方案到自主训练

语音唤醒技术选型与实践:从开源方案到自主训练

引言

语音唤醒(Keyword Spotting, KWS)是语音交互的"守门员"------设备在低功耗持续监听中,精准识别特定唤醒词,从而触发后续指令。在智慧指挥调度、智能语音、智能音箱、车载助手、可穿戴设备等端侧场景中,KWS 面临三大硬指标:低延迟 (≤200ms)、低功耗 (模型需轻量)、高隐私(数据不出设备)。

本文将从工程角度,系统梳理四条主流技术路线:

  1. sherpa‑onnx‑kws ------ 生产级端侧语音工具包
  2. Vosk ------ 通用离线语音识别引擎
  3. 阿里"小云" ------ 专用固定唤醒词模型
  4. 自主训练 ------ 完全定制的"高级玩家"路线

一、四条技术路线速览

路线 核心定位 模型大小 关键词定制 部署难度 典型场景
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 典型流程

  1. 数据准备:收集正样本(包含唤醒词的语音)和负样本(噪音、其他语音)。
  2. 模型设计 :选用轻量网络如 TC-ResNetCRNNTDNN,参数量可控制在 100k 以内。
  3. 训练与部署:使用 PyTorch/TensorFlow 训练,导出为 ONNX 或 TFLite Micro,部署到目标硬件。
  4. 优化:量化、剪枝、知识蒸馏等进一步提升端侧性能。

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,并补充了阿里"小云"与自主训练两种可选方案。希望这份梳理能帮助你根据项目阶段、资源约束和功能需求,做出最适合的语音唤醒技术选型。如果你在具体实现中遇到问题,欢迎留言交流。

相关推荐
OpsEye2 小时前
直连大模型API、开源网关、商用AI管理平台,该如何抉择
人工智能·开源
ClouGence2 小时前
2026 年 4 款数据库管理工具推荐:免费、开源、付费怎么选?
数据库·后端·开源
人工智能研究所3 小时前
正面硬刚 Seedance 2.5, 开源视频模型:LTX-2.5 用 6.8 秒生成 10 秒视频
人工智能·开源·音视频·文生视频·seedance·ltx-2.5
ZJU_统一阿萨姆3 小时前
【推理优化进阶】图编译与运行时:动态形状、CUDA Graph 与内存规划
开发语言·人工智能·语言模型·架构·开源
小玮看世界3 小时前
AtomGit 429 报错引发的思考:从“未授权限流”看开源平台的“浏览权”博弈
开源·github
敢敢是只喵i4 小时前
BailingHub 开源版安装完成后,怎样跑通第一条可验证的 Agent 任务?
开源
极创信息4 小时前
国产化信创适配认证高频术语:信创适配、软件自主可控、国产化率、代码溯源率、代码自主率、代码开源率是什么?
java·python·struts·eclipse·开源·php·hibernate
m4Rk_5 小时前
【论文阅读】Agent 记忆机制(43):Mem²Evolve——让经验与能力在双记忆中共同进化
论文阅读·人工智能·学习·开源·github
java_logo6 小时前
Docker 部署 openGauss:轻松搭建企业级开源关系型数据库平台
数据库·docker·开源·opengauss·轩辕镜像·opengauss部署教程·opengauss部署文档