Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
roms
.vercel
1 change: 1 addition & 0 deletions index.htm → index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<script src="toolbox.js" type="application/x-javascript"></script>
<script src="scrollbar.js" type="application/x-javascript"></script>
<script src="jsgb.cpu.js" type="application/x-javascript"></script>
<script src="jsgb.audio.js" type="application/x-javascript"></script>
<script src="jsgb.memory.js" type="application/x-javascript"></script>
<script src="jsgb.rom.js" type="application/x-javascript"></script>
<script src="jsgb.interrupts.js" type="application/x-javascript"></script>
Expand Down
324 changes: 324 additions & 0 deletions jsgb.audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/*
* JSGB DMG audio processing unit.
* The APU is clocked with CPU T-cycles and produces stereo PCM for Web Audio.
*/

var gbAudioClock = 4194304;
var gbAudioContext = null;
var gbAudioNode = null;
var gbAudioSampleRate = 44100;
var gbAudioSampleTicks = gbAudioClock / gbAudioSampleRate;
var gbAudioSampleCounter = 0;
var gbAudioFrameCounter = 0;
var gbAudioFrameStep = 0;
var gbAudioQueueL = [];
var gbAudioQueueR = [];
var gbAudioQueuePos = 0;
var gbAudioStarted = false;
var gbAudioPowered = false;

var gbAudioDuty = [
[0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,1],
[1,0,0,0,0,1,1,1],
[0,1,1,1,1,1,1,0]
];

function gb_Audio_New_Square(hasSweep) {
return {enabled:false,dac:false,length:0,lengthOn:false,duty:0,phase:0,
frequency:0,timer:4,volume:0,envelopePeriod:0,envelopeTimer:0,
envelopeAdd:false,sweep:hasSweep,sweepPeriod:0,sweepTimer:0,
sweepNegate:false,sweepShift:0,sweepShadow:0,sweepEnabled:false};
}

function gb_Audio_New_Wave() {
return {enabled:false,dac:false,length:0,lengthOn:false,frequency:0,
timer:2,position:0,volumeCode:0};
}

function gb_Audio_New_Noise() {
return {enabled:false,dac:false,length:0,lengthOn:false,timer:8,lfsr:0x7FFF,
clockShift:0,width7:false,divisor:0,volume:0,envelopePeriod:0,
envelopeTimer:0,envelopeAdd:false};
}

var gbAudioSquare1 = gb_Audio_New_Square(true);
var gbAudioSquare2 = gb_Audio_New_Square(false);
var gbAudioWave = gb_Audio_New_Wave();
var gbAudioNoise = gb_Audio_New_Noise();

function gb_Audio_Square_Period(c) { return (2048-c.frequency)*4; }
function gb_Audio_Wave_Period() { return (2048-gbAudioWave.frequency)*2; }
function gb_Audio_Noise_Period() {
return (gbAudioNoise.divisor ? gbAudioNoise.divisor*16 : 8) << gbAudioNoise.clockShift;
}

function gb_Audio_Set_Status() {
gbMemory[0xFF26]=(gbAudioPowered?0x80:0) |
(gbAudioSquare1.enabled?1:0) | (gbAudioSquare2.enabled?2:0) |
(gbAudioWave.enabled?4:0) | (gbAudioNoise.enabled?8:0);
}

function gb_Audio_Trigger_Square(c) {
c.enabled=c.dac;
if (!c.length) c.length=64;
c.timer=gb_Audio_Square_Period(c);
c.phase=0;
c.volume=(gbMemory[c===gbAudioSquare1?0xFF12:0xFF17]>>4)&15;
c.envelopeTimer=c.envelopePeriod || 8;
if (c.sweep) {
c.sweepShadow=c.frequency;
c.sweepTimer=c.sweepPeriod || 8;
c.sweepEnabled=!!(c.sweepPeriod || c.sweepShift);
if (c.sweepShift) gb_Audio_Sweep_Calculate(c, false);
}
gb_Audio_Set_Status();
}

function gb_Audio_Trigger_Wave() {
var c=gbAudioWave;
c.enabled=c.dac;
if (!c.length) c.length=256;
c.timer=gb_Audio_Wave_Period();
c.position=0;
gb_Audio_Set_Status();
}

function gb_Audio_Trigger_Noise() {
var c=gbAudioNoise;
c.enabled=c.dac;
if (!c.length) c.length=64;
c.timer=gb_Audio_Noise_Period();
c.lfsr=0x7FFF;
c.volume=(gbMemory[0xFF21]>>4)&15;
c.envelopeTimer=c.envelopePeriod || 8;
gb_Audio_Set_Status();
}

function gb_Audio_Clock_Length(c) {
if (c.lengthOn && c.length && !--c.length) c.enabled=false;
}

function gb_Audio_Clock_Envelope(c) {
if (!c.envelopePeriod || !c.enabled) return;
if (!--c.envelopeTimer) {
c.envelopeTimer=c.envelopePeriod;
if (c.envelopeAdd && c.volume<15) c.volume++;
else if (!c.envelopeAdd && c.volume>0) c.volume--;
}
}

function gb_Audio_Sweep_Calculate(c, update) {
var delta=c.sweepShadow>>c.sweepShift;
var frequency=c.sweepNegate ? c.sweepShadow-delta : c.sweepShadow+delta;
if (frequency>2047 || frequency<0) {
c.enabled=false;
return false;
}
if (update && c.sweepShift) {
c.sweepShadow=c.frequency=frequency;
c.timer=gb_Audio_Square_Period(c);
}
return true;
}

function gb_Audio_Clock_Sweep() {
var c=gbAudioSquare1;
if (!c.sweepEnabled || --c.sweepTimer) return;
c.sweepTimer=c.sweepPeriod || 8;
if (c.sweepPeriod && c.sweepShift && gb_Audio_Sweep_Calculate(c,true)) gb_Audio_Sweep_Calculate(c,false);
}

function gb_Audio_Clock_Frame() {
if (!(gbAudioFrameStep&1)) {
gb_Audio_Clock_Length(gbAudioSquare1);
gb_Audio_Clock_Length(gbAudioSquare2);
gb_Audio_Clock_Length(gbAudioWave);
gb_Audio_Clock_Length(gbAudioNoise);
}
if (gbAudioFrameStep===2 || gbAudioFrameStep===6) gb_Audio_Clock_Sweep();
if (gbAudioFrameStep===7) {
gb_Audio_Clock_Envelope(gbAudioSquare1);
gb_Audio_Clock_Envelope(gbAudioSquare2);
gb_Audio_Clock_Envelope(gbAudioNoise);
}
gbAudioFrameStep=(gbAudioFrameStep+1)&7;
gb_Audio_Set_Status();
}

function gb_Audio_Advance_Square(c,ticks) {
c.timer-=ticks;
while (c.timer<=0) { c.timer+=gb_Audio_Square_Period(c); c.phase=(c.phase+1)&7; }
}

function gb_Audio_Advance_Wave(ticks) {
var c=gbAudioWave;
c.timer-=ticks;
while (c.timer<=0) { c.timer+=gb_Audio_Wave_Period(); c.position=(c.position+1)&31; }
}

function gb_Audio_Advance_Noise(ticks) {
var c=gbAudioNoise;
c.timer-=ticks;
while (c.timer<=0) {
c.timer+=gb_Audio_Noise_Period();
var bit=(c.lfsr^(c.lfsr>>1))&1;
c.lfsr=(c.lfsr>>1)|(bit<<14);
if (c.width7) c.lfsr=(c.lfsr&~0x40)|(bit<<6);
}
}

function gb_Audio_Channel_Output(channel) {
if (channel===0) return gbAudioSquare1.enabled && gbAudioSquare1.dac ?
gbAudioDuty[gbAudioSquare1.duty][gbAudioSquare1.phase]*gbAudioSquare1.volume : null;
if (channel===1) return gbAudioSquare2.enabled && gbAudioSquare2.dac ?
gbAudioDuty[gbAudioSquare2.duty][gbAudioSquare2.phase]*gbAudioSquare2.volume : null;
if (channel===2) {
if (!gbAudioWave.enabled || !gbAudioWave.dac || !gbAudioWave.volumeCode) return null;
var b=gbMemory[0xFF30+(gbAudioWave.position>>1)];
var sample=(gbAudioWave.position&1)?b&15:b>>4;
return sample>>(gbAudioWave.volumeCode-1);
}
return gbAudioNoise.enabled && gbAudioNoise.dac ?
((~gbAudioNoise.lfsr)&1)*gbAudioNoise.volume : null;
}

function gb_Audio_Mix_Sample() {
var route=gbMemory[0xFF25], volume=gbMemory[0xFF24], left=0, right=0;
for (var i=0;i<4;i++) {
var output=gb_Audio_Channel_Output(i);
if (output===null) continue;
var sample=output/7.5-1;
if (route&(1<<i)) right+=sample;
if (route&(0x10<<i)) left+=sample;
}
left*=((volume>>4)&7)+1;
right*=(volume&7)+1;
// There is no reason to retain PCM if the browser has no audio output.
if (!gbAudioNode) return;
gbAudioQueueL.push(left/32);
gbAudioQueueR.push(right/32);
if (gbAudioQueuePos>8192) {
gbAudioQueueL=gbAudioQueueL.slice(gbAudioQueuePos);
gbAudioQueueR=gbAudioQueueR.slice(gbAudioQueuePos);
gbAudioQueuePos=0;
}
}

function gb_Audio_Step(ticks) {
if (!gbAudioPowered) return;
gb_Audio_Advance_Square(gbAudioSquare1,ticks);
gb_Audio_Advance_Square(gbAudioSquare2,ticks);
gb_Audio_Advance_Wave(ticks);
gb_Audio_Advance_Noise(ticks);
gbAudioFrameCounter+=ticks;
while (gbAudioFrameCounter>=8192) { gbAudioFrameCounter-=8192; gb_Audio_Clock_Frame(); }
gbAudioSampleCounter+=ticks;
while (gbAudioSampleCounter>=gbAudioSampleTicks) {
gbAudioSampleCounter-=gbAudioSampleTicks;
gb_Audio_Mix_Sample();
}
}

function gb_Audio_Create_Output() {
if (gbAudioContext) return;
var AudioContext=window.AudioContext||window.webkitAudioContext;
if (!AudioContext) return;
gbAudioContext=new AudioContext();
gbAudioSampleRate=gbAudioContext.sampleRate;
gbAudioSampleTicks=gbAudioClock/gbAudioSampleRate;
gbAudioNode=gbAudioContext.createScriptProcessor(2048,0,2);
gbAudioNode.onaudioprocess=function(e) {
var left=e.outputBuffer.getChannelData(0), right=e.outputBuffer.getChannelData(1);
var available=gbAudioQueueL.length-gbAudioQueuePos;
// Start (or restart) only with a complete block ready. Keeping partial
// audio queued avoids repeatedly alternating between signal and silence.
if (available<left.length) gbAudioStarted=false;
else if (!gbAudioStarted) gbAudioStarted=true;
for (var i=0;i<left.length;i++) {
if (gbAudioStarted && gbAudioQueuePos<gbAudioQueueL.length) {
left[i]=gbAudioQueueL[gbAudioQueuePos];
right[i]=gbAudioQueueR[gbAudioQueuePos++];
} else {
left[i]=right[i]=0;
gbAudioStarted=false;
}
}
};
gbAudioNode.connect(gbAudioContext.destination);
}

function gb_Audio_Resume() {
gb_Audio_Create_Output();
gbAudioStarted=false;
if (gbAudioContext && gbAudioContext.state==='suspended') gbAudioContext.resume();
}

function gb_Audio_Pause() {
if (gbAudioContext && gbAudioContext.state==='running') gbAudioContext.suspend();
}

function gb_Init_Audio() {
gbAudioSquare1=gb_Audio_New_Square(true);
gbAudioSquare2=gb_Audio_New_Square(false);
gbAudioWave=gb_Audio_New_Wave();
gbAudioNoise=gb_Audio_New_Noise();
// The post-boot register values written by gb_Init_Memory belong to a
// powered APU. NR52 can still turn it off afterwards.
gbAudioPowered=true;
gbAudioSampleCounter=gbAudioFrameCounter=gbAudioFrameStep=0;
gbAudioStarted=false;
gbAudioQueueL=[]; gbAudioQueueR=[]; gbAudioQueuePos=0;
}

function gb_Audio_Power(on) {
if (!on) {
for (var a=0xFF10;a<=0xFF25;a++) gbMemory[a]=0;
gbAudioSquare1=gb_Audio_New_Square(true);
gbAudioSquare2=gb_Audio_New_Square(false);
gbAudioWave=gb_Audio_New_Wave();
gbAudioNoise=gb_Audio_New_Noise();
gbAudioPowered=false;
} else if (!gbAudioPowered) {
gbAudioPowered=true;
gbAudioFrameCounter=gbAudioFrameStep=0;
}
gb_Audio_Set_Status();
}

function gb_Audio_Write(a,v) {
v&=0xFF;
if (a===0xFF26) { gb_Audio_Power(!!(v&0x80)); return; }
if (!gbAudioPowered && a<0xFF30) return;
gbMemory[a]=v;
var c;
switch(a) {
case 0xFF10: c=gbAudioSquare1; c.sweepPeriod=(v>>4)&7; c.sweepNegate=!!(v&8); c.sweepShift=v&7; break;
case 0xFF11: c=gbAudioSquare1; c.duty=v>>6; c.length=64-(v&63); break;
case 0xFF12: c=gbAudioSquare1; c.dac=!!(v&0xF8); c.envelopePeriod=v&7; c.envelopeAdd=!!(v&8); if (!c.dac)c.enabled=false; break;
case 0xFF13: c=gbAudioSquare1; c.frequency=(c.frequency&0x700)|v; break;
case 0xFF14: c=gbAudioSquare1; c.frequency=(c.frequency&0xFF)|((v&7)<<8); c.lengthOn=!!(v&0x40); if(v&0x80)gb_Audio_Trigger_Square(c); break;
case 0xFF16: c=gbAudioSquare2; c.duty=v>>6; c.length=64-(v&63); break;
case 0xFF17: c=gbAudioSquare2; c.dac=!!(v&0xF8); c.envelopePeriod=v&7; c.envelopeAdd=!!(v&8); if (!c.dac)c.enabled=false; break;
case 0xFF18: c=gbAudioSquare2; c.frequency=(c.frequency&0x700)|v; break;
case 0xFF19: c=gbAudioSquare2; c.frequency=(c.frequency&0xFF)|((v&7)<<8); c.lengthOn=!!(v&0x40); if(v&0x80)gb_Audio_Trigger_Square(c); break;
case 0xFF1A: gbAudioWave.dac=!!(v&0x80); if(!gbAudioWave.dac)gbAudioWave.enabled=false; break;
case 0xFF1B: gbAudioWave.length=256-v; break;
case 0xFF1C: gbAudioWave.volumeCode=(v>>5)&3; break;
case 0xFF1D: gbAudioWave.frequency=(gbAudioWave.frequency&0x700)|v; break;
case 0xFF1E: gbAudioWave.frequency=(gbAudioWave.frequency&0xFF)|((v&7)<<8); gbAudioWave.lengthOn=!!(v&0x40); if(v&0x80)gb_Audio_Trigger_Wave(); break;
case 0xFF20: gbAudioNoise.length=64-(v&63); break;
case 0xFF21: c=gbAudioNoise; c.dac=!!(v&0xF8); c.envelopePeriod=v&7; c.envelopeAdd=!!(v&8); if(!c.dac)c.enabled=false; break;
case 0xFF22: c=gbAudioNoise; c.clockShift=v>>4; c.width7=!!(v&8); c.divisor=v&7; break;
case 0xFF23: gbAudioNoise.lengthOn=!!(v&0x40); if(v&0x80)gb_Audio_Trigger_Noise(); break;
}
gb_Audio_Set_Status();
}

function gb_Audio_Read(a) {
if (a>=0xFF30) return gbMemory[a];
var masks=[0x80,0x3F,0x00,0xFF,0xBF,0xFF,0x3F,0x00,0xFF,0xBF,
0x7F,0xFF,0x9F,0xFF,0xBF,0xFF,0xFF,0x00,0x00,0xBF,0x00,0x00,0x70,0x00,0x00,0x00,0x70];
if (a===0xFF26) { gb_Audio_Set_Status(); return gbMemory[a]|0x70; }
return gbMemory[a]|masks[a-0xFF10];
}
24 changes: 21 additions & 3 deletions jsgb.gameboy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@

var gbRunInterval;
var gbFpsInterval;
var gbFrameDuration=70224/4194304*1000;
var gbNextFrameTime=0;

function gb_Frame() {
gbEndFrame=false;
Expand All @@ -22,23 +24,39 @@ function gb_Step(){
gb_Dump_All();
}

function gb_Schedule_Frame() {
if (gbPause) return;
gb_Frame();
gbNextFrameTime+=gbFrameDuration;
var now=performance.now();
// Do not execute a large burst after the tab or the main thread was blocked.
if (gbNextFrameTime<now-gbFrameDuration*2) gbNextFrameTime=now+gbFrameDuration;
gbRunInterval=setTimeout(gb_Schedule_Frame,Math.max(0,gbNextFrameTime-now));
}

function gb_Run() {
if (!gbPause) return;
gbPause=false;
gb_Audio_Resume();
$('BR').disabled=1;
$('BP').disabled=0;
$('BS').disabled=1;
gbFpsInterval=setInterval(gb_Show_Fps,1000);
gbRunInterval=setInterval(gb_Frame,16);
// Produce one frame immediately. This primes the audio queue before the
// first Web Audio callback and removes the initial underrun.
gb_Frame();
gbNextFrameTime=performance.now()+gbFrameDuration;
gbRunInterval=setTimeout(gb_Schedule_Frame,gbFrameDuration);
}

function gb_Pause() {
if (gbPause) return;
gbPause=true;
gb_Audio_Pause();
$('BR').disabled=0;
$('BP').disabled=1;
$('BS').disabled=0;
clearInterval(gbRunInterval);
clearTimeout(gbRunInterval);
clearInterval(gbFpsInterval);
$('STATUS').innerHTML='Pause';
gb_Dump_All();
Expand All @@ -49,6 +67,7 @@ function gb_Insert_Cartridge(fileName, Start) {
gbSeconds = 0;
gbFrames = 0;
gb_Init_Debugger();
gb_Init_Audio();
gb_Init_Memory();
gb_Init_LCD();
gb_Init_Interrupts();
Expand All @@ -59,4 +78,3 @@ function gb_Insert_Cartridge(fileName, Start) {
if (Start) $('BR').onclick();
else $('BP').onclick();
}

Loading