ZX Spectrum:实验1位声音

Hacker News Top 新闻

摘要

作者在ZX Spectrum上实验PCM和PWM声音技术,以实现多声道1位音频播放,并记录了过程中的成功与失败。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/08 15:45

# ZX Spectrum:1比特音频的探索之旅 来源:https://bumbershootsoft.wordpress.com/2026/09/05/zx-spectrum-experimenting-with-1-bit-sound/ 在ZX Spectrum系统巡礼的框架下,我编写了一些让1比特蜂鸣器能发出简单音调的例程(https://bumbershootsoft.wordpress.com/2026/08/01/zx-spectrum-system-tour-sound/)。这些工作很大程度上受到我早期在Apple II平台上探索的启发(https://bumbershootsoft.wordpress.com/2016/09/05/making-music-on-the-apple-ii/)。但大家都知道那并非极限,因为我们曾在IBM PC的1比特扬声器上获得了出乎意料的优质效果(https://bumbershootsoft.wordpress.com/2016/12/10/beyond-beep-boop-mastering-the-pc-speaker/)。原本这周我计划在ZX Spectrum上复刻一些进阶的PC扬声器技术,可惜效果未达预期。不过没关系,最终我反而实现了其他多项功能。 本周的核心成就是通过1比特扬声器实现了*多声道音频*——本质上就是和弦演奏。此外,我还重现了PC扬声器文章中提到的简单1比特PCM回放,并简短尝试了更高级的PWM技术。这两种技术都为和弦播放的研究提供了重要参考。 本周的内容不会是详尽的指南——我在博客中既记录成功也记录失败,而本周积累的失败案例实在不少。 ## 脉冲编码调制与脉冲宽度调制 *脉冲编码调制*是个相当简单的概念:将波形采样为一系列0到最大值之间的数值,再将这些数值发送至硬件转换为扬声器电压。最大值越大,对振幅的控制越精细;采样速度越快,对频率的控制越精细。(信号处理的一个基本原则是:采样率必须至少是被采样频率的两倍(https://en.wikipedia.org/wiki/Nyquist_frequency))。PCM波形通常通过采样位数和采样率来描述——高质量采样可能是16位44kHz,而多数应用场景只需8位采样配合低至8kHz的采样率。 ZX Spectrum的内存访问速度约为1MHz,因此即使采用直接CPU周期控制,也能实现不错的16kHz回放。但扬声器只有开关两种状态,这意味着这是*1比特PCM*——音质会相当糟糕;你会听到类似音量过大导致的失真,而且这种失真会持续存在,即使在小音量时也是如此。 不同型号的Spectrum CPU速度略有差异;我采用48K型号恒定的3.5MHz时钟进行周期计数。用每秒16000次采样除以3.5MHz,可知每次写入需间隔219个周期。这个时间相当充裕;我们能轻松处理每字节8个采样的1比特PCM录音。采样数据约消耗每秒2KB空间,在48K系统中虽然紧张但不至于灾难性。正如我们将看到的,主要问题在于音质失真,而该技术的*实现*并无特殊挑战。 *脉冲宽度调制*更为复杂,但承诺提供更高的音频质量。在电信号层面,PWM数据每个采样周期发送1比特脉冲,*脉冲宽度*代表该采样点的音频信号强度。(相比之下,PCM通过发送*多比特数字值*实现相同效果。PWM比PCM更接近模拟信号,尽管其1比特特性更为激进。)在物理层面,这源于一个事实:虽然电信号能在纳秒内完成0/1切换,但*连接设备的物理扬声器*需要数十微秒才能完成“开/关”状态的物理转换。通过在转换过程的不同阶段切换信号关闭时机,扬声器总强度的变化比1比特PCM提供了更精确可控的方式。 在IBM PC上,1比特扬声器与硬件定时器绑定,可通过输入7位PCM数据生成高品质音频。而Spectrum没有这种便利,必须依靠周期计数来管理。我未能在Spectrum上成功实现令人满意的PWM系统;不过我已有线索,并坚信该技术方案本身是可行的。 ## Z80的精确周期延迟 在深入代码细节前,我们需明确实现精确计时延迟的要素。Z80的“T状态”计数比6502的CPU周期更大且更复杂,虽然我曾为6502编写过实现任意周期延迟的简单指令(https://bumbershootsoft.wordpress.com/2015/08/09/vertical-splitscreen-part-3-cycle-counting-to-victory/),但我的Z80指导原则必然更具条件性。本次实践中,我主要依靠经验法则,以更灵活的方式组合延迟序列。 - **`NOP`本身耗时4个周期**,基本所有单字节8位操作(这类指令很多)同样如此。当剩余延迟较短且为4的倍数时,我们基本就完成了。 - **`INC HL`耗时6个周期**,类似结构的单字节16位指令也是如此。它们可用来消解2个周期的误差,或用两个字节(而非三个)实现12周期等待。 - **`LD A,n`耗时7个周期**,其他8位立即数指令亦然。只要*总*等待时间足够长,这些指令就能让我们达到周期级的精确度。 - **`JP`指令即使条件跳转也固定耗时10周期**,这使得它们在必须精确计时的决策循环中比**`JR`**或**`DJNZ`**更受欢迎。不过后者仍有其用途,因为... - **`LD B,n; LBL: DJNZ LBL`**是你能写出的最短循环,耗时*13n+2*周期。对于长时间等待,这个循环将承担主要部分,而上述其他指令则可消解任何不便的余数。 这虽非系统化方法,但足以满足我们的需求。 ## PCM回放例程 总体方案是将8个采样打包到每个字节中,高位优先播放。对于16位采样,我们需要每次输出间隔219个周期。输入数据将把指针放在**`HL`**中,采样字节数放在**`DE`**中。(我们假设总采样数是8的倍数,编码器只需在末尾填充——那是编码器的问题而非我们的。)通常我们用**`BC`**作为计数器,但这里需要**`B`**作为*内部延迟计数器*,因此**`DE`**必须承担计数任务。 有趣的是,我们其实从例程的尾部开始编写。当进入主回放循环时,我们将处于219周期序列的*中间*,为了明确时序约束,必须先写出循环的结尾部分。事实证明,整个函数的序言和结语非常简短,可以立即处理——它们只需禁用和重用中断。 ```assembly pcmout: di .lp: ????????????? ; 从字节播放8个采样,以...结尾 out ($fe),a ; + 11 (219) ...最终位输出结束 ;; 调整字节计数器并循环 dec de ; + 6 ( 6) ld a,d ; + 4 ( 10) or e ; + 4 ( 14) jp nz,.lp ; + 10 ( 24) ;; 完成;重新启用中断并返回 ei ret ``` 通常逐位处理字节时,我们会持续向一端移位并根据进位标志位决定操作。这里我们可以更巧妙些:需要将处理的位复制到输出字节的**`$10`**位。考虑到这点,更有效的方法是取初始字节,*右旋三位*,然后直接处理**`$10`**位。注意需使用8位**`RRC`**和**`RLC`**指令,而非9位**`RR`**和**`RL`**指令。 根据上文,我们在周期24时进入循环。读取字节、准备数据并在正确时间输出第一位的代码如下: ```assembly .lp: ld a,(hl) ; + 7 ( 31) 加载字节 inc hl ; + 6 ( 37) 推进指针 rrca ; + 4 ( 41) 将$80位旋至$10位 rrca ; + 4 ( 45) 使用8位旋转 rrca ; + 4 ( 49) ld c,a ; + 4 ( 53) 将字节暂存至C and $10 ; + 7 ( 60) 隔离音频位 or $01 ; + 7 ( 67) 蓝色边框——何乐而不为 ????????????? ; +141 (208) out ($fe),a ; + 11 (219) ``` 现在需要延迟141个周期。这是我设计的方案: ```assembly inc hl ; + 6 ( 73) dec hl ; + 6 ( 79) ld b,9 ; + 7 ( 86) jp 1F ; + 10 ( 96) 1 djnz 1B ; +112 (208) ``` 这正好达到所需位置。 完成一位,还有七位。我考虑将其设为内循环,但寄存器已用尽,溢出到内存可能引发问题。更简单的办法是让sjasm直接复制粘贴代码。 ```assembly repeat 7 rlc c ; + 8 ( 8) 下一位 ld c,a ; + 4 ( 12) and $10 ; + 7 ( 19) 隔离音频位 or $01 ; + 7 ( 26) 仍为蓝色边框 ld b,1 ; + 7 ( 33) 延迟182周期... dec b ; + 4 ( 37) ld b,13 ; + 7 ( 44) 1 djnz 1B ; +164 (208) out ($fe),a ; + 11 (219) ...然后输出该位 endrepeat ``` 这正是我们需要的全部;之后只需加上最初框架中的最后六条指令。函数运行完美!但实际音质非常糟糕。我甚至尝试了预处理来清理无意义信号区域的噪声,收效甚微。不过*函数本身*就其功能而言完全正常,它会是我们的实用备选方案。目前我暂时没兴趣将**`repeat`**宏优化为正式循环。 ## 和弦演奏 早在当年,通过蜂鸣器实现多声道音乐就相当常见。我多年来的经验表明有两种方法能获得尚可的结果: - **琶音技术**。在C64和Amiga上常见做法是将和弦分配给单声道输出,通过每帧改变频率来模拟和弦效果。这会产生一种独特的“嗡嗡”声。基于我现有的代码,这应该很容易实现。 - **软件混音**。在单声道系统上实现多声道的常规方法是简单将所有输入信号相加后直接输出。若尝试这种方法我们可能需要妥协,但基本原理应该可行。 ### 琶音技术实现 这应该非常简单。我已有一个能播放特定时长单音的例程。只需将时长设得很短,再套入外循环即可。类似这样: ```assembly ld b,$18 1 push bc ld bc,$0d0 ld de,$0367 call sound ld bc,$0d0 ld de,$0441 call sound ld bc,$0d0 ld de,$051a call sound pop bc djnz 1B ``` 我们之前已得知:若要保持稳定时序,不能操作**`$4000`**到**`$7FFF`**之间的内存,因此这里我将代码起始地址设为**`$8100`**,确保**`PUSH`**、**`POP`**和**`CALL`**指令不会导致停顿。 总体效果尚可。音质略显刺耳,但能明显听出是和弦。我注意到若音程间隔太短,和弦会出现失调。我相当确定这是因为切换音符不仅耗时,还会**重置脉冲计数器**,可能导致转换点产生严重偏离规格的波形。当我把**`HL`**和**`A`**的初始化移到顶层和弦函数而非原**`sound`**例程时,效果确实有所改善。随后我通过寄存器同时传递三个频率值,并将其载入立即数指令的参数中。**`chord`**函数最终如下: ```assembly chord: ld (.f1),hl ld (.f2),de ld (.f3),bc ld a,($5c48) ; BORDCR and $38 rrca rrca rrca or $08 di ld b,$18 ld hl,0 1 push bc ld bc,$0d0 .f1 equ $+1 ld de,$0000 call sound ld bc,$0d0 .f2 equ $+1 ld de,$0000 call sound ld bc,$0d0 .f3 equ $+1 ld de,$0000 call sound pop bc djnz 1B ei ret ``` 最后我还编写了一个宏来简化和弦进行的指定,这样就能轻松实现和弦进行: ```assembly macro play 3 ld hl,@1 ld de,@2 ld bc,@3 call chord endmacro ;; 主程序 play $0367,$0441,$051a ; I级和弦 play $0367,$048b,$05ba ; IV级和弦 play $0367,$0441,$051a ; I级和弦 play $0336,$03d2,$051a ; V级和弦 play $0367,$0441,$051a ; I级和弦 ret ``` 与上文PCM代码不同的是,这里的音质放在任何应用中都不会显得突兀。*与*PCM代码相同的是,我充分利用了Sjasm的宏功能。 ## 软件混音 我曾为Dragon平台构建过基于周期计数的多音合成器(https://bumbershootsoft.wordpress.com/2024/04/06/softsoniq-cycle-counting-out-polyphonic-music/)。其背后的基本原理仍然适用,但1比特输出允许我们适当简化。 1. 采用与之前相同的基于频率计数的发声系统,但维护三个计数器和频率步进值而非一个。 2. 每次更新计数器后,将各计数器的高位相加,根据总和处于可能范围的上半部或下半部来设置扬声器状态。 若活跃声道数总是奇数,实现会简单得多;对于偶数声道,我可能需要让中间值保持扬声器输出不变。在Apple II(扬声器控制是切换开关)上实现这点比在Spectrum(直接写入扬声器值)更容易。恰好三个声道(如本例)则更简单,因为可能范围是0-3,只需查看2位和的最高位。 每个声道的逻辑与我在系统巡礼音频部分(https://bumbershootsoft.wordpress.com/2026/08/01/zx-spectrum-system-tour-sound/)编写的代码非常相似。主要有两点区别:

相似文章

ZX Spectrum 系统之旅:声音

Hacker News Top

关于 ZX Spectrum 声音编程的技术综述,涵盖 1 位蜂鸣器和 AY-3-8910 芯片,并提供演奏 C 大调音阶的示例。

在Amiga上零CPU占用播放Atari ST音乐

Hacker News Top

本文详细介绍了一种在Amiga上播放Atari ST音乐的技术,通过利用Amiga的PAULA芯片模拟YM2149音效芯片,实现零CPU占用,使CPU能够专注于其他任务,如渲染图形。