何かあれば GitHub のリポジトリに issue を作るか ryukau@gmail.com までお気軽にどうぞ。
Update: 2023-06-22
指数曲線 (Exponential Curve) を使ったエンベロープを作ります。
値が 1 から 0 に向かって減衰する指数曲線 \(E_d(t)\) の式です。
\[ E_d(t) = \alpha^t, \quad 0 \leq \alpha \leq 1 \]
\(\alpha\) は減衰の速さを決める任意の値、 \(t\) は単位が秒数の時間です。
ユーザから指定された減衰時間 \(\tau\) から \(\alpha\) を決めます。 \(E_d\) は \(0 < \alpha\) のとき \(t = +\infty\) でようやく 0 になります。言い換えると \(E_d\) はいつまで経っても 0 になりません。そこで 0 の代わりに十分に小さな値 \(\epsilon\) に到達する時間を求めます。
\[ E_d(\tau) = \alpha^\tau = \epsilon \]
時間の単位を秒数 \(\tau\) からサンプル数 \(n_\tau\) に置き換えます。
\[ n_\tau = \tau f_s \]
\(f_s\) はサンプリング周波数です。
\(\tau\) と \(\epsilon\) が与えられたとき \(\alpha^\tau = \epsilon\) の関係と \(n_\tau\) の式より \(\alpha\) が求められます。
\[ \alpha = \epsilon^\Tau, \quad \Tau = \frac{1}{\tau f_s}. \]
\(\epsilon\)
の値は任意です。この文章では 1e-5
を使っています。値を決めるときの参考までに、 10 進数では
float
は約 7 桁、 double
は約 16
桁の精度があります。
実装します。
#include <cmath>
template<typename Sample> class ExpDecayCurve {
public:
void reset(Sample sampleRate, Sample seconds)
{
= Sample(1);
value (sampleRate, seconds);
set}
void set(Sample sampleRate, Sample seconds)
{
= pow(threshold, Sample(1) / (seconds * sampleRate));
alpha }
bool isTerminated() { return value <= threshold; }
()
Sample process{
if (value <= threshold) return Sample(0);
*= alpha;
value return value - threshold;
}
protected:
const Sample threshold = 1e-5;
= 0;
Sample value = 0;
Sample alpha };
reset()
は再トリガ時、 set()
はコントロールレートで呼び出されます。
isTerminated()
は複数の曲線を組み合わせて ADSR
エンベロープなどを作るとき、状態遷移を行うために使います。
process()
の最後で threshold
を引いているのは、エンベロープの終端の不連続点を小さくするためです。この処理によって出力の範囲は
[0, 1 - threshold]
になります。
値の範囲が [0, 1]
かつ終端に崖を作りたくないときはルックアップテーブルが使えます。ただし、滑らかさは劣ります。
増加する指数曲線 \(E_a(t)\) の式です。
\[ E_d(\tau) = \epsilon \alpha^\tau = 1 \]
\(\alpha\) について解きます。
\[ \alpha = \left( \frac{1}{\epsilon} \right)^\Tau, \quad \Tau = \frac{1}{\tau f_s}. \]
実装します。
template<typename Sample> class ExpAttackCurve {
public:
void reset(Sample sampleRate, Sample seconds)
{
= threshold;
value (sampleRate, seconds);
set}
void set(Sample sampleRate, Sample seconds)
{
alpha= pow(Sample(1) / threshold, Sample(1) / (seconds * sampleRate));
}
bool isTerminated() { return value >= Sample(1); }
()
Sample process{
*= alpha;
value if (value >= Sample(1)) return Sample(1 - threshold);
return value - threshold;
}
protected:
const Sample threshold = 1e-5;
= 0;
Sample value = 0;
Sample alpha };
\(1 - E_d(t)\) として減衰する指数曲線を上下反転して使う方法があります。
実装です。
// 1 - ExpDecayCurve.process();
template<typename Sample> class NegativeExpAttackCurve {
public:
void reset(Sample sampleRate, Sample seconds)
{
= Sample(1);
value (sampleRate, seconds);
set}
void set(Sample sampleRate, Sample seconds)
{
= pow(threshold, Sample(1) / (seconds * sampleRate));
alpha }
bool isTerminated() { return value <= threshold; }
()
Sample process{
if (value <= threshold) return Sample(1 - threshold);
*= alpha;
value return Sample(1 - threshold) - value;
}
protected:
const Sample threshold = 1e-5;
= 0;
Sample value = 0;
Sample alpha };
C++ での実装例です。
このエンベロープは IterativeSinCluster
で使われています。この文章に掲載している実装では sustain
をバッファ内で補間していないので、サステイン中にサステイン音量を変更するとノイズが乗ります。また、エンベロープが終了していない状態で再トリガすると、デクリックの副エンベロープが掛け合わされることによってノイズが乗ります。
reset()
の curve
の値で
ExpAttackCurve
と NegativeExpAttackCurve
を入れ替えられるようにしています。 curve
の値はトリガ時に渡された値をエンベロープの終了までに使い続けることを想定しています。
#include <algorithm>
#include <cmath>
// t in [0, 1].
template<typename Sample> inline Sample cosinterp(Sample t)
{
return 0.5 * (1.0 - cos(pi * t));
}
template<typename Sample> class ExpADSREnvelope {
public:
void setup(Sample sampleRate)
{
this->sampleRate = sampleRate;
= int32_t(0.001 * sampleRate);
declickLength }
(Sample seconds, Sample noteFreq)
Sample adaptTime{
const Sample cycle = Sample(1) / noteFreq;
return seconds < cycle ? cycle : seconds;
}
void reset(
,
Sample attackTime,
Sample decayTime,
Sample sustainLevel,
Sample releaseTime,
Sample noteFreq)
Sample curve{
if (declickCounter >= declickLength || state == State::terminated) declickCounter = 0;
= State::attack;
state
= std::clamp<Sample>(sustainLevel, Sample(0), Sample(1));
sustain
= value;
offset = Sample(1) - value;
range
this->curve = std::clamp<Sample>(curve, Sample(0), Sample(1));
= adaptTime(attackTime, noteFreq);
attackTime .reset(sampleRate, attackTime);
atk.reset(sampleRate, attackTime);
atkNeg.reset(sampleRate, decayTime);
dec.reset(sampleRate, adaptTime(releaseTime, noteFreq));
rel}
void set(
,
Sample attackTime,
Sample decayTime,
Sample sustainLevel,
Sample releaseTime)
Sample noteFreq{
switch (state) {
case State::attack:
= adaptTime(attackTime, noteFreq);
attackTime .set(sampleRate, attackTime);
atk.set(sampleRate, attackTime);
atkNeg// Fall through.
case State::decay:
.set(sampleRate, decayTime);
dec// Fall through.
case State::sustain:
= std::clamp<Sample>(sustainLevel, Sample(0), Sample(1));
sustain // Fall through.
case State::release:
.set(sampleRate, adaptTime(releaseTime, noteFreq));
rel// Fall through.
default:
break;
}
}
void release()
{
= value;
range = State::release;
state }
bool isAttacking() { return state == State::attack; }
bool isReleasing() { return state == State::release; }
bool isTerminated() { return state == State::terminated; }
inline Sample declickIn(Sample input)
{
if (declickCounter >= declickLength) return input;
+= 1;
declickCounter return input * cosinterp<Sample>(declickCounter / Sample(declickLength));
}
()
Sample process{
switch (state) {
case State::attack: {
const auto atkPos = atk.process();
const auto atkMix = atkPos + curve * (atkNeg.process() - atkPos);
= range * declickIn(atkMix) + offset;
value if (atk.isTerminated()) {
= State::decay;
state = Sample(1) - sustain;
range }
} break;
case State::decay:
= range * declickIn(dec.process()) + sustain;
value if (value <= sustain) state = State::sustain;
break;
case State::sustain:
= declickIn(sustain);
value break;
case State::release:
= range * declickIn(rel.process());
value if (rel.isTerminated()) state = State::terminated;
break;
default:
return 0;
}
return value;
}
protected:
enum class State : int32_t { attack, decay, sustain, release, terminated };
int32_t declickLength;
int32_t declickCounter = 0;
<Sample> atk{};
ExpAttackCurve<Sample> atkNeg{};
NegativeExpAttackCurve<Sample> dec{};
ExpDecayCurve<Sample> rel{};
ExpDecayCurve
= State::terminated;
State state = 0;
Sample value = 0;
Sample curve = 44100;
Sample sampleRate = 0;
Sample offset = 1;
Sample range = 1;
Sample sustain };
テストに使ったコードへのリンクです。
テスト結果です。図の縦軸は振幅、横軸は秒数です。音のサンプルはエンベロープを 100 Hz のサイン波の音量に適用しています。
エンベロープのアタック時間やディケイ時間などがとても短いときにでるプチノイズを低減することをデクリック (declick) と呼びます。 Image-Line の Sytrus というシンセサイザでの用例にならっています。
ここでは 1
ミリ秒の短いアタックを持つ副エンベロープを用意して指数曲線を使った主エンベロープに掛け合わせています。また、直線の
ADSR エンベロープで紹介した adaptTime()
も組み合わせて使っています。
モノフォニックのときに副エンベロープを使うと再トリガの処理が複雑になるのでお勧めしません。出力を slew limiter に通すほうが楽に実装できます。オーディオプラグインの UI から入力された値の補間も参考にしてみてください。
図は副エンベロープによるデクリックを行ったエンベロープ出力の例です。副エンベロープのアタックカーブは \(0.5 + 0.5 \cos(n)\) で、 \(n\) は \(-\pi\) から \(0\) に向かって増加しています。
1000 Hz のサイン波の音量に適用した音のサンプルです。プチノイズが聞き取りやすいようにサイン波の初期位相を \(\dfrac{\pi}{2}\) にしています。
P controller についてはオーディオプラグインの UI から入力された値の補間を参照してください。
ステップ状の不連続点を始点として指数曲線を描く出力が得られる P controller の特性を利用してエンベロープを実装します。
ユーザから指定された時間 \(T\) の逆数をカットオフ周波数 \(f_c\) に使っています。
\[ f_c = \frac{1}{T} \]
アタックはカウンタを使って時間経過で次の状態に進みます。
リリースは出力がしきい値 threshold
以下になったときに次の状態に進みます。
状態 State::tail
では出力が threshold
から
0 に到達するように直線を描きます。
#include <algorithm>
#include <cmath>
constexpr double twopi = 6.283185307179586;
template<typename Sample> class PController {
public:
// float 型での cutoffHz の下限は 3~4 Hz 程度。
static Sample cutoffToP(Sample sampleRate, Sample cutoffHz)
{
auto omega_c = Sample(twopi) * cutoffHz / sampleRate;
auto y = Sample(1) - cos(omega_c);
return -y + sqrt((y + Sample(2)) * y);
}
void setP(Sample p) { kp = std::clamp<Sample>(p, Sample(0), Sample(1)); }
void reset(Sample value = 0) { this->value = value; }
(Sample input) { return value += kp * (input - value); }
Sample process
= 1; // Range in [0, 1].
Sample kp = 0;
Sample value };
template<typename Sample> class ExpADSREnvelopeP {
public:
void setup(Sample sampleRate)
{
this->sampleRate = sampleRate;
= uint32_t(0.01 * sampleRate);
tailLength }
void reset(Sample attackTime, Sample decayTime, Sample sustainLevel, Sample releaseTime)
{
= State::attack;
state = std::clamp<Sample>(sustainLevel, Sample(0), Sample(1));
sustain = int32_t(sampleRate * attackTime);
atk = decayTime;
decTime = releaseTime;
relTime .setP(PController<Sample>::cutoffToP(sampleRate, Sample(1) / attackTime));
pController}
void set(Sample attackTime, Sample decayTime, Sample sustainLevel, Sample releaseTime)
{
switch (state) {
case State::attack:
= int32_t(sampleRate * attackTime);
atk // Fall through.
case State::decay:
= decayTime;
decTime = std::clamp<Sample>(sustainLevel, Sample(0), Sample(1));
sustain // Fall through.
case State::release:
= releaseTime;
relTime
default:
break;
}
if (state == State::attack)
.setP(
pController<Sample>::cutoffToP(sampleRate, Sample(1) / attackTime));
PControllerelse if (state == State::decay)
.setP(PController<Sample>::cutoffToP(sampleRate, Sample(1) / decayTime));
pControllerelse if (state == State::release)
.setP(
pController<Sample>::cutoffToP(sampleRate, Sample(1) / releaseTime));
PController}
void release()
{
= State::release;
state .setP(PController<Sample>::cutoffToP(sampleRate, Sample(1) / relTime));
pController}
bool isAttacking() { return state == State::attack; }
bool isReleasing() { return state == State::release; }
bool isTerminated() { return state == State::terminated; }
()
Sample process{
switch (state) {
case State::attack: {
= pController.process(Sample(1));
value --atk;
if (atk == 0) {
= State::decay;
state .setP(
pController<Sample>::cutoffToP(sampleRate, Sample(1) / decTime));
PController}
} break;
case State::decay:
= pController.process(sustain);
value break;
case State::release:
= pController.process(0);
value if (value < threshold) {
= threshold;
value = State::tail;
state = tailLength;
tailCounter }
break;
case State::tail:
--tailCounter;
= threshold * tailCounter / float(tailLength);
value if (tailCounter == 0) {
= State::terminated;
state .reset(0);
pController} else {
.reset(value);
pController}
break;
default:
return 0;
}
return value;
}
private:
enum class State : int32_t { attack, decay, release, tail, terminated };
const Sample threshold = 1e-5;
uint32_t tailLength = 32;
uint32_t tailCounter = tailLength;
<Sample> pController;
PController= State::terminated;
State state uint32_t atk = 0;
= 0;
Sample decTime = 0;
Sample relTime = 44100;
Sample sampleRate = 1;
Sample sustain = 0;
Sample value };
テストに使ったコードへのリンクです。
テスト結果です。
P_ADSR
の State::tail
の部分を拡大した図です。
減衰する指数曲線とその反転を掛け合わせて合成したエンベロープ \(E_{\mathtt{AD}}(t)\) を作ります。
\[ E_{\mathtt{AD}}(t) = (1 - a^{t}) d^{t} \]
ピークの位置 \(t_p\) とピークの大きさ \(E_{\mathtt{AD}}(t_p)\) を求めます。 \(t_p\) はアタック時間です。 \(E_{\mathtt{AD}}(t_p)\) は出力範囲を \([0, 1]\) に正規化するために使えます。
\(t_p\) の時点で \(E_{\mathtt{AD}}(t)\) を \(t\) について微分した関数の値が 0 になるはずです。 Maxima で解きます。
expr: diff((1-a^t) * d^t, t);
solve(0 = expr, t);
出力です。
\[ t_p = \frac{\log{\left( \dfrac{\log{(d)}}{\log{(d)}+\log{(a)}}\right) }}{\log{(a)}} \]
\(a\) と \(d\) を求めます。ユーザが指定したアタック時間を \(A\) 、 ユーザが指定したディケイ時間を \(D\) とします。 \([0, 1)\) の範囲の適当なしきい値 \(\epsilon\) を用意して、 \(a^{A} = d^{D} = \epsilon\) とすると \(a, d\) は次の式で計算できます。
\[ a = \epsilon^{1/A}, \quad b = \epsilon^{1/B} \]
C++ での実装です。
#include <cmath>
class ExpAD {
public:
void setup(float sampleRate) { this->sampleRate = sampleRate; }
bool isTerminated() { return valueD <= threshold; }
// attack and decay in seconds.
void reset(float attack, float decay)
{
= 1.0f;
valueA if (attack < 1e-5) attack = 1e-5;
= powf(threshold, 1.0f / (attack * sampleRate));
alphaA
= 1.0f;
valueD if (decay < 1e-5) decay = 1e-5;
= powf(threshold, 1.0f / (decay * sampleRate));
alphaD
if (attack <= 0.0f) {
= 1.0f;
gain } else if (decay <= 0.0f) {
= 0.0f;
gain } else {
auto log_a = logf(alphaA);
auto log_d = logf(alphaD);
auto t_p = logf(log_d / (log_a + log_d)) / log_a;
= 1.0f / ((1.0f - powf(alphaA, t_p)) * powf(alphaD, t_p));
gain }
}
float process()
{
*= alphaA;
valueA *= alphaD;
valueD return gain * (1.0f - threshold - valueA) * (valueD - threshold);
}
protected:
const float threshold = 1e-5;
float sampleRate = 44100;
float gain = 0;
float valueA = 0;
float alphaA = 0;
float valueD = 0;
float alphaD = 0;
};
テストコードへのリンクです。
テスト結果です。
この計算方法は EnvelopedSine で使っています。エンベロープ \(\tilde{E}_{\mathtt{AD}}\) の式です。
\[ \tilde{E}_{\mathtt{AD}}(t) = (1 - e^{-at}) e^{-bt} \]
\(E_{\mathtt{AD}}(t)\) の式について \(a \to e^{-a},\ d \to e^{-b}\) と置き換えています。
ピークの位置 \(t_p\) とピークの大きさ \(\tilde{E}_{\mathtt{AD}}(t_p)\) を求めます。
expr: diff((1-exp(-a*t)) * exp(-b*t), t);
solve(0 = expr, t);
\[ t_p = \frac{\log{\left( \dfrac{a}{b}+1\right) }}{a} \]
\(a\) と \(b\) を求めます。アタック時間を \(A\) 、 ディケイ時間を \(B\) 、 適当なしきい値を \(\epsilon \in [0, 1)\) とします。 \(e^{-a A},\ e^{-b B},\ \epsilon\) が等しくなるような \(a, b\) は次の式で計算できます。
\(e^{-a A} = \epsilon\) より、
\[ a = - \dfrac{\log(\epsilon)}{A}. \]
\(e^{-b B} = \epsilon\) より、
\[ \quad b = - \dfrac{\log(\epsilon)}{B}. \]
コード例です。
import numpy as np
def envelopeExpr(a, b, time):
return (1 - np.exp(-a * time)) * np.exp(-b * time)
def envelope(attack, decay, eps=1e-5):
= -np.log(eps) / attack
_a = -np.log(eps) / decay
_b = np.log(_a / _b + 1) / _a
peakTime = 1 / envelopeExpr(_a, _b, peakTime)
gain
= 48000
samplerate = 1
duration = np.linspace(0, duration, duration * samplerate)
time
return envelopeExpr(_a, _b, time)
= envelope(1.0, 2.0) output
P Controller による実装
を追加。