Skip to content

Commit

Permalink
Add audio to web version
Browse files Browse the repository at this point in the history
  • Loading branch information
aelred committed Sep 27, 2024
1 parent a28c2a5 commit c3f00dd
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 4 deletions.
31 changes: 27 additions & 4 deletions src/runtime/web.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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;

Expand Down Expand Up @@ -191,7 +191,7 @@ impl Runtime for Web {
}

struct NesContext {
nes: NES<BufferDisplay, ()>,
nes: NES<BufferDisplay, WebSpeaker>,
rom_hash: u64,
}

Expand Down Expand Up @@ -263,12 +263,13 @@ fn set_rom(rom: &[u8]) -> Result<NesContext, Box<dyn Error>> {
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 })
Expand Down Expand Up @@ -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);
}
47 changes: 47 additions & 0 deletions web/audio-processor.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
31 changes: 31 additions & 0 deletions web/audio.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions web/webpack.config.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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'),
Expand All @@ -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'
}
}
}

0 comments on commit c3f00dd

Please sign in to comment.