zx-generation
Version:
A high-fidelity ZX Spectrum emulator in JavaScript — fully generated by a large language model (LLM) to explore the boundaries of AI in systems programming.
2 lines (1 loc) • 9.57 kB
JavaScript
class e{static T_STATES_PER_FRAME=69888;static FRAMES_PER_SECOND=50;static CPU_FREQ=e.T_STATES_PER_FRAME*e.FRAMES_PER_SECOND;static MAX_EDGES_PER_FRAME=2048;constructor(){this.audioContext=null,this.workletNode=null,this.compressor=null,this.gainNode=null,this.edgePool=Array.from({length:4},(()=>new Float64Array(2*e.MAX_EDGES_PER_FRAME))),this.currentLevel=0,this.frameEdges=[],this.totalTStates=0,this.frameCount=0,this.edgeCountTot=0,this.volume=.5,this.enabled=!1,this.resumeButton=null,this.debugMode=!1}async init(){if(this.audioContext)return this.enabled;try{this.audioContext=new(window.AudioContext||window.webkitAudioContext)({latencyHint:"interactive",sampleRate:48e3}),this.audioContext.addEventListener("statechange",(()=>this.#e()));const e=this.audioContext.sampleRate,t=this.#t(e),s=URL.createObjectURL(new Blob([t],{type:"application/javascript"}));return await this.audioContext.audioWorklet.addModule(s),URL.revokeObjectURL(s),this.workletNode=new AudioWorkletNode(this.audioContext,"zx-beeper"),this.workletNode.port.onmessage=e=>{e.data?.returnBuffer instanceof ArrayBuffer&&this.edgePool.push(new Float64Array(e.data.returnBuffer))},this.compressor=this.audioContext.createDynamicsCompressor(),this.compressor.threshold.value=-12,this.compressor.knee.value=2,this.compressor.ratio.value=2,this.compressor.attack.value=.001,this.compressor.release.value=.1,this.gainNode=this.audioContext.createGain(),this.gainNode.gain.value=0,this.workletNode.connect(this.compressor),this.compressor.connect(this.gainNode),this.gainNode.connect(this.audioContext.destination),this.gainNode.gain.setValueAtTime(0,this.audioContext.currentTime),this.gainNode.gain.linearRampToValueAtTime(this.volume,this.audioContext.currentTime+.1),"suspended"===this.audioContext.state&&this.#s(),this.enabled=!0,!0}catch(e){return console.error("AudioWorklet init failed:",e),this.enabled=!1,!1}}setBeeperState(e,t){if(!this.enabled)return;const s=.9*(16&e?1:0)+.33*(8&e?0:1);s!==this.currentLevel&&(this.frameEdges.push({tState:t,level:s}),this.currentLevel=s,++this.edgeCountTot)}startFrame(){this.frameEdges=[{tState:0,level:this.currentLevel}]}endFrame(t){if(!this.enabled||!this.workletNode)return;this.totalTStates+=t,++this.frameCount;const s=this.edgePool.pop()||new Float64Array(2*e.MAX_EDGES_PER_FRAME),n=Math.min(this.frameEdges.length,e.MAX_EDGES_PER_FRAME);for(let e=0;e<n;++e){const t=this.frameEdges[e];s[2*e]=t.tState,s[2*e+1]=t.level}this.workletNode.port.postMessage({frame:!0,edges:s,edgeCount:n,frameTStates:t,syncTState:this.totalTStates},[s.buffer])}reset(){this.enabled&&(this.gainNode?.gain.linearRampToValueAtTime(0,this.audioContext.currentTime+.02),this.currentLevel=0,this.frameEdges=[],this.totalTStates=0,this.frameCount=0,this.edgeCountTot=0,this.workletNode?.port.postMessage({reset:!0}),setTimeout((()=>{this.gainNode?.gain.linearRampToValueAtTime(this.volume,this.audioContext.currentTime+.05)}),30))}setVolume(e){this.volume=Math.max(0,Math.min(1,e)),this.gainNode&&this.gainNode.gain.exponentialRampToValueAtTime(Math.max(1e-4,this.volume),this.audioContext.currentTime+.1)}setMuted(e){this.setVolume(e?0:this.volume)}setDebugMode(e){this.debugMode=!!e}isReady(){return this.enabled&&"running"===this.audioContext?.state}getStats(){return{enabled:this.enabled,contextState:this.audioContext?this.audioContext.state:"closed",sampleRate:this.audioContext?this.audioContext.sampleRate:0,totalTStates:this.totalTStates,frameCount:this.frameCount,totalEdges:this.edgeCountTot,bufferSize:e.MAX_EDGES_PER_FRAME,volume:this.volume}}async stop(){this.enabled&&(this.enabled=!1,this.gainNode?.gain.linearRampToValueAtTime(0,this.audioContext.currentTime+.05),await new Promise((e=>setTimeout(e,60))),this.workletNode?.disconnect(),this.compressor?.disconnect(),this.gainNode?.disconnect(),await(this.audioContext?.close()),this.workletNode=this.compressor=this.gainNode=this.audioContext=null,this.resumeButton?.remove(),this.resumeButton=null)}#t(e){return`\n class ZXBeeperProcessor extends AudioWorkletProcessor {\n static T_STATES_PER_FRAME = 69888;\n static FRAMES_PER_SECOND = 50;\n static CPU_FREQ = ZXBeeperProcessor.T_STATES_PER_FRAME *\n ZXBeeperProcessor.FRAMES_PER_SECOND;\n\n constructor () {\n super();\n this.sr = ${e}; // fixed\n this.tStatesPerSample =\n ZXBeeperProcessor.CPU_FREQ / this.sr;\n this.samplesPerFrame =\n Math.round(this.sr / ZXBeeperProcessor.FRAMES_PER_SECOND);\n\n /* Edge data -------------------------------------- */\n this.edgeBuf = null;\n this.edgeCount = 0;\n\n /* Synth state ----------------------------------- */\n this.currentLevel = 0;\n this.generatorTState = 0; // absolute\n\n /* Filters: 3.5 kHz low-pass + DC blocker -------- */\n const fc = 3500;\n this.lpAlpha =\n 1 - Math.exp(-2 * Math.PI * fc / this.sr);\n this.lpState = 0;\n this.dcCoeff = 0.9995;\n this.dcPrevIn = 0;\n this.dcPrevOut = 0;\n\n /* Frame buffer ---------------------------------- */\n this.frameBuf =\n new Float32Array(this.samplesPerFrame + 128);\n this.fbPos = 0;\n this.frameReady = false;\n\n this.port.onmessage = e => this.#onMessage(e.data);\n }\n\n /* ------------------ messages ---------------------- */\n #onMessage (d) {\n if (d.frame) {\n this.edgeBuf = new Float64Array(d.edges);\n this.edgeCount = d.edgeCount | 0;\n this.frameTStates = d.frameTStates | 0;\n this.syncTState = d.syncTState | 0;\n\n this.#renderFrame();\n this.frameReady = true;\n\n } else if (d.reset) {\n this.currentLevel = 0;\n this.lpState = 0;\n this.dcPrevIn = 0;\n this.dcPrevOut = 0;\n this.fbPos = 0;\n this.frameReady = false;\n this.generatorTState = 0;\n }\n }\n\n /* -------------- render single 20 ms frame ---------- */\n #renderFrame () {\n const N = this.samplesPerFrame;\n let eix = 0;\n\n for (let s = 0; s < N; ++s) {\n const relTS = s * this.tStatesPerSample;\n while (eix < this.edgeCount &&\n this.edgeBuf[eix * 2] <= relTS) {\n this.currentLevel = this.edgeBuf[eix * 2 + 1];\n ++eix;\n }\n\n /* low-pass */\n this.lpState += this.lpAlpha *\n (this.currentLevel - this.lpState);\n\n /* bipolar + DC-block */\n const bb = (this.lpState - 0.5) * 2;\n const out = bb - this.dcPrevIn +\n this.dcCoeff * this.dcPrevOut;\n this.dcPrevIn = bb;\n this.dcPrevOut = out;\n\n this.frameBuf[s] = out * 0.6;\n }\n\n /* advance generator clock & PLL correction */\n this.generatorTState += N * this.tStatesPerSample;\n const drift = this.syncTState - this.generatorTState;\n this.generatorTState += drift * 0.05; // 5 % pull\n\n this.fbPos = 0;\n\n /* return buffer for reuse */\n this.port.postMessage({ returnBuffer: this.edgeBuf.buffer },\n [this.edgeBuf.buffer]);\n this.edgeBuf = null;\n this.edgeCount = 0;\n }\n\n /* ---------------- audio callback ------------------ */\n process (_in, out) {\n const ch = out[0][0];\n if (!ch) return true;\n\n for (let i = 0, n = ch.length; i < n; ++i) {\n if (this.frameReady && this.fbPos < this.samplesPerFrame) {\n ch[i] = this.frameBuf[this.fbPos++];\n } else {\n /* hold last sample to avoid clicks */\n ch[i] = this.fbPos ? this.frameBuf[this.fbPos - 1] : 0;\n }\n if (this.fbPos >= this.samplesPerFrame) {\n this.frameReady = false;\n this.fbPos = 0;\n }\n }\n return true;\n }\n }\n registerProcessor("zx-beeper", ZXBeeperProcessor);\n `}#s(){}#e(){}}export{e as SpectrumAudioWorklet};