吐槽兩句:
本來(lái)想用科大訊飛來(lái)做語(yǔ)音朗讀的,但是看了一下離線語(yǔ)音合成貌似要收費(fèi)......作為一個(gè)新產(chǎn)品肯定是給不起了。所以我用原生API實(shí)現(xiàn)了這個(gè)功能,效果還不錯(cuò)。實(shí)現(xiàn)思路主要分為三塊,文字轉(zhuǎn)語(yǔ)音,UI變化,后臺(tái)播放等配置。
文字轉(zhuǎn)語(yǔ)音
第一步,導(dǎo)入AVFoundation.framework.

簡(jiǎn)單講一下我們要用到的類和方法。
AVSpeechSynthesizer //控制整個(gè)閱讀過(guò)程
//閱讀狀態(tài),是否正在閱讀,暫停閱讀時(shí)這里依然是YES
@property(nonatomic, readonly, getter=isSpeaking) BOOL speaking;
//暫定狀態(tài),當(dāng)前閱讀是否暫停
@property(nonatomic, readonly, getter=isPaused) BOOL paused;
//停止閱讀,停止后speaking = NO
- (BOOL)stopSpeakingAtBoundary:(AVSpeechBoundary)boundary;
//暫停閱讀,暫停后paused = YES
- (BOOL)pauseSpeakingAtBoundary:(AVSpeechBoundary)boundary;
//繼續(xù)閱讀,paused = NO;
- (BOOL)continueSpeaking;
//代理方法
@protocol AVSpeechSynthesizerDelegate <NSObject>
//開始閱讀
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didStartSpeechUtterance:(AVSpeechUtterance *)utterance;
//完成閱讀,正常讀完
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance;
//暫停閱讀
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didPauseSpeechUtterance:(AVSpeechUtterance *)utterance;
//繼續(xù)閱讀
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didContinueSpeechUtterance:(AVSpeechUtterance *)utterance;
//閱讀被打斷或取消
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didCancelSpeechUtterance:(AVSpeechUtterance *)utterance;
/*
* 即將閱讀到的內(nèi)容
* characterRange : 要讀的字的位置,這里可能是字或者詞語(yǔ),所以長(zhǎng)度一般是1-3
* utterance:要讀的句子,依然是設(shè)置要讀的內(nèi)容而不是單個(gè)的文字或詞語(yǔ)。
*/
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer willSpeakRangeOfSpeechString:(NSRange)characterRange utterance:(AVSpeechUtterance *)utterance;
AVSpeechUtterance //提供閱讀的內(nèi)容,這里只寫幾個(gè)常用的方法和屬性
//用string初始化內(nèi)容。
+ (instancetype)speechUtteranceWithString:(NSString *)string;
@property(nonatomic, readonly) NSString *speechString;
@property(nonatomic) float rate; //語(yǔ)速,
@property(nonatomic) float pitchMultiplier; // 聲音高度。[0.5 - 2] Default = 1
@property(nonatomic) NSTimeInterval preUtteranceDelay;//間隔時(shí)間,讀完一句可以停久一點(diǎn)
AVSpeechSynthesisVoice //提供閱讀的聲音
//初始化一個(gè)聲音,languageCode可以填@"zh-CN" 代表普通話,還有粵語(yǔ),臺(tái)灣話,各國(guó)語(yǔ)言。
+ (nullable AVSpeechSynthesisVoice *)voiceWithLanguage:(nullable NSString *)languageCode
第二步,實(shí)現(xiàn)文字轉(zhuǎn)語(yǔ)音相關(guān)代碼,這里我是寫在一個(gè)單例里面。方便全局控制。特別注意的是設(shè)置暫停,停止時(shí)需要傳參數(shù)AVSpeechBoundaryImmediate或者AVSpeechBoundaryWord,前者是立刻執(zhí)行,后者是讀完一個(gè)字再執(zhí)行。這里在實(shí)際使用上差別還是蠻大的,建議選擇前者可以避免一些奇奇怪怪的錯(cuò)誤。
//file.h里面
@protocol SpeechManagerDelegate <NSObject>
@optional
- (void)didStartSpeechUtterance:(AVSpeechUtterance*)utterance;
- (void)didFinishSpeechUtterance:(AVSpeechUtterance*)utterance;
- (void)didPauseSpeechUtterance:(AVSpeechUtterance*)utterance;
- (void)didCancelSpeechUtterance:(AVSpeechUtterance*)utterance;
- (void)willSpeakRangeOfSpeechString:(NSRange)characterRange utterance:(AVSpeechUtterance *)utterance;
- (void)needRepeatSpeech:(AVSpeechUtterance *)utterance;
@end
// file.m里面
@interface SpeechManager() <AVSpeechSynthesizerDelegate>
@property (nonatomic, strong) AVSpeechSynthesizer *avSpeech;
@property (nonatomic, strong) AVSpeechUtterance *speechUtt;
@end
- (void)setSpeechContent:(NSString *)content {
AVSpeechUtterance *speechUtt = [AVSpeechUtterance speechUtteranceWithString:content];
CGFloat value = [LZUtils fetchSpeechSpeed];
speechUtt.rate = [self getSpeechSpeedWith:value];
AVSpeechSynthesisVoice *voice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];
speechUtt.voice = voice;
self.speechUtt = speechUtt;
}
- (void)beginSpeech {
//這里需要注意一下,一個(gè)avspeech對(duì)象只能播放一次,同一個(gè)對(duì)象中途不能重新播放。
AVSpeechSynthesizer *avSpeech = [[AVSpeechSynthesizer alloc] init];
avSpeech.delegate = self;
[avSpeech speakUtterance:self.speechUtt];
self.avSpeech = avSpeech;
}
- (void)pauseSpeech {
[self.avSpeech pauseSpeakingAtBoundary:AVSpeechBoundaryImmediate];
}
- (void)continueSpeech {
if(self.avSpeech.isPaused) {
[self.avSpeech continueSpeaking];
[NSThread sleepForTimeInterval:0.25f];
}
}
- (void)endSpeech {
if(self.avSpeech.isSpeaking) {
[self.avSpeech stopSpeakingAtBoundary:AVSpeechBoundaryImmediate];
[NSThread sleepForTimeInterval:0.25f];
}
}
//代理主要是返回給controller,用來(lái)和UI交互
#pragma mark - AVSpeechSynthesizerDelegate;
- (void)speechSynthesizer:(AVSpeechSynthesizer*)synthesizer didStartSpeechUtterance:(AVSpeechUtterance*)utterance{
NSLog(@"---開始播放");
self.nRepeat = NO;
if(self.delegate && [self.delegate respondsToSelector:@selector(didStartSpeechUtterance:)]) {
[self.delegate didStartSpeechUtterance:utterance];
}
}
- (void)speechSynthesizer:(AVSpeechSynthesizer*)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance*)utterance{
NSLog(@"---完成播放");
if(self.delegate && [self.delegate respondsToSelector:@selector(didFinishSpeechUtterance:)]) {
[self.delegate didFinishSpeechUtterance:utterance];
}
}
- (void)speechSynthesizer:(AVSpeechSynthesizer*)synthesizer didPauseSpeechUtterance:(AVSpeechUtterance*)utterance{
NSLog(@"---播放中止");
if(self.delegate && [self.delegate respondsToSelector:@selector(didPauseSpeechUtterance:)]) {
[self.delegate didPauseSpeechUtterance:utterance];
}
}
- (void)speechSynthesizer:(AVSpeechSynthesizer*)synthesizer didContinueSpeechUtterance:(AVSpeechUtterance*)utterance{
NSLog(@"---恢復(fù)播放");
}
- (void)speechSynthesizer:(AVSpeechSynthesizer*)synthesizer didCancelSpeechUtterance:(AVSpeechUtterance*)utterance{
NSLog(@"---播放取消");
if(self.delegate && [self.delegate respondsToSelector:@selector(didCancelSpeechUtterance:)]) {
[self.delegate didCancelSpeechUtterance:utterance];
}
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer willSpeakRangeOfSpeechString:(NSRange)characterRange utterance:(AVSpeechUtterance *)utterance {
if(self.delegate && [self.delegate respondsToSelector:@selector(willSpeakRangeOfSpeechString:utterance:)]) {
[self.delegate willSpeakRangeOfSpeechString:characterRange utterance:utterance];
}
}
有了這個(gè)單例,你就可以把文字傳進(jìn)來(lái),通過(guò)beginSpeech,pauseSpeech,continueSpeech,endSpeech來(lái)控制語(yǔ)音播放了。
第三步,處理UI展示。
在語(yǔ)音播放的時(shí)候,通常界面上會(huì)將當(dāng)前播放的語(yǔ)句添加背景色展示給用戶。先說(shuō)一下如何給label上的文字添加背景色。
如果你是給普通的label設(shè)置了富文本,你可以直接給富文本添加屬性。
//先移除range范圍的背景色
[mString removeAttribute:(NSString *)NSBackgroundColorAttributeName range:range];
//給range范圍添加背景色
[mString addAttribute:(NSString*)NSBackgroundColorAttributeName value:[UIColor redColor] range:range];
如果你是用coretext實(shí)現(xiàn)的UI,很遺憾NSBackgroundColorAttributeName并不能兼容,(我測(cè)試了一下在iOS10是可以的,但10以下就不行了)為了兼容建議使用YYLabel,可以使用YYTextBorder類設(shè)置背景顏色,非常方便。
NSMutableAttributedString *muattString = [NSMutableAttributedString new];
YYTextBorder *yyborder = [[YYTextBorder alloc] init];
yyborder.fillColor = [UIColor colorWithHexString:@"#b0cbf4"];
yyborder.cornerRadius = 0; // a huge value
yyborder.lineJoin = kCGLineJoinBevel;
yyborder.insets = UIEdgeInsetsMake(-1, -1, -1, -1);
[muattString yy_setTextBackgroundBorder:yyborder range:range];
進(jìn)入正題,如何讓UI跟隨語(yǔ)音變化呢,我們需要用到之前的那幾個(gè)回調(diào)。我的實(shí)現(xiàn)思路是,將一整章內(nèi)容切割成很多份,放進(jìn)一個(gè)類似隊(duì)列的數(shù)據(jù)結(jié)構(gòu)里,每一次播放其中一段,播放完畢后切換到下一段。
- (void)findVoiceContents:(NSString *)content {
self.voiceArr = [NSMutableArray arrayWithArray:[content componentsSeparatedByString:@"\n"]] ;
}
//先進(jìn)先出,出去后移除數(shù)組元素。
- (NSString *)popVoickContent {
if(self.voiceArr.count == 0) {
return nil;
}
NSString *string = [self.voiceArr firstObject];
[self.voiceArr removeObjectAtIndex:0];
return string;
}
接下來(lái)在代理里處理分段播放內(nèi)容
- (void)didStartSpeechUtterance:(AVSpeechUtterance *)utterance {
//由于某些頁(yè)開頭并不是新的一段,這里計(jì)算一下當(dāng)前閱讀內(nèi)容是否含有段首。
NSInteger loc = [utterance.speechString hasPrefix:@" "] ? 2 : 0;
NSInteger len = [utterance.speechString hasPrefix:@" "] ? utterance.speechString.length - 2 : utterance.speechString.length;
//BookTextController是一個(gè)文本內(nèi)容控制器,我的Label是加在這個(gè)控制器里的,你也可以直接在當(dāng)前控制器添加label等控件。
BookTextController *textController = (BookTextController *)self.currentViewController;
[textController addTextBackgroudColorWhthRange:NSMakeRange(self.voiceOffset + loc, len)];
//這個(gè)偏移量定位當(dāng)前章節(jié)閱讀的位置,初始值為0,每一次開始閱讀后都要給這個(gè)偏移量+當(dāng)前閱讀內(nèi)容的長(zhǎng)度。
self.voiceOffset += utterance.speechString.length + 1;
}
- (void)didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {
NSString *content = [self popVoickContent];
if(content == nil) {
//下一章 ,將章節(jié)偏移量置為0
self.voiceOffset = 0;
//清除當(dāng)前文本控制器上已顯示的文字背景
LZBookTextController *textController = (LZBookTextController *)self.currentViewController;
[textController clearAllTextBackgroudColor];
//獲取下一章的內(nèi)容,這里必須是成功獲取才能繼續(xù)執(zhí)行閱讀。這里可能是異步也可能是同步的。
@weakify(self)
[self resetContextCompletion:^(BOOL success) {
@strongify(self)
if(success) {
if(self.speechManager.isSpeech) {
[self findVoiceContentString];
NSString *content = [self popVoickContent];
[self.speechManager setSpeechContent:content];
[self.speechManagerbeginSpeech];
//鎖屏后顯示播放器內(nèi)容。
if([UIApplication sharedApplication].applicationState == UIApplicationStateBackground) {
[self.voiceMgr setLockScreenNowPlayingInfo];
}
}
}
else {
//獲取章節(jié)失敗則停止播放。
[MBProgressHUD showError:@"已停止播放"];
self.speechManager.isSpeech = NO;
[self.speechController.view removeFromSuperview];
[self.speechController removeFromParentViewController];
[self.voiceMgr setAudioSessionActive:NO];
}
}];
}
else {
if(self.voiceMgr.isSpeech) {
//重新設(shè)置播放內(nèi)容,再次播放
[self.voiceMgr setSpeechContent:content];
[self.voiceMgr beginSpeech];
}
}
}
到這里,切換章節(jié)繼續(xù)播放就完成了,接下來(lái)是處理同一章節(jié)里,翻頁(yè)的繼續(xù)播放。我希望的用戶體驗(yàn)是當(dāng)一頁(yè)內(nèi)容讀到最后一個(gè)字的時(shí)候,界面自動(dòng)變?yōu)橄乱豁?yè),并且頂部的文字顯示閱讀背景,且跟隨語(yǔ)音變化。
處理方案是在
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer willSpeakRangeOfSpeechString:(NSRange)characterRange utterance:(AVSpeechUtterance *)utterance
代理回調(diào)里處理。還是回到之前的controller里。
- (void)willSpeakRangeOfSpeechString:(NSRange)characterRange utterance:(AVSpeechUtterance *)utterance {
NSInteger diff = 0;
LZBookTextController *textController = (LZBookTextController *)self.currentViewController;
//readerPager是當(dāng)前頁(yè)的屬性,pageRange是當(dāng)前頁(yè)在章節(jié)內(nèi)容里的范圍。
//這個(gè)diff表示當(dāng)前閱讀的句子的位置是否已大于當(dāng)前頁(yè)的最大位置。
//如果即將讀的句子已經(jīng)比當(dāng)前頁(yè)碼的最大位置更大,則說(shuō)明需要翻頁(yè)了
diff = self.voiceOffset - textController.readerPager.pageRange.length;
if(diff >= 0) {
//這里進(jìn)行進(jìn)一步的檢測(cè),因?yàn)橛行┒温浜荛L(zhǎng),我們希望讀到最后一個(gè)字再翻頁(yè)。
if(utterance.speechString.length - diff <= characterRange.location + characterRange.length) {
if(self.voiceArr.count == 0) {
//這里處理和上面切章的代理沖突,沒(méi)有更多內(nèi)容則不執(zhí)行翻頁(yè)操作
return ;
}
if([self.voiceArr.firstObject isEqualToString:@""]) {
[self.voiceArr removeObjectAtIndex:0];
return;
}
//翻頁(yè)
@weakify(self)
[self resetContextCompletion:^(BOOL success) {
@strongify(self)
if(success) {
LZBookTextController *textController1 = (LZBookTextController *)self.currentViewController;
[textController1 addTextBackgroudColorWhthRange:NSMakeRange(0, diff)];
//翻頁(yè)完,更新閱讀的偏移值
self.voiceOffset = diff;
}
}];
}
}
}
基本的動(dòng)作已經(jīng)處理得差不多了,其實(shí)里面還有一些細(xì)節(jié)要處理,這里只是做一個(gè)參考哈。
第四步,后臺(tái)播放
這個(gè)東西網(wǎng)上的資料就很多了,我稍微介紹一下大致流程。
1.開啟后臺(tái)服務(wù)

2.注冊(cè)播放
//在APPdelegate回調(diào)里實(shí)現(xiàn)
- (void)applicationWillResignActive:(UIApplication *)application {
if([SpeechManager sharedInstance].isSpeech) {
//允許應(yīng)用程序接收遠(yuǎn)程控制
[[SpeechManager sharedInstance] setAudioSessionActive:YES];
[[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
[[SpeechManager sharedInstance] setLockScreenNowPlayingInfo];
}
}
//這個(gè)代碼如果只卸載APPdelegate里,靜音的情況下就播放不出來(lái)了,所以我開始播放的時(shí)候也調(diào)用了
- (void)setAudioSessionActive:(BOOL)active {
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:AVAudioSessionCategoryPlayback error:nil];
[session setActive:active error:nil];
}
3.設(shè)置鎖屏界面播放器內(nèi)容
先引入MediaPlayer.framework

然后設(shè)置具體內(nèi)容
- (void)setLockScreenNowPlayingInfo
{
//更新鎖屏?xí)r的歌曲信息
if (NSClassFromString(@"MPNowPlayingInfoCenter")) {
NSMutableDictionary *dict = [[NSMutableDictionary alloc] init];
[dict setObject:self.chapterName forKey:MPMediaItemPropertyTitle];
[dict setObject:self.author forKey:MPMediaItemPropertyArtist];
[dict setObject:self.bookName forKey:MPMediaItemPropertyAlbumTitle];
UIImage *newImage = self.coverImage;
[dict setObject:[[MPMediaItemArtwork alloc] initWithImage:newImage]
forKey:MPMediaItemPropertyArtwork];
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:dict];
}
}
這樣你鎖屏后,不解鎖就可以看到正在閱讀的內(nèi)容了

第五步 處理打斷。
這里說(shuō)兩種情況:一種是其他APP及電話造成的播放打斷,另一種是插拔耳機(jī)。
1.被其他APP或電話打斷,最新的做法是用通知中心實(shí)現(xiàn)。
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioSessionInterruptionNotification:) name:AVAudioSessionInterruptionNotification object:nil];
- (void)audioSessionInterruptionNotification:(NSNotification *)notification{
/*
監(jiān)聽(tīng)到的中斷事件通知,AVAudioSessionInterruptionOptionKey
typedef NS_ENUM(NSUInteger, AVAudioSessionInterruptionType)
{
AVAudioSessionInterruptionTypeBegan = 1, 中斷開始
AVAudioSessionInterruptionTypeEnded = 0, 中斷結(jié)束
}
*/
// int type = [notification.userInfo[AVAudioSessionInterruptionOptionKey] intValue];
// switch (type) {
// case AVAudioSessionInterruptionTypeBegan: // 被打斷
// {
// 暫停播放
// }
// break;
// case AVAudioSessionInterruptionTypeEnded: // 中斷結(jié)束
// {
// 繼續(xù)播放
// }
// break;
// default:
// break;
// }
}
2.插拔耳機(jī)時(shí)的操作。同樣添加通知。
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioSessionRouteChangeNotification:) name:AVAudioSessionRouteChangeNotification object:[AVAudioSession sharedInstance]];
- (void)audioSessionRouteChangeNotification:(NSNotification *)notification{
NSDictionary *dic=notification.userInfo;
int changeReason= [dic[AVAudioSessionRouteChangeReasonKey] intValue];
//等于AVAudioSessionRouteChangeReasonOldDeviceUnavailable表示舊輸出不可用
if (changeReason==AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
AVAudioSessionRouteDescription *routeDescription=dic[AVAudioSessionRouteChangePreviousRouteKey];
AVAudioSessionPortDescription *portDescription= [routeDescription.outputs firstObject];
//原設(shè)備為耳機(jī)則暫停
if ([portDescription.portType isEqualToString:@"Headphones"]) {
[self.toolBar pauseSpeechAction];
}
}
}
關(guān)于處理打斷,網(wǎng)上的資料很多,但我試了一下這樣寫效果最好。當(dāng)然大家也可以嘗試其他的方式。
總結(jié)
系統(tǒng)提供的API非常簡(jiǎn)單,我覺(jué)得難點(diǎn)還是在UI和語(yǔ)音之間的同步,我也是第一次做這個(gè)之前沒(méi)有找到合適的demo,希望這篇文章可以幫到大家。當(dāng)然我的實(shí)現(xiàn)思路不知道我也不知道好不好,如果有問(wèn)題或其他的方案希望可以分享給我一起交流。