如何讓iOS推送播放語音?

本文字數:4293字
預計閱讀時間:33分鐘
一:背景
iOS 推送播放語音的需求調研,即收到推送后,播放推送的文案,文案的內容不固定。類似于支付寶和微信的收款到賬語音。
只有iOS10以上才支持app被喚醒后在后臺/鎖屏狀態(tài)下播放音頻。所以iOS10以下的設備,在收到VoIP Push后只能在local push上設定一段固定鈴聲,這也是為什么iOS10以下只有“微信支付收款到賬”。 iOS 12.0之前,后臺播放音頻未被限制,直接使用Notification Service Extension (iOS 10.0以后才支持) 功能使用系統(tǒng)提供的功能AVSpeechSynthesizer即可。 iOS 12.0之后,Notification Service Extension后臺播放音頻功能被限制,所以播放實現起來比較困難。 如果要上架商店,只有播放固定的音頻,或固定拼接的音頻,通過設置通知的聲音或者發(fā)送本地通知設置本地通知的聲音來播放。 如果無需上架商店,可以手動打開Notification Service Extension的后臺播放。
二:開發(fā)過程
a. Notification Service Extension

項目添加了Notification Service Extension之后的邏輯,和沒添加之前有所不同。如下圖:添加了之后,接受到推送時,會觸發(fā)Notification Service Extension中的方法,在這個方法中,可以修改推送的標題、內容、聲音。然后把修改后的推送展示出來。
通知欄的生命周期:
從通知叮一下展示(觸發(fā)代碼:self.contentHandler(self.bestAttemptContent);)出來到通知被收起(系統(tǒng)控制),大概有6秒左右的時間。 如果收到通知后,沒有呼出通知欄,最多30s系統(tǒng)會調用serviceExtensionTimeWillExpire方法中的self.contentHandler(self.bestAttemptContent)來呼出通知欄。
要注意的是,Notification Service Extension和主項目不是同一個Target,所以主項目的文件和這個Target文件是不共享的。
創(chuàng)建新文件的時候要注意勾選要添加到的Target 比如添加推送播放語音的類,需要勾選到Notification Service Extension Target下; 拷貝播放語音的第三方SDK,需要勾選到Notification Service Extension Target下; 在第三方平臺創(chuàng)建新應用時,要填寫的bundleID也應該是Notification Service Extension Target對應的bundleID。,這點尤其要注意,因為百度的測試賬號離線SDK的添加只能添加一次,錯了的話,就要用新的賬號再去注冊,血淚的教訓,??。 bundle目錄的訪問也不是同一個,可以通過App Group共享數據。 打開后臺播放時,其實也應該是Notification Service Extension Target下的后臺播放,這個后面詳細說明。
創(chuàng)建步驟如下:
創(chuàng)建Notificaiton Service Extension Target,選中Xcode項目,點擊File -> New -> Target,選中Notification Service Extension Target。有兩個很相似的,注意選對,如下圖:

點擊Next,輸入Product Name

點擊完成,點擊Activate

打開NotificationService.m中的文件,這個類就是Notificaiton Service Extension添加后自動創(chuàng)建的類,添加了之后,接受到推送的處理都可以在這個位置修改
其中修改推送鈴聲時要注意: 多條推送處理的問題,在didReceiveNotificationRequest:withContentHandler:方法中調用self.contentHandler(self.bestAttemptContent);,即會展示對應的通知,如果不調用此方法,最多30s系統(tǒng)會自動調用此方法,假設一次性來了10條通知,會發(fā)現,通知并沒有彈出10次,也沒有按順序一次次展示,所以多條推送如果沒有處理,播放語音時就會出現問題。 語音的文件類型:自定義鈴聲支持的聲音格式包括,aiff、wav以及wav格式,鈴聲的長度必須小于30s,否則系統(tǒng)會播放默認的鈴聲。 音頻文件存儲的目錄和讀取的優(yōu)先級,主應用中的Library/Sounds文件夾中、AppGroups共享目錄中的Library/Sounds文件夾中、main bundle 在系統(tǒng)播放類AVSpeechSynthesizer的代理方法中,有播放完成的回掉speechSynthesizer:didFinishSpeechUtterance:,把呼出通知欄的代碼self.contentHandler(self.bestAttemptContent)從didReceiveNotificationRequest:withContentHandler:方法中,移到播放完成的回掉方法中調用,即可保證語音按順序一條條展示。(或者添加到數組或著OperationQueue中,播放完成繼續(xù)下一條) didReceiveNotificationRequest:withContentHandler:方法,其中的bestAttemptContent中的userInfo即包含了推送的詳細信息。如果想要修改展示的標題和內容或者推送的語音,都在這個方法最后回掉前操作,
@interface NotificationService ()
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
@end
@implementation NotificationService
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
// Modify the notification content here...
// 修改推送的標題
// self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
// 修改推送的聲音,自定義鈴聲支持的聲音格式包括,aiff、wav以及wav格式,鈴聲的長度必須小于30s,否則系統(tǒng)會播放默認的鈴聲。
// self.bestAttemptContent.sound = [UNNotificationSound soundNamed:@"a.wav"];
// 播放處理
[self playVoiceWithInfo:self.bestAttemptContent.userInfo];
self.contentHandler(self.bestAttemptContent);
}
- (void)serviceExtensionTimeWillExpire {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
self.contentHandler(self.bestAttemptContent);
}
- (void)playVoiceWithInfo:(NSDictionary *)userInfo {
NSLog(@"NotificationExtension content : %@",userInfo);
NSString *title = userInfo[@"aps"][@"alert"][@"title"];
NSString *subTitle = userInfo[@"aps"][@"alert"][@"subtitle"];
NSString *subMessage = userInfo[@"aps"][@"alert"][@"body"];
NSString *isRead = userInfo[@"isRead"];
NSString *isUseBaiDu = userInfo[@"isBaiDu"];
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionDuckOthers error:nil];
[[AVAudioSession sharedInstance] setActive:YES
withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation
error:nil];
// Ps: 下面代碼示例并沒有多條播放的處理,還請注意
if ([isRead isEqual:@"1"]) {
// 播放語音
if ([isUseBaiDu isEqual:@"1"]) {
// 使用百度離線語音播放
[[BaiDuTtsUtils shared] playBaiDuTTSVoiceWithContent:title];
}
else {
// 使用系統(tǒng)語音播放
[[AppleTtsUtils shared] playAppleTTSVoiceWithContent:title];
}
}
else {
// 無需播放語音
}
}
@end
其中AppleTtsUtils中實現如下,大致就是使用AVSpeechSynthesizer直接播放,設置音量和語速,需要注意的是,
音量的設置 靜音時是不會播放的 實際播放的音量大小=設置的音量大小*系統(tǒng)音量的大小。所以即使設置了大音量,但是系統(tǒng)音量很小,播放的聲音也很小。(比如系統(tǒng)volume是0.5,AVAudioPlayer的音量是0.6,則最終的音量為0.5*0.6 =0.3)。解決方案是:最終的解決方案借鑒了進入收付款展示二維碼時自動調節(jié)屏幕亮度的方案:如果屏幕亮度未達到閾值,則調高屏幕亮度到閾值,離開頁面時,將亮度設回原亮度。同理,播放提示音時,若用戶設置的系統(tǒng)音量小于閾值,則調節(jié)到閾值。提示音播放完畢后,將提示音調回原音量,大致意思是: 數字的處理 數字轉語音,采用zh-CN的voice后,數字的播放方式是幾萬幾千幾百幾十幾這種,可采用數字后面拼接空格的方式來處理;遍歷內容的每一個字符串,如果是數字,則拼接一個空格到后面,最后播放時數字就會一個個讀出來。
#import "AppleTtsUtils.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
@interface AppleTtsUtils ()<AVSpeechSynthesizerDelegate>
@property (nonatomic, strong) AVSpeechSynthesizer *speechSynthesizer;
@property (nonatomic, strong) AVSpeechSynthesisVoice *speechSynthesisVoice;
@end
@implementation AppleTtsUtils
+ (instancetype)shared {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self class] new];
});
return instance;
}
- (BOOL)isNumber:(NSString *)str
{
if (str.length == 0) {
return NO;
}
NSString *regex = @"[0-9]*";
NSPredicate *pred = [NSPredicate predicateWithFormat:@"SELF MATCHES %@",regex];
if ([pred evaluateWithObject:str]) {
return YES;
}
return NO;
}
- (void)playAppleTtsVoiceWithContent:(NSString *)content {
if ((content == nil) || (content.length <= 0)) {
return;
}
// 數字轉語音,采用zh-CN的voice后,數字的播放方式是幾萬幾千幾百幾十幾這種,故而采用數字后面拼接空格的方式來處理;遍歷內容的每一個字符串,如果是數字,則拼接一個空格到后面,最后播放時數字就會一個個讀出來。
NSString *newResult = @"";
for (int i = 0; i < content.length; i++) {
NSString *tempStr = [content substringWithRange:NSMakeRange(i, 1)];
newResult = [newResult stringByAppendingString:tempStr];
if ([self deptNumInputShouldNumber:tempStr] ) {
newResult = [newResult stringByAppendingString:@" "];
}
}
// Todo: 英文轉語音
AVSpeechUtterance *utterance = [AVSpeechUtterance speechUtteranceWithString:newResult];
utterance.rate = AVSpeechUtteranceDefaultSpeechRate;
utterance.voice = self.speechSynthesisVoice;
utterance.volume = 1.0;
utterance.rate = AVSpeechUtteranceDefaultSpeechRate;
[self.speechSynthesizer speakUtterance:utterance];
}
- (AVSpeechSynthesizer *)speechSynthesizer {
if (!_speechSynthesizer) {
_speechSynthesizer = [[AVSpeechSynthesizer alloc] init];
_speechSynthesizer.delegate = self;
}
return _speechSynthesizer;
}
- (AVSpeechSynthesisVoice *)speechSynthesisVoice {
if (!_speechSynthesisVoice) {
_speechSynthesisVoice = [AVSpeechSynthesisVoice voiceWithLanguage:@"zh-CN"];
}
return _speechSynthesisVoice;
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didStartSpeechUtterance:(AVSpeechUtterance *)utterance {
NSLog(@"didStartSpeechUtterance");
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didCancelSpeechUtterance:(AVSpeechUtterance *)utterance {
NSLog(@"didCancelSpeechUtterance");
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didPauseSpeechUtterance:(AVSpeechUtterance *)utterance {
NSLog(@"didPauseSpeechUtterance");
}
- (void)speechSynthesizer:(AVSpeechSynthesizer *)synthesizer didFinishSpeechUtterance:(AVSpeechUtterance *)utterance {
NSLog(@"didFinishSpeechUtterance");
[self.speechSynthesizer stopSpeakingAtBoundary:AVSpeechBoundaryWord];
// // 每一條語音播放完成后,我們調用此代碼,用來呼出通知欄
// 可通過Block回掉暴露給上層
// self.contentHandler(self.bestAttemptContent);
}
b. 百度TTS離線SDK添加
打開百度智能控制臺,選中應用列表,創(chuàng)建新的要測試的應用,創(chuàng)建后會有,這里bundleId要寫創(chuàng)建的對應的Notification Service Extension的bundleId,而不是主項目的bundleId,一定要注意!!!如下圖

左側選中離線SDK管理,點擊添加,然后選中剛剛創(chuàng)建的應用,點擊完成后,點擊下載序列號列表,然后把AppId、AppKey、SecretKey、以及序列號存儲,用于初始化離線SDK。如下圖

左側選中離線SDK管理時,點擊右邊的下載SDK,以及開發(fā)文檔,按照SDK的說法
?
集成指南: 強烈建議用戶首先運行SDK包中的Demo工程,Demo工程中詳細說明了語音合成的使用方法,并提供了完整的示例。一般情況下,您只需參照demo工程即可完成所有的集成和配置工作。
所以,把SDK下載好了之后,打開BDSClientSample項目,然后把TTSViewController.mm文件中的APP_ID、API_KEY、SECRET_KEY和SN改為剛剛申請的,然后運行測試,看能否正常播放語音,播放成功說明申請的沒有問題,就可以繼續(xù)往項目中集成,要不然,集成到項目中發(fā)現不播放,會懷疑是SDK的問題。??,以為集成后調試確實很容易讓人懷疑人生。
把SDK解壓后的BDSClientHeaders、BDSClientLib、BDSClientResource文件夾拖拽到Notification Service Extension的target下,注意勾選copy選項,然后把BDSClientLib文件夾下的.gitignore刪除,要不然編譯會失敗,真的,不騙人,??,踩坑指南

添加依賴的系統(tǒng)庫,參考BDSClientSample項目中的依賴,注意添加到Notification Service Extension的target下,如下圖:

done,編譯Notification Service Extension的target,注意選對target,噢噢,這個地方還有個問題,新創(chuàng)建的target是根據Xcode的版本來的,所以還需要修改一下這個target兼容的最低target,要不然默認可能是14.4,然后運行調試不報錯,能正常運行,但是斷點不走,驚不驚喜,??。

添加百度語音處理代碼到Notification Service Extension的target下,如上面寫的,BaiDuTtsUtils代碼如下
這里要注意的是, configureOfflineTTS方法中,offlineSpeechData和offlineTextData資源的加載,默認和Demo中寫的一致即可,其實是BDSClientResource文件夾下TTS文件夾中的內容,如果下載的有別的語音文件,這里就加載自己下載的語音文件。
#import "BaiDuTtsUtils.h"
#import "BDSSpeechSynthesizer.h"
// 百度TTS
NSString* BaiDuTTSAPP_ID = @"Your_APP_ID";
NSString* BaiDuTTSAPI_KEY = @"Your_APP_KEY";
NSString* BaiDuTTSSECRET_KEY = @"Your_SECRET_KEY";
NSString* BaiDuTTSSN = @"Your_SN";
@interface BaiDuTtsUtils ()<BDSSpeechSynthesizerDelegate>
@end
@implementation BaiDuTtsUtils
+ (instancetype)shared {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self class] new];
});
return instance;
}
#pragma mark - baidu tts
-(void)configureOfflineTTS{
NSError *err = nil;
NSString* offlineSpeechData = [[NSBundle mainBundle] pathForResource:@"bd_etts_common_speech_m15_mand_eng_high_am-mgc_v3.6.0_20190117" ofType:@"dat"];
NSString* offlineTextData = [[NSBundle mainBundle] pathForResource:@"bd_etts_common_text_txt_all_mand_eng_middle_big_v3.4.2_20210319" ofType:@"dat"];
// #error "set offline engine license"
if (offlineSpeechData == nil || offlineTextData == nil) {
NSLog(@"離線合成 資源文件為空!");
return;
}
err = [[BDSSpeechSynthesizer sharedInstance] loadOfflineEngine:offlineTextData speechDataPath:offlineSpeechData licenseFilePath:nil withAppCode:BaiDuTTSAPP_ID withSn:BaiDuTTSSN];
if(err){
NSLog(@"Offline TTS init failed");
return;
}
}
- (void)playBaiDuTTSVoiceWithContent:(NSString *)voiceText {
NSLog(@"TTS version info: %@", [BDSSpeechSynthesizer version]);
[BDSSpeechSynthesizer setLogLevel:BDS_PUBLIC_LOG_VERBOSE];
// 設置委托對象
[[BDSSpeechSynthesizer sharedInstance] setSynthesizerDelegate:self];
[self configureOfflineTTS];
[[BDSSpeechSynthesizer sharedInstance] setPlayerVolume:10];
[[BDSSpeechSynthesizer sharedInstance] setSynthParam:[NSNumber numberWithInteger:5] forKey:BDS_SYNTHESIZER_PARAM_SPEED];
// 開始合成并播放
NSError* speakError = nil;
NSInteger sentenceID = [[BDSSpeechSynthesizer sharedInstance] speakSentence:voiceText withError:&speakError];
if (speakError) {
NSLog(@"錯誤: %ld, %@", (long)speakError.code, speakError.localizedDescription);
}
}
- (void)synthesizerStartWorkingSentence:(NSInteger)SynthesizeSentence
{
NSLog(@"Began synthesizing sentence %ld", (long)SynthesizeSentence);
}
- (void)synthesizerFinishWorkingSentence:(NSInteger)SynthesizeSentence
{
NSLog(@"Finished synthesizing sentence %ld", (long)SynthesizeSentence);
}
- (void)synthesizerSpeechStartSentence:(NSInteger)SpeakSentence
{
NSLog(@"Began playing sentence %ld", (long)SpeakSentence);
}
- (void)synthesizerSpeechEndSentence:(NSInteger)SpeakSentence
{
NSLog(@"Finished playing sentence %ld", (long)SpeakSentence);
}
@end
c. 調試
刺激的部分來了,上面都編譯通過了沒問題,使用推送調試,先運行一次主項目,然后選中Notification Service Extension Target運行,didReceiveNotificationRequest:withContentHandler:方法中添加斷點,,給自己推送消息,會發(fā)現斷點走到了這里,說明target的創(chuàng)建沒有問題。
然后控制推送參數的,isRead和isBaiDu參數,決定推送過來的語音是否走百度的語音播放。噢,說到推送參數,這個地方還需要在payload推送參數中添加"mutable-content = 1"字段,eg:
{
"aps": {
"alert": {
"title":"標題",
"subtitle: "副標題",
"body": "內容"
},
"badge": 1,
"sound": "default",
"mutable-content": "1",
}
}
推送調試,會發(fā)現運行正常,但是語音沒有播放,不管是系統(tǒng)的還是百度的,哈哈哈,崩潰不。仔細看控制臺,會發(fā)現,報錯如下
Ps: iOS 12.0之后,在Notification Service Extension調用系統(tǒng)播放AVSpeechSynthesizer時報的錯誤。
[AXTTSCommon] Failure starting audio queue alp!
[AXTTSCommon] _BeginSpeaking: couldn't begin playback
Ps: iOS 12.0之后,在Notification Service Extension調用百度的SDK直接播放時報的錯誤。
[ERROR][AudioBufPlayer.mm:1088]AudioQueue start errored error: 561015905 (!pla)
[ERROR][AudioBufPlayer.mm:1099]Can't begin playback while in background!
都是一個意思,即不能在后臺播放音頻。怎么解決呢,當然是添加backgroundMode字段了,打開主工程的Signing&Capabilities,添加backgrondModes,勾選Audio, Airplay, and Picture in Picture,如下圖


OK,try again! 再次推送,會發(fā)現————還是不行,同樣的報錯,哈哈哈,絕望不,不好意思,我收斂一下,這個地方其實添加的沒錯,只不過要注意
?
在Notification Service Extension配置了之后,發(fā)現收到通知后還是不會播放聲音,在這個Extension的Target下打開plist,添加Required background modes字段,里面item0寫上App plays audio or streams audio/video using AirPlay后,再次調試,發(fā)現百度的語音即可播放。 這種方式審核時不被通過,因為這個Extension的target其實是沒有backgroundMode的設置的,從Signing&Capabilities中可以看出,直接添加backgroundMode是沒有的。故而如果不是上線到蘋果商店的,只是公司內部分發(fā),可以用這種方式。
添加了之后,再次推送,就會發(fā)現百度的語音就可以播放了,而且數字和英文、中文播放都十分完美,除了價格有些感人,其他的沒毛病。而系統(tǒng)的播放語音,如果先推送系統(tǒng)的,會發(fā)現不能播放,還是同樣的報錯;但是如果先推送了走百度的,百度播放了之后,再推送系統(tǒng)的,就會發(fā)現系統(tǒng)的也能播報,但是系統(tǒng)播報的英文和數字會有問題,記得處理,可以聽一下英文字母E的發(fā)音,發(fā)音額。。。解決方案——暫無,還沒找到,建議走第三方合成的語音。
由于項目不需要上線商店,所以到這里其實就結束了。但是對于上線到商店到應用來說,這種處理方法是不行的,上線到商店的應用其實只有播放固定格式的音頻一種解決方法,即替換推送的聲音。使用固定格式的音頻、或者固定格式的合成音頻替換掉推送的聲音,或者采用遠程推送靜音,發(fā)送多個本地通知,各個本地通知的聲音替換掉這種方法。這些是從末尾的參考中得到的啟示。
三、結論
直接上圖,整理后的思維導圖如下,大部分比較復雜的處理邏輯其實是iOS 12.0之后的處理。

引用
iOS 語音播報解決方案(實現支付寶/微信語音收款提示功能) iOS極光推送+語音播報(支付寶收款播報) 百度離線合成iOS-SDK集成文檔 百度智能控制臺 iOS12.1之后語音播報問題解決,以及對Notification Service Extension的一些探索 iOS12.1使用百度語音無法播報 微信iOS收款到賬語音提醒開發(fā)總結 iOS13微信收款到賬語音提醒開發(fā)總結
也許你還想看
(▼點擊文章標題或封面查看)
2021-04-15
2021-04-08
2021-03-18
2021-01-07

