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.
120 lines
4.5 KiB
Swift
120 lines
4.5 KiB
Swift
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<String>
|
||
private let partialContinuation: AsyncStream<String>.Continuation
|
||
|
||
init(asr: VolcengineStreamingASR) {
|
||
self.asr = asr
|
||
var capturedContinuation: AsyncStream<String>.Continuation!
|
||
self.partialResults = AsyncStream<String> { 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()
|
||
}
|
||
}
|