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.
135 lines
5.8 KiB
Swift
135 lines
5.8 KiB
Swift
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
|
||
}
|