2026-07-23

过去几年我一直在为 GBA 和 rp2040 开发合成器, 并制作了一些快速振荡器。

首先,所有内容都适用于用整数进行合成。我认为对于 振荡器,即使你有 FPU,使用整数也更容易处理, 因为有溢出特性。

在这篇文章中,我们将完全使用 32 位值进行数学运算,原因有二。 一是这是 GBA 上的原生寄存器大小,也是我过去几年一直在使用的。 二是,它对于音高分辨率来说是一个非常好的平衡点。当使用 32 位相位时, 你可以在可听范围内相当准确地表示所有音高。当你降低相位的位深度时, 你会开始在低音中丢失音高精度。你可以处理这个问题,许多 demoscener 都在这样做, 但这会成为你在创作音乐时需要主动考虑的事情。使用 32 位,这就不是问题了。 超过 32 位并不能真正带来太多好处。

我在这里展示的大多数波形都会有混叠问题。尖锐的 转换会产生听起来不太好的可听伪影。修复这个问题的一种方法是使用 PolyBLEP

我从未实现过这个,而且无论如何,这段代码的重点是尽可能快。 这是为超受限环境设计的,你必须接受混叠的存在并继续前进。 对于预算有限的无混叠声音,你可以使用正弦波之间的相位调制, 你会以非常低的成本获得超级干净的声音,尽管不如这些波形便宜。 我将另写一篇文章讨论这个问题。如果我有时间实现快速 PolyBLEP, 我也会发文讨论,但这目前不在我的待办事项中。

目录

锯齿波

所有振荡器都从锯齿波开始,锯齿波有两个组成部分: 当前相位,以及每个音频样本添加到相位的值。

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 溢出,我们就完成了一次振荡。

如果你熟悉 奈奎斯特频率的概念, 这里很明显:当我们的相位步长为 0x80000000 时,我们的波形每 两个样本振荡一次,正好是采样率的一半。如果我们的相位步长高于 0x80000000,我们开始每两个样本溢出多次,但我们的 输出实际上开始缓慢向后滚动。这也会将我们的锯齿波从上升斜坡翻转为下降斜坡。 作为有符号整数,大于等于 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

方波

根据个人喜好以及对你的硬件来说什么是快的,你可以采取两种方法。

数学计算

如果没有无分支条件,数学计算是最好的选择。 这适用于 thumbv1,比如常见的 Cortex-M0+ 处理器。 在其他地方也很好,所以先试试这个。

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 当 phase < 0x80000000
	// out = 0xFFFFFFFF 当 phase >= 0x80000000
	let mut out = (phase as i32) >> 31;

	// out = INT_MIN 当 phase < 0x80000000
	// out = INT_MAX 当 phase >= 0x80000000
	out = out + (-0x80000000);

	outbuf[i] = out;
}

// arm assembly
// loop:
// add phase, step
// asr out, phase, 31    /* 如果 0x80000000 放入寄存器
// sub out, 0x80000000      可以单步完成 */
// str out, [outbuf], 4
// subs count, 1
// bne loop

// thumb assembly (不能像 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,你可以牺牲一点精度和每个迭代的额外周期来去掉直流偏移寄存器:

// loop:
// adds phase, step
// asrs out, phase, 31
// subs out, 0x80
// lsls out, 24
// str out, [outbuf], 4
// subs count, 1
// bne loop

条件计算

如果有无分支条件,第二种算法更快。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);

	// 我们使用 0x7F000000 而不是 0x7FFFFFFF,
	// 因为在 arm/thumb 上加载带有 <= 8 个有效数字的
	// 常量需要更少的周期
	if ((phase & 0x80000000) == 0) {
		outbuf[i] = -0x7F000000;
	else {
		outbuf[i] = 0x7F000000;
	}

	outbuf[i] = phase as i32;
}

// Arm assembly. thumbv2 会类似,但使用 `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  /* 在 +phase 中计数 */
// mvnmi out, phase  /* 在 -phase 中计数 */
// str out, [outbuf], 4
// subs count, 1
// bne loop
1     -       -
     / \     / \
    /   \   /   \
   /     \ /     \
0 -       -       -



-1

注意 ! 是一的补码运算符,意味着相位中的所有位都被翻转。

这从 0 正常上升到 0x7FFFFFFF。一旦我们达到 0x80000000, 我们开始反转数字。 !0x80000000 = 0x7FFFFFFF!0x80000001 = 0x7FFFFFFE,依此类推,一直计数回 !0xFFFFFFFF = 0

注意这意味着我们的三角波有非常轻微的平头,因为 0 出现两次, 0x7FFFFFFF 也是如此。当处理 32 位相位时,你永远 不会真正听到这个伪影,但它在较小的相位中可能会变得更明显。

作为有符号整数,!x = (-x) - 1。你可能想知道,为什么我们不使用 实际的 - 运算符来取二的补码,或者使用 .abs() 绝对值函数? 似乎这会消除我们的平头!好吧,它几乎做到了, 除了有一个大问题: INT_MIN.abs() == INT_MIN。 0x80000000 的二的补码是 0x80000000。当我们输出有符号音频样本时, 我们的波形会在尖端有一个非常讨厌的回绕负尖峰,像这样:

1      |        |
      /|\      /|
     / | \    / |
    /  |  \  /  |
0  /   |   \/   |
       |        |
       |        |
       |        |
       |        |
-1     |        |

使用条件的双极性三角波

输出单极性波有时可以,但通常你想要一个在负数和正数之间振荡的波。 我们的解决方案很粗糙。 我们只是从三角波中减去直流偏移,就得到了。

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);

	// 如果相位为正则全为零,如果相位为负则全为 1
	let mut out = (phase as i32) >> 31;

	// 当相位为正时,out = phase
	// 当相位为负时,out = !phase
	out = out ^ (phase as i32);

	// 移除直流偏移
	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;

	// 从底部开始注释掉行
	// 以达到所需的脉冲宽度。
	out = out & (phase << 1); // 将脉冲宽度减半至 25%
	out = out & (phase << 2); // 再次减半至 12.5%
	// out = out & (phase << 3); // 再次减半至 6.25%

	let mut out = (out as i32) >> 31;

	// out = INT_MIN 当 phase < 0x80000000
	// out = INT_MAX 当 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           /* 如果 0x80000000 放入寄存器
// sub out, 0x80000000      可以单步完成 */
// 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);

	// 选择你的战士
	//
	// 1/6 波
	// tri = tri << 1
	//
	// 1/4 波
	// tri = tri >> 0
	//
	// 1/3 波
	// tri = tri >> 1
	//
	// 2/5 波
	// tri = tri >> 2
	//
	// 方波(无调制)
	// 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 设置宽度 */
// asr out, phase, 31    /* 如果 0x80000000 放入寄存器
// sub out, 0x80000000      可以单步完成 */
// 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);

	// 2 倍频率的锯齿波
	let mut out: i32 = (phase << 1) as i32;

	// 锯齿波 -> 单极性三角波(参见三角波部分)
	// 范围 0 - 0x7FFFFFFF
	let mut out = out ^ (out >> 31);

	// 我们将计算 X - X^2 来获得
	// 从 0 到半相位的抛物线弧。这也可以被认为是
	// lerp(from: X, to: 0, alpha: X)
	let x = out;

	// 缩放到 0 - 0x7FFF
	let mut out = phase >> 16;

	// 输出从 0 - 0x4000FFFE 的弧
	out = out * out;
	out = x - out

	// 在相位的后半部分否定第二个弧
	out = out ^ ((phase as i32) >> 31);

	// 最终输出:-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 限制:不能用单个寄存器平方
// mul out, tmp       */
// sub out, x, out
// eor out, out, phase, asr 31
// str out, [outbuf], 4
// subs count, 1
// bne loop

用 ASCII 艺术很难公正地展示这个,所以这里是一个正确的渲染:

parasine.png