diff --git a/src/runtime/web.rs b/src/runtime/web.rs index 7a5fcd6..21201e2 100644 --- a/src/runtime/web.rs +++ b/src/runtime/web.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] // Might be disabled by features -use crate::{runtime::Runtime, BufferDisplay, Buttons, INes, HEIGHT, NES, WIDTH}; +use crate::{runtime::Runtime, BufferDisplay, Buttons, INes, NESSpeaker, HEIGHT, NES, WIDTH}; use anyhow::{anyhow, Context}; use base64::{prelude::BASE64_STANDARD, Engine}; use std::{ @@ -17,7 +17,7 @@ use web_sys::{ }; use zip::ZipArchive; -use super::FRAME_DURATION; +use super::{FRAME_DURATION, NES_AUDIO_FREQ, TARGET_AUDIO_FREQ}; pub struct Web; @@ -191,7 +191,7 @@ impl Runtime for Web { } struct NesContext { - nes: NES, + nes: NES, rom_hash: u64, } @@ -263,12 +263,13 @@ fn set_rom(rom: &[u8]) -> Result> { let ines = INes::read(rom)?; let cartridge = ines.into_cartridge(); let display = BufferDisplay::default(); + let speaker = WebSpeaker::default(); let mut rom_hasher = DefaultHasher::new(); rom.hash(&mut rom_hasher); let rom_hash = rom_hasher.finish(); - let mut nes = NES::new(cartridge, display, ()); + let mut nes = NES::new(cartridge, display, speaker); load_state(rom_hash, &mut nes)?; Ok(NesContext { nes, rom_hash }) @@ -332,3 +333,25 @@ fn state_key(rom_hash: u64) -> String { let hash_base64 = BASE64_STANDARD.encode(rom_hash.to_le_bytes()); format!("nes-state-{}", hash_base64) } + +#[derive(Default)] +struct WebSpeaker { + next_sample: f64, +} + +impl NESSpeaker for WebSpeaker { + fn emit(&mut self, value: u8) { + // Naive downsampling + if self.next_sample <= 0.0 { + push_audio_buffer(value); + self.next_sample += NES_AUDIO_FREQ / TARGET_AUDIO_FREQ as f64; + } + self.next_sample -= 1.0; + } +} + +#[wasm_bindgen(module = "/web/audio.js")] +extern "C" { + #[wasm_bindgen(js_name = pushAudioBuffer)] + fn push_audio_buffer(byte: u8); +} diff --git a/web/audio-processor.js b/web/audio-processor.js new file mode 100644 index 0000000..da8674d --- /dev/null +++ b/web/audio-processor.js @@ -0,0 +1,47 @@ +class AudioProcessor extends AudioWorkletProcessor { + constructor(nodeOptions) { + super(); + this.buffer = new CircularBuffer(2048); + this.port.onmessage = event => { + for (const value of event.data) { + this.buffer.write(value); + } + } + } + + process(inputs, outputs, parameters) { + outputs[0][0].set(this.buffer.readSlice(outputs[0][0].length)); + return true; + } +} + +registerProcessor("audio-processor", AudioProcessor); + +class CircularBuffer { + constructor(size) { + this.buffer = new Float32Array(size); + this.writeCursor = Math.floor(size / 2); + this.readCursor = 0; + } + + write(value) { + this.buffer[this.writeCursor] = value; + this.writeCursor = (this.writeCursor + 1) % this.buffer.length; + } + + readSlice(length) { + let end = this.readCursor + length; + let result = null; + if (end < this.buffer.length) { + result = this.buffer.slice(this.readCursor, end); + } else { + end = end - this.buffer.length; + result = new Float32Array([ + ...this.buffer.slice(this.readCursor), + ...this.buffer.slice(0, end) + ]); + } + this.readCursor = end; + return result; + } +} \ No newline at end of file diff --git a/web/audio.js b/web/audio.js new file mode 100644 index 0000000..554b183 --- /dev/null +++ b/web/audio.js @@ -0,0 +1,31 @@ +const BUFFER_SIZE = 128; + +let audioProcessorNode = null; +const buffer = new Float32Array(BUFFER_SIZE); +let bufferIndex = 0; + +async function startAudio() { + if (audioProcessorNode) return; + const context = new AudioContext({ sampleRate: 44100 }); + await context.audioWorklet.addModule('./audio-processor.js'); + audioProcessorNode = new AudioWorkletNode(context, 'audio-processor'); + audioProcessorNode.connect(context.destination); +} + +addEventListener("click", startAudio); +addEventListener("keydown", startAudio); +addEventListener("visibilitychange", () => { + if (audioProcessorNode && document.visibilityState !== 'visible') { + audioProcessorNode.port.postMessage(new Float32Array(BUFFER_SIZE)) + } +}); + +export function pushAudioBuffer(byte) { + if (audioProcessorNode == null || document.visibilityState !== 'visible') return; + buffer[bufferIndex] = (byte / 255) - 0.5; + bufferIndex += 1; + if (bufferIndex === BUFFER_SIZE) { + bufferIndex = 0; + audioProcessorNode.port.postMessage(buffer); + } +} diff --git a/web/package.json b/web/package.json index b27ebdc..a0ff849 100644 --- a/web/package.json +++ b/web/package.json @@ -2,6 +2,7 @@ "type": "module", "devDependencies": { "@wasm-tool/wasm-pack-plugin": "1.5.0", + "copy-webpack-plugin": "^12.0.2", "html-webpack-plugin": "^5.6.0", "webpack": "^5.93.0", "webpack-cli": "^5.1.4", diff --git a/web/webpack.config.js b/web/webpack.config.js index 005db7b..1d2943d 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -1,4 +1,5 @@ import WasmPackPlugin from '@wasm-tool/wasm-pack-plugin'; +import CopyWebpackPlugin from 'copy-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import { resolve } from 'path'; const __dirname = import.meta.dirname; @@ -13,6 +14,11 @@ export default { new HtmlWebpackPlugin({ template: 'index.html' }), + new CopyWebpackPlugin({ + patterns: [ + { from: 'audio-processor.js', to: 'audio-processor.js' }, + ] + }), new WasmPackPlugin({ crateDirectory: resolve(__dirname, '..'), outDir: resolve(__dirname, 'pkg'), @@ -23,5 +29,12 @@ export default { mode: 'development', experiments: { asyncWebAssembly: true + }, + devServer: { + headers: { + // Required to run locally without CORS problems + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp' + } } } \ No newline at end of file