AITalk:从零到一打造 macOS 系统级语音输入引擎
一个完整的 AI 语音输入解决方案,让说话变成优雅的文字
项目背景
在日常工作中,我们经常需要在各种应用中输入大量文字------邮件回复、文档编写、代码注释等。虽然 macOS 自带听写功能,但它存在诸多问题:识别准确率不高、输出口语化严重、需要联网但延迟较大。我希望打造一个更智能的解决方案:
核心目标:
- 🎯 系统级覆盖:在任何应用中都能使用
- 🤖 AI 智能优化:自动去除口语化、优化表达
- ⚡ 极速响应:2秒内完成识别和注入
- 🔒 隐私优先:音频不落盘、仅内存处理
于是,AITalk 诞生了。
技术架构
整体设计
AITalk 采用经典的 C/S 架构,分为 macOS 客户端 和 Python 后端服务两部分:
录音
WebSocket
流式传输
文本优化
最终文本
WebSocket
注入
用户按下热键
macOS 客户端
Swift
音频采集
AVFoundation
Python 后端
FastAPI
STT 服务
豆包语音识别
LLM 服务
豆包大模型
文本注入
Accessibility API
技术栈选择
macOS 客户端(Swift)
- 框架:纯 Swift + AppKit
- 音频采集:AVFoundation (16kHz, PCM16)
- 全局热键:Carbon Event Manager
- 文本注入:macOS Accessibility API
- 网络通信:Starscream (WebSocket)
Python 后端
- Web 框架:FastAPI + Uvicorn
- STT:豆包语音识别 API(流式实时识别)
- LLM:豆包大模型(文本润色优化)
- 并发处理:asyncio + websockets
核心技术实现
1. 全局热键监听
macOS 中实现全局热键有多种方式,最终选择了 Carbon Event Manager,原因是:
- ✅ 不依赖 NSEvent,无需应用获得焦点
- ✅ 支持复杂组合键(Cmd+Shift+Space)
- ✅ 系统级监听,任何应用都能触发
swift
// GlobalHotkeyManager.swift
func registerHotkey(keyCode: UInt16, modifiers: [CGEventFlags]) {
var carbonModifiers: UInt32 = 0
for modifier in modifiers {
if modifier == .maskCommand { carbonModifiers |= UInt32(cmdKey) }
if modifier == .maskShift { carbonModifiers |= UInt32(shiftKey) }
}
let callback: EventHandlerUPP = { (nextHandler, theEvent, userData) -> OSStatus in
let manager = Unmanaged<GlobalHotkeyManager>.fromOpaque(userData!).takeUnretainedValue()
DispatchQueue.main.async { manager.toggleRecording() }
return noErr
}
InstallEventHandler(GetApplicationEventTarget(), callback, 1, [eventType],
Unmanaged.passUnretained(self).toOpaque(), &eventHandler)
RegisterEventHotKey(UInt32(keyCode), carbonModifiers, hotKeyID,
GetApplicationEventTarget(), 0, &hotKeyRef)
}
关键点:
- 使用
toggle模式:按一次开始录音,再按一次停止 - 通过
Unmanaged传递 Swift 对象给 C 回调 - 异步分发到主线程处理,避免阻塞
2. 音频采集与流式传输
采集高质量音频是语音识别的基础。AITalk 使用 AVAudioEngine 构建实时音频处理管道:
swift
// AudioRecorder.swift
func startRecording() {
let inputNode = audioEngine.inputNode
let format = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 16000, channels: 1, interleaved: true)!
inputNode.installTap(onBus: 0, bufferSize: 3200, format: format) { buffer, _ in
self.delegate?.audioRecorder(self, didReceiveBuffer: buffer)
}
audioEngine.prepare()
try audioEngine.start()
}
技术细节:
- 采样率: 16kHz(语音识别标准)
- 格式: PCM Int16(未压缩,高质量)
- Buffer 大小: 3200 帧 = 200ms(平衡延迟和稳定性)
音频数据通过 WebSocket 实时发送至后端:
swift
// WebSocketClient.swift
func sendAudioData(_ data: Data) {
let message: [String: Any] = [
"type": "audio_data",
"data": data.base64EncodedString()
]
let jsonData = try! JSONSerialization.data(withJSONObject: message)
socket.write(data: jsonData)
}
3. STT 语音识别(豆包 API)
后端接收音频流后,通过豆包的流式语音识别 API 进行实时转写:
python
# stt_service.py
async def stream_stt_doubao(audio_chunks: List[bytes], language: str) -> str:
"""使用豆包流式 STT API 进行语音识别"""
# 创建 WebSocket 连接
url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"
auth_params = generate_auth_params(APP_ID, RESOURCE_ID)
async with websockets.connect(url + "?" + auth_params) as ws:
# 发送配置
await ws.send(json.dumps({
"type": "full_client_request",
"data": {
"stream_id": request_id,
"sequence": 0,
"payload_type": 0x1, # JSON
"payload": {
"format": "pcm",
"rate": 16000,
"bits": 16,
"channel": 1,
"language": language
}
}
}))
# 流式发送音频
for i, chunk in enumerate(audio_chunks):
await ws.send(chunk) # 二进制音频
# 接收识别结果
transcript = ""
async for message in ws:
result = json.loads(message)
if result.get("type") == "transcript":
transcript = result["transcript"]["text"]
return transcript
优势:
- ✅ 流式处理,边录边传,降低总延迟
- ✅ 支持中英文混合识别
- ✅ 高准确率(官方宣称 95%+)
4. LLM 文本优化
原始 STT 输出通常包含口语化表达,需要 LLM 进行润色:
python
# llm_service.py
async def refine_text_doubao(raw_text: str, mode: str = "default") -> str:
"""使用豆包大模型润色文本"""
system_prompts = {
"default": "你是文本润色助手。将口语化的语音识别结果转换为书面语...",
"formal": "你是专业商务写作助手...",
"concise": "你是简洁表达专家...",
}
response = await client.chat.completions.create(
model="doubao-pro",
messages=[
{"role": "system", "content": system_prompts[mode]},
{"role": "user", "content": raw_text}
],
temperature=0.3, # 降低随机性,保持原意
max_tokens=500
)
return response.choices[0].message.content
示例效果:
| 原始识别 | 优化后 |
|---|---|
| "嗯...我觉得呢,这个方案应该可能是比较好的吧" | "我认为这个方案比较好。" |
| "那个啥,明天我们就开会讨论一下" | "明天我们开会讨论。" |
5. 文本注入(Accessibility API)
这是整个项目最具挑战性的部分。macOS 提供了 Accessibility API,允许应用访问和操作 UI 元素:
swift
// TextInjector.swift
func injectText(_ text: String) -> Bool {
guard let element = elementResolver.getFocusedElement() else {
return false
}
// 方法 1: 直接设置 AXValue(最优)
if injectViaAXValue(text, element: element) {
return true
}
// 方法 2: 剪贴板 + Cmd+V(通用)
if injectViaClipboard(text) {
return true
}
// 方法 3: 逐字符输入(最后手段)
return injectViaTyping(text)
}
private func injectViaAXValue(_ text: String, element: AXUIElement) -> Bool {
// 获取当前值
var currentValue: CFTypeRef?
AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, ¤tValue)
let existingText = currentValue as? String ?? ""
// 拼接新文本
let newText = existingText + text
// 设置新值
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString,
newText as CFTypeRef)
return result == .success
}
三层注入策略:
- AXValue 直接设置:最快、最可靠,但需要 Accessibility 权限
- 剪贴板 + Cmd+V 模拟:兼容性好,几乎所有应用都支持
- 逐字符输入模拟:最后手段,慢但一定能用
关键技术难点与解决方案
难点 1:Accessibility 权限识别问题
问题: 即使在系统设置中授予了 Accessibility 权限,AXIsProcessTrusted() 仍返回 false。
原因:
- 应用重新编译后,bundle ID 或签名可能改变
- macOS 缓存了权限状态,未及时更新
解决方案:
swift
// 主动请求权限,带系统提示
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
let isTrusted = AXIsProcessTrustedWithOptions(options)
难点 2:HUD 窗口抢占焦点
问题: 显示 HUD 进度窗口时,系统焦点被抢占,导致无法获取原目标文本框。
解决方案:
swift
// HUDPanel.swift - 使用非激活面板
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 160, height: 160),
styleMask: [.nonactivatingPanel, .utilityWindow, .hudWindow], // 关键!
backing: .buffered,
defer: false
)
self.level = .floating
self.ignoresMouseEvents = true // 穿透鼠标事件
}
难点 3:WebSocket 断线重连
问题: 网络波动或服务器重启导致连接断开。
解决方案:
swift
// WebSocketClient.swift
func connect() {
socket.onDisconnected = { [weak self] error in
self?.scheduleReconnect()
}
}
private func scheduleReconnect() {
reconnectAttempts += 1
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0) // 指数退避
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
self.connect()
}
}
性能优化
1. 延迟优化
| 环节 | 延迟 | 优化措施 |
|---|---|---|
| 音频采集 | 200ms | 优化 buffer 大小 |
| 网络传输 | 50ms | 本地服务器 |
| STT 识别 | 800ms | 流式处理 |
| LLM 优化 | 600ms | 并发执行 + 温度降低 |
| 文本注入 | 10ms | Accessibility API |
| 总计 | ≤2s |
2. 内存管理
swift
// 及时释放音频缓冲区
audioBuffers.removeAll()
// 使用 autoreleasepool 避免峰值
autoreleasepool {
let audioData = bufferToData(buffer)
webSocketClient.sendAudioData(audioData)
}
3. 成本优化
月度成本分析(1000 次会话):
- 豆包 STT:¥5(¥0.005/次)
- 豆包 LLM:¥2(¥0.002/次)
- 服务器托管:¥30(轻量云服务器)
- 总计:≈ ¥37/月
项目成果
功能特性
- ✅ 系统级语音输入:支持任意 macOS 应用
- ✅ AI 智能优化:自动去除"嗯、啊、那个"等口语词
- ✅ 多种输出模式:默认、正式、简洁、原文
- ✅ 中英文支持:自动识别语言
- ✅ 极速响应:端到端延迟 ≤2秒
- ✅ 隐私保护:音频不落盘,仅内存处理
- ✅ 可靠性:三层文本注入,99% 成功率
技术指标
| 指标 | 数值 |
|---|---|
| 平均延迟 | 1.8s |
| STT 准确率 | 95%+ |
| 注入成功率 | 99%+ |
| 内存占用 | <50MB |
| CPU 占用 | <5% |
代码仓库
| 目录 | 说明 |
|---|---|
TypelessMac/ |
Swift 客户端源码 |
├─ Hotkey/ |
全局热键管理 |
├─ Audio/ |
音频采集 |
├─ Accessibility/ |
权限和元素解析 |
├─ TextInjection/ |
文本注入引擎 |
├─ Network/ |
WebSocket 通信 |
├─ State/ |
会话状态管理 |
├─ UI/ |
HUD 界面 |
server/ |
Python 后端服务 |
├─ main.py |
FastAPI WebSocket 服务器 |
├─ stt_service.py |
STT 语音识别 |
├─ llm_service.py |
LLM 文本优化 |
docs/ |
完整技术文档 |
未来展望
短期计划(1-3个月)
- 🎨 设置界面:可视化配置热键、模式、语言
- 🌍 更多语言:日语、韩语、西班牙语...
- 📊 音频可视化:实时音量显示
- 🔄 自动更新:Sparkle 框架集成
中期计划(3-6个月)
- 🧠 本地 STT:集成 Whisper.cpp,无需联网
- 📱 iOS 版本:通过 Shortcuts 实现类似功能
- 🎯 应用 Profile:针对不同应用自动切换模式
- 🔌 插件系统:允许第三方扩展功能
长期愿景
- 🌐 跨平台支持:Windows、Linux 版本
- 🤝 多人协作:团队共享术语库
- 🧩 智能联想:根据上下文预测内容
- 📈 个人模型:基于用户习惯的微调
技术总结
开发 AITalk 的过程是一次完整的产品工程实践:
- 架构设计:清晰的 C/S 分离,职责明确
- 技术选型:平衡性能、成本、开发效率
- 系统编程:深入 macOS 底层 API
- AI 集成:STT + LLM 协同优化
- 用户体验:从热键到注入的每个细节打磨
- 工程质量:异常处理、重连机制、资源管理
最大的收获是对 macOS 系统编程能力的深入理解 ,以及如何将 AI 技术落地到实际产品。
参考资料
项目地址 : AITalk on GitHub
Built with ❤️ for productivity enthusiasts