推送语音播报--简单归纳

前言:在iOS 12.01之后AVSpeechSynthesisVoice(文字转语音)已经不可用,所以只能使用修改推送中的sound属性来实现播报

可供参考的实现方法:
  1. 将所有需要的播报场景音频文件放入工程main bundle,然后根据需要播报对影文件。
  • 该方法适用于播报场景少的情况
  1. 使用三方离线语音合成或在线合成sdk(如百度、讯飞等),对要合成的文本进行转换,然后设置到sound属性中
  • 需要开App Groups,将合成的音频文件保存到Groups对应的Library/Sounds目录下
  • 需付费
  1. 由后台合成,然后根据通过推送进行下载,然后设置到sound上
  • 需要开App Groups,将合成的音频文件保存到Groups对应的Library/Sounds目录下
  • 参考文章
  1. 本地音频文件组合(如将‘您的的支付宝到账100.65元’,由‘您的的支付宝到账’、‘98’和‘点六五元’三个文件组合而成)
  • 需要开App Groups,将合成的音频文件保存到Groups对应的Library/Sounds目录下
  • 需要设置通知应用扩展程序的在info.plist使用后台模式, 不设置回报如下错误
  • ⚠️上面这条是苹果不允许的,所以这只适合不走App Store的情况
  • 参考
// 错误
Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16980), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x10550bba0 {Error Domain=NSOSStatusErrorDomain Code=-16980 "(null)"}}
  • 3和4的参考代码
#import "NotificationService.h"
#import <AVFoundation/AVFoundation.h>

/// 播报类型
typedef enum : NSUInteger {
    ///  默认,不播报
    TLSpeakerTypeNone = 0,
    /// 收到一笔预订订单,请及时处理
    TLSpeakerTypeOrderOfBook,
    /// 收到一笔外卖订单,请及时处理
    TLSpeakerTypeOrderOfTakeaway,
    /// 收到一笔报餐订单,请及时处理
    TLSpeakerTypeOrderOfReport,
    /// 收到微信付款**元
    TLSpeakerTypePaymentOfWechat,
    /// 收到支付宝付款**元
    TLSpeakerTypePaymentOfAli,
} TLSpeakerType;


@interface NotificationService ()
/// 当前播报类型
@property(nonatomic, assign) TLSpeakerType type;
/// 通知内容
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
/// 通知处理完后的回调
@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
/// 下载task
@property(nonatomic, strong) NSURLSessionDownloadTask *task;
@end

@implementation NotificationService

/// 收到通知后的处理
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];
    
    NSDictionary *userInfo = self.bestAttemptContent.userInfo;
    
    NSInteger type = [userInfo[@"type"] integerValue];
    CGFloat value = 0.f;
    if (type <= TLSpeakerTypePaymentOfAli) {
        self.type = type;
        value = [userInfo[@"amount"] floatValue];
    }
    [self setNotificationSoundWithType:self.type
                                 value:value
                   notificationContent:self.bestAttemptContent
            notificationContentHandler:contentHandler];
    
    //    [self loadWavWithUrl:@"https://test.iyouxin.com/order_pic/abcd.mp3"];
}

/// 处理即将过期,进行默认处理
- (void)serviceExtensionTimeWillExpire {
    NSString *soundName = nil;
    switch (self.type) {
        case TLSpeakerTypeOrderOfBook:
            soundName = @"order_book.mp3";
            break;
        case TLSpeakerTypeOrderOfTakeaway:
            soundName = @"order_takeaway.mp3";
            break;
        case TLSpeakerTypeOrderOfReport:
            soundName = @"order_report.mp3";
            break;
        case TLSpeakerTypePaymentOfWechat:
            soundName = @"wx_normal.mp3";
            break;
        case TLSpeakerTypePaymentOfAli:
            soundName = @"ali_normal.mp3";
            break;
        default:
            break;
    }
    if (soundName) {
        self.bestAttemptContent.sound = [UNNotificationSound soundNamed:soundName];
    }
    self.contentHandler(self.bestAttemptContent);
}

/// 最大播报金额
#define kMaxValue 100

/// 通知拦截处理
- (void)setNotificationSoundWithType:(TLSpeakerType)type
                               value:(CGFloat )value
                 notificationContent:(UNMutableNotificationContent *)notificationContent
          notificationContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    
    if (type < TLSpeakerTypePaymentOfWechat) {
        [self serviceExtensionTimeWillExpire];
        return;
    }
    
    // > kMaxValue
    if (value > kMaxValue) {
        NSString *soundName = type == TLSpeakerTypePaymentOfWechat ? @"wx_normal.mp3" : @"ali_normal.mp3";
        if (soundName) {
            notificationContent.sound = [UNNotificationSound soundNamed:soundName];
        }
        self.contentHandler(notificationContent);
        return;
    }
    
    // <= kMaxValue
    NSArray *sourceURLs = [self paymentSoundSourcesWithType:type value:value];
    NSString *path = self.filePath;
    NSString *soundName = @"synthetic_sound.m4a";
    NSString *toPath = [path stringByAppendingPathComponent:[NSString stringWithFormat:@"%@", soundName]];
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if ([fileManager fileExistsAtPath:toPath]) {
        // 移除旧的文件
        [fileManager removeItemAtPath:toPath error:nil];
    }
    
    NSURL *outputURL = [NSURL fileURLWithPath:toPath];
    [self sourceURLs:sourceURLs composeToURL:outputURL completed:^(NSError *error) {
         if (error) {
             [self serviceExtensionTimeWillExpire];
         }else{
             notificationContent.sound = [UNNotificationSound soundNamed:soundName];
             self.contentHandler(notificationContent);
         }
    }];
}

// 获取小于kMaxValue的付款音频文件URL集合,为合并做准备
- (NSArray <NSURL *>*)paymentSoundSourcesWithType:(TLSpeakerType)type value:(CGFloat)value {
    NSMutableArray *urls = [NSMutableArray array];
    NSString *fileName1 = type == TLSpeakerTypePaymentOfWechat ? @"payment_wechat" : @"payment_ali";
    NSURL *url = [self urlWithFileName:fileName1];
    if (url) {
        [urls addObject:url];
    }
    
    NSInteger num = @(value).integerValue;
    NSString *fileName2 = @(num).stringValue;
    url = [self urlWithFileName:fileName2];
    if (url) {
        [urls addObject:url];
    }
    
    NSString *number = [self twoDecimalsWithNum:@(value).stringValue];
    NSInteger decimals = [[number componentsSeparatedByString:@"."].lastObject integerValue];
    NSString *fileName3 = decimals <= 0 ? @"元" : [NSString stringWithFormat:@"点%02zi元", decimals];
    url = [self urlWithFileName:fileName3];
    if (url) {
        [urls addObject:url];
    }
    return urls;
}

// MARK: - 合并音频文件
/// 合并音频文件
/// @param sourceURLs 需要合并的多个音频文件
/// @param outputURL  合并后音频文件的临时存放地址
/// 注意:导出的文件是:m4a格式的.
- (void)sourceURLs:(NSArray *)sourceURLs
      composeToURL:(NSURL *)outputURL
         completed:(void (^)(NSError *error))completed {

    if (sourceURLs.count < 1) {
        dispatch_async(dispatch_get_main_queue(), ^{
            NSString *domain = @"合并音频文件";
            NSDictionary *userInfo = @{NSLocalizedDescriptionKey : @"源文件不足两个无需合并"};
            NSError *error = [NSError errorWithDomain:domain code:-101 userInfo:userInfo];
            completed(error);
        });
        return;
    }

    // 合并所有的录音文件
    AVMutableComposition *mixComposition = [AVMutableComposition composition];

    // 音频插入的开始时间
    CMTime beginTime = kCMTimeZero;
    // 获取音频合并音轨
    AVMutableCompositionTrack *audioTrack = nil;
    audioTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio
                                             preferredTrackID:kCMPersistentTrackID_Invalid];

    // 用于记录错误的对象
    NSError *error = nil;
    CGFloat i = 1;
    for (NSURL *sourceURL in sourceURLs) {
        // 音频文件资源
        AVURLAsset  *audioAsset = [[AVURLAsset alloc] initWithURL:sourceURL options:nil];
        // 需要合并的音频文件的区间
        CMTimeValue value = audioAsset.duration.value * i;
        CMTimeScale timescale = audioAsset.duration.timescale;
        CMTimeRange timeRange = CMTimeRangeMake(CMTimeMake(value * (1 - i) * 0.5 , timescale),
                                                CMTimeMake(value , timescale));
        i = 0.8;
        AVAssetTrack *track = [[audioAsset tracksWithMediaType:AVMediaTypeAudio] objectAtIndex:0];
        // 参数说明:
        // insertTimeRange:源录音文件的的区间
        // ofTrack:插入音频的内容
        // atTime:源音频插入到目标文件开始时间
        // error: 插入失败记录错误
        // 返回:YES表示插入成功,`NO`表示插入失败
        BOOL success = [audioTrack insertTimeRange:timeRange
                                           ofTrack:track
                                            atTime:beginTime
                                             error:&error];
#if DEBUG
        // 如果插入失败,打印插入失败信息
        if (!success) {
            NSLog(@"插入音频失败: %@",error);
        }
#endif
        // 下条记录开始时间
        beginTime = CMTimeAdd(beginTime, CMTimeMake(value, timescale));
    }

    // 创建一个导入M4A格式的音频的导出对象
    NSString *presetName = AVAssetExportPresetAppleM4A;
    AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:mixComposition
                                                                           presetName:presetName];
    exportSession.outputURL = outputURL; // 导入音视频的URL
    exportSession.outputFileType = AVFileTypeAppleM4A;  // 导出音视频的文件格式
    exportSession.shouldOptimizeForNetworkUse = YES;
    
    // ⚠️需要在OusiCanteenNotification中的info.plist中设置后台模式 (违规操作,不可上AppStore)
    [exportSession exportAsynchronouslyWithCompletionHandler:^{
        completed(exportSession.error);
        
#if DEBUG
        if (exportSession.status == AVAssetExportSessionStatusCompleted) {
            NSLog(@"语音文件合并成功");
        }else {
            NSLog(@"语音文件合并失败: %@", exportSession.error);
        }
#endif
    }];
}

// MARK: - 辅助方法
/// 获取保存文件的路径
- (NSString *)filePath {
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSString *ID = @"group.com.youxin.ousicanteen";
    NSURL *groupURL = [fileManager containerURLForSecurityApplicationGroupIdentifier:ID];
    NSString *groupPath = [groupURL path];

    NSString *filePath = [groupPath stringByAppendingPathComponent:@"Library/Sounds"];
    
    if (![fileManager fileExistsAtPath:filePath]) {
        [fileManager createDirectoryAtPath:filePath
               withIntermediateDirectories:NO
                                attributes:nil
                                     error:nil];
    }
    
    return filePath;
}

/// 保留两位小数
- (NSString *)twoDecimalsWithNum:(NSString *)num {
    return [NSString stringWithFormat:@"%.2Lf", [self roundToFloat:2 num:num]];
}

/// 四舍五入位 digits 位小数
- (long double)roundToFloat:(NSUInteger)digits num:(NSString *)num {
    // 使用doubleValue能提高四舍五入的准确性
    double val = num.length < 8 ? num.floatValue : num.doubleValue;
    return roundl(val * powl(10, digits)) / powl(10, digits);
}

- (NSURL *)urlWithFileName:(NSString *)fileName {
    NSURL *url = nil;
    @try {
        url = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:fileName ofType:@"mp3"]];
    } @catch (NSException *exception) {
        
    } @finally {
        
    }
    return url;
}


// MARK: - 下载语音文件(未使用)
/// 需要设置App Transport Security Settings
- (void)loadWavWithUrl:(NSString *)urlStr{
    NSLog(@"开始下载");
    NSURL *url = [NSURL URLWithString:urlStr];
       //默认的congig
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    
    //session
    NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:nil delegateQueue:[NSOperationQueue mainQueue]];
    self.task = [session downloadTaskWithURL:url completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (!error) {
            NSLog(@"下载完成");
            NSString *name = [NSString stringWithFormat:@"%u.mp3",arc4random()%50000 ];
             //获取保存文件的路径
             NSString *path = self.filePath;
             //将url对应的文件copy到指定的路径

             NSFileManager *fileManager = [NSFileManager defaultManager];
             if(![fileManager fileExistsAtPath:path]){
                 [fileManager createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil];
             }
             NSString * soundStr = [NSString stringWithFormat:@"%@",name];

             NSString *savePath = [path stringByAppendingPathComponent:[NSString stringWithFormat:@"%@",soundStr]];
             if ([fileManager fileExistsAtPath:savePath]) {
                 [fileManager removeItemAtPath:savePath error:nil];
                }
             NSURL *saveURL = [NSURL fileURLWithPath:savePath];
            
             NSError * saveError;
             // 文件移动到cache路径中
             [[NSFileManager defaultManager] moveItemAtURL:location toURL:saveURL error:&saveError];
             if (!saveError)
             {
                 self.bestAttemptContent.sound = [UNNotificationSound soundNamed:name];;
                 self.contentHandler(self.bestAttemptContent);
                 
             }

        }else{
            
            NSLog(@"失败");
        }
         
    }];
    
    //启动下载任务
    [_task resume];
}
@end

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容