前言:在iOS 12.01之后
AVSpeechSynthesisVoice(文字转语音)
已经不可用,所以只能使用修改推送中的sound属性来实现播报
可供参考的实现方法:
- 将所有需要的播报场景音频文件放入工程main bundle,然后根据需要播报对影文件。
- 该方法适用于播报场景少的情况
- 使用三方离线语音合成或在线合成sdk(如百度、讯飞等),对要合成的文本进行转换,然后设置到sound属性中
- 需要开App Groups,将合成的音频文件保存到Groups对应的
Library/Sounds
目录下- 需付费
- 由后台合成,然后根据通过推送进行下载,然后设置到sound上
- 需要开App Groups,将合成的音频文件保存到Groups对应的
Library/Sounds
目录下- 参考文章
- 本地音频文件组合(如将‘您的的支付宝到账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