From 294e53745b8bce5e1a8e58e111373a409f5e24ad Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 12:46:55 +0800 Subject: [PATCH] feat(asr): Apple Speech provider + ASR switcher tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AppleSpeechASRProvider:包装 SFSpeechRecognizer,on-device 优先, partial / final 走标准 ASRStreamingSession 协议,无需 API key - 新增 ASRProviderRegistry:M1 预设 volcengine / apple-speech - CredentialsVault 增加 activeASRProviderId / configuredASRProviderIds - DictationCoordinator.makeASRProvider 改为按 vault.activeASRProviderId 路由 - 新增 ASR Tab(chip 切换 + 主操作「保存」按钮);侧边栏导航新增 .asr - scripts/build-app.sh Info.plist 加入 NSSpeechRecognitionUsageDescription - Tests/OpenLessCoreTests 新增 ASRProviderRegistryTests --- .../OpenLessASR/AppleSpeechASRProvider.swift | 347 ++++++++++++++++++ .../OpenLessApp/DictationCoordinator.swift | 32 +- .../OpenLessApp/Settings/SettingsView.swift | 4 + .../Settings/Tabs/ASRProvidersTab.swift | 248 +++++++++++++ .../OpenLessCore/ASRProviderRegistry.swift | 59 +++ .../CredentialsVault.swift | 40 ++ .../ASRProviderRegistryTests.swift | 78 ++++ scripts/build-app.sh | 2 + 8 files changed, 802 insertions(+), 8 deletions(-) create mode 100644 Sources/OpenLessASR/AppleSpeechASRProvider.swift create mode 100644 Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift create mode 100644 Sources/OpenLessCore/ASRProviderRegistry.swift create mode 100644 Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift diff --git a/Sources/OpenLessASR/AppleSpeechASRProvider.swift b/Sources/OpenLessASR/AppleSpeechASRProvider.swift new file mode 100644 index 00000000..82d09c02 --- /dev/null +++ b/Sources/OpenLessASR/AppleSpeechASRProvider.swift @@ -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) 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 + private let partialContinuation: AsyncStream.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? + 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.Continuation! + self.partialResults = AsyncStream { 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) 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 + ) -> FinalContinuationOutcome { + lock.lock() + defer { lock.unlock() } + if didDeliverFinal { + return .alreadyDelivered + } + finalContinuation = continuation + return .registered + } + + private struct CancelSnapshot { + let task: SFSpeechRecognitionTask? + let cont: CheckedContinuation? + 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.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.. RawTranscript { + let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) + return RawTranscript(text: text, durationMs: durationMs) + } + + private func deliverFinal(_ outcome: Result) { + 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)") + } + } +} diff --git a/Sources/OpenLessApp/DictationCoordinator.swift b/Sources/OpenLessApp/DictationCoordinator.swift index 0ac51cb1..37d8ffc6 100644 --- a/Sources/OpenLessApp/DictationCoordinator.swift +++ b/Sources/OpenLessApp/DictationCoordinator.swift @@ -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 + } } } diff --git a/Sources/OpenLessApp/Settings/SettingsView.swift b/Sources/OpenLessApp/Settings/SettingsView.swift index 3c14f074..b646ec8f 100644 --- a/Sources/OpenLessApp/Settings/SettingsView.swift +++ b/Sources/OpenLessApp/Settings/SettingsView.swift @@ -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() diff --git a/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift b/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift new file mode 100644 index 00000000..8bedca80 --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift @@ -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) + } +} diff --git a/Sources/OpenLessCore/ASRProviderRegistry.swift b/Sources/OpenLessCore/ASRProviderRegistry.swift new file mode 100644 index 00000000..097af410 --- /dev/null +++ b/Sources/OpenLessCore/ASRProviderRegistry.swift @@ -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 } + } +} diff --git a/Sources/OpenLessPersistence/CredentialsVault.swift b/Sources/OpenLessPersistence/CredentialsVault.swift index b5898882..878a1bf4 100644 --- a/Sources/OpenLessPersistence/CredentialsVault.swift +++ b/Sources/OpenLessPersistence/CredentialsVault.swift @@ -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。 diff --git a/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift b/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift new file mode 100644 index 00000000..1797a4b0 --- /dev/null +++ b/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift @@ -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 = ["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")) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7ac37fe0..6386ff3d 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -72,6 +72,8 @@ cat > "${APP_DIR}/Contents/Info.plist" <OpenLess 需要麦克风权限以录制您的语音并转写为文字。 NSAppleEventsUsageDescription OpenLess 需要权限以将转写后的文字插入到您当前光标所在的输入框。 + NSSpeechRecognitionUsageDescription + OpenLess 在你切换到本地 ASR 时使用 macOS 内置语音识别完成离线转写,音频不离开本机。 SUFeedURL ${SPARKLE_FEED_URL} SUPublicEDKey