Add sampling capability to square wave channels

This commit is contained in:
Jeremy Penner 2024-11-23 20:52:16 -05:00
parent 8772714135
commit 82591aa870
11 changed files with 119 additions and 13 deletions

BIN
Tetris.nes Normal file

Binary file not shown.

View file

@ -1,4 +1,5 @@
module.exports = {
Controller: require("./controller"),
NES: require("./nes"),
Sample: require("./sample"),
};

View file

@ -316,23 +316,31 @@ PAPU.prototype = {
}
// Clock Square channel 1 Prog timer:
var square1sample = this.nes.opts[square1.sampleName];
square1.progTimerCount -= nCycles;
if (square1.progTimerCount <= 0) {
square1.progTimerCount += (square1.progTimerMax + 1) << 1;
if (square1sample) { square1sample.advance(4); }
square1.squareCounter++;
square1.squareCounter &= 0x7;
square1.updateSampleValue();
} else if (square1sample) {
square1.updateSampleValue();
}
// Clock Square channel 2 Prog timer:
var square2sample = this.nes.opts[square2.sampleName];
square2.progTimerCount -= nCycles;
if (square2.progTimerCount <= 0) {
square2.progTimerCount += (square2.progTimerMax + 1) << 1;
if (square2sample) { square2sample.advance(4); }
square2.squareCounter++;
square2.squareCounter &= 0x7;
square2.updateSampleValue();
} else if (square2sample) {
square2.updateSampleValue();
}
// Clock noise channel Prog timer:
@ -499,6 +507,9 @@ PAPU.prototype = {
this.noise.accValue = smpNoise >> 4;
this.noise.accCount = 1;
var extra = 0;
if (this.nes.opts.sampleSquare1) { this.smpSquare1 = 0; extra += this.square1.sampleValue; }
if (this.nes.opts.sampleSquare2) { this.smpSquare2 = 0; extra += this.square2.sampleValue; }
// Stereo sound.
// Left channel:
@ -560,7 +571,7 @@ PAPU.prototype = {
}
if (this.nes.opts.onAudioSample) {
this.nes.opts.onAudioSample(sampleValueL / 32768, sampleValueR / 32768);
this.nes.opts.onAudioSample((sampleValueL / 32768) + extra, (sampleValueR / 32768) + extra);
}
// Reset sampled values:
@ -1156,6 +1167,7 @@ ChannelNoise.prototype = {
var ChannelSquare = function (papu, square1) {
this.papu = papu;
this.sampleName = `sampleSquare${square1 ? 1 : 2}`;
// prettier-ignore
this.dutyLookup = [
@ -1292,6 +1304,7 @@ ChannelSquare.prototype = {
},
updateSampleValue: function () {
var sample = this.papu.nes.opts[this.sampleName];
if (this.isEnabled && this.lengthCounter > 0 && this.progTimerMax > 7) {
if (
this.sweepMode === 0 &&
@ -1299,13 +1312,25 @@ ChannelSquare.prototype = {
) {
//if (this.sweepCarry) {
this.sampleValue = 0;
if (sample) { sample.reset(); }
} else {
if (sample) {
if (this.envVolume === 0 || this.masterVolume === 0) {
this.sampleValue = 0;
sample.reset();
} else {
this.sampleValue = sample.sample(this.progTimerCount, (this.progTimerMax + 1) << 1, this.masterVolume);
// console.log("sampled value:", this.sampleValue);
}
} else {
this.sampleValue =
this.masterVolume *
this.dutyLookup[(this.dutyMode << 3) + this.squareCounter];
}
}
} else {
this.sampleValue = 0;
if (sample) { sample.reset(); }
}
},

52
jsnes/src/sample.js Normal file
View file

@ -0,0 +1,52 @@
var Sample = function (audiobuffer, toneHz, gain) {
var samples = new Float32Array(audiobuffer.getChannelData(0));
for (var chan = 1; chan < audiobuffer.numberOfChannels; chan ++) {
var channel_samples = audiobuffer.getChannelData(chan);
for (var i = 0; i < samples.length; i ++) {
samples[i] += channel_samples[i] + 1;
}
}
gain = (gain ? gain : 1) / (audiobuffer.numberOfChannels * 2);
for (i = 0; i < samples.length; i ++) {
samples[i] = samples[i] * gain;
}
this.samples = samples;
this.index = 0;
this.increment = audiobuffer.sampleRate / toneHz;
this.prevMaxTimer = null;
this.clocksPerPeriod = 1;
}
Sample.prototype = {
clone: function() {
var copy = Object.create(this);
copy.index = 0;
return copy;
},
advance: function(clocksPerPeriod) {
this.clocksPerPeriod = clocksPerPeriod;
this.index += this.increment / clocksPerPeriod;
},
reset: function() { this.index = 0; },
sample: function(timer, maxTimer, volume) {
if (maxTimer !== this.prevMaxTimer) {
if (this.prevMaxTimer) {
var semitoneInterval = 1.059463;
const semitoneUp = this.prevMaxTimer * semitoneInterval;
const semitoneDown = this.prevMaxTimer / semitoneInterval;
if (maxTimer <= semitoneDown || maxTimer >= semitoneUp) {
this.index = 0;
}
}
this.prevMaxTimer = maxTimer;
}
var index = (this.index + (((this.increment / this.clocksPerPeriod) * (maxTimer - timer)) / maxTimer)) >> 0;
if (index < 0) index = 0;
if (index >= this.samples.length) index = this.samples.length - 1;
return (this.samples[index] * volume);
}
}
module.exports = Sample;

View file

@ -6,10 +6,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"rebuild": "cd jsnes && yarn && cd .. && yarn upgrade jsnes && vite --force"
},
"dependencies": {
"jsnes": "file:../jsnes"
"jsnes": "file:./jsnes"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",

BIN
samples/YEARGH.WAV Normal file

Binary file not shown.

BIN
samples/meow1.wav Normal file

Binary file not shown.

View file

@ -1,7 +1,9 @@
<script>
import { loadSample } from './lib/sampload.js'
import NES from './lib/NES.svelte'
let rom = $state(null);
let opts = $state({});
function nes_load_url(url) {
// todo: port to fetch
@ -21,12 +23,23 @@
};
req.send();
}
async function meow() {
const sample = await loadSample("samples/meow1.wav", 1400, 5);
opts = { sampleSquare1: sample, sampleSquare2: sample.clone() };
}
async function yeargh() {
const sample = await loadSample("samples/YEARGH.WAV", 1400, 1);
opts = { sampleSquare1: sample, sampleSquare2: sample.clone() };
}
</script>
<main>
<NES {rom}/>
<button onclick={() => nes_load_url("smb.nes")}>Start!</button>
<NES {rom} {opts}/>
<button onclick={() => nes_load_url("smb.nes")}>Mario</button>
<button onclick={() => nes_load_url("Tetris.nes")}>Tetris</button>
<button onclick={() => { opts = {} }}>Normal</button>
<button onclick={meow}>Meow</button>
<button onclick={yeargh}>Yeargh</button>
<p>DPad: Arrow keys<br/>B button: Z, A button: X, Select: Tab / C, Start: Return / V</p>
</main>

View file

@ -3,7 +3,7 @@
import { onMount } from 'svelte';
let nes = $state();
let { rom } = $props();
let { rom, opts } = $props();
const SCREEN_WIDTH = 256;
const SCREEN_HEIGHT = 240;
@ -81,10 +81,10 @@
}
let emu = new jsnes.NES({
onFrame: function(framebuffer_24){
onFrame: function(framebuffer_24) {
for(var i = 0; i < FRAMEBUFFER_SIZE; i++) framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];
},
onAudioSample: function(l, r){
onAudioSample: function(l, r) {
audio_samples_L[audio_write_cursor] = l;
audio_samples_R[audio_write_cursor] = r;
audio_write_cursor = (audio_write_cursor + 1) & SAMPLE_MASK;
@ -105,6 +105,7 @@
canvas_ctx.putImageData(image, 0, 0);
}
nes = {
baseOpts: emu.opts,
emu,
activate() {
this.stopAnimating();
@ -140,6 +141,11 @@
}
}
})
$effect(() => {
if (nes) {
nes.emu.opts = { ...opts, ...nes.baseOpts };
}
})
</script>
<main>

8
src/lib/sampload.js Normal file
View file

@ -0,0 +1,8 @@
import { Sample } from 'jsnes';
export async function loadSample(url, toneHz, gain) {
const audioCtx = new AudioContext();
const response = await fetch(url);
const buffer = await audioCtx.decodeAudioData(await response.arrayBuffer());
return new Sample(buffer, toneHz, gain);
}

View file

@ -357,7 +357,7 @@ is-reference@^3.0.3:
dependencies:
"@types/estree" "^1.0.6"
"jsnes@file:../jsnes":
"jsnes@file:./jsnes":
version "1.2.1"
kleur@^4.1.5: