HomeJournalThis post

Web Audio Synthesis With PeriodicWave

Turn Fourier coefficients into three custom browser timbres, cull authored partials by pitch, render offline, inspect measurements, and export the recipe.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Web Audio PeriodicWave turns Fourier coefficient arrays into a custom oscillator, but the useful creative artifact is the recipe plus evidence—not an adjective about the sound. This createPeriodicWave tutorial builds three sound pigments, removes authored partials above Nyquist for the selected note, renders offline, inspects the result, and exports the complete browser harmonic synthesis recipe.

Read a timbre as harmonics

A periodic timbre can be treated as a palette of harmonics. The fundamental defines the perceived pitch; integer multiples add material. A strong second harmonic changes the silhouette differently from a cluster of high, gently decaying partials. Web Audio PeriodicWave turns that palette into an oscillator definition.

I find this more useful than starting from adjectives like warm, glassy, or hollow. Those words are good art direction, but coefficients are the repeatable recipe. A creative coding tool can show bars for each partial, synthesize a cycle, render a short note, and keep the subjective label beside measurable evidence rather than inside it.

The artifact includes three authored “sound pigments.” Reed emphasizes a small descending stack of sine components. Glass places energy in sparse upper partials. Soft square uses odd harmonics with decreasing amplitude. They are illustrations, not acoustic models of real instruments. Web Audio PeriodicWave keeps each palette compact enough to inspect coefficient by coefficient.

This approach connects visual and sonic craft. The coefficient bars are editable material; the waveform is one projection of that material; the heard note depends on pitch, envelope, gain, sample rate, implementation, and playback system. Accessible data sonification is a useful companion because audio should add a route through information, not remove the visible one.

The goal of this Web Audio PeriodicWave tutorial is therefore a reproducible recipe and honest render receipt—not a claim that one set of numbers has a universal emotional meaning.

Harmonic bars become three sound pigmentsA shared set of harmonic bars branches into reed, glass, and soft-square waveform silhouettes with distinct coefficient structures.HARMONIC PALETTEREEDGLASSSOFT SQUARE
Coefficient arrays are the material recipe; the names are art direction, not acoustic identity claims.
Three illustrative sound-pigment recipes
PresetCoefficient structureClaim boundary
ReedSix descending sine terms with a stronger secondIllustrative, not an instrument model
GlassSparse upper partials with gapsSubjective label, reproducible recipe
Soft squareBounded odd-harmonic falloffNot an ideal discontinuous square wave

Map real and imaginary arrays correctly

createPeriodicWave(real, imag) accepts equal-length coefficient arrays. The real array contains cosine terms; imag contains sine terms. Index k describes harmonic k. Index zero is the DC component, and the Web Audio specification sets both zero-index coefficients to zero when constructing the wave.

I still validate and report that zeroing before calling the API. Silent correction without a receipt makes a recipe hard to debug. Arrays must have equal length of at least two, every coefficient must be finite and bounded, and the lab limits the number of authored partials. A mismatch fails before an AudioContext is created. A custom oscillator Web Audio developers can audit starts with that fail-closed boundary.

The Web Audio 1.1 PeriodicWave definition also defines normalization. With default normalization, the constructed waveform is scaled so its maximum is one. The lab keeps normalization enabled and applies a conservative gain envelope afterward. I do not mix a “disable normalization” option into the beginner recipe because it widens the clipping and comparability surface.

Phase matters. A cosine and sine coefficient at the same index share frequency but shift the cycle shape. Two recipes can carry similar spectral magnitude and look different in time. That is why the export keeps both arrays, rather than only a magnitude bar chart.

AudioWorklet creative audio becomes relevant when synthesis needs custom sample processing. For a periodic oscillator, PeriodicWave keeps the first implementation smaller and lets the platform own oscillator scheduling.

Build three Web Audio PeriodicWave pigments

I design each preset with a constraint instead of nudging random sliders. Reed uses the first six sine terms with a steady falloff and a modest second harmonic. Glass leaves gaps, then places quieter energy higher in the series. Soft square uses odd indices and an inverse-harmonic falloff, but stops long before an ideal discontinuous square wave.

Each preset has a name, real and imaginary arrays, author note, fundamental, sample rate, duration, attack, release, and output gain. The name is editable metadata. The arrays are the synthesis recipe. The render receipt records which partials survived the selected pitch. Web Audio PeriodicWave makes those declared arrays the durable handoff, not the preset name.

This is where browser harmonic synthesis becomes artistic rather than merely mathematical. Moving one coefficient changes both the waveform silhouette and the spectral balance. Sparse recipes make those relationships easier to learn. I show three side-by-side shapes so differences are perceptible before playback and remain available when audio is muted.

I do not claim these recipes reproduce a clarinet, bell, or analog circuit. A real instrument changes over time, pitch, articulation, and performance. One static periodic spectrum plus an amplitude envelope is deliberately smaller. The limitation is creatively useful: it turns a complex timbre into a material study.

The cymatics Web Audio and Canvas study explores another sound-image relationship. Here the visual is not a physics simulation. It is an interface for inspecting coefficients, waveform, and spectrum while preserving their different meanings.

Bound partials by pitch and sample rate

A harmonic at index k has authored frequency k times the fundamental. Before building the wave, I keep only indices where k × f0 < sampleRate / 2. Everything at or above Nyquist is zeroed and listed in the receipt.

That guard changes with pitch. At 48 kHz and 110 Hz, many partials are eligible. Raise the fundamental to 2 kHz and the usable authored palette becomes much smaller. The second figure makes this visible as a staircase rather than hiding it inside a loop.

The guard does not make the output “alias-free.” The specification permits implementations to alter actual waveforms to avoid aliasing, and oscillator behavior is more nuanced than deleting input coefficients. Research on alias-suppressed digital oscillators shows why discontinuities and high-frequency components deserve careful treatment. My check only proves that the Web Audio PeriodicWave recipe did not intentionally submit a harmonic on or above Nyquist for this note.

This boundary matters when exporting a createPeriodicWave tutorial. The recipe should include the authored arrays and the culled arrays. Otherwise, two pitches can appear to use the same preset while sending different effective harmonic sets into the context.

I also cap fundamentals, coefficient count, duration, and sample rate in the artifact. Those bounds keep memory and render work predictable. Invalid numbers, oversized arrays, and a recipe with no surviving fundamental fail closed before preview or export.

Usable authored partials decrease as pitch risesThree frequency staircases at a forty-eight-kilohertz sample rate show many eligible harmonics at 110 hertz, fewer at 880 hertz, and only a small set at 4 kilohertz.NYQUIST · 24 kHz110 Hz880 Hz4 kHzharmonic index →
The input guard removes authored partials at or above Nyquist for the selected note; it does not certify alias-free output.
110 Hz at 48 kHz
Eligible while k × 110 remains below 24,000.
880 Hz at 48 kHz
Far fewer authored indices survive.
4 kHz at 48 kHz
Only harmonics 1–5 are below Nyquist.
Boundary
The browser may still modify oscillator waveforms; this is an authored-input check.

Render offline before playback

The first output is not sound from the speakers. It is a short OfflineAudioContext render. The lab creates the Web Audio PeriodicWave, assigns it to an oscillator, applies an attack-hold-release gain envelope, renders a fixed duration, then calculates sample count, peak, RMS, zero crossings, and a digest of the recipe—not a cross-browser audio hash.

Offline rendering gives the interface a complete buffer to inspect without racing the wall clock. It also makes export straightforward. The WAV encoder receives the rendered channel; the JSON receipt receives the recipe, context settings, coefficient cull, envelope, and measurements.

I avoid bit-identical sample claims. Browser engines may implement oscillator tables and alias reduction differently. A fixed recipe digest proves the input configuration, while rendered measurements describe the executing browser. They should not be confused. The label “Fourier coefficients audio” is too compressed to carry that distinction by itself. The MDN createPeriodicWave reference is clear about coefficient roles and array constraints, but it does not turn different implementations into identical samplers.

The offline pass also protects the preview path. If the peak exceeds the lab’s declared ceiling or the buffer contains a nonfinite sample, the receipt fails and playback stays disabled. Default PeriodicWave normalization plus conservative output gain should keep the preset inside bounds, but the measurement is still checked.

This is evidence before sensation: render, inspect, then offer an optional listen.

Inspect waveform and spectrum honestly

A waveform plot shows amplitude over time. A spectrum plot estimates energy by frequency. They answer different questions, so I keep them adjacent rather than layering them into one decorative trace. The coefficient table remains the semantic source for readers who cannot or do not want to interpret either chart.

For this lab, a small deterministic DFT window is enough to confirm that expected harmonic bins receive energy in the offline buffer. It is not a mastering analyzer. Window choice, leakage, render duration, and implementation details affect the displayed spectrum. The receipt says which samples and bins were inspected.

RMS and peak also need modest language. Peak helps catch clipping risk under the declared gain. RMS summarizes energy in this short render. Neither tells me whether a timbre is pleasant, audible on a particular device, or perceptually balanced against another sound.

I annotate culled authored partials separately from low rendered bins. One is an input policy; the other is a browser observation. Keeping those categories distinct is the audio version of preserving data provenance in a chart.

That distinction matters for Web Audio PeriodicWave work because a beautiful oscillogram can imply more certainty than the recipe earned. My preferred interface lets a reader move from coefficients to effective partials to buffer measurements to optional sound. Every view points back to the same exported recipe, and every claim names whether it came from authored input or the current browser render.

Recipe to render receipt pipelineA coefficient recipe passes through validation and PeriodicWave into OfflineAudioContext, producing a waveform, spectrum, measurements, WAV, JSON receipt, and recipe digest.RECIPEreal + imagPERIODICWAVEnormalizedOFFLINErenderINSPECTwave + spectrumEXPORTWAV + JSONinput digest stays separate from browser measurements
The recipe is cross-engine evidence; the rendered samples are observations from the executing browser.
  1. Validate: equal finite arrays, bounded length, zero DC, eligible partials.
  2. Construct: normalized PeriodicWave and conservative gain envelope.
  3. Render: OfflineAudioContext creates the inspected buffer.
  4. Measure: sample count, duration, peak, RMS, and selected spectrum bins.
  5. Export: WAV from this browser plus JSON recipe and receipt; no universal audio hash.

Design safe accessible controls

Preview begins silent. The page does not create audible output on load, and it never hides audio behind hover. A labeled button starts a short note after a user gesture. The AudioContext resumes inside that action, the oscillator receives a quick attack and release, and a stop path cancels the current preview before starting another.

Volume defaults low and is labeled with its numeric value. Preset buttons expose selected state. Coefficient controls have real labels, bounds, and keyboard operation. Waveform and spectrum canvases have nearby text summaries and tables; sound is optional evidence, not the only way to understand the result.

I also respect reduced motion in the visualization. The waveform can update as a static redraw without animated sweeps. A captioned screen recording should not require audio to follow the transformation, and an audio-enabled derivative should provide a transcript of the controls and recipe changes.

The short envelope avoids hard starts and stops that can click, but it is not a hearing-safety guarantee. Device gain and downstream processing remain outside the page. I never label a browser synthesis preset safe for headphones merely because its samples stay below full scale.

The same restraint applies to rhythm. A Euclidean rhythm generator for Web Audio can schedule these timbres later, but the first artifact keeps one note and one decision. That narrow scope makes user gesture, stop, gain, and export behavior easy to inspect.

Export the whole recipe

A useful Web Audio PeriodicWave export contains more than WAV bytes. I include schema version, preset or custom name, authored real and imaginary arrays, zeroed DC report, effective arrays after Nyquist culling, fundamental, sample rate, duration, envelope, gain, normalization policy, recipe digest, and executing-browser measurements.

The WAV is a listening artifact from this browser. The JSON is the reconstruction contract. Keeping both lets another person recreate the intent even if their rendered samples differ. If the recipe changes, the digest changes. If only the engine changes, the input digest can stay stable while measurements move.

I would store a few golden recipe digests in tests and avoid golden audio hashes across engines. The tests should independently check array lengths, finite bounds, DC zeroing, partial eligibility, duration-derived sample count, peak ceiling, export labels, and fail-closed input behavior. Browser-specific waveform observations belong in the receipt, not a universal invariant.

This is the final value of Web Audio PeriodicWave for creative coding: sound becomes a versioned design material. I can name the palette, inspect how pitch changes it, hear the result by choice, and carry enough evidence for someone else to challenge or rebuild it.

The artifact stays intentionally small, but the handoff is complete. It does not say “this sounds glassy” and ask for trust. It shows the coefficients, effective harmonics, render settings, measurements, limitations, and optional sound that produced that description.

Runnable browser artifact — Declared coefficients, partial policy, and local render measurements; not bit-identical cross-engine samples, alias-free output, or objective timbre quality.

HTML23 lines
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web Audio PeriodicWave timbre lab</title><style>:root{font-family:system-ui;color-scheme:dark;background:#071322;color:#edf6ff}body{max-width:70rem;margin:auto;padding:2rem}button,input,textarea{font:inherit;min-height:44px;padding:.6rem}button[aria-pressed="true"]{outline:3px solid #f5b954;outline-offset:2px}button:disabled{opacity:.5}.controls{display:flex;gap:.7rem;flex-wrap:wrap;align-items:end}.coefficients,.inspection{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.coefficients label{display:grid;gap:.35rem;min-width:0}.coefficients textarea{min-height:5rem;resize:vertical}section{background:#101f34;border:1px solid #31445f;border-radius:1rem;padding:1rem;margin:1rem 0}canvas{inline-size:100%;block-size:12rem;background:#071322;border-radius:.7rem}pre{white-space:pre-wrap;overflow-wrap:anywhere}.muted{color:#afc4dc}@media(max-width:42rem){body{padding:1rem}.coefficients,.inspection{grid-template-columns:1fr}}</style><main><h1>Web Audio synthesis with PeriodicWave</h1><p>Shape three coefficient recipes, cull authored partials above Nyquist, render offline, inspect, then choose whether to listen.</p><section aria-labelledby="recipe-heading"><h2 id="recipe-heading">Timbre recipe</h2><div class="controls" role="group" aria-label="Sound pigment preset"><button type="button" data-preset="reed" aria-pressed="true">Reed</button><button type="button" data-preset="glass" aria-pressed="false">Glass</button><button type="button" data-preset="soft-square" aria-pressed="false">Soft square</button></div><div class="coefficients"><label for="real">Real coefficients<textarea id="real" spellcheck="false"></textarea></label><label for="imag">Imaginary coefficients<textarea id="imag" spellcheck="false"></textarea></label></div><div class="controls"><label for="fundamental">Fundamental Hz<input id="fundamental" type="number" min="40" max="4000" value="220"></label><label for="volume">Preview volume <output id="volume-value" for="volume">0.12</output><input id="volume" type="range" min="0" max="0.25" step="0.01" value="0.12"></label><button id="render" type="button">Render inspection</button><button id="play" type="button" disabled>Preview sound</button><button id="stop" type="button" disabled>Stop preview</button><button id="wav" type="button" disabled>Download WAV</button><button id="json" type="button" disabled>Download JSON</button></div></section><section><h2>Waveform and spectrum inspection</h2><div class="inspection"><canvas id="wave" width="960" height="240" aria-label="Rendered waveform; numeric summary follows"></canvas><canvas id="spectrum" width="960" height="240" aria-label="Hann-window spectrum at authored harmonic bins; numeric table is in the receipt"></canvas></div><p id="summary" class="muted"></p></section><section><h2>Recipe and render receipt</h2><pre id="receipt" tabindex="0"></pre></section></main><script>(()=>{"use strict";const MAX_PARTIALS=64,MAX_DURATION=2,ENVELOPE={attackSeconds:.02,releaseSeconds:.08,sustainGain:.22},presets={reed:{real:[0,0,0,0,0,0,0],imag:[0,1,.62,.34,.2,.12,.07]},glass:{real:[0,0,.18,0,.12,0,.07,0,.04],imag:[0,1,0,.4,0,.22,0,.1,0]},"soft-square":{real:[0,0,0,0,0,0,0,0],imag:[0,1,0,.333,0,.2,0,.143]}};
const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==="object"?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value,hash=value=>{let h=2166136261,s=JSON.stringify(canonical(value));for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return(h>>>0).toString(16).padStart(8,"0");};
function validate(input){if(!input||typeof input!=="object"||Array.isArray(input))throw Error("invalid-recipe");const f=structuredClone(input);if(!Array.isArray(f.real)||!Array.isArray(f.imag)||f.real.length!==f.imag.length||f.real.length<2||f.real.length>MAX_PARTIALS)throw Error("invalid-coefficient-shape");if([...f.real,...f.imag].some(x=>!Number.isFinite(x)||Math.abs(x)>4))throw Error("invalid-coefficient");if(!Number.isFinite(f.fundamental)||f.fundamental<40||f.fundamental>4000||!Number.isSafeInteger(f.sampleRate)||f.sampleRate<8000||f.sampleRate>96000||!Number.isFinite(f.duration)||f.duration<=0||f.duration>MAX_DURATION)throw Error("invalid-render-settings");const dc={real:f.real[0],imag:f.imag[0]};f.real[0]=0;f.imag[0]=0;return{recipe:f,dc};}
function cull(input){const{recipe,dc}=validate(input),nyquist=recipe.sampleRate/2,removed=[];for(let k=1;k<recipe.real.length;k++)if(k*recipe.fundamental>=nyquist){if(recipe.real[k]||recipe.imag[k])removed.push(k);recipe.real[k]=0;recipe.imag[k]=0;}if(!recipe.real.slice(1).some(Boolean)&&!recipe.imag.slice(1).some(Boolean))throw Error("no-surviving-partial");return{recipe,dc,removed,nyquist};}
function analyzeSpectrum(samples,sampleRate,fundamental,maxHarmonic){const size=Math.min(2048,samples.length),start=Math.max(0,Math.floor((samples.length-size)/2)),bins=[];let windowSum=0;for(let n=0;n<size;n++)windowSum+=.5-.5*Math.cos(2*Math.PI*n/(size-1));for(let harmonic=1;harmonic<=Math.min(maxHarmonic,16);harmonic++){const frequency=harmonic*fundamental;if(frequency>=sampleRate/2)break;let real=0,imag=0;for(let n=0;n<size;n++){const window=.5-.5*Math.cos(2*Math.PI*n/(size-1)),angle=2*Math.PI*frequency*n/sampleRate,value=samples[start+n]*window;real+=value*Math.cos(angle);imag-=value*Math.sin(angle);}bins.push({harmonic,frequency:Number(frequency.toFixed(2)),magnitude:Number((2*Math.hypot(real,imag)/windowSum).toFixed(6))});}return{method:"direct DFT at harmonic centers",window:"Hann",sampleWindow:size,bins};}
async function render(input,metadata={preset:"custom",previewVolume:.12}){const effective=cull(input),f=effective.recipe,length=Math.round(f.sampleRate*f.duration),Context=globalThis.OfflineAudioContext||globalThis.webkitOfflineAudioContext;if(!Context)throw Error("offline-audio-unsupported");const context=new Context(1,length,f.sampleRate),osc=context.createOscillator(),gain=context.createGain(),wave=context.createPeriodicWave(new Float32Array(f.real),new Float32Array(f.imag),{disableNormalization:false});osc.setPeriodicWave(wave);osc.frequency.value=f.fundamental;gain.gain.setValueAtTime(0,0);gain.gain.linearRampToValueAtTime(ENVELOPE.sustainGain,ENVELOPE.attackSeconds);gain.gain.setValueAtTime(ENVELOPE.sustainGain,Math.max(ENVELOPE.attackSeconds,f.duration-ENVELOPE.releaseSeconds));gain.gain.linearRampToValueAtTime(0,f.duration);osc.connect(gain).connect(context.destination);osc.start(0);osc.stop(f.duration);const buffer=await context.startRendering(),samples=buffer.getChannelData(0),peak=samples.reduce((m,x)=>Math.max(m,Math.abs(x)),0),rms=Math.sqrt(samples.reduce((s,x)=>s+x*x,0)/samples.length),zeroCrossings=samples.reduce((count,value,index)=>index&&((samples[index-1]<0&&value>=0)||(samples[index-1]>0&&value<=0))?count+1:count,0),spectrum=analyzeSpectrum(samples,f.sampleRate,f.fundamental,f.real.length-1),core={schema:"periodicwave-lab-v1",status:"PASS",recipeDigest:hash(f),preset:metadata.preset||"custom",authored:{real:input.real,imag:input.imag},effective:{real:f.real,imag:f.imag},dcForcedToZero:effective.dc,removedPartials:effective.removed,nyquist:effective.nyquist,sampleRate:f.sampleRate,fundamental:f.fundamental,duration:f.duration,sampleCount:samples.length,envelope:ENVELOPE,normalization:{periodicWave:"enabled",renderGain:ENVELOPE.sustainGain},previewVolume:metadata.previewVolume??.12,measurements:{peak:Number(peak.toFixed(6)),rms:Number(rms.toFixed(6)),zeroCrossings,clipped:peak>=1},spectrum,claimBoundary:"The input guard excludes authored harmonics at or above Nyquist for this note; browser renders are not bit-identical, alias-free, or perceptual-quality guarantees."};if(samples.length!==length||!Number.isFinite(peak)||peak>=.5||!spectrum.bins.length)throw Error("render-invariant-failed");return{receipt:{...core,receiptHash:hash(core)},buffer,samples};}
function parseCoefficients(value){const parts=value.split(/[\s,]+/).filter(Boolean);if(parts.length<2||parts.length>MAX_PARTIALS)throw Error("invalid-coefficient-shape");const numbers=parts.map(Number);if(numbers.some(x=>!Number.isFinite(x)||Math.abs(x)>4))throw Error("invalid-coefficient");return numbers;}
	let selectedPreset="reed",last=null,active=null,renderGeneration=0;function setSelected(name){selectedPreset=name;document.querySelectorAll("[data-preset]").forEach(button=>button.setAttribute("aria-pressed",String(button.dataset.preset===name)));}
function selectPreset(name){const preset=presets[name];if(!preset)throw Error("invalid-preset");document.querySelector("#real").value=preset.real.join(", ");document.querySelector("#imag").value=preset.imag.join(", ");setSelected(name);clearDependent();}
function input(){return{real:parseCoefficients(document.querySelector("#real").value),imag:parseCoefficients(document.querySelector("#imag").value),fundamental:Number(document.querySelector("#fundamental").value),sampleRate:48000,duration:.5};}
function drawWave(samples){const canvas=document.querySelector("#wave"),ctx=canvas.getContext("2d"),mid=canvas.height/2;ctx.clearRect(0,0,canvas.width,canvas.height);ctx.strokeStyle="#5ee0e8";ctx.lineWidth=3;ctx.beginPath();for(let x=0;x<canvas.width;x++){const i=Math.floor(x/canvas.width*samples.length),y=mid-samples[i]*mid*.9;x?ctx.lineTo(x,y):ctx.moveTo(x,y);}ctx.stroke();}
function drawSpectrum(bins){const canvas=document.querySelector("#spectrum"),ctx=canvas.getContext("2d"),gap=12,width=(canvas.width-gap*(bins.length+1))/bins.length,max=Math.max(...bins.map(bin=>bin.magnitude),.001);ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle="#f5b954";bins.forEach((bin,index)=>{const height=bin.magnitude/max*(canvas.height-40);ctx.fillRect(gap+index*(width+gap),canvas.height-height,width,height);});}
function clearCanvases(){for(const id of ["wave","spectrum"]){const canvas=document.getElementById(id);canvas.getContext("2d").clearRect(0,0,canvas.width,canvas.height);}}
function wav(buffer){const samples=buffer.getChannelData(0),out=new ArrayBuffer(44+samples.length*2),view=new DataView(out),write=(o,s)=>[...s].forEach((c,i)=>view.setUint8(o+i,c.charCodeAt(0)));write(0,"RIFF");view.setUint32(4,36+samples.length*2,true);write(8,"WAVEfmt ");view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,buffer.sampleRate,true);view.setUint32(28,buffer.sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);write(36,"data");view.setUint32(40,samples.length*2,true);for(let i=0;i<samples.length;i++)view.setInt16(44+i*2,Math.max(-1,Math.min(1,samples[i]))*32767,true);return new Blob([out],{type:"audio/wav"});}
function stopPreview(){if(active){try{active.source.stop();}catch{}active.context.close();active=null;}document.querySelector("#stop").disabled=true;}
	function clearDependent(){renderGeneration++;stopPreview();last=null;for(const id of ["play","stop","wav","json"])document.getElementById(id).disabled=true;document.querySelector("#summary").textContent="";clearCanvases();return renderGeneration;}
	function needsRender(reason="controls-changed"){document.querySelector("#receipt").textContent=JSON.stringify({schema:"periodicwave-lab-v1",status:"NEEDS_RENDER",reason,exportsAvailable:false},null,2);}
	function markDirty(){clearDependent();needsRender();}
	function showFailure(error){clearDependent();document.querySelector("#receipt").textContent=JSON.stringify({schema:"periodicwave-lab-v1",status:"FAIL",error:error.message,exportsAvailable:false},null,2);}
	function requestFromControls(){const recipe=input(),metadata={preset:selectedPreset,previewVolume:Number(document.querySelector("#volume").value)};return{recipe,metadata,snapshot:JSON.stringify(canonical({recipe,metadata}))};}
	async function inspect(renderer=render){const generation=clearDependent();let request;try{request=requestFromControls();document.querySelector("#receipt").textContent=JSON.stringify({schema:"periodicwave-lab-v1",status:"RENDERING",exportsAvailable:false},null,2);const next=await renderer(request.recipe,request.metadata);if(generation!==renderGeneration)return null;let current;try{current=requestFromControls();}catch{clearDependent();needsRender("controls-invalidated-during-render");return null;}if(current.snapshot!==request.snapshot){clearDependent();needsRender("controls-changed-during-render");return null;}last=next;drawWave(next.samples);drawSpectrum(next.receipt.spectrum.bins);document.querySelector("#receipt").textContent=JSON.stringify(next.receipt,null,2);document.querySelector("#summary").textContent="Rendered "+next.receipt.sampleCount+" samples; peak "+next.receipt.measurements.peak+"; RMS "+next.receipt.measurements.rms+"; "+next.receipt.measurements.zeroCrossings+" zero crossings; "+next.receipt.spectrum.bins.length+" inspected bins.";for(const id of ["play","wav","json"])document.getElementById(id).disabled=false;return next.receipt;}catch(error){if(generation===renderGeneration)showFailure(error);return null;}}
async function play(){if(!last)return;stopPreview();const Context=globalThis.AudioContext||globalThis.webkitAudioContext,context=new Context(),source=context.createBufferSource(),gain=context.createGain();gain.gain.value=Number(document.querySelector("#volume").value);source.buffer=last.buffer;source.connect(gain).connect(context.destination);source.addEventListener("ended",()=>{if(active?.source===source){context.close();active=null;document.querySelector("#stop").disabled=true;}});active={context,source};document.querySelector("#stop").disabled=false;await context.resume();source.start();}
	function download(blob,name){const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);}document.querySelectorAll("[data-preset]").forEach(button=>button.addEventListener("click",()=>{selectPreset(button.dataset.preset);inspect();}));for(const id of ["real","imag"]){document.getElementById(id).addEventListener("input",()=>{setSelected("custom");markDirty();});}document.querySelector("#fundamental").addEventListener("input",markDirty);document.querySelector("#volume").addEventListener("input",event=>{document.querySelector("#volume-value").value=Number(event.target.value).toFixed(2);markDirty();});document.querySelector("#render").addEventListener("click",()=>inspect());document.querySelector("#play").addEventListener("click",()=>play().catch(showFailure));document.querySelector("#stop").addEventListener("click",stopPreview);document.querySelector("#wav").addEventListener("click",()=>{if(last)download(wav(last.buffer),"periodicwave-render.wav");});document.querySelector("#json").addEventListener("click",()=>{if(last)download(new Blob([JSON.stringify(last.receipt,null,2)],{type:"application/json"}),"periodicwave-recipe.json");});globalThis.JPJournalLab={validate,cull,render,analyzeSpectrum,presets,input,inspect,selectPreset,hasLast:()=>Boolean(last),renderGeneration:()=>renderGeneration};selectPreset("reed");inspect();})();</script></html>