资讯动态

iOS TTS离线朗读实战:长文本切分与逐词高亮

发布时间:2026/11/6 7:02:42 来源:尧图企业网站定制
1. 从一行文字到一段人声AVSpeechSynthesizer 的最小闭环去年做一个阅读类的小工具用户场景很明确通勤路上、地铁里没有信号仍然希望把收藏的文章听一遍。后台一查数据超过六成的使用发生在网络不稳定的环境下任何依赖云端合成的方案在这个场景里都是死路。最后落地的东西很简单就是 iOS 系统自带的AVFoundation里的文本转语音能力——AVSpeechSynthesizer加上AVSpeechUtterance两个类型就把事情办了。这块东西在面试里也经常被顺带问到因为它足够小能在一个回答里讲清楚输入、处理、输出和边界条件。先说清楚它能干什么把一段字符串交给它系统用本地的语音引擎合成音频从扬声器播出来同时通过代理回调节奏信息让你能做逐字高亮、进度条、暂停续读。它不需要联网不产生调用费用文本不出设备。听起来很美但真用起来坑集中在三个地方发音人怎么选、语速手感怎么调、长文本怎么切。这三件事决定了你做出来的是一个能响的 Demo还是一个用户愿意连续听二十分钟的功能。适合谁看手上有一个需要朗读能力的 iOS 项目、正准备做一个听书或无障碍功能、或者面试里被问到讲一个你用过的系统框架的人。下面我按从最小闭环到工程化的顺序拆代码是 SwiftObjective-C 的写法差异只是方法名和代理签名逻辑一样。1.1 这条链路上到底发生了什么把合成过程想成一条流水线会更容易理解参数为什么这么设计。你在最上游放进去一个AVSpeechUtterance它承载的是文本 一组描述怎么读的元数据发音人、语速、音调、音量、前后停顿。合成器AVSpeechSynthesizer拿到它之后先做文本规范化——把数字、日期、单位、缩写展开成可发音的形式比如把2024念成二零二四还是两千零二十四这一步由语言和发音人决定你干预不了多少。然后是韵律规划决定哪里停顿、哪个字重读最后交给声学模型生成波形写入音频输出。这条链路里有个关键点合成和播放是同一个对象在管。AVSpeechSynthesizer既是合成器也是播放器speak(_:)是异步入队的调用瞬间返回实际朗读在后台队列推进。所以你不需要自己搞音频队列也正因为如此你对播放到哪了的感知只能靠代理回调而不是自己读缓冲区的位置。再往下看一层合成器最终要把音频送到硬件走的仍然是AVAudioSession这套音频会话体系。这意味着朗读的音量、是否受静音开关影响、能否和其他 App 的声音共存全部由音频会话的类别决定而不是由utterance.volume决定。很多人第一次遇到代码没问题但就是没声音根因就在这里——合成成功了播放入口被系统按规则掐掉了。理解这条链路的价值在于排查顺序出问题时先问合成有没有发生再问声音有没有被允许出来最后才问参数是不是写错了。这个顺序能把排查时间从半小时压到三分钟。1.2 为什么不直接接一个云端语音服务这是个绕不开的选型问题我当时的对比结论是这样的维度系统 AVSpeechSynthesizer云端语音合成服务网络依赖完全离线必须联网弱网体验崩塌成本零按字符计费长文本成本明显隐私文本不出设备文本要上传包体积0通常需要 SDK 或走 HTTP音色质量机器感明显但可用接近真人可选音色多可控粒度词级回调粒度粗可拿到精确时间戳定制能力几乎为零支持音色克隆、情感调节结论不是哪个更好而是哪个先用。我的做法是默认走系统合成保证任何网络状态下都能出声把云端合成做成可选的高清音色开关用户主动开启后才下载音频并缓存。这样弱网用户不会看到转圈愿意为音质付费的用户也有路径。这个降级可用的思路在任何涉及多媒体能力的项目里都成立面试里讲出来比单纯说我用了某某 SDK有价值得多。还有一个现实因素系统合成不需要处理音频文件的下载、校验、缓存淘汰、断点续传这一整套东西早期版本的开发量能压到一个下午。先跑通再优化比一上来就设计完美的双通道架构要务实。1.3 三十行的最小可运行代码先给一个能直接跑的版本后面所有讨论都基于它扩展import AVFoundation final class Speaker: NSObject, AVSpeechSynthesizerDelegate { private let synth AVSpeechSynthesizer() override init() { super.init() synth.delegate self } func speak(_ text: String) { guard !text.isEmpty else { return } let utterance AVSpeechUtterance(string: text) utterance.voice AVSpeechSynthesisVoice(language: zh-CN) utterance.rate 0.48 utterance.pitchMultiplier 1.0 utterance.volume 1.0 utterance.preUtteranceDelay 0.05 utterance.postUtteranceDelay 0.1 synth.speak(utterance) } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { print(开始朗读) } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { print(朗读结束) } }这段代码里有两个必须注意的细节踩过一次就记一辈子。第一synth必须是强引用的成员变量不能是局部变量。如果你在方法里let synth AVSpeechSynthesizer()然后synth.speak(...)就返回了对象会被释放你会遇到偶尔能读、偶尔不读、代理回调完全不触发的玄学现象。原因是合成器被释放时朗读会静默终止而释放时机取决于运行时看起来就像随机失败。这个坑我第一次遇到时查了快两个小时最后发现是作用域问题。第二代理必须在speak之前设置好。如果先 speak 再设 delegatedidStart有可能漏掉。另外代理回调默认在主线程之外实测下来AVSpeechSynthesizerDelegate的回调是在主线程的但如果你的回调里做了重活仍然建议切到主队列再更新 UI避免偶发的界面卡顿。还有个小技巧informationView之类的调试界面里可以把synth.isSpeaking和当前 utterance 的speechString打印出来能快速确认是不是有多条任务在排队。2. 声音、语速、音调参数调优的真实手感参数表看文档就够了但文档不会告诉你0.48 和 0.52 的差别在中文长句里有多明显。这一节讲的是调参的实际体感是从反复试听里磨出来的。2.1 AVSpeechUtterance 的四个可调旋钮一个 utterance 上真正影响听感的核心属性只有四个其余都是辅助voice发音人对象决定了语言、音色、性别倾向。不设置会用系统默认中文环境下默认往往是普通话女声。rate语速范围 0.0 到 1.0系统常量AVSpeechUtteranceDefaultSpeechRate是 0.5。注意这是个归一化值不是倍速0.5 不等于1 倍速0.25 也不是半速。pitchMultiplier音调倍数有效范围 0.5 到 2.0默认 1.0。往低调会变沉闷往高调会变尖细中文超过 1.3 就开始失真。volume这条 utterance 的相对音量0.0 到 1.0。注意它是在系统媒体音量基础上的相对系数用户把音量调到 0 的话你设 1.0 也没用。还有两个容易被忽略的preUtteranceDelay和postUtteranceDelay单位是秒。它们在队列里制造停顿是做分段朗读时控制节奏的关键——两条 utterance 之间如果完全没有延迟听起来会连成一口气加上 0.1 到 0.2 秒的postUtteranceDelay句子之间就有了呼吸感。注意这两个属性只在队列播放时生效如果你一次只塞一条然后手动控制间隔那就用不上它们。一个反直觉的经验volume调低来做淡出是行不通的。utterance 的 volume 在朗读过程中改不了它是在入队时读取的想做出淡出效果只能自己控制音频会话或者改用write(_:toBufferCallback:)拿到 PCM 自己混音。这个限制在做朗读即将结束时的渐弱需求时会撞上。2.2 中文发音人的识别与回退策略AVSpeechSynthesisVoice.speechVoices()返回系统里所有可用发音人中文相关的要按语言码前缀过滤。我的处理逻辑是这样的func bestChineseVoice() - AVSpeechSynthesisVoice? { let all AVSpeechSynthesisVoice.speechVoices() .filter { $0.language.hasPrefix(zh) } // 先按质量降序再优先普通话 let sorted all.sorted { lhs, rhs in if lhs.quality.rawValue ! rhs.quality.rawValue { return lhs.quality.rawValue rhs.quality.rawValue } return lhs.language zh-CN rhs.language ! zh-CN } return sorted.first ?? AVSpeechSynthesisVoice(language: zh-CN) }quality有三个档位default、enhanced、premium。高档位的音色在系统设置里需要用户手动下载没下载的话你就是拿不到speechVoices()里也不会出现。所以千万不要把 identifier 写死比如把某个具体的 bundle 标识符硬编码进代码——系统版本升级后标识符可能变新版本可能移除旧音色结果就是线上用户突然变成默认声音甚至完全没声。按language quality动态挑选才是稳的写法。另一个细节.language除了zh-CN还有zh-TW、zh-HKzh-TW和zh-HK在念简体中文文本时会出现读音怪异的情况比如某些多音字按繁体习惯读。如果你的文本是简体务必明确指定zh-CN别依赖系统自动匹配。回退链我一般写成三层首选zh-CN且质量最高 → 退到任意zh前缀 → 再退到不设 voice用系统默认。绝不返回 nil 直接放弃因为不设 voice 时系统仍然会按文本语言猜一个比完全不出声好。2.3 语速数值与实际听感的对应关系这块没有官方换算表我给的是实测出来的大致区间中文普通话、句子长度 15 到 30 字的条件下rate 取值大致字数/分钟适用场景体感描述0.35 及以下150 左右外语学习、逐词跟读拖沓正常听会走神0.40 - 0.45200 - 230教学讲解、技术文档清晰停顿多适合边听边记0.48 - 0.52250 - 300通用朗读、新闻资讯自然接近正常人语速0.55 - 0.60320 - 380通勤速听、老用户开始有紧迫感长句易糊0.65 以上400速听模式辅音粘连明显可懂度下降这张表是按能听懂而不是能听清每个字来定的实际感受跟发音人、句子复杂度强相关。我的默认值定在 0.48比系统的 0.5 稍慢一点——因为朗读和对话不一样用户在听朗读时往往同时在干别的事走路、开车、看屏幕稍慢一点容错率更高。然后在外层给一个三档设置慢0.42、标准0.48、快0.56不要做连续滑块用户根本分不出 0.47 和 0.48 的区别反而会觉得这个控件没反应。音调pitchMultiplier我的建议是基本不要动保持 1.0。中文是声调语言音调倍数会直接扭曲四个声调的相对关系超过 1.2 就明显不自然。如果产品要求男声女声正确做法是换发音人而不是调 pitch——调 pitch 得到的是变声器效果不是另一个人的声音。最后一个经验不同发音人对同一个 rate 值的响应不一致。同一个 0.48在 A 音色上是舒适区间在 B 音色上可能偏快。所以换默认发音人的时候语速映射要重新试听一遍别假设它是线性一致的。3. 长文本朗读的工程化处理切分、进度与打断Demo 阶段读一句你好世界当然没问题产品里用户点的是朗读全文两三千字起步。这一节是真正花时间的地方。3.1 为什么不建议把整篇文章一次性丢进去技术上可以AVSpeechUtterance的字符串长度没有硬上限几千字也能正常合成。但实际用起来有四个问题断点不可恢复。用户听到第 30% 时切到后台、或者来了个电话回来想续读。stopSpeaking(at:)之后你没办法让合成器从第 850 个字继续它只有整条开始和整条结束。要么从头再来要么自己记住位置重建 utterance——而重建就得自己切。暂停粒度太粗。pauseSpeaking(at: .word)会在词边界暂停但对一条超长 utterance暂停响应可能延迟用户点了暂停还多念了两三句。进度反馈不准。willSpeakRangeOfSpeechString的NSRange是相对于整条字符串的偏移文本越长这个偏移在 UI 上换算成滚动位置越麻烦而且回调频率不变的情况下长文本上的进度看起来是一跳一跳的。出错影响面大。长 utterance 里如果混入了无法处理的字符某些表情符号、特殊符号组合可能整条失败而不是丢几个字。我的切分策略是按句切合并到 80 到 200 字一段func splitForSpeech(_ text: String, maxLen: Int 160) - [String] { let terminators: SetCharacter [。, , , ., !, ?, \n] var result: [String] [] var buffer for ch in text { buffer.append(ch) if terminators.contains(ch) { if buffer.count maxLen { result.append(buffer) buffer } else { // 短句先攒着等凑够长度再吐出去 continue } } } if !buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { result.append(buffer) } return result }这个实现是简化版真实项目里还要处理英文句子里的Mr.、3.14这种假句号代码块、URL 这种不该念或者要特殊处理的内容以及空行带来的额外停顿。切分粒度宁小勿大160 字左右一段是试出来的——太小会导致段间停顿过密听起来一顿一顿太大又回到前面的问题。切完之后不要一次性全塞进队列。我的做法是维持一个两段的缓冲窗口当前段 下一段预入队播完一段再补一段。这样既能保证段间无缝衔接又能在用户点暂停时立刻停住还能在用户拖动进度条时快速清空重建。3.2 用 willSpeakRange 做逐词高亮代理方法speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:)会在即将念到某个范围时回调这是做卡拉 OK 式高亮的唯一手段。回调的NSRange是 UTF-16 偏移量跟NSString体系一致不能直接拿去索引 Swift 的 String必须转换func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { let text utterance.speechString guard let swiftRange Range(characterRange, in: text) else { return } let spoken String(text[swiftRange]) print(正在读: \(spoken) - \(characterRange.location)) DispatchQueue.main.async { self.highlightedRange swiftRange } }这里有个中文场景特有的坑回调粒度是按词不是按字而且词的划分由系统决定你无法控制。实测下来两三个字的词语会一起回调标点符号有时单独回调有时并入。所以高亮效果天然是跳变的块状不是平滑的逐字推进。如果你的设计稿画的是逐字高亮做出来一定对不上不如设计成当前句高亮 已读部分变色这种更贴合的样式。另一个坑是表情符号和组合字符。NSRange按 UTF-16 计算一个 emoji 可能占 2 个甚至更多 code unit用Range(_:in:)转换是安全的但如果自己用index(offsetBy:)硬算就会越界崩溃。这个崩溃在真机上偶发、在模拟器上难复现属于典型的线上问题。所以转换一律走Range(_:in:)别自己算。高亮的性能也要注意如果文本很长每次回调都重设整个NSAttributedString会导致滚动卡顿。正确做法是把文本预先构建成NSTextStorage每次只修改受影响的 attribute range或者用一个独立的UITextView只显示当前段整篇文章的高亮状态自己用数据模型记录。3.3 打断、续读与用户手势的配合用户在朗读过程中可能的动作有五种暂停、继续、停止、拖动进度、点某一段直接跳过去。每一种都要有明确的状态机用户动作调用需要注意暂停pauseSpeaking(at: .word)会在最近的词边界停可能延迟几百毫秒继续continueSpeaking()只在暂停状态下有效其他状态下返回 false停止stopSpeaking(at: .immediate)立即停当前 utterance 作废队列剩余清空跳段先 stop 再 speak 新段必须先 stop否则新段会排到旧段后面拖动进度计算目标段索引后 stop speak段内进度无法精确定位只能按段跳pauseSpeaking和continueSpeaking都返回Bool一定要判断返回值不然会出现用户连点两次暂停第二次返回 false 但 UI 已经切成播放态的状态错位。我的做法是不用返回值驱动 UI用代理回调驱动 UIdidStart才把图标切成暂停didFinish/didCancel才切回播放。这样即使操作被系统忽略UI 也是准的。还有一个必踩的坑stopSpeaking(at: .immediate)之后紧接着speak新段有时候新段不响。原因通常是停止操作是异步的合成器内部状态还没完全清空。解决方案是给个极短的延迟或者在didCancel代理回调里再触发下一条。我更推荐后者逻辑更干净func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { if let next pendingUtterance { pendingUtterance nil synthesizer.speak(next) } }实测这个写法比DispatchQueue.main.asyncAfter(deadline: .now() 0.1)靠谱得多因为延迟写死了在慢设备上仍可能失败而回调是确定事件。4. 无声与中断一份按顺序执行的排查清单这块我整理成固定顺序因为乱查很浪费时间。每次遇到TTS 不出声从第 1 步往下走绝大多数问题在前三步就能定位。4.1 第一步区分没合成还是没出声这两件事的现象完全一样但根因和修法完全不同。判断方法很直接实现didStart和didFinish看有没有回调。didStart不触发→ 合成没开始。检查synthesizer 是不是局部变量被释放了、代理有没有设、utterance.speechString是不是空串或全是空白字符、是不是在非主线程调用了speak虽然通常不崩但行为不稳。didStart触发但听不见→ 合成成功播放被拦。查音频会话和硬件音量。didStart和didFinish都触发得特别快几百毫秒内结束→ 合成器认为文本念完了常见于文本全是标点或不可发音字符。我遇到过一次很典型的传入的文本是富文本转出来的字符串里面全是\u{FFFC}这类对象替换符界面看起来有内容但合成器认为没东西可念didFinish瞬间触发。排查方式是把speechString的unicodeScalars打出来看一看全是特殊码点问题立刻明确。还有一个更隐蔽的didFinish完全不触发isSpeaking一直是 true但没声音。这通常是音频路由切换导致的——用户插了蓝牙耳机又拔掉会话没恢复。查AVAudioSession.sharedInstance().currentRoute能确认。4.2 第二步音频会话类别决定了静音开关是否生效这是最高频的代码全对但没声原因。iOS 的音频会话类别有好几种跟朗读相关的两个类别静音开关拨到静音时是否打断其他 App 音频适用场景.ambient静音不打断可混音辅助性音效用户可能不希望被打扰.playback仍然出声默认打断主要内容播放如听书、朗读.playAndRecord仍然出声打断需要录音的场景如果你的朗读功能是主要内容用户主动点了朗读全文应该用.playback。用默认的.ambient会导致用户在静音模式下完全听不到然后来投诉这个功能坏了。let session AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers]) try session.setActive(true) } catch { print(音频会话配置失败: \(error)) }mode: .spokenAudio这个模式是专门为语音内容设计的会做一些听感上的优化比默认的.default更适合朗读。options: [.duckOthers]表示压低其他 App 的音量而不是直接打断这个在微信语音条自动播放这类场景里是标准做法——用户能听到你的声音背景音乐只是变小了不会突然断掉。.duckOthers和.mixWithOthers的区别要分清duck是压低对方mix是完全混在一起互不影响。朗读场景用duck音效场景用mix。如果两个都加duck行为优先。还有一个必须做的收尾朗读结束后要setActive(false, options: .notifyOthersOnDeactivation)把音频会话还回去不然其他 App 的音乐会一直处于被压低的状态用户体验很差。这个notifyOthersOnDeactivation不能省省了对方收不到恢复通知。4.3 第三步锁屏、来电、路由变化这些外部打断用户点了播放锁屏放兜里继续听——这是听书类产品的核心场景但 TTS 在这里的表现比AVAudioPlayer要脆弱。要让 App 在后台继续运行Info.plist里必须加UIBackgroundModes包含audio并且音频会话类别是.playback。这两个条件缺一不可。但即便都满足AVSpeechSynthesizer在后台被系统终止的情况仍然会发生尤其是长时间朗读时。原因是它没有AVAudioPlayer那样明确的持有音频焦点的信号系统判定没在播音频就可能挂起进程。我的应对办法是在后台期间定期确认状态并重建监听didFinish如果发现后面还有待读的段但isSpeaking为 false就重新触发下一段。这个看门狗逻辑听起来丑但确实是目前比较稳的做法。另一个思路是用write(_:toBufferCallback:)预合成音频文件然后用AVAudioPlayer播放——AVAudioPlayer的后台表现稳定得多代价是没了实时高亮回调需要自己按时间轴计算。这个取舍在下一节展开。来电打断也是必须处理的。系统会在来电时自动把音频会话标记为中断你应该监听AVAudioSession.interruptionNotificationNotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { note in guard let info note.userInfo, let raw info[AVAudioSessionInterruptionTypeKey] as? UInt, let type AVAudioSession.InterruptionType(rawValue: raw) else { return } switch type { case .began: self.pauseForInterruption() case .ended: let optRaw info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let opts AVAudioSession.InterruptionOptions(rawValue: optRaw) if opts.contains(.shouldResume) { self.resumeAfterInterruption() } unknown default: break } }注意.shouldResume只是建议恢复不是必须恢复。如果用户在来电期间手动退出了朗读界面你强行恢复就是错的。所以恢复之前一定要校验当前 UI 状态和用户意图别一股脑continueSpeaking()。最后补充一个属于经验范畴的细节蓝牙耳机断开重连后正在朗读的 utterance 会继续但音量路由可能没切回来。处理办法是监听AVAudioSession.routeChangeNotification在.oldDeviceUnavailable时暂停等用户回来再继续。这个在开车场景里非常重要突然从耳机切到外放会吓到人。5. 把 Demo 变成可用功能缓存、离线与性能边界功能能跑通只是开始真正上线前还有几个问题要回答哪些能力离线可用、能不能预生成音频、长文本会不会卡。5.1 系统 TTS 的离线边界到底在哪需要明确一点AVSpeechSynthesizer的合成完全在本地完成不联网。但这不等于所有发音人都离线可用。分三种情况内置的 default 质量发音人随系统预装任何设备上都能用离线无问题。这是默认保障。enhanced 和 premium 质量发音人需要用户在设置 - 辅助功能 - 朗读内容里手动下载语音包。没下载的设备上speechVoices()里根本查不到这些条目你按 quality 排序也选不到它们。所以设计上必须假设用户只有 default 可用高质量音色只能是加分项。语言包某些小语种在首次使用时系统会提示下载这个下载动作是系统弹窗你的 App 无法控制也无法感知进度。如果产品要支持多语言朗读第一次进入时最好给个正在准备语音包的提示而不是让用户看着转圈。基于这个边界我的建议是默认发音人的路径必须在任何设备上都能跑通高质量音色作为可选增强用音色选择界面显式列出可用项没下载的标注需在系统设置中下载。这样做虽然不如我全都要漂亮但线上投诉会少很多。5.2 预合成音频把 TTS 变成可缓存的音频文件如果你的产品需要精确的进度控制、后台稳定播放、或者离线分享纯speak是不够的。iOS 13 之后AVSpeechSynthesizer提供了write(_:toBufferCallback:)可以把合成结果直接输出成 PCM 缓冲区你就能存成文件func synthesizeToFile(text: String, url: URL, done: escaping (Bool) - Void) { let synth AVSpeechSynthesizer() let utterance AVSpeechUtterance(string: text) utterance.voice AVSpeechSynthesisVoice(language: zh-CN) utterance.rate 0.48 var audioFile: AVAudioFile? var bufFormat: AVAudioFormat? synth.write(utterance) { buffer in guard let pcm buffer as? AVAudioPCMBuffer, pcm.frameLength 0 else { return } if audioFile nil { bufFormat pcm.format audioFile try? AVAudioFile(forWriting: url, settings: pcm.format.settings) } try? audioFile?.write(from: pcm) // 最后一块 frameLength 为 0 表示结束 if pcm.frameLength 0 { audioFile nil done(true) } } }这里有几个实测出来的要点。输出格式通常是单声道浮点、采样率在 22kHz 左右具体值跟系统和发音人相关不要写死格式按第一块 buffer 的format来建文件。缓冲区是分块回调的最后会有一个frameLength 0的结束标记必须靠它判断完成而不是靠回调次数。拿到文件之后播放就可以完全交给AVAudioPlayer好处非常明显进度精确到毫秒、支持currentTime任意 seek、后台稳定、可以做 0.5x 到 2.0x 变速不变调。代价是失去了willSpeakRange回调逐词高亮要自己按每段字数 / 段时长做线性估算精度会差一些但对于整句高亮这个级别是够用的。缓存策略上我用的是按文本内容哈希 参数指纹做 key。参数指纹包含发音人 identifier、rate、pitch因为同样的文本换了语速就是不同的音频。缓存放 Caches 目录设置一个总量上限比如 200MB超出按最后访问时间淘汰。别放 Documents会被 iCloud 备份带上用户空间报警会投诉。这套方案的成本是首次朗读有延迟——需要等合成完成才能播一条 200 字的文本大概几百毫秒。我的处理是边合成边播放合成第一段完成后立刻开始播后面几段在后台继续合成。这样首响延迟能压到用户感觉不到的程度。5.3 性能上的几个实测数字给几个实际测量的参考环境是中端机型、中文、默认发音人场景耗时/表现说明200 字文本首字出声150 - 300ms冷启动首次会慢一些200 字文本完整合成到文件400 - 800ms用 write 回调受 CPU 影响单条 utterance 2000 字可正常朗读但停止响应延迟明显连续 30 段队列播放稳定段间用 postUtteranceDelay 控制节奏内存占用基本可忽略不涉及大缓冲区结论很明确性能不是瓶颈体验设计才是。真正需要花心思的是首响延迟、段间衔接、高亮精度和后台稳定性这四个是用户能直接感知到的。CPU 和内存这块系统 TTS 的表现一直很轻。6. 面试现场怎么讲这段经历标题里另一半提到的面试其实和上面的内容是一回事——技术面试里最容易被追问的就是这种看起来简单、细节很多的能力点。我按自己的理解梳理一下怎么组织回答。6.1 从我做过 TTS到我解决过什么问题面试官听到我用过 AVSpeechSynthesizer大概率不会接着问 API 怎么调而是会往深处挖。这时候如果只答得出设置 voice 然后 speak回答就断在这里了。有效的答法是按场景 - 约束 - 方案 - 代价四段式讲先给场景做了一个离线阅读工具核心场景是通勤时无网络环境下听文章。这一步交代了为什么不能选云端方案。再给约束约束有三个必须离线、必须后台持续播放、必须支持逐句高亮。约束是关键它解释了后面所有设计选择的来源。然后给方案方案上我用系统的合成器分句切分维持两段缓冲队列用 willSpeakRange 代理做高亮音频会话用 playback spokenAudio duckOthers。具体到参数和 API 名字说明是真做过。最后给代价代价是段间有极短的停顿、高亮粒度只能到词不能到字、后台长文本仍有被挂起的概率所以我额外做了预合成到文件 AVAudioPlayer 播放的降级通道。主动说缺陷比被追问出来强得多。这个结构的好处是它展示的是判断力不是记忆力。面试官招人招的是能解决问题的人不是能背文档的人。6.2 前端面试流程里各轮的考察差异既然标题提到了前端面试这里顺带说一下流程层面的东西。不同轮次的考察重点差别很大准备方向不该一样。简历筛选和在线测评主要看你有没有真的产出过东西。项目描述里写实现了朗读功能和写基于系统合成器实现了离线朗读含分句缓冲、词级高亮、后台续播通过率差别很明显。别写参与了、负责了写清楚你做了什么、用什么做的、解决了什么具体问题。技术一面基本是基础题JS 语言特性、浏览器渲染、网络协议、框架原理加上一道中等难度的算法。这一轮的特点是广度优先、不深挖答题要快要准卡住了主动说这块我了解得不够深我的理解是……比硬编好。技术二面是深度轮会盯着你简历上的一个项目往下挖三层。这时候前面那套场景 - 约束 - 方案 - 代价就用上了。如果简历上写的是 iOS 相关的项目而面的是前端岗面试官往往会问这个能力在前端怎么实现这就是 Web Speech API 的场合了——window.speechSynthesis加上SpeechSynthesisUtterance思路和AVSpeechSynthesizer几乎一致也是speak入队、onboundary回调做高亮、cancel清空队列。能把这个类比讲清楚说明你理解的是原理而不是 API。交叉面或主管面更多看工程判断和协作。会问如果产品要求音色更好听你怎么办、预算有限你怎么排优先级、上线后用户反馈听不清你怎么定位。这类问题没有标准答案重点是你有没有成本意识和排查路径。关于 Web Speech API 的实战提醒浏览器的语音合成在移动端支持度差异很大部分移动浏览器需要用户手势触发才允许发声speechSynthesis.getVoices()在页面首次加载时可能返回空数组需要监听voiceschanged事件。这几个坑和 iOS 端的音频会话没配好就没声属于同一类问题——播放权限的隐式条件理解了这一点跨端迁移就不难了。顺带说一句对标题党的看法。这类把两个不相关主题拼在一起的标题在技术社区里很常见看着像是为了搜索流量。但对读者来说如果两半内容都是实的反而能捞到点意外收获——比如你本来是来查框架用法的顺手看到了面试怎么准备。真正要警惕的是那种标题覆盖五个领域、正文全是空话的东西。判断标准很简单看它有没有给出具体的参数、具体的报错、具体的取舍有就是真做过没有就是攒的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑