Use AudioWorklet where available (#82)

Use AudioWorklet where available
This commit is contained in:
Will Scullin 2021-06-25 15:38:35 -07:00 committed by GitHub
parent 8087294456
commit b4c13d7620
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 161 additions and 76 deletions

View File

@ -82,6 +82,8 @@ let io: Apple2IO;
let _currentDrive: DriveNumber = 1; let _currentDrive: DriveNumber = 1;
let _e: boolean; let _e: boolean;
let ready: Promise<[void, void]>;
export const driveLights = new DriveLights(); export const driveLights = new DriveLights();
export function dumpAppleSoftProgram() { export function dumpAppleSoftProgram() {
@ -213,7 +215,7 @@ function loadingStop() {
MicroModal.close('loading-modal'); MicroModal.close('loading-modal');
if (!paused) { if (!paused) {
_apple2.ready.then(() => { ready.then(() => {
_apple2.run(); _apple2.run();
}).catch(console.error); }).catch(console.error);
} }
@ -762,7 +764,7 @@ export function updateUI() {
export function pauseRun() { export function pauseRun() {
const label = document.querySelector<HTMLElement>('#pause-run i')!; const label = document.querySelector<HTMLElement>('#pause-run i')!;
if (paused) { if (paused) {
_apple2.ready.then(() => { ready.then(() => {
_apple2.run(); _apple2.run();
}).catch(console.error); }).catch(console.error);
label.classList.remove('fa-play'); label.classList.remove('fa-play');
@ -831,6 +833,8 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer:
optionsModal.addOptions(audio); optionsModal.addOptions(audio);
initSoundToggle(); initSoundToggle();
ready = Promise.all([audio.ready, apple2.ready]);
MicroModal.init(); MicroModal.init();
keyboard = new KeyBoard(cpu, io, e); keyboard = new KeyBoard(cpu, io, e);
@ -859,7 +863,7 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer:
event.preventDefault(); event.preventDefault();
}); });
window.addEventListener('copy', (event) => { window.addEventListener('copy', (event: Event) => {
event.clipboardData!.setData('text/plain', vm.getText()); event.clipboardData!.setData('text/plain', vm.getText());
event.preventDefault(); event.preventDefault();
}); });
@ -879,7 +883,7 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer:
_apple2.stop(); _apple2.stop();
processHash(hash); processHash(hash);
} else { } else {
_apple2.ready.then(() => { ready.then(() => {
_apple2.run(); _apple2.run();
}).catch(console.error); }).catch(console.error);
} }

View File

@ -17,6 +17,7 @@ import { debug } from '../util';
* Audio Handling * Audio Handling
*/ */
const QUANTUM_SIZE = 128;
const SAMPLE_SIZE = 1024; const SAMPLE_SIZE = 1024;
const SAMPLE_RATE = 44000; const SAMPLE_RATE = 44000;
@ -36,45 +37,66 @@ export class Audio implements OptionHandler {
private audioContext; private audioContext;
private audioNode; private audioNode;
private workletNode: AudioWorkletNode;
private started = false; private started = false;
ready: Promise<void>;
constructor(io: Apple2IO) { constructor(io: Apple2IO) {
this.audioContext = new AudioContext({ this.audioContext = new AudioContext({
sampleRate: SAMPLE_RATE sampleRate: SAMPLE_RATE
}); });
// TODO(flan): MDN says that createScriptProcessor is deprecated and if (window.AudioWorklet) {
// replaced by AudioWorklet. FF and Chrome support AudioWorklet, but this.ready = this.audioContext.audioWorklet.addModule('./dist/audio_worker.bundle.js');
// Safari does not (yet). this.ready
this.audioNode = this.audioContext.createScriptProcessor(SAMPLE_SIZE, 1, 1); .then(() => {
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio_worker');
this.audioNode.onaudioprocess = (event) => { io.sampleRate(this.audioContext.sampleRate, QUANTUM_SIZE);
const data = event.outputBuffer.getChannelData(0); io.addSampleListener((sample) => {
const sample = this.samples.shift(); if (this.sound && this.audioContext.state === 'running') {
let idx = 0; this.workletNode.port.postMessage(sample);
let len = data.length; }
});
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);
if (sample) { this.audioNode.onaudioprocess = (event) => {
len = Math.min(sample.length, len); const data = event.outputBuffer.getChannelData(0);
for (; idx < len; idx++) { const sample = this.samples.shift();
data[idx] = sample[idx]; 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++) { for (; idx < data.length; idx++) {
data[idx] = 0.0; data[idx] = 0.0;
}
};
this.audioNode.connect(this.audioContext.destination);
io.sampleRate(this.audioContext.sampleRate, SAMPLE_SIZE);
io.addSampleListener((sample: number[]) => {
if (this.sound) {
if (this.samples.length < 5) {
this.samples.push(sample);
} }
} };
});
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); window.addEventListener('keydown', this.autoStart);
if (window.ontouchstart !== undefined) { if (window.ontouchstart !== undefined) {
@ -85,7 +107,6 @@ export class Audio implements OptionHandler {
debug('Sound initialized'); debug('Sound initialized');
} }
autoStart = () => { autoStart = () => {
if (this.audioContext && !this.started) { if (this.audioContext && !this.started) {
this.samples = []; this.samples = [];

58
js/ui/audio_worker.ts Normal file
View File

@ -0,0 +1,58 @@
/* Copyright 2021 Will Scullin <scullin@scullinsteel.com>
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation. No representations are made about the suitability of this
* software for any purpose. It is provided "as is" without express or
* implied warranty.
*/
declare global {
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Map<string, Float32Array>): void;
}
const AudioWorkletProcessor: {
prototype: AudioWorkletProcessor;
new(options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
function registerProcessor(name: string, ctor :{ new(): AudioWorkletProcessor; }): void
}
export class AppleAudioProcessor extends AudioWorkletProcessor {
private samples: Float32Array[] = []
constructor() {
super();
console.info('AppleAudioProcessor constructor');
this.port.onmessage = (ev: MessageEvent) => {
this.samples.push(ev.data);
if (this.samples.length > 256) {
this.samples.shift();
}
};
}
static get parameterDescriptors() {
return [];
}
process(_inputList: Float32Array[][], outputList: Float32Array[][], _parameters: Map<string, Float32Array>) {
const sample = this.samples.shift();
const output = outputList[0];
if (sample) {
for (let idx = 0; idx < sample.length; idx++) {
output[0][idx] = sample[idx];
}
}
// Keep alive indefinitely.
return true;
}
}
registerProcessor('audio_worker', AppleAudioProcessor);

View File

@ -1,53 +1,10 @@
const path = require('path'); const path = require('path');
module.exports = const baseConfig = {
{
devtool: 'source-map', devtool: 'source-map',
mode: 'development', mode: 'development',
entry: {
main2: path.resolve('js/entry2.js'),
main2e: path.resolve('js/entry2e.js')
},
output: {
path: path.resolve('dist/'),
filename: '[name].bundle.js',
chunkFilename: '[name].bundle.js',
library: {
name: 'Apple2',
type: 'umd',
export: 'Apple2',
},
},
devServer: {
compress: true,
static: {
watch: {
ignored: /(node_modules|test|\.git)/
},
directory: __dirname,
},
dev: {
publicPath: '/dist/',
},
},
module: { module: {
rules: [ rules: [
{
test: /\.2mg$/i,
use: [
{
loader: 'file-loader',
},
],
},
{
test: /\.rom$/i,
use: [
{
loader: 'raw-loader',
},
],
},
{ {
test: /\.ts$/i, test: /\.ts$/i,
use: [ use: [
@ -63,3 +20,48 @@ module.exports =
extensions: ['.ts', '.js'], extensions: ['.ts', '.js'],
}, },
}; };
module.exports = [
{
...baseConfig,
entry: {
main2: path.resolve('js/entry2.js'),
main2e: path.resolve('js/entry2e.js')
},
output: {
path: path.resolve('dist/'),
filename: '[name].bundle.js',
chunkFilename: '[name].bundle.js',
library: {
name: 'Apple2',
type: 'umd',
export: 'Apple2',
},
},
devServer: {
compress: true,
static: {
watch: {
ignored: /(node_modules|test|\.git)/
},
directory: __dirname,
},
dev: {
publicPath: '/dist/',
},
},
},
{
...baseConfig,
target: false,
entry: {
audio_worker: path.resolve('js/ui/audio_worker.ts')
},
output: {
publicPath: '/dist/',
path: path.resolve('dist/'),
filename: '[name].bundle.js',
globalObject: 'globalThis',
},
},
];