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;