ここ数年、GBAやrp2040向けのシンセサイザーを開発してきて、高速なオシレーターを作成しました。
まず、この内容はすべて整数を使った合成に適用されます。FPUがあったとしても、オシレーターではラッピングの観点から整数の方が扱いやすいと考えています。
この記事では、32ビット値のみで演算を行います。理由は2つあります。1つ目はGBAのネイティブなレジスタサイズであり、過去数年で使用してきたためです。もう1つは、ピッチ解像度の観点で非常に良いバランスだからです。32ビットの位相を使用することで、可聴範囲内のすべてのピッチを非常に正確に表現できます。位相のビット深度を下げると、低音域でのピッチ精度が低下し始めます。これでも対応は可能で、多くのデモシーン開発者も行っていますが、音楽制作時に積極的に考慮する必要があります。32ビットではこの問題は発生しません。32ビットを超えても大きな利点はありません。
ここで紹介する波形は、ほとんどエイリアシングの問題を抱えています。急峻な遷移により、聞き苦しい可聴アーティファクトが発生します。これを修正する方法の1つは、PolyBLEPを使用することです。
私はこれを実装したことがなく、いずれにせよこのコードの目的はとにかく高速であることです。非常に制約のある環境向けで、エイリアシングが存在することを受け入れて進める必要があります。予算内でエイリアシングのない音を実現するには、正弦波間の位相変調を使用すると、非常に安価で非常にクリーンな音が得られますが、これらの波形ほど安価ではありません。これについては別の記事で説明します。高速なPolyBLEPを実装したら、それについても投稿しますが、現時点では予定に入っていません。
目次
ノコギリ波
すべてのオシレーターはノコギリ波として始まり、ノコギリ波には2つの要素があります。現在の位相と、各オーディオサンプルごとに位相に加算される値です。
phase: u32 = 0
step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32
outbuf: [i32; num_samples]
各オーディオサンプルごとに、位相にstepを加算し、オシレーターの出力が位相となります。これにより、ランプアップのノコギリ波が得られます。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
outbuf[i] = phase as i32;
}
// Arm assembly
// loop:
// add phase, step
// str phase, [outbuf], 4
// subs count, 1
// bne loop
1 / /
/| /|
/ | / |
/ | / |
0 / | / |
| / | /
| / | /
|/ |/
-1 / /
phaseがオーバーフローするたびに、1回の振動が完了します。
ナイキスト周波数の概念に馴染みがある方には、ここで非常に明確に理解できるでしょう。位相ステップが0x80000000の場合、波形は2サンプルごとに1回振動し、サンプルレートのちょうど1/2の周波数になります。位相ステップが0x80000000より大きい場合、2サンプルごとに1回以上オーバーフローしますが、実際の出力はゆっくりと逆方向にロールし始めます。また、ノコギリ波はランプアップではなくランプダウンに反転します。符号付き整数として見ると、0x80000000以上の位相は負になります。当然ながら、位相ステップが0x80000000を超えるにつれて、振動はどんどん遅くなります。
メモリからの波形
ノコギリ波をシフトダウンすれば、メモリに保存された波形をインデックスとして使用でき、ウェーブテーブルシンセシスを自由に行えます。8ビット波形を使用すると最高のパフォーマンスが得られます。これは、16ビットまたは32ビット波形にアクセスする際に、下位アドレスビットをマスクするのに追加の命令が必要になるためです。そのため、私は通常8ビット波形を使用しています。サンプルとは異なり、波形は16ビットのダイナミックレンジによる恩恵が少ないと感じているためです。
この例では1024サンプルの波形を使用しています。他のサイズの場合はビットシフトを調整してください。波形は高速アクセス可能な場所に保存するようにしてください。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
let wavedata: [i8; 1024] = /* ... */
for i in 0..num_samples {
phase = phase.wrapping_add(step);
let out = wavedata[(phase >> 22) as usize];
// 必要に応じて出力をスケールアップするか、
// 8ビットのままにして固定小数点のボリュームコントロールに
// サイクルを節約 :)
outbuf[i] = out;
}
// Arm assembly
// loop:
// add phase, step
// lsr out, phase, 22
// ldrsb out, [wavedata, out]
// str out, [outbuf], 4
// subs count, 1
// bne loop
矩形波
個人の好みやハードウェアの処理速度に応じて、2つのアプローチがあります。
数学的計算
分岐なしの条件分岐がない場合、数学的計算が最適です。これは、一般的で見られるCortex-M0+プロセッサのようなthumbv1に当てはまります。他の環境でも問題なく動作するため、まずこちらを試してください。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
// out = 0x00000000 when phase < 0x80000000
// out = 0xFFFFFFFF when phase >= 0x80000000
let mut out = (phase as i32) >> 31;
// out = INT_MIN when phase < 0x80000000
// out = INT_MAX when phase >= 0x80000000
out = out + (-0x80000000);
outbuf[i] = out;
}
// arm assembly
// loop:
// add phase, step
// asr out, phase, 31 /* can be single step with rsb
// sub out, 0x80000000 if 0x80000000 is put in register */
// str out, [outbuf], 4
// subs count, 1
// bne loop
// thumb assembly (cannot subtract large immediates like arm32)
// movs dc_offset, 0x80
// lsls dc_offset, 24
// loop:
// adds phase, step
// asrs out, phase, 31
// subs out, dc_offset
// str out, [outbuf], 4
// subs count, 1
// bne loop
1 _________ _________
| | | |
| | | |
| | | |
0 | | | |
| | | |
| | | |
| | | |
-1 ________| |________| |
Thumbでは、精度と各イテレーションで1サイクル犠牲にすることで、DCオフセットレジスタを削除できます。
// loop:
// adds phase, step
// asrs out, phase, 31
// subs out, 0x80
// lsls out, 24
// str out, [outbuf], 4
// subs count, 1
// bne loop
条件分岐による計算
2番目のアルゴリズムは、分岐なし条件分岐が利用可能な場合のみ高速です。arm32は任意の命令を条件付きにでき、thumbv2には小さな分岐なし条件ブロックを形成するIT命令があるため、このアルゴリズムは両方で良好に動作し、GBAで使用しているものです。数学的計算に対する主な利点は、完全に0を中心にしていることと、定数を調整することで出力される矩形波の振幅を簡単に制御できることです。使用している信号チェーンによっては、こちらの方が適合するかもしれません。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
// We use 0x7F000000 instead of 0x7FFFFFFF,
// because it takes fewer cyles to load a
// constant with <= 8 sigfigs on arm/thumb
if ((phase & 0x80000000) == 0) {
outbuf[i] = -0x7F000000;
else {
outbuf[i] = 0x7F000000;
}
outbuf[i] = phase as i32;
}
// Arm assembly. thumbv2 would be similar, but use `IT`
// loop:
// adds phase, step
// movpl out, -0x7F000000
// movmi out, 0x7F000000
// str out, [outbuf], 4
// subs count, 1
// bne loop
三角波
ここでの理論を説明するために、まず条件分岐を使用した実装を示し、その後、同じ動作をするが少しわかりにくい分岐なしのバリアントを示します。
条件分岐を使用したユニポーラ三角波
三角波は上昇してから下降します。浮動小数点では表現しにくいですが、整数では非常に簡単です。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
if (phase & 0x80000000 == 0) {
outbuf[i] = phase as i32;
} else {
outbuf[i] = (!phase) as i32;
}
}
// Arm assembly
// loop:
// adds phase, step
// movpl out, phase /* count-up in +phase */
// mvnmi out, phase /* count-down in -phase */
// str out, [outbuf], 4
// subs count, 1
// bne loop
1 - -
/ \ / \
/ \ / \
/ \ / \
0 - - -
-1
!は1の補数演算子であり、phaseのすべてのビットが反転されることに注意してください。
これは通常通り0から0x7FFFFFFFまで上昇します。0x80000000に到達すると、数値を反転し始めます。!0x80000000 = 0x7FFFFFFF、!0x80000001 = 0x7FFFFFFEとなり、!0xFFFFFFFF = 0までカウントダウンします。
これは、三角波の頂点がわずかに平坦になることを意味します。0と0x7FFFFFFFが2回ずつ出現するためです。32ビットの位相を扱う場合、このアーティファクトが実際に聞こえることはありませんが、位相が小さい場合はより顕著になる可能性があります。
符号付き整数として、!x = (-x) - 1となります。平坦な頂点をなくすために、2の補数を取る実際の-演算子や、.abs()絶対値関数を使用しない理由が気になるかもしれません。ほぼ解決しそうですが、大きな問題があります。INT_MIN.abs() == INT_MIN。0x80000000の2の補数は0x80000000です。符号付きオーディオサンプルを出力すると、波形は頂点で非常に不快なラップアラウンド負スパイクを持つことになります。
条件分岐を使用したバイポーラ三角波
ユニポーラ波形を出力することは問題ない場合もありますが、通常は負と正の間で振動する波形が必要です。その解決策は単純です。三角波からDCオフセットを減算するだけです。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
if (phase & 0x80000000 == 0) {
outbuf[i] = (phase as i32) - 0x40000000;
} else {
outbuf[i] = ((!phase) as i32) - 0x40000000;
}
}
// Arm assembly
// loop:
// adds phase, step
// movpl out, phase
// mvnmi out, phase
// sub out, 0x40000000
// str out, [outbuf], 4
// subs count, 1
// bne loop
1
- -
/ \ / \
0 / \ / \
/ \ / \
- - -
-1
これにより、i32の全範囲ではなく、-0.5から+0.5の範囲で振動する三角波が生成されます。実際には問題ありません。オシレーター生成後、常に何らかのボリュームコントロールに入力され、信号チェーンのどこかでボリュームの損失を補正できるためです。
三角波の数学的計算
条件分岐の実装は理解しやすいですが、同じことをより少ない命令数で数学的に行うこともできます。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
// Will be all-zeroes if phase is positive or all-ones if phase is negative
let mut out = (phase as i32) >> 31;
// When phase is positive, out = phase
// When phase is negative, out = !phase
out = out ^ (phase as i32);
// Remove DC-offset
outbuf[i] = out - 0x40000000;
}
// arm assembly
// loop:
// add phase, step
// eor out, phase, phase, asr 31
// sub out, 0x40000000
// str out, [outbuf], 4
// subs count, 1
// bne loop
パルス波
25%、12.5%、6.25%など
矩形波生成ステップの前に上位ビットをANDすることで、矩形波のデューティサイクルをさらに制限してパルス波を得ることができます。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
let mut out = phase;
// Comment out lines starting from the bottom
// to achieve desired pulse width.
out = out & (phase << 1); // Cut pulse width in half to 25%
out = out & (phase << 2); // Cut again to 12.5%
// out = out & (phase << 3); // Cut again to 6.25%
let mut out = (out as i32) >> 31;
// out = INT_MIN when phase < 0x80000000
// out = INT_MAX when phase >= 0x80000000
out = out + (-0x80000000);
outbuf[i] = out;
}
// arm assembly
// loop:
// add phase, step
// and out, phase, phase, lsl 1
// and out, out, phase, lsl 2
// asr out, 31 /* can be single step with rsb
// sub out, 0x80000000 if 0x80000000 is put in register */
// str out, [outbuf], 4
// subs count, 1
// bne loop
1 _____ _____
| | | |
| | | |
| | | |
0 | | | |
| | | |
| | | |
| | | |
-1 ____________| |____________| |
16.6%、25%、33.3%、40%
ユニポーラ三角波モジュレータで矩形波キャリアを位相変調することにより、パルス波を生成することもできます。三角波の振幅を変更することで、前の方法では提供できないいくつかのパルス幅を選択できます。
前の方法はローエッジを伸長しますが、この方法はハイエッジを伸長します。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
let mut tri = phase as i32;
tri = tri ^ (tri >> 31);
// Choose your fighter
//
// 1/6 wave
// tri = tri << 1
//
// 1/4 wave
// tri = tri >> 0
//
// 1/3 wave
// tri = tri >> 1
//
// 2/5 wave
// tri = tri >> 2
//
// square wave (no modulation)
// tri = 0;
let square_phase = phase.wrapping_add(tri as u32);
let mut square = (square_phase as i32) >> 31;
square -= 0x80000000;
outbuf[i] = square;
}
// Arm assembly
// mov dc_offset, 0x80000000;
// loop:
// add phase, step
// eor out, phase, phase, asr 31 /* out = tri */
// add out, phase, out, lsl 1 /* out = sq phase, shift sets width */
// asr out, phase, 31 /* can be single step with rsb
// sub out, 0x80000000 if 0x80000000 is put in register */
// str out, [outbuf], 4
// subs count, 1
// bne loop
1 _____________ _____________
| | | |
| | | |
| | | |
0 | | | |
| | | |
| | | |
| | | |
-1 ____| |____| |
三角波に位相オフセットを追加すると、パルスにさらに興味深い変化が生じますが、これはこの記事の範囲外です。
正弦波の放物線近似
「基本」の限界を少し押し広げていますが、基本的な波形の近似なので、楽しむために行います。
高速な32ビット乗算命令があれば、位相の前半に放物線を取り、後半にその放物線の否定を取ることで、正弦波の近似を得ることができます。
この近似は物理学やグラフィックスには十分な精度ではありませんが、オーディオでは三角波よりも純粋な音に近く、適切な正弦波ほど純粋ではない音が得られます。
この用途は非常にニッチです。GBAではROM内の正弦ルックアップテーブルにアクセスする方が高速なため、この方法は使用しません。rp2040ではROMアクセスが遅いものの、SRAMに十分な容量があるため正弦テーブルを保持できます。高性能なシステムでは、キャッシュに十分な余裕があるため、パフォーマンスを追求する場合は正弦テーブルをプリロードできます。
これを使用する唯一の理由は、サイズコーディングでルックアップテーブルのスペースがない場合、または独特の音を求める場合のみです。
let mut phase: u32 = 0;
let step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32;
let mut outbuf: [i32; num_samples];
for i in 0..num_samples {
phase = phase.wrapping_add(step);
// Saw wave at 2x frequency
let mut out: i32 = (phase << 1) as i32;
// Saw -> Unipolar triangle (see Triangle section)
// Range 0 - 0x7FFFFFFF
let mut out = out ^ (out >> 31);
// We are going to calculate X - X^2 to get a
// parabolic arc from 0 to half-phase. This is
// can also be thought of as
// lerp(from: X, to: 0, alpha: X)
let x = out;
// Scale to 0 - 0x7FFF
let mut out = phase >> 16;
// Output arc from 0 - 0x4000FFFE
out = out * out;
out = x - out
// Negate the second arc in the second half of
// the phase
out = out ^ ((phase as i32) >> 31);
// Final output: -0x4000FFFF - 0x4000FFFE
outbuf[i] = phase as i32;
}
// Arm assembly
// loop:
// add phase, step
// lsl out, phase, 1
// eor out, out, out, asr 31
// mov x, out
// lsr out, 16
// mov tmp, out /* arm limitation: cant square
// mul out, tmp with single reg */
// sub out, x, out
// eor out, out, phase, asr 31
// str out, [outbuf], 4
// subs count, 1
// bne loop
ASCIIアートではこの波形を正しく表現するのが難しいため、適切なレンダリングを以下に示します。

0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.