iOS 閱讀器功能小記——語(yǔ)音朗讀(系統(tǒng))

吐槽兩句:

本來(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.
D473608B-5BD6-485D-9393-3293D8086FFA.png

簡(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ù)


DB5B8EA2-5F09-4492-8618-6731A2BAD749.png

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


D3298828-8458-4BAB-B783-D05C38931B7F.png

然后設(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)容了


82E58139DFE3FC93D50325A579B036AC.png
第五步 處理打斷。

這里說(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)題或其他的方案希望可以分享給我一起交流。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,812評(píng)論 25 709
  • github排名https://github.com/trending,github搜索:https://gith...
    小米君的demo閱讀 4,946評(píng)論 2 38
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,085評(píng)論 4 61
  • 從神話里尋找 太上老君將七月丟進(jìn)丹爐 所謂入伏 不過(guò)是入了爐 靠海的城市 也未能幸免 地圖上的河山 十之八九 都煉...
    一團(tuán)菌閱讀 391評(píng)論 2 6
  • 別人不清楚,難道我自己還不清楚嗎? 2017·7·27 下午 星期四 悶熱 今天不想去鋪?zhàn)由希幌肟吹讲幌矚g...
    星月新晨閱讀 316評(píng)論 0 2

友情鏈接更多精彩內(nèi)容