标签: 语音指令

  • Unity接入Whisper做语音指令

    Unity接入Whisper做语音指令

    本文解决什么问题:给独立游戏加一套本地中文语音指令

    做独立游戏时,我常想给玩家一个「解放双手」的操作方式:喊一声「攻击」,角色就出手;喊「暂停」,游戏就停。这篇教程记录我在本机把 Unity 接入 Whisper 做语音指令 的完整过程——不走云端 API,全程本地跑 whisper.cpp,做出一个能识别「上/下/攻击/暂停」的可运行 Demo,并把我踩过的麦克风权限、WAV 采样率、中文路径、识别延迟四个坑一次讲清。

    适合谁

    • 有 Unity C# 基础,想给游戏加语音控制、但不想接付费云识别的独立开发者。
    • 对本地大模型/语音识别落地感兴趣,希望离线、可控、无网络延迟的同学。

    实验环境(作者本机实测)

    下文所有代码与结论均在以下环境跑通,日期 2026-06:

    • 操作系统:Windows 11 23H2
    • 引擎:Unity 2022.3.21f1 LTS(2D 模板)
    • 语音识别:whisper.cpp(2024 年后可执行文件名为 whisper-cli.exe,旧版叫 main.exe
    • 模型:ggml-small.bin(约 466MB,中文识别精度/速度折中)
    • 脚本运行时:Unity 内置 Mono,.NET Standard 2.1

    整体流程

    麦克风输入 AudioClip WAV(16kHz) whisper.cpp 中文文本 指令映射 角色控制
    图 1:Unity 本地语音指令数据流——录音后落地成 16kHz WAV,交给 whisper.cpp 转写,再把文本映射为游戏指令。

    Unity 项目准备:场景、麦克风与 whisper.cpp

    建测试场景

    新建一个 2D 项目,在场景里放一个 Sprite 当作「玩家」(比如一个方块),挂上后面的控制脚本。UI 上加一行提示文字「按住 V 说话」即可,Demo 不需要复杂美术。

    准备 whisper.cpp 与模型

    从 whisper.cpp 官方仓库(github.com/ggerganov/whisper.cpp,访问日期 2026-06)获取 Windows 预编译包,或用 CMake 自行编译,得到 whisper-cli.exe。模型从 Hugging Face 仓库 ggerganov/whisper.cpp 下载 ggml-small.bin

    关键:把 exe 和模型放在纯英文路径下(我放的是 D:\whisper\),原因见后文踩坑小节。目录结构:

    D:\whisper\
    ├─ whisper-cli.exe
    └─ models\
       └─ ggml-small.bin

    先在命令行验证 whisper.cpp 本身能跑(用任意一段 16kHz 的中文 wav):

    whisper-cli.exe -m models\ggml-small.bin -f test.wav -l zh -nt

    其中 -l zh 指定中文,-nt 表示不输出时间戳、只打印纯文本,方便后面直接读它的标准输出。

    实操一:C# 录制麦克风并保存为 Whisper 可识别的 WAV

    whisper.cpp 内置的 WAV 读取器只认 16kHz、16-bit、单声道 PCM。Unity 的 Microphone.Start 允许直接指定采样率,所以我在录音源头就锁死 16000Hz,省掉后期重采样。

    VoiceRecorder.cs——负责录音并落地 WAV:

    using System.IO;
    using UnityEngine;
    
    public class VoiceRecorder : MonoBehaviour
    {
        const int SampleRate = 16000;   // Whisper 只吃 16kHz
        const int MaxSeconds = 5;       // 单条指令最长录音时长
    
        AudioClip _clip;
        string _device;
    
        void Start()
        {
            if (Microphone.devices.Length == 0)
            {
                Debug.LogError("未检测到麦克风设备,请检查系统权限");
                return;
            }
            _device = Microphone.devices[0];
            Debug.Log($"使用麦克风:{_device}");
        }
    
        public void StartRecord()
        {
            // 第 4 个参数强制 16kHz,从源头避免采样率不匹配
            _clip = Microphone.Start(_device, false, MaxSeconds, SampleRate);
        }
    
        // 结束录音,写成 wav 并返回文件路径
        public string StopAndSave()
        {
            int pos = Microphone.GetPosition(_device);
            Microphone.End(_device);
            if (_clip == null || pos <= 0) return null;
    
            var samples = new float[pos * _clip.channels];
            _clip.GetData(samples, 0);   // 只取实际录到的采样,避免尾部空白
    
            string path = Path.Combine(Application.persistentDataPath, "cmd.wav");
            WavUtility.Save(path, samples, SampleRate, _clip.channels);
            return path;
        }
    }

    WavUtility.cs——把 Unity 的 float 采样写成标准 16-bit PCM WAV。手写 44 字节头,别偷懒用第三方库,方便你自己核对每个字段:

    using System.IO;
    using System.Text;
    using UnityEngine;
    
    public static class WavUtility
    {
        static void Tag(BinaryWriter bw, string t) => bw.Write(Encoding.ASCII.GetBytes(t));
    
        public static void Save(string path, float[] samples, int sampleRate, int channels)
        {
            int byteRate = sampleRate * channels * 2;   // 16-bit = 2 字节
            int dataSize = samples.Length * 2;
    
            using var fs = new FileStream(path, FileMode.Create);
            using var bw = new BinaryWriter(fs);
    
            Tag(bw, "RIFF"); bw.Write(36 + dataSize); Tag(bw, "WAVE");
            Tag(bw, "fmt "); bw.Write(16);            // fmt 块大小
            bw.Write((short)1);                       // PCM
            bw.Write((short)channels);
            bw.Write(sampleRate);
            bw.Write(byteRate);
            bw.Write((short)(channels * 2));          // block align
            bw.Write((short)16);                      // 位深
            Tag(bw, "data"); bw.Write(dataSize);
    
            foreach (var s in samples)
            {
                short v = (short)(Mathf.Clamp(s, -1f, 1f) * short.MaxValue);
                bw.Write(v);
            }
        }
    }

    实操二:调用本地 Whisper 转写并映射成游戏指令

    转写就是用 System.Diagnostics.Process 起一个 whisper.cpp 进程,读它的标准输出。注意 StandardOutputEncoding 一定要设成 UTF-8,否则中文会变乱码。

    WhisperRunner.cs

    using System.Diagnostics;
    using System.Text;
    
    public static class WhisperRunner
    {
        // exe 与模型放英文路径,避免中文路径踩坑
        const string ExePath   = @"D:\whisper\whisper-cli.exe";
        const string ModelPath = @"D:\whisper\models\ggml-small.bin";
    
        public static string Transcribe(string wavPath)
        {
            var psi = new ProcessStartInfo
            {
                FileName  = ExePath,
                Arguments = $"-m \"{ModelPath}\" -f \"{wavPath}\" -l zh -nt",
                RedirectStandardOutput = true,
                RedirectStandardError  = true,
                UseShellExecute = false,
                CreateNoWindow  = true,
                StandardOutputEncoding = Encoding.UTF8   // 关键:中文不乱码
            };
    
            using var p = Process.Start(psi);
            string output = p.StandardOutput.ReadToEnd();
            p.WaitForExit();
            return output.Trim();
        }
    }

    Whisper 的中文输出可能带标点(比如「攻击。」),所以映射时用 Contains 做包含匹配,比精确相等鲁棒得多。

    CommandMapper.cs

    public enum VoiceCommand { None, Up, Down, Attack, Pause }
    
    public static class CommandMapper
    {
        public static VoiceCommand Map(string text)
        {
            if (string.IsNullOrEmpty(text)) return VoiceCommand.None;
            if (text.Contains("上"))   return VoiceCommand.Up;
            if (text.Contains("下"))   return VoiceCommand.Down;
            if (text.Contains("攻击")) return VoiceCommand.Attack;
            if (text.Contains("暂停")) return VoiceCommand.Pause;
            return VoiceCommand.None;
        }
    }

    实操三:接入角色控制,跑通语音 Demo

    把三块拼起来:按住 V 录音,松开就转写并执行。转写必须放到后台线程——whisper.cpp 是同步阻塞的,直接在主线程调用会让整个 Unity 画面卡住半秒到几秒,这是我第一版最直观的翻车点。用 Task.Run 丢到线程池,await 回来后再改 Transform(Unity API 只能在主线程调,而 async void 续接后仍在主线程,安全)。

    VoiceController.cs

    using System.Threading.Tasks;
    using UnityEngine;
    
    public class VoiceController : MonoBehaviour
    {
        public VoiceRecorder recorder;
        public Transform player;
        public float step = 1f;
    
        bool _busy;
    
        void Update()
        {
            if (Input.GetKeyDown(KeyCode.V)) recorder.StartRecord();
            if (Input.GetKeyUp(KeyCode.V) && !_busy) HandleVoice();
        }
    
        async void HandleVoice()
        {
            _busy = true;
            string wav = recorder.StopAndSave();
            if (wav == null) { _busy = false; return; }
    
            var sw = System.Diagnostics.Stopwatch.StartNew();
            // 转写放后台线程,避免卡住主线程
            string text = await Task.Run(() => WhisperRunner.Transcribe(wav));
            sw.Stop();
    
            Debug.Log($"识别结果:{text}(耗时 {sw.ElapsedMilliseconds} ms)");
            Apply(CommandMapper.Map(text));   // 回到主线程后再动 Transform
            _busy = false;
        }
    
        void Apply(VoiceCommand cmd)
        {
            switch (cmd)
            {
                case VoiceCommand.Up:     player.position += Vector3.up   * step; break;
                case VoiceCommand.Down:   player.position += Vector3.down * step; break;
                case VoiceCommand.Attack: Debug.Log("触发攻击动作"); break;
                case VoiceCommand.Pause:
                    Time.timeScale = Time.timeScale > 0 ? 0 : 1; break;
            }
        }
    }

    在场景里把 VoiceRecorderVoiceController 挂到同一个空物体上,把玩家 Sprite 拖到 player 字段、把 recorder 引用连好。运行后按住 V 说「上」,方块上移;说「暂停」,游戏冻结。我本机识别结果长这样:

    识别结果:攻击(耗时 1180 ms)
    识别结果:暂停(耗时 1063 ms)

    真实踩坑与报错处理

    坑 1:麦克风没权限,Microphone.devices 为空

    现象:Microphone.devices.Length == 0,或录出来全是静音。Windows 11 下要去「设置 → 隐私和安全性 → 麦克风」,同时打开「麦克风访问」和「让桌面应用访问你的麦克风」——注意 Unity 编辑器属于桌面应用,很多人只开了前者。改完重启 Unity 编辑器再测。

    坑 2:WAV 采样率不对,whisper.cpp 直接报错

    如果偷懒用了设备默认的 44100Hz 录音再喂给 whisper.cpp,会看到类似:

    error: read_wav: WAV file 'cmd.wav' must be 16 kHz

    解决办法就是本文的做法——在 Microphone.Start 里直接指定 16000,源头对齐,别指望后期转。写 WAV 头时 sampleRate 字段也要跟着写 16000,否则头里标错一样报错。

    坑 3:模型/音频路径含中文,进程静默失败

    whisper.cpp 在 Windows 上对 UTF-8 路径处理不稳,路径里带中文(比如放在「D:\语音模型\」)时经常读不到文件、进程返回空字符串却不报明显错误。我最初就是卡在这——把 exe、模型统一挪到 D:\whisper\ 纯英文目录后立刻正常。Unity 的 Application.persistentDataPath 一般也是英文,问题主要出在模型侧。

    坑 4:识别延迟过高,操作跟不上

    ggml-small 在纯 CPU 上跑一条 2–3 秒的短指令,我这台 Ryzen 5 大约 1–1.5 秒返回,做回合制或菜单操作够用;但要做实时动作就偏慢。我实测的几个提速方向:

    • 换更小的模型ggml-baseggml-tiny,短指令场景精度损失有限,延迟能压到几百毫秒。
    • 用量化模型:如 ggml-small-q5_0.bin,体积和耗时都更低。
    • 缩短录音窗口:指令通常 1–2 秒就够,别录满 5 秒,音频越短转写越快。
    • 务必走后台线程(见实操三),否则再快也会有肉眼可见的卡顿。

    小结与完整代码

    整套方案的核心就三步:Unity 端用 Microphone 以 16kHz 录音并手写成 16-bit PCM WAV → Process 调本地 whisper.cpp 转写中文 → 用 Contains 把文本映射成指令驱动角色。相比接云端 API,本地方案离线、无调用费、数据不出机,代价是要自己扛模型体积和 CPU 延迟。

    项目结构一目了然:一个 2D 场景 + 一个玩家 Sprite + 四个脚本(VoiceRecorderWavUtilityWhisperRunnerCommandMapperVoiceController),whisper.cpp 与 ggml-small.bin 放在 D:\whisper\。上文已给出全部脚本,可按步骤复制到本地项目中运行;把 WhisperRunner 里的 ExePathModelPath 改成你自己的实际路径即可。想扩展就往 CommandMapper 里加词、往 VoiceController.Apply 里加动作,指令集可以自由生长。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。本文使用 AI 辅助整理代码与排查报错,上述识别结果、耗时与踩坑均为作者本机(Unity 2022.3.21f1 / Windows 11 / whisper.cpp + ggml-small / 2026-06)实测复现。如发现事实或代码错误,欢迎在评论区指正。