Merge: Apple Speech ASR provider + ASR switcher tab

Adds AppleSpeechASRProvider (SFSpeechRecognizer wrapper, on-device when
available, hotwords via contextualStrings, async TCC auth). New ASR tab
in Settings with chip-row picker (Volcengine + Apple Speech), prominent
borderedProminent .large Save button gated on dirty state. Vault gains
activeASRProviderId getter/setter + configuredASRProviderIds.
DictationCoordinator routes makeASRProvider() through the active id.

Info.plist gains NSSpeechRecognitionUsageDescription via build-app.sh
heredoc — single targeted addition.

Branch: feature/apple-speech-asr-and-switcher
This commit is contained in:
baiqing committed 2026-04-29 12:47:55 +08:00
commit b92e5e7778
8 files changed
+802 -8

No files matched your search

@@ -0,0 +1,347 @@
import AVFoundation
import Foundation
import Speech
import OpenLessCore
// AppleSpeechASRProvider 把 macOS 内置 `SFSpeechRecognizer` 包装成 `ASRProvider`。
//
// 为什么单独一个 provider 而不是共用火山那一套:
// - Apple Speech 不走 WebSocket / HTTP,是系统 SDK;audio 通过 AVAudioPCMBuffer 直接 append
// 到 SFSpeechAudioBufferRecognitionRequest,没有 framing / gzip。
// - 不需要任何 apiKey / endpoint 配置;权限走 TCC(NSSpeechRecognitionUsageDescription)。
// - 在 Apple Silicon 上 zh-CN 支持 on-device,能彻底离线;Intel / 老机器降级走 Apple 云端。
//
// V1 限制(接受):
// - 一个 session 一个 SFSpeechRecognitionTask,不做长时段轮换。Apple 的 ~60 秒软上限可能让
// 超长录音失败;这里不处理,先把"切到 Apple Speech 能识别"的主路径打通。
// - `transcribeBatch` 抛 `.unsupportedMode`——Apple Speech 的批量入口(SFSpeechURLRecognitionRequest)
// 在 M1 没用到,先不实现。
// - hotwords 通过 `contextualStrings` 喂给 recognizer,Apple 文档建议总长不超过 100 条。
public final class AppleSpeechASRProvider: ASRProvider {
public let info: ASRProviderInfo
private let logger: (@Sendable (String) -> Void)?
public init(logger: (@Sendable (String) -> Void)? = nil) {
self.logger = logger
self.info = ASRProviderInfo(
providerId: "apple-speech",
displayName: "macOS 本地 (Apple Speech)",
mode: .streaming,
supportsHotwords: true,
supportsLanguageHint: true,
supportsPartialResults: true
)
}
public func openStreamingSession(
language: String,
hotwords: [String]
) async throws -> ASRStreamingSession {
// 1) 鉴权——SFSpeechRecognizer 需要先经过 requestAuthorization。
// notDetermined / denied / restricted 全部视为"用不了"——抛 authFailed 让上层走 mock。
let auth = await Self.requestAuthorization()
guard auth == .authorized else {
logger?("[apple-speech] authorization not granted: \(auth.rawValue)")
throw ASRError.authFailed(statusCode: nil)
}
// 2) 构造 recognizer。指定 language 解不出来时回退到 zh-CN(同时也兜住空字符串)。
let locale = Locale(identifier: language.isEmpty ? "zh-CN" : language)
guard let recognizer = SFSpeechRecognizer(locale: locale) else {
logger?("[apple-speech] no recognizer for locale=\(locale.identifier)")
throw ASRError.providerError(code: "unavailable", message: "Apple Speech 不支持 locale \(locale.identifier)")
}
guard recognizer.isAvailable else {
logger?("[apple-speech] recognizer.isAvailable=false")
throw ASRError.providerError(code: "unavailable", message: "Apple Speech 当前不可用(系统忙或未联网)")
}
// 3) 构造请求。on-device 优先;不支持 on-device 的机器自动回退到 Apple 云端,不硬失败。
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
// macOS 13+:让识别结果自带标点。低版本属性不存在,但项目最低 15.0 不会触发。
request.addsPunctuation = true
// contextualStrings 即"提示词"——Apple 文档建议 ≤100 条;多余的截掉。
let cleanHotwords = hotwords
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
request.contextualStrings = Array(cleanHotwords.prefix(100))
let session = AppleSpeechSession(
recognizer: recognizer,
request: request,
onDevice: recognizer.supportsOnDeviceRecognition,
logger: logger
)
try session.startTask()
logger?("[apple-speech] streaming session opened (locale=\(locale.identifier), onDevice=\(recognizer.supportsOnDeviceRecognition), hotwords=\(request.contextualStrings.count))")
return session
}
public func transcribeBatch(
pcm: Data,
sampleRate: Int,
channels: Int,
language: String,
hotwords: [String]
) async throws -> RawTranscript {
throw ASRError.unsupportedMode
}
/// 把 SFSpeechRecognizer.requestAuthorization 的 callback 包成 async。
/// 同一个进程内多次调用是安全的——Apple 内部缓存了授权状态。
private static func requestAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { (continuation: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}
/// 适配 Apple Speech 到 `ASRStreamingSession`。
///
/// - 录音输出是 16 kHz / Int16 / mono / interleaved 的 `Data`。
/// - SFSpeechAudioBufferRecognitionRequest 接 `AVAudioPCMBuffer`;我们就地把 Int16 → Float32。
/// 保持 16 kHz / mono:Apple 的 recognizer 在低采样率上工作良好,不需要再升 24kHz/44.1kHz。
final class AppleSpeechSession: ASRStreamingSession, @unchecked Sendable {
let partialResults: AsyncStream<String>
private let partialContinuation: AsyncStream<String>.Continuation
private let recognizer: SFSpeechRecognizer
private let request: SFSpeechAudioBufferRecognitionRequest
private let onDevice: Bool
private let logger: (@Sendable (String) -> Void)?
/// 录音的 PCM 格式:16 kHz / Int16 / mono。
private let inputSampleRate: Double = 16_000
/// 转给 Apple 的 PCM 格式:16 kHz / Float32 / mono;同采样率不需要 resample。
private let bufferFormat: AVAudioFormat
private let lock = NSLock()
private var task: SFSpeechRecognitionTask?
/// 最终 transcript continuation——`awaitFinalResult()` 阻塞在这上面,
/// 由 SFSpeechRecognitionTask 的 result handler 在 `result.isFinal=true` 或 error 到达时唤醒。
private var finalContinuation: CheckedContinuation<RawTranscript, Error>?
private var didDeliverFinal = false
private var didEndAudio = false
private var startedAt: Date = Date()
init(
recognizer: SFSpeechRecognizer,
request: SFSpeechAudioBufferRecognitionRequest,
onDevice: Bool,
logger: (@Sendable (String) -> Void)?
) {
self.recognizer = recognizer
self.request = request
self.onDevice = onDevice
self.logger = logger
// 16 kHz Float32 mono——和录音同采样率,避免重采样链路。
// 强制成 non-optional:参数都是合法值,AVAudioFormat 不会失败。
self.bufferFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
var capturedContinuation: AsyncStream<String>.Continuation!
self.partialResults = AsyncStream<String> { continuation in
capturedContinuation = continuation
}
self.partialContinuation = capturedContinuation
}
/// 启动 SFSpeechRecognitionTask。失败时把 ASRError 抛出来,让 provider 路径降级。
func startTask() throws {
lock.lock()
defer { lock.unlock() }
startedAt = Date()
// recognitionTask(with:resultHandler:) 实际上不会抛错(错误从 handler 走),
// 这里 try 仅用于符合 protocol;如果未来 Apple 改了 API 也好定位。
let task = recognizer.recognitionTask(with: request) { [weak self] result, error in
self?.handleResult(result, error: error)
}
self.task = task
}
// MARK: - AudioConsumer
func consume(pcmChunk: Data) {
appendPCM(pcmChunk)
}
// MARK: - ASRStreamingSession
func sendAudio(_ pcm: Data) async throws {
appendPCM(pcm)
}
func endStream() async throws {
// 把 lock 操作下沉到同步辅助方法,避免 async 上下文里直接持锁
// (Swift 6 严格并发模式会把这种用法升级为 error)。
guard markEndAudio() else { return }
request.endAudio()
logger?("[apple-speech] endAudio()")
}
func awaitFinalResult() async throws -> RawTranscript {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<RawTranscript, Error>) in
// continuation 注册走同步辅助:拿到 .registered 才让它挂在 finalContinuation 上;
// 拿到 .alreadyDelivered 时直接抛 timeout(理论上不会触发)。
switch registerFinalContinuation(continuation) {
case .registered:
return
case .alreadyDelivered:
continuation.resume(throwing: ASRError.timeout)
}
}
}
func cancel() async {
let snapshot = takeStateForCancel()
snapshot.task?.cancel()
partialContinuation.finish()
if !snapshot.alreadyDelivered {
snapshot.cont?.resume(throwing: ASRError.cancelled)
}
logger?("[apple-speech] cancelled")
}
// MARK: - 同步辅助(持锁;不在 async 上下文里直接调用 NSLock)
/// 标记 endAudio 已调用。返回 true 表示这是首次标记(外层应实际调用 endAudio)。
private func markEndAudio() -> Bool {
lock.lock()
defer { lock.unlock() }
if didEndAudio { return false }
didEndAudio = true
return true
}
private enum FinalContinuationOutcome {
case registered
case alreadyDelivered
}
private func registerFinalContinuation(
_ continuation: CheckedContinuation<RawTranscript, Error>
) -> FinalContinuationOutcome {
lock.lock()
defer { lock.unlock() }
if didDeliverFinal {
return .alreadyDelivered
}
finalContinuation = continuation
return .registered
}
private struct CancelSnapshot {
let task: SFSpeechRecognitionTask?
let cont: CheckedContinuation<RawTranscript, Error>?
let alreadyDelivered: Bool
}
private func takeStateForCancel() -> CancelSnapshot {
lock.lock()
defer { lock.unlock() }
let snapshot = CancelSnapshot(
task: task,
cont: finalContinuation,
alreadyDelivered: didDeliverFinal
)
task = nil
finalContinuation = nil
didDeliverFinal = true
return snapshot
}
// MARK: - 内部
/// 把 16 kHz Int16 mono `Data` 转成 AVAudioPCMBuffer(Float32)→ request.append(buffer)。
/// 容错:长度不是 2 的倍数 / 空数据 → 静默丢弃。
private func appendPCM(_ pcm: Data) {
guard !pcm.isEmpty else { return }
let int16Count = pcm.count / MemoryLayout<Int16>.size
guard int16Count > 0 else { return }
guard let buffer = AVAudioPCMBuffer(pcmFormat: bufferFormat, frameCapacity: AVAudioFrameCount(int16Count)) else {
return
}
buffer.frameLength = AVAudioFrameCount(int16Count)
guard let floatChannel = buffer.floatChannelData?[0] else { return }
// Int16 → Float32:除以 32768,得到 [-1, 1) 的范围。
pcm.withUnsafeBytes { rawBuffer -> Void in
guard let baseAddr = rawBuffer.baseAddress else { return }
let int16Ptr = baseAddr.assumingMemoryBound(to: Int16.self)
let scale: Float = 1.0 / 32768.0
for i in 0..<int16Count {
floatChannel[i] = Float(int16Ptr[i]) * scale
}
}
request.append(buffer)
}
/// SFSpeechRecognitionTask 的 result handler 回调入口。
/// - 流式 partial:yield 到 partialResults。
/// - final(result.isFinal=true):填 finalContinuation。
/// - error:填 finalContinuation 抛错。
private func handleResult(_ result: SFSpeechRecognitionResult?, error: Error?) {
if let result {
let text = result.bestTranscription.formattedString
if !result.isFinal {
if !text.isEmpty {
partialContinuation.yield(text)
}
return
}
// Final
deliverFinal(.success(makeTranscript(from: text)))
return
}
if let error {
// SFSpeech 偶尔在 task cancel 时把 cancel 包成 error 推回来——映射成 cancelled。
let nsErr = error as NSError
let mapped: ASRError
if nsErr.domain == "kAFAssistantErrorDomain" || nsErr.code == 203 || nsErr.code == 1 {
// 不同 macOS 版本错误码不一致;这里把"无识别结果 / no speech detected"等
// 业务错误统一包到 providerError,让 coordinator 当一次失败处理。
mapped = .providerError(code: "speech-error-\(nsErr.code)", message: nsErr.localizedDescription)
} else {
mapped = .network(nsErr)
}
deliverFinal(.failure(mapped))
}
}
private func makeTranscript(from text: String) -> RawTranscript {
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
return RawTranscript(text: text, durationMs: durationMs)
}
private func deliverFinal(_ outcome: Result<RawTranscript, Error>) {
lock.lock()
if didDeliverFinal {
lock.unlock()
return
}
didDeliverFinal = true
let cont = finalContinuation
finalContinuation = nil
lock.unlock()
partialContinuation.finish()
switch outcome {
case .success(let transcript):
cont?.resume(returning: transcript)
logger?("[apple-speech] final delivered (len=\(transcript.text.count))")
case .failure(let error):
cont?.resume(throwing: error)
logger?("[apple-speech] final error: \(error)")
}
}
}
+24 -8
View File
@@ -580,14 +580,30 @@ final class DictationCoordinator {
/// 构造当前会话使用的 `ASRProvider`。
///
/// C-1 阶段硬编码到 `VolcengineASRProvider`;C-3(Settings UI)会改成从
/// `vault.activeASRProviderId` 路由。返回 nil 时 coordinator 走 mock 流程
/// (沿用旧版「凭据缺失 → 提示性占位」的契约,不向用户报硬错误)。
/// 路由依据 `vault.activeASRProviderId`:
/// - "volcengine":要凭据;缺凭据时返回 nil → coordinator 走 mock 流程。
/// - "apple-speech":无凭据;直接构造 `AppleSpeechASRProvider`,权限弹窗在
/// 首次 openStreamingSession 时由系统触发。
/// - 其他 id:未知 / 未来 provider,先返回 nil 走 mock,避免把用户卡死。
///
/// 返回 nil 时 coordinator 走 mock 流程(沿用旧版「凭据缺失 → 提示性占位」的契约,
/// 不向用户报硬错误)。
private func makeASRProvider() -> ASRProvider? {
guard let creds = loadVolcengineCredentials() else { return nil }
return VolcengineASRProvider(
credentials: creds,
logger: { msg in Log.write(msg) }
)
let activeId = CredentialsVault.shared.activeASRProviderId
switch activeId {
case "volcengine":
guard let creds = loadVolcengineCredentials() else { return nil }
return VolcengineASRProvider(
credentials: creds,
logger: { msg in Log.write(msg) }
)
case "apple-speech":
return AppleSpeechASRProvider(
logger: { msg in Log.write(msg) }
)
default:
Log.write("[asr] 未知 active provider \(activeId),走 mock 流程")
return nil
}
}
}
@@ -15,6 +15,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case history
case dictionary
case polish
case asr
case llm
case help
case settings
@@ -27,6 +28,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case .history: return "历史记录"
case .dictionary: return "词汇表"
case .polish: return "风格"
case .asr: return "ASR"
case .llm: return "LLM Provider"
case .help: return "帮助中心"
case .settings: return "设置"
@@ -39,6 +41,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case .history: return "clock"
case .dictionary: return "text.book.closed"
case .polish: return "paintpalette"
case .asr: return "waveform"
case .llm: return "wand.and.stars"
case .help: return "questionmark.circle"
case .settings: return "gearshape"
@@ -71,6 +74,7 @@ struct SettingsView: View {
case .history: HistoryTab()
case .dictionary: DictionaryTab()
case .polish: StyleTab()
case .asr: ASRProvidersTab()
case .llm: LLMProvidersTab()
case .help: HelpTab()
case .settings: SettingsHubTab()
@@ -0,0 +1,248 @@
import AppKit
import SwiftUI
import OpenLessCore
import OpenLessPersistence
/// ASR Provider 设置 Tab:横向 chip 切换 active provider,下方显示当前选项的提示
/// 与"凭据填在哪里"的指引;底部一个明显的主操作「保存」按钮把 selection 持久化。
///
/// 设计要点:
/// - 状态来源是 `CredentialsVault.shared`:所有读 / 写都直接打 vault,UI 是无状态视图层。
/// - 与 `LLMProvidersTab` 的差异:ASR 各家协议字段差异大(火山 3 字段 / Apple Speech 0 字段),
/// 不能像 LLM 那样共用一份表单——所以这里只承担"切 active",具体字段:
/// * 火山引擎 → 跳「设置」Tab 的 ASR 字段段
/// * Apple Speech → 无字段,仅提示权限会被系统询问
/// - 「保存」按钮采用主操作样式(borderedProminent),让用户看见明确的提交动作。
/// 切 chip 不立即写盘,避免误触;只有点击保存才会落到 credentials.json 里。
@MainActor
struct ASRProvidersTab: View {
@StateObject private var model = ASRProvidersModel()
@State private var saved = false
var body: some View {
SettingsPage(
title: "ASR Provider",
subtitle: "选择哪家语音识别引擎负责把你的语音转成文字。火山引擎更准但要 API key;macOS 本地 (Apple Speech) 完全离线、零配置,第一次切换时系统会请求语音识别权限。"
) {
GlassSection(title: "选择 ASR", symbol: "waveform") {
providerChips
DividerLine()
Text(model.helpTextForSelection)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
GlassSection(title: detailSectionTitle, symbol: detailSectionSymbol) {
providerDetail
}
PrimaryActionRow {
Button {
model.commitSelection()
flashSaved()
} label: {
Label("保存", systemImage: "checkmark.circle.fill")
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!model.hasUnsavedChanges)
if saved {
Label("已保存", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.onAppear { model.load() }
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in
model.load()
}
}
// MARK: - Chip row
private var providerChips: some View {
HStack(spacing: 10) {
ForEach(ASRProviderRegistry.presets, id: \.providerId) { preset in
ASRProviderChip(
preset: preset,
isSelected: model.selectedProviderId == preset.providerId,
isActive: model.activeProviderId == preset.providerId,
onSelect: { model.selectProvider(preset.providerId) }
)
}
Spacer()
}
.padding(.vertical, 4)
}
// MARK: - Detail section
private var detailSectionTitle: String {
if let preset = ASRProviderRegistry.preset(for: model.selectedProviderId) {
return preset.displayName
}
return model.selectedProviderId
}
private var detailSectionSymbol: String {
switch model.selectedProviderId {
case "apple-speech": return "lock.shield"
case "volcengine": return "key"
default: return "info.circle"
}
}
@ViewBuilder
private var providerDetail: some View {
switch model.selectedProviderId {
case "apple-speech":
appleSpeechDetail
case "volcengine":
volcengineDetail
default:
Text("未知 ASR provider:\(model.selectedProviderId)")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var appleSpeechDetail: some View {
VStack(alignment: .leading, spacing: 12) {
detailLine("无需配置 API key —— Apple Speech 完全由 macOS 系统提供。", symbol: "checkmark.seal")
detailLine("中文 (zh-CN) 在 Apple Silicon 上支持完全离线识别;其他语言或 Intel 机器会回退到 Apple 云端。", symbol: "globe")
detailLine("第一次切换并开始录音时,系统会弹窗请求「语音识别」权限——选择允许即可。", symbol: "hand.raised.fill")
detailLine("保存后,下一次按下录音键就会通过 Apple Speech 识别。", symbol: "play.circle.fill")
}
.padding(.vertical, 4)
}
private var volcengineDetail: some View {
VStack(alignment: .leading, spacing: 12) {
detailLine("火山引擎使用云端流式识别,准确度高,需要在控制台获取 App ID / Access Token / Resource ID。", symbol: "cloud")
detailLine("具体凭据字段填在 「设置」Tab 的「火山引擎大模型流式 ASR」段。", symbol: "gearshape")
detailLine("缺少凭据时本 Tab 选中火山引擎依然能保存——但下一次录音会回落到演示模式。", symbol: "exclamationmark.triangle")
}
.padding(.vertical, 4)
}
private func detailLine(_ text: String, symbol: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: symbol)
.foregroundStyle(.secondary)
.frame(width: 20)
.padding(.top, 2)
Text(text)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
// MARK: - 提示反馈
private func flashSaved() {
saved = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false }
}
}
// MARK: - Chip
@MainActor
private struct ASRProviderChip: View {
let preset: ASRProviderRegistry.Preset
let isSelected: Bool
let isActive: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 8) {
Image(systemName: chipSymbol)
.symbolRenderingMode(.hierarchical)
Text(preset.displayName)
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
if isActive {
Text("当前")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color.green.opacity(0.18))
)
.foregroundStyle(.green)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(isSelected ? Color.blue.opacity(0.6) : Color.primary.opacity(0.12), lineWidth: isSelected ? 1.5 : 1)
)
.foregroundStyle(isSelected ? .primary : .secondary)
}
.buttonStyle(.plain)
.help(preset.helpText)
}
private var chipSymbol: String {
switch preset.providerId {
case "apple-speech": return "applelogo"
case "volcengine": return "cloud.fill"
default: return "waveform"
}
}
}
// MARK: - Model
@MainActor
final class ASRProvidersModel: ObservableObject {
@Published private(set) var configuredIds: [String] = []
@Published private(set) var activeProviderId: String = defaultActiveASRProviderId
@Published private(set) var selectedProviderId: String = defaultActiveASRProviderId
/// 用户切了 chip 但还没点保存。保存按钮的 enable 状态依赖这个。
var hasUnsavedChanges: Bool {
selectedProviderId != activeProviderId
}
var helpTextForSelection: String {
ASRProviderRegistry.preset(for: selectedProviderId)?.helpText
?? "未知 ASR provider;请联系开发者或检查 ~/.openless/credentials.json。"
}
func load() {
let vault = CredentialsVault.shared
configuredIds = vault.configuredASRProviderIds
activeProviderId = vault.activeASRProviderId
// 第一次加载或 active 不在列表里时,selectedProviderId 跟随 active;
// 用户切换过 selected 后保留用户选择直到保存或离开。
if !configuredIds.contains(selectedProviderId) {
selectedProviderId = activeProviderId
}
}
func selectProvider(_ providerId: String) {
selectedProviderId = providerId
}
/// 把当前 selection 写回 vault;触发凭据变更通知,让 DictationCoordinator 等订阅方刷新缓存。
func commitSelection() {
let vault = CredentialsVault.shared
guard vault.activeASRProviderId != selectedProviderId else { return }
vault.activeASRProviderId = selectedProviderId
activeProviderId = selectedProviderId
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
}
}
@@ -0,0 +1,59 @@
import Foundation
/// ASR provider 预设注册表。
///
/// 集中维护"用户能开箱即用的 ASR 供应商列表"——M1 仅有火山引擎(云端流式)和
/// macOS 本地 Apple Speech(离线 / 系统内置)。UI 层用它生成 ASR Tab 的切换列表
/// 与帮助文案;运行期由 DictationCoordinator.makeASRProvider() 据 providerId 路由。
///
/// 与 `LLMProviderRegistry` 的差异:
/// - LLM 全是 OpenAI 兼容协议,可以共用同一份 OpenAICompatibleConfig;ASR 各家协议
/// 差异大(火山自有 WebSocket framing,Apple 是系统 SDK,Whisper 是 HTTP 批量),
/// 所以 preset 只暴露最小元数据(id + displayName + mode + helpText);具体配置
/// 字段由 provider 各自的 UI 段提供。
/// - 这里**不**暴露 baseURL / apiKey 之类的字段——provider 的字段由 SettingsHubTab
/// 或新的 ASR Tab 各自承载(火山 3 字段在 SettingsHubTab;Apple Speech 无字段)。
public enum ASRProviderRegistry {
/// 单条预设记录。
public struct Preset: Sendable, Hashable {
public let providerId: String
public let displayName: String
public let mode: ASRMode
/// "这家 provider 是什么 / 怎么用" 的提示文案;UI 在切换列表下面展示。
public let helpText: String
public init(
providerId: String,
displayName: String,
mode: ASRMode,
helpText: String
) {
self.providerId = providerId
self.displayName = displayName
self.mode = mode
self.helpText = helpText
}
}
/// M1 内置的 ASR provider 预设。
/// 顺序即为 UI 列表的展示顺序——火山在前(旧用户默认),Apple Speech 在后。
public static let presets: [Preset] = [
Preset(
providerId: "volcengine",
displayName: "火山引擎 (Volcengine)",
mode: .streaming,
helpText: "需要在火山引擎控制台获取 App ID / Access Token / Resource ID。"
),
Preset(
providerId: "apple-speech",
displayName: "macOS 本地 (Apple Speech)",
mode: .streaming,
helpText: "使用 macOS 内置语音识别。免费、离线(zh-CN 在 Apple Silicon 上支持纯离线),不需要 API key。首次切换时系统会请求语音识别权限。"
),
]
/// 根据 providerId 查预设;未知 id 返回 nil。
public static func preset(for providerId: String) -> Preset? {
presets.first { $0.providerId == providerId }
}
}
@@ -499,6 +499,46 @@ extension CredentialsVault {
}
}
// MARK: - 多 ASR provider 支持(C-2 切换器)
extension CredentialsVault {
/// 当前选中的 ASR provider id;getter 同时承担"loadIfNeeded"。
/// 切换 active ASR 后会通过写盘把新值持久化;调用方应 post `.openLessCredentialsChanged`
/// 让 DictationCoordinator 等订阅方刷新缓存(这里不直接 post,避免循环依赖)。
public var activeASRProviderId: String {
get {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
return schema.active.asr
}
set {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
guard schema.active.asr != newValue else { return }
schema.active.asr = newValue
try? writeLocked()
}
}
/// 列出所有"可选"的 ASR provider id:
/// - schema 里已有条目的 id(用户填过火山字段就会落到 providers.asr 里)
/// - registry 预设的 id(保证 Apple Speech 这种"无字段"provider 也能出现在 picker 里)
/// - 当前 active id(即便条目不存在也要列出,避免"切换-删除"流程把用户卡死)
public var configuredASRProviderIds: [String] {
lock.lock()
defer { lock.unlock() }
loadIfNeededLocked()
var ids = Set(schema.providers.asr.keys)
ids.insert(schema.active.asr)
for preset in ASRProviderRegistry.presets {
ids.insert(preset.providerId)
}
return ids.sorted()
}
}
// MARK: - 时间戳工具
/// 紧凑 ISO8601 时间戳,例如 `20260429T103045`。固定 UTC,避免 DST。
@@ -0,0 +1,78 @@
import XCTest
@testable import OpenLessCore
/// ASRProviderRegistry 的静态健康检查:M1 应包含火山引擎 + Apple Speech 两条预设,
/// id 不重复、displayName 非空、mode 合法。
///
/// 这些断言看起来很无聊,但 registry 直接被 ASR Tab 的 chip 列表与 vault 兜底逻辑用,
/// 漏一条都会变成"用户切不了 ASR / picker 空白"的运行期 bug。
final class ASRProviderRegistryTests: XCTestCase {
func test_presets_haveExpectedCount() {
// Arrange
let presets = ASRProviderRegistry.presets
// Act / Assert
XCTAssertEqual(presets.count, 2, "M1 计划列出的 ASR 预设是 volcengine / apple-speech")
}
func test_presets_includeAllExpectedProviderIds() {
// Arrange
let expected: Set<String> = ["volcengine", "apple-speech"]
// Act
let actualIds = Set(ASRProviderRegistry.presets.map(\.providerId))
// Assert
XCTAssertEqual(actualIds, expected)
}
func test_presets_haveUniqueProviderIds() {
// Arrange
let ids = ASRProviderRegistry.presets.map(\.providerId)
// Act / Assert
XCTAssertEqual(Set(ids).count, ids.count, "providerId 必须唯一,否则 vault 会被互相覆盖")
}
func test_presets_haveNonEmptyDisplayName() {
for preset in ASRProviderRegistry.presets {
XCTAssertFalse(preset.displayName.isEmpty, "preset \(preset.providerId) 的 displayName 不能为空")
}
}
func test_presets_haveNonEmptyHelpText() {
for preset in ASRProviderRegistry.presets {
XCTAssertFalse(preset.helpText.isEmpty, "preset \(preset.providerId) 的 helpText 不能为空")
}
}
func test_volcenginePreset_isStreaming() {
// Arrange
let preset = ASRProviderRegistry.preset(for: "volcengine")
// Act / Assert
XCTAssertNotNil(preset)
XCTAssertEqual(preset?.mode, .streaming)
}
func test_appleSpeechPreset_isStreaming() {
// Arrange
let preset = ASRProviderRegistry.preset(for: "apple-speech")
// Act / Assert
XCTAssertNotNil(preset)
XCTAssertEqual(preset?.mode, .streaming)
}
func test_preset_lookup_returnsRegistered() {
for preset in ASRProviderRegistry.presets {
let resolved = ASRProviderRegistry.preset(for: preset.providerId)
XCTAssertEqual(resolved, preset)
}
}
func test_preset_lookup_unknownReturnsNil() {
XCTAssertNil(ASRProviderRegistry.preset(for: "no-such-provider-xyz"))
}
}
+2
View File
@@ -72,6 +72,8 @@ cat > "${APP_DIR}/Contents/Info.plist" <<EOF
<string>OpenLess 需要麦克风权限以录制您的语音并转写为文字。</string>
<key>NSAppleEventsUsageDescription</key>
<string>OpenLess 需要权限以将转写后的文字插入到您当前光标所在的输入框。</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OpenLess 在你切换到本地 ASR 时使用 macOS 内置语音识别完成离线转写,音频不离开本机。</string>
<key>SUFeedURL</key>
<string>${SPARKLE_FEED_URL}</string>
<key>SUPublicEDKey</key>