本文解决什么问题:给独立游戏加一套本地中文语音指令
做独立游戏时,我常想给玩家一个「解放双手」的操作方式:喊一声「攻击」,角色就出手;喊「暂停」,游戏就停。这篇教程记录我在本机把 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
整体流程
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;
}
}
}
在场景里把 VoiceRecorder、VoiceController 挂到同一个空物体上,把玩家 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-base或ggml-tiny,短指令场景精度损失有限,延迟能压到几百毫秒。 - 用量化模型:如
ggml-small-q5_0.bin,体积和耗时都更低。 - 缩短录音窗口:指令通常 1–2 秒就够,别录满 5 秒,音频越短转写越快。
- 务必走后台线程(见实操三),否则再快也会有肉眼可见的卡顿。
小结与完整代码
整套方案的核心就三步:Unity 端用 Microphone 以 16kHz 录音并手写成 16-bit PCM WAV → Process 调本地 whisper.cpp 转写中文 → 用 Contains 把文本映射成指令驱动角色。相比接云端 API,本地方案离线、无调用费、数据不出机,代价是要自己扛模型体积和 CPU 延迟。
项目结构一目了然:一个 2D 场景 + 一个玩家 Sprite + 四个脚本(VoiceRecorder、WavUtility、WhisperRunner、CommandMapper、VoiceController),whisper.cpp 与 ggml-small.bin 放在 D:\whisper\。上文已给出全部脚本,可按步骤复制到本地项目中运行;把 WhisperRunner 里的 ExePath、ModelPath 改成你自己的实际路径即可。想扩展就往 CommandMapper 里加词、往 VoiceController.Apply 里加动作,指令集可以自由生长。
