Skip to content

Commit 4e98ec3

Browse files
kieranhjclaude
andcommitted
Add channel 0 (noise) support
15-bit LFSR matching the SN76489AN — white feeds back bit0 ^ bit1, periodic feeds back bit0 only. Pre-rendered into looping AudioBuffers at the three documented shift rates (clock/512, clock/1024, clock/2048 against the BBC's 4 MHz chip clock). When channel === 0 the pitch envelope is skipped (chip ignores PI/PN there) and the SOUND P arg is reinterpreted as a noise mode via the low 3 bits. UI swaps the pitch input for an 8-option dropdown and hides the pitch envelope subsection. P=3 / P=7 ("follows tone 2") are decoded but currently audibly played as medium rate. Six noise presets seeded (kick, snare, hat, cymbal, bass buzz, explosion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49d7e23 commit 4e98ec3

7 files changed

Lines changed: 348 additions & 80 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ A static web app for editing, visualising, and auditioning `ENVELOPE` parameters
1010
- Editable BBC BASIC `ENVELOPE` and `SOUND` lines — paste an existing statement to populate the editor, or copy the formatted line for use in BASIC.
1111
- Visualisation of the resulting amplitude envelope (with A/D/S/R phase markers) and the absolute pitch over time.
1212
- Web Audio playback with a sweeping playhead synced to the audio clock.
13-
- 12 presets covering single-shot envelopes (Piano, Pluck, Bell, Pad, Bass, Drum, Laser) and looping pitch envelopes (Vibrato, Trill, Siren, Arpeggio, Wobble).
13+
- All four channels supported: tone (1..3) plus the noise channel (0). Noise is generated by a 15-bit LFSR matching the SN76489AN — white or periodic, at any of the three documented shift rates — selected by an 8-option dropdown when channel 0 is active.
14+
- Game-sourced presets (Sentinel, Zalaga, Elite, Chuckie Egg, Thrust), BBC User Guide example envelopes (UG #1..#9), synth-design presets (Piano, Pluck, Bell, Pad, Bass, Drum, Laser, Vibrato, Trill, Siren, Arpeggio, Wobble), and noise-channel presets (kick, snare, hat, cymbal, bass buzz, explosion).
1415
- Shareable URL: every change is reflected in the URL bar via `?env=…&sound=…`, so copying the address reproduces the exact state.
1516
- "Run in BBC emulator" button that opens the current `ENVELOPE`/`SOUND` in [bbc.xania.org](https://bbc.xania.org/) (jsbeeb) for one-click A/B against the real BBC.
1617

@@ -51,11 +52,11 @@ A few non-obvious quirks (also documented in `CLAUDE.md`):
5152
- Pitch sections with `PN = 0` are not free — the OS hits `BEQ skipToNextChannel` after loading the empty section, so each empty section costs `T` cs of dead time. Loop wraps and non-empty section transitions are free.
5253
- The MOS allocates exactly **4** envelope slots (`N = 1..4`); higher numbers aren't usable.
5354
- Pitch is musically quantised to the BBC's own divider table (`pitchToHz()` in `src/envelope.ts`), not to equal temperament — higher octaves drift slightly sharp, matching the real machine.
55+
- Channel 0 noise uses only the low 3 bits of the `SOUND` `P` argument: bit 2 selects type (0 = periodic, 1 = white) and bits 1..0 select the LFSR shift rate (`clock/512`, `clock/1024`, `clock/2048` at the BBC's 4 MHz chip clock — i.e., ~7.8 / 3.9 / 2.0 kHz). PI/PN are ignored on the noise channel; the amplitude envelope works identically to tone channels.
5456

5557
## Future work
5658

57-
- **Channel 0 (noise).** This tool currently restricts `SOUND` channel to 1..3 and uses a square oscillator. Real BBC channel 0 is a separate noise generator with several modes selected by the pitch parameter (white / periodic at different rates, plus a mode that tracks channel 2's frequency).
58-
- **Periodic noise as bass.** When channel 0 is set to one of the periodic modes — particularly the mode that tracks channel 2 — the result is a coloured tone an octave or two below the reference. This is the classic BBC "periodic bass" trick used by many games. Supporting it requires emulating the SN76489's noise LFSR rather than substituting a square wave.
59+
- **Noise rate "follows tone 2".** Two of the eight noise modes (`P=3` and `P=7`) clock the LFSR from channel 2's tone period, letting you sweep noise pitch under tone-channel control. The tool decodes these modes but currently audibly plays them at the medium rate; supporting the cross-channel link properly needs a virtual channel-2 pitch.
5960
- **Static amplitude.** Negative `SOUND` amplitude (-15..-1) selects a static volume bypassing the envelope. Currently disallowed in the UI.
6061
- **Stereo / multi-channel mixing.** Real BBC sequences usually involve simultaneous notes across multiple channels; this tool is single-note only.
6162
- **Authentic SN76489 timbre on tone channels.** The current square oscillator is a stand-in. A `PeriodicWave` or AudioWorklet implementation of the SN76489 tone generator would give a closer match.

src/audio.ts

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,82 @@
1-
import { pitchToHz, type Sample } from "./envelope";
1+
import { pitchToHz, type NoiseMode, type Sample } from "./envelope";
22

33
const CS = 0.01; // one centisecond, in seconds
44

55
let ctx: AudioContext | null = null;
6-
let activeNodes: { osc: OscillatorNode; gain: GainNode } | null = null;
6+
interface ActiveNodes {
7+
source: OscillatorNode | AudioBufferSourceNode;
8+
gain: GainNode;
9+
}
10+
let activeNodes: ActiveNodes | null = null;
711
let playStart: number | null = null;
812
let playDuration: number | null = null;
913

14+
// Noise buffer cache, keyed by `${type}:${rate}` and rebuilt per AudioContext.
15+
let noiseBufferCache: { ctx: AudioContext; buffers: Map<string, AudioBuffer> } | null = null;
16+
17+
// BBC Micro feeds the SN76489 at 4 MHz. The TI datasheet documents the noise
18+
// shift rate as clock/N where N ∈ {512, 1024, 2048} — i.e., the divisors are
19+
// applied to the raw input clock (the chip's own /16 prescaler is implied
20+
// in those numbers). High = ~7.8 kHz shift, low = ~1.95 kHz, which is what
21+
// makes white noise actually sound like noise rather than crackle.
22+
const BBC_CHIP_CLOCK_HZ = 4_000_000;
23+
const NOISE_DIVISORS = [512, 1024, 2048] as const;
24+
25+
/**
26+
* Build a tiled mono AudioBuffer of the SN76489AN noise output for one
27+
* (type, rate) combination. We simulate a 15-bit LFSR — white feeds back the
28+
* XOR of bits 0 and 1, periodic feeds back bit 0 only (period 15 → the
29+
* characteristic BBC "drum" buzz). The LFSR is advanced sub-sample-accurately
30+
* with a fractional counter so the output is correct at any AudioContext rate.
31+
*
32+
* Two seconds is plenty: white noise loops imperceptibly, periodic loops at
33+
* ~30 Hz/8 Hz so two seconds easily contains a whole number of cycles.
34+
*/
35+
function buildNoiseBuffer(ac: AudioContext, mode: NoiseMode): AudioBuffer {
36+
const sampleRate = ac.sampleRate;
37+
const seconds = 2;
38+
const n = Math.floor(sampleRate * seconds);
39+
const buf = ac.createBuffer(1, n, sampleRate);
40+
const data = buf.getChannelData(0);
41+
42+
// Rate 3 ("follows tone2") is punted to medium (rate 1).
43+
const rateIdx = mode.rate === 3 ? 1 : mode.rate;
44+
const shiftHz = BBC_CHIP_CLOCK_HZ / NOISE_DIVISORS[rateIdx]!;
45+
const shiftsPerSample = shiftHz / sampleRate;
46+
47+
let lfsr = 0x4000;
48+
let phase = 0;
49+
let out = (lfsr & 1) === 1 ? 1 : -1;
50+
51+
for (let i = 0; i < n; i++) {
52+
phase += shiftsPerSample;
53+
while (phase >= 1) {
54+
phase -= 1;
55+
const fb = mode.type === "white"
56+
? (lfsr & 1) ^ ((lfsr >> 1) & 1)
57+
: (lfsr & 1);
58+
lfsr = ((lfsr >> 1) | (fb << 14)) & 0x7fff;
59+
if (lfsr === 0) lfsr = 0x4000; // guard against the trivial lock-up state
60+
out = (lfsr & 1) === 1 ? 1 : -1;
61+
}
62+
data[i] = out;
63+
}
64+
return buf;
65+
}
66+
67+
function getNoiseBuffer(ac: AudioContext, mode: NoiseMode): AudioBuffer {
68+
if (!noiseBufferCache || noiseBufferCache.ctx !== ac) {
69+
noiseBufferCache = { ctx: ac, buffers: new Map() };
70+
}
71+
const key = `${mode.type}:${mode.rate}`;
72+
let buf = noiseBufferCache.buffers.get(key);
73+
if (!buf) {
74+
buf = buildNoiseBuffer(ac, mode);
75+
noiseBufferCache.buffers.set(key, buf);
76+
}
77+
return buf;
78+
}
79+
1080
function getCtx(): AudioContext {
1181
if (!ctx) ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
1282
return ctx;
@@ -30,42 +100,61 @@ function bbcAmpToGain(amp: number): number {
30100
* Play a sample stream as if produced by SOUND with the given base pitch.
31101
* The visualiser and the audio engine consume the same `samples` array, so
32102
* what you see is what you hear.
103+
*
104+
* For tone channels (1..3) `basePitch` is the BBC pitch byte that PI/PN
105+
* offsets are added to. For the noise channel (0) `basePitch` is ignored
106+
* and `noiseMode` selects type + rate; the chip ignores PI/PN there.
33107
*/
34-
export function play(samples: Sample[], basePitch: number): void {
108+
export function play(samples: Sample[], basePitch: number, noiseMode: NoiseMode | null = null): void {
35109
stop();
36110
if (samples.length === 0) return;
37111
const ac = getCtx();
38112
if (ac.state === "suspended") ac.resume();
39113

40-
const osc = ac.createOscillator();
41114
const gain = ac.createGain();
42-
osc.type = "square"; // closer to the BBC's tonal character than sine
43-
osc.connect(gain).connect(ac.destination);
115+
let source: OscillatorNode | AudioBufferSourceNode;
116+
117+
if (noiseMode) {
118+
const noiseSrc = ac.createBufferSource();
119+
noiseSrc.buffer = getNoiseBuffer(ac, noiseMode);
120+
noiseSrc.loop = true;
121+
noiseSrc.connect(gain).connect(ac.destination);
122+
source = noiseSrc;
123+
} else {
124+
const osc = ac.createOscillator();
125+
osc.type = "square"; // closer to the BBC's tonal character than sine
126+
osc.connect(gain).connect(ac.destination);
127+
source = osc;
128+
}
44129

45130
const t0 = ac.currentTime + 0.02;
46131
gain.gain.setValueAtTime(0, t0);
47-
osc.frequency.setValueAtTime(pitchToHz(basePitch + samples[0]!.pitchOffset), t0);
132+
if (!noiseMode && source instanceof OscillatorNode) {
133+
source.frequency.setValueAtTime(pitchToHz(basePitch + samples[0]!.pitchOffset), t0);
134+
}
48135

49136
for (let i = 0; i < samples.length; i++) {
50137
const t = t0 + i * CS;
51138
const s = samples[i]!;
52139
gain.gain.setValueAtTime(bbcAmpToGain(s.amplitude), t);
53-
osc.frequency.setValueAtTime(pitchToHz(basePitch + s.pitchOffset), t);
140+
if (!noiseMode && source instanceof OscillatorNode) {
141+
source.frequency.setValueAtTime(pitchToHz(basePitch + s.pitchOffset), t);
142+
}
54143
}
55144

56145
const tEnd = t0 + samples.length * CS;
57146
gain.gain.setValueAtTime(0, tEnd);
58-
osc.start(t0);
59-
osc.stop(tEnd + 0.05);
147+
source.start(t0);
148+
source.stop(tEnd + 0.05);
60149

61-
activeNodes = { osc, gain };
150+
activeNodes = { source, gain };
62151
playStart = t0;
63152
playDuration = samples.length * CS;
64-
osc.onended = () => {
65-
// Only clear shared playback state if this osc is still the active one;
66-
// otherwise a freshly-started note would have its timing wiped when the
67-
// previously-stopped osc's onended fired late.
68-
if (activeNodes && activeNodes.osc === osc) {
153+
source.onended = () => {
154+
// Only clear shared playback state if this source is still the active
155+
// one; otherwise a freshly-started note would have its timing wiped
156+
// when the previously-stopped source's onended fired late.
157+
if (activeNodes && activeNodes.source === source) {
69158
activeNodes = null;
70159
playStart = null;
71160
playDuration = null;
@@ -76,7 +165,7 @@ export function play(samples: Sample[], basePitch: number): void {
76165
export function stop(): void {
77166
if (!activeNodes) return;
78167
try {
79-
activeNodes.osc.stop();
168+
activeNodes.source.stop();
80169
} catch {
81170
// already stopped
82171
}

src/envelope.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,31 @@ export interface Sample {
4848
// single sweep that holds at the final pitch when sections are exhausted.
4949
const PITCH_NO_REPEAT_BIT = 0x80;
5050

51+
/**
52+
* SN76489 noise channel mode. The chip's noise control register is 3 bits:
53+
* bit 2 selects feedback type (0 = periodic, 1 = white), bits 1..0 select
54+
* the LFSR shift rate. Rate 3 ("follows tone2 period") is decoded but the
55+
* audio engine treats it as `medium` — see CLAUDE.md / future work.
56+
*/
57+
export type NoiseRate = 0 | 1 | 2 | 3;
58+
export interface NoiseMode {
59+
type: "periodic" | "white";
60+
rate: NoiseRate;
61+
}
62+
63+
/**
64+
* Decode the BBC SOUND pitch argument for channel 0 into noise type + rate.
65+
* Only the low 3 bits matter; the OS masks higher bits when writing the
66+
* chip's noise control register.
67+
*/
68+
export function decodeNoiseP(pitch: number): NoiseMode {
69+
const p = (pitch | 0) & 0x07;
70+
return {
71+
type: (p & 0x04) !== 0 ? "white" : "periodic",
72+
rate: (p & 0x03) as NoiseRate,
73+
};
74+
}
75+
5176
/**
5277
* Expand an envelope into a stream of per-centisecond samples for a given
5378
* SOUND command. `soundDuration` is in 1/20 s units (the BBC unit), and the
@@ -56,7 +81,7 @@ const PITCH_NO_REPEAT_BIT = 0x80;
5681
* The returned stream covers attack + decay + sustain + release, i.e. the
5782
* full audible lifetime of the note.
5883
*/
59-
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number, hold = false): Sample[] {
84+
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number, hold = false, channel = 1): Sample[] {
6085
const samples: Sample[] = [];
6186

6287
// SOUND amplitude argument: 0 = silence, -15..-1 = static volume, 1..4 = envelope number.
@@ -100,6 +125,7 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
100125
// resets offset and runs the same tick's step from section 0, so the
101126
// wrap itself doesn't cost extra time (matches a normal step interval).
102127
const stepPitch = () => {
128+
if (channel === 0) return; // noise channel ignores PI/PN entirely
103129
if (tStep === 0) return; // T=0 disables the pitch envelope
104130
if (sectionIdx >= sections.length) {
105131
if (!repeat) return;

src/main.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "./style.css";
22
import {
33
DEFAULT_ENVELOPE,
4+
decodeNoiseP,
45
expand,
56
formatBasic,
67
formatSound,
@@ -52,7 +53,7 @@ interface SoundParams {
5253
const DEFAULT_SOUND: SoundParams = { channel: 1, amplitude: 1, pitch: 100, duration: 20 };
5354

5455
const SOUND_FIELDS: { key: keyof SoundParams; code: string; label: string; min: number; max: number; hint: string }[] = [
55-
{ key: "channel", code: "C", label: "Channel", min: 1, max: 3, hint: "1..3 = tone (channel 0 noise is not supported by this tool)" },
56+
{ key: "channel", code: "C", label: "Channel", min: 0, max: 3, hint: "0 = noise, 1..3 = tone" },
5657
{ key: "amplitude", code: "A", label: "Amplitude", min: 1, max: 4, hint: "1..4 selects envelope number (negative static volumes are not supported by this tool)" },
5758
{ key: "pitch", code: "P", label: "Pitch", min: 0, max: 255, hint: "4 units per semitone, 48 per octave" },
5859
{ key: "duration", code: "D", label: "Duration", min: 1, max: 255, hint: "in 1/20 s" },
@@ -108,9 +109,14 @@ function setEnvelopeNumber(n: number): void {
108109
if (ampInput) ampInput.value = String(c);
109110
}
110111

112+
function currentNoiseMode(): ReturnType<typeof decodeNoiseP> | null {
113+
return sound.channel === 0 ? decodeNoiseP(sound.pitch) : null;
114+
}
115+
111116
function refresh(skipLine?: "envelope" | "sound"): void {
112-
currentSamples = expand(env, sound.amplitude, sound.duration, holdEnabled);
113-
render(canvas, currentSamples, sound.pitch, playheadFraction());
117+
applyChannelMode();
118+
currentSamples = expand(env, sound.amplitude, sound.duration, holdEnabled, sound.channel);
119+
render(canvas, currentSamples, sound.pitch, playheadFraction(), currentNoiseMode());
114120
if (skipLine !== "envelope") envelopeLine.value = formatBasic(env);
115121
if (skipLine !== "sound") soundLine.value = formatSound(sound.channel, sound.amplitude, sound.pitch, sound.duration, holdEnabled);
116122
updateUrlParams();
@@ -156,7 +162,7 @@ function animatePlayhead(): void {
156162
if (playheadRaf !== null) return;
157163
const tick = () => {
158164
const f = playheadFraction();
159-
render(canvas, currentSamples, sound.pitch, f);
165+
render(canvas, currentSamples, sound.pitch, f, currentNoiseMode());
160166
if (f === null) {
161167
playheadRaf = null;
162168
return;
@@ -227,6 +233,72 @@ for (const f of SOUND_FIELDS) {
227233
soundInputs.set(f.key, input);
228234
}
229235

236+
// Noise-mode select. Sits in the SOUND grid alongside the pitch input but is
237+
// only visible when channel === 0; encodes the low 3 bits of the SOUND P arg
238+
// per the SN76489AN noise control register (bit 2 = type, bits 1..0 = rate).
239+
const noiseModeWrap = document.createElement("label");
240+
noiseModeWrap.className = "field";
241+
noiseModeWrap.title = "SN76489AN noise control: bit 2 = type, bits 1..0 = LFSR shift rate (P=3/7 'follows tone 2' currently treated as medium)";
242+
{
243+
const lbl = document.createElement("span");
244+
lbl.className = "field-label";
245+
const name = document.createElement("span");
246+
name.className = "field-name";
247+
name.textContent = "Noise mode";
248+
const codeEl = document.createElement("code");
249+
codeEl.className = "field-code";
250+
codeEl.textContent = "P";
251+
lbl.append(name, codeEl);
252+
noiseModeWrap.appendChild(lbl);
253+
}
254+
const noiseSelect = document.createElement("select");
255+
const NOISE_OPTIONS: Array<[number, string]> = [
256+
[0, "Periodic, high"],
257+
[1, "Periodic, medium"],
258+
[2, "Periodic, low"],
259+
[3, "Periodic, follows tone 2"],
260+
[4, "White, high"],
261+
[5, "White, medium"],
262+
[6, "White, low"],
263+
[7, "White, follows tone 2"],
264+
];
265+
for (const [v, label] of NOISE_OPTIONS) {
266+
const o = document.createElement("option");
267+
o.value = String(v);
268+
o.textContent = `${label} (P=${v})`;
269+
noiseSelect.appendChild(o);
270+
}
271+
noiseSelect.addEventListener("change", () => {
272+
sound.pitch = Number(noiseSelect.value);
273+
const pi = soundInputs.get("pitch");
274+
if (pi) pi.value = String(sound.pitch);
275+
refresh();
276+
});
277+
noiseModeWrap.appendChild(noiseSelect);
278+
soundGrid.appendChild(noiseModeWrap);
279+
280+
const pitchH3 = pitchGrid.previousElementSibling as HTMLElement;
281+
const pitchInputWrap = soundInputs.get("pitch")!.closest("label") as HTMLElement;
282+
283+
function applyChannelMode(): void {
284+
const isNoise = sound.channel === 0;
285+
pitchH3.style.display = isNoise ? "none" : "";
286+
pitchGrid.style.display = isNoise ? "none" : "";
287+
pitchInputWrap.style.display = isNoise ? "none" : "";
288+
noiseModeWrap.style.display = isNoise ? "" : "none";
289+
if (isNoise) {
290+
// Channel 0 only uses low 3 bits of P; clamp once on entry so the URL,
291+
// BASIC line, and select all stay consistent.
292+
const clamped = sound.pitch & 0x07;
293+
if (clamped !== sound.pitch) {
294+
sound.pitch = clamped;
295+
const pi = soundInputs.get("pitch");
296+
if (pi) pi.value = String(sound.pitch);
297+
}
298+
noiseSelect.value = String(sound.pitch);
299+
}
300+
}
301+
230302
// Enforce env.n === sound.amplitude after the inputs are constructed (URL
231303
// load may have populated mismatched values; treat sound.amplitude as the
232304
// authoritative side since it's what selects the envelope at playback).
@@ -329,7 +401,7 @@ function playOnce(): void {
329401
// so audio playback is just a single play() call — no setTimeout-based
330402
// re-trigger. Mirrors BBC SOUND duration -1 semantics: envelope keeps
331403
// running, doesn't restart from the top.
332-
play(currentSamples, sound.pitch);
404+
play(currentSamples, sound.pitch, currentNoiseMode());
333405
animatePlayhead();
334406
}
335407

@@ -339,7 +411,7 @@ function stopAll(): void {
339411
cancelAnimationFrame(playheadRaf);
340412
playheadRaf = null;
341413
}
342-
render(canvas, currentSamples, sound.pitch, null);
414+
render(canvas, currentSamples, sound.pitch, null, currentNoiseMode());
343415
}
344416

345417
document.getElementById("play")!.addEventListener("click", () => {
@@ -409,11 +481,11 @@ refresh();
409481
// interaction. The fallback no-ops if the immediate attempt already kicked
410482
// audio into the running state.
411483
if (autoplayRequested) {
412-
play(currentSamples, sound.pitch);
484+
play(currentSamples, sound.pitch, currentNoiseMode());
413485
animatePlayhead();
414486
const playOnGesture = (): void => {
415487
if (!audioContextIsRunning()) {
416-
play(currentSamples, sound.pitch);
488+
play(currentSamples, sound.pitch, currentNoiseMode());
417489
animatePlayhead();
418490
}
419491
document.removeEventListener("click", playOnGesture);

0 commit comments

Comments
 (0)