Skip to content

Commit

Permalink
WASM: Sound support
Browse files Browse the repository at this point in the history
  • Loading branch information
maxpoletaev committed Jan 11, 2025
1 parent 7f045f9 commit 393317d
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 66 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ network multiplayer feature, so it’s not completely useless.

<img src="screenshots.png" alt="Screenshots">

## WebAssembly Build

WebAssembly version for modern browsers is available at https://maxpoletaev.github.io/dendy/.
It runs smoothly in modern browsers, though it does not support netplay in its
current form. (there was an [experimental][wasm-netplay] implementation of
netplay over WebRTC, but it was too slow and unreliable to be usable).

[wasm-netplay]: https://drive.google.com/file/d/1r3ZY20L168u3djRMWA_KLMrY0eIr1ify/view?usp=sharing

## Download

You can download the latest pre-built binaries for Windows, macOS, and Linux
Expand Down
11 changes: 10 additions & 1 deletion apu/apu.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func (a *APU) Output() float32 {
out = f.do(out)
}

return out * 5.0
return clamp(out, -1, 1)
}

func (a *APU) Tick() {
Expand Down Expand Up @@ -230,3 +230,12 @@ func (a *APU) LoadState(r *binario.Reader) error {
r.ReadBoolTo(&a.frameIRQ),
)
}

func clamp(v float32, min, max float32) float32 {
if v < min {
return min
} else if v > max {
return max
}
return v
}
74 changes: 41 additions & 33 deletions cmd/dendy-wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@ import (
_ "embed"
"fmt"
"log"
"runtime"
"syscall/js"
"time"
"unsafe"

"github.com/maxpoletaev/dendy/consts"
"github.com/maxpoletaev/dendy/ines"
"github.com/maxpoletaev/dendy/input"
"github.com/maxpoletaev/dendy/system"
)

const (
debugFrameTime = false
audioBufferSize = 512
)

//go:embed nestest.nes
var bootROM []byte
var nestestROM []byte

func create(joy *input.Joystick, romData []byte) (*system.System, error) {
rom, err := ines.NewFromBuffer(romData)
Expand All @@ -40,51 +39,60 @@ func create(joy *input.Joystick, romData []byte) (*system.System, error) {
func main() {
log.SetFlags(0) // disable timestamps
joystick := input.NewJoystick()
audioBuf := make([]float32, audioBufferSize)

nes, err := create(joystick, bootROM)
nes, err := create(joystick, nestestROM)
if err != nil {
log.Fatalf("[ERROR] failed to initialize: %v", err)
}

global := js.Global()
jsapi := global.Get("go")
jsapi.Set("AudioBufferSize", audioBufferSize)
jsapi.Set("AudioSampleRate", consts.AudioSamplesPerSecond)

var (
mem runtime.MemStats
frameTimeSum time.Duration
frameCount uint
ticksCount int
sampleCount int
)

js.Global().Set("runFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
jsapi.Set("RunFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
buttons := args[0].Int()
start := time.Now()
var framePtr uintptr

for {
nes.Tick()
if nes.FrameReady() {
frame := nes.Frame()
joystick.SetButtons(uint8(buttons))
framePtr = uintptr(unsafe.Pointer(&frame[0]))
break
frameReady := false

for sampleCount < len(audioBuf) {
for ticksCount < consts.TicksPerAudioSample {
nes.Tick()
ticksCount++

if nes.FrameReady() {
joystick.SetButtons(uint8(buttons))
frameReady = true
}
}
}

if debugFrameTime {
frameTime := time.Since(start)
frameTimeSum += frameTime
frameCount++

if frameCount%120 == 0 {
runtime.ReadMemStats(&mem)
avgFrameTime := frameTimeSum / time.Duration(frameCount)
log.Printf("[INFO] frame time: %v, memory: %d", avgFrameTime, mem.HeapAlloc)
frameTimeSum = 0
frameCount = 0
audioBuf[sampleCount] = nes.AudioSample()
sampleCount++
ticksCount = 0

if frameReady {
return true
}
}

return framePtr
sampleCount = 0
return false
}))

jsapi.Set("GetFrameBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
return uintptr(unsafe.Pointer(&nes.Frame()[0]))
}))

jsapi.Set("GetAudioBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
return uintptr(unsafe.Pointer(&audioBuf[0]))
}))

js.Global().Set("uploadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
jsapi.Set("LoadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
data := js.Global().Get("Uint8Array").New(args[0])
romData := make([]byte, data.Length())
js.CopyBytesToGo(romData, data)
Expand Down
40 changes: 40 additions & 0 deletions web/audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.position = 0;
this.buffers = [];
this.current = null;

this.port.onmessage = (e) => {
this.buffers.push(e.data);
if (this.buffers.length > 3) {
this.buffers.shift();
}
};
}

process(inputs, outputs, parameters) {
let output = outputs[0];
let channel = output[0];

if (!this.current || this.position >= this.current.length) {
this.current = this.buffers.shift();
this.position = 0;

if (!this.current) {
for (let i = 0; i < channel.length; i++) {
channel[i] = 0;
}
return true;
}
}

for (let i = 0; i < channel.length; i++) {
channel[i] = this.current[this.position++];
}

return true;
}
}

registerProcessor("audio-processor", AudioProcessor);
4 changes: 3 additions & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
<div class="container">
<div class="game-section">
<div class="game-window">
<div class="unmute" id="unmute-button" style="display:none;">🔇 Click anywhere to unmute</div>
<canvas id="canvas"></canvas>
</div>
</div>

<div class="info-section">
<div class="rom-upload">
Select ROM (.nes): <input type="file" id="file-input" accept=".nes">
Select ROM (.nes):
<input type="file" id="file-input" accept=".nes">
</div>

<div class="controls-grid">
Expand Down
128 changes: 99 additions & 29 deletions web/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,67 @@ const documentReady = new Promise((resolve) => {
}
});

const go = new Go();
window.go = new Go();
const wasmReady = WebAssembly.instantiateStreaming(fetch("dendy.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});

Promise.all([wasmReady, documentReady]).then(() => {
const width = 256;
const height = 240;
const targetFPS = 60;
const scale = 2;
Promise.all([wasmReady, documentReady]).then(async () => {
const WIDTH = 256;
const HEIGHT = 240;
const TARGET_FPS = 60;
const SCALE = 2;

const audioBufferSize = go.AudioBufferSize;
const audioSampleRate = go.AudioSampleRate;

// ========================
// Canvas setup
// ========================

let canvas = document.getElementById("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.width = width*scale + "px";
canvas.style.height = height*scale + "px";
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.width = WIDTH * SCALE + "px";
canvas.style.height = HEIGHT * SCALE + "px";
canvas.style.imageRendering = "pixelated";

let ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
let buttonsPressed = 0;

// ========================
// Audio setup
// ========================

console.log(`[INFO] audio sample rate: ${audioSampleRate}, buffer size: ${audioBufferSize}`);
let audioCtx = new AudioContext({
sampleRate: audioSampleRate,
});

await audioCtx.audioWorklet.addModule("audio.js");
let audioNode = new AudioWorkletNode(audioCtx, "audio-processor");
audioNode.connect(audioCtx.destination);

// ========================
// Mute/unmute button
// ========================

let unmuteButton = document.getElementById("unmute-button");
if (audioCtx.state === "suspended") {
unmuteButton.style.display = "block";
}

document.addEventListener("click", function() {
if (audioCtx.state === "suspended") {
unmuteButton.style.display = "none";
audioCtx.resume();
}
}, {once: true});


// ========================
// Input handling
// ========================

const BUTTON_A = 1 << 0;
const BUTTON_B = 1 << 1;
Expand All @@ -48,6 +88,8 @@ Promise.all([wasmReady, documentReady]).then(() => {
"KeyK": BUTTON_A,
};

let buttonsPressed = 0;

document.addEventListener("keydown", (event) => {
if (keyMap[event.code]) {
event.preventDefault();
Expand All @@ -62,52 +104,80 @@ Promise.all([wasmReady, documentReady]).then(() => {
}
});

// ========================
// ROM loading
// ========================

let fileInput = document.getElementById("file-input");

fileInput.addEventListener("input", function() {
fileInput.addEventListener("input", function () {
this.files[0].arrayBuffer().then((buffer) => {
let rom = new Uint8Array(buffer);
let ok = uploadROM(rom);
let ok = go.LoadROM(rom);
if (!ok) {
alert("Invalid ROM file");
alert("Invalid or unsupported ROM file");
this.value = "";
}
});
this.blur();
this.blur(); // Avoid re-opening file dialog when pressing Enter
});

if (fileInput.files.length > 0) {
fileInput.files[0].arrayBuffer().then((buffer) => {
let rom = new Uint8Array(buffer);
let ok = uploadROM(rom);
let ok = go.LoadROM(rom);
if (!ok) {
fileInput.value = "";
}
});
}

// ========================
// Game loop
// ========================

function isInFocus() {
return document.hasFocus() && document.visibilityState === "visible";
}

function gameLoop() {
let nextFrame = () => {
let start = performance.now();
function getMemoryBuffer() {
return go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
}

function executeFrame() {
while (true) {
let frameReady = go.RunFrame(buttonsPressed);

if (isInFocus()) {
let framePtr = runFrame(buttonsPressed);
let memPtr = go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
let image = new ImageData(new Uint8ClampedArray(memPtr, framePtr, width * height * 4), width, height);
if (frameReady) {
let framePtr = go.GetFrameBufferPtr();
let image = new ImageData(new Uint8ClampedArray(getMemoryBuffer(), framePtr, WIDTH * HEIGHT * 4), WIDTH, HEIGHT);
ctx.putImageData(image, 0, 0);
return
}

let elapsed = performance.now() - start;
let nextTimeout = Math.max(0, (1000 / targetFPS) - elapsed);
setTimeout(nextFrame, nextTimeout);
};
let audioBufPtr = go.GetAudioBufferPtr();
let audioBuf = new Float32Array(getMemoryBuffer(), audioBufPtr, go.AudioBufferSize);
audioNode.port.postMessage(audioBuf.slice());
}
}

let lastFrameTime = performance.now();
const frameTime = 1000 / TARGET_FPS;

function loop() {
requestAnimationFrame(loop)

nextFrame();
const now = performance.now()
const elapsed = now - lastFrameTime
if (elapsed < frameTime) return

const excessTime = elapsed % frameTime
lastFrameTime = now - excessTime

if (isInFocus()) {
executeFrame();
}
}

gameLoop();
requestAnimationFrame(loop);
});
Loading

0 comments on commit 393317d

Please sign in to comment.