开源项目解析:Keyfree 的多模态语音输入架构设计

0 阅读8分钟

在 AI 输入法赛道上,绝大多数方案沿用的是"ASR + LLM"两步流水线——先用 Whisper 等模型把语音转成文字,再用大模型做润色、纠错、格式化。这套方案成熟可靠,但存在一个根本性的信息瓶颈:ASR 环节把丰富的音频信号压缩成了纯文本,语气、停顿、重音等副语言信息在第一步就丢失了。

百隅科技(ByuTech)开源的 Keyfree 选择了另一条路——直接让多模态 LLM 处理原始音频,一次 API 调用完成从语音到结构化文本的全部工作。本文将深入解析这套架构的设计思路、工程实现和取舍决策。

一、整体架构:一次调用,端到端

传统方案的数据流是:

Audio → ASR Model → Raw Text → LLM → Refined Text

Keyfree 的数据流是:

Audio → Multimodal LLM (audio + context prompt) → Final Text

差异看起来只是少了一步,但工程影响是深远的:

  1. 延迟减半:只有一次网络往返,不存在两个模型之间的串行等待。
  2. 信息保真:LLM 能"听到"原始音频,可以根据语气判断标点、根据停顿分段、根据重音识别强调。
  3. 上下文统一:场景信息(当前应用、窗口标题等)和音频在同一个 prompt 中送入,模型能在一次推理中同时理解"说了什么"和"在哪说的"。

核心状态机

Keyfree 的运行时围绕一个五状态的有限状态机展开:

enum AppState {
    case idle          // 等待用户触发
    case recording     // 录音中
    case transcribing  // 音频已发送,等待 LLM 响应
    case inserting     // 正在向目标应用插入文本
    case showingResult // 展示结果 / 错误信息
}

状态转移清晰且单向(正常路径),任何异常都回到 idle。这种设计避免了复杂的并发状态管理,在单用户桌面应用场景下既简单又可靠。

二、音频处理:手工构建 WAV

Keyfree 使用 AVFoundation 录制 16kHz mono PCM 音频。值得注意的是,项目没有引入任何第三方音频编解码库,而是手动构建 WAV 文件头:

// 手动构建 WAV 头部(44 字节)
func buildWAVHeader(dataSize: Int, sampleRate: Int = 16000, channels: Int = 1, bitsPerSample: Int = 16) -> Data {
    var header = Data(capacity: 44)

    // RIFF chunk
    header.append(contentsOf: "RIFF".utf8)
    header.append(UInt32(36 + dataSize).littleEndianBytes)
    header.append(contentsOf: "WAVE".utf8)

    // fmt sub-chunk
    header.append(contentsOf: "fmt ".utf8)
    header.append(UInt32(16).littleEndianBytes)           // Sub-chunk size
    header.append(UInt16(1).littleEndianBytes)             // PCM format
    header.append(UInt16(channels).littleEndianBytes)
    header.append(UInt32(sampleRate).littleEndianBytes)
    header.append(UInt32(sampleRate * channels * bitsPerSample / 8).littleEndianBytes) // Byte rate
    header.append(UInt16(channels * bitsPerSample / 8).littleEndianBytes) // Block align
    header.append(UInt16(bitsPerSample).littleEndianBytes)

    // data sub-chunk
    header.append(contentsOf: "data".utf8)
    header.append(UInt32(dataSize).littleEndianBytes)

    return header
}

选择 16kHz 采样率是一个平衡点:对语音识别来说信息量足够(人声基频通常在 85-255Hz),同时数据量只有 44.1kHz 的约三分之一。PCM 数据最终经过 Base64 编码后作为多模态 LLM API 的 audio 参数传输。

为什么不用压缩格式?  Opus 或 AAC 确实能大幅减小传输体积,但会引入编解码依赖。Keyfree 的设计哲学是零外部依赖,且语音输入通常只有几秒到十几秒,Base64 编码后的体积在可接受范围内。

三、上下文感知:三层系统

这是 Keyfree 最具技术含量的设计之一。语音输入的一个核心痛点是"同音不同字"和"格式不确定"——同样一句话,在写代码时、写邮件时、聊天时,期望的输出完全不同。Keyfree 通过三层上下文系统来解决这个问题:

Layer 1:实时上下文

struct RealtimeContext {
    let appName: String       // 如 "Xcode", "微信", "飞书"
    let windowTitle: String   // 如 "MyProject - ViewController.swift"
    let focusElement: String  // 如 "Code Editor", "Search Field"
}

通过 macOS Accessibility API 实时获取。当用户在 Xcode 中录音时,模型知道这是一个编程场景,会倾向于输出代码相关术语的正确拼写;在微信中录音时,则会使用更口语化的表达。

Layer 2:短期记忆

每 10 次输入或累积 500 字时触发一次摘要,时间窗口为 1 小时。摘要提取:

  • 当前讨论主题
  • 用户意图方向
  • 所在领域(技术/商务/日常)
  • 高频词汇
  • 出现的实体(人名、项目名等)

这层解决的是"连续对话"场景:当你在讨论某个技术方案时,后续的语音输入会自动继承之前的专业术语偏好。

Layer 3:长期用户画像

每日生成,基于 7 天滚动窗口,提取:

  • 用户身份特征(职业、领域)
  • 专业领域分布
  • 语言风格习惯
  • 常用实体列表

持久化到 Core Data,实现跨会话的个性化。

Prompt 栈式优先级

三层上下文通过栈式优先级合并到最终 prompt 中:

基础指令 < 术语表 < 用户画像 < 短期记忆 < 实时上下文 < 自定义指令

越靠近栈顶,优先级越高。这意味着用户的自定义指令永远能覆盖系统默认行为,而实时上下文能覆盖长期画像——你平时写代码为主,但此刻在写邮件,模型会以邮件场景为准。

四、文本插入:三级降级策略

把 LLM 生成的文本插入到目标应用的光标位置,听起来简单,实际上是 macOS 开发中的一个经典难题。不同应用对输入事件的处理差异极大。Keyfree 设计了三级降级策略:

级别方式延迟兼容性适用场景
L1Accessibility API (AXValue)~0ms~70%原生 NSTextField/NSTextView
L2CGEvent 键盘模拟中等~95%大部分应用
L3剪贴板注入较高100%兜底方案
func insertText(_ text: String) async {
    // L1: 尝试直接设置 AXValue
    if let focusedElement = getFocusedElement(),
       AXUIElementSetAttributeValue(focusedElement, kAXValueAttribute as CFString, text as CFTypeRef) == .success {
        return
    }

    // L2: CGEvent 键盘模拟,逐字符合成按键
    if simulateKeyboardInput(text) {
        return
    }

    // L3: 剪贴板兜底
    let savedClipboard = NSPasteboard.general.string(forType: .string)
    NSPasteboard.general.setString(text, forType: .string)
    simulateCommandV()
    // 恢复剪贴板
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        if let saved = savedClipboard {
            NSPasteboard.general.setString(saved, forType: .string)
        }
    }
}

L3 的剪贴板方案会先保存用户剪贴板内容,注入后再恢复——这是一个容易被忽视但对用户体验至关重要的细节。

五、流式响应:SSE + 缓冲刷新

Keyfree 使用 SSE(Server-Sent Events)接收 LLM 的流式响应,配合 30ms 的缓冲刷新策略:

class SSEStreamHandler {
    private var buffer = ""
    private var flushTimer: Timer?
    private let flushInterval: TimeInterval = 0.03  // 30ms

    func onReceiveChunk(_ chunk: String) {
        buffer.append(chunk)
        scheduleFlush()
    }

    private func scheduleFlush() {
        flushTimer?.invalidate()
        flushTimer = Timer.scheduledTimer(withTimeInterval: flushInterval, repeats: false) { [weak self] _ in
            guard let self = self, !self.buffer.isEmpty else { return }
            self.delegate?.insertPartialText(self.buffer)
            self.buffer = ""
        }
    }
}

30ms 的缓冲窗口是一个精心选择的值:比人眼能感知的延迟(约 100ms)短得多,确保实时感;同时避免了逐 token 插入带来的高频 UI 更新和文本闪烁。

六、持久化:Core Data 三实体模型

Keyfree 使用 Core Data 管理三个核心实体:

  • Recording:录音记录,包含音频数据引用、时间戳、转写结果
  • ContextSnapshot:上下文快照,记录每次输入时的应用环境
  • UserProfile:用户画像,存储长期偏好和使用模式

选择 Core Data 而非 SQLite 直接操作或第三方 ORM,与零依赖原则一致——Core Data 是 Apple 平台原生框架,不需要引入任何额外依赖。

七、BYOAPI 模式的架构影响

Keyfree 采用 BYOAPI(Bring Your Own API)模式,用户使用自己的 API Key 调用多模态 LLM。这个商业决策对架构有直接影响:

  1. 无需后端:不需要维护认证、计费、代理服务器,整个应用是纯客户端。
  2. 隐私友好:语音数据直接从用户设备到 LLM 服务商,不经过第三方。
  3. 成本透明:每次调用约 ¥0.01,用户可以在 LLM 服务商后台看到每一笔消费。
  4. 多模型支持:架构天然支持切换不同的 LLM 提供商,只要支持多模态音频输入。

八、设计取舍总结

决策选择替代方案理由
语音处理多模态 LLM 直接处理ASR + LLM 两步延迟更低,信息保真度更高
音频格式PCM + Base64Opus/AAC 压缩零依赖,实现简单
文本插入三级降级只用剪贴板最优体验优先,兼容性兜底
持久化Core DataSQLite / Realm零外部依赖
部署模式BYOAPISaaS 订阅隐私优先,成本透明

每一个决策都指向同一个设计哲学:在 macOS 原生能力的边界内,做到最优的用户体验。

总结

Keyfree 的架构设计展示了一种"少即是多"的工程美学——不追求功能堆砌,而是在有限的约束条件下(零依赖、纯客户端、BYOAPI)做出最优的技术选择。多模态 LLM 直接处理音频的方案,三层上下文系统,三级文本插入降级策略,这些设计决策背后都有清晰的工程逻辑。

项目已在 GitHub 开源(MIT 协议),欢迎感兴趣的开发者阅读源码、提出建议或参与贡献。

  • 项目地址:github.com/Maxwin-z/SpeakMore-macOS
  • 官网:byutech.cn
  • 开发团队:百隅科技(ByuTech)