第4章:包络与音量控制

4.1 为什么需要包络?

你有没有想过,为什么钢琴按下一个键,声音是慢慢变弱?

而吉他拨弦,声音是先冲上去再缓缓衰减?

这就是包络在起作用。说白了,包络就是声音的「形状」——它决定了音量随时间如何变化。

我在做游戏音效时,经常遇到新手直接把采样循环播放,结果听起来像机关枪一样生硬。嗯,这就是没加包络的问题。

4.2 ADSR包络的四个阶段

ADSR是四个英文单词的首字母:

  • A - Attack(起音):声音从0到最大音量的时间
  • D - Decay(衰减):从最大音量降到持续音量的时间
  • S - Sustain(持续):按住键时保持的音量水平
  • R - Release(释放):松开键后声音消失的时间

我刚开始做音频驱动时,总把Attack设得太短,结果每个音都像「啪」的一声炸开。后来才明白,不同的乐器需要不同的Attack时间。

核心概念:ADSR包络就是给声音一个「生命曲线」。没有包络的声音是死的,有了包络才有表情。

4.3 音量渐变与淡入淡出

淡入淡出其实是ADSR的简化版。

  • 淡入(Fade In):相当于只有Attack阶段,音量从0慢慢升到最大
  • 淡出(Fade Out):相当于只有Release阶段,音量从当前值慢慢降到0

我在做游戏场景切换时,背景音乐的淡入淡出特别重要。如果直接切,玩家会感觉「咯噔」一下,体验很差。

我的小技巧:淡入淡出的时间一般设0.5到2秒。太短没效果,太长会让人等得不耐烦。

4.4 代码实现:用Python生成ADSR包络

下面是我写的一个简单ADSR生成器。你可以直接跑起来听效果。

import numpy as np
import sounddevice as sd

def generate_adsr_envelope(sample_rate, attack, decay, sustain_level, release, total_duration):
    """
    生成ADSR包络
    :param sample_rate: 采样率(Hz)
    :param attack: 起音时间(秒)
    :param decay: 衰减时间(秒)
    :param sustain_level: 持续音量(0.0 ~ 1.0)
    :param release: 释放时间(秒)
    :param total_duration: 总时长(秒)
    :return: 包络数组
    """
    total_samples = int(sample_rate * total_duration)
    envelope = np.zeros(total_samples)
    
    # Attack阶段
    attack_samples = int(sample_rate * attack)
    envelope[:attack_samples] = np.linspace(0, 1.0, attack_samples)
    
    # Decay阶段
    decay_samples = int(sample_rate * decay)
    start_decay = attack_samples
    end_decay = start_decay + decay_samples
    envelope[start_decay:end_decay] = np.linspace(1.0, sustain_level, decay_samples)
    
    # Sustain阶段
    sustain_samples = total_samples - attack_samples - decay_samples - int(sample_rate * release)
    start_sustain = end_decay
    end_sustain = start_sustain + sustain_samples
    envelope[start_sustain:end_sustain] = sustain_level
    
    # Release阶段
    release_samples = int(sample_rate * release)
    start_release = end_sustain
    envelope[start_release:] = np.linspace(sustain_level, 0, release_samples)
    
    return envelope

# 生成一个440Hz的正弦波,加上ADSR包络
sample_rate = 44100
duration = 2.0
t = np.linspace(0, duration, int(sample_rate * duration))
wave = 0.5 * np.sin(2 * np.pi * 440 * t)

# 设置ADSR参数:Attack 0.1s, Decay 0.2s, Sustain 0.7, Release 0.3s
envelope = generate_adsr_envelope(sample_rate, 0.1, 0.2, 0.7, 0.3, duration)
wave_with_envelope = wave * envelope

# 播放
sd.play(wave_with_envelope, sample_rate)
sd.wait()

注意:运行这段代码需要安装 numpy 和 sounddevice。如果你在树莓派上跑,记得先配好音频输出。

4.5 参数调优经验

我整理了一张常用乐器的ADSR参数表,供你参考:

乐器 Attack (秒) Decay (秒) Sustain (0~1) Release (秒)
钢琴 0.01 0.2 0.6 0.5
吉他 0.005 0.1 0.4 0.3
小号 0.05 0.3 0.8 0.2
打击乐 0.001 0.05 0.0 0.1

你想想看,打击乐的Sustain是0,因为敲一下声音就没了,不需要持续。而小号的Sustain很高,因为吹奏时可以一直保持音量。

4.6 避坑指南

我曾经犯过一个低级错误:把Attack设成0,结果声音开头出现「咔嗒」声。这是因为波形从0突然跳到非零值,产生了直流偏移。

解决办法很简单:Attack时间至少留几个采样点,哪怕只有0.001秒也好。

另一个坑:Release时间不要设得太长,否则音符会「拖尾」。我在做快节奏游戏时,Release一般不超过0.1秒。

4.7 在自制游戏机上的实现

如果你用的是STM32或ESP32这类单片机,ADSR包络可以用定时器中断来实现。

基本思路是:

  1. 用一个定时器,每1ms触发一次中断
  2. 在中断里更新当前音量值
  3. 根据当前处于哪个阶段,计算新的音量
  4. 将音量值写入PWM或DAC寄存器

嗯,这里要注意:单片机的浮点运算很慢,建议把音量值预先算好存成整数表,查表更新。

4.8 小结

ADSR包络是音频处理的基础中的基础。没有它,你的游戏机只能发出「哔哔」声。有了它,你就能模拟出钢琴、吉他、小号等各种乐器的感觉。

我个人习惯是先调Attack和Release,这两个参数对声音的「触感」影响最大。Decay和Sustain更多影响声音的「持续感」。

下一章我们会讲如何用包络来做更复杂的音效,比如爆炸声、脚步声。到时候你会发现,包络的玩法远不止这些。