Metlivi 博客

如何根据每个音符的按键时长为其设置混响衰减

要让每个演奏的音符拥有时长相关的独立混响,请测量其 Note On 与 Note Off 之间的时间,将该时长映射为衰减时间,并将结果应用于专属于该音符的混响。音源释放后,仍要让该音符的混响继续运行。

本教程将在 SuperCollider 中实现这一行为,包括 MIDI 输入和重叠音符测试。目标很简单:短按的音符留下较短的尾音,长按的音符留下较长的尾音,即使它们的混响彼此重叠也是如此。

2026年10月29日阅读约需 8 分钟作者:Metlivi Editorial Team
第 1 节

为什么每个音符都需要独立的混响?

想象两个重叠的音符。第一个按住两秒,第二个只按四分之一秒。如果二者都送入同一个混响,为第二个音符更改衰减时间,也会改变第一个音符尚未结束的混响处理。在同一个混响状态中,共用的衰减控制无法保留两套独立设置。

因此,路由需要这样安排:每个音符的音源分别送入自己的混响,再将处理完成的各个声部输出混合在一起。这是从架构上得出的结论:尾音要彼此独立,就需要独立的处理状态,除非乐器或效果器本身已提供等效的内部隔离。

复音补丁为此提供了一个实用框架。Cycling ’74 介绍了 Max 的 poly~ 如何创建声部实例,以及如何用 thispoly~ 将每个实例标记为忙碌或可用。在这个任务中,声部必须在整个混响尾音持续期间保持占用,而不能在 Note Off 到来时立刻变为可用。Cycling ’74:复音

这里还有一项时序限制:在现场演奏中,只有音符结束时才能知道最终按键时长。下面的实现会在按键按下期间使用暂定的衰减,并在松开时设置测得的衰减。如果你需要从一开始就应用最终衰减,请使用序列中已有的音符时长,或在之后单独渲染各个音符。

第 2 节

按键时长应该控制什么?

将三个控制项分开处理:乐器的振幅释放、混响衰减和湿声输出电平。较长的乐器释放会让声音继续输入混响。较高的湿声电平会让效果听起来更响。这两种操作本身都不会延长混响衰减。

衰减通常也不表示尾音会在某个精确时刻停止。Ableton 的混响文档将 Decay 控件定义为尾音降至初始振幅的千分之一(即 −60 dB)所需的时间。比较衰减设置与尾音变得听不见的时刻时,这一区别很重要。Ableton:Live 音频效果参考,混响

初次实现时,可以使用下面这个明确标注为示例的映射,所有数值单位均为秒:

decay = min(6.0, max(0.25, 0.25 + 1.5 × heldDuration))

按键时长 指定的衰减时间
0.10 秒 0.40 秒
0.25 秒 0.625 秒
1.00 秒 1.75 秒
2.00 秒 3.25 秒
4.00 秒 6.00 秒,上限

这些数值是设计选择,不是声学测量结果。最小值让短促的点按也能留下可听见的尾音;乘数决定按得越久对效果的影响有多大;最大值则限制所要求的衰减时间。先从这套映射开始,再调整这三个参数,同时保持音符力度和湿声电平不变。

第 3 节

构建一个在 Note Off 后仍存续的声部

示例使用 SuperCollider 内置的 GVerb。它接受单声道输入,输出两个声道,并以秒为单位提供 revtime 参数。其文档还指出,实例化时会出现 CPU 峰值,且更改房间大小会产生伪影。因此,本示例将房间大小固定,建议先用少量同时演奏的音符进行尝试。SuperCollider:GVerb

每个声部包含一个小型双振荡器音源、较短的振幅释放和一个混响。另有一个独立的生命周期包络,只有在尾音预留时长过去后才释放声部。SuperCollider 的 EnvGen 支持在门控信号降为零时释放的持续包络,也支持在包络完成时释放所属合成器的完成动作。SuperCollider:EnvGen

请使用一个立体声音频输出正常工作的全新 SuperCollider 会话。运行下面完整的设置代码块,然后等待 Post 窗口中出现 Held-note reverb ready。无需采样文件或其他库。代码已对照文档检查,但此处未执行,因为当前没有 SuperCollider 解释器。

(
s.waitForBoot {
    SynthDef(\heldNoteVerb, {
        |out = 0, freq = 440, amp = 0.035,
        gate = 1, decay = 1, alive = 1|
        var sourceEnv, lifetime, source, wet;

        sourceEnv = EnvGen.kr(
            Env.asr(0.01, 1, 0.02), gate
        );
        lifetime = EnvGen.kr(
            Env.asr(0.01, 1, 0.03), alive,
            doneAction: 2
        );
        source = (
            SinOsc.ar(freq) + (0.25 * SinOsc.ar(freq * 2))
        ) * amp * sourceEnv;
        wet = GVerb.ar(
            source,
            roomsize: 20,
            revtime: Lag.kr(decay, 0.03),
            damping: 0.5,
            drylevel: 0,
            earlyreflevel: 0.2,
            taillevel: 0.5
        );
        Out.ar(out, ((source ! 2) + wet) * lifetime);
    }).add;
    s.sync;

    ~heldVoices = Dictionary.new;
    ~tailJobs = List.new;
    ~voiceGroup = Group.head(s);
    ~voiceKey = { |num, chan, src|
        src.asString ++ ":" ++ chan.asString ++ ":" ++ num.asString
    };
    ~decayFor = { |held|
        (0.25 + (1.5 * held)).clip(0.25, 6.0)
    };

    ~releaseHeld = { |num, chan = 0, src = 0|
        var key, queue, entry, held, decay, node;
        key = ~voiceKey.value(num, chan, src);
        queue = ~heldVoices[key];
        if(queue.notNil) {
            if(queue.notEmpty) {
                entry = queue.removeAt(0);
                node = entry[0];
                held = (Main.elapsedTime - entry[1]).max(0);
                decay = ~decayFor.value(held);
                node.set(\decay, decay, \gate, 0);
                [\note, num, \held, held, \decay, decay].postln;
                ~tailJobs.add(Routine {
                    ((2 * decay) + 0.1).wait;
                    node.set(\alive, 0);
                }.play(SystemClock));
            };
            if(queue.isEmpty) { ~heldVoices.removeAt(key) };
        };
    };

    ~startHeld = { |vel, num, chan = 0, src = 0|
        var key, node;
        if(vel <= 0) {
            ~releaseHeld.value(num, chan, src);
        } {
            key = ~voiceKey.value(num, chan, src);
            if(~heldVoices[key].isNil) {
                ~heldVoices[key] = List.new;
            };
            node = Synth(\heldNoteVerb, [
                \freq, num.midicps,
                \amp, (vel / 127) * 0.035
            ], ~voiceGroup);
            ~heldVoices[key].add([node, Main.elapsedTime]);
        };
    };
    "Held-note reverb ready".postln;
};
)

释放时的操作顺序很重要:声部会同时收到衰减设置和音源门控变化。音源在 20 毫秒内渐弱时,混响会继续处理。大约经过两倍指定衰减时间再加上一小段余量后,整个声部会渐弱并释放自身。

最后这段余量是资源管理选择,并非对静音时刻的测量。如果清理时仍能听到尾音,请增加余量。对于规模更大的乐器,可将固定余量替换为输出电平监测,并设置最长生命周期。本示例也会保留已完成任务的引用,直到清理时才移除,因此它是演示代码,不适合无限期运行的乐器。

第 4 节

将演奏的 MIDI 音符连接到正确的声部

时长测量必须对应某个特定的音符实例。只追踪最近一次 Note On 的时间戳,一旦音符重叠就会出错。此设置会把每个声部及其起始时间存储起来,索引键由来源、通道和音高组成。

SuperCollider 的 MIDI 指南演示了如何初始化 MIDI、连接输入,以及保存合成器引用,以便 Note Off 能释放对应的声音。其 MIDIdef 参考文档规定,回调参数的顺序依次为数值、音符编号、通道和来源。SuperCollider:使用 MIDI、SuperCollider:MIDIdef

设置就绪后,运行此代码块,即可通过已连接的 MIDI 输入演奏乐器:

(
MIDIClient.init;
MIDIIn.connectAll;

MIDIdef.noteOn(\heldVerbOn, { |vel, num, chan, src|
    ~startHeld.value(vel, num, chan, src);
});
MIDIdef.noteOff(\heldVerbOff, { |vel, num, chan, src|
    ~releaseHeld.value(num, chan, src);
});
)

本示例将接收到的音符事件之间的时间间隔定义为“按键时长”,未实现延音踏板行为。如果要让踏板延长时长,应将受影响的释放操作推迟到踏板抬起时,再测量延长后的时间间隔。

同一音高的重复按键会存入队列,并按最早按下的顺序释放。这是针对同一来源和通道上重叠的同音事件所采用的明确匹配策略。没有对应按键的 Note Off 会被忽略;没有对应松键事件的 Note On 则会一直保持激活状态,直到清理时为止。扩展乐器前,请检查事件配对情况。

第 5 节

更改音色前,先测试重叠尾音

先不接键盘,测试一下行为。以下序列使用与 MIDI 输入相同的事件函数:音符 60 持续两秒;音符 67 在它开始半秒后进入,持续四分之一秒。它们预计分别会获得约 3.25 秒和 0.625 秒的衰减时间。

(
~heldTest = Routine {
    ~startHeld.value(100, 60);
    1.0.wait;
    ~startHeld.value(100, 67);
    0.25.wait;
    ~releaseHeld.value(67);
    0.75.wait;
    ~releaseHeld.value(60);
}.play(SystemClock);
)

SystemClock 按秒进行调度,因此这项测试不依赖乐曲速度设置。实际运行时,打印出的时长与设定时长之间可能略有差异。SuperCollider:SystemClock

听一听较高音符的短尾音如何在较低音符持续期间响起,随后再听较低音符较长的尾音。然后交换两者的时长。第二轮有助于确认结果由时长而非音高决定。结合音频,查看打印出的按键时长与衰减时间对应关系。

安装 MIDI 处理器后,运行以下清理代码块即可停止并移除此示例。它也会在移除声部前取消待执行的尾音任务:

(
MIDIdef(\heldVerbOn).free;
MIDIdef(\heldVerbOff).free;
if(~heldTest.notNil) { ~heldTest.stop };
~tailJobs.do { |job| job.stop };
~tailJobs.clear;
~voiceGroup.free;
~heldVoices.clear;
)
第 6 节

如何诊断行为异常的尾音?

如果一个音符松开时所有尾音都会变化,请检查音频路由。即使每个音符都有独立的时间戳,只要测量结果控制的是声部混合后的同一个混响,效果仍会是共享的。请将混响移入各个声部,或将彼此隔离的音符音频路由到不同的处理实例。

如果尾音恰好在 Note Off 时消失,请检查声部的生命周期。振幅包络应该停止音源发声,但不能释放包含该音源的混响。在 Max 实现中,应遵循文档所述的 poly~ 分配模型,让声部的忙碌状态保持到湿声输出结束。Cycling ’74:复音

如果衰减数值不对,在更改混响前先打印事件键和时间戳。在 Max 中,对应的计时基础模块是 timer,它测量两个事件之间的经过时间,默认单位为毫秒。目标参数需要秒时,请明确转换单位。Cycling ’74:timer

如果性能变得不稳定,请先减少同时发声的声部数量,并检查混响的选择,再扩展补丁。本演示没有限制声部数量,每个新音符都会创建一个混响。用于实际制作的乐器需要明确的分配策略,同时考虑按住的音符和仍在响起的尾音。

时序和重叠测试通过后,可以将振荡器替换为你选择的逐声部音源。保留同一流程:分配声部、记录起始时间、在释放时测量、设置衰减,并让混响持续运行直到尾音结束。这样,按键时长就能成为一种可重复控制的演奏参数。

第 7 节

来源

相关阅读

继续探索这个主题