Mac 录制音频,波形图的绘制

开篇

废话不多说,先上效果图。


animation.gif

上面一闪一闪的大概是没录好吧。🙄

需求分析

首先分析这段动画的过程:

  • 第一阶段:点击图标后,图标像泡泡一样收缩放大;
  • 第二阶段:图标缩小,同时向下方移动;
  • 第三阶段:波形图绘制,随着数据的增多,波形图向左移动,同时视图的宽度越来越长
  • 第四阶段:视图宽度达到一个阈值后不再增大,波形图继续向左移动,超出左侧范围的部分不再显示。

PS. 波形图是上下对称的哦。

实现思路

首先看动画,前两个阶段的动画有先后顺序,第二阶段的两个动画同时发生,可以用动画组来做。

然后,第二、第三阶段之间的节点,可以看到图标刚好缩小到波形视图的高度后,波形视图出现,如果分为两个视图,总有一个生硬的过渡过程,为了达到一个流畅的视觉效果,可以使用一个视图来做。

最后,仔细看波形图,新数据是从右侧进入的,由于这是个录音的过程,因此数据是在不断增多的,可以用实时绘图来实现。

接下来上自定义 button 的代码。

  • 第一阶段 ~ 第二阶段

    - (void)startAnimation{
        self.enabled = NO;  //动画过程中禁用
        [self moveAnchorPointToCenter];   //将锚点移到中心 (为了达到围绕中心缩放的效果)
        
        //放大
        CAKeyframeAnimation *scaleToBigAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
        scaleToBigAnimation.values = @[@(1.0), @(.7f), @(1.f), @(1.3f), @(1.7f)];   //先从1.0缩小到0.7,再放大到1.7,这样就实现了泡泡效果
        scaleToBigAnimation.duration = 0.5;
        scaleToBigAnimation.beginTime = 0;
        
        //缩小
        CAKeyframeAnimation *scaleAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
        scaleAnimation.values = @[@(1.7f), @(1)];
        scaleAnimation.duration = 0.75;
        scaleAnimation.beginTime = scaleToBigAnimation.beginTime + scaleToBigAnimation.duration;
        
        //位置下移,与缩小动画同时进行
        CABasicAnimation *positionAnimation = [CABasicAnimation animationWithKeyPath:@"position.y"];
        positionAnimation.toValue = @(self.layer.position.y - NSWidth(self.frame));
        positionAnimation.duration = scaleAnimation.duration;
        positionAnimation.beginTime = scaleAnimation.beginTime;
        
        //添加动画组
        CAAnimationGroup *animationGroup = [CAAnimationGroup animation];
        animationGroup.delegate = self;
        animationGroup.duration = scaleToBigAnimation.duration + positionAnimation.duration;
        [animationGroup setValue:@"animationGroup" forKey:@"AnimationKey"];
        animationGroup.animations = @[scaleToBigAnimation, scaleAnimation, positionAnimation];
        [self.layer addAnimation:animationGroup forKey:@"animationGroup"];
    }
    
    - (void)moveAnchorPointToCenter{
        //由于图层锚点默认是在原点(0,0),需要让图层围绕中心点缩放
        self.layer.anchorPoint = CGPointMake(0.5, 0.5);
        
        //锚点改变后,为了让图层随着视图移动,将图层的位置也改到锚点的位置
        NSRect rect = self.frame;
        CGFloat centerX = rect.origin.x + rect.size.width / 2.f;
        CGFloat centerY = rect.origin.y + rect.size.height / 2.f;
        self.layer.position = CGPointMake(centerX, centerY);
    }
    
    - (void)resumeAnchorPoint{
        self.layer.anchorPoint = CGPointZero;
        self.layer.position = self.frame.origin;
    }
    

    动画结束后,在代理里触发下一步操作。

    #pragma mark - CAAnimation delegate
    - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag{
        if (flag) {
            if ([self.delegate respondsToSelector:@selector(voiceRecordingWillBegin)]) {
                  //执行代理方法,准备数据
                [self.delegate voiceRecordingWillBegin];
            }
            [self resumeAnchorPoint];
            [self startRecording];
            
        }
    }
    
  • 第三阶段 ~ 第四阶段

    本文 demo 里所有的音频数据都是随机生成的模拟数据,也没有实现真正的录音哈。

    控制器在 voiceRecordingWillBegin 这个方法里,准备需要绘制的数据。

    #pragma mark - voice recording button delegate
    //录音即将开始
    - (void)voiceRecordingWillBegin{
          //之前的动画只是图层动画,图层已经到了目标位置,但视图的 frame 还在原来的位置,因此要修改视图的位置和尺寸
        NSRect frame = self.voiceRecordBtn.frame;
        CGFloat centerX = frame.origin.x + frame.size.width / 2.f;
        CGFloat centerY = frame.origin.y + frame.size.height / 2.f - frame.size.height;
        frame.size.width = 400; //这是蓝色波形图的最大宽度
        frame.origin.x = centerX - frame.size.width / 2.f;
        frame.origin.y = centerY - frame.size.height / 2.f;
        self.voiceRecordBtn.frame = frame;
        
          //添加计时器,构造模拟数据
        [self addTimer];
    }
    
    - (void)addTimer{
        //添加定时器
        _timer = [NSTimer scheduledTimerWithTimeInterval:.1f target:self selector:@selector(addPoint) userInfo:nil repeats:YES];
    
        [[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
    }
    
    - (void)addPoint
    {
        //随机点
         NSPoint point = NSMakePoint(self.voiceRecordBtn.bounds.size.height / 2.f, arc4random_uniform(NSHeight(self.voiceRecordBtn.frame) / 4.f) + 0);
        
        //插入到数组(动画视图最右边),array添加CGPoint需要转换一下
    //    [self.pointArray insertObject:[NSValue valueWithPoint:point] atIndex:0];
        [self.pointArray addObject:[NSValue valueWithPoint:point]];
        
        //传值,重绘视图
        self.voiceRecordBtn.pointArray = self.pointArray;
    }
    

    回到自定义按钮的.m 文件

    点数组的 setter 方法

    - (void)setPointArray:(NSArray *)pointArray{
        _pointArray = pointArray;
        [self setNeedsDisplay:YES];
    }
    

    开始录音

    - (void)startRecording{
        self.talking = YES;
        [self setNeedsDisplay:YES];
        
        //延迟_recordingDuration执行,若没有手动停止,则自动停止录音
        [self performSelector:@selector(stopRecording) withObject:nil afterDelay:_recordingDuration];
    }
    

    停止录音

    - (void)stopRecording{
        //取消延迟执行
        [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(stopRecording) object:nil];
        
        self.enabled = YES;
        self.frame = self.initialFrame; //录音结束后,按钮回到点击前的初始状态
        self.talking = NO;
        
        [self setNeedsDisplay:YES];
        
        if ([self.delegate respondsToSelector:@selector(voiceRecordingDidFinish)]) {
            [self.delegate voiceRecordingDidFinish];
        }
    }
    

    以下是重头戏了,每次调用[self setNeedsDisplay:YES]方法时,系统会自动调用drawRect:(NSRect)dirtyRect 方法,我们在这个方法里绘制数据。

    - (void)drawRect:(NSRect)dirtyRect{
        [super drawRect:dirtyRect];
        
        if (!self.talking) {
            //现在没有在录音,即初始状态
            NSBezierPath *rectPath = [NSBezierPath bezierPathWithOvalInRect:dirtyRect];
            [[NSColor blueColor] setFill];
            [rectPath fill];
            
            NSImage *image = [NSImage imageNamed:@"SideAudio"];
            [image drawInRect:dirtyRect];
            return;
        }
        
        CGFloat midY = NSHeight(dirtyRect) / 2.f;
        CGFloat midX = NSWidth(dirtyRect) / 2.f;
        CGFloat leftX = midX - _pointArray.count / 2.f - _initialWidth / 2.f;
        CGFloat rightX = midX + _pointArray.count / 2.f + _initialWidth / 2.f;
        
        // Drawing code here.
        CGContextRef ctx = [[NSGraphicsContext currentContext] graphicsPort];
        
        //绘制初始线型,模拟一般录音场景,刚开始可能没有说话,一条横线
        CGMutablePathRef linePath = CGPathCreateMutable();
        CGPathMoveToPoint(linePath, nil, leftX, midY);
        CGPathAddLineToPoint(linePath, nil, leftX + _initialWidth, midY); //_initialWidth 横线的宽度,这里给了个固定值
        CGContextAddPath(ctx, linePath);
        
        //绘制上半部分波形
        CGMutablePathRef halfPath = CGPathCreateMutable();                 //绘制路径
        CGPathMoveToPoint(halfPath, nil, NSWidth(dirtyRect), midY);
        for (NSInteger i = 0; i < _pointArray.count; i++) {
            NSValue *pointValue = _pointArray[i];
            NSPoint point = pointValue.pointValue;
            NSInteger j = _pointArray.count - i - 1;
            if (point.y == 0) {
                CGPathMoveToPoint(halfPath, nil, rightX - j + 1, midY);
                CGPathAddLineToPoint(halfPath, NULL, rightX - j, midY);
            }else{
                CGPathMoveToPoint(halfPath, nil, rightX - j, midY);
                CGPathAddLineToPoint(halfPath, NULL, rightX - j, midY + point.y);
            }
        }
        
        //实现波形图反转
        CGMutablePathRef fullPath = CGPathCreateMutable();//创建新路径
        CGPathAddPath(fullPath, NULL, halfPath);          //合并路径
        CGAffineTransform transform = CGAffineTransformIdentity; //反转
        //反转配置
        transform = CGAffineTransformTranslate(transform, 0, NSHeight(dirtyRect));
        transform = CGAffineTransformScale(transform, 1.0, -1.0);
        CGPathAddPath(fullPath, &transform, halfPath);
        
        //将路径添加到上下文中
        CGContextAddPath(ctx, fullPath);
        
        //绘制矩形区域,即不断变长的蓝色背景
        CGMutablePathRef rectPath = CGPathCreateMutable();
        CGPathMoveToPoint(rectPath, nil, leftX, 0);
        CGPathAddRoundedRect(rectPath, nil, CGRectMake(leftX, 0, _pointArray.count + _initialWidth, NSHeight(dirtyRect)), NSHeight(dirtyRect) / 2.f, NSHeight(dirtyRect) / 2.f);
        CGContextAddPath(ctx, rectPath);
        
        CGContextSetLineWidth(ctx, 1);
        CGContextSetStrokeColorWithColor(ctx, [NSColor whiteColor].CGColor);
        CGContextSetFillColorWithColor(ctx, [NSColor blueColor].CGColor);
        CGContextDrawPath(ctx, kCGPathFillStroke);
        
        //移除
        CGPathRelease(halfPath);
        CGPathRelease(fullPath);
    
    }
    

结束,明天放播放录音的动画实现。

Demo 地址:https://github.com/YunFei2015/AudioWaveAnimation.git

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

推荐阅读更多精彩内容