Files
openless/Sources/OpenLessASR/VolcengineASRProvider.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

120 lines
4.5 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
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()
}
}