From cda2d98eba6f40b7c4929555c68ef89541e73c61 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:11:11 +0100 Subject: [PATCH 1/6] Generate web audio at the AudioContext's native sample rate 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. --- .../src/main/java/emu/joric/gwt/GwtAYPSG.java | 124 ++++++++++++++---- .../java/emu/joric/gwt/GwtJOricRunner.java | 24 ++-- .../java/emu/joric/gwt/PSGAudioWorklet.java | 45 ++++++- .../java/emu/joric/worker/JOricWebWorker.java | 35 ++--- html/webapp/sound-renderer.js | 9 +- 5 files changed, 178 insertions(+), 59 deletions(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index a7c98b5..4457be6 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -18,14 +18,32 @@ public class GwtAYPSG implements AYPSG { // The Oric runs at 1 MHz. private static final int CLOCK_1MHZ = 1000000; - private static final int SAMPLE_RATE = 22050; private static final int CYCLES_PER_SECOND = 1000000; - - // The number of cycles it takes to generate a single sample. - public static final double CYCLES_PER_SAMPLE = ((float) CYCLES_PER_SECOND / (float) SAMPLE_RATE); - // Number of samples to queue before being output to the audio hardware. - public static final int SAMPLE_LATENCY = 3072; + // Samples are generated at the AudioContext's native rate, to avoid the + // browser resampling at the context boundary. The cap exists because the + // chip emulation's fixed point maths overflows a 32-bit int above 48 kHz + // (the envelope period reaches (0xFFFF * updateStep) << 1, which is ~77% + // of Integer.MAX_VALUE at 48 kHz), and rates above 48 kHz would provide no + // real audible benefit anyway. The fallback rate is used only if creation + // of the AudioContext fails, in which case there is no audio output at all, + // but the sample generation maths must remain sane. 22050 was the fixed + // rate that was used before host-native rate support was added. + public static final int MAX_SAMPLE_RATE = 48000; + public static final int FALLBACK_SAMPLE_RATE = 22050; + + // Target time by which sample generation runs ahead of audio output. This + // protects against scheduling delays in the web worker (e.g. a skipped + // animation frame) and jitter between us and the hosts audio system, at + // the cost of latency between the emulation writing a register and the + // result being heard. The equivalent number of samples is derived from + // this in sampleLatency. + public static final int SAMPLE_LATENCY_MS = 140; + + // The actual sample rate, and values derived from it. See configureSampleRate. + private int sampleRate; + 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 @@ -84,15 +102,18 @@ public class GwtAYPSG implements AYPSG { private double cyclesToNextSample; private SharedQueue sampleSharedQueue; - // One-pole DC-blocker state and coefficient used to model the AC coupling capacitor - // on Oric audio output to remove the DC offset that the audio chip's emulated unipolar - // signal would otherwise carry into the AudioWorklet output. - // R = 0.995 gives a -3 dB corner at ~17.5 Hz @ 22050 Hz sample rate, which should be - // below any expected normally audible Oric content. - private static final float DC_BLOCKER_R = 0.995f; + // One-pole DC-blocker state and corner frequency used to model the AC coupling + // capacitor on Oric audio output to remove the DC offset that the audio chip's + // emulated unipolar signal would otherwise carry into the AudioWorklet output. + // The filter coefficient R is derived from the sample rate (in configureSampleRate) + // 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.) + private static final float DC_BLOCKER_CORNER_HZ = 17.5f; + private float dcBlockerR; private float dcBlockerX1; private float dcBlockerY1; - + // TODO: Remove these after debugging timing issue. private long cycleCount; private long startTime; @@ -113,27 +134,52 @@ public class GwtAYPSG implements AYPSG { * @param gwtJOricRunner */ public GwtAYPSG(GwtJOricRunner gwtJOricRunner) { - this((JavaScriptObject)null); + this((JavaScriptObject)null, FALLBACK_SAMPLE_RATE); initialiseAudioWorklet(gwtJOricRunner); + // Now that the AudioContext exists, reconfigure for whatever rate it + // actually opened at. + configureSampleRate(audioWorklet.getSampleRate()); } /** * Constructor for GwtAYPSG (invoked by the web worker). - * + * * @param audioBufferSAB SharedArrayBuffer for the audio ring buffer. + * @param sampleRate The sample rate of the AudioContext created by the UI thread. */ - public GwtAYPSG(JavaScriptObject audioBufferSAB) { + public GwtAYPSG(JavaScriptObject audioBufferSAB, int sampleRate) { this.startTime = TimeUtils.millis(); - + if (audioBufferSAB == null) { - audioBufferSAB = SharedQueue.getStorageForCapacity(22050); + // Sized to hold 1 second at the maximum supported sample rate. The + // capacity is primarily headroom; the latency that is heard is + // governed by SAMPLE_LATENCY_MS. + audioBufferSAB = SharedQueue.getStorageForCapacity(MAX_SAMPLE_RATE); } this.sampleSharedQueue = new SharedQueue(audioBufferSAB); - // 1024 is about 46ms of sample data, and is 8 frames of data for the - // audio worklet processor. + // Samples are pushed to the shared queue in chunks of 512, i.e. 4 of + // the AudioWorklet's fixed 128-sample render quanta. This is push + // granularity only, so is independent of sample rate; the latency that + // is heard is governed by SAMPLE_LATENCY_MS. this.sampleBuffer = TypedArrays.createFloat32Array(512); this.sampleBufferOffset = 0; + + configureSampleRate(sampleRate); + } + + /** + * Sets the sample rate and the values derived from it. For the UI thread + * instance, this is the rate of the AudioContext it created. For the web + * worker instance, it is the rate received in the Initialise message. + * + * @param sampleRate The sample rate that samples will be played at. + */ + private void configureSampleRate(int sampleRate) { + this.sampleRate = sampleRate; + this.cyclesPerSample = ((double) CYCLES_PER_SECOND) / sampleRate; + this.sampleLatency = (sampleRate * SAMPLE_LATENCY_MS) / 1000; + this.dcBlockerR = 1.0f - (float)((2 * Math.PI * DC_BLOCKER_CORNER_HZ) / sampleRate); } /** @@ -148,7 +194,7 @@ public void init(Via via, Keyboard keyboard, Snapshot snapshot) { this.via = via; keyboard.setPsg(this); - updateStep = (int) (((long) step * 8L * (long) SAMPLE_RATE) / (long) CLOCK_1MHZ); + updateStep = (int) (((long) step * 8L * (long) sampleRate) / (long) CLOCK_1MHZ); output = new int[] { 0, 0, 0, 0xFF }; count = new int[] { updateStep, updateStep, updateStep, 0x7fff, updateStep }; period = new int[] { updateStep, updateStep, updateStep, updateStep, 0 }; @@ -164,7 +210,7 @@ public void init(Via via, Keyboard keyboard, Snapshot snapshot) { busDirection = 0; addressLatch = 0; - cyclesToNextSample = CYCLES_PER_SAMPLE; + cyclesToNextSample = cyclesPerSample; dcBlockerX1 = 0f; dcBlockerY1 = 0f; @@ -227,7 +273,7 @@ public void emulateCycle() { // If enough cycles have elapsed since the last sample, then output another. if (--cyclesToNextSample <= 0) { - cyclesToNextSample += CYCLES_PER_SAMPLE; + cyclesToNextSample += cyclesPerSample; // No point writing samples until we know that the AudioWorklet is ready. if (writeSamplesEnabled) { @@ -263,7 +309,7 @@ public void resumeSound() { logToJSConsole("Cleared " + totalCleared + " old samples."); // Now fill with silence, so that we do not slow down emulation rate. - int silentSampleCount = GwtAYPSG.SAMPLE_LATENCY - (GwtAYPSG.SAMPLE_RATE / 60); + int silentSampleCount = sampleLatency - (sampleRate / 60); sampleSharedQueue.push(TypedArrays.createFloat32Array(silentSampleCount)); } } @@ -565,7 +611,7 @@ public void writeSample() { // likely a closer approximation of the original hardware circuit bahaviour.) float x = sample / 16384.0f; float y = Math.max(-1f, Math.min(1f, - x - dcBlockerX1 + DC_BLOCKER_R * dcBlockerY1)); + x - dcBlockerX1 + dcBlockerR * dcBlockerY1)); dcBlockerX1 = x; dcBlockerY1 = y; sampleBuffer.set(sampleBufferOffset, y); @@ -599,6 +645,34 @@ public void writeSample() { public SharedQueue getSampleSharedQueue() { return sampleSharedQueue; } + + /** + * Returns the sample rate that samples are being generated at. + * + * @return The sample rate that samples are being generated at. + */ + public int getSampleRate() { + return sampleRate; + } + + /** + * Returns the target number of samples for the shared queue, i.e. + * SAMPLE_LATENCY_MS worth of samples at the current sample rate. + * + * @return The target number of samples for the shared queue. + */ + public int getSampleLatency() { + return sampleLatency; + } + + /** + * Returns the number of cycles it takes to generate a single sample. + * + * @return The number of cycles it takes to generate a single sample. + */ + public double getCyclesPerSample() { + return cyclesPerSample; + } JavaScriptObject getSharedArrayBuffer() { return sampleSharedQueue.getSharedArrayBuffer(); diff --git a/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java b/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java index 199cf1c..4619af0 100644 --- a/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java +++ b/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java @@ -196,9 +196,10 @@ public void onError(final ErrorEvent pEvent) { // then another message to Start the machine with the given game data. The // game data is "transferred", whereas the others are not but rather shared. worker.postObject("Initialise", createInitialiseObject( - keyMatrixSAB, + keyMatrixSAB, pixelDataSAB, - audioDataSAB)); + audioDataSAB, + gwtPSG.getSampleRate())); worker.postArrayBufferAndObject("Start", programArrayBuffer, createStartObject( @@ -217,20 +218,23 @@ public void onError(final ErrorEvent pEvent) { * Creates a JavaScript object, wrapping the objects to send to the web worker to * initialise the Machine. * - * @param keyMatrixSAB - * @param pixelDataSAB - * @param audioDataSAB - * + * @param keyMatrixSAB + * @param pixelDataSAB + * @param audioDataSAB + * @param sampleRate The sample rate that sound samples should be generated at. + * * @return The created object. */ private native JavaScriptObject createInitialiseObject( - JavaScriptObject keyMatrixSAB, + JavaScriptObject keyMatrixSAB, JavaScriptObject pixelDataSAB, - JavaScriptObject audioDataSAB)/*-{ - return { + JavaScriptObject audioDataSAB, + int sampleRate)/*-{ + return { keyMatrixSAB: keyMatrixSAB, pixelDataSAB: pixelDataSAB, - audioDataSAB: audioDataSAB + audioDataSAB: audioDataSAB, + sampleRate: sampleRate }; }-*/; diff --git a/html/src/main/java/emu/joric/gwt/PSGAudioWorklet.java b/html/src/main/java/emu/joric/gwt/PSGAudioWorklet.java index f31a455..cb1f737 100644 --- a/html/src/main/java/emu/joric/gwt/PSGAudioWorklet.java +++ b/html/src/main/java/emu/joric/gwt/PSGAudioWorklet.java @@ -30,8 +30,9 @@ public class PSGAudioWorklet { public PSGAudioWorklet(SharedQueue sampleSharedQueue, GwtJOricRunner gwtJOricRunner) { this.sampleSharedQueue = sampleSharedQueue; this.gwtJOricRunner = gwtJOricRunner; - - initialise(sampleSharedQueue.getSharedArrayBuffer()); + + initialise(sampleSharedQueue.getSharedArrayBuffer(), + GwtAYPSG.MAX_SAMPLE_RATE, GwtAYPSG.FALLBACK_SAMPLE_RATE); } /** @@ -44,8 +45,12 @@ public PSGAudioWorklet(SharedQueue sampleSharedQueue, GwtJOricRunner gwtJOricRun * call to resume within a user gesture. * * @param audioBufferSAB The SharedArrayBuffer to get the sound sample data from. + * @param maxSampleRate The maximum supported sample rate. If the device's native + * rate is higher, the AudioContext is opened at this rate instead. + * @param fallbackSampleRate The rate to report if AudioContext creation fails. */ - private native void initialise(JavaScriptObject audioBufferSAB)/*-{ + private native void initialise(JavaScriptObject audioBufferSAB, + int maxSampleRate, int fallbackSampleRate)/*-{ var ua = navigator.userAgent.toLowerCase(); var isIOS = ( (ua.indexOf("iphone") >= 0 && ua.indexOf("like iphone") < 0) || @@ -66,12 +71,32 @@ private native void initialise(JavaScriptObject audioBufferSAB)/*-{ try { // If this is not executing within a user gesture, then it will be suspended. - this.audioContext = new AudioContext({sampleRate: 22050}); + // The AudioContext is opened at the device's preferred sample rate, so that + // no resampling happens at the context boundary, unless that rate is above + // the maximum that the sample generation supports, in which case it is opened + // at that maximum instead and the browser resamples to the device rate. + this.audioContext = new AudioContext(); + if (this.audioContext.sampleRate > maxSampleRate) { + console.log("Device sample rate of " + this.audioContext.sampleRate + + " is above the maximum supported. Recreating AudioContext at " + + maxSampleRate + "."); + this.audioContext.close(); + this.audioContext = new AudioContext({sampleRate: maxSampleRate}); + } } catch (e) { console.log("Failed to create AudioContext. Error was: " + e); } - + + // The sample generation rate is driven by the rate the AudioContext was + // actually able to open at. The fallback should never be needed, since if + // the AudioContext failed to create then there is no audio output anyway, + // but the sample generation maths needs a sane rate regardless. + // Math.round because the Web Audio API exposes sampleRate as a float, + // and the Java side expects an exact int. + this.sampleRate = (this.audioContext ? Math.round(this.audioContext.sampleRate) : fallbackSampleRate); + console.log("AudioContext sample rate is " + this.sampleRate + "."); + if (this.audioContext) { if (this.audioContext.state == "running") { // For Chrome, it may be that the state is already running at startup. We @@ -156,6 +181,16 @@ public void notifyAudioReady() { public native boolean isReady()/*-{ return this.ready; }-*/; + + /** + * Returns the sample rate of the AudioContext, i.e. the rate at which sound + * samples will be consumed, and therefore the rate they should be generated at. + * + * @return The sample rate of the AudioContext. + */ + public native int getSampleRate()/*-{ + return this.sampleRate; + }-*/; /** * This is invoked whenever the sound output should be resumed. diff --git a/html/src/main/java/emu/joric/worker/JOricWebWorker.java b/html/src/main/java/emu/joric/worker/JOricWebWorker.java index 406f04f..01d9117 100644 --- a/html/src/main/java/emu/joric/worker/JOricWebWorker.java +++ b/html/src/main/java/emu/joric/worker/JOricWebWorker.java @@ -70,9 +70,10 @@ public void onMessage(MessageEvent event) { JavaScriptObject keyMatrixSAB = getNestedObject(eventObject, "keyMatrixSAB"); JavaScriptObject pixelDataSAB = getNestedObject(eventObject, "pixelDataSAB"); JavaScriptObject audioDataSAB = getNestedObject(eventObject, "audioDataSAB"); + int sampleRate = getNestedInt(eventObject, "sampleRate"); keyboardMatrix = new GwtKeyboardMatrix(keyMatrixSAB); pixelData = new GwtPixelData(pixelDataSAB); - psg = new GwtAYPSG(audioDataSAB); + psg = new GwtAYPSG(audioDataSAB, sampleRate); break; case "Start": @@ -168,20 +169,19 @@ private AppConfigItem buildAppConfigItemFromEventObject(JavaScriptObject eventOb /** * This method is the main emulator loop that is run for each animation frame. The - * web worker uses requestAnimationFrame to request that this method is called on + * web worker uses requestAnimationFrame to request that this method is called on * each frame. As this is GWT, it does so via a native method below. This particular * implementation uses an approach where it only emulates as many cycles required to - * fill the sample buffer up to a certain number of samples, e.g. 3072. This value - * will be tweaked during testing on different browsers and devices to choose the - * most appropriate. It needs to balance protecting against delays in the web worker - * generating samples, perhaps due to an animation frame being skipped, and not - * introducing too much delay in the sound that is heard. A value of 3072 would be - * a delay of 3072/22050*1000=139ms. That fraction of a second may not be noticeable - * but going much higher would become a perceivable latency/lag. In an ideal world, - * the web worker would write out 128 samples and the Web Audio thread would read - * that and output it immediately, but in reality both sides do sometimes pause - * slightly, and so we need a "buffer" of already prepared samples for the audio - * thread, thus the 3072 sample figure. + * fill the sample buffer up to a certain number of samples, which is the number of + * samples that GwtAYPSG.SAMPLE_LATENCY_MS represents at the current sample rate. + * That value needs to balance protecting against delays in the web worker + * generating samples, perhaps due to an animation frame being skipped, and not + * introducing too much delay in the sound that is heard. The 140ms value may not + * be noticeable but going much higher would become a perceivable latency/lag. In + * an ideal world, the web worker would write out 128 samples and the Web Audio + * thread would read that and output it immediately, but in reality both sides do + * sometimes pause slightly, and so we need a "buffer" of already prepared samples + * for the audio thread. * * @param timestamp */ @@ -205,9 +205,10 @@ public void performAnimationFrame(double timestamp) { // to a value that would leave the available samples in the queue // at a roughly fixed number. This is to avoid under or over generating // samples, being always a given number of samples ahead in the buffer. + int sampleLatency = psg.getSampleLatency(); int currentBufferSize = psg.getSampleSharedQueue().availableRead(); - int samplesToGenerate = (currentBufferSize >= GwtAYPSG.SAMPLE_LATENCY? 0 : GwtAYPSG.SAMPLE_LATENCY - currentBufferSize); - expectedCycleCount = (int)(samplesToGenerate * GwtAYPSG.CYCLES_PER_SAMPLE); + int samplesToGenerate = (currentBufferSize >= sampleLatency? 0 : sampleLatency - currentBufferSize); + expectedCycleCount = (int)(samplesToGenerate * psg.getCyclesPerSample()); // While the emulation cycle rate is throttling by the audio thread // output rate, we keep resetting the startTime, in case sound is turned @@ -326,6 +327,10 @@ private native String getNestedString(JavaScriptObject obj, String fieldName)/*- return obj.object[fieldName]; }-*/; + private native int getNestedInt(JavaScriptObject obj, String fieldName)/*-{ + return obj.object[fieldName]; + }-*/; + private native ArrayBuffer getArrayBuffer(JavaScriptObject obj)/*-{ return obj.buffer; }-*/; diff --git a/html/webapp/sound-renderer.js b/html/webapp/sound-renderer.js index c933177..7a52218 100644 --- a/html/webapp/sound-renderer.js +++ b/html/webapp/sound-renderer.js @@ -146,8 +146,9 @@ class RingBuffer { */ class SoundRenderer extends AudioWorkletProcessor { - // To output 22050 samples per second, 128 each call. - static CALLS_PER_SECOND = (22050 / 128); + // The number of process() calls per second, given 128 samples each call. + // The sampleRate global is provided by the AudioWorkletGlobalScope. + static CALLS_PER_SECOND = (sampleRate / 128); // The number of calls since the last debug logging reset. callCount = 0; @@ -203,8 +204,8 @@ class SoundRenderer extends AudioWorkletProcessor { let timeThisCall = currentTime * 1000; // The inputs is ignored. We get up to samples from the ring buffer instead. - // We have only one output, with one channel (mono), sample rate 22050. - // Sample values are float values between -1 and 1. + // We have only one output, with one channel (mono), at the AudioContext's + // sample rate. Sample values are float values between -1 and 1. let logDebugOutput = false; From 69b6d160fe33e53a70aebb802255363d01305e63 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:47:35 +0100 Subject: [PATCH 2/6] Fix envelope generator freezing when envelope period is 0 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. --- html/src/main/java/emu/joric/gwt/GwtAYPSG.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index 4457be6..86f9eef 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -197,12 +197,18 @@ public void init(Via via, Keyboard keyboard, Snapshot snapshot) { updateStep = (int) (((long) step * 8L * (long) sampleRate) / (long) CLOCK_1MHZ); output = new int[] { 0, 0, 0, 0xFF }; count = new int[] { updateStep, updateStep, updateStep, 0x7fff, updateStep }; - period = new int[] { updateStep, updateStep, updateStep, updateStep, 0 }; + // Every period must be non-zero: writeSample's counter catch-up loops + // never terminate on a zero period. + period = new int[] { updateStep, updateStep, updateStep, updateStep, updateStep }; registers = new int[16]; volumeA = volumeB = volumeC = volumeEnvelope = 0; disableToneA = disableToneB = disableToneC = disableAllNoise = false; - countEnv = hold = alternate = attack = holding = 0; + countEnv = hold = alternate = attack = 0; + // The envelope starts out holding, as if the reset-default shape 0 + // (decay then hold) had already completed its decay to 0. It starts + // moving when a program first writes the envelope shape register. + holding = 1; enable = 0; outNoise = 0; random = 1; @@ -430,6 +436,12 @@ public void writeRegister(int address, int value) { case 0x0B: case 0x0C: { int val = (((registers[0x0C] << 8) | registers[0x0B]) * updateStep) << 1; + // On the real chip an envelope period register value of 0 runs at + // twice the speed of period 1, i.e. 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.) Period 1 is (updateStep << 1) + // here, so period 0 maps to half of that, which is updateStep. + val = (val == 0 ? updateStep : val); int last = period[ENVELOPE]; period[ENVELOPE] = val; int newCount = count[ENVELOPE] - (val - last); @@ -560,7 +572,7 @@ public void writeSample() { left -= add; } while (left > 0); - if (holding == 0 && period[ENVELOPE] != 0) { + if (holding == 0) { if ((count[ENVELOPE] -= step) <= 0) { int ce = countEnv; int envelopePeriod = period[ENVELOPE]; From 4dfe9a9414391ad986356f01388ba28ebbd14a84 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:29:13 +0100 Subject: [PATCH 3/6] Fix envelope shape decoding for hold shapes 11, 13 and 15 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. --- html/src/main/java/emu/joric/gwt/GwtAYPSG.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index 86f9eef..0f20b5a 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -458,9 +458,6 @@ public void writeRegister(int address, int value) { } else { hold = value & 0x01; alternate = value & 0x02; - if (hold != 0) { - attack = alternate; - } } count[ENVELOPE] = period[ENVELOPE]; countEnv = 0x0f; From ddde806a1e8d795c3e0624615fb16e5f154b4922 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:37:42 +0100 Subject: [PATCH 4/6] Fix period register writes disturbing the tone, noise and envelope counters 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. --- html/src/main/java/emu/joric/gwt/GwtAYPSG.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index 0f20b5a..24da9c8 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -392,7 +392,12 @@ public void writeRegister(int address, int value) { int val = (((registers[(address << 1) + 1] & 0x0f) << 8) | registers[address << 1]) * updateStep; int last = period[address]; period[address] = val = ((val < 0x8000) ? 0x8000 : val); - int newCount = count[address] - (val - last); + // Adjust the time remaining to the next flip-flop toggle so that the + // time already elapsed since the last toggle is preserved, i.e. the + // period write moves only the target, as on the real chip. If the + // elapsed time already exceeds the new period, the clamp below makes + // the overdue toggle happen straight away. + int newCount = count[address] + (val - last); count[address] = newCount < 1 ? 1 : newCount; break; } @@ -403,7 +408,7 @@ public void writeRegister(int address, int value) { val *= 2; int last = period[NOISE]; period[NOISE] = val = val == 0 ? updateStep : val; - int newCount = count[NOISE] - (val - last); + int newCount = count[NOISE] + (val - last); count[NOISE] = newCount < 1 ? 1 : newCount; break; } @@ -444,7 +449,7 @@ public void writeRegister(int address, int value) { val = (val == 0 ? updateStep : val); int last = period[ENVELOPE]; period[ENVELOPE] = val; - int newCount = count[ENVELOPE] - (val - last); + int newCount = count[ENVELOPE] + (val - last); count[ENVELOPE] = newCount < 1 ? 1 : newCount; break; } From c9c94bb46459140b92ad6206c41810a7bede5f17 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:38:38 +0100 Subject: [PATCH 5/6] Fix noise period 0 running at twice the speed of period 1 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. --- html/src/main/java/emu/joric/gwt/GwtAYPSG.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java index 24da9c8..3bb6685 100644 --- a/html/src/main/java/emu/joric/gwt/GwtAYPSG.java +++ b/html/src/main/java/emu/joric/gwt/GwtAYPSG.java @@ -405,9 +405,14 @@ public void writeRegister(int address, int value) { // Noise period. case 0x06: { int val = (value & 0x1f) * updateStep; + // A noise period of 0 behaves the same as a noise period of 1: the + // data sheet notes that the lowest period value is 1 for both tone + // and noise, and this behaviour has been verified using original + // Oric-1 hardware. + val = (val == 0 ? updateStep : val); val *= 2; int last = period[NOISE]; - period[NOISE] = val = val == 0 ? updateStep : val; + period[NOISE] = val; int newCount = count[NOISE] + (val - last); count[NOISE] = newCount < 1 ? 1 : newCount; break; From 7a4e5a2a56ecc99524442b582386e64f4b07ee16 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:04:07 +0100 Subject: [PATCH 6/6] Model the Oric's analog output stage (volume curve and channel mixing) 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. --- .../src/main/java/emu/joric/gwt/GwtAYPSG.java | 102 +++++++++++++++--- 1 file changed, 89 insertions(+), 13 deletions(-) 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