diff --git a/Sources/OpenLessASR/AliyunParaformerASRProvider.swift b/Sources/OpenLessASR/AliyunParaformerASRProvider.swift new file mode 100644 index 00000000..1e781300 --- /dev/null +++ b/Sources/OpenLessASR/AliyunParaformerASRProvider.swift @@ -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 + private let partialContinuation: AsyncStream.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? + /// 终态: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.Continuation! + self.partialResults = AsyncStream { 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) 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 = ["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 + ) -> 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? + 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? + 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? + 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)") + } +} diff --git a/Sources/OpenLessASR/CustomOpenAIWhisperASRProvider.swift b/Sources/OpenLessASR/CustomOpenAIWhisperASRProvider.swift new file mode 100644 index 00000000..e8dc22d3 --- /dev/null +++ b/Sources/OpenLessASR/CustomOpenAIWhisperASRProvider.swift @@ -0,0 +1,284 @@ +import Foundation +import OpenLessCore + +// CustomOpenAIWhisperASRProvider —— OpenAI 兼容批量 ASR provider。 +// +// 协议要点(详见 docs/plans/provider-abstraction.md §4.5): +// 1. 端点:`POST /audio/transcriptions`,multipart/form-data。 +// 2. 鉴权:`Authorization: Bearer `。 +// 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) } + } +} diff --git a/Sources/OpenLessApp/DictationCoordinator.swift b/Sources/OpenLessApp/DictationCoordinator.swift index 37d8ffc6..0304954e 100644 --- a/Sources/OpenLessApp/DictationCoordinator.swift +++ b/Sources/OpenLessApp/DictationCoordinator.swift @@ -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 } +} diff --git a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift index c3503858..23a09c54 100644 --- a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift +++ b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift @@ -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 以及用户自定义条目; diff --git a/Sources/OpenLessPersistence/CredentialsVault.swift b/Sources/OpenLessPersistence/CredentialsVault.swift index 878a1bf4..278ec993 100644 --- a/Sources/OpenLessPersistence/CredentialsVault.swift +++ b/Sources/OpenLessPersistence/CredentialsVault.swift @@ -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: - 时间戳工具 diff --git a/Tests/OpenLessASRTests/AliyunParaformerASRProviderTests.swift b/Tests/OpenLessASRTests/AliyunParaformerASRProviderTests.swift new file mode 100644 index 00000000..7cd75467 --- /dev/null +++ b/Tests/OpenLessASRTests/AliyunParaformerASRProviderTests.swift @@ -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)") + } + } +} diff --git a/Tests/OpenLessASRTests/CustomOpenAIWhisperASRProviderTests.swift b/Tests/OpenLessASRTests/CustomOpenAIWhisperASRProviderTests.swift new file mode 100644 index 00000000..a20129f3 --- /dev/null +++ b/Tests/OpenLessASRTests/CustomOpenAIWhisperASRProviderTests.swift @@ -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) + } +}