ZX Spectrum: 1ビットサウンドの実験
ZX Spectrum: Experimenting with 1-Bit Sound (bumbershootsoft.wordpress.com)
要約
この記事では、ZX Spectrumの1ビットブザーを使用してマルチチャンネルサウンド(和音)を再生する実験について解説しています。筆者は、IBM PCの1ビットスピーカーで得られた成果に触発され、PCスピーカーの高度な技術をZX Spectrumで再現しようと試みました。その過程で、PCM(パルス符号変調)とPWM(パルス幅変調)の概念を説明し、特に1ビットPCMの再生ルーチンと、Z80 CPUでの正確なサイクルカウントによる遅延処理について詳細に述べています。PWMの実装は満足いく結果を得られなかったものの、将来的な可能性を示唆しています。
全文翻訳
ZX Spectrum: 1ビットサウンドの実験
ZX Spectrumツアーの一環として、1ビットのビーパーから簡単なトーンを再生できるルーチンを実装しました。これらは、Apple IIでの以前の作業に大きく触発されたものです。しかし、それが限界ではないことを、IBM PCの1ビットスピーカーから信じられないほど良い結果を得たことからも知っています。当初の計画では、PCスピーカーの高度なテクニックをZX Spectrumで再現することでした。残念ながら、それは期待したほどうまくいきませんでしたが、代わりに他の多くのことを実現できたので、それはそれで良いのです。
今週達成した主なことは、1ビットスピーカーによるマルチチャンネルサウンド、つまり和音の再生に関連しています。サイドプロジェクトとして、PCスピーカーの記事のシンプルな1ビットPCM再生を再現し、より高度なPWMテクニックも briefly 実験しました。これらはどちらも、和音再生の作業に影響を与えています。
この記事は網羅的なガイドにはなりません。このブログでは、成功だけでなく失敗も記録しており、今週は失敗が多かった方です。
パルスコード変調(PCM)とパルス幅変調(PWM)
パルスコード変調は非常にシンプルな概念です。波形は、0から最大値までの値のシリーズとしてサンプリングされ、それらの値がハードウェアに送信されてスピーカー電圧に変換されます。最大値が大きいほど、振幅の制御はより細かくなり、サンプルをより速く送信するほど、周波数の制御はより細かくなります。(信号処理の基本的なルールとして、ナイキスト周波数の半分よりも高い周波数の波形を正確にサンプリングすることはできません。)PCM波形は通常、各サンプルを表すために使用されるビット数とサンプルレートで記述されます。高品質なサンプルは16ビット44kHzかもしれませんが、ほとんどのユースケースは8kHz程度の低いサンプリングレートで8ビットサンプルで対応できるでしょう。
SpectrumはCPUで約1MHzでメモリにアクセスできるため、直接、サイクルカウントされたCPU制御下でも、16kHzの再生という respectable なレートに到達できます。しかし、スピーカーは常にオンかオフのどちらかです。これは1ビットPCMであることを意味し、かなり音が悪くなることが予想されます。スピーカーを過大増幅して破損させたときのような歪みが、常に、そして低音量でも発生するでしょう。
SpectrumのモデルによってCPU速度はわずかに異なります。サイクルカウントには、フラット3.5MHzで動作した48Kモデルのクロックを使用します。これを毎秒16,000サンプルで割ると、書き込みの間隔は219サイクルになることがわかります。これはかなり余裕のある時間であり、1バイトあたり8サンプルをパックした1ビットPCMレコーディングを簡単に消費できます。サンプルは毎秒約2KBを消費し、48Kシステムではタイトですが、壊滅的ではありません。主な問題は、後述するように、サウンドの歪みです。このテクニックの実装には特別な課題はありません。
パルス幅変調は少しトリッキーですが、より高いオーディオ品質を約束します。電気信号レベルでは、PWMデータはサンプルごとに1ビットのパルスを送信し、パルスの長さがそのサンプルポイントでのオーディオ信号の意図された強度を示します。(PCMと比較してください。PCMはこれを達成するために、実質的にマルチビットデジタル値をワイヤに送信します。PWMは、より積極的に1ビットであるにもかかわらず、PCMよりもアナログ的です。)物理レベルでは、これは、電気信号がナノ秒単位で1から0に変化し、元に戻ることができるという事実の結果として現れますが、デバイスに接続された物理スピーカーは、「オン」と「オフ」の状態の間を実際に移動するのに数十マイクロ秒を必要とします。信号をその移動のさまざまなポイントでオフにすることで、スピーカーの総強度は、1ビットPCMが提供するものよりもはるかに正確に制御可能な方法で変化します。
IBM PCでは、1ビットスピーカーはハードウェアタイマーに接続されており、7ビットPCMデータをそのタイマーに供給することで高品質なオーディオを生成できます。Spectrumはそれほど親切ではなく、サイクルカウントで管理する必要があります。私はSpectrumで満足のいくPWMシステムを実現できませんでした。しかし、いくつかの手がかりがあり、テクニック全体としては妥当だと確信しています。
Z80でのサイクルエグザクトな遅延
コードの詳細に入る前に、正確なタイミング遅延を行うために必要なことを明確にする必要があります。Z80の「Tステート」カウントは、6502のCPUサイクルよりもはるかに大きく、はるかに掴みどころがありません。そのため、6502で任意のサイクル数を簡単に待つための命令をまとめることができたのに対し、私のZ80のガイダンスは必然的に少し条件付きになります。この作業では、主に経験則に頼り、より有機的な方法で遅延シーケンスを組み立てました。
NOP自体は4サイクルです。 ほとんどの1バイト8ビット演算(多数あります)も同様です。残りの遅延が短く、4サイクルの倍数である場合、基本的に完了です。INC HLは6サイクルです。 同様に構造化された1バイト16ビット命令も同様です。これらは2サイクルのずれを丸めるか、3バイトではなく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サイクルのシーケンスの途中にいることになります。タイミングの制約を知るために、ループの最後をそれに合わせて書く必要があります。また、全体的な関数プロローグとエピローグは非常に短いため、すぐに処理できることも判明しました。それらがすべて行う必要があるのは、割り込みを無効にして再度有効にすることだけです。
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
通常、バイトを1ビットずつ消費する場合、片端またはもう片端からシフトし続け、キャリービットを調べて次の処理を決定します。ここでは少し工夫できます。消費するビットを、出力バイトの$10ビットにコピーする必要があります。この事実を考慮すると、最初のバイトを取得し、3ビット右に回転させてから、$10ビットを直接扱う方が効果的です。8ビットのRRCおよびRLC命令を使用する必要があり、9ビットのRRおよびRL命令は使用しません。
上記から、サイクル24でループに入ることがわかっています。バイトを読み込み、準備し、適切なタイミングで最初のビットを出力すると、次のようになります。
.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サイクル遅延させる必要があります。私が考えたのは次のとおりです。
inc hl ; + 6 ( 73)
dec hl ; + 6 ( 79)
ld b,9 ; + 7 ( 86)
jp 1F ; + 10 ( 96)
1 djnz 1B ; +112 (208)
これでちょうど良い位置に着地します。
あと7ビットです。これを内部ループにしようかと考えましたが、レジスタが足りなくなり、メモリへのスピルは醜くなる可能性があります。単純にsjasmにコードをコピー&ペーストさせる方がはるかに簡単です。
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
これで十分です。それ以降は、最初のスケルトンからの最後の6命令だけです。関数はうまく動作します!しかし、実際の音質はひどいものです。意味のある信号がほとんどない部分のノイズをクリーンアップするために、前処理さえ試みましたが、あまり助けになりませんでした。それでも、関数はそれ自体としては問題なく機能し、ポケットに入れておくのに便利なものになるでしょう。しかし、repeatマクロを本格的なループにチューニングする気にはあまりなれません。
和音の再生
マルチチャンネル音楽は、当時、ビーパーを通して比較的一般的だったようです。長年の私の冒険では、比較的良い結果を得るための2つのアプローチが示唆されています。
- アルペジオ。 C64とAmigaの両方で一般的だったのは、単一の出力チャンネルに和音を与え、フレームごとに周波数を変更して和音効果を生み出すことです。これにより、非常に特徴的な「ブーン」という音がしました。これは、これまでに書いたものがあれば実装は容易なはずです。
- ソフトウェアミキシング。 モノラルシステムで複数のサウンドチャンネルを取得する通常の方法は、すべての入力信号を合計し、それを独自の出力に出力することです。ここでそれを試す場合は、いくつかのショートカットをカットする必要がありますが、基本的な原則は妥当であるはずです。
アルペジオの実装
これは非常に簡単であるはずです。すでに一定時間単音を再生するルーチンがあります。その時間を非常に短くし、外部ループに入れることができます。次のようになります。
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命令によって遅延が発生しないようにします。
全体的な結果はもっともらしいです。少しザラザラしていますが、明らかに和音です。私が気づいたことの1つは、間隔を短くしすぎると、和音がデチューンすることでした。私がそこで起こっていたと確信しているのは、ノートを変更すると追加の時間が必要になるだけでなく、パルスカウンターがリセットされることであり、遷移ポイントで非常に規格外の波形が発生する可能性があることです。トップレベルの和音関数にHLとAの初期化を移動させたところ、元のsoundルーチンよりも改善が見られました。次に、3つの周波数を一度にレジスタで渡し、それらを即値命令の引数にロードしました。chord関数は次のようになりました。
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
最後に、コード進行を指定しやすくするために小さなマクロを作成しました。これにより、短いコード進行を与えることができます。
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用のウェーブテーブルシステムでした。その背後にある基本的な原則は依然として適用されますが、1ビット出力では少し簡略化できます。
- これまで行ってきたのと同じ周波数カウンターベースのサウンドシステムを実行しますが、1つだけではなく3つのカウンターと周波数ステップを維持します。
- 各カウンターが更新された後、それぞれの最上位ビットを合計し、可能な範囲の上半分または下半分にあるかどうかに基づいてスピーカーを設定します。
これは、アクティブなチャンネルが常に奇数個の場合に非常に簡単です。偶数個のチャンネルの場合、中央の値がスピーカー出力をそのままにするようにしたいと考えています。これは、Spectrum(スピーカー値を直接書き込む)よりもApple II(スピーカー制御がトグルである)の方が達成しやすいでしょう。ここではちょうど3つのボイスがあるため、0〜3の可能な範囲があるため、2ビット合計の最上位ビットを見るだけでさらに簡単です。
各チャンネルのロジックは、サウンドツアーで書いたものと非常によく似ています。主な違いは2つあります。すべての3つを一度にレジスタに保持できないため、各コードでカウンターと周波数コードをメモリと同期させる必要があります。また、以前の実質17ビットカウンター(キャリービットを含む)ではなく、16ビットカウンターを使用する必要があります。古いコードでは、キャリービットがセットされたときに値を出力して保持していましたが、今度はカウンターの最上位ビットを取得し、それを実行中の合計に追加する値として使用する必要があります。タイミングとともにマクロでラップします。
macro count_channel count,freq
ld hl,(count) ; +16
ld de,(freq) ; +20
add hl,de ; +11
bit 7,h ; + 8
jp m,1F ; +10
jp 2F ; +10
1 inc a ; + 4
dec de ; + 6
2 ld (count),hl ; +16
endmacro
ここにはいくつかの分岐があるため、単純な合計はできませんが、このコードのすべてのパスは正確に91サイクルです。
周波数コードは公開変数になります(引数はそのように渡します)が、チャンネルカウンターはプライベートにできます。
freq1 # 2
freq2 # 2
freq3 # 2
chord:
ld hl,0
ld (.counter1),hl
ld (.counter2),hl
ld (.counter3),hl
di
.lp:
xor a ; + 4 合計値をクリア
count_channel .counter1,freq1 ; +91 各ボイスを順番に処理
count_channel .counter2,freq2 ; +91
count_channel .counter3,freq3 ; +91
add a ; + 4 Aを8倍し、$02ビットを$10の位置に移動
add a ; + 4
add a ; + 4
and $10 ; + 7 そのビットを分離
or $0f ; + 7 白い背景
out ($fe),a ; +11 合計ビットを出力
dec bc ; + 6 カウンターを減らして続行
ld a,b ; + 4
or c ; + 4
jp nz,.lp ; +10
ei
ret
.counter1 # 2
.counter2 # 2
.counter3 # 2
ループあたり338サイクル、および「フリップ」カウンター値が$10000の代わりに$8000なので、スケールを再計算する必要があります。
B C D E F G A
$061b $0678 $0743 $0826 $08a2 $09b1 $0ae1
メインプログラムは、呼び出しでより多くのメモリ作業を委任したため、今回は少し冗長です。
macro play 4
ld hl,@2
ld (freq1),hl
ld hl,@3
ld (freq2),hl
ld hl,@4
ld (freq3),hl
ld bc,@1
call chord
endmacro
play $2000,$0678,$0826,$09b1 ; I
play $2000,$0678,$08a2,$0ae1 ; IV
play $2000,$0678,$0826,$09b1 ; I
play $2000,$061b,$0743,$09b1 ; V
play $4000,$0678,$0826,$09b1 ; I
ret
しかし、ロジックは高レベルではそれほど違いはなく、このアプローチの全体的なサウンド結果は、アルペジオバージョンよりもはるかに優れています。サウンドはより豊かに感じられ、1ビットシステムのように聞こえません。私はこれに非常に感銘を受けました。
この時点で、実際に機能した再生ルーチンの限界に達しました。プログラムのコレクションをGitHubリポジトリの専用ディレクトリにアップロードしました。Makefileでビルドして実行するか、そこにあるオーディオファイルを確認して、アルペジオとチャンネル合計テクニックをPCM波形とともに聞くことができます。ここからは、機能しなかったアプローチ、または実際のインプリメンテーションに完全に到達しなかったアプローチをカバーします。
その他のアプローチ
特にアルペジオシステムに関連して、実験したいことがいくつかありましたが、コードを正しく記述するのは少し面倒でした。そして、悪いサウンドがテクニックが悪いためなのか、私の実装がバグっていたためなのか、常に明らかではありませんでした。しばらくして、重要なことに気づきました。これらのテストのためにカスタムサウンドエンジンを書く必要はありません。波形ファイルを事前に生成し、PCMプレーヤーで実行するだけです。
試したかった主なことは、周波数を速く交換するとデチューンするという問題の調査でした。私の理論では、完全な波形が得られないため、正しく聞こえなかったということでした。もしそうなら、波形を事前に生成していれば修正できます。各周波数の波を1つずつ、順番に正確に1つだけ実行できます。
これも正しく聞こえませんでしたが、PCMデータを調べると、その理由の非常に良い説明が得られました。低周波数は再生に時間がかかります。単純な2つのノート(1オクターブ離れた)の和音を再生することを想像してください。低いノートは高いノートの周波数の半分ですが、それは波形の2/3が低いノートに費やされることを意味し、結果の波形がアンバランスになるようです。
同様に、波形をタイムスライスで分割すると、最初のテストと非常に似た歪みが生じました。タイムスライスが短いほど、実際のパルスが正しい長さになる回数は少なくなります。
しかし…パルス波をまったく保持しない場合はどうなるでしょうか?
Apple IIgsのサウンドシステムは非常に珍しいです。1986年としては不合理な数である15のステレオウェーブテーブルチャンネルを備えています。それを達成する方法は、すべてのデジタルサウンドチャンネルを「アルペジオ」することです。3つのチャンネルがアクティブな場合、サウンドチップは各チャンネルから順番に1つのサンプルを再生するため、任意のチャンネルは3番目のサンプルにのみ表示されます。私たちがパルス波で行ってきたようにインターリーブする代わりに、IIgsからページを取り、パルスごとではなくサンプルごとに波形をシャッフルするのはどうでしょうか?
答えは、悪くないということです。これは、アルペジオの以前のすべての試みよりも著しく優れていますが、耳にはサンプル加算アプローチほど良くはありません。チャンネル加算アプローチは遷移を最小限に抑えようとするため、全体的な波形が「クリーン」に感じられるためだと思います。サンプルシャッフルアプローチは信号に同じ周波数成分をすべて持ちますが、遷移の数が多いため、全体的に少し「ザラザラ」した感じになります。
パルス波変調(PWM)
私の以前のPWM作業は、スピーカーのオン/オフを制御するタイミングを設定するためにハードウェアタイマーを使用できることに依存していました。これは、タイマーの設定レジスタを7ビットPCMデータを受信するポートのように扱えるほど正確でした。ここではそのような幸運はありませんが、それでもそれほど悪くはないはずです。
SpectrumのスピーカーがPCのものとほぼ同等であると仮定すると、完全な移動を達成するには50マイクロ秒(または175サイクル、または43.75 NOP命令)かかります。正確な幅のパルスを配信することは、OUT ($FE),A、XOR $10、そして一連の45個の**NOP**命令の命令の問題であるはずです。その後、適切なポイントで別のOUT命令をこのノーオペレーションストリームに編集し、その後、ペアの**NOP**に戻すことができます。最終的な結果は、常に一定時間実行されるコードのチャンクであり、パルス幅に対してマイクロ秒よりもわずかに悪い制御を提供します。その後、サンプルごとにこのプロセスを繰り返します。
これは問題ないはずです。しかし、実験ではこれを機能させることができませんでした。このプロジェクトの部分から、教訓的な失敗をいくつか得ました。
PWMベースシステムの1つの欠点は、送信するすべてのパルスが完了するとスピーカーの状態をリセットすることです。パルス自体は、サンプルレートが十分に低い場合に認識可能な規則的な信号シリーズを生成します。私のテストプログラムでは、この高音のきしむ音だけがすべて聞こえました。非常に注意深く聞くと、元のサンプルのごく一部を聞き取ることができたかもしれません。
少なくとも、それがエミュレーターの問題ではない(または完全にそうではない)ことを示唆しています。しかし、それ以外に私にできることはあまりありません。
仲間の助けを借りてなんとかする
幸いなことに、私を正しい方向へ導いてくれる非常に知識豊富なSinclairの専門家がたくさんいます。ソーシャルメディアやSpectrum Computingサイトのフォーラムでの会話は、私に多くのことを考えさせました。
- パルス幅変調は絶対に機能します。デモがあります。
- Fuseエミュレーターはこれらを処理するのに十分であり、むしろ信頼性が高すぎるほどです。実際のハードウェアの結果は、アンプが接続されていないとほとんど聞こえないようです。
- グラフィックスシステムツアーの一部として以前にリンクした作業を行っているL Break Into Programは、伝説的なFollin兄弟と、彼らがターゲットにしたシステム(Spectrumを含む)のいくつかで協力しました。彼らの遺産の許可を得て、彼は彼らのサウンドドライバーの一部をGitHubで公開しました。
私はまだこれらをあまり見ていません。私は常に、以前の作業を単に再利用するのではなく、できるだけ新鮮な視点でこれらのシステムに直面したいと思っています。しかし、私が達成したこと以外で十分な壁にぶつかったため、過去の作業を詳細に調べる時期が来たと考えています。
今週を終えるにあたり、実際にそれを行う機会はあまりありませんでした。少なくとも、Fuseがビーパーシミュレーターをどこに保持しているかを特定し、出力に対して3つの可能なローパスフィルターのうちの1つを実装していることがわかります。これは、スピーカーの物理性をシミュレートするための妥当な実装メカニズムであり、これが機能するはずだと確信しています。さまざまな種類のハードウェアを持つ人々の時間を借りる機会が得られるまで、それについて決定的なことは何も言えません。純粋なソフトウェアラボでは、どこまでしか進めません。