feat(asr): aliyun-paraformer streaming + custom-openai-whisper batch
- 通用化 providers.asr entry:CredentialsProviderASREntry 覆盖 apiKey/baseURL/model + 旧 volcengine 三字段,保留 typealias 让 v0→v1 迁移与既有测试无改动通过。 - vault helpers:asrProviderConfig / setASRProviderConfig / removeASRProvider,与 LLM 那一组对称。 - AliyunParaformerASRProvider:URLSessionWebSocketTask + DashScope run-task / finish-task / result-generated 协议,partial 即时 yield,sentence_end=true 拼接为 final。 - CustomOpenAIWhisperASRProvider:包 16-bit PCM 为 WAV → multipart/form-data → /audio/transcriptions;25MB 上限前置拦截;HTTP 401/413/429/5xx → ASRError;流式入口抛 unsupportedMode。 - DictationCoordinator.makeASRProvider:新增 aliyun-paraformer / custom-openai-whisper 路由;阿里 key 缺失时回退复用 LLM aliyun-dashscope 同一把 key;Whisper batch dispatch 暂未接入(TODO 留在 case 内)。
This commit is contained in:
1 parent
b92e5e7778
commit
020b6a6bf0
7 files changed
+1231
-12
No files matched your search
@@ -0,0 +1,593 @@
|
||||
import Foundation
|
||||
import OpenLessCore
|
||||
|
||||
// AliyunParaformerASRProvider —— DashScope `paraformer-realtime-v2` 流式 ASR provider。
|
||||
//
|
||||
// 协议要点(详见 docs/plans/provider-abstraction.md §4.7):
|
||||
// 1. WS 连接到 wss://dashscope.aliyuncs.com/api-ws/v1/inference,header 带 Bearer apiKey。
|
||||
// 2. 连上后立刻发**文本帧** `run-task`(JSON),等服务端 `task-started` 才能开始推音频。
|
||||
// 3. 音频以**二进制帧**发送(PCM 原始字节,不要 base64)。
|
||||
// 4. 用户停说时发**文本帧** `finish-task`,等 `task-finished` 拿全部 final 文本。
|
||||
// 5. partial:每条 `result-generated` 事件都带 text;`sentence_end=true` 表示这一句已固化。
|
||||
// 终态文本由所有 `sentence_end=true` 的 text 拼接而成;其余只走 partialResults。
|
||||
//
|
||||
// V1 限制(与 §4.7 对齐):
|
||||
// - hotwords 必须先通过 vocabulary 上传 API 得到 vocabulary_id 才能注入;本期不做,
|
||||
// `supportsHotwords=false`。词典命中由 coordinator 后处理替换实现(已有逻辑)。
|
||||
// - DataInspection 默认 `enable`;隐私敏感用户可在 settings 显式关掉,但本 provider
|
||||
// 不暴露这个开关,使用默认值。
|
||||
// - cancel 直接关 WS,不做 graceful drain(DashScope 协议没有 graceful cancel)。
|
||||
|
||||
public final class AliyunParaformerASRProvider: ASRProvider {
|
||||
public let info: ASRProviderInfo
|
||||
private let apiKey: String
|
||||
private let endpoint: URL
|
||||
private let model: String
|
||||
private let logger: (@Sendable (String) -> Void)?
|
||||
|
||||
/// - Parameters:
|
||||
/// - apiKey: DashScope Bearer token(与 LLM `aliyun-dashscope` 共享同一把 key)。
|
||||
/// - endpoint: WS endpoint,默认国内站;测试或国际部署可覆盖。
|
||||
/// - model: 默认 `paraformer-realtime-v2`,电话场景可换 `paraformer-realtime-8k-v2`。
|
||||
/// - logger: 调试日志回调;libraries 不能直接落日志文件,由 caller 注入。
|
||||
public init(
|
||||
apiKey: String,
|
||||
endpoint: URL = URL(string: "wss://dashscope.aliyuncs.com/api-ws/v1/inference")!,
|
||||
model: String = "paraformer-realtime-v2",
|
||||
logger: (@Sendable (String) -> Void)? = nil
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.endpoint = endpoint
|
||||
self.model = model
|
||||
self.logger = logger
|
||||
self.info = ASRProviderInfo(
|
||||
providerId: "aliyun-paraformer",
|
||||
displayName: "阿里通义 Paraformer",
|
||||
mode: .streaming,
|
||||
// vocabulary 注入暂不做,热词改走 coordinator 后处理 → 这里照实暴露 false。
|
||||
supportsHotwords: false,
|
||||
supportsLanguageHint: true,
|
||||
supportsPartialResults: true
|
||||
)
|
||||
}
|
||||
|
||||
public func openStreamingSession(
|
||||
language: String,
|
||||
hotwords: [String]
|
||||
) async throws -> ASRStreamingSession {
|
||||
let session = AliyunParaformerSession(
|
||||
apiKey: apiKey,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
language: language,
|
||||
logger: logger
|
||||
)
|
||||
try await session.open()
|
||||
return session
|
||||
}
|
||||
|
||||
public func transcribeBatch(
|
||||
pcm: Data,
|
||||
sampleRate: Int,
|
||||
channels: Int,
|
||||
language: String,
|
||||
hotwords: [String]
|
||||
) async throws -> RawTranscript {
|
||||
throw ASRError.unsupportedMode
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 流式 session
|
||||
|
||||
/// DashScope WebSocket 会话适配。
|
||||
///
|
||||
/// 线程模型:所有"内部状态"(partial 文本累积、final 句子列表、continuation、task_id)走
|
||||
/// `NSLock` 同步——和 VolcengineStreamingSessionAdapter / AppleSpeechSession 一致,避免在 async
|
||||
/// 上下文里暴露持锁路径触发 Swift 6 严格并发警告。
|
||||
final class AliyunParaformerSession: ASRStreamingSession, @unchecked Sendable {
|
||||
let partialResults: AsyncStream<String>
|
||||
private let partialContinuation: AsyncStream<String>.Continuation
|
||||
|
||||
private let apiKey: String
|
||||
private let endpoint: URL
|
||||
private let model: String
|
||||
private let language: String
|
||||
private let logger: (@Sendable (String) -> Void)?
|
||||
|
||||
private let lock = NSLock()
|
||||
private let session: URLSession
|
||||
private var task: URLSessionWebSocketTask?
|
||||
/// 客户端生成的 task_id;run-task 与 finish-task 必须一致。
|
||||
private let taskId: String
|
||||
/// 拼接 final 文本:所有 `sentence_end=true` 的 result-generated.text 顺序 join。
|
||||
private var finalSentences: [String] = []
|
||||
/// 已收到 task-started 才能发音频;之前到达的 PCM 全部缓冲在 pendingAudio。
|
||||
private var taskStarted: Bool = false
|
||||
private var pendingAudio: [Data] = []
|
||||
/// awaitFinalResult 的 continuation;task-finished / 错误 / 关闭时 resume。
|
||||
private var finalContinuation: CheckedContinuation<RawTranscript, Error>?
|
||||
/// 终态:true 后任何 deliver 都被吞,避免 continuation 被重复 resume。
|
||||
private var didDeliverFinal: Bool = false
|
||||
/// 是否已发 finish-task,避免 endStream 多次调用重复发送。
|
||||
private var didSendFinish: Bool = false
|
||||
private var startedAt: Date = Date()
|
||||
|
||||
init(
|
||||
apiKey: String,
|
||||
endpoint: URL,
|
||||
model: String,
|
||||
language: String,
|
||||
logger: (@Sendable (String) -> Void)?
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.endpoint = endpoint
|
||||
self.model = model
|
||||
self.language = language
|
||||
self.logger = logger
|
||||
self.taskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
self.session = URLSession(configuration: .default)
|
||||
var capturedContinuation: AsyncStream<String>.Continuation!
|
||||
self.partialResults = AsyncStream<String> { continuation in
|
||||
capturedContinuation = continuation
|
||||
}
|
||||
self.partialContinuation = capturedContinuation
|
||||
}
|
||||
|
||||
// MARK: - 生命周期
|
||||
|
||||
/// 建立 WebSocket,发 run-task,等 `task-started` 事件之后再返回。
|
||||
/// 在等待 task-started 期间到达的 PCM 都进 pendingAudio,task-started 后批量 flush。
|
||||
func open() async throws {
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
// 默认开启数据巡检;隐私敏感用户后续可通过 settings 切到 disable。
|
||||
request.setValue("enable", forHTTPHeaderField: "X-DashScope-DataInspection")
|
||||
|
||||
let task = session.webSocketTask(with: request)
|
||||
// 不在 async 上下文里直接持锁——下沉到同步辅助,避免 Swift 6 严格并发警告升级为 error。
|
||||
attachTask(task)
|
||||
task.resume()
|
||||
receiveLoop(task: task)
|
||||
|
||||
// 发 run-task。
|
||||
let payload = makeRunTaskPayload()
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
do {
|
||||
try await task.send(.string(text))
|
||||
logger?("[aliyun-paraformer] run-task sent (taskId=\(taskId), model=\(model))")
|
||||
} catch {
|
||||
// 握手未通过 / 401 / 403 在 send 阶段会以 URLError 形式回流。
|
||||
await mapAndDeliverConnectError(error)
|
||||
throw mapURLError(error)
|
||||
}
|
||||
|
||||
// 等 task-started(最多 10s)。这里用一个 polling 风格 await——
|
||||
// 避免给 session 再加一个独立 continuation,让代码路径单一。
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline {
|
||||
if isTaskStartedSnapshot() { return }
|
||||
if let err = takeTerminalErrorSnapshot() { throw err }
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
// 超时——主动取消会话并抛 timeout。
|
||||
await cancel()
|
||||
throw ASRError.timeout
|
||||
}
|
||||
|
||||
// MARK: - AudioConsumer
|
||||
|
||||
func consume(pcmChunk: Data) {
|
||||
// 同步入口:根据 taskStarted 决定立即发还是入队。
|
||||
// URLSessionWebSocketTask.send(_:completionHandler:) 在底层并发队列异步执行,
|
||||
// 这里不能 await——直接调 completion handler 版本即可。
|
||||
lock.lock()
|
||||
let started = taskStarted
|
||||
let snapshotTask = task
|
||||
if !started {
|
||||
pendingAudio.append(pcmChunk)
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard started, let snapshotTask else { return }
|
||||
snapshotTask.send(.data(pcmChunk)) { [weak self] err in
|
||||
if let err = err {
|
||||
self?.logger?("[aliyun-paraformer] audio send 失败: \(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ASRStreamingSession
|
||||
|
||||
func sendAudio(_ pcm: Data) async throws {
|
||||
consume(pcmChunk: pcm)
|
||||
}
|
||||
|
||||
func endStream() async throws {
|
||||
guard markFinishSent() else { return }
|
||||
let payload: [String: Any] = [
|
||||
"header": [
|
||||
"action": "finish-task",
|
||||
"task_id": taskId,
|
||||
"streaming": "duplex",
|
||||
],
|
||||
"payload": [
|
||||
"input": [String: Any]()
|
||||
],
|
||||
]
|
||||
guard let task = currentTaskSnapshot(),
|
||||
let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let text = String(data: data, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await task.send(.string(text))
|
||||
logger?("[aliyun-paraformer] finish-task sent")
|
||||
} catch {
|
||||
logger?("[aliyun-paraformer] finish-task send 失败: \(error.localizedDescription)")
|
||||
throw mapURLError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func awaitFinalResult() async throws -> RawTranscript {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<RawTranscript, Error>) in
|
||||
// 注册前先看终态:可能 task-finished 已经在 receiveLoop 里到达且没有 awaiter。
|
||||
switch registerFinalContinuation(continuation) {
|
||||
case .registered:
|
||||
return
|
||||
case .alreadyDeliveredText(let text, let durationMs):
|
||||
continuation.resume(returning: RawTranscript(text: text, durationMs: durationMs))
|
||||
case .alreadyDeliveredError(let err):
|
||||
continuation.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
let snapshot = takeStateForCancel()
|
||||
snapshot.task?.cancel()
|
||||
partialContinuation.finish()
|
||||
if !snapshot.alreadyDelivered {
|
||||
snapshot.cont?.resume(throwing: ASRError.cancelled)
|
||||
}
|
||||
logger?("[aliyun-paraformer] cancelled")
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
private func makeRunTaskPayload() -> [String: Any] {
|
||||
// language_hints:从 BCP-47 抽出主语言代码(zh-CN → zh,en-US → en),
|
||||
// 默认还兜上 en 提高混说场景识别率。空 / 未识别 → 仅给 zh。
|
||||
let hints = languageHints(from: language)
|
||||
let parameters: [String: Any] = [
|
||||
"format": "pcm",
|
||||
"sample_rate": 16000,
|
||||
"language_hints": hints,
|
||||
"punctuation_prediction_enabled": true,
|
||||
"max_sentence_silence": 500,
|
||||
// 8k-v2 上 emo_tag 与 semantic_punctuation_enabled 互斥;这里用默认 16k v2 → 关掉
|
||||
// semantic_punctuation_enabled 即可,emo_tag 不需要。
|
||||
"semantic_punctuation_enabled": false,
|
||||
"heartbeat": false,
|
||||
]
|
||||
// hotwords 注入需要 vocabulary_id(先上传词表才有),v1 不做——这里不放 vocabulary_id 字段。
|
||||
|
||||
return [
|
||||
"header": [
|
||||
"action": "run-task",
|
||||
"task_id": taskId,
|
||||
"streaming": "duplex",
|
||||
],
|
||||
"payload": [
|
||||
"task_group": "audio",
|
||||
"task": "asr",
|
||||
"function": "recognition",
|
||||
"model": model,
|
||||
"parameters": parameters,
|
||||
"input": [String: Any](),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
private func languageHints(from bcp47: String) -> [String] {
|
||||
let trimmed = bcp47.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ["zh", "en"] }
|
||||
let primary = trimmed.split(separator: "-").first.map(String.init)?.lowercased() ?? "zh"
|
||||
// DashScope paraformer-realtime-v2 接受的码:zh / en / ja / yue / ko / de / fr / ru / es / it / ar
|
||||
let known: Set<String> = ["zh", "en", "ja", "yue", "ko", "de", "fr", "ru", "es", "it", "ar"]
|
||||
if known.contains(primary) {
|
||||
// 中英混说默认值;其他主语言只挂自己。
|
||||
return primary == "zh" || primary == "en" ? ["zh", "en"] : [primary]
|
||||
}
|
||||
return ["zh", "en"]
|
||||
}
|
||||
|
||||
/// 接收循环:解析 `task-started` / `result-generated` / `task-finished` / `task-failed`。
|
||||
private func receiveLoop(task: URLSessionWebSocketTask) {
|
||||
task.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
self.handleConnectionError(error)
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
self.handleEvent(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
self.handleEvent(text)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
if !self.isFinishedSnapshot() {
|
||||
self.receiveLoop(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let header = json["header"] as? [String: Any],
|
||||
let event = header["event"] as? String else {
|
||||
logger?("[aliyun-paraformer] unrecognized event: \(text.prefix(200))")
|
||||
return
|
||||
}
|
||||
switch event {
|
||||
case "task-started":
|
||||
logger?("[aliyun-paraformer] task-started")
|
||||
flushPendingOnTaskStarted()
|
||||
case "result-generated":
|
||||
handleResultGenerated(payload: json["payload"] as? [String: Any] ?? [:])
|
||||
case "task-finished":
|
||||
logger?("[aliyun-paraformer] task-finished")
|
||||
deliverFinalSuccess()
|
||||
case "task-failed":
|
||||
let code = (header["error_code"] as? String) ?? "unknown"
|
||||
let message = (header["error_message"] as? String) ?? "task-failed"
|
||||
logger?("[aliyun-paraformer] task-failed code=\(code) message=\(message)")
|
||||
deliverFinalFailure(.providerError(code: code, message: message))
|
||||
default:
|
||||
// 忽略心跳 / 其他暂未消费的事件。
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func handleResultGenerated(payload: [String: Any]) {
|
||||
// 标准 payload 形态:{ "output": { "sentence": { "text": "...", "sentence_end": true/false } } }
|
||||
let output = payload["output"] as? [String: Any] ?? [:]
|
||||
let sentence = output["sentence"] as? [String: Any] ?? [:]
|
||||
let text = (sentence["text"] as? String) ?? ""
|
||||
let sentenceEnd = (sentence["sentence_end"] as? Bool) ?? false
|
||||
if text.isEmpty { return }
|
||||
if sentenceEnd {
|
||||
appendFinalSentence(text)
|
||||
} else {
|
||||
partialContinuation.yield(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectionError(_ error: Error) {
|
||||
// WS upgrade 阶段的 401 / 403 通过 URLError 流回;中间断连也走这里。
|
||||
// 只有终态没到时才升级成 final 错误,否则忽略。
|
||||
let mapped = mapURLError(error)
|
||||
if !isDeliveredSnapshot() {
|
||||
// 没正常 task-finished 就被中断 → 用 mapped 错误结束。
|
||||
deliverFinalFailure(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 URLError → ASRError 映射,覆盖 401 / 403 / 超时 / 取消 / 其他网络错。
|
||||
private func mapURLError(_ error: Error) -> ASRError {
|
||||
let nsErr = error as NSError
|
||||
if nsErr.domain == NSURLErrorDomain {
|
||||
switch nsErr.code {
|
||||
case NSURLErrorUserAuthenticationRequired,
|
||||
NSURLErrorUserCancelledAuthentication:
|
||||
return .authFailed(statusCode: 401)
|
||||
case NSURLErrorTimedOut:
|
||||
return .timeout
|
||||
case NSURLErrorCancelled:
|
||||
return .cancelled
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// URLSessionWebSocketTask 把握手 HTTP 状态码暴露在 userInfo["_NSURLErrorHTTPResponseKey"]。
|
||||
if let response = nsErr.userInfo["_NSURLErrorFailingURLPeerTrustErrorKey"] as? HTTPURLResponse {
|
||||
return mapHTTPStatus(response.statusCode)
|
||||
}
|
||||
if let response = nsErr.userInfo["NSErrorFailingURLResponseKey"] as? HTTPURLResponse {
|
||||
return mapHTTPStatus(response.statusCode)
|
||||
}
|
||||
return .network(nsErr)
|
||||
}
|
||||
|
||||
private func mapHTTPStatus(_ code: Int) -> ASRError {
|
||||
switch code {
|
||||
case 401: return .authFailed(statusCode: 401)
|
||||
case 403: return .quotaExceeded
|
||||
case 408: return .timeout
|
||||
case 429: return .quotaExceeded
|
||||
default:
|
||||
let nsErr = NSError(domain: "AliyunParaformer", code: code, userInfo: [
|
||||
NSLocalizedDescriptionKey: "WS upgrade failed (HTTP \(code))"
|
||||
])
|
||||
return .network(nsErr)
|
||||
}
|
||||
}
|
||||
|
||||
private func mapAndDeliverConnectError(_ error: Error) async {
|
||||
let mapped = mapURLError(error)
|
||||
deliverFinalFailure(mapped)
|
||||
}
|
||||
|
||||
// MARK: - 同步辅助(持锁;不在 async 上下文里直接调用 NSLock)
|
||||
|
||||
private func attachTask(_ newTask: URLSessionWebSocketTask) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.task = newTask
|
||||
self.startedAt = Date()
|
||||
}
|
||||
|
||||
private func appendFinalSentence(_ text: String) {
|
||||
lock.lock()
|
||||
finalSentences.append(text)
|
||||
lock.unlock()
|
||||
// partial 也 yield 一份:"这一句已固化"对 UI 来说也是个有效中间态。
|
||||
partialContinuation.yield(text)
|
||||
}
|
||||
|
||||
private func flushPendingOnTaskStarted() {
|
||||
var pending: [Data] = []
|
||||
var snapshotTask: URLSessionWebSocketTask?
|
||||
lock.lock()
|
||||
taskStarted = true
|
||||
pending = pendingAudio
|
||||
pendingAudio.removeAll(keepingCapacity: false)
|
||||
snapshotTask = task
|
||||
lock.unlock()
|
||||
|
||||
guard let snapshotTask else { return }
|
||||
for chunk in pending {
|
||||
snapshotTask.send(.data(chunk)) { [weak self] err in
|
||||
if let err = err {
|
||||
self?.logger?("[aliyun-paraformer] flush audio 失败: \(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isTaskStartedSnapshot() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return taskStarted
|
||||
}
|
||||
|
||||
private func isFinishedSnapshot() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return didDeliverFinal
|
||||
}
|
||||
|
||||
private func isDeliveredSnapshot() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return didDeliverFinal
|
||||
}
|
||||
|
||||
private func currentTaskSnapshot() -> URLSessionWebSocketTask? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return task
|
||||
}
|
||||
|
||||
private func markFinishSent() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if didSendFinish { return false }
|
||||
didSendFinish = true
|
||||
return true
|
||||
}
|
||||
|
||||
/// 暂存的终态错误:在 open() 等待 task-started 阶段,receiveLoop 已经收到 task-failed
|
||||
/// 等致命事件时,把错误暴露给 open 让它直接抛。
|
||||
private func takeTerminalErrorSnapshot() -> ASRError? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard didDeliverFinal else { return nil }
|
||||
// 已 deliver 但 finalContinuation 还没注册——错误在 cont 里已经投递;
|
||||
// open 路径直接返回一个语义合理的错误。
|
||||
return .providerError(code: "task-failed", message: "DashScope 拒绝任务(详见日志)")
|
||||
}
|
||||
|
||||
private enum FinalContinuationOutcome {
|
||||
case registered
|
||||
case alreadyDeliveredText(String, Int)
|
||||
case alreadyDeliveredError(Error)
|
||||
}
|
||||
|
||||
private func registerFinalContinuation(
|
||||
_ continuation: CheckedContinuation<RawTranscript, Error>
|
||||
) -> FinalContinuationOutcome {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if didDeliverFinal {
|
||||
// 终态已到:要么是文本(task-finished 拼好的),要么是错误。
|
||||
if let err = pendingFinalError {
|
||||
pendingFinalError = nil
|
||||
return .alreadyDeliveredError(err)
|
||||
}
|
||||
let text = finalSentences.joined()
|
||||
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||
return .alreadyDeliveredText(text, durationMs)
|
||||
}
|
||||
finalContinuation = continuation
|
||||
return .registered
|
||||
}
|
||||
|
||||
private struct CancelSnapshot {
|
||||
let task: URLSessionWebSocketTask?
|
||||
let cont: CheckedContinuation<RawTranscript, Error>?
|
||||
let alreadyDelivered: Bool
|
||||
}
|
||||
|
||||
private func takeStateForCancel() -> CancelSnapshot {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let snapshot = CancelSnapshot(
|
||||
task: task,
|
||||
cont: finalContinuation,
|
||||
alreadyDelivered: didDeliverFinal
|
||||
)
|
||||
task = nil
|
||||
finalContinuation = nil
|
||||
didDeliverFinal = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 已 deliver 终态但 awaitFinalResult 还没注册时暂存错误,等注册时再投递。
|
||||
private var pendingFinalError: Error?
|
||||
|
||||
private func deliverFinalSuccess() {
|
||||
var cont: CheckedContinuation<RawTranscript, Error>?
|
||||
var text = ""
|
||||
var durationMs = 0
|
||||
lock.lock()
|
||||
if didDeliverFinal {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
didDeliverFinal = true
|
||||
cont = finalContinuation
|
||||
finalContinuation = nil
|
||||
text = finalSentences.joined()
|
||||
durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||
lock.unlock()
|
||||
partialContinuation.finish()
|
||||
cont?.resume(returning: RawTranscript(text: text, durationMs: durationMs))
|
||||
logger?("[aliyun-paraformer] final delivered (len=\(text.count))")
|
||||
}
|
||||
|
||||
private func deliverFinalFailure(_ error: ASRError) {
|
||||
var cont: CheckedContinuation<RawTranscript, Error>?
|
||||
lock.lock()
|
||||
if didDeliverFinal {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
didDeliverFinal = true
|
||||
cont = finalContinuation
|
||||
finalContinuation = nil
|
||||
if cont == nil {
|
||||
pendingFinalError = error
|
||||
}
|
||||
lock.unlock()
|
||||
partialContinuation.finish()
|
||||
cont?.resume(throwing: error)
|
||||
logger?("[aliyun-paraformer] final error: \(error)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import OpenLessCore
|
||||
|
||||
// CustomOpenAIWhisperASRProvider —— OpenAI 兼容批量 ASR provider。
|
||||
//
|
||||
// 协议要点(详见 docs/plans/provider-abstraction.md §4.5):
|
||||
// 1. 端点:`POST <baseURL>/audio/transcriptions`,multipart/form-data。
|
||||
// 2. 鉴权:`Authorization: Bearer <apiKey>`。
|
||||
// 3. 入参:file(音频字节,本实现包成 16 kHz / 16-bit / mono WAV)+ model(默认 whisper-1)+
|
||||
// language(如 "zh")+ 可选 prompt(hotwords 拼接,软提示,不保证生效)。
|
||||
// 4. 响应:`{"text": "..."}`。错误标准 OpenAI 形态。
|
||||
//
|
||||
// V1 限制(与 §4.5 对齐):
|
||||
// - 单次硬上限 25 MB(multipart body 超过 → 413)。包成 WAV 后 PCM ~ 32 KB/s @ 16k mono,
|
||||
// 约 13 分钟到顶。这里在 transcribeBatch 入口就先按 25 MB 截断 / fail,避免上传后才被服务端拒。
|
||||
// - hotwords 仅作 prompt 注入,软提示——`supportsHotwords=false`。Whisper-1 的 prompt 看后 224
|
||||
// tokens;gpt-4o 系列更宽容,但 OpenAI 官方明确"不保证精确替换"。
|
||||
// - 流式入口 (`openStreamingSession`) 直接抛 `.unsupportedMode`。
|
||||
//
|
||||
// V1 集成范围:
|
||||
// - DictationCoordinator 暂未实现"录完整体上传"分支(streaming dispatch only)。本 provider
|
||||
// 已经独立可用——可以在单元测试里直接传 PCM 调;coordinator 的 batch 路径 TODO 后续补。
|
||||
|
||||
public final class CustomOpenAIWhisperASRProvider: ASRProvider {
|
||||
public let info: ASRProviderInfo
|
||||
private let baseURL: URL
|
||||
private let apiKey: String
|
||||
private let model: String
|
||||
private let session: URLSession
|
||||
private let logger: (@Sendable (String) -> Void)?
|
||||
|
||||
/// OpenAI Whisper 单次上传的硬上限(25 MB)。包好 WAV 后超过此阈值直接 fail。
|
||||
public static let maxUploadBytes: Int = 25 * 1024 * 1024
|
||||
/// 网络超时(覆盖上传 + 服务端转录 + 抖动)。
|
||||
public static let requestTimeoutSeconds: TimeInterval = 60
|
||||
|
||||
public init(
|
||||
baseURL: URL,
|
||||
apiKey: String,
|
||||
model: String = "whisper-1",
|
||||
session: URLSession = .shared,
|
||||
logger: (@Sendable (String) -> Void)? = nil
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.apiKey = apiKey
|
||||
self.model = model
|
||||
self.session = session
|
||||
self.logger = logger
|
||||
self.info = ASRProviderInfo(
|
||||
providerId: "custom-openai-whisper",
|
||||
displayName: "自定义 OpenAI 兼容 (Whisper)",
|
||||
mode: .batch,
|
||||
// prompt 注入只是软提示,不算真热词。
|
||||
supportsHotwords: false,
|
||||
supportsLanguageHint: true,
|
||||
supportsPartialResults: false
|
||||
)
|
||||
}
|
||||
|
||||
public func openStreamingSession(
|
||||
language: String,
|
||||
hotwords: [String]
|
||||
) async throws -> ASRStreamingSession {
|
||||
throw ASRError.unsupportedMode
|
||||
}
|
||||
|
||||
public func transcribeBatch(
|
||||
pcm: Data,
|
||||
sampleRate: Int,
|
||||
channels: Int,
|
||||
language: String,
|
||||
hotwords: [String]
|
||||
) async throws -> RawTranscript {
|
||||
let started = Date()
|
||||
// 1) 包成 WAV。Whisper 服务端会自动重采样,但前端需要一个合法容器;
|
||||
// 16-bit / mono / 用户给的 sampleRate(默认 16k)就够了。
|
||||
let wav = WAVHeader.wrap(pcm: pcm, sampleRate: sampleRate, channels: channels)
|
||||
guard wav.count <= Self.maxUploadBytes else {
|
||||
throw ASRError.invalidAudio("超过 OpenAI Whisper 单次上限 25MB")
|
||||
}
|
||||
|
||||
// 2) 构造 multipart/form-data 请求。
|
||||
let boundary = "openless-\(UUID().uuidString)"
|
||||
let url = baseURL.appendingPathComponent("audio/transcriptions")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = Self.requestTimeoutSeconds
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = makeMultipartBody(
|
||||
boundary: boundary,
|
||||
wav: wav,
|
||||
language: language,
|
||||
hotwords: hotwords
|
||||
)
|
||||
|
||||
logger?("[whisper] POST \(url.absoluteString) bytes=\(body.count) lang=\(language) hot=\(hotwords.count)")
|
||||
|
||||
// 3) 发送。upload(for:from:) 不会单独读 request.httpBody,所以 body 走第二个参数。
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.upload(for: request, from: body)
|
||||
} catch {
|
||||
let nsErr = error as NSError
|
||||
if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorTimedOut {
|
||||
throw ASRError.timeout
|
||||
}
|
||||
if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorCancelled {
|
||||
throw ASRError.cancelled
|
||||
}
|
||||
throw ASRError.network(nsErr)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ASRError.network(NSError(
|
||||
domain: "CustomOpenAIWhisper",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "非 HTTP 响应"]
|
||||
))
|
||||
}
|
||||
|
||||
// 4) HTTP 状态码 → ASRError。
|
||||
switch http.statusCode {
|
||||
case 200..<300:
|
||||
break
|
||||
case 401:
|
||||
throw ASRError.authFailed(statusCode: 401)
|
||||
case 413:
|
||||
throw ASRError.invalidAudio("audio too large")
|
||||
case 429:
|
||||
throw ASRError.quotaExceeded
|
||||
case 500...599:
|
||||
let message = parseErrorMessage(data: data) ?? "server error"
|
||||
throw ASRError.providerError(code: "http-\(http.statusCode)", message: message)
|
||||
default:
|
||||
let message = parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)"
|
||||
throw ASRError.providerError(code: "http-\(http.statusCode)", message: message)
|
||||
}
|
||||
|
||||
// 5) 解析 `{"text": "..."}`。
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = json["text"] as? String else {
|
||||
throw ASRError.providerError(
|
||||
code: "invalid-response",
|
||||
message: "Whisper 响应缺少 text 字段"
|
||||
)
|
||||
}
|
||||
|
||||
let durationMs = Int(Date().timeIntervalSince(started) * 1000)
|
||||
return RawTranscript(text: text, durationMs: durationMs)
|
||||
}
|
||||
|
||||
// MARK: - Multipart 构造
|
||||
|
||||
/// 构造 multipart body。字段顺序与 OpenAI 官方示例一致:file → model → language → prompt。
|
||||
private func makeMultipartBody(
|
||||
boundary: String,
|
||||
wav: Data,
|
||||
language: String,
|
||||
hotwords: [String]
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
|
||||
// file 字段
|
||||
body.appendUTF8("--\(boundary)\(crlf)")
|
||||
body.appendUTF8("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\(crlf)")
|
||||
body.appendUTF8("Content-Type: audio/wav\(crlf)\(crlf)")
|
||||
body.append(wav)
|
||||
body.appendUTF8(crlf)
|
||||
|
||||
// model 字段
|
||||
body.appendUTF8("--\(boundary)\(crlf)")
|
||||
body.appendUTF8("Content-Disposition: form-data; name=\"model\"\(crlf)\(crlf)")
|
||||
body.appendUTF8("\(model)\(crlf)")
|
||||
|
||||
// language 字段(OpenAI 接收 ISO-639-1:zh / en / ja…;空字符串就不写让服务端自动检测)
|
||||
let lang = simplifyLanguage(language)
|
||||
if !lang.isEmpty {
|
||||
body.appendUTF8("--\(boundary)\(crlf)")
|
||||
body.appendUTF8("Content-Disposition: form-data; name=\"language\"\(crlf)\(crlf)")
|
||||
body.appendUTF8("\(lang)\(crlf)")
|
||||
}
|
||||
|
||||
// prompt 字段:hotwords 拼接成逗号分隔字符串作为软提示。
|
||||
let cleaned = hotwords
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !cleaned.isEmpty {
|
||||
let prompt = cleaned.joined(separator: ", ")
|
||||
body.appendUTF8("--\(boundary)\(crlf)")
|
||||
body.appendUTF8("Content-Disposition: form-data; name=\"prompt\"\(crlf)\(crlf)")
|
||||
body.appendUTF8("\(prompt)\(crlf)")
|
||||
}
|
||||
|
||||
// 收尾 boundary
|
||||
body.appendUTF8("--\(boundary)--\(crlf)")
|
||||
return body
|
||||
}
|
||||
|
||||
/// BCP-47 → ISO-639-1:`zh-CN` → `zh`,`en-US` → `en`,空 / 不识别返回空字符串
|
||||
/// (让 OpenAI 走自动检测)。
|
||||
private func simplifyLanguage(_ bcp47: String) -> String {
|
||||
let trimmed = bcp47.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
return trimmed.split(separator: "-").first.map(String.init) ?? ""
|
||||
}
|
||||
|
||||
/// 尝试从 OpenAI 风格的错误响应里挖 message:`{"error":{"message": "...", ...}}`。
|
||||
private func parseErrorMessage(data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
if let err = json["error"] as? [String: Any], let m = err["message"] as? String {
|
||||
return m
|
||||
}
|
||||
if let m = json["message"] as? String { return m }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WAV 包装
|
||||
|
||||
/// 把 16-bit signed little-endian PCM 包成 RIFF/WAVE。
|
||||
///
|
||||
/// 仅支持 PCM(format code = 1)。`channels` / `sampleRate` 使用调用方给出的值,
|
||||
/// bits-per-sample 锁死 16(OpenLessRecorder 的输出格式)。
|
||||
enum WAVHeader {
|
||||
/// WAV header 长度:12 (RIFF) + 24 (fmt) + 8 (data prefix) = 44 字节。
|
||||
static let headerSize: Int = 44
|
||||
|
||||
static func wrap(pcm: Data, sampleRate: Int, channels: Int) -> Data {
|
||||
let dataSize = UInt32(pcm.count)
|
||||
let bitsPerSample: UInt16 = 16
|
||||
let blockAlign: UInt16 = UInt16(channels) * bitsPerSample / 8
|
||||
let byteRate: UInt32 = UInt32(sampleRate) * UInt32(blockAlign)
|
||||
let chunkSize: UInt32 = 36 + dataSize
|
||||
|
||||
var header = Data()
|
||||
// RIFF header
|
||||
header.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // "RIFF"
|
||||
header.append(uint32LE: chunkSize)
|
||||
header.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // "WAVE"
|
||||
|
||||
// fmt chunk
|
||||
header.append(contentsOf: [0x66, 0x6d, 0x74, 0x20]) // "fmt "
|
||||
header.append(uint32LE: 16) // fmt chunk size = 16
|
||||
header.append(uint16LE: 1) // audio format = PCM
|
||||
header.append(uint16LE: UInt16(channels))
|
||||
header.append(uint32LE: UInt32(sampleRate))
|
||||
header.append(uint32LE: byteRate)
|
||||
header.append(uint16LE: blockAlign)
|
||||
header.append(uint16LE: bitsPerSample)
|
||||
|
||||
// data chunk
|
||||
header.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // "data"
|
||||
header.append(uint32LE: dataSize)
|
||||
|
||||
var wav = Data(capacity: header.count + pcm.count)
|
||||
wav.append(header)
|
||||
wav.append(pcm)
|
||||
return wav
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func appendUTF8(_ s: String) {
|
||||
if let data = s.data(using: .utf8) {
|
||||
self.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func append(uint16LE value: UInt16) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { self.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
mutating func append(uint32LE value: UInt32) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { self.append(contentsOf: $0) }
|
||||
}
|
||||
}
|
||||
@@ -584,12 +584,17 @@ final class DictationCoordinator {
|
||||
/// - "volcengine":要凭据;缺凭据时返回 nil → coordinator 走 mock 流程。
|
||||
/// - "apple-speech":无凭据;直接构造 `AppleSpeechASRProvider`,权限弹窗在
|
||||
/// 首次 openStreamingSession 时由系统触发。
|
||||
/// - "aliyun-paraformer":DashScope Bearer key;找不到时回退去复用 LLM
|
||||
/// `aliyun-dashscope` 的 apiKey(同一把 key 两个产品都能用)。
|
||||
/// - "custom-openai-whisper":仅注册 case;coordinator 当前的 streaming 流程不会消费
|
||||
/// 这个 provider(mode=.batch),录音路径会落 mock,直到 batch dispatch 接好。
|
||||
/// - 其他 id:未知 / 未来 provider,先返回 nil 走 mock,避免把用户卡死。
|
||||
///
|
||||
/// 返回 nil 时 coordinator 走 mock 流程(沿用旧版「凭据缺失 → 提示性占位」的契约,
|
||||
/// 不向用户报硬错误)。
|
||||
private func makeASRProvider() -> ASRProvider? {
|
||||
let activeId = CredentialsVault.shared.activeASRProviderId
|
||||
let vault = CredentialsVault.shared
|
||||
let activeId = vault.activeASRProviderId
|
||||
switch activeId {
|
||||
case "volcengine":
|
||||
guard let creds = loadVolcengineCredentials() else { return nil }
|
||||
@@ -601,9 +606,57 @@ final class DictationCoordinator {
|
||||
return AppleSpeechASRProvider(
|
||||
logger: { msg in Log.write(msg) }
|
||||
)
|
||||
case "aliyun-paraformer":
|
||||
// 优先用 ASR 自己存的 key;空时回退复用 LLM aliyun-dashscope(同一把 DashScope token)。
|
||||
if let entry = vault.asrProviderConfig(for: "aliyun-paraformer"),
|
||||
let key = entry.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty {
|
||||
return AliyunParaformerASRProvider(
|
||||
apiKey: key,
|
||||
logger: { msg in Log.write(msg) }
|
||||
)
|
||||
}
|
||||
if let llmCfg = vault.llmProviderConfig(for: "aliyun-dashscope"),
|
||||
!llmCfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Log.write("[asr] aliyun-paraformer 没有自己的 apiKey,复用 LLM aliyun-dashscope key")
|
||||
return AliyunParaformerASRProvider(
|
||||
apiKey: llmCfg.apiKey,
|
||||
logger: { msg in Log.write(msg) }
|
||||
)
|
||||
}
|
||||
Log.write("[asr] aliyun-paraformer 缺少 apiKey,走 mock 流程")
|
||||
return nil
|
||||
case "custom-openai-whisper":
|
||||
// TODO(coordinator-batch): 当前 coordinator 只走 streaming 路径
|
||||
// (`provider.openStreamingSession(...)`)。Whisper 是 batch provider,
|
||||
// `openStreamingSession` 会抛 .unsupportedMode;需要补一条
|
||||
// "录完整体 → provider.transcribeBatch" 的分发链路再接进来。
|
||||
// 在那之前用户选了这家 provider,会落 mock;UI 应给提示。
|
||||
guard let entry = vault.asrProviderConfig(for: "custom-openai-whisper"),
|
||||
let key = entry.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty,
|
||||
let urlStr = entry.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let baseURL = URL(string: urlStr) else {
|
||||
Log.write("[asr] custom-openai-whisper 缺少 baseURL/apiKey,走 mock 流程")
|
||||
return nil
|
||||
}
|
||||
let model = entry.model?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? "whisper-1"
|
||||
return CustomOpenAIWhisperASRProvider(
|
||||
baseURL: baseURL,
|
||||
apiKey: key,
|
||||
model: model,
|
||||
logger: { msg in Log.write(msg) }
|
||||
)
|
||||
default:
|
||||
Log.write("[asr] 未知 active provider \(activeId),走 mock 流程")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 小工具
|
||||
|
||||
private extension String {
|
||||
/// 空字符串 → nil;保持调用点的 `??` 兜底语义统一。
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -32,14 +32,16 @@ public struct CredentialsSchemaV1: Codable, Sendable, Equatable {
|
||||
|
||||
/// `providers` 节点。M1 类型化为已知的两类(asr / llm),未来扩展时把字典加到这里。
|
||||
public struct CredentialsProviders: Codable, Sendable, Equatable {
|
||||
/// ASR 类别:providerId → provider 配置。M1 仅有 `volcengine`。
|
||||
public var asr: [String: CredentialsProviderASRVolcengine]
|
||||
/// ASR 类别:providerId → provider 配置。
|
||||
/// 通用 entry:火山(appKey/accessKey/resourceId)/ 阿里 paraformer(apiKey)/
|
||||
/// 自定义 OpenAI Whisper(apiKey/baseURL/model)共用同一份 entry。
|
||||
public var asr: [String: CredentialsProviderASREntry]
|
||||
/// LLM 类别:providerId → provider 配置。
|
||||
/// 任意 OpenAI 兼容供应商(ark / openai / aliyun-dashscope / deepseek / moonshot / 自定义)共用一份 entry。
|
||||
public var llm: [String: CredentialsProviderLLMEntry]
|
||||
|
||||
public init(
|
||||
asr: [String: CredentialsProviderASRVolcengine] = [:],
|
||||
asr: [String: CredentialsProviderASREntry] = [:],
|
||||
llm: [String: CredentialsProviderLLMEntry] = [:]
|
||||
) {
|
||||
self.asr = asr
|
||||
@@ -47,15 +49,41 @@ public struct CredentialsProviders: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Volcengine streaming ASR 凭据。
|
||||
/// 通用 ASR provider 凭据条目。
|
||||
///
|
||||
/// 各家 ASR provider 的字段需求差异较大(火山要 appKey/accessKey/resourceId;阿里 paraformer
|
||||
/// 只要 apiKey;自定义 OpenAI 兼容 Whisper 需要 apiKey/baseURL/model),所以这里把所有可能用到的
|
||||
/// 字段都设成可选——具体 provider 只填它需要的子集,其余字段保持 nil。
|
||||
///
|
||||
/// 自定义编码:nil 字段直接省略(不写 `"appKey": null`),保持 JSON 干净。
|
||||
public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable {
|
||||
/// 这是**向后兼容**的——已经迁移到 v1 的 `providers.asr.volcengine` 仍然只包含
|
||||
/// appKey/accessKey/resourceId 三个字段,新解码不会失败。
|
||||
public struct CredentialsProviderASREntry: Codable, Sendable, Equatable, Hashable {
|
||||
/// 通用 API key(用于阿里 paraformer / 自定义 Whisper 等单 token 鉴权 provider)。
|
||||
public var apiKey: String?
|
||||
/// 自定义 OpenAI Whisper / 私有部署的 base URL。
|
||||
/// 字符串而不是 URL:编辑过程中用户输入可能是非法 URL,schema 不在这里早 fail。
|
||||
public var baseURL: String?
|
||||
/// 模型 id(自定义 Whisper 默认 `whisper-1`;预设 provider 一般固定 model 名,可省)。
|
||||
public var model: String?
|
||||
/// 火山 App Key(旧字段;保持向后兼容)。
|
||||
public var appKey: String?
|
||||
/// 火山 Access Key(旧字段;保持向后兼容)。
|
||||
public var accessKey: String?
|
||||
/// 火山 Resource ID(旧字段;保持向后兼容)。
|
||||
public var resourceId: String?
|
||||
|
||||
public init(appKey: String? = nil, accessKey: String? = nil, resourceId: String? = nil) {
|
||||
public init(
|
||||
apiKey: String? = nil,
|
||||
baseURL: String? = nil,
|
||||
model: String? = nil,
|
||||
appKey: String? = nil,
|
||||
accessKey: String? = nil,
|
||||
resourceId: String? = nil
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.baseURL = baseURL
|
||||
self.model = model
|
||||
self.appKey = appKey
|
||||
self.accessKey = accessKey
|
||||
self.resourceId = resourceId
|
||||
@@ -63,15 +91,23 @@ public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable {
|
||||
|
||||
/// 是否所有字段都是空 / nil。空 provider 在写盘时不应出现。
|
||||
public var isAllEmpty: Bool {
|
||||
isNilOrEmpty(appKey) && isNilOrEmpty(accessKey) && isNilOrEmpty(resourceId)
|
||||
isNilOrEmpty(apiKey)
|
||||
&& isNilOrEmpty(baseURL)
|
||||
&& isNilOrEmpty(model)
|
||||
&& isNilOrEmpty(appKey)
|
||||
&& isNilOrEmpty(accessKey)
|
||||
&& isNilOrEmpty(resourceId)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case appKey, accessKey, resourceId
|
||||
case apiKey, baseURL, model, appKey, accessKey, resourceId
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(apiKey, forKey: .apiKey)
|
||||
try c.encodeIfPresent(baseURL, forKey: .baseURL)
|
||||
try c.encodeIfPresent(model, forKey: .model)
|
||||
try c.encodeIfPresent(appKey, forKey: .appKey)
|
||||
try c.encodeIfPresent(accessKey, forKey: .accessKey)
|
||||
try c.encodeIfPresent(resourceId, forKey: .resourceId)
|
||||
@@ -79,12 +115,19 @@ public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable {
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey)
|
||||
baseURL = try c.decodeIfPresent(String.self, forKey: .baseURL)
|
||||
model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||
appKey = try c.decodeIfPresent(String.self, forKey: .appKey)
|
||||
accessKey = try c.decodeIfPresent(String.self, forKey: .accessKey)
|
||||
resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容别名:B-2 / C-1 时期叫 `CredentialsProviderASRVolcengine`。
|
||||
/// 通用化(C-3)后所有 ASR provider 共用同一类型。保留 typealias 让外部代码继续编译。
|
||||
public typealias CredentialsProviderASRVolcengine = CredentialsProviderASREntry
|
||||
|
||||
/// 通用 OpenAI 兼容 LLM provider 凭据条目。
|
||||
///
|
||||
/// 同一份结构覆盖 Ark / OpenAI / DashScope / DeepSeek / Moonshot 以及用户自定义条目;
|
||||
|
||||
@@ -325,11 +325,14 @@ public final class CredentialsVault: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateVolc(_ apply: (inout CredentialsProviderASRVolcengine) -> Void) {
|
||||
private func mutateVolc(_ apply: (inout CredentialsProviderASREntry) -> Void) {
|
||||
// 老路径:`volcengine.*` 账号 key 始终路由到 active ASR provider。
|
||||
// M1 active.asr 永远是 "volcengine",未来加供应商时这里也无需再改。
|
||||
// 当 active.asr 是火山时该路径仍然按预期工作;切到 apple-speech / aliyun-paraformer
|
||||
// 等无 appKey/accessKey/resourceId 字段的 provider 后,老 API 把字段写到当前 active
|
||||
// provider 的 entry 上是无意义的(也不会用到),但不会破坏 schema。要避免这种情况,
|
||||
// 应在 UI 切换 active 时通过新的 setASRProviderConfig 直接寻址 "volcengine"。
|
||||
let key = schema.active.asr
|
||||
var existing = schema.providers.asr[key] ?? CredentialsProviderASRVolcengine()
|
||||
var existing = schema.providers.asr[key] ?? CredentialsProviderASREntry()
|
||||
apply(&existing)
|
||||
if existing.isAllEmpty {
|
||||
schema.providers.asr.removeValue(forKey: key)
|
||||
@@ -537,6 +540,47 @@ extension CredentialsVault {
|
||||
}
|
||||
return ids.sorted()
|
||||
}
|
||||
|
||||
// MARK: - ASR provider 通用配置(C-3)
|
||||
|
||||
/// 读出某个 ASR provider 的完整凭据 entry。返回 nil 表示该 provider 在 schema 里没条目。
|
||||
/// 与 `llmProviderConfig` 不同:ASR 各家协议字段差异大,这里直接返回 raw entry,
|
||||
/// 让 provider 工厂自己挑需要的字段(如阿里只看 apiKey;自定义 Whisper 看 baseURL+apiKey+model)。
|
||||
public func asrProviderConfig(for providerId: String) -> CredentialsProviderASREntry? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
return schema.providers.asr[providerId]
|
||||
}
|
||||
|
||||
/// 写一份完整的 ASR provider 配置。
|
||||
/// - 全字段都为空 (`isAllEmpty`) 时直接删除对应条目,避免 JSON 里留下空对象。
|
||||
public func setASRProviderConfig(_ entry: CredentialsProviderASREntry, for providerId: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
if entry.isAllEmpty {
|
||||
schema.providers.asr.removeValue(forKey: providerId)
|
||||
} else {
|
||||
schema.providers.asr[providerId] = entry
|
||||
}
|
||||
try? writeLocked()
|
||||
}
|
||||
|
||||
/// 删除某个 ASR provider;不允许删 active provider。
|
||||
/// 与 `removeLLMProvider` 一致——需要先把 `activeASRProviderId` 切到别的 id 再删。
|
||||
public func removeASRProvider(_ providerId: String) throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
loadIfNeededLocked()
|
||||
|
||||
if schema.active.asr == providerId {
|
||||
throw CredentialsError.cannotRemoveActiveProvider(providerId)
|
||||
}
|
||||
guard schema.providers.asr[providerId] != nil else { return }
|
||||
schema.providers.asr.removeValue(forKey: providerId)
|
||||
try writeLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 时间戳工具
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import XCTest
|
||||
@testable import OpenLessASR
|
||||
import OpenLessCore
|
||||
|
||||
/// AliyunParaformerASRProvider 的纯结构断言。
|
||||
///
|
||||
/// 注:实际 WebSocket 握手 + run-task 协议交互需要真实 DashScope 端点,单元测试里不做集成验证。
|
||||
final class AliyunParaformerASRProviderTests: XCTestCase {
|
||||
|
||||
// MARK: - info 元数据
|
||||
|
||||
func test_info_reports_aliyun_paraformer_streaming_provider() {
|
||||
// Arrange
|
||||
let provider = AliyunParaformerASRProvider(apiKey: "sk-test")
|
||||
|
||||
// Act
|
||||
let info = provider.info
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(info.providerId, "aliyun-paraformer")
|
||||
XCTAssertEqual(info.mode, .streaming)
|
||||
XCTAssertFalse(info.supportsHotwords, "vocabulary_id 上传 v1 不做,supportsHotwords 应为 false")
|
||||
XCTAssertTrue(info.supportsLanguageHint)
|
||||
XCTAssertTrue(info.supportsPartialResults)
|
||||
}
|
||||
|
||||
func test_info_displayName_is_localized_chinese() {
|
||||
// Arrange
|
||||
let provider = AliyunParaformerASRProvider(apiKey: "sk-test")
|
||||
|
||||
// Act
|
||||
let displayName = provider.info.displayName
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(displayName, "阿里通义 Paraformer")
|
||||
}
|
||||
|
||||
// MARK: - 批量入口在流式 provider 上必须明确不支持
|
||||
|
||||
func test_transcribeBatch_throws_unsupportedMode() async {
|
||||
// Arrange
|
||||
let provider = AliyunParaformerASRProvider(apiKey: "sk-test")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import XCTest
|
||||
@testable import OpenLessASR
|
||||
import OpenLessCore
|
||||
|
||||
/// CustomOpenAIWhisperASRProvider + WAVHeader 的纯结构断言。
|
||||
///
|
||||
/// 没有真实 OpenAI 端点的 multipart 上传集成验证;那部分留给手工冒烟。
|
||||
final class CustomOpenAIWhisperASRProviderTests: XCTestCase {
|
||||
|
||||
// MARK: - info 元数据
|
||||
|
||||
func test_info_reports_whisper_batch_provider() {
|
||||
// Arrange
|
||||
let provider = CustomOpenAIWhisperASRProvider(
|
||||
baseURL: URL(string: "https://api.openai.com/v1")!,
|
||||
apiKey: "sk-test"
|
||||
)
|
||||
|
||||
// Act
|
||||
let info = provider.info
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(info.providerId, "custom-openai-whisper")
|
||||
XCTAssertEqual(info.mode, .batch)
|
||||
XCTAssertFalse(info.supportsHotwords, "Whisper 仅支持 prompt 软提示,不算真热词")
|
||||
XCTAssertTrue(info.supportsLanguageHint)
|
||||
XCTAssertFalse(info.supportsPartialResults)
|
||||
}
|
||||
|
||||
func test_info_displayName_is_localized_chinese() {
|
||||
// Arrange
|
||||
let provider = CustomOpenAIWhisperASRProvider(
|
||||
baseURL: URL(string: "https://api.openai.com/v1")!,
|
||||
apiKey: "sk-test"
|
||||
)
|
||||
|
||||
// Act
|
||||
let displayName = provider.info.displayName
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(displayName, "自定义 OpenAI 兼容 (Whisper)")
|
||||
}
|
||||
|
||||
// MARK: - 流式入口在批量 provider 上必须明确不支持
|
||||
|
||||
func test_openStreamingSession_throws_unsupportedMode() async {
|
||||
// Arrange
|
||||
let provider = CustomOpenAIWhisperASRProvider(
|
||||
baseURL: URL(string: "https://api.openai.com/v1")!,
|
||||
apiKey: "sk-test"
|
||||
)
|
||||
|
||||
// Act / Assert
|
||||
do {
|
||||
_ = try await provider.openStreamingSession(language: "zh-CN", hotwords: [])
|
||||
XCTFail("openStreamingSession 应抛 ASRError.unsupportedMode")
|
||||
} catch let error as ASRError {
|
||||
XCTAssertEqual(error, .unsupportedMode)
|
||||
} catch {
|
||||
XCTFail("应抛 ASRError,实际抛: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WAV header 构造
|
||||
|
||||
func test_wavHeader_wraps_pcm_with_riff_format() {
|
||||
// Arrange:1 秒 16k mono Int16 PCM = 32000 字节静音。
|
||||
let pcm = Data(repeating: 0, count: 32_000)
|
||||
|
||||
// Act
|
||||
let wav = WAVHeader.wrap(pcm: pcm, sampleRate: 16_000, channels: 1)
|
||||
|
||||
// Assert
|
||||
// 总长 = 44 (header) + 32000 (data)
|
||||
XCTAssertEqual(wav.count, 44 + 32_000)
|
||||
|
||||
// RIFF / WAVE / fmt / data 标识
|
||||
XCTAssertEqual(Array(wav[0..<4]), Array("RIFF".utf8))
|
||||
XCTAssertEqual(Array(wav[8..<12]), Array("WAVE".utf8))
|
||||
XCTAssertEqual(Array(wav[12..<16]), Array("fmt ".utf8))
|
||||
XCTAssertEqual(Array(wav[36..<40]), Array("data".utf8))
|
||||
|
||||
// chunkSize(offset 4-7)= 36 + dataSize
|
||||
let chunkSize = readUInt32LE(wav, at: 4)
|
||||
XCTAssertEqual(chunkSize, UInt32(36 + 32_000))
|
||||
|
||||
// fmt chunk size(offset 16-19)= 16
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 16), 16)
|
||||
|
||||
// audio format(offset 20-21)= 1 (PCM)
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 20), 1)
|
||||
|
||||
// num channels(offset 22-23)= 1
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 22), 1)
|
||||
|
||||
// sample rate(offset 24-27)= 16000
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 24), 16_000)
|
||||
|
||||
// byte rate(offset 28-31)= sampleRate * channels * bitsPerSample / 8 = 32000
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 28), 32_000)
|
||||
|
||||
// block align(offset 32-33)= channels * bitsPerSample / 8 = 2
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 32), 2)
|
||||
|
||||
// bits per sample(offset 34-35)= 16
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 34), 16)
|
||||
|
||||
// data size(offset 40-43)= pcm.count
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 40), 32_000)
|
||||
}
|
||||
|
||||
func test_wavHeader_records_stereo_44k_correctly() {
|
||||
// Arrange:44.1 kHz stereo 测一下非默认参数也写对了。
|
||||
let pcm = Data(repeating: 0, count: 4_000)
|
||||
|
||||
// Act
|
||||
let wav = WAVHeader.wrap(pcm: pcm, sampleRate: 44_100, channels: 2)
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 22), 2) // channels
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 24), 44_100) // sample rate
|
||||
XCTAssertEqual(readUInt32LE(wav, at: 28), 44_100 * 2 * 2) // byte rate = sr * ch * bytesPerSample
|
||||
XCTAssertEqual(readUInt16LE(wav, at: 32), 4) // block align = ch * bytesPerSample
|
||||
}
|
||||
|
||||
// MARK: - 辅助
|
||||
|
||||
private func readUInt32LE(_ data: Data, at offset: Int) -> UInt32 {
|
||||
let b0 = UInt32(data[offset])
|
||||
let b1 = UInt32(data[offset + 1])
|
||||
let b2 = UInt32(data[offset + 2])
|
||||
let b3 = UInt32(data[offset + 3])
|
||||
return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
|
||||
}
|
||||
|
||||
private func readUInt16LE(_ data: Data, at offset: Int) -> UInt16 {
|
||||
let b0 = UInt16(data[offset])
|
||||
let b1 = UInt16(data[offset + 1])
|
||||
return b0 | (b1 << 8)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user