概要
ADSRの特性がきっちり出るEnvelope Generator をArduinoで作ることにした。

目標(要件)
- Arduino(実際は、手元にあった Pro Micro 互換機)を使い、ソフトでADSR特性を作る。
- ADSR特性はコード中に計算式を書く。log() や指数関数は重たいらしいので、別の数式を使う。
- FAST PWM とCR(コンデンサと抵抗)のLPFで、Analog出力を得る。(外部のDACは使わない。)
- ソフトはChatGTPでつくる。
本文
仕様
サイズ:12HP x 3U
電圧:12V から5Vを生成
入出力:Gate IN 1, Envelope OUT 2 + 1 (Inverse)
調整用POT 5個; Attack Speed, Decay Speed, Relese Speed, Attack Level, Sustain Level

速度選択:Long/Short の2つのTime rangeを切り換える。
LED:Attackフェーズ、Decayフェーズ
内容は、DOEPFERのA-140-1 ADSR ENVELOPE GEN.の影響を大いに受けている。
ソフト開発
ソフト開発用試験回路
で書いた回路略図
Sparkfun Pro Micro
+-----------+
| |
| VCC 5V |
| GND |
| |
| A0 ---|--- ATTACK_SPEED_PIN
| A1 ---|--- DECAY_SPEED_PIN
| A2 ---|--- SUSTAIN_LEVEL_PIN
| A3 ---|--- RELEASE_SPEED_PIN
| A7 ---|--- ATTACK_THRES_PIN
| 2 ---|--- GATE_IN_PIN
| 4 ---|--- SCALE_SWITCH_PIN
| 7 ---|--- ATTACK_LED_PIN
| 8 ---|--- DECAY_LED_PIN
| 9 ---|--- ENVELOPE_OUT_PIN ---- R (470Ω) ----+----> Output
| 10 ---|--- INV_ENVELOPE_OUT_PIN ---- 同上 |
| | +
| | C (10µF)
| | +
| | |
| | GND
+-----------+

ソフト仕様:Chat GPTへトスした内容
Pro Microを使って、ADSRの4つのフェーズを持つエンベロープジェネレター用のコードを書いてください。
要件は、
2つのポットで、それぞれATTACK特性の最大値(電圧)とSustainのレベル(電圧)を決める。
3つのポットそれぞれで、Attack, Dekay, Releaseの増減の速さを決める。(CR特性の時定数に相当)
ADSRの特性はCR(コンデンサ・抵抗)の特性に合わせて、指数関数とする。
エンベロープ特性の出力pinはA0、5つのポットの入力はPro Microのアナログピンを順番に割り当てる。
AttackとDecayのフェーズではそれぞれLEDを点灯する。
Attackフェーズは外部からのGate入力HIGHをきっかけに開始する。
Attack特性がATTACK特性の最大値(電圧)になると、Decay特性に移行する。その後、Sustain フェーズになる。
Gate入力がLOWになると、Sustain フェーズから、Releaseフェーズになる。
ADCは FAST PWM とLPFを使う。PWM周波数は、50kHz。
以下は、開発中の仕様変更(追加仕様)
- FAST PWMは 10bit出力、周波数は10bitで取れうる最大周波数(15.64kHz)
- FAST PWMのAチャンネルから正のEnvelope, BチャンネルからInverse(反転)のEnvelopeを出力する。反転基準電圧は2.5V。
- POT特性をN等分して、その中央値をPOT設定値にする。(特性の安定化対策)
Envelope特性(CR特性)
Envelope特性はコンデンサと抵抗で作るLPFの特性を模擬する。この特性は一般的に指数関数として知られている。しかし、Arduinoのような非力なマイコンにとっては、
$$\ f(t)= A_0 - \exp(-t/\tau) $$
のような指数関数の計算は負担が大きいらしい。そこで、ADSRduinoで用いられている$\sqrt()$と$\cos () $ 利用した式を利用した。オイラーなのか、テイラーなのか、はたやハイパボリックなのか理論は不明であるが、CR特性らしい特性が得られる。
たとえば、AttackフェーズでのEnvelope特性は次の差分方程式で与えられる。(らしい。詳細は後述)
$$ envelope \mathrel{+}= (attackThres - envelope) \times (1 - \alpha_1)$$
ここにおいて、
$$\begin{aligned}
\alpha_1 &= \sqrt{0.999 \times \cos\left(\frac{1023 - CV_1} {ADC \_{SCALE}}\right)}\\
CV_1 &=f(attackSpeed); POT特性を表す任意の関数
\end{aligned}
$$
となる。
0.999 は解の収束と安定のための時間係数(Tfactor)であり、ADC_SCALE はCR回路における時定数$\tau$のような性質を持つ定数だ。
$CV_1 =f(attackSpeed)$ は、POTを回すときの回転角とEnvelope特性の変化がリニアな感覚に近づくようトライ&エラーを繰り返して決めた。
$$CV_1=A\times \sqrt{attackSpeed}+B$$
という飽和特性が、リニア感覚に近かったのでこの形にした。$CV_2, CV_3$ ともに係数も同じ式にした。
なお、POTの値は、1023のフル値を4から5分割してその中間値を使っている。これはPOT値の変動で波形が乱れたことへの対策である。
各々の因子数値がEnvelopeに与える影響を調べてみると、下図のようになる。各因子とも値が増えると、飽和(収束)するまでに時間がかかる。

差分方程式を選んでいる理由として、以下の点が推測されます:
---
1. 数値計算の簡便性
差分方程式は計算が簡単で、マイクロコントローラのようなリソースが限られた環境で効率的に実装できます。指数関数や対数関数を用いる場合に比べて、計算量が少なくなり、処理速度が向上します。
2. 直感的なパラメータ調整
alphaを調整することで、エンベロープの変化速度を簡単に制御できます。alphaが0に近いほど変化が速く、1に近いほど変化が遅くなります。このシンプルな調整方法は、エンベロープの制御において直感的で便利です。
3. 安定性の確保
差分方程式を使用することで、数値的な安定性が向上します。特に、alphaの範囲を0から1の間に制限することで、エンベロープの変化が極端にならないようにできます。
4. 簡単な実装
この形式の式は実装が簡単で、必要な計算も少ないため、開発の手間が少なくて済みます。また、ポテンショメータの値をそのままalphaに反映させることで、ユーザーが直感的に操作できるようになります。
5. 近似としての使用
この式は、エンベロープの変化をモデル化するための近似的な方法として機能します。完璧な指数関数的変化を求めるのではなく、エンベロープの滑らかな変化を再現するための実用的なアプローチとして使用されています。
スケッチ
実験回路で検証しながら、ChatGTPと何度かやり取りして完成したコードを下記に載せる。
//Arduino_ADSR_Diff_INV.ino
/*
* Sparkfun Pro Micro Envelope Generator with LED Indicators
* The envelope characteristics apply the differential equations from ADSRduino.
* https://github.com/m0xpd/ADSRduino?tab=readme-ov-file
* https://m0xpd.blogspot.com/2017/02/signal-processing-on-arduino.html
* Using FAST PWM at 10-bit (15.62 kHz) on Atmega32U4, pin D9. D10
* When Attack LvL is maximum, CR characteristics change to linear characteristics.
*
* Updated on 2024.06.20
*/
#define UART_TRACE (0) // alternative
#define UART_PLOT (0) // alternative
#define PIN_CHECK (0)
// Pin definitions
#define ATTACK_THR A7 //D6
#define ATTACK_SPEED A0
#define DECAY_SPEED A1
#define SUSTAIN_LEVEL A2
#define RELEASE_SPEED A3
#define GATE_IN 2
#define ATTACK_LED 7
#define DECAY_LED 8
#define ENVELOPE_OUT 9 // A9, Use Timer1 for PWM, OCR1A
#define INVERTED_OUT 10 // A10, Pin for inverted output, OCR1B
#define SCALE_SWITCH 4 // Switch for selecting ADC_SCALE
#if (PIN_CHECK)
const int CheckPin = 5; // For debugging
#endif
// Constants
const float ADC_SCALE_LONG = 2385; // Long time constant. Experimentally determined value
const float ADC_SCALE_SHORT = 550; // Short time constant. Experimentally determined value
const float invertRefVoltage = 2.2; // Reference voltage (2.5V). Experimentally determined value
const int invertRefValue = 1023 * (invertRefVoltage / 5.0); // Scale value of reference voltage
// Scaling constants
const float LONG_SCALING_FACTOR = 0.8; // Calculated scaling factor for LONG
const float SHORT_SCALING_FACTOR = 2.0; // Calculated scaling factor for SHORT
// Variables
float attackThres;
float sustainLevel;
float attackSpeed;
float decaySpeed;
float releaseSpeed;
float envelope = 0.0;
float alpha1, alpha2, alpha3;
bool gateState = false;
int state = 0;
void setup() {
pinMode(GATE_IN, INPUT);
pinMode(ATTACK_LED, OUTPUT);
pinMode(DECAY_LED, OUTPUT);
pinMode(ENVELOPE_OUT, OUTPUT);
pinMode(INVERTED_OUT, OUTPUT); // Pin for inverted output
pinMode(SCALE_SWITCH, INPUT_PULLUP); // Set switch pin
#if (PIN_CHECK)
pinMode(CheckPin, OUTPUT);
#endif
#if (UART_TRACE)
Serial.begin(9600);
#endif
#if (UART_PLOT)
Serial.begin(9600);
#endif
// Set PWM frequency on Timer1
TCCR1A = 0; // Clear Timer/Counter Control Registers
TCCR1B = 0;
TCNT1 = 0; // Clear Timer counter
// Set Timer1 to 10-bit Fast PWM mode
TCCR1A |= (1 << WGM10) | (1 << WGM11); // Fast PWM 10-bit
TCCR1B |= (1 << WGM12); // Fast PWM 10-bit
TCCR1B |= (1 << CS10); // No prescaling, highest possible frequency
// Enable output on pin 9 (OC1A) and pin 10 (OC1B)
TCCR1A |= (1 << COM1A1) | (1 << COM1B1);
}
void loop() {
// Select ADC_SCALE with switch
bool useLongScale = digitalRead(SCALE_SWITCH) == HIGH; // Read switch state
float ADC_SCALE = useLongScale ? ADC_SCALE_LONG : ADC_SCALE_SHORT; // If true (High), use LONG. If switch ON (Low), use SHORT
float scalingFactor = useLongScale ? LONG_SCALING_FACTOR : SHORT_SCALING_FACTOR; // Select scaling factor
// Read potentiometer values
// Normalize to 0-1, Apply SQRT():saturation characteristics
float CV1 = sqrt((float)readPot(ATTACK_SPEED, 5)/1023 ) * 900; // Experimentally determined value
float CV2 = sqrt((float)readPot(DECAY_SPEED, 5)/1023 ) * 900;
float CV3 = sqrt((float)readPot(RELEASE_SPEED, 5)/1023 ) * 900;
CV1 = constrain(CV1, 0, 1023);
CV2 = constrain(CV2, 0, 1023);
CV3 = constrain(CV3, 0, 1023);
// Calculate alpha values based on potentiometer readings
alpha1 = sqrt(0.999 * cos((1023 - CV1) / ADC_SCALE));
alpha2 = sqrt(0.999 * cos((1023 - CV2) / ADC_SCALE));
alpha3 = sqrt(0.99999 * cos((1023 - CV3) / ADC_SCALE));
// Read potentiometer values. Experimentally determined value
attackThres = readPot(ATTACK_THR, 5); // 10-bit range, specifiedValue(918) is the median of the last division
sustainLevel = readPot(SUSTAIN_LEVEL, 5); // 10-bit range
// Read gate input
bool gateInput = digitalRead(GATE_IN);
// Update envelope based on gate input
if (gateInput == HIGH && gateState == LOW) {
envelope = 0;
state = 0;
digitalWrite(ATTACK_LED, HIGH);
} else if (gateInput == LOW && gateState == HIGH) {
state = 3;
digitalWrite(ATTACK_LED, LOW);
digitalWrite(DECAY_LED, LOW);
gateState = LOW; // Update gateState to match gateInput
}
gateState = gateInput;
// Update envelope based on state
switch (state) {
case 0: // Attack
if (attackThres == 1023) {
envelope += scalingFactor / (ADC_SCALE / 1023 * pow(CV1 /1023, 3) ); // Linear increase, with scaling factor
} else {
envelope += (attackThres - envelope) * (1 - alpha1);
}
if (envelope >= attackThres * 0.90) { // \tau 0.632, 2\tau 0.865 3\tau 0.950 4\tau 0.982 5\tau 0.993
envelope = attackThres * 0.90; // Experimentally determined value
state = 1; // Decay
digitalWrite(ATTACK_LED, LOW);
digitalWrite(DECAY_LED, HIGH);
}
break;
case 1: // Decay
if (attackThres == 1023) {
envelope -= scalingFactor / (ADC_SCALE / 1023 * pow(CV2 /1023, 3) ); // Linear decrease, Experimentally determined value
} else {
envelope += (sustainLevel - envelope) * (1 - alpha2);
}
if ((attackThres > sustainLevel && envelope <= sustainLevel) ||
(attackThres <= sustainLevel && envelope >= sustainLevel)) {
envelope = sustainLevel;
state = 2; // Sustain
digitalWrite(DECAY_LED, LOW);
}
break;
case 2: // Sustain
// Do nothing
#if (PIN_CHECK)
digitalWrite(CheckPin, HIGH);
#endif
break;
case 3: // Release
if (attackThres == 1023) {
envelope -= scalingFactor / (ADC_SCALE / 1023 * pow(CV3 /1023, 3) ); // Linear decrease
} else {
envelope += (0 - envelope) * (1 - alpha3);
}
if (envelope <= 0) {
envelope = 0;
state = 4; // 新しいステートを追加して、Releaseフェーズの完了を示す
}
#if (PIN_CHECK)
digitalWrite(CheckPin, LOW);
#endif
break;
case 4: // Release Complete
// GateがHighになるまで何もしない
if (gateInput == HIGH) {
state = 0; // GateがHighになったらAttackフェーズに移行
digitalWrite(ATTACK_LED, HIGH);
}
break;
}
// Constrain envelope value to 0-1023 range
envelope = constrain(envelope, 0, 1023); // 10-bit range
// Output envelope as PWM
OCR1A = (int)envelope;
// Inverted output (mirror around invertRefValue)
float invertedEnvelope = (invertRefValue - envelope) + invertRefValue; // Corrected calculation
invertedEnvelope = constrain(invertedEnvelope, 0, 1023); // Ensure within 10-bit range
OCR1B = (int)invertedEnvelope;
#if (UART_TRACE)
// Print envelope for debugging
Serial.print("Envelope: ");
Serial.print(eint)envelope);
Serial.print(" CV1: ");
Serial.print((int)CV1);
Serial.print(" CV2: ");
Serial.print((int)CV2);
Serial.print(" CV3: ");
Serial.println((int)CV3);
#endif
#if (UART_PLOT)
// Print envelope for debugging
Serial.print(useLongScale);
Serial.print("\t");
Serial.print((int)envelope);
Serial.print("\t");
Serial.print("\t");
Serial.print((int)analogRead(ATTACK_SPEED));
Serial.print("\t");
Serial.print((int)CV1);
Serial.print("\t");
Serial.print("\t");
Serial.print((int)analogRead(DECAY_SPEED));
Serial.print("\t");
Serial.print((int)CV2);
Serial.print("\t");
Serial.print("\t");
Serial.print((int)analogRead(RELEASE_SPEED));
Serial.print("\t");
Serial.print((int)CV3);
Serial.print("\t");
Serial.print("\t");
Serial.print((int)analogRead(ATTACK_THR));
Serial.print("\t");
Serial.print((int)attackThres);
Serial.print("\t");
Serial.print("\t");
Serial.print((int)analogRead(SUSTAIN_LEVEL));
Serial.print("\t");
Serial.println((int)sustainLevel);
#endif
}
int readPot(int pin, int divisions) {
return readPot(pin, divisions, 1023); // Default to 1023 if not specified
}
int readPot(int pin, int divisions, int specifiedValue) {
int value = analogRead(pin);
if (value < 0 || value >= 1023) {
return specifiedValue; // Ensure value is within 10-bit range
}
int divisionSize = 1024 / divisions;
int div = value / divisionSize;
int result = div * divisionSize + (divisionSize / 2);
return constrain(result, 0, 1023); // Constrain to 10-bit range
}
実験特性
実験時の特性一例を示す。きれいなADSR特性とINV(反転)特性が得られている。
![]()
AttackLevel(AttackThres)とSustainLevelをPOTで独自に調整できるので、SustainLevelをAttackLevelよりも大きくして、下図のように2段立ち上がりの特性も作れる。

おまけに、Attack_Thres POTを右に回し切ったときには、リニア特性がでるようにしてみた。ソフトで作るとこのような思い付きにも対応できるメリットがある。ピンク色波形はSustainフェーズを示す信号である。リニア特性にすると、このフェーズが明確になる。

リニア特性を共有したときのネックは、Envelopeの立ち上がり時間が指数関数とは異なることだ。下図のような特性を切り換えるので、合わせたとしても、どこかの2点でしか合わない。

このグラフだとわかりずらいので、実験回路で実際に測定してみた。横軸のPOT抵抗と縦軸の立ち上がり時間をプロットしている。
青色の指数関数特性に対して、他の色の線は、複数のリニア特性を設定条件を変えて描いている。同じ抵抗値(POTの回転角)だと、リニア特性はAttackの時間が長くなるのが分かる。

完成
回路図
- 基本的にPro micro が動けば良いので、5V単電源である。
- 以前にPro Micr のRAWピンに12Vを入れて内装のレギュレータで5Vに降圧させたとき、レギュレータが壊れた経験があるので、念のため外付けの3端子レギュレータを設けた。
- 要らないかもしれないが、出力にRail to Rail オペアンプのLMC602でボルテージフォロアを追加した。
特性例:リニア特性

Attack特性の立ち上がり時間は表のようになる。ShortとLongで10倍変わる。
| CR特性 | リニア特性 |
Short | [ms] |
18 | 32 |
676 | 680 |
Long | [s] |
0.36 | 0.24 |
7.56 | 7.52 |
消費電流:45mA
3方向写真

差分方程式によるエンベロープ
アナログのRC特性に似たエンベロープ特性をデジタルで実現するために、一次差分方程式を使ったフィルタリングを行う。
理論的背景
アナログRC回路
アナログのRC回路は、以下の微分方程式で表される:
$$
V_{out}(t) = V_{in}(t) - RC \cdot \frac{dV_{out}(t)}{dt}
$$
ここで:
- $V_{out}(t)$:出力電圧
- $V_{in}(t)$:入力電圧
- $R$:抵抗
- $C$:コンデンサ容量
離散化(オイラー前進差分)
時間を離散化し、微分を差分で近似すると:
$$
\frac{dV_{out}}{dt} \approx \frac{V_{out}(kT) - V_{out}((k-1)T)}{T}
$$
ここで:
この式を差分方程式に代入して整理すると:
$$
V_{out}(kT) = V_{in}(kT) - RC \cdot \frac{V_{out}(kT) - V_{out}((k-1)T)}{T}
$$
これを整理していくと、最終的に次の形になる:
$$
V_{out}[k] = \frac{T}{T + RC} \cdot V_{in}[k] + \frac{RC}{T + RC} \cdot V_{out}[k-1]
$$
さらに簡略化して:
$$
V_{out}[k] = (1 - \alpha) \cdot V_{in}[k] + \alpha \cdot V_{out}[k-1]
$$
ここで:
$$
\alpha = \frac{RC}{T + RC}
$$
α の計算(非線形調整)
実装ではポテンショメータの値(例:$CV_0$, $CV_1$, $CV_3$)に基づき、エンベロープの変化速度を調整するために、非線形スケーリングを行っている:
$$
\alpha_1 = \sqrt{0.999 \cdot \cos\left(\frac{1023 - CV_0}{795}\right)}
$$
$$
\alpha_2 = \sqrt{0.999 \cdot \cos\left(\frac{1023 - CV_1}{795}\right)}
$$
$$
\alpha_3 = \sqrt{0.99999 \cdot \cos\left(\frac{1023 - CV_3}{795}\right)}
$$
このようにして、エンベロープ各フェーズ(アタック、ディケイ、リリースなど)が適切な時間特性で変化し、アナログRC回路に近い自然な応答が得られる。
関連リンク