Skip to content

Commit 393317d

Browse files
committed
WASM: Sound support
1 parent 7f045f9 commit 393317d

File tree

7 files changed

+218
-66
lines changed

7 files changed

+218
-66
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ network multiplayer feature, so it’s not completely useless.
1515

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

18+
## WebAssembly Build
19+
20+
WebAssembly version for modern browsers is available at https://maxpoletaev.github.io/dendy/.
21+
It runs smoothly in modern browsers, though it does not support netplay in its
22+
current form. (there was an [experimental][wasm-netplay] implementation of
23+
netplay over WebRTC, but it was too slow and unreliable to be usable).
24+
25+
[wasm-netplay]: https://drive.google.com/file/d/1r3ZY20L168u3djRMWA_KLMrY0eIr1ify/view?usp=sharing
26+
1827
## Download
1928

2029
You can download the latest pre-built binaries for Windows, macOS, and Linux

apu/apu.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func (a *APU) Output() float32 {
134134
out = f.do(out)
135135
}
136136

137-
return out * 5.0
137+
return clamp(out, -1, 1)
138138
}
139139

140140
func (a *APU) Tick() {
@@ -230,3 +230,12 @@ func (a *APU) LoadState(r *binario.Reader) error {
230230
r.ReadBoolTo(&a.frameIRQ),
231231
)
232232
}
233+
234+
func clamp(v float32, min, max float32) float32 {
235+
if v < min {
236+
return min
237+
} else if v > max {
238+
return max
239+
}
240+
return v
241+
}

cmd/dendy-wasm/main.go

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,21 @@ import (
44
_ "embed"
55
"fmt"
66
"log"
7-
"runtime"
87
"syscall/js"
9-
"time"
108
"unsafe"
119

10+
"github.com/maxpoletaev/dendy/consts"
1211
"github.com/maxpoletaev/dendy/ines"
1312
"github.com/maxpoletaev/dendy/input"
1413
"github.com/maxpoletaev/dendy/system"
1514
)
1615

1716
const (
18-
debugFrameTime = false
17+
audioBufferSize = 512
1918
)
2019

2120
//go:embed nestest.nes
22-
var bootROM []byte
21+
var nestestROM []byte
2322

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

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

49+
global := js.Global()
50+
jsapi := global.Get("go")
51+
jsapi.Set("AudioBufferSize", audioBufferSize)
52+
jsapi.Set("AudioSampleRate", consts.AudioSamplesPerSecond)
53+
4954
var (
50-
mem runtime.MemStats
51-
frameTimeSum time.Duration
52-
frameCount uint
55+
ticksCount int
56+
sampleCount int
5357
)
5458

55-
js.Global().Set("runFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
59+
jsapi.Set("RunFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
5660
buttons := args[0].Int()
57-
start := time.Now()
58-
var framePtr uintptr
59-
60-
for {
61-
nes.Tick()
62-
if nes.FrameReady() {
63-
frame := nes.Frame()
64-
joystick.SetButtons(uint8(buttons))
65-
framePtr = uintptr(unsafe.Pointer(&frame[0]))
66-
break
61+
frameReady := false
62+
63+
for sampleCount < len(audioBuf) {
64+
for ticksCount < consts.TicksPerAudioSample {
65+
nes.Tick()
66+
ticksCount++
67+
68+
if nes.FrameReady() {
69+
joystick.SetButtons(uint8(buttons))
70+
frameReady = true
71+
}
6772
}
68-
}
6973

70-
if debugFrameTime {
71-
frameTime := time.Since(start)
72-
frameTimeSum += frameTime
73-
frameCount++
74-
75-
if frameCount%120 == 0 {
76-
runtime.ReadMemStats(&mem)
77-
avgFrameTime := frameTimeSum / time.Duration(frameCount)
78-
log.Printf("[INFO] frame time: %v, memory: %d", avgFrameTime, mem.HeapAlloc)
79-
frameTimeSum = 0
80-
frameCount = 0
74+
audioBuf[sampleCount] = nes.AudioSample()
75+
sampleCount++
76+
ticksCount = 0
77+
78+
if frameReady {
79+
return true
8180
}
8281
}
8382

84-
return framePtr
83+
sampleCount = 0
84+
return false
85+
}))
86+
87+
jsapi.Set("GetFrameBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
88+
return uintptr(unsafe.Pointer(&nes.Frame()[0]))
89+
}))
90+
91+
jsapi.Set("GetAudioBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
92+
return uintptr(unsafe.Pointer(&audioBuf[0]))
8593
}))
8694

87-
js.Global().Set("uploadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
95+
jsapi.Set("LoadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
8896
data := js.Global().Get("Uint8Array").New(args[0])
8997
romData := make([]byte, data.Length())
9098
js.CopyBytesToGo(romData, data)

web/audio.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
class AudioProcessor extends AudioWorkletProcessor {
2+
constructor() {
3+
super();
4+
this.position = 0;
5+
this.buffers = [];
6+
this.current = null;
7+
8+
this.port.onmessage = (e) => {
9+
this.buffers.push(e.data);
10+
if (this.buffers.length > 3) {
11+
this.buffers.shift();
12+
}
13+
};
14+
}
15+
16+
process(inputs, outputs, parameters) {
17+
let output = outputs[0];
18+
let channel = output[0];
19+
20+
if (!this.current || this.position >= this.current.length) {
21+
this.current = this.buffers.shift();
22+
this.position = 0;
23+
24+
if (!this.current) {
25+
for (let i = 0; i < channel.length; i++) {
26+
channel[i] = 0;
27+
}
28+
return true;
29+
}
30+
}
31+
32+
for (let i = 0; i < channel.length; i++) {
33+
channel[i] = this.current[this.position++];
34+
}
35+
36+
return true;
37+
}
38+
}
39+
40+
registerProcessor("audio-processor", AudioProcessor);

web/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
<div class="container">
1414
<div class="game-section">
1515
<div class="game-window">
16+
<div class="unmute" id="unmute-button" style="display:none;">🔇 Click anywhere to unmute</div>
1617
<canvas id="canvas"></canvas>
1718
</div>
1819
</div>
1920

2021
<div class="info-section">
2122
<div class="rom-upload">
22-
Select ROM (.nes): <input type="file" id="file-input" accept=".nes">
23+
Select ROM (.nes):
24+
<input type="file" id="file-input" accept=".nes">
2325
</div>
2426

2527
<div class="controls-grid">

web/main.js

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,67 @@ const documentReady = new Promise((resolve) => {
66
}
77
});
88

9-
const go = new Go();
9+
window.go = new Go();
1010
const wasmReady = WebAssembly.instantiateStreaming(fetch("dendy.wasm"), go.importObject).then((result) => {
1111
go.run(result.instance);
1212
});
1313

14-
Promise.all([wasmReady, documentReady]).then(() => {
15-
const width = 256;
16-
const height = 240;
17-
const targetFPS = 60;
18-
const scale = 2;
14+
Promise.all([wasmReady, documentReady]).then(async () => {
15+
const WIDTH = 256;
16+
const HEIGHT = 240;
17+
const TARGET_FPS = 60;
18+
const SCALE = 2;
19+
20+
const audioBufferSize = go.AudioBufferSize;
21+
const audioSampleRate = go.AudioSampleRate;
22+
23+
// ========================
24+
// Canvas setup
25+
// ========================
1926

2027
let canvas = document.getElementById("canvas");
21-
canvas.width = width;
22-
canvas.height = height;
23-
canvas.style.width = width*scale + "px";
24-
canvas.style.height = height*scale + "px";
28+
canvas.width = WIDTH;
29+
canvas.height = HEIGHT;
30+
canvas.style.width = WIDTH * SCALE + "px";
31+
canvas.style.height = HEIGHT * SCALE + "px";
2532
canvas.style.imageRendering = "pixelated";
2633

2734
let ctx = canvas.getContext("2d");
2835
ctx.imageSmoothingEnabled = false;
29-
let buttonsPressed = 0;
36+
37+
// ========================
38+
// Audio setup
39+
// ========================
40+
41+
console.log(`[INFO] audio sample rate: ${audioSampleRate}, buffer size: ${audioBufferSize}`);
42+
let audioCtx = new AudioContext({
43+
sampleRate: audioSampleRate,
44+
});
45+
46+
await audioCtx.audioWorklet.addModule("audio.js");
47+
let audioNode = new AudioWorkletNode(audioCtx, "audio-processor");
48+
audioNode.connect(audioCtx.destination);
49+
50+
// ========================
51+
// Mute/unmute button
52+
// ========================
53+
54+
let unmuteButton = document.getElementById("unmute-button");
55+
if (audioCtx.state === "suspended") {
56+
unmuteButton.style.display = "block";
57+
}
58+
59+
document.addEventListener("click", function() {
60+
if (audioCtx.state === "suspended") {
61+
unmuteButton.style.display = "none";
62+
audioCtx.resume();
63+
}
64+
}, {once: true});
65+
66+
67+
// ========================
68+
// Input handling
69+
// ========================
3070

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

91+
let buttonsPressed = 0;
92+
5193
document.addEventListener("keydown", (event) => {
5294
if (keyMap[event.code]) {
5395
event.preventDefault();
@@ -62,52 +104,80 @@ Promise.all([wasmReady, documentReady]).then(() => {
62104
}
63105
});
64106

107+
// ========================
108+
// ROM loading
109+
// ========================
110+
65111
let fileInput = document.getElementById("file-input");
66112

67-
fileInput.addEventListener("input", function() {
113+
fileInput.addEventListener("input", function () {
68114
this.files[0].arrayBuffer().then((buffer) => {
69115
let rom = new Uint8Array(buffer);
70-
let ok = uploadROM(rom);
116+
let ok = go.LoadROM(rom);
71117
if (!ok) {
72-
alert("Invalid ROM file");
118+
alert("Invalid or unsupported ROM file");
73119
this.value = "";
74120
}
75121
});
76-
this.blur();
122+
this.blur(); // Avoid re-opening file dialog when pressing Enter
77123
});
78124

79125
if (fileInput.files.length > 0) {
80126
fileInput.files[0].arrayBuffer().then((buffer) => {
81127
let rom = new Uint8Array(buffer);
82-
let ok = uploadROM(rom);
128+
let ok = go.LoadROM(rom);
83129
if (!ok) {
84130
fileInput.value = "";
85131
}
86132
});
87133
}
88134

135+
// ========================
136+
// Game loop
137+
// ========================
138+
89139
function isInFocus() {
90140
return document.hasFocus() && document.visibilityState === "visible";
91141
}
92142

93-
function gameLoop() {
94-
let nextFrame = () => {
95-
let start = performance.now();
143+
function getMemoryBuffer() {
144+
return go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
145+
}
146+
147+
function executeFrame() {
148+
while (true) {
149+
let frameReady = go.RunFrame(buttonsPressed);
96150

97-
if (isInFocus()) {
98-
let framePtr = runFrame(buttonsPressed);
99-
let memPtr = go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
100-
let image = new ImageData(new Uint8ClampedArray(memPtr, framePtr, width * height * 4), width, height);
151+
if (frameReady) {
152+
let framePtr = go.GetFrameBufferPtr();
153+
let image = new ImageData(new Uint8ClampedArray(getMemoryBuffer(), framePtr, WIDTH * HEIGHT * 4), WIDTH, HEIGHT);
101154
ctx.putImageData(image, 0, 0);
155+
return
102156
}
103157

104-
let elapsed = performance.now() - start;
105-
let nextTimeout = Math.max(0, (1000 / targetFPS) - elapsed);
106-
setTimeout(nextFrame, nextTimeout);
107-
};
158+
let audioBufPtr = go.GetAudioBufferPtr();
159+
let audioBuf = new Float32Array(getMemoryBuffer(), audioBufPtr, go.AudioBufferSize);
160+
audioNode.port.postMessage(audioBuf.slice());
161+
}
162+
}
163+
164+
let lastFrameTime = performance.now();
165+
const frameTime = 1000 / TARGET_FPS;
166+
167+
function loop() {
168+
requestAnimationFrame(loop)
108169

109-
nextFrame();
170+
const now = performance.now()
171+
const elapsed = now - lastFrameTime
172+
if (elapsed < frameTime) return
173+
174+
const excessTime = elapsed % frameTime
175+
lastFrameTime = now - excessTime
176+
177+
if (isInFocus()) {
178+
executeFrame();
179+
}
110180
}
111181

112-
gameLoop();
182+
requestAnimationFrame(loop);
113183
});

0 commit comments

Comments
 (0)