# iOS SDK 优谷雅言语音评测服务的 iOS 与 macOS SDK,提供整段评测,实时评测,语音合成,报告查询,自动重试,幂等键,断线重连,错误分类,音频预检与录音。 来源:https://open.shengzhiai.com/sdk/v2/sdks/ios/ | 项 | 内容 | |---|---| | 版本 | `2.1.0`,2026-10-12 | | 仓库 | `https://open.shengzhiai.com/git/yugu-ios-sdk.git`,匿名只读 | | 产品 | `YuguCore`,`YuguSDK` | | 平台 | iOS 13 起,macOS 11 起,`YuguCore` 另支持 Linux | | 工具链 | Swift 5.9 起,Xcode 15 起 | | 第三方依赖 | 无 | | 许可 | Apache-2.0 | ## 产品 | 产品 | 内容 | 平台 | |---|---|---| | `YuguCore` | 整段评测,实时评测,语音合成,报告查询,重试,幂等,签名,错误分类,音频预检,只依赖 Foundation | iOS,macOS,Linux | | `YuguSDK` | `YuguCore` 的全部能力加录音器 `YuguRecorder`,写 `import YuguSDK` 即可使用全部类型 | iOS,macOS | App 一般选 `YuguSDK`。服务端或命令行工具只做评测时选 `YuguCore`。 ## 安装 ### Xcode 添加依赖 1. 在 Xcode 菜单 File 中选择 Add Package Dependencies。 2. 在搜索框输入 `https://open.shengzhiai.com/git/yugu-ios-sdk.git`。 3. Dependency Rule 选择 Up to Next Major Version,版本填 `2.1.0`。 4. 产品勾选 `YuguSDK`,点击 Add Package。 ### Package.swift 声明 ```swift // swift-tools-version:5.9 import PackageDescription let package = Package( name: "MyApp", platforms: [.iOS(.v13)], dependencies: [ .package(url: "https://open.shengzhiai.com/git/yugu-ios-sdk.git", from: "2.1.0"), ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "YuguSDK", package: "yugu-ios-sdk"), ]), ] ) ``` 包标识取仓库地址的最后一段,所以 `package:` 参数写 `yugu-ios-sdk`。 ### 版本固定与回退 每个版本对应仓库里的一个 Git 标签,已发布的标签不会改动。固定在某个版本写 `.package(url: "https://open.shengzhiai.com/git/yugu-ios-sdk.git", exact: "2.1.0")`,回退时把版本号改成更早的标签,然后在 Xcode 中执行 File 菜单的 Packages 下的 Reset Package Caches。 ### CocoaPods 方式 ```ruby pod 'YuguSDK', :git => 'https://open.shengzhiai.com/git/yugu-ios-sdk.git', :tag => '2.1.0' ``` CocoaPods 把两个产品编进同一个模块 `YuguSDK`,代码里写 `import YuguSDK`。 ### 仓库访问 仓库地址为 HTTPS,匿名只读,不需要账号,令牌与证书配置。沙箱环境与测试密钥的申请方式见 SDK 总仓库的 `SANDBOX.md`。 ## 麦克风权限 使用录音器的 App 必须在 Info.plist 中声明麦克风用途,缺少这一项时 iOS 在首次录音时终止 App。 ```xml NSMicrophoneUsageDescription 录制朗读音频,用于语音评测 ``` macOS App 开启了沙盒时,另需在 entitlements 中打开 `com.apple.security.device.audio-input`。只评测已有音频时不需要麦克风权限。 ## 五分钟上手 ```swift import YuguSDK // 创建客户端,App 内复用同一个实例 let client = YuguClient(options: YuguClientOptions( auth: .appKey("", secretKey: ""))) // 评测一段 16 kHz 单声道 WAV let config = EvaluateConfig(coreType: .sentence, referenceText: "今天天气很好", language: "zh-CN") let result = try await client.evaluate(audio: .file(wavURL), config: config) // 读取分数 print("总分", result.overall ?? 0) print("流利度", result.dimensions.fluency ?? 0, "完整度", result.dimensions.integrity ?? 0) for word in result.words { print(word.word ?? "", word.overall ?? 0) } // 不再使用时释放 client.close() ``` 不使用 `async` 时用完成回调写法,回调在 `callbackQueue` 上执行,缺省为主队列: ```swift client.evaluate(audio: .file(wavURL), config: config) { outcome in switch outcome { case .success(let result): print(result.overall ?? 0) case .failure(let error): print(error.category, error.code, error.message) } } ``` 用麦克风录音再评测: ```swift let recorder = YuguRecorder() YuguRecorder.requestPermission { granted in guard granted else { return } try? recorder.start() } // 用户读完后 if let recording = recorder.stop() { client.evaluate(audio: recording.audioInput, config: config) { outcome in // 处理结果 } } recorder.release() ``` ## 客户端选项 `YuguClientOptions` 的缺省值与其他各端 SDK 相同。 | 选项 | 缺省值 | 含义 | |---|---|---| | `baseUrl` | `https://open.shengzhiai.com` | REST 基址 | | `wsBaseUrl` | 由 `baseUrl` 推出,缺省为 `wss://open.shengzhiai.com` | WebSocket 基址 | | `auth` | 必填 | `.appKey(appKey, secretKey:)` 或 `.token(jwt)` | | `connectTimeoutMs` | 10000 | REST 连接超时,也是 WebSocket 握手超时 | | `readTimeoutMs` | 120000 | 单次 REST 尝试的超时 | | `totalTimeoutMs` | 300000 | 一次逻辑调用的总时限,含重试与等待 | | `retry` | `RetryPolicy.default` | 重试策略 | | `autoIdempotencyKey` | `true` | 写操作自动生成幂等键 | | `logLevel` | `.warn` | 日志级别 | | `logger` | `ConsoleLogger` | 日志输出 | | `eventListener` | 无 | 指标回调 | | `audioPrecheck` | `.warn` | 音频预检方式 | | `strictAudio` | `false` | 平台报 1001 时改为返回音频错误 | | `userAgent` | `yugu-ios-sdk/2.1.0` | 请求头 `User-Agent` | | `reconnect` | `ReconnectPolicy.default` | 实时评测重连策略 | | `audioBufferPolicy` | `.replay` | 重连时的音频缓冲策略 | | `pingIntervalMs` | 15000 | 协议层心跳间隔 | | `pongTimeoutMs` | 30000 | 心跳应答超时 | | `resultTimeoutMs` | 300000 | 结束后等待终评的时限 | | `callbackQueue` | 主队列 | 完成回调与会话回调所在队列 | | `urlSessionConfiguration` | 无 | 自定义 URLSession 配置,例如代理 | | `httpTransport` | 无 | 替换 HTTP 层,例如证书锁定 | | `webSocketTransportFactory` | 无 | 替换 WebSocket 层 | 单次调用用 `RequestOptions` 覆盖:`idempotencyKey`,`timeoutMs`,`readTimeoutMs`,`retry`,`cancellationToken`,`audioPrecheck`。实时会话用 `StreamOptions` 覆盖:`idempotencyKey`,`reconnect`,`audioBufferPolicy`,`connectTimeoutMs`,`resultTimeoutMs`,`pingIntervalMs`,`pongTimeoutMs`,`audioPrecheck`。 ## 接口一览 | 方法 | 作用 | |---|---| | `evaluate(audio:config:image:options:)` | 原生整段评测 `POST /api/v1/evaluate` | | `evaluateCompat(coreType:audio:params:options:)` | 声通兼容整段评测 `POST /{coreType}` | | `tts(_:options:)` | 语音合成 `POST /api/v1/tts/generate` | | `getReport(recordId:options:)` | 报告查询 `GET /api/v1/report/{recordId}` | | `streamEvaluate(config:options:listener:)` | 原生实时评测会话 | | `streamEvaluateCompat(coreType:params:options:listener:)` | 声通兼容实时评测会话 | | `close()` | 释放客户端,可重复调用 | REST 方法都有两种写法,`async throws` 写法跟随任务取消,完成回调写法返回 `YuguCall`,调用 `cancel()` 取消。客户端的全部方法线程安全。 评测参数在 `EvaluateConfig` 中设置,常用字段为 `coreType`,`referenceText`,`language`,`slack`,`scale`,`precision`,`agegroup`,`includeReport`,`includeAsrText`,`refPinyin`,`taskType`,`paragraphNeedWordScore`,平台新增的字段放进 `extra`。`config` 分片的 JSON 文本由 SDK 按固定顺序生成,整数值的小数写成整数,发出的文本与签名的文本一字不差。`realtimeFeedback` 只用于原生实时评测,打开朗读进度,见实时评测的朗读进度一节,整段评测不发送这个字段。 ## 结果字段 整段评测与实时评测都返回 `EvalResult`。 | 属性 | 来源 | 内容 | |---|---|---| | `overall` | `result.overall`,连读题取 `result.connected_overall` | 总分 | | `dimensions` | `result` 的各维度 | 完整度,准确度,发音,流利度,声调,韵律,朗读技巧,情感,语速 | | `words` | `result.words` | 逐字逐词得分,拼音,读音状态,音素,时间 | | `sentences` | `result.sentences` | 分句得分,段落题带 `details` 逐词详情 | | `yuguScores` | `result.yuguScores` | 五维结构化分数 | | `connected` | 连读题 | 连读,失爆,弱读,节奏,各连接处的实现程度 | | `openTask` | 开放题 | 内容,语言运用,表达,识别文本,点评,审计标记 | | `report` | `report` | 报告摘要,建议,维度分 | | `asrText` | `asrText` | 识别文本与逐字对齐 | | `warnings` | `warnings` 与 `result.warning` | 平台的音频质量警告 | | `localWarnings` | 本地预检 | 本地预检警告 | | `idempotencyKey`,`replayed`,`attempts` | 调用信息 | 幂等键,是否为平台重放的结果,尝试次数 | | `raw`,`resultJSON` | 原始 JSON | 未建模的字段从这里读取 | 各评测模式的取分字段逐项见 SDK 总仓库的 `RESULTS.md`。`raw` 是 `JSONValue`,可用下标读取,也可用 `decode(_:)` 解码为自定义类型。 ## 错误处理 所有失败都以 `YuguError` 返回。 | 属性 | 含义 | |---|---| | `category` | 错误类别,共 16 类 | | `code` | 平台错误码,警告码或 SDK 本地错误码,未知时为 0 | | `name` | 错误码在码表中的名称 | | `httpStatus` | HTTP 状态,没有时为 0 | | `message` | 说明 | | `retryable` | 用同一个幂等键再次调用能否成功 | | `idempotencyKey` | 本次调用的幂等键 | | `recordId` | 平台已生成的记录号 | | `attempts` | 已尝试次数 | | `rawBody` | 响应原文,最多 4 KB | | `cause` | 底层错误,例如 `URLError` | 类别与便捷属性一一对应,按类别分支即可: | 类别 | 便捷属性 | 典型处置 | |---|---|---| | `network`,`timeout` | `isNetworkError`,`isTimeout` | 提示网络状况,可重试 | | `auth`,`permission` | `isAuthError`,`isPermissionDenied` | 检查密钥与授权 | | `invalidParam`,`notFound` | `isInvalidParameter`,`isNotFound` | 修正参数 | | `conflict` | `isConflict` | 幂等键冲突,见重试与幂等一节 | | `rateLimit`,`quota` | `isRateLimited`,`isQuotaExceeded` | 稍后重试或充值 | | `server`,`upstream` | `isServerError` | 可重试,持续失败时联系平台 | | `audio` | `isAudioQuality` | 提示用户重录 | | `state` | `isIllegalState` | 检查调用顺序 | | `cancelled` | `isCancelled` | 调用方已取消 | | `protocol` | `isProtocolViolation` | 响应无法解析,联系平台 | ```swift do { let result = try await client.evaluate(audio: .file(wavURL), config: config) show(result) } catch let error as YuguError { switch error.category { case .audio: askToRecordAgain() case .auth, .permission: checkCredentials() default: if error.retryable { retryLater(idempotencyKey: error.idempotencyKey) } } } ``` 本地错误码: | 错误码 | 含义 | |---|---| | 90001 | 网络错误,可重试 | | 90002 | 连接或读取超时,可重试 | | 90003 | 调用方取消 | | 90004 | 客户端已关闭 | | 90005 | 响应或帧无法解析 | | 90006 | 实时评测重连次数用尽 | | 90007 | 结束后等待终评超时,可重试 | | 90008 | 重连重放缓冲超过 10 MB | | 90009 | 当前会话状态不允许该操作 | | 90010 | 调用参数不合法,在任何网络请求之前报出 | | 90011 | 证书校验失败 | | 90101 到 90105 | 音频预检,见音频预检一节 | | 90201 到 90203 | 录音器,依次为没有权限,麦克风不可用,录音出错 | 平台错误码的完整码表内置在 `ErrorTable` 中,说明见 SDK 总仓库的 `ERRORS.md`。1004 与 1005 在错误响应里是账户状态错误,在评测结果的 `warning` 里是音频警告,`YuguErrors.fromCode` 按错误响应查表,`YuguErrors.fromWarning` 按警告查表。`YuguErrors.isRetryable(_:)` 与 SDK 自身的重试判断使用同一套规则。 ## 重试与幂等 ### 幂等键 `evaluate`,`evaluateCompat`,`tts` 与两种实时会话都带幂等键。调用方没有指定时,SDK 为每次逻辑调用生成一个 32 位小写十六进制的键,重试与重连沿用同一个键。指定的键须为 1 到 200 个可打印 ASCII 字符,不含空格,不合法时在发出请求之前报 90010。 平台对幂等键的处理: | 情形 | 平台行为 | |---|---| | 同一个键,同一个请求,首次已成功 | 直接返回首次结果,响应头带 `Idempotency-Replayed: true`,`result.replayed` 为 `true`,不再计费 | | 首次请求仍在处理 | 等待最多 30 秒后返回首次结果,仍未完成时返回 409 与错误码 40901,可重试 | | 同一个键,不同的请求 | 返回 409 与错误码 40903,不可重试 | | 首次请求失败 | 释放这个键,再次提交按新请求处理 | 键的作用域为 appKey,使用 token 时为用户,有效期 24 小时。业务上需要“同一题只计一次费”时,自行指定键,例如用题目号与用户号组成的字符串,失败后用 `error.idempotencyKey` 再次提交。 ```swift let options = RequestOptions(idempotencyKey: "exam-42-user-7-q3") let result = try await client.evaluate(audio: .file(wavURL), config: config, options: options) ``` ### 重试策略 缺省最多重试 2 次,共 3 次尝试。第 n 次重试前等待 `min(maxDelayMs, initialDelayMs 乘 multiplier 的 n-1 次方)`,再乘以 0.7 到 1.3 之间的随机系数,缺省约为 200 毫秒与 400 毫秒。响应带 `Retry-After` 时等待时间不少于该值,该值按 30 秒封顶。等待后会超过 `totalTimeoutMs` 时不再重试,直接返回最后一次的错误。 重试的错误:网络错误,超时,HTTP 408,425,429,500,502,503,504,以及码表标为可重试的错误码,例如 40901,42900,42901,50000,50200。参数错误,鉴权失败,额度不足等不重试。没有幂等键的写操作不重试,`autoIdempotencyKey` 为 `false`,调用时也未指定键,就属于这种情况。报告查询是 GET 请求,不带键也会重试。 每次重试触发 `eventListener` 的 `onRetry`,同时写一条 WARN 日志,形如 `retry 1/2 in 231 ms: HTTP 503 code=50200`。 ```swift var options = YuguClientOptions(auth: .appKey("", secretKey: "")) options.retry = RetryPolicy(maxRetries: 3, initialDelayMs: 300) options.totalTimeoutMs = 60_000 ``` ## 实时评测 ### 会话流程 ```swift let handlers = YuguStreamHandlers() handlers.onStateChanged = { from, to in print(from, "->", to) } handlers.onReconnecting = { attempt, delayMs, cause in showBanner("网络中断,正在重连") } handlers.onReconnected = { attempt, droppedBytes in hideBanner() } handlers.onResult = { result in show(result) } handlers.onError = { error in showError(error) } handlers.onClosed = { code, reason in releaseUI() } let session = try client.streamEvaluate(config: config, listener: handlers) try session.start() // 每帧 640 字节,16 kHz 单声道 16 位小端 PCM try session.sendAudio(frame) // 读完 try session.end() ``` `start()` 之后即可送音频,服务端就绪之前送来的音频由 SDK 暂存后按顺序发出。单次送入超过 32000 字节时,SDK 拆成不超过 32000 字节的帧发送,平台会以 1009 关闭超过 128 KB 的帧。`session.state` 随时可查。`cancel()` 与 `close()` 可重复调用。 ### 状态 ``` IDLE -> CONNECTING -> CONNECTED -> STARTED -> ENDING -> COMPLETED -> CLOSED CONNECTING | CONNECTED | STARTED | ENDING -> RECONNECTING -> CONNECTING 任一非终止状态 -> FAILED -> CLOSED 任一非终止状态 -> CANCELLED -> CLOSED ``` | 回调 | 时机 | |---|---| | `onStateChanged` | 每次状态变化 | | `onConnected` | 首次连接建立 | | `onStarted` | 服务端首次接受参数,此后送入的音频直接发出 | | `onPartial` | 进度帧,原生会话与声通兼容会话打开 `realtimeFeedback` 后收到,`end()` 之后到终评之前也会收到,见朗读进度一节 | | `onReconnecting` | 连接中断,第几次重连,等待多久,原因 | | `onReconnected` | 重连成功,DROP 策略下附带丢弃的字节数 | | `onWarning` | `end()` 时的本地预检警告 | | `onResult` | 终评结果 | | `onError` | 失败 | | `onClosed` | 会话结束,总是最后一个回调 | 回调的约定:每个会话只会收到 `onResult` 与 `onError` 中的一个,调用过 `cancel()` 的会话两者都不会收到,`onClosed` 总是最后一个回调,同一会话的回调不会同时执行,`session.state` 与最近一次 `onStateChanged` 报告的状态一致。 ### 断线重连 触发重连的情况:连接失败,连接被异常关闭,心跳超时,握手超时,终评超时,以及带可重试错误码的错误帧,例如 40901,42901,50200。不可重试的错误帧,鉴权失败的握手,证书错误直接进入 FAILED。服务端以 1000 正常关闭却没有给出结果时,会话以 90005 失败。服务端以 1002,1003,1007,1008,1009,1010 或 4000 到 4999 之间的代码关闭连接时,新连接也会被同样拒绝,会话直接以 90005 失败,不重连。 `ReconnectPolicy` 缺省最多连续重连 8 次,间隔约为 0.5 秒,1 秒,2 秒,4 秒,之后每次 4 秒,合计约 23 秒,各有正负 30% 的随机抖动,足以撑过 10 秒的断网。重连成功后次数从零重新计算,连续失败次数用尽时会话以 90006 失败,`cause` 为最后一次的原因。整个会话的重连总数不超过 `maxAttempts` 的 3 倍,缺省为 24 次,服务端反复接受连接又断开时,到达上限后同样以 90006 失败。 平台没有服务端续传,重连等于用同样的参数与同一个幂等键开一个新的服务端会话。音频缓冲策略 `AudioBufferPolicy` 决定音频怎么处理: | 策略 | 行为 | |---|---| | `.replay`,缺省 | 保留本次会话已送的全部音频,上限 10 MB。重连后重发开始帧,按原顺序重放全部音频,接着发出重连期间送入的音频,`end()` 已调用时再发结束帧。同一个幂等键使平台只评测一次,只计费一次。音频超过 10 MB 后,下一次需要重连时会话以 90008 失败 | | `.drop` | 不保留音频,重连期间送入的音频丢弃,`onReconnected` 给出丢弃的字节数,新会话只评测重连之后送入的音频。`end()` 已发出后断线时,会话直接失败 | | `.fail` | 不重连,连接中断即失败 | 关闭自动幂等键,也未指定键时,会话不重连。 ### 心跳与超时 会话进入 STARTED 后每 15 秒发一次协议层 ping,30 秒收不到 pong 视为连接中断。`connectTimeoutMs` 限定从发起连接到服务端接受参数的时间,`resultTimeoutMs` 限定 `end()` 之后等待终评的时间。任何状态都有时限,会话不会无回调地停住。 ### 朗读进度 原生会话在 `EvaluateConfig` 中设 `realtimeFeedback: true`,声通兼容会话在 `CompatParams` 中设 `realtimeFeedback: true`,平台在收音过程中下发进度帧,按参考文本逐字或逐词给出已经读到的位置,`onPartial` 回调 `StreamPartial`。按进度给参考文本着色的写法: ```swift let refText = "我是中国人,我是中国人。我们都是中国人。中华民族是一家人。" let config = EvaluateConfig(coreType: .sentence, referenceText: refText, language: "zh-CN", realtimeFeedback: true) let handlers = YuguStreamHandlers() handlers.onPartial = { partial in guard partial.hasProgress else { return } label.attributedText = colored(refText, partial.progress) } handlers.onResult = { result in show(result) } handlers.onError = { error in showError(error) } let session = try client.streamEvaluate(config: config, listener: handlers) // 已对上的字为绿色,读过但没对上的为橙色,还没读到的与标点为灰色 func colored(_ refText: String, _ progress: [ReadingWord]) -> NSAttributedString { let text = NSMutableAttributedString(string: refText, attributes: [.foregroundColor: UIColor.systemGray]) let ns = refText as NSString var from = 0 for w in progress { let range = ns.range(of: w.word, options: [], range: NSRange(location: from, length: ns.length - from)) if range.location == NSNotFound { break } let color: UIColor = w.matched ? .systemGreen : (w.read ? .systemOrange : .systemGray) text.addAttribute(.foregroundColor, value: color, range: range) from = range.location + range.length } return text } ``` | 属性 | 内容 | |---|---| | `bytes` | 服务端在当前连接上已收到的音频字节数,重连后从零重新累计 | | `progress` | 参考文本的全部单位,按原文顺序排列,取自进度帧的 `result.progress`,元素为 `ReadingWord`,没有进度时为空数组。中文按字,英文按空白分词,标点与空白不算单位 | | `readUnits` | `read` 为 `true` 的单位数,没有进度时为 -1 | | `matchedUnits` | `matched` 为 `true` 的单位数,没有进度时为 -1 | | `totalUnits` | 单位总数,等于 `progress` 的长度,没有进度时为 -1 | | `hasProgress` | 进度帧带 `result.progress` 时为 `true` | | `raw` | 进度帧的原始 JSON | `ReadingWord` 有三个属性:`word` 是取自参考文本的字或词,`read` 表示朗读位置已越过这个单位,`matched` 表示识别结果与这个单位一致。一轮之内 `read` 与 `matched` 只会由 `false` 变为 `true`,`matched` 为 `true` 时 `read` 也为 `true`。`read` 为 `true` 而 `matched` 为 `false`,表示朗读已越过这个位置但识别结果与参考文本不一致,可能是读错,漏读或识别误差,以终评为准。 进度变化时平台立即下发一帧,此外每收约 0.5 秒音频下发一帧,进度一般比朗读晚 1 到 2 秒,随网络情况变化。`end()` 之后会话处于 ENDING,平台对最后一段音频的识别结果仍以进度帧送达,`onResult` 之后不再回调 `onPartial`。进度帧不含发音评分,打开朗读进度不改变终评,也不额外计费。 进度只对 16 kHz 16 位单声道 PCM 生效,一轮开头带 WAV 头的同样生效。参考文本为空,音频为其他格式,或平台当时无法跟踪进度时,进度帧只有 `bytes`,`hasProgress` 为 `false`,终评不受影响。 声通兼容会话的进度帧另带声通格式的 `result.words`,只列出上一帧之后新读到的单位,覆盖第 `readUnits - words.count` 到第 `readUnits - 1` 个单位,顶层 `recordId` 为空字符串,记录号只在终评里给出。`words` 里的分数只表示识别结果与参考文本是否一致,发音与声调的实际得分以终评为准。逐字状态以 `progress` 为准,SDK 不读取 `words`,需要声通格式时从 `raw` 读取。重连后平台从第一个单位重新下发 `words`,拼接时按单位位置去重。原生会话的进度帧没有 `words`。 断线重连后,服务端在新的会话上从零重新计算进度。缓冲策略为 `.replay` 时,重放音频期间进度逐步追上,会话记住最近一次回调的 `readUnits` 与 `matchedUnits`,只回调两者都不小于上一次的进度帧,界面上的进度不会倒退,没有回调的进度帧各写一条 DEBUG 日志。缓冲策略为 `.drop` 时,新的服务端会话只评测重连之后送入的音频,重连成功时会话清除记住的进度,此后的进度按新的服务端会话重新开始,可能小于重连之前。不带进度的帧照常回调。每个会话单独记录,新的会话从零开始。 `realtimeFeedback` 缺省为 `false`,此时开始帧与 `2.0.0` 一字不差。声通兼容会话的 `CompatParams.realtimeFeedback` 发送的仍是 `realtime_feedback`。 ## 生命周期 客户端,会话,录音器都有显式释放接口,重复调用不会报错。 ```swift final class ReadingViewController: UIViewController { private let client = YuguClient(options: YuguClientOptions( auth: .appKey("", secretKey: ""))) private let recorder = YuguRecorder() private let recorderHandlers = YuguRecorderHandlers() private var session: YuguStreamSession? func startReading(text: String) throws { let handlers = YuguStreamHandlers() handlers.onResult = { [weak self] result in self?.show(result) } handlers.onError = { [weak self] error in self?.show(error) } let session = try client.streamEvaluate( config: EvaluateConfig(coreType: .sentence, referenceText: text, language: "zh-CN"), listener: handlers) self.session = session recorderHandlers.onFrame = { frame in try? session.sendAudio(frame) } recorder.setListener(recorderHandlers) try session.start() try recorder.start() } func finishReading() { recorder.stop() try? session?.end() } deinit { session?.cancel() recorder.release() client.close() } } ``` | 对象 | 释放接口 | 行为 | |---|---|---| | `YuguClient` | `close()` | 取消进行中的 REST 调用,这些调用以 90004 结束,取消全部会话,释放网络资源。关闭后的调用返回 90004 | | `YuguStreamSession` | `cancel()`,`close()` | 关闭连接,不再回调结果,最后回调 `onClosed`,随后释放监听器 | | `YuguRecorder` | `stop()`,`release()` | 停止采集,销毁音频引擎,释放麦克风,`release()` 之后丢弃监听器 | 录音器的状态为 IDLE,RECORDING,PAUSED,STOPPED,RELEASED,用 `recorder.state` 查询,`pause()` 与 `resume()` 用于暂停与继续。音频引擎在 `start()` 时创建,在 `stop()`,`release()` 与任何错误路径上销毁,麦克风不会在录音结束后继续占用。来电等音频中断开始时录音器转为 PAUSED,中断结束时回调 `interruptionEndedShouldResume`,由 App 决定是否调用 `resume()`。耳机插拔等路由变化时录音器重新连接输入继续录音,无法继续时以 90203 结束。 App 被系统终止时,进程内的连接与音频引擎随进程一起释放。平台只在收到结束帧后评测,没有发出结束帧的实时会话不评测,不计费。结束帧或整段评测请求已经到达平台时,平台照常评测与计费,用同一个幂等键与同样的音频再次提交即可取回结果,不再计费。 ## 音频预检 整段评测上传前与实时评测 `end()` 时,SDK 在本地检查 WAV 与 PCM 音频。 | 错误码 | 条件 | WARN 模式 | REJECT 模式 | |---|---|---|---| | 90101 | 时长不足 1 秒 | 警告 | 上传前失败 | | 90102 | 时长超过 300 秒,整段上传大于 50 MB,或一次实时会话的音频大于 10 MB | 警告 | 上传前失败 | | 90103 | 全程静音,峰值低于 200,均方根低于 30 | 警告 | 上传前失败 | | 90104 | 音量过低,均方根低于 -45 dBFS | 警告 | 警告 | | 90105 | 不是 16 位 PCM,或采样率低于 16000 | 警告 | 上传前失败 | 缺省为 WARN,警告放在结果的 `localWarnings` 中,实时会话另回调 `onWarning`。静音阈值与平台的静音判定一致,本地拒绝的音频上传后也只会得 0 分。REJECT 模式下实时会话不发结束帧,平台不评测,不计费。MP3 等其他格式只检查大小,整段上传的上限为平台限制的 50 MB。`AudioPrecheck.check(_:)` 可单独调用。 ## 外部音频 评测不依赖内置录音器,音频可以来自任何来源。 | 写法 | 用途 | |---|---| | `.data(bytes)` | 内存中的 WAV,MP3 等编码音频,类型由文件头识别 | | `.file(url)` | 本地音频文件 | | `.pcm16(pcm, sampleRate:, channels:)` | 16 位小端 PCM,SDK 加上 WAV 头后上传 | 实时会话用 `sendAudio(_:)` 送入外部采集的 16 kHz 单声道 16 位 PCM,每次 640 字节,即 20 毫秒,最为合适。`YuguWAV.wrap` 与 `YuguWAV.parse` 用于 WAV 封装与解析。 ## 日志与指标 日志级别为 `.off`,`.error`,`.warn`,`.info`,`.debug`,缺省为 `.warn`。日志不含 secretKey,签名,token 与音频数据,appKey 只保留前 4 个字符。自定义输出实现 `YuguLogger`: ```swift final class AppLogger: YuguLogger { func log(level: YuguLogLevel, tag: String, message: String, error: Error?) { os_log("%{public}@ %{public}@", tag, message) } } options.logger = AppLogger() options.logLevel = .info ``` `YuguEventListener` 的方法都是可选的: | 方法 | 时机 | |---|---| | `onRequestStart(op:method:path:attempt:)` | 每次 REST 尝试开始 | | `onRequestEnd(op:httpStatus:latencyMs:attempts:error:)` | 一次逻辑调用结束,耗时含全部重试 | | `onRetry(op:attempt:delayMs:error:)` | 安排一次重试 | | `onSessionStateChanged(sessionId:from:to:)` | 实时会话状态变化 | | `onReconnect(sessionId:attempt:succeeded:)` | 一次重连结束 | 指标回调在 SDK 内部队列上执行,需要尽快返回。 ## 测试 ```bash swift test swift test --enable-code-coverage ``` 测试覆盖签名的全部跨端向量,错误码表的每一项,各评测模式的真实返回,线上录制的朗读进度帧,multipart 字节布局,退避计算,幂等键复用,实时会话状态机与三种缓冲策略,重连后的进度过滤,生命周期与音频预检。在 SDK 总仓库中执行时,测试还会启动 `tools/mock-server` 的平台模拟服务,验证重试,计费只计一次,两条通道的朗读进度,以及实时会话在服务端断开,网络中断 10 秒,连接重置时的行为。没有 Node.js,单独克隆本仓库,或在不能启动进程的 iOS 模拟器上时,这部分测试自动跳过。持续集成脚本为总仓库的 `ci/ios.sh` 与 `ci/ios-macos.sh`。 2026-10-12 在 GitHub Actions 的 macos-14 构建机上用 Xcode 15.4 跑 `ci/ios-macos.sh`:macOS 上 216 项测试 212 项通过,4 项沙箱用例跳过。iOS 模拟器上 216 项测试 169 项通过,需要启动模拟服务的用例与 4 项沙箱用例共 47 项跳过。iOS 真机版,命令行演示与演示 App 均构建通过。录音器 `YuguRecorder` 只经过编译,没有单元测试,录音与音频中断尚未在真机验证。 设置 `YUGU_SANDBOX_APPKEY` 与 `YUGU_SANDBOX_SECRET` 后,测试另外连接沙箱跑四条端到端用例,未设置时这四条自动跳过,不连接平台。基址缺省为 `https://open.shengzhiai.com`,可用 `YUGU_SANDBOX_BASE` 修改。一次运行在沙箱上产生 5 次评测调用,第二次整段评测是重放,不占当日次数。 | 用例 | 内容 | |---|---| | 原生整段评测 | 同一个幂等键提交两次,第二次为重放,记录号相同 | | 声通兼容整段评测 | `sent.eval.cn` | | 原生实时评测 | 完整会话,核对回调顺序与总分,Linux 上经测试用的 WebSocket 客户端与本地转发连接沙箱 | | 原生实时评测朗读进度 | 打开 `realtimeFeedback`,按实际语速送入 25 个字的中文例句,核对进度帧逐字给出,进度不倒退,终评带总分 | ```bash export YUGU_SANDBOX_APPKEY='' export YUGU_SANDBOX_SECRET='' swift test --filter SandboxTests ``` ## 常见问题 ### 模拟器录音 iOS 模拟器使用 Mac 的麦克风,首次录音时 macOS 会询问是否允许 Xcode 使用麦克风。模拟器没有输入设备时 `start()` 报 90202。 ### Linux 上的实时评测 Linux 版 Foundation 的 WebSocket 依赖 libcurl 的 ws 支持,Ubuntu 24.04 自带的 libcurl 不包含这项支持,会话会以 90010 失败。Linux 上的整段评测,语音合成与报告查询不受影响。需要在 Linux 上做实时评测时,实现 `YuguWebSocketTransport` 后通过 `webSocketTransportFactory` 传入。 ### 签名失败 鉴权错误码 2003 表示签名不匹配,常见原因是 secretKey 填错,或设备时间与标准时间相差超过 300 秒。SDK 每次尝试都重新生成时间戳与随机串,签名的就是实际发出的文本,不需要自行签名。 ### 重复计费 写操作与实时会话都带幂等键,重试与重连沿用同一个键,平台对同一个键只计费一次。业务层再次提交同一次作答时,传入首次的 `idempotencyKey`。 ### 明文 HTTP 地址 App Transport Security 默认拦截 `http://` 地址,此时报 90010。联调本地服务时在 Info.plist 中为该域名添加 ATS 例外,生产地址为 HTTPS,不需要配置。 ### 回调线程 完成回调与会话回调在 `callbackQueue` 上执行,缺省为主队列,可直接更新界面。`async` 写法不经过 `callbackQueue`。录音器回调在 `RecorderOptions.callbackQueue` 上执行,缺省同样为主队列,在回调里调用 `sendAudio` 是线程安全的。 ## 许可 Apache-2.0,见 `LICENSE` 与 `NOTICE`。