2026-07-23

過去幾年我一直在為 GBA 和 rp2040 開發合成器,並做出了一些快速振盪器。

首先,這一切都適用於用整數進行合成。我認為對於振盪器來說,即使你有 FPU,用整數處理仍然容易得多,因為可以直接溢位。

在本篇文章中,我們將完全使用 32 位元值進行運算,原因有二。其一是 GBA 的原生暫存器大小,也是我過去幾年一直在使用的。其次,它對音高解析度來說是個非常好的平衡點。當使用 32 位元相位時,你可以在可聽範圍內相當準確地表示所有音高。隨著相位位元深度降低,你會開始在低音部分失去音高準確度。你可以接受這一點,許多 demo 場景也這麼做,但這會成為作曲時必須主動考慮的因素。使用 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];

	// Scale up the output if you need to, or
	// leave it 8-bit to save some cycles cycles
	// going into a fixed-point volume control :)
	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 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,你可以犧牲一點精確度和每個迭代的額外週期來消除直流偏移暫存器:

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

	// 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

注意 ! 是一補數運算子,代表相位中的所有位元都會被翻轉。

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

	// 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 藝術很難呈現這個,所以這裡有一個適當的渲染:

parasine.png