基础振荡器的快速合成

Lobsters Hottest 工具

摘要

一篇技术博客文章,详细介绍了面向GBA和rp2040的快速整数振荡器实现,涵盖锯齿波、方波、三角波和脉冲波,重点在于速度而非减少混叠。

<p><a href="https://lobste.rs/s/wj5bs5/fast_synthesis_basic_oscillators">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/24 06:59

# 基础振荡器的快速合成 来源: https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html 2026-07-23 过去几年我一直在为 GBA 和 rp2040 制作合成器,并设计出了一些快速的振荡器。 首先,所有这些都适用于使用整数进行合成。我认为对于振荡器来说,即使有浮点运算单元(FPU),整数也更容易处理,因为整数会自动回绕。 本文将完全使用 32 位数值进行数学运算,原因有二。其一,这是 GBA 的原生寄存器大小,也是我过去几年一直使用的。其二,对于音高分辨率来说,这是一个非常理想的平衡点。使用 32 位相位可以非常精确地表示可听范围内的所有音高。随着相位位深的减少,低频部分的音高精度会开始下降。你当然可以忍受这一点,许多演示场景开发者就是这样做的,但它在作曲时就会成为一个必须主动考虑的问题。使用 32 位则完全没有问题。超过 32 位并不会带来太多好处。 我在此展示的大多数波形都会存在混叠问题。尖锐的跳变会产生可闻的伪影,音色不佳。解决这个问题的一种方法是使用 **PolyBLEP** (https://www.martin-finke.de/articles/audio-plugins-018-polyblep-oscillator/)。 我从未实现过那个方法,而且这些代码的目的就是不惜一切代价追求速度。它们适用于资源极度受限的环境,你必须接受混叠的存在并继续前进。对于需要廉价且无混叠的声音,可以在正弦波之间使用相位调制,这样能以极低的代价获得非常干净的声音,虽然比不上这些波形。我会再写一篇文章讨论那个。如果将来我实现了快速的 PolyBLEP,我也会写篇文章,但目前这不在我的待办清单上。 ## 目录 - 锯齿波 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#saw) - 从内存读取波形 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#wave-from-memory) - 方波 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#square) - 三角波 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#triangle) - 脉冲波 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#pulse-waves) - 正弦波的抛物线近似 (https://artemis.sh/2026/07/23/fast-synthesis-basic-oscillators.html#parabola-approximation-of-a-sine-wave) ## 锯齿波 所有振荡器都始于锯齿波,而一个锯齿波包含两个部分:当前相位,以及每个音频样本加到相位上的一个值。 ``` phase: u32 = 0 step: u32 = (frequency_in_hz * 0x100000000u64 / sample_rate) as u32 outbuf: [i32; num_samples] ``` 每个音频样本,我们将步长加到相位上,振荡器的输出*就是*相位。这样我们就得到了一个上升锯齿波。 ``` 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 汇编 // loop: // add phase, step // str phase, [outbuf], 4 // subs count, 1 // bne loop ``` ``` 1 / / /| /| / | / | / | / | 0 / | / | | / | / | / | / |/ |/ -1 / / ``` 每当 `phase` 溢出,我们就完成了一次振荡。 如果你熟悉 **奈奎斯特频率** (https://en.wikipedia.org/wiki/Nyquist_frequency) 的概念,那么在这里很明显:当相位步长为 `0x80000000` 时,波形每两个样本振荡一次,正好是采样率的 1/2。如果相位步长大于 `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 汇编 // 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); // phase < 0x80000000 时 out = 0x00000000 // phase >= 0x80000000 时 out = 0xFFFFFFFF let mut out = (phase as i32) >> 31; // phase < 0x80000000 时 out = INT_MIN // phase >= 0x80000000 时 out = INT_MAX out = out + (-0x80000000); outbuf[i] = out; } // ARM 汇编 // loop: // add phase, step // asr out, phase, 31 /* 如果 0x80000000 已存入寄存器, // sub out, 0x80000000 可以用单步 rsb 实现 */ // str out, [outbuf], 4 // subs count, 1 // bne loop // Thumb 汇编(无法像 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 汇编。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 汇编 // 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 汇编 // 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); // 如果 phase 为正,结果全零;如果 phase 为负,结果全一 let mut out = (phase as i32) >> 31; // 当 phase 为正时,out = phase // 当 phase 为负时,out = !phase out = out ^ (phase as i32); // 去除直流偏移 outbuf[i] = out - 0x40000000; } // ARM 汇编 // 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; // phase < 0x80000000 时 out = INT_MIN // phase >= 0x80000000 时 out = INT_MAX out = out + (-0x80000000); outbuf[i] = out; } // ARM 汇编 // loop: // add phase, step // and out, phase, phase, lsl 1 // and out, out, phase, lsl 2 // asr out, 31 /* 如果 0x80000000 已存入寄存器, // sub out, 0x80000000 可以用单步 rsb 实现 */ // 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 汇编 // mov dc_offset, 0x80000000; // loop: // add phase, step // eor out, phase, phase, asr 31 /* out = tri */ // add out, phase, out, lsl 1 /* out = 方波相位,移位设置宽度 */ // asr out, phase, 31 /* 如果 0x80000000 已存入寄存器, // sub out, 0x80000000 可以用单步 rsb 实现 */ // str out, [outbuf], 4 // subs count, 1 // bne loop ``` ``` 1 _____________ _____________ | | | | | | | | | | | | 0 | | | | | | | | | | | | | | | | -1 ____| |____| | ``` 对三角波添加相位偏移会对脉冲产生进一步有趣的影响,但这超出了本文的范围。 ## 正弦波的抛物线近似

相似文章

Super Nintendo 的核心

Fabien Sanglard

对 Super Nintendo 中时钟生成硬件的详细分析,解释了用于产生所需频率的两个振荡器和可调电容器。

设备时钟生成

Hacker News Top

一篇技术博客文章,讨论在FPGA和ASIC设计中为与NOR闪存和NAND闪存等外设接口生成设备时钟的方法。

初学者的DDS信号发生器

Lobsters Hottest

详细指南,介绍如何构建一个低成本、多功能的直接数字合成(DDS)信号发生器,具有正弦波、方波、三角波、锯齿波等功能,并支持频率、幅度、占空比和调制调节。

用RP2350监视Z80

Hacker News Top

一篇博客文章,探讨如何使用带PIO的Raspberry Pi Pico RP2350来监视Z80微处理器的地址和数据总线,包括时序考虑和时钟速度限制。