iOS音视频:音频

原创:知识探索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、音效
  • 二、音乐
  • 三、音频会话
  • 四、播放音乐库中的音乐
  • 五、音频队列服务
  • 六、在线教室声音问题
  • Demo
  • 参考文献

一、音效

1、简介

在iOS中音频播放从形式上可以分为音效播放和音乐播放。前者主要指的是一些短音频播放,通常作为点缀音频,对于这类音频不需要进行进度、循环等控制。后者指的是一些较长的音频,通常是主音频,对于这些音频的播放通常需要进行精确的控制。在iOS中播放两类音频分别使用AudioToolbox.frameworkAVFoundation.framework来完成音效和音乐播放。AudioToolbox.framework是一套基于C语言的框架,使用它来播放音效其本质是将短音频注册到系统声音服务(System Sound Service)。

System Sound Service的使用限制
  • 音频播放时间不能超过30s。
  • 数据必须是PCM或者IMA4格式。
  • 音频文件必须打包成.caf.aif.wav中的一种(注意这是官方文档的说法,实际测试发现.mp3也可以播放)。
使用System Sound Service播放音效的步骤
  1. 调用AudioServicesCreateSystemSoundID( CFURLRef inFileURLSystemSoundID* outSystemSoundID)函数获得系统声音ID
  2. 如果需要监听播放完成操作,则使用AudioServicesAddSystemSoundCompletion( SystemSoundID inSystemSoundIDCFRunLoopRef inRunLoopCFStringRef inRunLoopModeAudioServicesSystemSoundCompletionProc inCompletionRoutinevoid* inClientData)方法注册回调函数。
  3. 调用AudioServicesPlaySystemSound(SystemSoundID inSystemSoundID) 或者AudioServicesPlayAlertSound(SystemSoundID inSystemSoundID) 方法播放音效(后者带有震动效果)。

2、Demo演示

a、播放音效文件
#import <AudioToolbox/AudioToolbox.h>

-(void)playSoundEffect:(NSString *)name
{
    NSString *audioFile = [[NSBundle mainBundle] pathForResource:name ofType:nil];
    NSURL *fileUrl = [NSURL fileURLWithPath:audioFile];
    
    //1.获得系统声音ID
    SystemSoundID soundID = 0;

    /**
     * inFileUrl:音频文件url
     * outSystemSoundID:声音id(此函数会将音效文件加入到系统音频服务中并返回一个长整形ID)
     */
    AudioServicesCreateSystemSoundID((__bridge CFURLRef)(fileUrl), &soundID);
  
    //如果需要在播放完之后执行某些操作,可以调用如下方法注册一个播放完成回调函数
    AudioServicesAddSystemSoundCompletion(soundID, NULL, NULL, soundCompleteCallback, NULL);
    
    //2.播放音频
    AudioServicesPlaySystemSound(soundID);//播放音效
    AudioServicesPlayAlertSound(soundID);//播放音效并震动
}
b、 播放完成回调函数
void soundCompleteCallback(SystemSoundID soundID,void * clientData)
{
    NSLog(@"播放完成...");
}
c、调用方式
[self playSoundEffect:@"videoRing.caf"]; // 传入音频文件名称

输出结果为:

2020-09-01 09:53:33.464405+0800 AVAudioRecorderDemo[5612:339649] 播放完成...
d、遇到的问题

这里会遇到一个问题,下面这行代码返回的path为空,而笔者已经把Sound.caf文件添加到项目中,最后在项目设置的Build Phases页面,在Copy Bundle Resources栏目下添加该Sound.caf文件就解决了。

NSString *path = [[NSBundle mainBundle] pathForResource:@"Sound.caf" ofType:nil];  

二、音乐

1、简介

如果播放较大的音频或者要对音频有精确的控制则System Sound Service可能就很难满足实际需求了,通常这种情况会选择使用AVFoundation.framework中的AVAudioPlayer来实现。AVAudioPlayer可以看成一个播放器,它支持多种音频格式,而且能够进行进度、音量、播放速度等控制。

AVAudioPlayer的属性
@property(readonly, getter=isPlaying) BOOL playing //是否正在播放,只读
@property(readonly) NSUInteger numberOfChannels //音频声道数,只读
@property(readonly) NSTimeInterval duration //音频时长
@property(readonly) NSURL *url //音频文件路径,只读
@property(readonly) NSData *data //音频数据,只读
@property float pan //立体声平衡,如果为-1.0则完全左声道,如果0.0则左右声道平衡,如果为1.0则完全为右声道
@property float volume //音量大小,范围0-1.0
@property BOOL enableRate //是否允许改变播放速率
@property float rate //播放速率,范围0.5-2.0,如果为1.0则正常播放,如果要修改播放速率则必须设置enableRate为YES
@property NSTimeInterval currentTime //当前播放时长
@property(readonly) NSTimeInterval deviceCurrentTime //输出设备播放音频的时间,注意如果播放中被暂停此时间也会继续累加
@property NSInteger numberOfLoops //循环播放次数,如果为0则不循环,如果小于0则无限循环,大于0则表示循环次数
@property(readonly) NSDictionary *settings //音频播放设置信息,只读
@property(getter=isMeteringEnabled) BOOL meteringEnabled //是否启用音频测量,默认为NO,一旦启用音频测量可以通过updateMeters方法更新测量值
AVAudioPlayer的方法
- (instancetype)initWithContentsOfURL:(NSURL *)url error:(NSError **)outError //使用文件URL初始化播放器,注意这个URL不能是HTTP URL,AVAudioPlayer不支持加载网络媒体流,只能播放本地文件
- (instancetype)initWithData:(NSData *)data error:(NSError **)outError //使用NSData初始化播放器,注意使用此方法时必须文件格式和文件后缀一致,否则出错,所以相比此方法更推荐使用上述方法 
- (BOOL)prepareToPlay; //加载音频文件到缓冲区,注意即使在播放之前音频文件没有加载到缓冲区程序也会隐式调用此方法
- (BOOL)play; //播放音频文件
- (BOOL)playAtTime:(NSTimeInterval)time //在指定的时间开始播放音频
- (void)pause; //暂停播放
- (void)stop; //停止播放
- (void)updateMeters; //更新音频测量值,注意如果要更新音频测量值必须设置meteringEnabled为YES,通过音频测量值可以即时获得音频分贝等信息
 (float)peakPowerForChannel:(NSUInteger)channelNumber; //获得指定声道的分贝峰值,注意如果要获得分贝峰值必须在此之前调用updateMeters方法
- (float)averagePowerForChannel:(NSUInteger)channelNumber; //获得指定声道的分贝平均值,注意如果要获得分贝平均值必须在此之前调用updateMeters方法
AVAudioPlayer的代理方法
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag; //音频播放完成

2、Demo

a、功能点

下面就使用AVAudioPlayer实现一个简单播放器,在这个播放器中实现了播放、暂停、显示播放进度功能,当然例如调节音量、设置循环模式、甚至是声波图像(通过分析音频分贝值)等功能都可以实现,这里就不再一一演示。实现步骤如下:

  1. 初始化AVAudioPlayer对象,此时通常指定本地文件路径。
  2. 设置播放器属性,例如重复次数、音量大小等。
  3. 调用play方法播放。

运行效果如下:

播放音乐
2020-09-01 11:22:00.863313+0800 AVAudioRecorderDemo[6245:396604] 音乐播放完成...
b、扩展和头文件

当然由于AVAudioPlayer一次只能播放一个音频文件,所以上一曲、下一曲其实可以通过创建多个播放器对象来完成,这里暂不实现。播放进度的实现主要依靠一个定时器实时计算当前播放时长和音频总时长的比例,另外为了演示委托方法,下面的代码中也实现了播放完成委托方法,通常如果有下一曲功能的话播放完可以触发下一曲音乐播放。

#import "MusicViewController.h"
#import <AVFoundation/AVFoundation.h>
#define kMusicFile @"桜道.mp3"
#define kMusicSinger @"歌手:Jusqu'à Grand-Père"
#define kMusicTitle @"歌曲:桜道"

@interface MusicViewController ()<AVAudioPlayerDelegate>

@property (nonatomic,strong) AVAudioPlayer *audioPlayer; //播放器
@property (strong, nonatomic) UILabel *controlPanel; //控制面板
@property (strong, nonatomic) UIProgressView *playProgress; //播放进度
@property (strong, nonatomic) UILabel *musicSinger; //演唱者
@property (strong, nonatomic) UIButton *playOrPause; //播放/暂停按钮(如果tag为0认为是暂停状态,1是播放状态)

@property (weak ,nonatomic) NSTimer *timer; //进度更新定时器

@end
c、播放控制

播放音频

-(void)play
{
    if (![self.audioPlayer isPlaying])
    {
        [self.audioPlayer play];
        self.timer.fireDate = [NSDate distantPast];//恢复定时器
    }
}

暂停播放

-(void)pause
{
    if ([self.audioPlayer isPlaying])
    {
        [self.audioPlayer pause];
        self.timer.fireDate = [NSDate distantFuture];//暂停定时器,注意不能调用invalidate方法,此方法会取消,之后无法恢复
    }
}

点击播放/暂停按钮

- (void)playClick:(UIButton *)sender
{
    if(sender.tag)
    {
        sender.tag = 0;
        [sender setImage:[UIImage imageNamed:@"playing_btn_play_n"] forState:UIControlStateNormal];
        [self pause];
    }
    else
    {
        sender.tag = 1;
        [sender setImage:[UIImage imageNamed:@"playing_btn_pause_n"] forState:UIControlStateNormal];
        [self play];
    }
}

更新播放进度

-(void)updateProgress
{
    float progress = self.audioPlayer.currentTime / self.audioPlayer.duration;
    [self.playProgress setProgress:progress animated:true];
}
d、AVAudioPlayerDelegate
-(void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag
{
    NSLog(@"音乐播放完成...");
    // 下一首
}
e、创建播放器和计时器

创建播放器

-(AVAudioPlayer *)audioPlayer
{
    if (!_audioPlayer)
    {
        NSString *urlStr = [[NSBundle mainBundle] pathForResource:kMusicFile ofType:nil];
        NSURL *url = [NSURL fileURLWithPath:urlStr];
        NSError *error = nil;
        
        // 初始化播放器,注意这里的Url参数只能是文件路径,不支持HTTP Url
        _audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&error];
        // 设置播放器属性
        _audioPlayer.numberOfLoops = 0; //设置为0不循环播放
        _audioPlayer.delegate = self;
        [_audioPlayer prepareToPlay]; //加载音频文件到缓存
        
        if(error)
        {
            NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioPlayer;
}

创建计时器

-(NSTimer *)timer
{
    if (!_timer)
    {
        _timer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(updateProgress) userInfo:nil repeats:true];
    }
    return _timer;
}

三、音频会话

1、简介

事实上上面的播放器还存在一些问题,例如通常我们看到的播放器即使退出到后台也是可以播放的,而这个播放器如果退出到后台它会自动暂停。

a、支持后台播放的条件
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
[audioSession setCategory:AVAudioSessionCategoryPlayback error:nil];
[audioSession setActive:YES error:nil];
  • 设置后台运行模式:在plist文件中添加Required background modes,并且设置item 0App plays audio or streams audio/video using AirPlay(其实可以直接通过XcodeProject Targets-Capabilities-Background Modes中设置)。
  • 设置AVAudioSession的类型为AVAudioSessionCategoryPlayback并且调用setActive:方法启动会话。
  • 为了能够让应用退到后台之后支持耳机控制,建议添加远程控制事件(这一步不是后台播放必须的)。

前两步是后台播放所必须设置的,第三步主要用于接收远程事件,如果这一步不设置虽然也能够在后台播放,但是无法获得音频控制权(如果在使用当前应用之前使用其他播放器播放音乐的话,此时如果按耳机播放键或者控制中心的播放按钮则会播放前一个应用的音频),并且不能使用耳机进行音频控制。

第一步操作相信大家都很容易理解,如果应用程序要允许运行到后台必须设置,正常情况下应用如果进入后台会被挂起,通过该设置可以让应用程序继续在后台运行。但是第二步使用的AVAudioSession有必要进行一下详细的说明。

在iOS中每个应用都有一个音频会话,这个会话就通过AVAudioSession来表示。AVAudioSession同样存在于AVFoundation框架中,它是单例模式设计,通过sharedInstance进行访问。在使用Apple设备时大家会发现有些应用只要打开其他音频播放就会终止,而有些应用却可以和其他应用同时播放,在多种音频环境中如何去控制播放的方式就是通过音频会话来完成的。

b、音频会话的几种会话模式:
会话类型 说明 是否要求输入 是否要求输出 是否遵从静音键
AVAudioSessionCategoryAmbient 混音播放,可以与其他音频应用同时播放
AVAudioSessionCategorySoloAmbient 独占播放
AVAudioSessionCategoryPlayback 后台播放,也是独占的
AVAudioSessionCategoryRecord 录音模式,用于录音时使用
AVAudioSessionCategoryPlayAndRecord 播放和录音,此时可以录音也可以播放
AVAudioSessionCategoryAudioProcessing 硬件解码音频,此时不能播放和录制
AVAudioSessionCategoryMultiRoute 多种输入输出,例如可以耳机、USB设备同时播放

注意:是否遵循静音键表示在播放过程中如果用户通过硬件设置为静音是否能关闭声音。

根据前面对音频会话的理解,相信大家开发出能够在后台播放的音频播放器并不难,但是注意一下,在前面的代码中也提到设置完音频会话类型之后需要调用setActive:方法将会话激活才能起作用。类似的,如果一个应用已经在播放音频,打开我们的应用之后设置了在后台播放的会话类型,此时其他应用的音频会停止而播放我们的音频,如果希望我们的程序音频播放完之后(关闭或退出到后台之后)能够继续播放其他应用的音频的话则可以调用setActive:方法关闭会话。


2、Demo演示

a、扩展和头文件
#import "AudioSessionViewController.h"
#import <AVFoundation/AVFoundation.h>
#define kMusicFile @"桜道.mp3"
#define kMusicSinger @"歌手:Jusqu'à Grand-Père"
#define kMusicTitle @"歌曲:桜道"

@interface AudioSessionViewController ()<AVAudioPlayerDelegate>

@property (nonatomic,strong) AVAudioPlayer *audioPlayer; //播放器
@property (strong, nonatomic) UILabel *controlPanel; //控制面板
@property (strong, nonatomic) UIProgressView *playProgress; //播放进度
@property (strong, nonatomic) UILabel *musicSinger; //演唱者
@property (strong, nonatomic) UIButton *playOrPause; //播放/暂停按钮(如果tag为0认为是暂停状态,1是播放状态)

@property (weak ,nonatomic) NSTimer *timer; //进度更新定时器

@end
b、耳机控制

实现了拔出耳机暂停音乐播放的功能,这也是一个比较常见的功能。可以通过通知获得输出改变的通知,然后拿到通知对象后根据userInfo获得是何种改变类型,进而根据情况对音乐进行暂停操作。

-(void)routeChange:(NSNotification *)notification
{
    NSDictionary *dictionary = notification.userInfo;
    [dictionary enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
        NSLog(@"notification userInfo,key:%@,value:%@",key,obj);
    }];
    int changeReason = [dictionary[AVAudioSessionRouteChangeReasonKey] intValue];
    
    // 等于AVAudioSessionRouteChangeReasonOldDeviceUnavailable 表示旧输出不可用
    if (changeReason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable)
    {
        AVAudioSessionRouteDescription *routeDescription = dictionary[AVAudioSessionRouteChangePreviousRouteKey];
        AVAudioSessionPortDescription *portDescription= [routeDescription.outputs firstObject];
        
        //原设备为耳机则暂停
        if ([portDescription.portType isEqualToString:@"Headphones"])
        {
            [self pause];
        }
    }
}

显示当前视图控制器时注册远程事件

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    
    //开启远程控制
    [[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
    
    //作为第一响应者
    //[self becomeFirstResponder];
}

当前控制器视图不显示时取消远程控制

- (void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    
    [[UIApplication sharedApplication] endReceivingRemoteControlEvents];
    //[self resignFirstResponder];
}
c、播放控制

播放音频

- (void)play
{
    if (![self.audioPlayer isPlaying])
    {
        [self.audioPlayer play];
        self.timer.fireDate = [NSDate distantPast];//恢复定时器
    }
}

暂停播放

- (void)pause
{
    if ([self.audioPlayer isPlaying])
    {
        [self.audioPlayer pause];
        self.timer.fireDate = [NSDate distantFuture];//暂停定时器,注意不能调用invalidate方法,此方法会取消,之后无法恢复
    }
}

点击播放/暂停按钮

- (void)playClick:(UIButton *)sender
{
    if(sender.tag)
    {
        sender.tag = 0;
        [sender setImage:[UIImage imageNamed:@"playing_btn_play_n"] forState:UIControlStateNormal];
        [self pause];
    }
    else
    {
        sender.tag = 1;
        [sender setImage:[UIImage imageNamed:@"playing_btn_pause_n"] forState:UIControlStateNormal];
        [self play];
    }
}

更新播放进度

- (void)updateProgress
{
    float progress = self.audioPlayer.currentTime / self.audioPlayer.duration;
    [self.playProgress setProgress:progress animated:true];
}
d、AVAudioPlayerDelegate
-(void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag
{
    NSLog(@"音乐播放完成...");

    // 根据实际情况播放完成可以将会话关闭,其他音频应用继续播放
    [[AVAudioSession sharedInstance] setActive:NO error:nil];
}

四、播放音乐库中的音乐

1、属性和方法

众所周知音乐是iOS的重要组成播放,无论是iPodiTouchiPhone还是iPad都可以在iTunes购买音乐或添加本地音乐到音乐库中同步到你的iOS设备。在MediaPlayer.frameowork中有一个MPMusicPlayerController用于播放音乐库中的音乐。

属性

播放器状态,枚举类型

@property (nonatomic, readonly) MPMusicPlaybackState playbackState 
MPMusicPlaybackStateStopped:停止播放 
MPMusicPlaybackStatePlaying:正在播放
MPMusicPlaybackStatePaused:暂停播放
MPMusicPlaybackStateInterrupted:播放中断
MPMusicPlaybackStateSeekingForward:向前查找
MPMusicPlaybackStateSeekingBackward:向后查找

重复模式,枚举类型

@property (nonatomic) MPMusicRepeatMode repeatMode  
MPMusicRepeatModeDefault:默认模式,使用用户的首选项(系统音乐程序设置)
MPMusicRepeatModeNone:不重复
MPMusicRepeatModeOne:单曲循环
MPMusicRepeatModeAll:在当前列表内循环

随机播放模式,枚举类型

@property (nonatomic) MPMusicShuffleMode shuffleMode 
MPMusicShuffleModeDefault:默认模式,使用用户首选项(系统音乐程序设置)
MPMusicShuffleModeOff:不随机播放
MPMusicShuffleModeSongs:按歌曲随机播放
MPMusicShuffleModeAlbums:按专辑随机播放

常用属性

@property (nonatomic, copy) MPMediaItem *nowPlayingItem //正在播放的音乐项
@property (nonatomic, readonly) NSUInteger indexOfNowPlayingItem //当前正在播放的音乐在播放队列中的索引
@property(nonatomic, readonly) BOOL isPreparedToPlay //是否做好播放准备
@property(nonatomic) NSTimeInterval currentPlaybackTime //当前已播放时间,单位:秒
@property(nonatomic) float currentPlaybackRate //当前播放速度,是一个播放速度倍率,0表示暂停播放,1代表正常速度
类方法
+ (MPMusicPlayerController *)applicationMusicPlayer; //获取应用播放器,注意此类播放器无法在后台播放
+ (MPMusicPlayerController *)systemMusicPlayer //获取系统播放器,支持后台播放
对象方法
- (void)setQueueWithQuery:(MPMediaQuery *)query //使用媒体队列设置播放源媒体队列
- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection //使用媒体项集合设置播放源媒体队列
- (void)skipToNextItem //下一曲
- (void)skipToBeginning //从起始位置播放
- (void)skipToPreviousItem //上一曲
- (void)beginGeneratingPlaybackNotifications //开启播放通知,注意不同于其他播放器,MPMusicPlayerController要想获得通知必须首先开启,默认情况无法获得通知
- (void)endGeneratingPlaybackNotifications //关闭播放通知
- (void)prepareToPlay //做好播放准备(加载音频到缓冲区),在使用play方法播放时如果没有做好准备回自动调用该方法
- (void)play //开始播放
- (void)pause //暂停播放
- (void)stop //停止播放
- (void)beginSeekingForward //开始向前查找(快进)
- (void)beginSeekingBackward //开始向后查找(快退)
- (void)endSeeking //结束查找
通知

要想获得MPMusicPlayerController通知必须首先调用beginGeneratingPlaybackNotifications开启通知

MPMusicPlayerControllerPlaybackStateDidChangeNotification //播放状态改变
MPMusicPlayerControllerNowPlayingItemDidChangeNotification //当前播放音频改变
MPMusicPlayerControllerVolumeDidChangeNotification //声音大小改变
MPMediaPlaybackIsPreparedToPlayDidChangeNotification //准备好播放

2、获取媒体文件列表

MPMusicPlayerController有两种播放器:applicationMusicPlayersystemMusicPlayer,前者在应用退出后音乐播放会自动停止,后者在应用停止后不会退出播放状态。

MPMusicPlayerController加载音乐不同于前面的AVAudioPlayer是通过一个文件路径来加载,而是需要一个播放队列。在MPMusicPlayerController中提供了两个方法来加载播放队列:- (void)setQueueWithQuery:(MPMediaQuery *)query- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection,正是由于它的播放音频来源是一个队列,因此MPMusicPlayerController支持上一曲、下一曲等操作。

那么接下来的问题就是如何获取MPMediaQueue或者MPMediaItemCollectionMPMediaQueue对象有一系列的类方法来获得媒体队列:

+ (MPMediaQuery *)albumsQuery;
+ (MPMediaQuery *)artistsQuery;
+ (MPMediaQuery *)songsQuery;
+ (MPMediaQuery *)playlistsQuery;
+ (MPMediaQuery *)podcastsQuery;
+ (MPMediaQuery *)audiobooksQuery;
+ (MPMediaQuery *)compilationsQuery;
+ (MPMediaQuery *)composersQuery;
+ (MPMediaQuery *)genresQuery;

有了这些方法,就可以很容易获到歌曲、播放列表、专辑媒体等媒体队列了,这样就可以通过: - (void)setQueueWithQuery:(MPMediaQuery *)query方法设置音乐来源了。又或者得到MPMediaQueue之后创建MPMediaItemCollection,使用- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection设置音乐来源。

有时候可能希望用户自己来选择要播放的音乐,这时可以使用MPMediaPickerController,它是一个视图控制器,类似于UIImagePickerController,选择完播放来源后可以在其代理方法中获得MPMediaItemCollection对象。

无论是通过哪种方式获得MPMusicPlayerController的媒体源,可能都希望将每个媒体的信息显示出来,这时候可以通过MPMediaItem对象获得。一个MPMediaItem代表一个媒体文件,通过它可以访问媒体标题、专辑名称、专辑封面、音乐时长等等。无论是MPMediaQueue还是MPMediaItemCollection都有一个items属性,它是MPMediaItem数组,通过这个属性可以获得MPMediaItem对象。


3、Demo演示

a、功能点

下面就简单看一下MPMusicPlayerController的使用,在下面的例子中简单演示了音乐的选择、播放、暂停、通知、下一曲、上一曲功能,相信有了上面的概念,代码读起来并不复杂(示例中是直接通过MPMeidaPicker进行音乐选择的,但是仍然提供了两个方法getLocalMediaQuerygetLocalMediaItemCollection来演示如何直接通过MPMediaQueue获得媒体队列或媒体集合)。在Info.plist文件中添加Privacy - Media Library Usage Description访问权限,提示语可为:访问音乐库权限。

a、数据源

获得媒体队列

- (MPMediaQuery *)getLocalMediaQuery
{
    MPMediaQuery *mediaQueue = [MPMediaQuery songsQuery];
    for (MPMediaItem *item in mediaQueue.items)
    {
        NSLog(@"item 标题:%@,albumTitle 专辑标题:%@",item.title,item.albumTitle);
    }
    return mediaQueue;
}

获取媒体集合

-(MPMediaItemCollection *)getLocalMediaItemCollection
{
    MPMediaQuery *mediaQueue = [MPMediaQuery songsQuery];
    NSMutableArray *array = [NSMutableArray array];
    for (MPMediaItem *item in mediaQueue.items)
    {
        [array addObject:item];
        NSLog(@"item 标题:%@,albumTitle 专辑标题:%@",item.title,item.albumTitle);
    }
    MPMediaItemCollection *mediaItemCollection = [[MPMediaItemCollection alloc] initWithItems:[array copy]];
    return mediaItemCollection;
}
b、MPMediaPickerControllerDelegate

选择完成

-(void)mediaPicker:(MPMediaPickerController *)mediaPicker didPickMediaItems:(MPMediaItemCollection *)mediaItemCollection
{
    MPMediaItem *mediaItem = [mediaItemCollection.items firstObject];// 播放第一个音乐
    
    //注意很多音乐信息如标题、专辑、表演者、封面、时长等信息都可以通过MPMediaItem的valueForKey:方法得到,也都有对应的属性可以直接访问
    //NSString *title = [mediaItem valueForKey:MPMediaItemPropertyAlbumTitle];
    //NSString *artist = [mediaItem valueForKey:MPMediaItemPropertyAlbumArtist];
    //MPMediaItemArtwork *artwork = [mediaItem valueForKey:MPMediaItemPropertyArtwork];
    //UIImage *image = [artwork imageWithSize:CGSizeMake(100, 100)];//专辑图片
    
    NSLog(@"标题:%@,表演者:%@,专辑:%@",mediaItem.title ,mediaItem.artist,mediaItem.albumTitle);
    [self.musicPlayer setQueueWithItemCollection:mediaItemCollection];
    [self dismissViewControllerAnimated:YES completion:nil];
}

取消选择

-(void)mediaPickerDidCancel:(MPMediaPickerController *)mediaPicker
{
    [self dismissViewControllerAnimated:YES completion:nil];
}
c、通知

添加通知

-(void)addNotification
{
    NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
    [notificationCenter addObserver:self selector:@selector(playbackStateChange:) name:MPMusicPlayerControllerPlaybackStateDidChangeNotification object:self.musicPlayer];
}

播放状态改变通知

-(void)playbackStateChange:(NSNotification *)notification
{
    switch (self.musicPlayer.playbackState)
    {
        case MPMusicPlaybackStatePlaying:
            NSLog(@"正在播放...");
            break;
        case MPMusicPlaybackStatePaused:
            NSLog(@"播放暂停.");
            break;
        case MPMusicPlaybackStateStopped:
            NSLog(@"播放停止.");
            break;
        default:
            break;
    }
}
d、创建媒体播发器
-(MPMusicPlayerController *)musicPlayer
{
    if (!_musicPlayer)
    {
        _musicPlayer = [MPMusicPlayerController systemMusicPlayer];
        // 开启通知,否则监控不到MPMusicPlayerController的通知
        [_musicPlayer beginGeneratingPlaybackNotifications];
        // 添加通知
        [self addNotification];
        
        // 如果不使用MPMediaPickerController可以使用如下方法获得音乐库媒体队列
        //[_musicPlayer setQueueWithItemCollection:[self getLocalMediaItemCollection]];
    }
    return _musicPlayer;
}
e、创建媒体选择器
-(MPMediaPickerController *)mediaPicker
{
    if (!_mediaPicker)
    {
        // 初始化媒体选择器,这里设置媒体类型为音乐,其实这里也可以选择视频、广播等
        //_mediaPicker = [[MPMediaPickerController alloc] initWithMediaTypes:MPMediaTypeMusic];
        _mediaPicker = [[MPMediaPickerController alloc] initWithMediaTypes:MPMediaTypeAny];
        // 允许多选
        _mediaPicker.allowsPickingMultipleItems = YES;
        // 显示iCloud选项
        _mediaPicker.showsCloudItems = YES;
        _mediaPicker.prompt = @"请选择要播放的音乐";
        //设置选择器代理
        _mediaPicker.delegate = self;
    }
    return _mediaPicker;
}

@end

注意:模拟器和没有安装Apple music app的真机都会报错,必须在真机上调试,且安装苹果自带的音乐APP。

The requested app extension could not be found

五、音频队列服务

1、简介

无论是前面的录音还是音频播放均不支持网络流媒体播放,当然对于录音来说这种需求可能不大,但是对于音频播放来说有时候就很有必要了。AVAudioPlayer只能播放本地文件,并且是一次性加载所有音频数据,初始化AVAudioPlayer时指定的URL也只能是File URL而不能是HTTP URL

当然,将音频文件下载到本地然后再调用AVAudioPlayer来播放也是一种播放网络音频的办法,但是这种方式最大的弊端就是必须等到整个音频下载完成才能播放,而不能使用流式播放,这往往在实际开发中是不切实际的。那么在iOS中如何播放网络流媒体呢?就是使用AudioToolbox框架中的音频队列服务Audio Queue Services

使用音频队列服务完全可以做到音频播放和录制,首先看一下录音音频服务队列:

录音音频服务队列

一个音频服务队列Audio Queue由三部分组成:

  • 三个缓冲器Buffers:每个缓冲器都是一个存储音频数据的临时仓库。
  • 一个缓冲队列Buffer Queue:一个包含音频缓冲器的有序队列。
  • 一个回调Callback:一个自定义的队列回调函数。

声音通过输入设备进入缓冲队列中,首先填充第一个缓冲器;当第一个缓冲器填充满之后自动填充下一个缓冲器,同时会调用回调函数,在回调函数中需要将缓冲器中的音频数据写入磁盘,同时将缓冲器放回到缓冲队列中以便重用。

下面是Apple官方关于音频队列服务的流程示意图:

官方关于音频队列服务的流程示意图

类似的,看一下音频播放缓冲队列,其组成部分和录音缓冲队列类似。

音频播放缓冲队列

但是在音频播放缓冲队列中,回调函数调用的时机不同于音频录制缓冲队列,流程刚好相反。将音频读取到缓冲器中,一旦一个缓冲器填充满之后就放到缓冲队列中,然后继续填充其他缓冲器。当开始播放时,则从第一个缓冲器中读取音频进行播放,一旦播放完之后就会触发回调函数,开始播放下一个缓冲器中的音频,同时填充第一个缓冲器,填充满之后再次放回到缓冲队列。

下面是详细的流程图:

详细的流程图

当然,要明白音频队列服务的原理并不难,问题是如何实现这个自定义的回调函数,这其中我们有大量的工作要做,控制播放状态、处理异常中断、进行音频编码等等。由于牵扯内容过多,而且不是本文目的,如果以后有时间将另开一篇文章重点介绍,目前有很多第三方优秀框架可以直接使用,例如FreeStreamer

使用FreeStreamer之前要做如下准备工作:

  1. 拷贝FreeStreamer中的Reachability.hReachability.mCommonastreamer两个文件夹中的内容到项目中。
  2. 添加FreeStreamer使用的类库:CFNetwork.frameworkAudioToolbox.frameworkAVFoundation.frameworklibxml2.dylibMediaPlayer.framework
  3. 如果引用libxml2.dylib编译不通过,需要在Xcode的Targets-Build Settings-Header Build Path中添加$(SDKROOT)/usr/include/libxml2
  4. FreeStreamer中的FreeStreamerMobile-Prefix.pch文件添加到项目中并将Targets-Build Settings-Precompile Prefix Header设置为YES,在Targets-Build Settings-Prefix Header设置为$(SRCROOT)/项目名称/FreeStreamerMobile-Prefix.pch

2、Demo演示

然后就可以编写代码播放网络音频了。

#import "FreeStreamerViewController.h"
#import "FSAudioStream.h"

@interface FreeStreamerViewController ()

@property (nonatomic,strong) FSAudioStream *audioStream;

@end

@implementation FreeStreamerViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    [self.audioStream play];
}

// 取得本地文件路径
-(NSURL *)getFileUrl
{
    NSString *urlStr = [[NSBundle mainBundle]pathForResource:@"桜道.mp3" ofType:nil];
    NSURL *url = [NSURL fileURLWithPath:urlStr];
    return url;
}

// 取得网络文件路径
-(NSURL *)getNetworkUrl
{
    NSString *urlStr = @"http://192.168.1.102/liu.mp3";
    NSURL *url = [NSURL URLWithString:urlStr];
    return url;
}

// 创建FSAudioStream对象
-(FSAudioStream *)audioStream
{
    if (!_audioStream)
    {
        NSURL *url = [self getNetworkUrl];
        
        // 创建FSAudioStream对象
        _audioStream=[[FSAudioStream alloc] initWithUrl:url];
        _audioStream.onFailure=^(FSAudioStreamError error,NSString *description){
            NSLog(@"播放过程中发生错误,错误信息:%@",description);
        };
        _audioStream.onCompletion=^(){
            NSLog(@"播放完成!");
        };
        [_audioStream setVolume:0.5];//设置声音
    }
    return _audioStream;
}

@end

其实FreeStreamer的功能很强大,不仅仅是播放本地、网络音频那么简单,它还支持播放列表、检查包内容、RSS订阅、播放中断等很多强大的功能,甚至还包含了一个音频分析器。


六、在线教室声音问题

1、问题描述

在线教室场景下,声音是最重要的内容传输渠道之一,保障声音的稳定可靠,是在线教室质量非常重要的一环。同时在线教室里许多功能模块都与声音有关联,如何处理好各个模块间的声音冲突成为一个重要话题。

AVAudioSession

在 iOS 端,说到声音的话题就绕不开 AVAudioSessionAVAudioSession 的作用是管理音频这一唯一硬件资源的分配,通过调优合适的 AVAudioSession 来适配我们的 APP 对于音频的功能需求。切换音频场景的时候,需要相应的切换 AVAudioSession

AVAudioSessionCategory

教育场景下主要使用到的音频场景有:

image.png
AVAudioSessionMode

iOS 提供 AVAudioSessionMode 用于与 AVAudioSessionCategory 搭配使用,教育场景下使用到的音频模式主要有:

AVAudioSessionOptions

我们可以使用 options 去微调 Category 行为,教育场景下常用的有:

通话音量与媒体音量

一般而言,通话音量指的是进行语音、视频通话时的音量。媒体音量指的是播放音乐、视频或游戏的音效、背景音的音量。在实际使用中,两者的差异在于,通话音量有较好的回声消除,媒体音量有较好的声音表现力。媒体音量可以调整到 0,而通话音量不可以。

通话音量与媒体音量只能二选一,因此需要区分系统音量走的是通话音量还是媒体音量。系统音量走通话音量,是指在设备上调整音量时,调整的是通话音量。媒体音量同理。媒体音量和通话音量分别属于 2 个不同的、独立的系统,一个设置不会影响到另外一个。

进入通话后,音效的播放音量由通话音量控制。退出通话后,则由媒体音量控制。一般在教育场景下,学生作为观众拉流时,使用的媒体音量,老师说话的声音更加立体饱满,当学生连麦时,使用的通话音量,以保证通话声音的质量。简单来说,非连麦模式下会使用媒体音量控制,连麦模式下会使用通话音量控制,两者有独立的音量控制机制。

当播放媒体资源时,使用播放器(如 AVPlayer)播放音频,播放器底层 AudioUnitdescriptionVoiceProcessingIORTC SDK 内部维护了一个 AudioUnit,通话音量下 AudioUnitdescriptionRemoteIO,媒体音量下为 VoiceProcessingIO,当出现模式切换时,会销毁原来的 AudioUnit,再创建新的 AudioUnit,始终保持一个 AudioUnit来进行音频播放。

通话音量下,AVPlayerVoiceProcessingIOAudioUnit 声音会被抑制。同样的,在媒体音量下,RTC SDK 内的 AudioUnitdescription 设置为 VoiceProcessingIO,如果此时其他模块通过设置 AVAudioSession 切换到通话音量,RTC 的声音也会被抑制。

行业现状

在线教室场景下,很多功能都需要播放声音,包括课中音视频直播、课后回放、webview 内嵌课件声音(包括音频、视频、音效)、课堂音频、课堂视频、课堂游戏声音、音效声音等。除此之外,教室内还包括很多需要声音录制的功能,包括连麦、跟读、集体发言、聊天语音输入、语音识别等。教室内这些功能存在各种组合,且对 AVAudioSession 的设置要求存在差异,而 AVAudioSession 又是一个单例,如果没有一个统一管理的逻辑,很容易就出现设置混乱的问题。目前行业内碰到的比较多的问题主要是听不见 RTC 声音与媒体声音被抑制。

听不见 RTC 声音

听不见 RTC 声音的主要原因是其他功能在设置 AVAudioSession 时,AVAudioSessionOptions 未包含 AVAudioSessionCategoryOptionMixWithOthers 混音模式,导致 RTC 声音被高优进程打断。比如在非混音模式下播放 webview 的内嵌音频,因为 webview 是使用系统进程来播放声音,优先级最高,所以 APP 进程下的 RTC 声音就会被抑制导致无法正常发声。

这类问题一般都比较隐蔽,因为简单的场景如果有问题,在上线之前一般都能测试出来,而当多个功能场景串起来之后才触发问题,往往就很难在测试期间发现,且如果线上没有完备的日志查询体系,针对线上这类问题排查起来难度也非常大,往往因为定位不到原因而长期遗留。

媒体声音被抑制

在通话音量模式下,媒体声音会被压低,导致声音变小。比较常见的场景是在小班场景下,学生在推流时播放课堂音视频等媒体资源,声音会比 RTC 的声音要小,导致媒体声音听不清楚。通话模式下(连麦时)媒体声音会被压低,原因是 iOS 手机系统会开启回声消除以保证人声体验,因此会压低媒体通道的声音,也会压低背景音效。

教育行业内部分头部 APP 也没有从根本上解决该问题,很多都是通过从产品功能层面上规避问题,通过产品妥协来为技术问题让步。比如在播放课堂音视频资源时,默认将所有学生都强制关麦,关麦时学生处于媒体音量,就不存在被压低的问题了,等到课堂音视频播放结束后,再允许学生开麦。这种通过规避问题场景来解决问题的方式,不具有可复制性。

RTC 声音变小

RTC 声音变小,主要原因是声音通过听筒发声,而没有正常通过扬声器发声,造成声音变小的假象。另外在 iOS14 系统下,使用过 RTC 的通话模式并切回媒体模式后,再调用 setCategory:PlayAndRecord + DefaultToSpeaker 就会必现声音小的问题。


2、解决方案

针对上述行业痛点,通过底层原理的分析与实际项目经验,从代码规范、问题兜底、问题报警梳理出一套可行的解决方案。

听不见 RTC 声音、RTC 声音变小

RTC 的声音问题基本是因为其他模块功能对 AVAudioSession 进行了更改,且在功能结束之后,也没有将 AVAudioSession 重置到 RTC 需要的设置。本身音视频 SDK(如agorazego 等)对这种情况会有一定的兜底逻辑,但是这种兜底如果存在侵入性,也是不合理的,因此具有一定的局限性。

AudioSession 修改规范

由于系统无法区分同一个进程中是哪个模块对 AudioSession 进行了更改,所以为了避免听不见 RTC 声音的问题,在使用 RTC 时,其它模块对 AudioSession 的调用更改,需要遵循以下原则:

  • 模块调用 setCategory 前先判断下,当前 AudioSession 如已满足使用需要,不用再次设置,避免触发 iOS 14 系统 Bug
  • 模块需要录音时,Category 应该使用 PlayAndRecord(为了防止打断正在播放的音频,不要使用仅录音的 CategoryRecord),当前 category 不是 PlayAndRecord 的情况下再调用 setCategory
  • 模块仅需要播放时,当前 categoryPlayAndRecordPlaybackAmbient的情况下不需要 setCategory
  • 若当前的 category不满足模块使用,在 setCategory 之前应该先保存当前的 AudioSession 状态,然后再 setCategory、使用音频功能,使用结束后,应该重新 setCategory 恢复到之前的 AudioSession 状态
  • 在设置 audioSession 时,categoryOptions 都应该包含 AVAudioSessionCategoryOptionDefaultToSpeakerAVAudioSessionCategoryOptionMixWithOthers,iOS10 系统及以上还应包含 AVAudioSessionCategoryOptionAllowBluetooth
//需要录音时,AudioSession的设置代码如下:
if ([AVAudioSession sharedInstance].category != AVAudioSessionCategoryPlayAndRecord) {
            [RTCAudioSessionCacheManager cacheCurrentAudioSession];
            AVAudioSessionCategoryOptions categoryOptions = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionMixWithOthers;
            if (@available(iOS 10.0, *)) {
                categoryOptions |= AVAudioSessionCategoryOptionAllowBluetooth;
            }
            [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:categoryOptions error:nil];
            [[AVAudioSession sharedInstance] setActive:YES error:nil];
}

//功能结束时重置audioSession
[RTCAudioSessionCacheManager resetToCachedAudioSession];
static AVAudioSessionCategory cachedCategory = nil;
static AVAudioSessionCategoryOptions cachedCategoryOptions = nil;

@implementation RTCAudioSessionCacheManager

......

@end

更改audioSession前缓存RTC当下的设置:

+ (void)cacheCurrentAudioSession {
    if (![[AVAudioSession sharedInstance].category isEqualToString:AVAudioSessionCategoryPlayback] && ![[AVAudioSession sharedInstance].category isEqualToString:AVAudioSessionCategoryPlayAndRecord]) {
        return;
    }
    @synchronized (self) {
        cachedCategory = [AVAudioSession sharedInstance].category;
        cachedCategoryOptions = [AVAudioSession sharedInstance].categoryOptions;
    }
}

重置到缓存的audioSession设置:

+ (void)resetToCachedAudioSession {
    if (!cachedCategory || !cachedCategoryOptions) {
        return;
    }
    BOOL needResetAudioSession = ![[AVAudioSession sharedInstance].category isEqualToString:cachedCategory] || [AVAudioSession sharedInstance].categoryOptions != cachedCategoryOptions;
    if (needResetAudioSession) {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [[AVAudioSession sharedInstance] setCategory:cachedCategory withOptions:cachedCategoryOptions error:nil];
            [[AVAudioSession sharedInstance] setActive:YES error:nil];
            @synchronized (self) {
                cachedCategory = nil;
                cachedCategoryOptions = nil;
            }
        });
    }
}
兜底策略

考虑到在线教室场景的复杂度,让教室内所有功能代码都遵循 AVAudioSession的修改规范,虽然有严格的codeReview,但是也存在一定的人为因素风险,随着业务功能不断迭代,无法完全保证线上不出问题,因此一套可靠的兜底策略显得非常有必要。

兜底策略的基本逻辑是 hookAVAudioSession 的变化,当各模块对 AVAudioSession 的设置不符合规范要求时,我们在不影响功能的前提下强制进行修正,比如对 options 补充上混音模式。

通过方法交换我们可以 hookAVAudioSession 的更改。比如用 kk_setCategory:withOptions: error:与系统的 setCategory:withOptions: error: 进行交换,在交换的方法里,我们判断 options 是否包含 AVAudioSessionCategoryOptionMixWithOthers,如果没有包含我们就进行追加。在需要进行对audioSession进行修正的场景下(RTC直播),修改options时未包含mixWithOther,则给options追加mixWithOther

- (BOOL)kk_setCategory:(AVAudioSessionCategory)category withOptions:(AVAudioSessionCategoryOptions)options error:(NSError **)outError {

    BOOL addMixWithOthersEnable = shouldFixAudioSession && !(options & AVAudioSessionCategoryOptionMixWithOthers)];
    if (addMixWithOthersEnable) {
        return [self kk_setCategory:category withOptions:options | AVAudioSessionCategoryOptionMixWithOthers error:outError];;
    }
    return [self kk_setCategory:category withOptions:options error:outError];
}

但上述方法只对通过调用 setCategory:withOptions: error:来设置 audioSession 有效,如果调用了 setCategory:error:来更改audioSession,则会造成调用死循环的问题。在 iOS 底层实现中,调用 setCategory:error:时,内部会再调用 setCategory:withOptions: error:方法,因为进行了方法交换,从而出现嵌套调用问题。

针对该问题,我们通过监听 AVAudioSessionRouteChangeNotification 通知,来 hookcategory 的变化,AVAudioSessionRouteChangeNotification 在调用 setCategory:error: 时会触发,而不会在调用 setCategory:withOptions: error:时直接触发,进而与上述方法形成了很好的互补。

//添加对AVAudioSessionRouteChange的监听
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleRouteChangeNotification:) name:AVAudioSessionRouteChangeNotification object:nil];

- (void)handleRouteChangeNotification:(NSNotification *)notification {
  NSNumber* reasonNumber =
      notification.userInfo[AVAudioSessionRouteChangeReasonKey];
  AVAudioSessionRouteChangeReason reason =
      (AVAudioSessionRouteChangeReason)reasonNumber.unsignedIntegerValue;
    if (reason == AVAudioSessionRouteChangeReasonCategoryChange) {
        AVAudioSessionCategoryOptions currentCategoryOptions = [AVAudioSession sharedInstance].categoryOptions;
        AVAudioSessionCategory currentCategory = [AVAudioSession sharedInstance].category;
        //在需要进行对audioSession进行修正的场景下(RTC直播),修改category时options未包含mixWithOther,则给options追加mixWithOther
        if (shouldFixAudioSession  && !(currentCategoryOptions & AVAudioSessionCategoryOptionMixWithOthers)) {
            [[AVAudioSession sharedInstance] setCategory:currentCategory withOptions:currentCategoryOptions | AVAudioSessionCategoryOptionMixWithOthers error:nil];
        }
    }
}
报警机制

即使有修改规范与兜底策略的保障,随着教室业务迭代与 iOS 系统升级,也无法保证线上完全不出问题,因此我们建立了问题报警机制,当线上出现问题时,能在工作群里及时收到警报,根据警报的问题信息,通过日志进一步排查问题。通过报警机制,我们可以更快速的对线上问题作出反应,不被动依赖于学生的投诉反馈,以最快的速度推进问题解决。

RTC 声音被打断时,底层音视频 SDK 会回调警告错误码(如agorawarningCode 为 1025),当出现对应的警告码时,结合 slardar 的报警功能,在飞书群里以消息的形式进行同步。同时在 hookAVAudioSession 的变更时,通过获取堆栈信息,可以定位到是哪个模块触发的更改,结合报警用户信息,可以更方便的定位问题。

媒体声音被抑制

媒体声音在媒体音量下开启播放,播放途中因为连麦而切换到了通话音量,此时因为系统特性,媒体音量会被通话音量抑制而导致声音变小。针对该问题,我们使用音视频 SDK 提供的混音、混流功能来规避。基本原理是播放媒体资源时,我们拿到资源的 pcm 音频数据,将数据抛给 RTCaudioUnit 进行混合,由 RTC 音频播放单元统一播放,如果此时 RTC 使用的是通话音量,则媒体资源也是使用的通话音量播放,反之亦然。以此来保证媒体资源与 RTC 始终保持统一的音量控制机制,而避免声音大小存在差异。

混音是指给到音频的本地文件路径,或者播放的 url,由 SDK 进行数据读取与播放。混流是指针对视频文件,播放器只解码播放视频数据,将音频数据实时抛出来给到 SDK,SDK 将传入的实时音频数据与RTC音频数据进行混合与播放。项目中我们使用点播 SDK TTVideoEngine 来实现视频播放与音频外抛。


Demo

Demo在我的Github上,欢迎下载。
Multi-MediaDemo

参考文献

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