From b4c13d76201d8005eaeff6df9a74dbae7a9ad35a Mon Sep 17 00:00:00 2001 From: Will Scullin Date: Fri, 25 Jun 2021 15:38:35 -0700 Subject: [PATCH] Use AudioWorklet where available (#82) Use AudioWorklet where available --- js/ui/apple2.ts | 12 ++++-- js/ui/audio.ts | 77 ++++++++++++++++++++++-------------- js/ui/audio_worker.ts | 58 ++++++++++++++++++++++++++++ webpack.config.js | 90 ++++++++++++++++++++++--------------------- 4 files changed, 161 insertions(+), 76 deletions(-) create mode 100644 js/ui/audio_worker.ts diff --git a/js/ui/apple2.ts b/js/ui/apple2.ts index 7714ef1..3e707e6 100644 --- a/js/ui/apple2.ts +++ b/js/ui/apple2.ts @@ -82,6 +82,8 @@ let io: Apple2IO; let _currentDrive: DriveNumber = 1; let _e: boolean; +let ready: Promise<[void, void]>; + export const driveLights = new DriveLights(); export function dumpAppleSoftProgram() { @@ -213,7 +215,7 @@ function loadingStop() { MicroModal.close('loading-modal'); if (!paused) { - _apple2.ready.then(() => { + ready.then(() => { _apple2.run(); }).catch(console.error); } @@ -762,7 +764,7 @@ export function updateUI() { export function pauseRun() { const label = document.querySelector('#pause-run i')!; if (paused) { - _apple2.ready.then(() => { + ready.then(() => { _apple2.run(); }).catch(console.error); label.classList.remove('fa-play'); @@ -831,6 +833,8 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer: optionsModal.addOptions(audio); initSoundToggle(); + ready = Promise.all([audio.ready, apple2.ready]); + MicroModal.init(); keyboard = new KeyBoard(cpu, io, e); @@ -859,7 +863,7 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer: event.preventDefault(); }); - window.addEventListener('copy', (event) => { + window.addEventListener('copy', (event: Event) => { event.clipboardData!.setData('text/plain', vm.getText()); event.preventDefault(); }); @@ -879,7 +883,7 @@ function onLoaded(apple2: Apple2, disk2: DiskII, smartPort: SmartPort, printer: _apple2.stop(); processHash(hash); } else { - _apple2.ready.then(() => { + ready.then(() => { _apple2.run(); }).catch(console.error); } diff --git a/js/ui/audio.ts b/js/ui/audio.ts index f7e2645..1b8d684 100644 --- a/js/ui/audio.ts +++ b/js/ui/audio.ts @@ -17,6 +17,7 @@ import { debug } from '../util'; * Audio Handling */ +const QUANTUM_SIZE = 128; const SAMPLE_SIZE = 1024; const SAMPLE_RATE = 44000; @@ -36,45 +37,66 @@ export class Audio implements OptionHandler { private audioContext; private audioNode; + private workletNode: AudioWorkletNode; private started = false; + ready: Promise; + constructor(io: Apple2IO) { this.audioContext = new AudioContext({ sampleRate: SAMPLE_RATE }); - // 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 (window.AudioWorklet) { + this.ready = this.audioContext.audioWorklet.addModule('./dist/audio_worker.bundle.js'); + this.ready + .then(() => { + this.workletNode = new AudioWorkletNode(this.audioContext, 'audio_worker'); - this.audioNode.onaudioprocess = (event) => { - const data = event.outputBuffer.getChannelData(0); - const sample = this.samples.shift(); - let idx = 0; - let len = data.length; + 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); - if (sample) { - len = Math.min(sample.length, len); - for (; idx < len; idx++) { - data[idx] = sample[idx]; + 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: number[]) => { - if (this.sound) { - if (this.samples.length < 5) { - this.samples.push(sample); + 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) { @@ -85,7 +107,6 @@ export class Audio implements OptionHandler { debug('Sound initialized'); } - autoStart = () => { if (this.audioContext && !this.started) { this.samples = []; diff --git a/js/ui/audio_worker.ts b/js/ui/audio_worker.ts new file mode 100644 index 0000000..ff27599 --- /dev/null +++ b/js/ui/audio_worker.ts @@ -0,0 +1,58 @@ +/* Copyright 2021 Will Scullin + * + * 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): 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) { + 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); diff --git a/webpack.config.js b/webpack.config.js index 6b415c1..67754e8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,53 +1,10 @@ const path = require('path'); -module.exports = -{ +const baseConfig = { devtool: 'source-map', 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: { rules: [ - { - test: /\.2mg$/i, - use: [ - { - loader: 'file-loader', - }, - ], - }, - { - test: /\.rom$/i, - use: [ - { - loader: 'raw-loader', - }, - ], - }, { test: /\.ts$/i, use: [ @@ -63,3 +20,48 @@ module.exports = 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', + }, + }, +];