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:
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user