From 377d86c33a70f1ac561154b696df71228cb9edaa Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 12:32:49 +0800 Subject: [PATCH] feat(asr): introduce ASRProvider protocol + Volcengine wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C-1 of provider abstraction (mirrors LLM-side B-1/B-2/B-3). - Add ASRProvider / ASRStreamingSession / ASRProviderInfo / ASRMode / ASRError to OpenLessCore. Streaming and batch shapes share a single protocol; non-self entry throws .unsupportedMode. - ASRStreamingSession inherits AudioConsumer; sendAudio(_:) gets a default impl that forwards to consume(pcmChunk:) — least-invasive bridge, no changes to existing AudioConsumer / Recorder / BufferingAudioConsumer. - VolcengineASRProvider wraps existing VolcengineStreamingASR via a thin adapter (VolcengineStreamingSessionAdapter). Zero modifications to VolcengineStreamingASR / VolcengineFrame / VolcengineCredentials. - DictationCoordinator now drives ASR through ASRProvider: makeASRProvider() returns VolcengineASRProvider when creds exist, nil otherwise (silent mock fallback preserved). beginSession, endSession, handleCancel, failSession all route through asrSession: ASRStreamingSession. - Add OpenLessASRTests target with VolcengineASRProvider info / unsupportedMode coverage, plus ASRProviderInfo / ASRError / ASRMode value-shape tests. C-3 (settings UI) will switch makeASRProvider() to read vault.activeASRProviderId; partial results stream is wired but empty-finished pending an internal Volcengine partial hook. --- Package.swift | 5 + .../OpenLessASR/VolcengineASRProvider.swift | 119 ++++++++++++++++ .../OpenLessApp/DictationCoordinator.swift | 66 ++++++--- Sources/OpenLessCore/ASRProvider.swift | 134 ++++++++++++++++++ .../VolcengineASRProviderTests.swift | 116 +++++++++++++++ 5 files changed, 418 insertions(+), 22 deletions(-) create mode 100644 Sources/OpenLessASR/VolcengineASRProvider.swift create mode 100644 Sources/OpenLessCore/ASRProvider.swift create mode 100644 Tests/OpenLessASRTests/VolcengineASRProviderTests.swift diff --git a/Package.swift b/Package.swift index b88d8bee..9e6f3b1d 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,11 @@ let package = Package( dependencies: ["OpenLessPersistence", "OpenLessCore"], path: "Tests/OpenLessPersistenceTests" ), + .testTarget( + name: "OpenLessASRTests", + dependencies: ["OpenLessASR", "OpenLessCore"], + path: "Tests/OpenLessASRTests" + ), ], // 暂留 Swift 5 语言模式,避免一次性吞掉 Swift 6 严格并发的全部改造。 // 升 .v6 时需要重新审视 Recorder / VolcengineStreamingASR 等 @unchecked Sendable 类。 diff --git a/Sources/OpenLessASR/VolcengineASRProvider.swift b/Sources/OpenLessASR/VolcengineASRProvider.swift new file mode 100644 index 00000000..b2e8905b --- /dev/null +++ b/Sources/OpenLessASR/VolcengineASRProvider.swift @@ -0,0 +1,119 @@ +import Foundation +import OpenLessCore + +// VolcengineASRProvider 把现有的 `VolcengineStreamingASR` 包装成符合 +// `ASRProvider` / `ASRStreamingSession` 协议的对象。 +// +// 这一层是「纯外包装」——不修改 VolcengineStreamingASR 任何内部逻辑, +// 仅把它的 openSession / consume / sendLastFrame / awaitFinalResult / cancel +// 暴露给 coordinator 通过 protocol 调用。 +// +// 现有 `VolcengineStreamingASR` 的几个特性决定了这里的妥协: +// 1. 它在 `init` 时一次性接收 `dictionaryEntries`(即热词),没有运行时换词的接口。 +// 所以 hotwords 在 openStreamingSession 阶段被构造成 DictionaryEntry,再 +// 传进 VolcengineStreamingASR.init —— 我们不动它。 +// 2. 它的 `cancel()` 是同步方法;我们在 `ASRStreamingSession.cancel()` 里 await +// 没有意义但符合协议(async 包同步是无害的)。 +// 3. 它没有暴露 partial 回调。火山协议本身有增量结果,但旧实现把它们累到 +// `rawText` 里直到拿到 final 才 fire continuation;本次 C-1 不动这个逻辑, +// `partialResults` 返回空 stream。需要 partial UI 时再回头改 ASR 内部。 + +public final class VolcengineASRProvider: ASRProvider { + public let info: ASRProviderInfo + private let credentials: VolcengineCredentials + private let logger: (@Sendable (String) -> Void)? + + public init( + credentials: VolcengineCredentials, + logger: (@Sendable (String) -> Void)? = nil + ) { + self.credentials = credentials + self.logger = logger + self.info = ASRProviderInfo( + providerId: "volcengine", + displayName: "火山引擎", + mode: .streaming, + supportsHotwords: true, + supportsLanguageHint: false, + supportsPartialResults: false + ) + } + + public func openStreamingSession( + language: String, + hotwords: [String] + ) async throws -> ASRStreamingSession { + // 把 hotwords[String] 适配回 DictionaryEntry(VolcengineStreamingASR 的入参形态)。 + // language 暂不消费——VolcengineStreamingASR 当前没有暴露 language 入参。 + // C-3/C-4 接入多 ASR provider 时再回来扩展。 + let entries = hotwords + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .map { phrase in + DictionaryEntry(phrase: phrase) + } + + let asr = VolcengineStreamingASR( + credentials: credentials, + dictionaryEntries: entries, + logger: logger + ) + try await asr.openSession() + return VolcengineStreamingSessionAdapter(asr: asr) + } + + public func transcribeBatch( + pcm: Data, + sampleRate: Int, + channels: Int, + language: String, + hotwords: [String] + ) async throws -> RawTranscript { + throw ASRError.unsupportedMode + } +} + +/// 适配 `VolcengineStreamingASR` 到 `ASRStreamingSession`。 +/// +/// 它本身是 `AudioConsumer`:`consume(pcmChunk:)` 直接转发给底层 ASR 实例。 +/// `partialResults` 暂时是个空 stream(在 init 里立即 finish),UI 即便订阅也不会拿到东西。 +final class VolcengineStreamingSessionAdapter: ASRStreamingSession, @unchecked Sendable { + private let asr: VolcengineStreamingASR + let partialResults: AsyncStream + private let partialContinuation: AsyncStream.Continuation + + init(asr: VolcengineStreamingASR) { + self.asr = asr + var capturedContinuation: AsyncStream.Continuation! + self.partialResults = AsyncStream { continuation in + capturedContinuation = continuation + } + self.partialContinuation = capturedContinuation + // 当前 VolcengineStreamingASR 不暴露 partial 回调,立即关掉 stream,避免订阅方挂等。 + // 等火山 partial 真接入时,把这一行删掉,改成 onPartial { text in cont.yield(text) }。 + capturedContinuation.finish() + } + + // MARK: - AudioConsumer + + func consume(pcmChunk: Data) { + asr.consume(pcmChunk: pcmChunk) + } + + // MARK: - ASRStreamingSession + + func sendAudio(_ pcm: Data) async throws { + asr.consume(pcmChunk: pcm) + } + + func endStream() async throws { + try await asr.sendLastFrame() + } + + func awaitFinalResult() async throws -> RawTranscript { + return try await asr.awaitFinalResult() + } + + func cancel() async { + asr.cancel() + } +} diff --git a/Sources/OpenLessApp/DictationCoordinator.swift b/Sources/OpenLessApp/DictationCoordinator.swift index 20c0e1e5..0ac51cb1 100644 --- a/Sources/OpenLessApp/DictationCoordinator.swift +++ b/Sources/OpenLessApp/DictationCoordinator.swift @@ -30,7 +30,10 @@ final class DictationCoordinator { /// Toggle 状态:idle → 开始;listening → 结束;starting/processing 阶段忽略重复触发。 private var sessionPhase: SessionPhase = .idle - private var asr: VolcengineStreamingASR? + /// 当前 ASR session(通过 ASRProvider.openStreamingSession 拿到)。 + /// 抽象化后 coordinator 不再直接持有 VolcengineStreamingASR;具体 provider 由 + /// `makeASRProvider()` 选择,C-3 起会从 vault.activeASRProviderId 路由到不同实现。 + private var asrSession: ASRStreamingSession? private var audioConsumer: BufferingAudioConsumer? private var sessionStartedAt: Date = Date() /// hold 模式下,Esc 取消后下一次 .released 应被忽略(否则会再次触发结束流程)。 @@ -211,8 +214,11 @@ final class DictationCoordinator { guard sessionPhase == .starting || sessionPhase == .listening else { return } Log.write("用户取消") sessionPhase = .idle - asr?.cancel() - asr = nil + if let session = asrSession { + asrSession = nil + // session.cancel() 是 async;让它在后台 detach 即可,不阻塞 UI/cancel 路径。 + Task { await session.cancel() } + } recorder.stop() audioConsumer?.clear() audioConsumer = nil @@ -261,28 +267,27 @@ final class DictationCoordinator { } guard sessionPhase == .starting else { return } - guard let credentials = loadVolcengineCredentials() else { - Log.write("缺少火山引擎凭据;麦克风已启动,结束后走 mock 模式") + guard let provider = makeASRProvider() else { + Log.write("缺少 ASR 凭据;麦克风已启动,结束后走 mock 模式") sessionPhase = .listening return } let dictionaryEntries = dictionary.enabledEntries() - let asr = VolcengineStreamingASR( - credentials: credentials, - dictionaryEntries: dictionaryEntries, - logger: { msg in Log.write(msg) } - ) - self.asr = asr + let hotwords = dictionaryEntries.map(\.trimmedPhrase).filter { !$0.isEmpty } do { - try await asr.openSession() - Log.write("[asr] WebSocket 已连接") + let session = try await provider.openStreamingSession( + language: "zh-CN", + hotwords: hotwords + ) + Log.write("[asr] streaming session opened (provider=\(provider.info.providerId))") guard sessionPhase == .starting else { - asr.cancel() + await session.cancel() return } - audioConsumer.attach(asr) + self.asrSession = session + audioConsumer.attach(session) Log.write("[asr] 音频已接入 ASR") sessionPhase = .listening } catch { @@ -310,17 +315,17 @@ final class DictationCoordinator { audioConsumer = nil Log.write("[session] 录音停止(含 250ms 末尾 padding),等待 ASR 终态") - guard let asr = self.asr else { + guard let session = self.asrSession else { // mock pipeline:无凭据时给一段提示 await runMockPipeline() return } do { - try await asr.sendLastFrame() - let raw = try await asr.awaitFinalResult() + try await session.endStream() + let raw = try await session.awaitFinalResult() Log.write("[asr] final: \(raw.text)") - self.asr = nil + self.asrSession = nil let trimmed = raw.text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { Log.write("[asr] 终态文本为空 — 跳过润色与插入") @@ -333,7 +338,7 @@ final class DictationCoordinator { ) } catch { Log.write("[asr] final 失败: \(error)") - self.asr = nil + self.asrSession = nil await failSession(reason: "识别失败") } } @@ -517,8 +522,10 @@ final class DictationCoordinator { recorder.stop() audioConsumer?.clear() audioConsumer = nil - asr?.cancel() - asr = nil + if let session = asrSession { + asrSession = nil + await session.cancel() + } sessionPhase = .idle capsule.update(state: .error(reason)) DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in @@ -568,4 +575,19 @@ final class DictationCoordinator { // loadArkCredentials 已移除:润色路径改走 vault.llmProviderConfig(for:) + // OpenAICompatibleLLMProvider,不再单独构造 ArkCredentials。 + + // MARK: - ASR provider 工厂 + + /// 构造当前会话使用的 `ASRProvider`。 + /// + /// C-1 阶段硬编码到 `VolcengineASRProvider`;C-3(Settings UI)会改成从 + /// `vault.activeASRProviderId` 路由。返回 nil 时 coordinator 走 mock 流程 + /// (沿用旧版「凭据缺失 → 提示性占位」的契约,不向用户报硬错误)。 + private func makeASRProvider() -> ASRProvider? { + guard let creds = loadVolcengineCredentials() else { return nil } + return VolcengineASRProvider( + credentials: creds, + logger: { msg in Log.write(msg) } + ) + } } diff --git a/Sources/OpenLessCore/ASRProvider.swift b/Sources/OpenLessCore/ASRProvider.swift new file mode 100644 index 00000000..cc5ac695 --- /dev/null +++ b/Sources/OpenLessCore/ASRProvider.swift @@ -0,0 +1,134 @@ +import Foundation + +// ASRProvider 是录音层与具体 ASR 供应商之间的抽象。 +// +// 与 LLMProvider 的差异:ASR 有「流式」和「批量」两种交互形态。 +// - 流式(火山 / 阿里 paraformer / Apple Speech):边录边推 PCM,partial → final +// - 批量(OpenAI Whisper):录完整体上传,等几秒拿 final +// +// 我们在 protocol 层一并暴露两种入口;具体 provider 用 `info.mode` 自报家门, +// 调到错误入口时抛 `.unsupportedMode`。这样 coordinator 调用的代码在两种模式下 +// 的「形状」是一致的——选择由配置决定,业务流程不分叉。 +// +// AudioConsumer 桥接策略: +// `AudioConsumer.consume(pcmChunk:)` 是同步 + 不抛错的现存协议(OpenLessRecorder / +// BufferingAudioConsumer 都依赖它)。`ASRStreamingSession` 同时继承 AudioConsumer, +// provider 自己实现 `consume(pcmChunk:)`(通常委托给内部异步发送任务)。这是最小侵入 +// 方案——不改 AudioConsumer,不改任何现有 conformer 的签名。 + +/// ASR 提供两种交互形态。 +public enum ASRMode: String, Codable, Sendable { + /// 边录边推 PCM,边拿 partial 边等 final。火山 / 阿里 / Apple Speech 都属此类。 + case streaming + /// 录完整体上传,等几秒拿 final。OpenAI Whisper 属此类。 + case batch +} + +/// Provider 元数据:用于 UI 选择列表 + coordinator 路由决策。 +public struct ASRProviderInfo: Sendable, Hashable { + /// 稳定 slug,例如 "volcengine" / "openai-whisper" / "aliyun-paraformer" / "apple-speech"。 + public let providerId: String + /// 给 UI 显示的中文名,例如「火山引擎」。 + public let displayName: String + public let mode: ASRMode + /// 是否支持「真热词」(服务器端生效),不是 prompt 软提示。 + public let supportsHotwords: Bool + public let supportsLanguageHint: Bool + /// 流式 provider 是否会推送 partial(增量识别结果)。批量 provider 应为 false。 + public let supportsPartialResults: Bool + + public init( + providerId: String, + displayName: String, + mode: ASRMode, + supportsHotwords: Bool, + supportsLanguageHint: Bool, + supportsPartialResults: Bool + ) { + self.providerId = providerId + self.displayName = displayName + self.mode = mode + self.supportsHotwords = supportsHotwords + self.supportsLanguageHint = supportsLanguageHint + self.supportsPartialResults = supportsPartialResults + } +} + +/// ASR provider 抽象。 +/// +/// 同一份 protocol 同时承载流式 / 批量两类入口;非自家形态直接抛 `.unsupportedMode`。 +public protocol ASRProvider: Sendable { + var info: ASRProviderInfo { get } + + /// 流式入口:开 session,coordinator 后续把 PCM 推进 session(session 是 AudioConsumer)。 + /// - Parameters: + /// - language: BCP-47 语言提示,例如 "zh-CN" / "en-US"。不支持的 provider 可忽略。 + /// - hotwords: 用户词典中启用的词条;服务器端真热词。 + /// - Throws: 批量 provider 应抛 `ASRError.unsupportedMode`;连接失败抛对应错误。 + func openStreamingSession(language: String, hotwords: [String]) async throws -> ASRStreamingSession + + /// 批量入口:传完整 PCM,返回 final transcript。 + /// - Throws: 流式 provider 应抛 `ASRError.unsupportedMode`。 + func transcribeBatch( + pcm: Data, + sampleRate: Int, + channels: Int, + language: String, + hotwords: [String] + ) async throws -> RawTranscript +} + +/// 流式 session 抽象。 +/// +/// 同时继承 `AudioConsumer`:可以直接 `BufferingAudioConsumer.attach(session)`, +/// recorder 推 PCM → session.consume(pcmChunk:) → provider 内部发送队列。 +public protocol ASRStreamingSession: AudioConsumer, Sendable { + /// 显式异步发送一段 PCM(16 kHz / 16-bit LE / mono);framing / 编码 / gzip 由 provider 自负。 + /// 默认实现转发到 `AudioConsumer.consume(pcmChunk:)`,供 provider 选择性覆盖。 + func sendAudio(_ pcm: Data) async throws + + /// 用户停止说话,告诉 provider 收尾(不是 cancel)。 + func endStream() async throws + + /// 可选 partial:UI 想显示就订阅,不订阅也不影响 final。批量 / 不支持的 provider 应返回空 stream。 + var partialResults: AsyncStream { get } + + /// 阻塞等 final transcript。`endStream()` 之后调用。 + func awaitFinalResult() async throws -> RawTranscript + + /// 用户按 Esc / 取消。drop 所有内部状态。 + func cancel() async +} + +/// `sendAudio(_:)` 的默认实现:直接走 `consume(pcmChunk:)`。Provider 若有更精确的 +/// 异步控制(背压 / 错误回流)可自己覆盖。 +public extension ASRStreamingSession { + func sendAudio(_ pcm: Data) async throws { + consume(pcmChunk: pcm) + } +} + +/// ASR provider 抛出的错误。 +/// +/// 与 `LLMError` 风格保持一致:URLError 不直接暴露(不 Sendable / 不稳定 Equatable), +/// 网络错误统一用 NSError 包装。 +public enum ASRError: Error, Sendable, Equatable { + /// 凭据缺失(apiKey / appId 等)。 + case missingCredentials + /// 流式 provider 调批量入口或反之。 + case unsupportedMode + /// 鉴权失败。`statusCode` 可空——某些协议在 WebSocket 握手后才报错。 + case authFailed(statusCode: Int?) + /// 配额或限流。 + case quotaExceeded + /// 输入 PCM 不合法(采样率 / 声道数错等)。 + case invalidAudio(String) + /// 服务端返回业务错误(带 code + message)。 + case providerError(code: String, message: String) + /// 操作超时。 + case timeout + /// 其他网络错误。 + case network(NSError) + /// 用户主动取消。 + case cancelled +} diff --git a/Tests/OpenLessASRTests/VolcengineASRProviderTests.swift b/Tests/OpenLessASRTests/VolcengineASRProviderTests.swift new file mode 100644 index 00000000..6f1a5a75 --- /dev/null +++ b/Tests/OpenLessASRTests/VolcengineASRProviderTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import OpenLessASR +import OpenLessCore + +final class VolcengineASRProviderTests: XCTestCase { + + private func makeProvider() -> VolcengineASRProvider { + let creds = VolcengineCredentials( + appID: "test-app", + accessToken: "test-token", + resourceID: VolcengineCredentials.defaultResourceId + ) + return VolcengineASRProvider(credentials: creds) + } + + // MARK: - info 元数据 + + func test_info_reports_volcengine_streaming_provider() { + // Arrange + let provider = makeProvider() + + // Act + let info = provider.info + + // Assert + XCTAssertEqual(info.providerId, "volcengine") + XCTAssertEqual(info.mode, .streaming) + XCTAssertTrue(info.supportsHotwords) + } + + func test_info_displayName_is_localized_chinese() { + // Arrange + let provider = makeProvider() + + // Act + let displayName = provider.info.displayName + + // Assert + XCTAssertEqual(displayName, "火山引擎") + } + + // MARK: - 批量入口在流式 provider 上必须明确不支持 + + func test_transcribeBatch_throws_unsupportedMode() async { + // Arrange + let provider = makeProvider() + let pcm = Data(repeating: 0, count: 32_000) // 1 秒静音 + + // Act / Assert + do { + _ = try await provider.transcribeBatch( + pcm: pcm, + sampleRate: 16_000, + channels: 1, + language: "zh-CN", + hotwords: [] + ) + XCTFail("transcribeBatch 应抛 ASRError.unsupportedMode") + } catch let error as ASRError { + XCTAssertEqual(error, .unsupportedMode) + } catch { + XCTFail("应抛 ASRError,实际抛: \(error)") + } + } +} + +// MARK: - ASRProviderInfo / ASRError 基础形状 + +final class ASRProviderInfoTests: XCTestCase { + + func test_info_struct_is_value_equal() { + // Arrange + let a = ASRProviderInfo( + providerId: "x", + displayName: "X", + mode: .streaming, + supportsHotwords: true, + supportsLanguageHint: true, + supportsPartialResults: true + ) + let b = ASRProviderInfo( + providerId: "x", + displayName: "X", + mode: .streaming, + supportsHotwords: true, + supportsLanguageHint: true, + supportsPartialResults: true + ) + + // Act / Assert + XCTAssertEqual(a, b) + } + + func test_asrError_unsupportedMode_is_equatable() { + // Arrange + let lhs: ASRError = .unsupportedMode + let rhs: ASRError = .unsupportedMode + + // Act / Assert + XCTAssertEqual(lhs, rhs) + XCTAssertNotEqual(lhs, .missingCredentials) + XCTAssertNotEqual(lhs, .timeout) + } + + func test_asrMode_codable_roundtrips() throws { + // Arrange + let original: ASRMode = .batch + + // Act + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ASRMode.self, from: encoded) + + // Assert + XCTAssertEqual(decoded, original) + } +}