前段时间在推特上看到有人推荐"闪电说",说这个语音输入工具还挺快的。我试用了一下,发现它在下载一个ONNX格式的模型文件,然后就能完全离线进行语音转文字了。
这个思路挺有意思的。我想着自己也做一个类似的工具,完全本地化,不需要联网,所有数据都在本地处理,同时还要自动添加标点符号。
当时我想的是希望能够调用本地的AI来修正我自己的口头禅和一些错别字。因为我说话的时候经常带有一些停顿或者口头禅,如果直接发出来的话,不太好。我希望这个软件能加上这个特性。当时闪电说还没有看到有这个功能,我就想自己能够实现。后来闪电说也有这样的修正功能了。
我让AI教我怎么实现,经过一步一步的交互,最终花了10多个小时,把这个功能实现出来了。
需要说明的是,我一行swift代码都不会写,所有的代码都是让AI来实现的。整个过程就是我跟AI对话,描述需求,AI生成代码,我测试,遇到问题再问AI,这样循环往复。这也给大家一个鼓励,就是你不用会写代码,也可以开发Mac下的APP,而且也可以开发出一个非常棒的app。AI时代,想法和执行力比代码能力更重要。
今天来聊聊这个项目遇到的技术挑战和解决方案。整个开发过程遇到了不少问题,每个问题的解决都学到了很多。
项目目标
这个输入法需要满足几个核心需求:
- 实时语音识别,完全离线运行
- 低延迟,快速响应
- 自动添加标点符号
- 隐私安全,所有数据在本地处理
第一步:调用ONNX格式的大模型文件实现语音转文字
技术选型:SenseVoice-Small
因为我是直接看到闪电说是怎么实现的,所以没有做网上调研的过程。一上来,我们就从闪电说的数据目录里面找到了一个叫 SenseVoice 的模型。
这个模型是阿里巴巴达摩院开源的,我们用的是 SenseVoice-Small 版本。
这个模型有几个优点:多语言支持好,支持50多种语言,识别效果比 Whisper 更好;推理速度快,10秒音频只要70ms,比 Whisper-Large 快15倍;可以完全离线运行;模型体积适中,Small版本适合桌面应用;还提供了ONNX格式的模型文件,便于跨平台部署。
核心实现:ONNX Runtime集成
挑战是如何在Swift中调用ONNX Runtime来加载和运行模型。我们通过C桥接的方式,把ONNX Runtime的C API封装成Swift类。
加载模型:
class ONNXRuntimeWrapper {
private var session: OpaquePointer? // OrtSession*
private var env: OpaquePointer? // OrtEnv*
private var api: UnsafePointer<OrtApi>?
func loadModel(from path: String) throws {
// 创建会话选项
var sessionOptions: OpaquePointer?
var status = api.pointee.CreateSessionOptions(&sessionOptions)
// 创建会话
var session: OpaquePointer?
let pathCString = path.cString(using: .utf8)
status = api.pointee.CreateSession(
env,
pathCString,
sessionOptions,
&session
)
self.session = session
}
}
音频特征提取:
在调用模型之前,需要将音频转换为模型可以理解的格式。SenseVoice模型需要Mel频谱特征作为输入:
// 提取 Mel 频谱特征
let features = try await AudioFeatureExtractor.extractMelFeatures(
from: recording,
cmvnURL: cmvnURL
)
这个过程大概包括几个步骤:先把音频转成16kHz单声道PCM格式,然后用kaldi-native-fbank库计算80维Mel频谱特征,再做个LFR处理(Low Frame Rate降采样,m=7, n=6),最后应用CMVN归一化。
关于采样率的说明:
我们最开始录音的时候是按48kHz或44.1kHz采样的(具体记不清了),但最终给到模型的时候,模型只需要16kHz。这意味着无论你用什么质量的麦克风,最终都会被降采样到16kHz。所以如果只是用来做语音输入的话,没有必要买一些高质量的话筒,因为到最终都是一个非常低的质量给到模型的。普通的麦克风就完全够用了。
运行推理:
func runInference(input: [[[Float]]], language: TranscriptLanguage = .auto) throws -> [Int] {
// 准备输入张量
let batchSize: Int64 = 1
let sequenceLength: Int64 = Int64(input[0].count)
let featureDim: Int64 = Int64(input[0][0].count)
// 创建输入张量:speech [batch, sequence, feature_dim]
var inputTensor: OpaquePointer?
let shape: [Int64] = [batchSize, sequenceLength, featureDim]
status = api.pointee.CreateTensorWithDataAsOrtValue(
memoryInfo,
inputData,
flatInput.count * MemoryLayout<Float>.size,
shape,
3,
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,
&inputTensor
)
// 创建其他必需输入:speech_lengths, language, textnorm
// ...
// 运行推理
status = api.pointee.Run(
session,
runOptions,
&inputNamePtrs,
inputTensors.withUnsafeBufferPointer { $0.baseAddress },
inputTensors.count,
&outputNamePtrs,
outputNamePtrs.count,
&outputValues
)
// CTC解码:从logits中提取token IDs
let tokenIDs = try decodeCTCLogits(from: ctcLogits, actualLength: actualSequenceLength, api: api)
return tokenIDs
}
Token解码:
模型输出的是CTC logits,需要进行CTC解码:
private func decodeCTCLogits(from outputValue: OpaquePointer, actualLength: Int?, api: UnsafePointer<OrtApi>) throws -> [Int] {
// 1. 对每个时间步执行argmax,获取最可能的token
var tokenIDs: [Int] = []
for t in 0..<sequenceLength {
var maxValue: Float = -Float.infinity
var maxIndex: Int = 0
let startIdx = t * vocabSize
let endIdx = startIdx + vocabSize
for i in startIdx..<endIdx {
let value = floatPtr[i]
if value > maxValue {
maxValue = value
maxIndex = i - startIdx
}
}
// 过滤blank token(通常是0)
if maxIndex != 0 {
tokenIDs.append(maxIndex)
}
}
// 2. CTC去重:移除连续重复的token
var deduplicatedTokens: [Int] = []
var prevToken: Int? = nil
for token in tokenIDs {
if token != prevToken {
deduplicatedTokens.append(token)
prevToken = token
}
}
return deduplicatedTokens
}
文本转换:
最后,将token IDs映射回文本:
private static func postprocessTokens(tokenIDs: [Int], tokenMap: [Int: String]) -> String {
// 过滤特殊token(<s>, </s>, <unk>等)
var filteredTokens = tokenIDs.filter { token in
token != sosToken && token != eosToken && token != unkToken && token >= 0
}
// Token到文本转换,同时过滤SenseVoice特殊token(<|xxx|>格式)
var textParts: [String] = []
for tokenID in filteredTokens {
if let token = tokenMap[tokenID] {
let isSpecialToken = token.hasPrefix("<|") && token.hasSuffix("|>")
if !isSpecialToken {
textParts.append(token)
}
}
}
// 合并文本
let text = textParts.joined(separator: "")
return text
}
内存录音优化
最开始的时候,我是把录音写到本地硬盘文件上,然后再读取文件传给模型。后来发现对于比较短的音频文件,没有必要写到硬盘文件上面,直接在内存里处理就好了。
这个优化带来的好处还挺明显的:减少磁盘I/O开销,提升性能;降低延迟,不需要等待文件写入和读取;简化代码,不需要管理临时文件;减少磁盘占用,特别是频繁使用的时候。
实现上,我们直接用 AVAudioEngine 把音频数据捕获到内存中,录音结束后直接将内存中的数据传给模型进行处理。对于语音输入这种短音频场景,内存完全够用,不需要写文件。
第二步:CMVN文件和窗口函数的实现
这一步是语音转文字的基础,如果没有实现这一步,模型根本没办法将语音转换成文字。
问题发现
在实现音频特征提取时,我们发现需要应用CMVN(Cepstral Mean and Variance Normalization)归一化。CMVN文件(am.mvn)包含了全局的均值和方差统计信息,用于对音频特征进行归一化。
第一次尝试:Swift原生实现
最开始的时候,我想让AI用Swift来实现一个fbank库。最初,我们尝试在Swift中实现窗口函数(Window Function)和Mel频谱计算:
// 尝试1:使用Swift实现窗口函数
private static func applyWindowFunction(_ samples: [Float], windowType: String) -> [Float] {
let frameLength = samples.count
var windowed = samples
switch windowType {
case "hamming":
for i in 0..<frameLength {
let a = 2.0 * Double.pi / Double(frameLength - 1)
windowed[i] = Float(0.54 - 0.46 * cos(a * Double(i)))
}
case "hanning":
for i in 0..<frameLength {
let a = 2.0 * Double.pi / Double(frameLength - 1)
windowed[i] = Float(0.5 - 0.5 * cos(a * Double(i)))
}
// ... 其他窗口类型
}
return windowed
}
但遇到了几个问题:Swift实现的窗口函数与Kaldi的标准实现存在细微差异;纯Swift实现速度较慢;特征提取结果与Python参考实现不一致。
第二次尝试:使用C库
后来发现Swift实现的问题太多,就改用了原生的kaldi-native-fbank库。这个库提供了标准的Kaldi特征提取实现,是用C++编写的,与Kaldi完全兼容。
解决方案是通过C桥接调用kaldi-native-fbank。
创建C桥接头文件:
// KaldiFbankBridge.h
#ifndef KaldiFbankBridge_h
#define KaldiFbankBridge_h
#include <stdbool.h>
typedef void* KaldiFbankHandle;
KaldiFbankHandle KaldiFbankCreate(
int sampleRate,
int numMelBins,
float frameLengthMs,
float frameShiftMs,
const char *windowType,
float dither,
bool snipEdges
);
void KaldiFbankAcceptWaveform(KaldiFbankHandle handle, const float *waveform, int numSamples);
int KaldiFbankNumFramesReady(KaldiFbankHandle handle);
void KaldiFbankGetFrame(KaldiFbankHandle handle, int frameIndex, float *features);
void KaldiFbankDestroy(KaldiFbankHandle handle);
#endif /* KaldiFbankBridge_h */
实现桥接代码:
// KaldiFbankBridge.mm (Objective-C++)
#import "KaldiFbankBridge.h"
#include "../../ThirdParty/kaldi-native-fbank/csrc/online-feature.h"
#include "../../ThirdParty/kaldi-native-fbank/csrc/feature-window.cc"
#include "../../ThirdParty/kaldi-native-fbank/csrc/feature-functions.cc"
// ... 其他源文件
struct KaldiFbankHandleWrapper {
knf::FbankOptions opts;
std::unique_ptr<knf::OnlineFbank> fbank;
};
KaldiFbankHandle KaldiFbankCreate(
int sampleRate,
int numMelBins,
float frameLengthMs,
float frameShiftMs,
const char *windowType,
float dither,
bool snipEdges) {
auto wrapper = new KaldiFbankHandleWrapper();
wrapper->opts.frame_opts.samp_freq = sampleRate;
wrapper->opts.frame_opts.dither = dither;
wrapper->opts.frame_opts.window_type = windowType;
wrapper->opts.frame_opts.frame_shift_ms = frameShiftMs;
wrapper->opts.frame_opts.frame_length_ms = frameLengthMs;
wrapper->opts.mel_opts.num_bins = numMelBins;
wrapper->opts.frame_opts.snip_edges = snipEdges;
return wrapper;
}
void KaldiFbankAcceptWaveform(KaldiFbankHandle handle, const float *waveform, int numSamples) {
auto wrapper = static_cast<KaldiFbankHandleWrapper*>(handle);
if (!wrapper->fbank) {
wrapper->fbank = std::make_unique<knf::OnlineFbank>(wrapper->opts);
}
std::vector<float> waveformVec(waveform, waveform + numSamples);
wrapper->fbank->AcceptWaveform(wrapper->opts.frame_opts.samp_freq, waveformVec);
}
int KaldiFbankNumFramesReady(KaldiFbankHandle handle) {
auto wrapper = static_cast<KaldiFbankHandleWrapper*>(handle);
return wrapper->fbank ? wrapper->fbank->NumFramesReady() : 0;
}
void KaldiFbankGetFrame(KaldiFbankHandle handle, int frameIndex, float *features) {
auto wrapper = static_cast<KaldiFbankHandleWrapper*>(handle);
if (wrapper->fbank) {
auto frame = wrapper->fbank->GetFrame(frameIndex);
std::copy(frame.begin(), frame.end(), features);
}
}
在Swift中使用:
static func computeKaldiFbankFeatures(audioData: [Float]) throws -> [[Float]] {
// 创建Kaldi FBank处理器
guard let handle = KaldiFbankCreate(
16000, // sampleRate
80, // numMelBins
25.0, // frameLengthMs
10.0, // frameShiftMs
"hamming", // windowType
1.0, // dither
true // snipEdges
) else {
throw VideoProcessingError.transcriptionFailed("无法创建Kaldi FBank处理器")
}
defer {
KaldiFbankDestroy(handle)
}
// 转换音频数据:从[-1, 1]范围转换为Int16范围
let int16Waveform = audioData.map { $0 * Float(Int16.max) }
// 输入音频数据
int16Waveform.withUnsafeBufferPointer { buffer in
KaldiFbankAcceptWaveform(handle, buffer.baseAddress, Int32(buffer.count))
}
// 提取所有帧的特征
let numFrames = KaldiFbankNumFramesReady(handle)
var features: [[Float]] = []
for i in 0..<numFrames {
var frameFeatures = [Float](repeating: 0, count: 80)
frameFeatures.withUnsafeMutableBufferPointer { buffer in
KaldiFbankGetFrame(handle, Int32(i), buffer.baseAddress)
}
features.append(frameFeatures)
}
return features
}
CMVN归一化实现:
/// 加载CMVN文件(am.mvn)
/// CMVN文件格式:两行,第一行是均值,第二行是方差
private static func loadCMVN(from url: URL) throws -> (means: [Float], vars: [Float]) {
let content = try String(contentsOf: url, encoding: .utf8)
let lines = content.components(separatedBy: .newlines).filter { !$0.isEmpty }
guard lines.count >= 2 else {
throw VideoProcessingError.transcriptionFailed("CMVN文件格式错误")
}
let means = lines[0].components(separatedBy: .whitespaces)
.compactMap { Float($0) }
let vars = lines[1].components(separatedBy: .whitespaces)
.compactMap { Float($0) }
guard means.count == vars.count else {
throw VideoProcessingError.transcriptionFailed("CMVN均值和方差维度不匹配")
}
return (means, vars)
}
/// 应用Global CMVN
/// Python逻辑: (inputs + means) * vars
private static func applyGlobalCMVN(features: [[Float]], means: [Float], vars: [Float]) -> [[Float]] {
guard !features.isEmpty else { return features }
let dim = features[0].count
guard means.count == dim, vars.count == dim else {
print("警告:CMVN维度不匹配,跳过CMVN")
return features
}
var normalizedFeatures = features
for i in 0..<features.count {
var frame = features[i]
// frame = frame + means
vDSP_vadd(frame, 1, means, 1, &frame, 1, vDSP_Length(dim))
// frame = frame * vars
vDSP_vmul(frame, 1, vars, 1, &frame, 1, vDSP_Length(dim))
normalizedFeatures[i] = frame
}
return normalizedFeatures
}
这个问题的解决让我认识到,不要重复造轮子。当有成熟的C/C++库可用时,通过桥接调用比重新实现更可靠。音频特征提取的精度直接影响识别效果,使用标准库可以保证兼容性。
第三步:标点符号支持的实现
问题发现
在最初的实现中,我们发现模型输出的文本没有标点符号,这严重影响了文本的可读性。例如:
原始输出:我今天想要去超市买点东西顺便看看有没有什么优惠活动
期望输出:我今天想要去超市买点东西,顺便看看有没有什么优惠活动。
问题分析
通过查看SenseVoice的Python实现代码,我们发现模型实际上支持标点符号输出,但需要通过textnorm参数来控制。在Python代码中:
# SenseVoice Python实现
res = model.generate(
input=input_wav,
language=language,
use_itn=True, # 关键参数:是否包含标点符号
batch_size_s=60,
merge_vad=True
)
use_itn=True对应textnorm="withitn",值为14;use_itn=False对应textnorm="woitn",值为15。
解决方案
在ONNX Runtime调用中,我们需要添加textnorm输入参数:
// 4. textnorm 输入(文本规范化)
// textnormDict = { "withitn": 14, "woitn": 15 }
// use_itn=True -> textnorm="withitn" -> 14(包含标点符号)
// use_itn=False -> textnorm="woitn" -> 15(不包含标点符号)
var textnormTensor: OpaquePointer?
let textnormDataValue: Int32 = 14 // 14 = withitn (with ITN,包含标点符号)
let textnormData: [Int32] = [textnormDataValue]
textnormData.withUnsafeBufferPointer { buffer in
let shape: [Int64] = [batchSize]
var tensor: OpaquePointer?
let status = api.pointee.CreateTensorWithDataAsOrtValue(
memoryInfo,
UnsafeMutableRawPointer(mutating: buffer.baseAddress!),
textnormData.count * MemoryLayout<Int32>.size,
shape,
1,
ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32,
&tensor
)
textnormTensor = tensor
}
设置textnorm=14后,模型输出的文本就自动包含了标点符号。
这个问题的解决让我意识到,仔细阅读官方文档和示例代码很重要。很多功能其实已经实现了,只是需要找到正确的参数。
第四步:FN快捷键的特殊实现
问题背景
我们希望用户可以通过快捷键来触发语音输入。最初我们尝试使用标准的全局快捷键API,但发现FN键的实现方式与其他按键完全不同。
FN键的特殊性
FN键在macOS中比较特殊。它是一个修饰键,不是普通按键;它通过flagsChanged事件触发,而不是keyDown/keyUp;需要检查event.modifierFlags.contains(.function)标志位;还可以与其他修饰键(如Ctrl)组合使用。
实现方案
我们创建了专门的FN键处理逻辑:
private func handleFNKeyEvent(_ event: NSEvent, targetModifiers: NSEvent.ModifierFlags) {
let monitoredModifiers: NSEvent.ModifierFlags = [.command, .shift, .option, .control]
let eventModifiers = event.modifierFlags.intersection(monitoredModifiers)
let hasFunctionFlag = event.modifierFlags.contains(.function)
// 严格检查FN键:必须同时满足keyCode匹配和function标志位
let isFNKeyByCode = event.keyCode == 0x3F
let isFNKey = isFNKeyByCode && hasFunctionFlag
switch event.type {
case .flagsChanged:
// 如果之前按下FN,但现在function标志消失,认为是释放
if isKeyPressed && !hasFunctionFlag {
triggerFNRelease()
return
}
// 只有当function标志位存在时才处理
guard hasFunctionFlag else {
return
}
// 检查是否按下了Ctrl键(FN+Ctrl组合)
let hasCtrl = eventModifiers.contains(.control)
isCtrlPressedWithFN = hasCtrl
if !isKeyPressed {
isKeyPressed = true
hasOtherKeyPressedWithFN = false
lastFNKeyEventTime = Date()
// 延迟一小段时间触发,确保没有其他键按下(防止快速打字时误触发)
fnKeyReleaseTimer?.invalidate()
fnKeyReleaseTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false) { [weak self] _ in
guard let self = self, self.isKeyPressed, !self.hasOtherKeyPressedWithFN else { return }
let ctrlState = self.isCtrlPressedWithFN
DispatchQueue.main.async {
// 优先使用带Ctrl状态的回调
if let callback = self.onShortcutPressedWithCtrl {
callback(ctrlState)
} else {
self.onShortcutPressed?()
}
}
}
}
case .keyDown:
// 如果FN键按下后,又按了其他键,标记为"按了其他键"
if isKeyPressed && !isFNKey {
hasOtherKeyPressedWithFN = true
fnKeyReleaseTimer?.invalidate()
fnKeyReleaseTimer = nil
}
case .keyUp:
guard isFNKey else { return }
if isKeyPressed {
triggerFNRelease()
}
default:
break
}
}
几个关键点:使用0.05秒的延迟来区分"单独按FN"和"FN+其他键"的情况;跟踪isKeyPressed和hasOtherKeyPressedWithFN状态,避免误触发;支持FN+Ctrl组合,用于触发不同的功能。
普通快捷键(如Cmd+Space)的实现相对简单,而FN键需要检查flagsChanged事件、检查.function标志位、处理延迟触发和处理组合键状态。
第五步:AI错别字纠正的尝试与未来计划
需求背景
在语音识别完成后,我们希望进一步优化文本质量,包括去除口头禅、修正错别字和同音字错误、添加标点符号等。
技术选型:Ollama + DeepSeek-R1
我们选择了Ollama作为本地AI服务框架,尝试了几个模型:gemma2:2b(Google的小模型,速度快但效果一般)、gemma3:1b(更小的模型,速度更快但效果更差)、deepseek-r1:1.5b(DeepSeek的R1系列,专门优化了推理能力)。
实现方案
集成Ollama服务:
class OllamaService {
static let shared = OllamaService()
func optimizeTranscript(
text: String,
endpoint: String,
model: String,
apiToken: String?,
timeout: TimeInterval = 5.0,
systemPrompt: String
) async throws -> String {
// 构建请求体
let requestBody: [String: Any] = [
"model": model,
"prompt": text,
"system": systemPrompt,
"stream": false,
"options": [
"temperature": 0.3,
"top_p": 0.9
]
]
// 发送请求
guard let url = URL(string: "\(endpoint)/api/generate") else {
throw OllamaError.invalidEndpoint
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
request.timeoutInterval = timeout
let (data, response) = try await URLSession.shared.data(for: request)
// ... 解析响应
}
}
我们设计了详细的系统提示词来指导AI进行文本优化,包括去除水词和口头禅、添加标点符号、修正错别字等要求。
性能测试结果
我们在MacBook Pro上测试了不同模型的性能:
| 模型 | 平均响应时间 | 轻微修改分数 | 优秀测试数 |
|---|---|---|---|
| gemma2:2b | 2.5秒 | 45/100 | 3/8 |
| gemma3:1b | 1.8秒 | 38/100 | 2/8 |
| deepseek-r1:1.5b | 5.2秒 | 61.3/100 | 5/8 |
但有个问题:DeepSeek-R1虽然效果最好,但响应时间超过5秒,严重影响用户体验;小模型虽然速度快,但优化效果不理想。
性能优化尝试
为了提升DeepSeek-R1的性能,我们尝试了多种方案:
我们尝试了几种方案:优化Ollama配置(设置环境变量优化性能),但效果不明显;绕过Ollama,直接使用llama.cpp来运行GGUF格式的模型,但性能测试显示平均4.8秒,仍然太慢;使用更小的模型
我们还尝试了更小的模型,但效果都不理想。qwen2:0.5b速度1.2秒,但效果很差,轻微修改分数只有25/100。
最终决定:暂时放弃本地AI优化功能
经过多次测试和优化尝试,我们最终决定暂时放弃本地AI错别字纠正功能。主要原因:即使在最好的情况下,响应时间也需要5秒以上,这对于实时输入场景来说太慢了;用户需要等待5秒才能看到优化后的文本,这比直接使用原始文本更影响体验;运行大模型需要消耗大量CPU/GPU资源,影响系统其他应用的性能;虽然AI可以去除一些口头禅,但SenseVoice模型本身已经支持标点符号,基本需求已经满足。
未来计划:云端AI优化服务
虽然暂时放弃了本地AI优化,但我计划未来在中国境内部署一个小模型,专门用来进行错别字纠正和常见口头禅、水词的去除。这样的话,大家可以直接用线上的服务,就能实现AI纠正功能。云端服务可以保证更快的响应速度和更好的效果,同时不需要消耗本地资源。
保留的功能
虽然暂时放弃了本地AI优化,但我们保留了语音识别、标点符号(通过设置textnorm=14,模型自动输出带标点的文本)和基础文本清理(去除多余空格等)这些功能。
这个尝试让我更清楚地理解了性能与功能的平衡。对于实时交互应用,5秒的延迟是不可接受的。在消费级硬件上运行大模型,性能和效果往往难以兼顾。但通过云端服务,我们可以获得更好的性能和效果。
技术栈
用到的技术栈:Swift开发语言,SwiftUI做UI,AVFoundation处理音频,SenseVoice-Small (ONNX)做语音识别模型,ONNX Runtime做推理引擎,kaldi-native-fbank (C++库,通过桥接调用)做特征提取,Carbon/Cocoa Event Handling处理全局快捷键。
性能指标
性能表现:10秒音频约70ms推理时间;从录音结束到文本输出,约200-300ms(包括特征提取和推理);内存占用约100-200MB(包括模型加载);推理时CPU占用约10-20%(单核)。
未来改进方向
接下来计划做几个改进:支持实时显示识别结果,而不是等待录音结束;完善语言切换和自动检测功能(虽然SenseVoice模型本身支持多语言);允许用户选择不同的语音识别模型;支持下载和管理多个模型;添加性能指标监控,帮助用户优化设置。
开源计划
这个项目我会开源出来。后续我会在网上持续分享我是怎么一步步加各种改进的,包括新功能的实现过程、遇到的问题和解决方案。如果你对这个项目感兴趣,欢迎关注和参与。
总结
通过这次开发,我们成功实现了一款完全本地化的语音转文字输入法。整个开发过程遇到了不少问题,但每个问题的解决都学到了很多。
学会了如何在Swift中调用C API,理解了模型推理的完整流程;通过仔细阅读文档和源码,找到了标点符号的正确参数配置;深入理解了macOS的事件处理机制,特别是修饰键的特殊性;认识到使用成熟库的重要性,避免了重复造轮子;虽然本地AI优化暂时放弃了,但这个过程让我更清楚地理解了性能与功能的平衡,也为未来的云端AI优化服务打下了基础。
这些技术的结合,让我在保证隐私安全的前提下,实现了低延迟、高质量的语音输入体验。希望这篇文章对正在开发类似应用的开发者有所帮助。如果你有任何问题或建议,欢迎交流讨论!
如果你也想实现这样一个应用,基本上只需要把我这篇博客的地址告诉AI,让AI来访问这篇博客就可以实现一套类似的app了。因为我们核心的技术点都已经在博客里详细说明了,包括ONNX Runtime集成、CMVN文件和窗口函数的实现、标点符号支持、FN快捷键处理等等。AI可以根据这些技术细节帮你生成代码,实现类似的功能。