Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 99 additions & 25 deletions html/src/main/java/emu/joric/gwt/GwtAYPSG.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

/**
Expand All @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 14 additions & 10 deletions html/src/main/java/emu/joric/gwt/GwtJOricRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
};
}-*/;

Expand Down
45 changes: 40 additions & 5 deletions html/src/main/java/emu/joric/gwt/PSGAudioWorklet.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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) ||
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 20 additions & 15 deletions html/src/main/java/emu/joric/worker/JOricWebWorker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand Down Expand Up @@ -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;
}-*/;
Expand Down
Loading
Loading