|
| 1 | +# Envelope-tool ↔ BBC OS sound-chip debugging notes |
| 2 | + |
| 3 | +These notes accompany the four reference traces in the jsbeeb repo root: |
| 4 | +`trace-env1.json`, `trace-env2.json`, `trace-env3.json`, `trace-env4.json`, |
| 5 | +plus `trace-vol-sweep.csv`. Each trace was captured by running the same |
| 6 | +`ENVELOPE` + `SOUND` pair on a real BBC OS 1.20 inside the jsbeeb headless |
| 7 | +emulator and logging every byte the OS sends to the SN76489. |
| 8 | + |
| 9 | +The traces are the ground truth. envelope-tool's job is to produce the same |
| 10 | +sequence of register writes (modulo a small set of documented quirks) when |
| 11 | +fed the same `ENVELOPE` and `SOUND` parameters. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 1. The two scales involved |
| 16 | + |
| 17 | +The BBC and the SN76489 use different volume conventions. |
| 18 | + |
| 19 | +| Scale | Range | Type | Where it appears | |
| 20 | +| ------------------ | ---------------- | ------------------------------- | ------------------------------------ | |
| 21 | +| BBC amplitude | `0..127` (7-bit) | **Linear** | `ENVELOPE` AA/AD/AS/AR, ALA, ALD | |
| 22 | +| SN76489 attenuation | `0..15` (4-bit) | **Logarithmic** (~2 dB per step) | What gets written to the sound chip | |
| 23 | + |
| 24 | +The OS continuously holds a current 7-bit amplitude per channel and converts |
| 25 | +it to a 4-bit SN attenuation every time it issues a chip write. |
| 26 | + |
| 27 | +## 2. The OS volume mapping |
| 28 | + |
| 29 | +It is **not** a 128-byte lookup table. The OS uses a single arithmetic |
| 30 | +expression: |
| 31 | + |
| 32 | +``` |
| 33 | +attenuation = (127 - amplitude) >> 3 // integer divide, no rounding |
| 34 | +``` |
| 35 | + |
| 36 | +Equivalently, sixteen uniform cells eight BBC units wide: |
| 37 | + |
| 38 | +| Attenuation (loudness) | BBC amplitude range | |
| 39 | +| ---------------------- | ------------------- | |
| 40 | +| 0 (loudest) | 120..127 | |
| 41 | +| 1 | 112..119 | |
| 42 | +| 2 | 104..111 | |
| 43 | +| 3 | 96..103 | |
| 44 | +| 4 | 88..95 | |
| 45 | +| 5 | 80..87 | |
| 46 | +| 6 | 72..79 | |
| 47 | +| 7 | 64..71 | |
| 48 | +| 8 | 56..63 | |
| 49 | +| 9 | 48..55 | |
| 50 | +| 10 | 40..47 | |
| 51 | +| 11 | 32..39 | |
| 52 | +| 12 | 24..31 | |
| 53 | +| 13 | 16..23 | |
| 54 | +| 14 | 8..15 | |
| 55 | +| 15 (silent) | 0..7 | |
| 56 | + |
| 57 | +Verified empirically by the `trace-vol-sweep.csv` capture (envelope with |
| 58 | +`AD=-1`, `T=1`, swept from BBC amp 127 → 0): the SN attenuation increments |
| 59 | +exactly every eighth tick. |
| 60 | + |
| 61 | +This linear-to-quasi-log mapping is the entire reason the BBC's "volume |
| 62 | +curve" looks non-linear when you observe register writes — the BBC |
| 63 | +amplitude is linear, the SN is log, and the conversion is integer |
| 64 | +truncation. |
| 65 | + |
| 66 | +### Implication: which `ENVELOPE` step sizes will skip attenuations? |
| 67 | + |
| 68 | +The OS writes to the chip only when attenuation **changes** between ticks. |
| 69 | +If a single tick changes BBC amplitude by enough to cross more than one |
| 70 | +8-wide cell, intermediate attenuations are skipped: |
| 71 | + |
| 72 | +| Per-tick BBC amp delta | Will it skip attenuations? | |
| 73 | +| ---------------------- | -------------------------- | |
| 74 | +| 1..7 | Never (every cell entered) | |
| 75 | +| 8 | Sometimes (boundary aligned) | |
| 76 | +| 9..15 | Sometimes (one in N skipped) | |
| 77 | +| 10 | Skips ~1 in every 4 cells (see env1) | |
| 78 | +| ≥16 | Always skips ≥1 cell per tick | |
| 79 | + |
| 80 | +env1 shows the canonical skip pattern (10 BBC units per tick → attenuations |
| 81 | +1, 6, 11 skipped). envelope-tool must implement the same "write only on |
| 82 | +change" logic, or it will emit extra writes envelope-tool didn't. |
| 83 | + |
| 84 | +## 3. Trace format |
| 85 | + |
| 86 | +Each entry in the JSON traces: |
| 87 | + |
| 88 | +```js |
| 89 | +{ |
| 90 | + cycle: 5604034, // CPU cycle, relative to first captured write (2 MHz clock) |
| 91 | + byte: 208, // raw byte written to &FE43 (SN76489 data port) |
| 92 | + byteHex: "0xd0", |
| 93 | + channel: 2, // SN76489 channel index (0/1/2 = tone, 3 = noise) |
| 94 | + kind: "vol", // one of: "vol" | "periodLo" | "periodHi" | "noise" |
| 95 | + field: 0, // decoded value (vol 0-15, period 0-15 lo/hi, noise 0-15) |
| 96 | + usedLatch: false // true if this byte continued a previously-latched register |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +For tone period: `period = (periodHi << 4) | periodLo` (12-bit period, 4 lo |
| 101 | +bits then 6 hi bits over the bus). |
| 102 | + |
| 103 | +### BBC channel → SN76489 channel mapping |
| 104 | + |
| 105 | +| BBC `SOUND` channel | SN76489 channel | Notes | |
| 106 | +| ------------------- | --------------- | ---------------------------- | |
| 107 | +| 0 | 3 | Noise channel | |
| 108 | +| 1 | 2 | Tone (this is what env1 uses) | |
| 109 | +| 2 | 1 | Tone | |
| 110 | +| 3 | 0 | Tone | |
| 111 | + |
| 112 | +Yes, the order is reversed. envelope-tool must apply this mapping. |
| 113 | + |
| 114 | +## 4. The traces, summarised |
| 115 | + |
| 116 | +| Trace | ENVELOPE | SOUND | Channel | Writes | What it stresses | |
| 117 | +| -------------------- | ------------------------------------------------- | -------------------- | -------------- | ------ | -------------------------------------- | |
| 118 | +| `trace-env1.json` | `1,8,1,-1,1,1,1,1,121,-10,-5,-2,120,-1` | `SOUND 1,1,100,40` | tone (SN 2) | 48 | Volume **skips** + pitch wobble (PI±1) | |
| 119 | +| `trace-env2.json` | `2,1,14,-18,-1,44,32,50,6,1,0,-2,120,126` | `SOUND 0,2,0,16` | noise (SN 3) | 173 | Pitch envelope writes to **noise control register** | |
| 120 | +| `trace-env3.json` | `3,4,0,0,0,1,1,1,126,-4,-2,-4,126,110` | `SOUND 0,3,7,100` | noise (SN 3) | 16 | Volume-only, slow `T=4` | |
| 121 | +| `trace-env4.json` | `1,1,0,0,0,0,0,0,127,-3,-2,-4,126,80` | `SOUND 0,1,6,30` | noise (SN 3) | 16 | Volume-only, fast `T=1`, **decay→sustain transition** at ALD=80 | |
| 122 | +| `trace-vol-sweep.csv`| `1,1,0,0,0,1,1,1,126,-1,-1,0,126,1` | `SOUND 0,1,6,200` | noise (SN 3) | 17 | Sweep amp 126→0 to derive cell boundaries | |
| 123 | + |
| 124 | +## 5. Behaviours envelope-tool must reproduce |
| 125 | + |
| 126 | +### 5.1 Volume conversion (the big one) |
| 127 | + |
| 128 | +Every tick: |
| 129 | + |
| 130 | +1. Update BBC amplitude (signed add of AA/AD/AS/AR, clamp to 0..127). |
| 131 | +2. Compute `att = (127 - amp) >> 3`. |
| 132 | +3. If `att` differs from the previously written value, send `0x90 | (channel << 5) | att` to the chip (the high bit and `0x10` mark a volume write; channel is the SN channel, not the BBC channel). |
| 133 | + |
| 134 | +### 5.2 Pitch envelope is per-tick, not per-step |
| 135 | + |
| 136 | +Pitch sections (PI1/PI2/PI3 with PN1/PN2/PN3 step counts) advance one |
| 137 | +unit per **envelope tick** (`T` × 10 ms), not per attenuation change. The |
| 138 | +OS writes a new period every time the section's pitch value changes. |
| 139 | + |
| 140 | +For tone channels the pitch byte goes through the SN's two-byte period |
| 141 | +protocol: `periodLo` (latched), then `periodHi` ~400 cycles later. For |
| 142 | +noise channels the same byte goes to the noise control register |
| 143 | +(`0xE0 | (value & 0x0F)`), which selects shift rate / white-vs-periodic |
| 144 | +mode rather than frequency. |
| 145 | + |
| 146 | +**Crucial**: when all three PI values are zero, the OS writes nothing for |
| 147 | +pitch — no redundant register writes. envelope-tool should suppress |
| 148 | +writes when the value hasn't changed. |
| 149 | + |
| 150 | +### 5.3 The note-start "double-write" |
| 151 | + |
| 152 | +At note start, every trace shows the OS writing the initial attenuation |
| 153 | +twice in quick succession: |
| 154 | + |
| 155 | +``` |
| 156 | +cycle 0: att 0 (initial level after attack reaches ALA) |
| 157 | +cycle ~290: att 1 (first decay tick — fires immediately, ~145 µs later) |
| 158 | +``` |
| 159 | + |
| 160 | +This is the OS's normal startup flow (attack target → first envelope tick |
| 161 | +runs immediately) and is reproducible across all volume-only traces. It |
| 162 | +is probably inaudible. envelope-tool can match it or skip it; just be |
| 163 | +aware when diffing. |
| 164 | + |
| 165 | +### 5.4 Decay → sustain transition |
| 166 | + |
| 167 | +When BBC amplitude crosses `ALD`, the per-tick rate switches from `AD` |
| 168 | +to `AS`. env4 demonstrates: 30 ms cadence (3 ticks/cell at `AD=-3`) |
| 169 | +becomes 40 ms cadence (4 ticks/cell at `AS=-2`) at attenuation 7→8, |
| 170 | +which corresponds to BBC amp ≈ 80 = `ALD`. |
| 171 | + |
| 172 | +### 5.5 Pitch envelope continues during silence |
| 173 | + |
| 174 | +Once attenuation reaches 15, the volume engine stops writing — but the |
| 175 | +pitch envelope continues to advance and emit register writes for the |
| 176 | +remainder of the SOUND duration. env1 and env2 both show this. env3 |
| 177 | +and env4 don't, because their pitch envelopes are zero so there's |
| 178 | +nothing to write. envelope-tool must keep ticking pitch on silent |
| 179 | +channels. |
| 180 | + |
| 181 | +### 5.6 SOUND queue startup latency |
| 182 | + |
| 183 | +In absolute cycle terms, every SOUND command in these traces fires |
| 184 | +**~1 second after the BASIC line is typed**. The emulator's typing |
| 185 | +finishes, the OS schedules the note via the sound queue, and the actual |
| 186 | +register writes begin a noticeable delay later. |
| 187 | + |
| 188 | +For envelope-tool this matters perceptually: if it begins emitting writes |
| 189 | +the instant the user clicks "play," the attack will start sooner than the |
| 190 | +real BBC. For trace diffing this can be ignored (cycles in the JSON are |
| 191 | +already rebased to the first note write). |
| 192 | + |
| 193 | +## 6. Suggested debug procedure |
| 194 | + |
| 195 | +1. **Implement the volume formula first** (`att = (127 - amp) >> 3`). |
| 196 | + Diff against `trace-env3.json` and `trace-env4.json`. They are |
| 197 | + volume-only and the cleanest tests. |
| 198 | +2. **Add the decay→sustain transition** so env4's timing change at |
| 199 | + attenuation 7 reproduces. |
| 200 | +3. **Add "write only on change"** semantics. Diff against env1: you must |
| 201 | + see the same skipped attenuations (1, 6, 11) when `AD=-10`. |
| 202 | +4. **Implement pitch envelopes for tone channels**. Diff against env1's |
| 203 | + period writes (oscillating between 0xECD and 0xECA). |
| 204 | +5. **Implement pitch envelopes for noise channels** by writing to the |
| 205 | + noise control register. Diff against env2. |
| 206 | +6. **Verify the BBC↔SN channel reversal** (BBC 0 ↔ SN 3, BBC 1 ↔ SN 2, |
| 207 | + etc.). All four traces use this mapping. |
| 208 | + |
| 209 | +The diff doesn't have to be byte-for-byte cycle-perfect — small cycle |
| 210 | +deltas (a few hundred cycles per write) are normal because the OS doesn't |
| 211 | +schedule writes on exact cycle boundaries. Aim for **same byte values in |
| 212 | +the same order** with cycle deltas within ±5%. |
| 213 | + |
| 214 | +## 7. Reproducing the traces |
| 215 | + |
| 216 | +The capture tool is `tools/sound-trace.js`. Command shape: |
| 217 | + |
| 218 | +```sh |
| 219 | +node tools/sound-trace.js \ |
| 220 | + --cmd "ENVELOPE 1,8,1,-1,1,1,1,1,121,-10,-5,-2,120,-1" \ |
| 221 | + --cmd "SOUND 1,1,100,40" \ |
| 222 | + --run-cycles 12000000 \ |
| 223 | + --skip-cycles 5000000 \ |
| 224 | + --relative-cycles \ |
| 225 | + --pretty \ |
| 226 | + --out trace.json |
| 227 | +``` |
| 228 | + |
| 229 | +`--skip-cycles` drops boot-time noise (the OS silences channel 0 at startup). |
| 230 | +`--relative-cycles` rebases the first remaining write to cycle 0. |
| 231 | + |
| 232 | +The tool requires `npm install` to have been run in the jsbeeb repo (it |
| 233 | +pulls in `sharp` via `MachineSession`). |
0 commit comments