2022-05-10 13:52:06 +00:00
|
|
|
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
2021-03-28 23:39:18 +00:00
|
|
|
import Apple2IO from '../apple2io';
|
|
|
|
import { debug } from '../util';
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Audio Handling
|
|
|
|
*/
|
|
|
|
|
2021-06-25 22:38:35 +00:00
|
|
|
const QUANTUM_SIZE = 128;
|
2021-03-28 23:39:18 +00:00
|
|
|
const SAMPLE_SIZE = 1024;
|
|
|
|
const SAMPLE_RATE = 44000;
|
|
|
|
|
2021-04-21 00:42:32 +00:00
|
|
|
export const SOUND_ENABLED_OPTION = 'enable_sound';
|
2021-03-28 23:48:46 +00:00
|
|
|
|
2021-04-21 00:42:32 +00:00
|
|
|
declare global {
|
|
|
|
interface Window {
|
|
|
|
webkitAudioContext: AudioContext;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
|
|
|
|
|
|
export class Audio implements OptionHandler {
|
2021-03-28 23:39:18 +00:00
|
|
|
private sound = true;
|
|
|
|
private samples: number[][] = [];
|
|
|
|
|
|
|
|
private audioContext;
|
|
|
|
private audioNode;
|
2021-06-25 22:38:35 +00:00
|
|
|
private workletNode: AudioWorkletNode;
|
2021-03-28 23:39:18 +00:00
|
|
|
private started = false;
|
|
|
|
|
2021-06-25 22:38:35 +00:00
|
|
|
ready: Promise<void>;
|
|
|
|
|
2021-03-28 23:39:18 +00:00
|
|
|
constructor(io: Apple2IO) {
|
|
|
|
this.audioContext = new AudioContext({
|
|
|
|
sampleRate: SAMPLE_RATE
|
|
|
|
});
|
|
|
|
|
2021-06-25 22:38:35 +00:00
|
|
|
if (window.AudioWorklet) {
|
|
|
|
this.ready = this.audioContext.audioWorklet.addModule('./dist/audio_worker.bundle.js');
|
|
|
|
this.ready
|
|
|
|
.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];
|
|
|
|
}
|
2021-03-28 23:39:18 +00:00
|
|
|
}
|
2021-06-25 22:38:35 +00:00
|
|
|
|
|
|
|
for (; idx < data.length; idx++) {
|
|
|
|
data[idx] = 0.0;
|
2021-03-28 23:39:18 +00:00
|
|
|
}
|
2021-06-25 22:38:35 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
2021-04-21 00:42:32 +00:00
|
|
|
|
|
|
|
window.addEventListener('keydown', this.autoStart);
|
|
|
|
if (window.ontouchstart !== undefined) {
|
|
|
|
window.addEventListener('touchstart', this.autoStart);
|
|
|
|
}
|
|
|
|
window.addEventListener('mousedown', this.autoStart);
|
|
|
|
|
2021-03-28 23:39:18 +00:00
|
|
|
debug('Sound initialized');
|
|
|
|
}
|
|
|
|
|
2021-04-25 19:09:30 +00:00
|
|
|
autoStart = () => {
|
2021-03-28 23:39:18 +00:00
|
|
|
if (this.audioContext && !this.started) {
|
|
|
|
this.samples = [];
|
2021-06-14 00:06:16 +00:00
|
|
|
this.audioContext.resume().then(() => {
|
|
|
|
this.started = true;
|
|
|
|
}).catch((error) => {
|
|
|
|
console.warn('audio not started', error);
|
|
|
|
});
|
2021-03-28 23:39:18 +00:00
|
|
|
}
|
2021-11-29 00:20:25 +00:00
|
|
|
};
|
2021-03-28 23:39:18 +00:00
|
|
|
|
2021-04-25 19:09:30 +00:00
|
|
|
start = () => {
|
2021-03-28 23:39:18 +00:00
|
|
|
if (this.audioContext) {
|
|
|
|
this.samples = [];
|
2021-06-14 00:06:16 +00:00
|
|
|
this.audioContext.resume().catch((error) => {
|
|
|
|
console.warn('audio not resumed', error);
|
|
|
|
});
|
2021-03-28 23:39:18 +00:00
|
|
|
}
|
2021-11-29 00:20:25 +00:00
|
|
|
};
|
2021-03-28 23:39:18 +00:00
|
|
|
|
2021-04-25 19:09:30 +00:00
|
|
|
isEnabled = () => {
|
2021-04-21 00:42:32 +00:00
|
|
|
return this.sound;
|
2021-11-29 00:20:25 +00:00
|
|
|
};
|
2021-04-21 00:42:32 +00:00
|
|
|
|
|
|
|
getOptions() {
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
name: 'Audio',
|
|
|
|
options: [
|
|
|
|
{
|
|
|
|
name: SOUND_ENABLED_OPTION,
|
|
|
|
label: 'Enabled',
|
|
|
|
type: BOOLEAN_OPTION,
|
|
|
|
defaultVal: true,
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2021-04-25 19:09:30 +00:00
|
|
|
setOption = (name: string, value: boolean) => {
|
2021-04-21 00:42:32 +00:00
|
|
|
switch (name) {
|
|
|
|
case SOUND_ENABLED_OPTION:
|
|
|
|
this.sound = value;
|
|
|
|
}
|
2021-11-29 00:20:25 +00:00
|
|
|
};
|
2021-03-28 23:39:18 +00:00
|
|
|
}
|