Model the Oric's analog sound output stage (volume curve and channel mixing) #17
Open
lxpollitt wants to merge 6 commits into
Open
Model the Oric's analog sound output stage (volume curve and channel mixing) #17lxpollitt wants to merge 6 commits into
lxpollitt wants to merge 6 commits into
Conversation
Previously the sample rate was hardcoded to 22050 Hz, both for sample generation and for the AudioContext, forcing the browser to resample to the device rate (typically 44100 or 48000 Hz) and, more significantly, putting the generation Nyquist at 11 kHz, so square wave harmonics above that folded back down as audible inharmonic artefacts (easily heard with e.g. SOUND 1,8,15 or SOUND 1,16,15). The AudioContext is now opened at the device's preferred rate, capped at 48 kHz (above which the chip emulation's 32-bit fixed point envelope maths would overflow, with no real audible benefit anyway), and the rate is passed to the web worker in the Initialise message so that sample generation matches. The sample latency target is now expressed in milliseconds (SAMPLE_LATENCY_MS = 140, equivalent to the previous 3072 samples at 22050 Hz) and the DC blocker coefficient is derived from the rate to keep its corner frequency at ~17.5 Hz. If AudioContext creation fails, the sample generation maths falls back to the previous fixed 22050 Hz rate.
When a program set the envelope period registers (R11/R12) to 0, the emulated envelope stopped advancing entirely: the period value of 0 tripped a guard in the per-sample envelope logic that skipped the envelope counter. A subsequent envelope shape write (R13) unconditionally presents the envelope's starting volume (15 for the attack-low shapes), so any channel in envelope mode froze at maximum DAC level, producing a large persistent DC offset. This was the root cause of the loud click on PLAY 0,0,0,0 (the Oric BASIC idiom for silencing sound), as the DC blocker took seconds to drain the stuck level away. On the real chip an envelope period of 0 does not freeze: it runs at twice the speed of period 1, completing a full 16-step envelope cycle in 128us at 1 MHz. (This is unlike the tone and noise periods, where 0 behaves the same as 1.) The fix maps an envelope period value of 0 to half of the period 1 value in the R11/R12 handler, initialises the envelope period consistently at reset, and removes the freezing guard. The envelope now also starts in the holding state at reset, matching the real chip's steady state shortly after power-on (the reset-default shape 0 decays to 0 and holds within 128us); previously the never-ticking guard masked this.
The envelope shape register (R13) write handler contained an extra assignment for the continue-plus-hold shapes: it overwrote the attack state with the alternate bit's raw value before the envelope cycle started. The attack state determines both the envelope's ramp direction and, via the end-of-cycle handling, the level a holding envelope holds at, so these shapes played wrong ramps and held at wrong levels. Shape 13 (attack then hold at maximum) instead started at maximum and faded to silence; shape 11 (decay then hold at maximum) played an erratic scrambled ramp and held 6 dB low; shape 15 (attack then hold at silence) held at a loud level instead. Shape 9 survived only by coincidence, as the wrong assignment happens to write the correct value for that shape. The per-sample envelope code already implements the data sheet's hold behaviour correctly when a cycle completes: it holds the last count, or flips to the initial count first when both the Hold and Alternate bits are set. The fix is simply to remove the bad assignment from the write handler. These shapes are reachable from Oric BASIC: PLAY envelope modes 5 and 7 map to shapes 11 and 13 via the ROM's envelope pattern table.
…unters The handlers for the tone (R0-R5), noise (R6) and envelope (R11/R12) period registers adjust the running countdown to the next flip-flop toggle when the period changes. The adjustment's sign was inverted: it subtracted the period change from the remaining count instead of adding it. The counters count down to the next toggle, so preserving the time already elapsed since the last toggle - which is what the real chip does, as a period write only changes the value the counter is compared against - requires moving the remaining count by the same amount as the period. With the inverted sign, every period write displaced the next toggle in the wrong direction by twice the period change: period increases could force an immediate spurious toggle (an audible click), and period decreases stalled the next toggle by up to nearly a full old period. The audible effect was extra transient noise during repeated period writes, e.g. pitch slides, vibrato and alternating tones, giving them a buzzy texture. Steady tones were unaffected, since the adjustment only happens at write time and the counter refills correctly from the new period at each toggle.
The noise period register (R6) handler mapped a written value of 0 to half the period 1 value, making the noise generator's shift rate twice as fast as period 1. The data sheet notes that, as with the tone period, the lowest noise period value is 1 (divide by 1), so a written value of 0 behaves the same as 1. This has also been verified on original Oric-1 hardware: noise periods 0 and 1 sound identical there (while 1 and 2 are clearly distinguishable), whereas the emulation produced noticeably brighter noise for period 0. The zero mapping predates the code's doubling of the noise period value and was not updated when the doubling was added, which is how "0 behaves as 1" silently became "0 behaves as a half". The fix applies the zero mapping before the doubling.
The previous code summed the three channels linearly, each scaled by a synthetic volume table with a uniform 3 dB step, as if the channels were isolated. This new model corrects both the per-level volume curve and the interaction between channels, by modelling the Oric's actual analog output stage. On the Oric the three AY-3-8912 channel outputs are wired directly together into a shared load (R4 (1K) in parallel with the R2 + R3 branch), so the channels interact: a loud channel pulls the shared output node harder and suppresses the others. The output is now computed from a resistor network model of that shared node, evaluated for every combination of the three channel volume levels into a table at startup. Each output sample is a trilinear blend of the table entries over the eight on/off states of the three channels, weighted by the fraction of the sample each channel's gate spent high. The per-level output stage resistances are from bench measurements of a real AY chip (fitted values from MAME's ay8910.cpp, BSD-3-Clause, derived from Matthew Westcott's public domain voltage measurements). As a result the solo volume curve now follows the measured DAC levels (roughly 2 dB steps at the top, wider through the middle, including the near-equal pair at levels 7 and 8) rather than the synthetic 3 dB curve, so mid-level volumes and envelope decays come out fuller. The model's one free parameter, the channel drive strength, was calibrated and validated against suppression measurements made on real Oric-1 hardware. A single channel playing alone is essentially unchanged in level; a full three-channel chord comes out around 5 dB quieter than the previous linear sum.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: This PR is stacked on the rest of the sound series PRs (#12-#16), because it builds on all of them and depends on many of their changes being present. It shows six commits; please review only the last one ("Model the Oric's analog output stage ..."). The other five are the already-submitted PRs and will drop out of this diff automatically as those merge. (I've stacked it on the whole series rather than work out the exact minimal dependency - I hope that's OK.)
Context
Sixth and last in the current series of AY-3-8912 sound emulation improvements, web version first as before. The WIP deployment with the whole series applied is available here if you want to hear the overall impact: https://joric-wip.pages.dev
Unlike the earlier PRs in this series, which fix chip behaviour mismatches (digital focus), this one focusses more on the interaction between the chip and the rest of the Oric's audio circuitry (analog focus) aiming to give a more accurate volume balance and interaction between channels.
Problem
The existing code mixes the three channels by summing them linearly, each scaled by a synthetic volume table with a uniform 3 dB step. This differs from the real Oric in two ways:
The per-level volume curve is not the real chip's. On a real AY the 16 DAC levels are not uniformly spaced - the steps are roughly 2 dB at the top, wider through the middle, with a near-equal pair at levels 7 and 8 - so the synthetic 3 dB curve makes mid-range volumes and envelope fades sound different from the real Oric.
The three channels do not actually mix linearly. On the Oric the AY's three channel outputs are wired directly together into a shared load (R4 in parallel with the R2 + R3 branch), so they interact: a high output level channel pulls the shared output node harder and reduces the others' contribution. Linear summing - the usual approach in AY emulators, since this interaction only appears once you model a specific machine's analog output network - misses this.
Examples to reproduce:
PINGcommand on a real Oric has more of a "ring" to it when compared to the current code, which subjectively fades somewhat more abruptly/harshly.SOUND 1,100,15:SOUND 2,1,0:SOUND 3,1,0:PLAY 7,0,4,1000- on a real Oric the tone produced from channel 1 has a tremolo style effect as channels 2 & 3 fade in and out according to the repeating envelope; with the emulator existing code there is no tremolo.PING:WAIT100:SOUND1,100,15:WAIT100:SOUND2,200,15:WAIT100:SOUND3,400,15- on a real Oric the tone produced from channel 1 starts loud, and then decreases as each of channels 2 and 3 sets their outputs to max; with the existing emulator code the volume does not change. (The PING is just an easy way to reset channels 2 & 3 at the start of the test.) Note that channels 2 and 3 being disabled by the PING command blocks their tones at the chip's internal digital signal mixing stage, but it doesn't disable their output / volume levels which get applied afterwards. So effectively they generate a DC offset in this scenario, which is then largely filtered out by our existing DC blocker filter code, which sits after the output combining circuitry we are modelling in this PR).Fix
Both are corrected together by replacing the linear sum with a resistor-network model of the Oric's shared output node. A table of the mixed output for every combination of the three channel volume levels is built at startup from per-level output-stage resistances (bench-measured AY chip values, from MAME's ay8910.cpp, BSD-3-Clause, originally Matthew Westcott's public domain measurements). Each output sample is then a blend of the table entries over the channels' on/off states during that sample period.
The model's one free parameter - the channel drive strength - was calibrated against measurements taken on real Oric-1 hardware. As an independent cross-check, the resulting solo volume curve lands within ~0.3 dB of Matthew Westcott's measured DAC levels across the audible range.
Scope
Web platform only. The new change (the last commit) is one file - GwtAYPSG. No changes to core or the other platforms. A single channel playing alone is essentially unchanged in level; the audible differences are in multi-channel material and in the shape of the volume curve. (The other five commits in this PR are the rest of the series - see the note at the top.)
Testing
Tested on macOS in Chrome, and with a real Oric-1 for some comparisons.
Envelope decays (e.g.
PING) sound fuller, and game audio that mixes mid-level effects with louder music seems better balanced to me. Sound combination tests driven from BASIC (such as the ones in the Problem section) behave similarly to the real Oric-1 hardware I have. And the balance of sounds in games such as Scuba Dive and Hunchback sound as I remember them - albeit very distant memories from the 1980s - so take that as being as subjective as it is. (I don't currently have the ability to easily load games onto my real Oric-1 so couldn't compare that.)