2025-11-21 16:17:05 +08:00
|
|
|
|
//
|
|
|
|
|
|
// QXAudioRecorderView.m
|
|
|
|
|
|
// QXLive
|
|
|
|
|
|
//
|
|
|
|
|
|
// Created by 启星 on 2025/11/13.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "QXAudioRecorderView.h"
|
|
|
|
|
|
#import <AVFoundation/AVFoundation.h>
|
|
|
|
|
|
#import "UIButton+QX.h"
|
|
|
|
|
|
#import "QXCOSUploadManager.h"
|
|
|
|
|
|
#import "QXMineNetwork.h"
|
|
|
|
|
|
|
|
|
|
|
|
@interface QXAudioRecorderView() <AVAudioRecorderDelegate, AVAudioPlayerDelegate,UIGestureRecognizerDelegate>
|
|
|
|
|
|
// UI Components
|
|
|
|
|
|
@property (nonatomic, strong) UILabel *titleLabel;
|
|
|
|
|
|
@property (nonatomic, strong) UIView *recordContentView;
|
|
|
|
|
|
@property (nonatomic, strong) UIImageView *recordButton;
|
|
|
|
|
|
@property (nonatomic, strong) CAShapeLayer *progressLayer;
|
|
|
|
|
|
@property (nonatomic, strong) UILabel *timeLabel;
|
|
|
|
|
|
@property (nonatomic, strong) UILabel *stateLabel;
|
|
|
|
|
|
@property (nonatomic, strong) UIButton *playButton;
|
|
|
|
|
|
@property (nonatomic, strong) UIButton *resetButton;
|
|
|
|
|
|
@property (nonatomic, strong) UIButton *authBtn;
|
|
|
|
|
|
|
|
|
|
|
|
// Audio Components
|
|
|
|
|
|
@property (nonatomic, strong) AVAudioRecorder *audioRecorder;
|
|
|
|
|
|
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
|
|
|
|
|
|
@property (nonatomic, strong) NSURL *originalAudioFileURL;
|
|
|
|
|
|
@property (nonatomic, strong) NSURL *mp3AudioFileURL;
|
|
|
|
|
|
@property (nonatomic, strong) NSURL *tempAudioFileURL; // 临时拼接文件
|
|
|
|
|
|
|
|
|
|
|
|
// Recording State
|
|
|
|
|
|
@property (nonatomic, assign) AudioRecorderState state;
|
|
|
|
|
|
@property (nonatomic, assign) NSTimeInterval currentDuration; // 当前段时长
|
|
|
|
|
|
@property (nonatomic, assign) NSTimeInterval totalDuration; // 总时长
|
|
|
|
|
|
@property (nonatomic, assign) NSInteger recordingSessionCount; // 录制段数
|
|
|
|
|
|
@property (nonatomic, strong) NSTimer *recordingTimer;
|
|
|
|
|
|
@property (nonatomic, assign) BOOL isCancelled;
|
|
|
|
|
|
@property (nonatomic, strong) NSMutableArray<NSURL *> *recordedSegments; // 录制的音频段
|
|
|
|
|
|
@end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@implementation QXAudioRecorderView
|
|
|
|
|
|
|
|
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame {
|
|
|
|
|
|
return [self initWithFrame:frame maxDuration:300.0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame maxDuration:(NSTimeInterval)maxDuration {
|
|
|
|
|
|
self = [super initWithFrame:frame];
|
|
|
|
|
|
if (self) {
|
|
|
|
|
|
_maxDuration = maxDuration;
|
|
|
|
|
|
_showPlaybackButton = YES;
|
|
|
|
|
|
_state = AudioRecorderStateReady;
|
|
|
|
|
|
_recordedSegments = [NSMutableArray array];
|
|
|
|
|
|
_recordingSessionCount = 0;
|
|
|
|
|
|
[self setupUI];
|
|
|
|
|
|
[self setupAudioSession];
|
|
|
|
|
|
[self setupGestureRecognizers];
|
|
|
|
|
|
}
|
|
|
|
|
|
return self;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)setupUI {
|
|
|
|
|
|
self.backgroundColor = [UIColor whiteColor];
|
|
|
|
|
|
[self addRoundedCornersWithRadius:16 byRoundingCorners:(UIRectCornerTopLeft|UIRectCornerTopRight)];
|
|
|
|
|
|
|
|
|
|
|
|
self.authBtn = [[UIButton alloc] initWithFrame:CGRectMake(SCREEN_WIDTH-ScaleWidth(57)-16, 16, ScaleWidth(57), ScaleWidth(20))];
|
|
|
|
|
|
[self.authBtn setImage:[UIImage imageNamed:@"singer_auth_btn"] forState:(UIControlStateNormal)];
|
|
|
|
|
|
[self.authBtn addTarget:self action:@selector(authAction) forControlEvents:(UIControlEventTouchUpInside)];
|
|
|
|
|
|
[self addSubview:self.authBtn];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.titleLabel = [[UILabel alloc] initWithFrame:CGRectMake((SCREEN_WIDTH-150)/2, 18, 150, 24)];
|
|
|
|
|
|
self.titleLabel.textColor = RGB16(0x333333);
|
|
|
|
|
|
self.titleLabel.font = [UIFont boldSystemFontOfSize:16];
|
|
|
|
|
|
self.titleLabel.text = @"我的认证";
|
|
|
|
|
|
self.titleLabel.textAlignment = NSTextAlignmentCenter;
|
|
|
|
|
|
[self addSubview:self.titleLabel];
|
|
|
|
|
|
|
|
|
|
|
|
self.recordContentView = [[UIView alloc] init];
|
|
|
|
|
|
[self addSubview:self.recordContentView];
|
|
|
|
|
|
|
|
|
|
|
|
// 录制按钮
|
|
|
|
|
|
self.recordButton = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"singer_record_btn"]];
|
|
|
|
|
|
self.recordButton.userInteractionEnabled = YES;
|
|
|
|
|
|
[self.recordContentView addSubview:self.recordButton];
|
|
|
|
|
|
|
|
|
|
|
|
// 进度圆环
|
|
|
|
|
|
self.progressLayer = [CAShapeLayer layer];
|
|
|
|
|
|
self.progressLayer.fillColor = [UIColor clearColor].CGColor;
|
|
|
|
|
|
self.progressLayer.strokeColor = QXConfig.themeColor.CGColor;
|
|
|
|
|
|
self.progressLayer.lineWidth = 4;
|
|
|
|
|
|
self.progressLayer.strokeEnd = 0;
|
|
|
|
|
|
// 确保圆环从顶部开始
|
|
|
|
|
|
self.progressLayer.strokeStart = 0; // 从起点开始
|
|
|
|
|
|
self.progressLayer.strokeEnd = 0; // 初始进度为0
|
|
|
|
|
|
// 如果需要动画效果,可以设置transform
|
|
|
|
|
|
self.progressLayer.transform = CATransform3DIdentity;
|
|
|
|
|
|
self.progressLayer.lineCap = kCALineCapRound;
|
|
|
|
|
|
[self.recordContentView.layer addSublayer:self.progressLayer];
|
|
|
|
|
|
|
|
|
|
|
|
// 时间标签
|
|
|
|
|
|
self.timeLabel = [[UILabel alloc] init];
|
|
|
|
|
|
self.timeLabel.text = [self formattedTime:0];
|
|
|
|
|
|
self.timeLabel.textAlignment = NSTextAlignmentCenter;
|
|
|
|
|
|
self.timeLabel.font = [UIFont systemFontOfSize:12];
|
|
|
|
|
|
self.timeLabel.textColor = RGB16A(0x000000, 0.45);
|
|
|
|
|
|
[self addSubview:self.timeLabel];
|
|
|
|
|
|
|
|
|
|
|
|
// 状态标签
|
|
|
|
|
|
self.stateLabel = [[UILabel alloc] init];
|
|
|
|
|
|
self.stateLabel.text = @"长按录音";
|
|
|
|
|
|
self.stateLabel.textAlignment = NSTextAlignmentCenter;
|
|
|
|
|
|
self.stateLabel.font = [UIFont systemFontOfSize:14];
|
|
|
|
|
|
self.stateLabel.textColor = [UIColor grayColor];
|
|
|
|
|
|
[self addSubview:self.stateLabel];
|
|
|
|
|
|
//
|
|
|
|
|
|
// // 录制段数标签
|
|
|
|
|
|
// self.sessionLabel = [[UILabel alloc] init];
|
|
|
|
|
|
// self.sessionLabel.text = @"";
|
|
|
|
|
|
// self.sessionLabel.textAlignment = NSTextAlignmentCenter;
|
|
|
|
|
|
// self.sessionLabel.font = [UIFont systemFontOfSize:12];
|
|
|
|
|
|
// self.sessionLabel.textColor = [UIColor lightGrayColor];
|
|
|
|
|
|
// [self addSubview:self.sessionLabel];
|
|
|
|
|
|
|
|
|
|
|
|
// 试听按钮
|
|
|
|
|
|
self.playButton = [[UIButton alloc] init];
|
|
|
|
|
|
[self.playButton setTitle:@"试听" forState:UIControlStateNormal];
|
|
|
|
|
|
[self.playButton setTitleColor:RGB16A(0x000000, 0.45) forState:(UIControlStateNormal)];
|
|
|
|
|
|
self.playButton.titleLabel.font = [UIFont systemFontOfSize:12];
|
|
|
|
|
|
[self.playButton setImage:[UIImage imageNamed:@"singer_try_listen"] forState:(UIControlStateNormal)];
|
|
|
|
|
|
[self.playButton addTarget:self action:@selector(playButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
|
|
|
|
|
// self.playButton.hidden = YES;
|
|
|
|
|
|
[self addSubview:self.playButton];
|
|
|
|
|
|
|
|
|
|
|
|
// 重置按钮
|
|
|
|
|
|
self.resetButton = [[UIButton alloc] init];
|
|
|
|
|
|
[self.resetButton setTitle:@"重置" forState:UIControlStateNormal];
|
|
|
|
|
|
[self.resetButton setImage:[UIImage imageNamed:@"singer_reset_record"] forState:(UIControlStateNormal)];
|
|
|
|
|
|
[self.resetButton setTitleColor:RGB16A(0x000000, 0.45) forState:(UIControlStateNormal)];
|
|
|
|
|
|
self.resetButton.titleLabel.font = [UIFont systemFontOfSize:12];
|
|
|
|
|
|
[self.resetButton addTarget:self action:@selector(resetButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
|
|
|
|
|
// self.resetButton.hidden = YES;
|
|
|
|
|
|
[self addSubview:self.resetButton];
|
|
|
|
|
|
[self.recordContentView bringSubviewToFront:self.recordButton];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)setupAudioSession {
|
|
|
|
|
|
// AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
|
|
|
|
|
// NSError *error = nil;
|
|
|
|
|
|
// [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:&error];
|
|
|
|
|
|
// [audioSession setActive:YES error:&error];
|
|
|
|
|
|
//
|
|
|
|
|
|
// if (error) {
|
|
|
|
|
|
// NSLog(@"Audio session setup error: %@", error);
|
|
|
|
|
|
// }
|
|
|
|
|
|
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
|
|
|
|
|
NSError *error = nil;
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 Playback 类别,会自动使用扬声器
|
|
|
|
|
|
[audioSession setCategory:AVAudioSessionCategoryPlayback error:&error];
|
|
|
|
|
|
|
|
|
|
|
|
// 或者使用 PlayAndRecord 但覆盖默认输出
|
|
|
|
|
|
[audioSession setCategory:AVAudioSessionCategoryPlayAndRecord
|
|
|
|
|
|
withOptions:AVAudioSessionCategoryOptionDefaultToSpeaker
|
|
|
|
|
|
error:&error];
|
|
|
|
|
|
|
|
|
|
|
|
[audioSession setActive:YES error:&error];
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
NSLog(@"Audio session setup error: %@", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)setupGestureRecognizers {
|
|
|
|
|
|
// 长按手势 - 开始录制
|
|
|
|
|
|
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
|
|
|
|
|
longPress.minimumPressDuration = 0.3;
|
|
|
|
|
|
longPress.delegate = self;
|
|
|
|
|
|
[self.recordButton addGestureRecognizer:longPress];
|
|
|
|
|
|
}
|
|
|
|
|
|
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
|
|
|
|
|
|
return YES;
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)layoutSubviews {
|
|
|
|
|
|
[super layoutSubviews];
|
|
|
|
|
|
|
|
|
|
|
|
CGFloat centerX = CGRectGetWidth(self.bounds) / 2;
|
|
|
|
|
|
// 时间标签
|
|
|
|
|
|
self.timeLabel.frame = CGRectMake((SCREEN_WIDTH-150)/2, self.titleLabel.bottom+4, 150, 30);
|
|
|
|
|
|
|
|
|
|
|
|
// 录制按钮
|
|
|
|
|
|
self.recordContentView.frame = CGRectMake(centerX - 50.5, self.timeLabel.bottom+22, 101, 101);
|
|
|
|
|
|
self.recordButton.frame = CGRectMake(11, 11, 80, 80);
|
|
|
|
|
|
|
|
|
|
|
|
// 进度圆环
|
|
|
|
|
|
CGRect progressRect = CGRectInset(self.recordContentView.bounds, 4, 4);
|
|
|
|
|
|
UIBezierPath *progressPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(CGRectGetMidX(progressRect), CGRectGetMidY(progressRect))
|
|
|
|
|
|
radius:progressRect.size.width / 2
|
|
|
|
|
|
startAngle:-M_PI_2 // 从顶部开始(-90度)
|
|
|
|
|
|
endAngle:-M_PI_2 + M_PI * 2 // 顺时针一圈
|
|
|
|
|
|
clockwise:YES];
|
|
|
|
|
|
|
|
|
|
|
|
self.progressLayer.path = progressPath.CGPath;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// // 状态标签
|
|
|
|
|
|
self.stateLabel.frame = CGRectMake(0, self.recordContentView.bottom, CGRectGetWidth(self.bounds), 20);
|
|
|
|
|
|
//
|
|
|
|
|
|
// // 段数标签
|
|
|
|
|
|
// self.sessionLabel.frame = CGRectMake(0, 205, CGRectGetWidth(self.bounds), 20);
|
|
|
|
|
|
|
|
|
|
|
|
// 按钮
|
|
|
|
|
|
self.playButton.frame = CGRectMake(SCREEN_WIDTH-38-35, self.recordContentView.top+35, 38, 60);
|
|
|
|
|
|
self.resetButton.frame = CGRectMake(35, self.recordContentView.top+35, 38, 60);
|
|
|
|
|
|
[self.playButton qx_layoutButtonNOSizeToFitWithEdgeInsetsStyle:(QXButtonEdgeInsetsStyleTop) imageTitleSpace:2];
|
|
|
|
|
|
[self.resetButton qx_layoutButtonNOSizeToFitWithEdgeInsetsStyle:(QXButtonEdgeInsetsStyleTop) imageTitleSpace:2];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
-(void)authAction{
|
|
|
|
|
|
///去认证
|
2025-12-04 14:11:00 +08:00
|
|
|
|
NSInteger minSecond = 60;
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
minSecond = 5;
|
|
|
|
|
|
# else
|
|
|
|
|
|
minSecond = 60;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
if ((int)self.totalDuration < minSecond) {
|
|
|
|
|
|
NSString *toast = [NSString stringWithFormat:@"录音时长不得小于%ld秒",minSecond];
|
|
|
|
|
|
showToast(toast);
|
2025-11-21 16:17:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self.originalAudioFileURL==nil) {
|
|
|
|
|
|
showToast(@"录制文件不存在");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-04 14:11:00 +08:00
|
|
|
|
if (self.delegate && [self.delegate respondsToSelector:@selector(didClickAuthWithFileUrl:)]) {
|
|
|
|
|
|
[self.delegate didClickAuthWithFileUrl:self.originalAudioFileURL];
|
|
|
|
|
|
}
|
2025-11-21 16:17:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Gesture Handlers
|
|
|
|
|
|
|
|
|
|
|
|
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
|
|
|
|
|
|
CGPoint touchPoint = [gesture locationInView:self];
|
|
|
|
|
|
|
|
|
|
|
|
switch (gesture.state) {
|
|
|
|
|
|
case UIGestureRecognizerStateBegan:
|
|
|
|
|
|
[self stopPlayback];
|
|
|
|
|
|
NSLog(@"开始录制 - 第%ld段", (long)self.recordingSessionCount + 1);
|
|
|
|
|
|
self.isCancelled = NO;
|
|
|
|
|
|
[self startRecording];
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case UIGestureRecognizerStateChanged:
|
|
|
|
|
|
// 检查是否上滑取消
|
|
|
|
|
|
if (touchPoint.y < CGRectGetMinY(self.recordButton.frame) - 20) {
|
|
|
|
|
|
if (!self.isCancelled) {
|
|
|
|
|
|
NSLog(@"取消录制");
|
|
|
|
|
|
self.isCancelled = YES;
|
|
|
|
|
|
[self updateUIForCancelled];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (self.isCancelled) {
|
|
|
|
|
|
NSLog(@"恢复录制");
|
|
|
|
|
|
self.isCancelled = NO;
|
|
|
|
|
|
[self updateUIForRecording];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case UIGestureRecognizerStateEnded:
|
|
|
|
|
|
case UIGestureRecognizerStateCancelled:
|
|
|
|
|
|
NSLog(@"结束录制 - 当前段时长: %.1f秒", self.currentDuration);
|
|
|
|
|
|
if (self.isCancelled) {
|
|
|
|
|
|
[self cancelRecording];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
[self stopRecording];
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Recording Control
|
|
|
|
|
|
|
|
|
|
|
|
- (void)startRecording {
|
|
|
|
|
|
if (self.state == AudioRecorderStateRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 配置录音器
|
|
|
|
|
|
NSURL *audioFileURL = [self generateAudioFileURL];
|
|
|
|
|
|
NSDictionary *recordSettings = @{
|
|
|
|
|
|
AVFormatIDKey: @(kAudioFormatLinearPCM),
|
|
|
|
|
|
AVSampleRateKey: @44100.0,
|
|
|
|
|
|
AVNumberOfChannelsKey: @1,
|
|
|
|
|
|
AVEncoderAudioQualityKey: @(AVAudioQualityHigh)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
NSError *error = nil;
|
|
|
|
|
|
self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:audioFileURL settings:recordSettings error:&error];
|
|
|
|
|
|
self.audioRecorder.delegate = self;
|
|
|
|
|
|
self.audioRecorder.meteringEnabled = YES;
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
NSLog(@"录音器初始化失败: %@", error);
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didFailWithError:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didFailWithError:error];
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.audioRecorder record]) {
|
|
|
|
|
|
self.state = AudioRecorderStateRecording;
|
|
|
|
|
|
self.recordingSessionCount++;
|
|
|
|
|
|
[self startRecordingTimer];
|
|
|
|
|
|
[self updateUIForRecording];
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)stopRecording {
|
|
|
|
|
|
if (self.state != AudioRecorderStateRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
[self.recordingTimer invalidate];
|
|
|
|
|
|
self.recordingTimer = nil;
|
|
|
|
|
|
|
|
|
|
|
|
NSTimeInterval segmentDuration = self.currentDuration;
|
|
|
|
|
|
[self.audioRecorder stop];
|
|
|
|
|
|
|
|
|
|
|
|
// 保存录制的音频段
|
|
|
|
|
|
if (segmentDuration > 0.5) { // 只保存超过0.5秒的有效录音
|
|
|
|
|
|
[self.recordedSegments addObject:self.audioRecorder.url];
|
|
|
|
|
|
self.totalDuration += segmentDuration;
|
|
|
|
|
|
NSLog(@"保存音频段,时长: %.1f秒,总时长: %.1f秒", segmentDuration, self.totalDuration);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.audioRecorder = nil;
|
|
|
|
|
|
self.currentDuration = 0;
|
|
|
|
|
|
|
|
|
|
|
|
self.state = AudioRecorderStateStopped;
|
|
|
|
|
|
[self updateUIForStopped];
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有录音段,合并音频
|
|
|
|
|
|
if (self.recordedSegments.count > 0) {
|
|
|
|
|
|
[self mergeAudioSegments];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)cancelRecording {
|
|
|
|
|
|
if (self.state != AudioRecorderStateRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
[self.recordingTimer invalidate];
|
|
|
|
|
|
self.recordingTimer = nil;
|
|
|
|
|
|
|
|
|
|
|
|
[self.audioRecorder stop];
|
|
|
|
|
|
[self.audioRecorder deleteRecording];
|
|
|
|
|
|
self.audioRecorder = nil;
|
|
|
|
|
|
self.currentDuration = 0;
|
|
|
|
|
|
|
|
|
|
|
|
self.state = AudioRecorderStateStopped;
|
|
|
|
|
|
[self updateUIForStopped];
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Audio Merging
|
|
|
|
|
|
|
|
|
|
|
|
- (void)mergeAudioSegments {
|
|
|
|
|
|
if (self.recordedSegments.count == 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
|
|
|
|
// 创建合并文件
|
|
|
|
|
|
NSURL *mergedFileURL = [self generateAudioFileURLWithExtension:@"m4a"];
|
|
|
|
|
|
|
|
|
|
|
|
// 创建音频组合
|
|
|
|
|
|
AVMutableComposition *composition = [AVMutableComposition composition];
|
|
|
|
|
|
CMTime currentTime = kCMTimeZero;
|
|
|
|
|
|
|
|
|
|
|
|
for (NSURL *audioURL in self.recordedSegments) {
|
|
|
|
|
|
AVURLAsset *audioAsset = [AVURLAsset assetWithURL:audioURL];
|
|
|
|
|
|
CMTimeRange timeRange = CMTimeRangeMake(kCMTimeZero, audioAsset.duration);
|
|
|
|
|
|
|
|
|
|
|
|
// 添加音频轨道
|
|
|
|
|
|
AVMutableCompositionTrack *audioTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
|
|
|
|
|
|
|
|
|
|
|
|
AVAssetTrack *sourceAudioTrack = [[audioAsset tracksWithMediaType:AVMediaTypeAudio] firstObject];
|
|
|
|
|
|
if (sourceAudioTrack) {
|
|
|
|
|
|
[audioTrack insertTimeRange:timeRange ofTrack:sourceAudioTrack atTime:currentTime error:nil];
|
|
|
|
|
|
currentTime = CMTimeAdd(currentTime, audioAsset.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 导出合并的音频
|
|
|
|
|
|
AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:composition presetName:AVAssetExportPresetAppleM4A];
|
|
|
|
|
|
exportSession.outputURL = mergedFileURL;
|
|
|
|
|
|
exportSession.outputFileType = AVFileTypeAppleM4A;
|
|
|
|
|
|
|
|
|
|
|
|
[exportSession exportAsynchronouslyWithCompletionHandler:^{
|
|
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
|
|
|
|
if (exportSession.status == AVAssetExportSessionStatusCompleted) {
|
|
|
|
|
|
self.originalAudioFileURL = mergedFileURL;
|
|
|
|
|
|
NSLog(@"音频合并完成,总时长: %.1f秒", self.totalDuration);
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为MP3
|
|
|
|
|
|
[self convertToMP3];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
NSLog(@"音频合并失败: %@", exportSession.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}];
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Reset Function
|
|
|
|
|
|
|
|
|
|
|
|
- (void)resetRecording {
|
|
|
|
|
|
NSLog(@"重置录音");
|
|
|
|
|
|
|
|
|
|
|
|
[self stopPlayback];
|
|
|
|
|
|
[self cancelRecording];
|
|
|
|
|
|
|
|
|
|
|
|
// 清理所有录音段
|
|
|
|
|
|
for (NSURL *segmentURL in self.recordedSegments) {
|
|
|
|
|
|
[[NSFileManager defaultManager] removeItemAtURL:segmentURL error:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
[self.recordedSegments removeAllObjects];
|
|
|
|
|
|
|
|
|
|
|
|
// 清理合并文件
|
|
|
|
|
|
if (self.originalAudioFileURL) {
|
|
|
|
|
|
[[NSFileManager defaultManager] removeItemAtURL:self.originalAudioFileURL error:nil];
|
|
|
|
|
|
self.originalAudioFileURL = nil;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (self.mp3AudioFileURL) {
|
|
|
|
|
|
[[NSFileManager defaultManager] removeItemAtURL:self.mp3AudioFileURL error:nil];
|
|
|
|
|
|
self.mp3AudioFileURL = nil;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置状态
|
|
|
|
|
|
self.totalDuration = 0;
|
|
|
|
|
|
self.currentDuration = 0;
|
|
|
|
|
|
self.recordingSessionCount = 0;
|
|
|
|
|
|
self.state = AudioRecorderStateReady;
|
|
|
|
|
|
|
|
|
|
|
|
[self updateUIForReady];
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Playback Control
|
|
|
|
|
|
|
|
|
|
|
|
- (void)playButtonTapped {
|
|
|
|
|
|
if (self.state == AudioRecorderStatePlaying) {
|
|
|
|
|
|
[self stopPlayback];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
[self playRecording];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)playRecording {
|
|
|
|
|
|
if (self.state == AudioRecorderStatePlaying) return;
|
|
|
|
|
|
|
|
|
|
|
|
NSURL *playbackURL = self.mp3AudioFileURL ?: self.originalAudioFileURL;
|
|
|
|
|
|
if (!playbackURL){
|
|
|
|
|
|
showToast(@"您还未录制声音");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 播放前切换到扬声器
|
|
|
|
|
|
// AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
|
|
|
|
|
// NSError *error = nil;
|
|
|
|
|
|
// [audioSession setCategory:AVAudioSessionCategoryPlayback error:&error];
|
|
|
|
|
|
// [audioSession overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:&error];
|
|
|
|
|
|
// [audioSession setActive:YES error:&error];
|
|
|
|
|
|
|
|
|
|
|
|
NSError *playerError = nil;
|
|
|
|
|
|
self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:playbackURL error:&playerError];
|
|
|
|
|
|
self.audioPlayer.delegate = self;
|
|
|
|
|
|
|
|
|
|
|
|
if (playerError) {
|
|
|
|
|
|
NSLog(@"播放器初始化失败: %@", playerError);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.audioPlayer play]) {
|
|
|
|
|
|
self.state = AudioRecorderStatePlaying;
|
|
|
|
|
|
[self updateUIForPlaying];
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)stopPlayback {
|
|
|
|
|
|
if (self.state != AudioRecorderStatePlaying) return;
|
|
|
|
|
|
|
|
|
|
|
|
[self.audioPlayer stop];
|
|
|
|
|
|
self.audioPlayer = nil;
|
|
|
|
|
|
|
|
|
|
|
|
self.state = AudioRecorderStateStopped;
|
|
|
|
|
|
[self updateUIForStopped];
|
|
|
|
|
|
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didChangeState:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didChangeState:self.state];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)resetButtonTapped {
|
|
|
|
|
|
[self resetRecording];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Timer
|
|
|
|
|
|
|
|
|
|
|
|
- (void)startRecordingTimer {
|
|
|
|
|
|
self.currentDuration = 0;
|
|
|
|
|
|
self.recordingTimer = [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(updateRecordingProgress) userInfo:nil repeats:YES];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateRecordingProgress {
|
|
|
|
|
|
if (self.state != AudioRecorderStateRecording) return;
|
|
|
|
|
|
|
|
|
|
|
|
self.currentDuration += 0.1;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新UI
|
|
|
|
|
|
[self updateProgress];
|
|
|
|
|
|
[self updateTimeLabel];
|
|
|
|
|
|
|
|
|
|
|
|
// 通知代理进度更新
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didUpdateProgress:totalDuration:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didUpdateProgress:self.currentDuration totalDuration:self.totalDuration + self.currentDuration];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否达到最大时长
|
|
|
|
|
|
if ((self.totalDuration + self.currentDuration) >= self.maxDuration) {
|
|
|
|
|
|
[self stopRecording];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - UI Updates
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateUIForReady {
|
|
|
|
|
|
self.progressLayer.strokeEnd = 0;
|
|
|
|
|
|
self.timeLabel.text = [self formattedTime:0];
|
|
|
|
|
|
self.stateLabel.text = @"长按录音";
|
|
|
|
|
|
//
|
|
|
|
|
|
// self.playButton.hidden = YES;
|
|
|
|
|
|
// self.resetButton.hidden = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateUIForRecording {
|
|
|
|
|
|
self.stateLabel.text = self.isCancelled ? @"松开手指取消" : @"录音中...松开结束";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// self.playButton.hidden = YES;
|
|
|
|
|
|
// self.resetButton.hidden = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateUIForStopped {
|
|
|
|
|
|
self.stateLabel.text = @"继续录音";
|
|
|
|
|
|
if (self.totalDuration > 0) {
|
|
|
|
|
|
if (self.showPlaybackButton) {
|
|
|
|
|
|
// self.playButton.hidden = NO;
|
|
|
|
|
|
// self.resetButton.hidden = NO;
|
|
|
|
|
|
[self.playButton setTitle:@"试听" forState:UIControlStateNormal];
|
|
|
|
|
|
[self.playButton setImage:[UIImage imageNamed:@"singer_try_listen"] forState:UIControlStateNormal];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// self.playButton.hidden = YES;
|
|
|
|
|
|
// self.resetButton.hidden = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// self.stateLabel.textColor = [UIColor grayColor];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateUIForPlaying {
|
|
|
|
|
|
// self.stateLabel.text = @"试听中...";
|
|
|
|
|
|
// self.stateLabel.textColor = [UIColor blueColor];
|
|
|
|
|
|
[self.playButton setTitle:@"停止" forState:UIControlStateNormal];
|
|
|
|
|
|
[self.playButton setImage:[UIImage imageNamed:@"singer_listen_stop"] forState:UIControlStateNormal];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateUIForCancelled {
|
|
|
|
|
|
// self.stateLabel.text = @"松开手指取消";
|
|
|
|
|
|
// self.stateLabel.textColor = [UIColor redColor];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateProgress {
|
|
|
|
|
|
CGFloat totalProgress = (self.totalDuration + self.currentDuration) / self.maxDuration;
|
|
|
|
|
|
self.progressLayer.strokeEnd = totalProgress;
|
|
|
|
|
|
|
|
|
|
|
|
// // 根据进度改变颜色
|
|
|
|
|
|
// if (totalProgress > 0.8) {
|
|
|
|
|
|
// self.progressLayer.strokeColor = [UIColor redColor].CGColor;
|
|
|
|
|
|
// } else if (totalProgress > 0.6) {
|
|
|
|
|
|
// self.progressLayer.strokeColor = [UIColor orangeColor].CGColor;
|
|
|
|
|
|
// } else {
|
|
|
|
|
|
// self.progressLayer.strokeColor = [UIColor colorWithRed:0.0 green:0.48 blue:1.0 alpha:1.0].CGColor;
|
|
|
|
|
|
// }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)updateTimeLabel {
|
|
|
|
|
|
NSTimeInterval currentTotal = self.totalDuration + self.currentDuration;
|
|
|
|
|
|
self.timeLabel.text = [self formattedTime:currentTotal];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - MP3 Conversion
|
|
|
|
|
|
|
|
|
|
|
|
- (void)convertToMP3 {
|
|
|
|
|
|
// if (!self.originalAudioFileURL) return;
|
|
|
|
|
|
//
|
|
|
|
|
|
// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
|
|
|
|
// NSString *mp3FileName = [NSString stringWithFormat:@"%@.mp3", [[NSUUID UUID] UUIDString]];
|
|
|
|
|
|
// NSString *mp3FilePath = [NSTemporaryDirectory() stringByAppendingPathComponent:mp3FileName];
|
|
|
|
|
|
// self.mp3AudioFileURL = [NSURL fileURLWithPath:mp3FilePath];
|
|
|
|
|
|
//
|
|
|
|
|
|
// @try {
|
|
|
|
|
|
// int read, write;
|
|
|
|
|
|
//
|
|
|
|
|
|
// FILE *pcm = fopen([[self.originalAudioFileURL path] UTF8String], "rb");
|
|
|
|
|
|
// FILE *mp3 = fopen([mp3FilePath UTF8String], "wb");
|
|
|
|
|
|
//
|
|
|
|
|
|
// const int PCM_SIZE = 8192;
|
|
|
|
|
|
// const int MP3_SIZE = 8192;
|
|
|
|
|
|
// short int pcm_buffer[PCM_SIZE * 2];
|
|
|
|
|
|
// unsigned char mp3_buffer[MP3_SIZE];
|
|
|
|
|
|
//
|
|
|
|
|
|
// lame_t lame = lame_init();
|
|
|
|
|
|
// lame_set_in_samplerate(lame, 44100);
|
|
|
|
|
|
// lame_set_VBR(lame, vbr_default);
|
|
|
|
|
|
// lame_init_params(lame);
|
|
|
|
|
|
//
|
|
|
|
|
|
// do {
|
|
|
|
|
|
// read = (int)fread(pcm_buffer, 2 * sizeof(short int), PCM_SIZE, pcm);
|
|
|
|
|
|
// if (read == 0)
|
|
|
|
|
|
// write = lame_encode_flush(lame, mp3_buffer, MP3_SIZE);
|
|
|
|
|
|
// else
|
|
|
|
|
|
// write = lame_encode_buffer_interleaved(lame, pcm_buffer, read, mp3_buffer, MP3_SIZE);
|
|
|
|
|
|
//
|
|
|
|
|
|
// fwrite(mp3_buffer, write, 1, mp3);
|
|
|
|
|
|
//
|
|
|
|
|
|
// } while (read != 0);
|
|
|
|
|
|
//
|
|
|
|
|
|
// lame_close(lame);
|
|
|
|
|
|
// fclose(mp3);
|
|
|
|
|
|
// fclose(pcm);
|
|
|
|
|
|
// }
|
|
|
|
|
|
// @catch (NSException *exception) {
|
|
|
|
|
|
// NSLog(@"MP3转换失败: %@", exception);
|
|
|
|
|
|
// self.mp3AudioFileURL = nil;
|
|
|
|
|
|
// }
|
|
|
|
|
|
// @finally {
|
|
|
|
|
|
// dispatch_async(dispatch_get_main_queue(), ^{
|
|
|
|
|
|
// if (self.mp3AudioFileURL && [self.delegate respondsToSelector:@selector(audioRecorderView:didFinishRecordingWithMP3File:duration:)]) {
|
|
|
|
|
|
// [self.delegate audioRecorderView:self didFinishRecordingWithMP3File:self.mp3AudioFileURL duration:self.totalDuration];
|
|
|
|
|
|
// }
|
|
|
|
|
|
// });
|
|
|
|
|
|
// }
|
|
|
|
|
|
// });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Utility Methods
|
|
|
|
|
|
|
|
|
|
|
|
- (NSURL *)generateAudioFileURL {
|
|
|
|
|
|
return [self generateAudioFileURLWithExtension:@"caf"];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSURL *)generateAudioFileURLWithExtension:(NSString *)extension {
|
|
|
|
|
|
long long time = [[NSDate date] timeIntervalSince1970];
|
|
|
|
|
|
NSString *fileName = [NSString stringWithFormat:@"%lld.%@", time ,extension];
|
|
|
|
|
|
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
|
|
|
|
|
|
return [NSURL fileURLWithPath:filePath];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)formattedTime:(NSTimeInterval)time {
|
|
|
|
|
|
int minutes = (int)time / 60;
|
|
|
|
|
|
int seconds = (int)time % 60;
|
|
|
|
|
|
int maxMinutes = (int)_maxDuration / 60;
|
|
|
|
|
|
int maxSeconds = (int)_maxDuration % 60;
|
|
|
|
|
|
return [NSString stringWithFormat:@"%02d:%02d | %02d:%02d", minutes, seconds,maxMinutes,maxSeconds];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)getRecordingStatus {
|
|
|
|
|
|
if (self.totalDuration > 0) {
|
|
|
|
|
|
return [NSString stringWithFormat:@"已录制%.1f秒,共%ld段", self.totalDuration, (long)self.recordingSessionCount];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return @"未开始录制";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - AVAudioRecorderDelegate
|
|
|
|
|
|
|
|
|
|
|
|
- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag {
|
|
|
|
|
|
NSLog(@"录音完成: %@", flag ? @"成功" : @"失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error {
|
|
|
|
|
|
NSLog(@"录音编码错误: %@", error);
|
|
|
|
|
|
if ([self.delegate respondsToSelector:@selector(qxAudioRecorderView:didFailWithError:)]) {
|
|
|
|
|
|
[self.delegate qxAudioRecorderView:self didFailWithError:error];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - AVAudioPlayerDelegate
|
|
|
|
|
|
|
|
|
|
|
|
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
|
|
|
|
|
|
[self stopPlayback];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error {
|
|
|
|
|
|
NSLog(@"播放解码错误: %@", error);
|
|
|
|
|
|
[self stopPlayback];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Public Properties
|
|
|
|
|
|
|
|
|
|
|
|
- (NSURL *)currentAudioFileURL {
|
|
|
|
|
|
return self.mp3AudioFileURL ?: self.originalAudioFileURL;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)dealloc {
|
|
|
|
|
|
[self.recordingTimer invalidate];
|
|
|
|
|
|
[self.audioRecorder stop];
|
|
|
|
|
|
[self.audioPlayer stop];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@end
|