apple2js/js/ui/audio.ts
Ian Flanigan e1e8eec218
Make errors in the disk and audio workers not block the emulator (#150)
Before, if there was an error in the audio worker or in the disk
worker, the emulator would not start. This could happen, for example,
if the page is loaded directly from disk in Chrome instead of through
a server.

Now, even if there is an error, the emulator will start.
2022-08-21 12:41:19 -07:00

146 lines
4.3 KiB
TypeScript

import { BOOLEAN_OPTION, OptionHandler } from '../options';
import Apple2IO from '../apple2io';
import { debug } from '../util';
/*
* Audio Handling
*/
const QUANTUM_SIZE = 128;
const SAMPLE_SIZE = 1024;
const SAMPLE_RATE = 44000;
export const SOUND_ENABLED_OPTION = 'enable_sound';
declare global {
interface Window {
webkitAudioContext: AudioContext;
}
}
const AudioContext = window.AudioContext || window.webkitAudioContext;
export class Audio implements OptionHandler {
private sound = true;
private samples: number[][] = [];
private audioContext;
private audioNode;
private workletNode: AudioWorkletNode;
private started = false;
ready: Promise<void>;
constructor(io: Apple2IO) {
this.audioContext = new AudioContext({
sampleRate: SAMPLE_RATE
});
if (window.AudioWorklet) {
const workletReady = this.audioContext.audioWorklet.addModule('./dist/audio_worker.bundle.js');
this.ready = workletReady
.then(() => {
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio_worker');
io.sampleRate(this.audioContext.sampleRate, QUANTUM_SIZE);
io.addSampleListener((sample) => {
if (this.sound && this.audioContext.state === 'running') {
this.workletNode.port.postMessage(sample);
}
});
this.workletNode.connect(this.audioContext.destination);
})
.catch(console.error);
} else {
// TODO(flan): MDN says that createScriptProcessor is deprecated and
// replaced by AudioWorklet. FF and Chrome support AudioWorklet, but
// Safari does not (yet).
this.audioNode = this.audioContext.createScriptProcessor(SAMPLE_SIZE, 1, 1);
this.audioNode.onaudioprocess = (event) => {
const data = event.outputBuffer.getChannelData(0);
const sample = this.samples.shift();
let idx = 0;
let len = data.length;
if (sample) {
len = Math.min(sample.length, len);
for (; idx < len; idx++) {
data[idx] = sample[idx];
}
}
for (; idx < data.length; idx++) {
data[idx] = 0.0;
}
};
this.audioNode.connect(this.audioContext.destination);
io.sampleRate(this.audioContext.sampleRate, SAMPLE_SIZE);
io.addSampleListener((sample) => {
if (this.sound && this.audioContext.state === 'running') {
if (this.samples.length < 5) {
this.samples.push(sample);
}
}
});
this.ready = Promise.resolve();
}
window.addEventListener('keydown', this.autoStart);
if (window.ontouchstart !== undefined) {
window.addEventListener('touchstart', this.autoStart);
}
window.addEventListener('mousedown', this.autoStart);
debug('Sound initialized');
}
autoStart = () => {
if (this.audioContext && !this.started) {
this.samples = [];
this.audioContext.resume().then(() => {
this.started = true;
}).catch((error) => {
console.warn('audio not started', error);
});
}
};
start = () => {
if (this.audioContext) {
this.samples = [];
this.audioContext.resume().catch((error) => {
console.warn('audio not resumed', error);
});
}
};
isEnabled = () => {
return this.sound;
};
getOptions() {
return [
{
name: 'Audio',
options: [
{
name: SOUND_ENABLED_OPTION,
label: 'Enabled',
type: BOOLEAN_OPTION,
defaultVal: true,
}
]
}
];
}
setOption = (name: string, value: boolean) => {
switch (name) {
case SOUND_ENABLED_OPTION:
this.sound = value;
}
};
}