今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

如何讓iOS推送播放語音

本文發表在《搜狐技術產品》公眾號如何讓 iOS 推送播放語音

iOS 推送播放語音#

一:背景#

iOS 推送播放語音的需求調研,即收到推送後,播放推送的文案,文案的內容不固定。類似於支付寶和微信的收款到账語音。

二:開發過程#

a. Notification Service Extension#

1342050-74642172d12a47b5.png

項目添加了 Notification Service Extension 之後的邏輯,和沒添加之前有所不同。如下圖:
添加了之後,接受到推送時,會觸發 Notification Service Extension 中的方法,在這個方法中,可以修改推送的標題、內容、聲音。然後把修改後的推送展示出來。

通知欄的生命周期:

  • 從通知叮一下展示(觸發代碼:self.contentHandler (self.bestAttemptContent);)出來到通知被收起(系統控制),大概有 6 秒左右的時間。
  • 如果收到通知後,沒有呼出通知欄,最多 30s 系統會調用 serviceExtensionTimeWillExpire 方法中的 self.contentHandler (self.bestAttemptContent) 來呼出通知欄。

要注意的是,Notification Service Extension 和主項目不是同一個 Target,所以主項目的文件和這個 Target 文件是不共享的。

  • 創建新文件的時候要注意勾選要添加到的 Target
    • 比如添加推送播放語音的類,需要勾選到 Notification Service Extension Target 下;
    • 拷貝播放語音的第三方 SDK,需要勾選到 Notification Service Extension Target 下;
    • 在第三方平台創建新應用時,要填寫的 bundleID 也應該是 Notification Service Extension Target 對應的 bundleID。,這點尤其要注意,因為百度的測試賬號離線 SDK 的添加只能添加一次,錯了的話,就要用新的賬號再去註冊,血淚的教訓,😂。
  • bundle 目錄的訪問也不是同一個,可以通過 App Group 共享數據。
  • 打開背景播放時,其實也應該是 Notification Service Extension Target 下的背景播放,這個後面詳細說明。

創建步驟如下:

  • 創建 Notificaiton Service Extension Target,選中 Xcode 項目,點擊 File -> New -> Target,選中 Notification Service Extension Target。有兩個很相似的,注意選對,如下圖:
    截屏 2021-04-13 下午 3.01.00.png

  • 點擊 Next,輸入 Product Name
    截屏 2021-04-13 下午 3.05.43.png

  • 點擊完成,點擊 Activate
    截屏 2021-04-13 下午 3.05.51.png

  • 打開 NotificationService.m 中的文件,這個類就是 Notificaiton Service Extension 添加後自動創建的類,添加了之後,接受到推送的處理都可以在這個位置修改

@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,否則系統會播放默認的鈴聲。
    //    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 {
            // 使用系統語音播放
            [[AppleTtsUtils shared] playAppleTTSVoiceWithContent:title];
        }
    }
    else {
        // 無需播放語音
    }

}

@end

其中 AppleTtsUtils 中實現如下,大致就是使用 AVSpeechSynthesizer 直接播放,設置音量和語速,需要注意的是,

#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 添加#

  1. 打開百度智能控制台,選中應用列表,創建新的要測試的應用,創建後會有,這裡 bundleId 要寫創建的對應的 Notification Service Extension 的 bundleId,而不是主項目的 bundleId,一定要注意!!!如下圖

    1618303510485.jpg

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

    1618303458956.jpg

  3. 左側選中離線 SDK 管理時,點擊右邊的下載 SDK,以及開發文檔,按照 SDK 的說法

    集成指南:強烈建議用戶首先運行 SDK 包中的 Demo 工程,Demo 工程中詳細說明了語音合成的使用方法,並提供了完整的示例。一般情況下,您只需參照 demo 工程即可完成所有的集成和配置工作。

  4. 所以,把 SDK 下載好了之後,打開 BDSClientSample 項目,然後把 TTSViewController.mm 文件中的 APP_ID、API_KEY、SECRET_KEY 和 SN 改為剛剛申請的,然後運行測試,看能否正常播放語音,播放成功說明申請的沒有問題,就可以繼續往項目中集成,要不然,集成到項目中發現不播放,會懷疑是 SDK 的問題。😂,以為集成後調試確實很容易讓人懷疑人生。

  5. 把 SDK 解壓後的 BDSClientHeaders、BDSClientLib、BDSClientResource 文件夾拖拽到 Notification Service Extension 的 target 下,注意勾選 copy 選項,然後把 BDSClientLib 文件夾下的.gitignore 刪除,要不然編譯會失敗,真的,不騙人,😂,踩坑指南
    1618304109702.jpg

  6. 添加依賴的系統庫,參考 BDSClientSample 項目中的依賴,注意添加到 Notification Service Extension 的 target 下,如下圖:
    1618304468870.jpg

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

  8. 添加百度語音處理代碼到 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: 方法中添加斷點,,給自己推送消息,會發現斷點走到了這裡,說明 target 的創建沒有問題。

然後控制推送參數的,isRead 和 isBaiDu 參數,決定推送過來的語音是否走百度的語音播放。噢,說到推送參數,這個地方還需要在 payload 推送參數中添加 "mutable-content = 1" 字段,eg:

{
  "aps": {
  "alert": {
      "title":"標題",
      "subtitle: "副標題",
      "body": "內容"
  },
  "badge": 1,
  "sound": "default",
  "mutable-content": "1",
  }
}

推送調試,會發現運行正常,但是語音沒有播放,不管是系統的還是百度的,哈哈哈,崩潰不。仔細看控制台,會發現,報錯如下

Ps: iOS 12.0 之後,在 Notification Service Extension 調用系統播放 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,如下圖
1618306139128.jpg
1618306179927.jpg

OK,try again! 再次推送,會發現 ———— 還是不行,同樣的報錯,哈哈哈,絕望不,不好意思,我收斂一下,這個地方其實添加的沒錯,只不過要注意

  1. 在 Notification Service Extension 配置了之後,發現收到通知後還是不會播放聲音,在這個 Extension 的 Target 下打開 plist,添加 Required background modes 字段,裡面 item0 寫上 App plays audio or streams audio/video using AirPlay 後,再次調試,發現百度的語音即可播放。
  2. 這種方式審核時不被通過,因為這個 Extension 的 target 其實是沒有 backgroundMode 的設置的,從 Signing&Capabilities 中可以看出,直接添加 backgroundMode 是沒有的。故而如果不是上線到蘋果商店的,只是公司內部分發,可以用這種方式。

添加了之後,再次推送,就會發現百度的語音就可以播放了,而且數字和英文、中文播放都十分完美,除了價格有些感人,其他的沒毛病。
而系統的播放語音,如果先推送系統的,會發現不能播放,還是同樣的報錯;但是如果先推送了走百度的,百度播放了之後,再推送系統的,就會發現系統的也能播報,但是系統播報的英文和數字會有問題,記得處理,可以聽一下英文字母 E 的發音,發音額。。。解決方案 —— 暫無,還沒找到,建議走第三方合成的語音。

由於項目不需要上線商店,所以到這裡其實就結束了。但是對於上線到商店到應用來說,這種處理方法是不行的,上線到商店的應用其實只有播放固定格式的音頻一種解決方法,即替換推送的聲音。使用固定格式的音頻、或者固定格式的合成音頻替換掉推送的聲音,或者採用遠程推送靜音,發送多個本地通知,各個本地通知的聲音替換掉這種方法。這些是從末尾的參考中得到的啟示。

三、結論#

直接上圖,整理後的思維導圖如下,大部分比較複雜的處理邏輯其實是 iOS 12.0 之後的處理。
推送播放語音.png

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。