diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index 3bb6685..ce41dfb 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -45,16 +45,72 @@ public class GwtAYPSG implements AYPSG { private double cyclesPerSample; private int sampleLatency; - // Not entirely sure what these volume levels should be. With LEVEL_DIVISOR set - // to 4, and volumes A, B, and C all at 15, then max sample is at 32760, which - // is just under the limit. - private final static int LEVEL_DIVISOR = 4; - private final static int[] VOLUME_LEVELS = { - 0x0000 / LEVEL_DIVISOR, 0x0055 / LEVEL_DIVISOR, 0x0079 / LEVEL_DIVISOR, - 0x00AB / LEVEL_DIVISOR, 0x00F1 / LEVEL_DIVISOR, 0x0155 / LEVEL_DIVISOR, 0x01E3 / LEVEL_DIVISOR, - 0x02AA / LEVEL_DIVISOR, 0x03C5 / LEVEL_DIVISOR, 0x0555 / LEVEL_DIVISOR, 0x078B / LEVEL_DIVISOR, - 0x0AAB / LEVEL_DIVISOR, 0x0F16 / LEVEL_DIVISOR, 0x1555 / LEVEL_DIVISOR, 0x1E2B / LEVEL_DIVISOR, - 0x2AAA / LEVEL_DIVISOR }; + // The three channels' output stages are connected in parallel on the Oric, + // into a load of R4 (1K) in parallel with the R2 + R3 branch (4K7 + 470), + // so the channels interact: a loud channel pulls the shared output node + // harder and suppresses the contribution of the others. This is modelled + // as a resistor network. Each volume level presents a different effective + // pull-up resistance at the channel output; the values below are from + // bench measurements of a real AY chip (as fitted in MAME's ay8910.cpp, + // BSD-3-Clause, derived from Matthew Westcott's December 2001 public + // domain voltage measurements). + private static final double[] CHANNEL_RES = { + 15950, 15350, 15090, 14760, 14275, 13620, 12890, 11370, + 10600, 8590, 7190, 5985, 4820, 3945, 3017, 2345 }; + private static final double RES_R_UP = 800000; + private static final double RES_R_DOWN = 8000000; + private static final double ORIC_LOAD_R = 838; + + // Calibration of the channels' drive strength against real Oric-1 + // hardware (June 2026): with one and then two channels output disabled + // with their volume parked at 15, the playing channel's measured + // acoustic level dropped by around 6.2 dB and 10.7 dB respectively. + // Applying a conductance scale factor of 2.32 to the resistor network + // channel conductances reproduces both measurements (and, as independent + // corroboration, brings the model's solo channel volume curve to within + // 0.3 dB of Westcott's bench-measured DAC levels across the audible range). + // The measurements of the Oric-1 were carried out with relatively basic + // equipment - so there is scope for refining these numbers further in + // future if greater accuracy is ever desired. + private static final double CONDUCTANCE_SCALE = 2.32; + + // The mixed output level for every combination of the three channels' + // volume levels, in sample units, baseline subtracted. Normalised so that + // a single channel at volume 15 (with the others silent) produces the + // same sample value as it always has (10920); the network model then + // makes a full three channel chord come out around 5 dB quieter than the + // simple mathematical sum of the three would. + private static final float[] MIX_TABLE = buildMixTable(); + + private static double mixNode(int a, int b, int c) { + int n = (a != 0 ? 1 : 0) + (b != 0 ? 1 : 0) + (c != 0 ? 1 : 0); + double gw = n / RES_R_UP; + double gt = n / RES_R_UP + 3.0 / RES_R_DOWN + 1.0 / ORIC_LOAD_R; + double g; + g = CONDUCTANCE_SCALE / CHANNEL_RES[a]; gw += g; gt += g; + g = CONDUCTANCE_SCALE / CHANNEL_RES[b]; gw += g; gt += g; + g = CONDUCTANCE_SCALE / CHANNEL_RES[c]; gw += g; gt += g; + return gw / gt; + } + + private static float[] buildMixTable() { + float[] table = new float[16 * 16 * 16]; + double base = mixNode(0, 0, 0); + double scale = 10920.0 / (mixNode(15, 0, 0) - base); + for (int a = 0; a < 16; a++) { + for (int b = 0; b < 16; b++) { + for (int c = 0; c < 16; c++) { + table[(a << 8) | (b << 4) | c] = + (float) ((mixNode(a, b, c) - base) * scale); + } + } + } + return table; + } + + private static float lerp(float from, float to, float weight) { + return from + (to - from) * weight; + } // Constants for index values into output, count, and period arrays. private static final int A = 0; @@ -109,6 +165,11 @@ public class GwtAYPSG implements AYPSG { // to keep the -3 dB corner at ~17.5 Hz regardless of rate, which should be below // any expected normally audible Oric content. (17.5 Hz is equivalent to the // R = 0.995 that was used when the sample rate was fixed at 22050 Hz.) + // Note that this corner is lower than the genuine Oric speaker path, whose + // coupling works out at ~90Hz based on the available schematics - so the + // real machine had a shorter decay tail after DC level steps (its line/DIN + // output corner was much lower, ~3Hz). Either way, a high-pass passes the + // step transient itself at full height; the corner only shapes the tail. private static final float DC_BLOCKER_CORNER_HZ = 17.5f; private float dcBlockerR; private float dcBlockerX1; @@ -615,9 +676,24 @@ public void writeSample() { } } - int sample = Math.min(((VOLUME_LEVELS[volumeA] * cnt[A]) >> 13) + - ((VOLUME_LEVELS[volumeB] * cnt[B]) >> 13) + - ((VOLUME_LEVELS[volumeC] * cnt[C]) >> 13), 0x7FFF); + // Each channel spent some fraction of this sample with its output gate + // high (cnt / step). The output is the time weighted average of the + // mix table's value over the eight on/off combinations of the three + // channels, i.e. a trilinear blend between the table entries for each + // channel being silent (index 0) or at its volume level. Averaging + // the (non-linear) network output over the states is slightly more + // faithful than evaluating it once at the averages. + float wA = cnt[A] * (1.0f / 32768.0f); + float wB = cnt[B] * (1.0f / 32768.0f); + float wC = cnt[C] * (1.0f / 32768.0f); + int ia = volumeA << 8; + int ib = volumeB << 4; + int ic = volumeC; + float aLow = lerp(lerp(MIX_TABLE[0], MIX_TABLE[ic], wC), + lerp(MIX_TABLE[ib], MIX_TABLE[ib | ic], wC), wB); + float aHigh = lerp(lerp(MIX_TABLE[ia], MIX_TABLE[ia | ic], wC), + lerp(MIX_TABLE[ia | ib], MIX_TABLE[ia | ib | ic], wC), wB); + int sample = (int) lerp(aLow, aHigh, wA); // Use a simple DC blocker to convert to -1.0 to 1.0, which is what the // AudioWorkletProcessor needs. The output clamp is folded into the same