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;