魔法施展中...

从零到一:打造一款高效的语音转文字输入法

5 分钟
...

前段时间在推特上看到有人推荐"闪电说",说这个语音输入工具还挺快的。我试用了一下,发现它在下载一个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+其他键"的情况;跟踪isKeyPressedhasOtherKeyPressedWithFN状态,避免误触发;支持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可以根据这些技术细节帮你生成代码,实现类似的功能。

📮 订阅更新
每周收到最新文章推送,不错过精彩内容

💡 我们尊重您的隐私,不会将邮箱用于其他用途

加载中...