ZX Spectrum 1位蜂鸣器的数字音频

Hacker News Top 新闻

摘要

本文介绍了如何使用汇编语言和特定时序技术,在ZX Spectrum的1位蜂鸣器上实现高质量的数字音频播放,并通过当时的硬件和现代克隆机进行了验证。

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

缓存时间: 2026/09/29 13:59

# ZX Spectrum 单比特蜂鸣器的数字音频 来源:https://bumbershootsoft.wordpress.com/2026/09/26/digital-audio-on-the-zx-spectrums-1-bit-beeper/ 是时候为这个ZX Spectrum系列收尾了,让我们来看看如何让每款Spectrum机型上的简单单比特蜂鸣器发出高质量的数字音频。效果相当不错:它就是上周演示视频(https://github.com/michaelcmartin/bumbershoot/raw/refs/heads/master/zxspectrum/sound/zx_sound_demo.ogg)中播放的最后一个声音,它与之前简单的PCM回放系统相比,效果判若云泥。 至此,我也在当时的硬件和现代克隆机上验证了演示程序的硬件兼容性:互联网的特性意味着通常通过代号识别,但Mastodon上的“gmc”能够在48K Spectrum上运行它,而Tom Harte则在Omni 128系统(一个现代重建的系统)上取得了结果。模拟器支持也相当广泛;我用Fuse录制了上述演示,但它在至少EightyOne、Zesarux、Spectaculator和Clock Signal上也能正常工作。 这项技术的*实用性*有些局限;在没有外部放大器的硬件上,声音*非常*轻柔,而我尝试的音频长度和质量所需的采样内存实际上等于“全部内存”。但它*确实*可行。 然而,*让它*工作却是一段旅程。我完全重写了两次代码才得到一个勉强能用的版本,即便如此,它也几乎不符合我自己设定的时间约束。我不得不使用了几种在这个系统上未曾用过的技术。我主要使用汇编语言工作,因为通常在我玩的系统上这样做更舒服——只要像DOS(https://bumbershootsoft.wordpress.com/2017/03/23/how-to-not-learn-assembly-language-in-dos-part-1/)那样有合适的编译器支持,用C语言通常也完全可行。但今天,用C语言我肯定做不到。且不说我需要精确计时的指令序列来实现音频效果,时间约束如此紧张,寄存器压力如此之大,以至于我需要直接访问芯片才能使其工作。 这甚至不是最难的问题形式,并且有一些直接的方法可以改进我在这里使用的技术,而我在测试中并不需要这些改进。今天我将通过研究Dmitry Milk的Spectrum声音引擎以及Michael J. Mahon为Apple IIe开发的RTSynth系统来收尾;他们解决的问题与我遇到的略有不同,所以即使我们的*声音输出*方法相似,尽管运行在完全不同的CPU上,他们最终彼此之间比与我系统之间的相似度更高。 ## 先前工作 我第一次接触单比特数字采样是在20世纪90年代初的DOS游戏中。具体来说,图形文字冒险游戏*Eric the Unready*可以通过PC扬声器播放音效,其手册称之为“RealSound”技术,而*Star Control II: The Ur-Quan Masters*使用了Amiga风格的跟踪器音乐作为配乐,并且可以像通过SoundBlaster一样愉快地通过你的PC扬声器输出它。 PC的单比特扬声器比Spectrum或Apple II的要容易编程得多;大约十年前我研究过简单的音调生成和PWM回放(https://bumbershootsoft.wordpress.com/2016/12/10/beyond-beep-boop-mastering-the-pc-speaker/)。据我所知,那里的所有内容仍然有效且准确,但我们今天做的不一定需要它。不过,它的方法*确实*启发了我们的设计和策略。 这里的基本物理原理是*电路具有电容*,*扬声器有质量*,因此单比特信号从0到1或反之需要时间,然后扬声器的纸锥需要更多时间才能实际移动以产生我们听到的声波。如果我们在信号传递中途打断它,信号和扬声器将无法完成整个空间的移动,我们将得到更精细变化的声波。 在PC上这*极其*简单——虽然扬声器的设置可以通过I/O端口直接命令,就像Spectrum那样,但它也可以由高度可编程的硬件定时器驱动,该定时器独立于系统时钟中断运行。这意味着,你不必用周期计数代码不断照看蜂鸣器,而只需在需要更改发送信号时配置定时信息。对于普通的哔哔声,这只在改变频率时发生,但你也可以以微秒精度配置单次脉冲宽度。这意味着你可以将CPU定时器中断设置在16kHz,或者任何你的采样率,然后根据采样值的PCM值设置声音定时器运行时间。因此,定时器芯片本身最终充当了某种数模转换器。 Spectrum上没有定时器,无论是用于强制采样率还是配置脉冲宽度,但我们应该能够在软件中直接实现这两者。基本想法是拥有一个与每个采样时长相同的循环,每次循环打开然后关闭扬声器,并在“再次关闭扬声器”两侧分别有两个短暂但可变长度的延迟。第一个延迟设置脉冲宽度,第二个确保整个代码块无论脉冲宽度如何都花费相同的时间。 ## 错误的尝试1:实际的延迟循环 我的第一次实现尝试从一些草稿计算开始。 - 我的PC回放系统接受1到127微秒的脉冲宽度。 - 8kHz采样率意味着我们需要每125微秒一个脉冲。这是我第一次皱眉的地方;我的测试程序使用了16kHz采样率,这意味着如果采样真的用满了整个范围,我会超出时限。也许我的采样比我想象的更轻柔。尽管如此,这里的采样大致相同,我们应该处于合理的状态。 - Spectrum CPU大约3.5 MHz,所以125微秒是438个周期。 - 438个周期足够舒适,可以容纳我们默认延迟循环的15次迭代:我们需要18个周期在打开扬声器后关闭它,因此我们的延迟范围最高可达`13*15+2+18=215`个周期。这接近16kHz的标记本身,这促使我在第一次实验中坚持使用8kHz模式。 我有一个之前用于NES(https://bumbershootsoft.wordpress.com/2022/12/03/digital-sound-playback-on-the-nes/)的8kHz 7位音频剪辑版本。在这个8kHz窗口中有充足的时间可以直接使用这些数据并实时转换为4位PCM。第一稿的伪代码如下: 1. 读取一个采样字节N,并将其截断为4位。 2. 打开扬声器。 3. 运行N次空操作循环。 4. 关闭扬声器。 5. 运行16-N次相同的空操作循环。 6. 递增采样指针,如果到达采样末尾则退出。 7. 等待适当的时间,直到达到125微秒的标记,然后返回步骤1。 这里的关键洞察是,虽然步骤3和5单独看可能花费不同的时间,但*它们一起*总是花费相同的时间。 不幸的是,这个构建根本行不通;我只得到了刺耳的高音调哨声。我当时得出结论,我延迟太久——我们没有成功在转换中途打断扬声器,而是产生了8kHz的音调。截断为更短的位宽也没有真正帮助。 ## 错误的尝试2:动态延迟 我的第一个想法是我遇到了“常数因子”问题——采样相当轻柔,意味着大部分采样值在6-10范围内,也许我需要降低这些数字。但我并不真正知道目标应该是什么,而13个周期的跳跃似乎有点大。我决定将其改为更精细的粒度。在博客开始时,我研究过6502(https://bumbershootsoft.wordpress.com/2014/05/04/cycle-exact-delays-on-the-6502/)上微调的可编程延迟,借鉴了我第一次在Atari 2600上看到的技术。那里有一些特定CPU的技巧我们无法在Sinclair的Z80上复制,但我们也不真正需要。这些技巧让1MHz的6502获得了精确的周期精度,这意味着4周期的精度对我们来说就足够了。 通用技术是在一系列延迟指令中放置一条长流,通过计算跳转到该序列中间来实现可变延迟。如果我们让那“一系列长延迟指令”全部是**`NOP`**指令,我们将获得1.14微秒的延迟变化精度。 结果*仍然*主要是8kHz音调,但现在我几乎能听到音调背后播放的音剪影的低语。这表明该技术可能有效,但仍然没有感觉它能正常工作,甚至没有感觉任何模拟器会产生好结果,或者模拟上的成功或失败是否意味着真实硬件的情况。那时我卡住了,开始向更有经验的爱好者请教。 正是在这里我了解了Dmitry Milk的演示,它们不仅在硬件上有效,在我使用的Fuse模拟器中也有效。我还了解到Spectrum蜂鸣器上的物理扬声器比PC上的要小得多、轻得多。这至少让我相信我的想法严重超出了预期长度。这也意味着我的4位信号中每个值对应额外的4个周期,为我们的脉冲提供了大约20微秒的动态范围,这可能就足够了。 现在我知道这个目标是*可能的*。我现在需要的是一些实际的目标数字。 ## 找到路径 既然我确认了Fuse足以产生这些效果,我深入研究了它的源代码,看看能否找到音频信号从低到高所需时间的信息。我发现的是Blip Buffer系统(https://github.com/blarggs-audio-libraries/Blip_Buffer),后面有一些低通滤波器来平滑信号。这让人相信脉冲响应会让我们的技术有效,但我找的是微秒数,不是脉冲响应或均衡器配置。最终我采取了简单的方法,记录通往系统扬声器的16位44.1kHz音频输出,并在编辑器中查看一个**`BEEP`**命令。这给了我一个数字范围:一个转换运行4到7个采样,即97-170微秒。这有点令人惊讶——它比我们的8kHz采样率长,但也在我们*瞄准*的范围内。我认为这是我当时的愚蠢:我忘记了系统的物理惯性不仅适用于扬声器*开始*移动的部分,也适用于它*减速并改变方向*的部分,而在旅程的大部分时间里它*仍然在向前移动*。 此时,我希望我的脉冲尽可能短:打开扬声器,运行0-15条**`NOP`**指令,然后关闭扬声器。我的脉冲长度将在18到78个周期之间。 我第一次尝试*很糟糕*。我安排了17个空操作,然后在进入它之前,用我的**`OUT ($FE),A`**指令覆盖它的两个字节,在退出时将它们重写为**`NOP`**指令。这速度太慢,我必须进行认真的计算以确保仍然符合8kHz,而且它非常脆弱且难以调试。 在我实际完成调试之前,我想到了一个更好的主意并放弃了它。我不需要自修改代码:只有16个可能的值,逻辑非常固定,所以我可以编写一个宏来逐个创建每个可能性,然后用跳转表在它们之间切换。调度看起来会像这样,假设A是0到15之间的值: ``` add a ; double value ld hl,.table add l ; align .table so this never carries ld l,a ld e,(hl) ; Load .table[A*2] into DE inc hl ld d,(hl) ex de,hl ; Put it in HL jp (hl) ; And jump to it. .continue: ;; Delay, rest of loop, etc.. .table: defw .s0,.s1,.s2,.s3,.s4,.s5,.s6,.s7 defw .s8,.s9,.s10,.s11,.s12,.s13,.s14,.s15 ;; Each value is a macro. .s0: pcmstep 0 .s1: pcmstep 1 .s2: pcmstep 2 ;; ... and so on... ``` `pcmstep`宏看起来会像这样: ``` macro pcmstep i ld a,$17 ; Turn speaker on out ($fe),a xor $10 ; Value for speaker off repeat i nop endrepeat out ($fe),a ; Turn speaker off repeat 15-i nop endrepeat jp .continue endmacro ``` 我还不确定如何处理音量,所以我没有尝试读取我的采样剪辑,而是让它产生锯齿波,以每个采样1的速度从0递增到15。这是一个简单的循环,在8kHz下,它产生了听起来合理的锯齿波嗡嗡声,频率合理(500 Hz略低于高音C,一个完全合理的音调),捕获的波形看起来很棒。 [](https://bumbershootsoft.wordpress.com/wp-content/uploads/2026/09/zx_sound_7_sawtooth_digi.png)我现在确信该技术是可靠的。现在我只需要完成它。 ## 从锯齿波到采样 从简单的程序化声波进步到从内存缓冲区播放适当采样的主要挑战是16位寄存器竞争太激烈。我们需要一个用于最外层循环的16位计数器、一个指向采样缓冲区的指针、一个指向跳转表的指针、一个用于保存从跳转表*读出*的值的指针,并且我们还需要保持**`B`**寄存器*特别*空闲,以便在进行精确延迟时用作计数寄存器。 只要我保持在8kHz,我就有时间将**`DE`**、**`HL`**和**`B`**溢出到RAM中,并在适当的时候恢复它们。然而,在写这篇文章时,我意识到我可以用**`EXX`**指令在常规寄存器和影子寄存器之间切换,并使用影子寄存器来保存最后三个值。如果我*这样*做,我会有足够的时间真正保持16kHz的节奏! 然而,我不会有足够的*内存*来保持它。我在16kHz下的剪辑大约40KB长,而我无法将其放入可靠的、不会与视频芯片冲突的32KB内存中。我还发现我没有时间存储压缩数据并在运行时解包。然而,我*可以*让我的`pcmstep`宏发送*每个脉冲两次*。 此时我几乎完成了:将锯齿波转换为采样给了我不错的结果,但我的声音剪辑仍然听不见。解决方案很明显,我需要放大它,但没有必要预处理我的波形数据,我只需修改跳转表来为我放大和削波!我将每个值减去五,乘以三,然后将极端值限制在0和15。现在采样清晰地从模拟器中传出,但还有一个最终的小问题;当我的回放程序退出时,整个系统崩溃,并且常常严重扭曲显示。 事实证明,影子寄存器中的值对系统ROM确实很重要。幸运的是,这很容易处理:我只需

相似文章

ZX Spectrum:实验1位声音

Hacker News Top

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

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能够专注于其他任务,如渲染图形。