# 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`。