Files
openless/Sources/OpenLessCore/ASRProvider.swift
T
baiqing 377d86c33a feat(asr): introduce ASRProvider protocol + Volcengine wrapper
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.
2026-04-29 12:32:49 +08:00

135 lines
5.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> { 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
}