Skip to content

Model the Oric's analog sound output stage (volume curve and channel mixing) #17

Open
lxpollitt wants to merge 6 commits into
lanceewing:masterfrom
lxpollitt:fix/web-output-stage-model
Open

Model the Oric's analog sound output stage (volume curve and channel mixing) #17
lxpollitt wants to merge 6 commits into
lanceewing:masterfrom
lxpollitt:fix/web-output-stage-model

Conversation

@lxpollitt

@lxpollitt lxpollitt commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

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:

  1. 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.

  2. 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:

  • For the volume curve: a plain PING command on a real Oric has more of a "ring" to it when compared to the current code, which subjectively fades somewhat more abruptly/harshly.
  • For the the channel mixing:
    • 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.)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant