// Magnitude spectrum of the rendered note, log-frequency axis, with the // fitted bore resonances and the harmonics of the played fundamental // overlaid. A recorder's spectrum is a harmonic comb over a breath-noise // floor: the harmonics come from the jet nonlinearity, and only the first // one or two sit on bore resonances. import { magnitudeSpectrum } from '../../lib/fft'; import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { y: Float32Array; sampleRate: number; modeF: Float32Array; fPlay: Float32Array; } const F_LO = 100; const F_HI = 24000; const DB_RANGE = 80; export default function SpectrumPlot({ y, sampleRate, modeF, fPlay }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 34; const padB = 18; const pw = w - padL - 6; const ph = h - padB - 8; const fx = (f: number) => padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; for (const f of [100, 200, 500, 1000, 2000, 5000, 10000, 20000]) { const x = fx(f); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(x, 8); ctx.lineTo(x, 8 + ph); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5); } ctx.textAlign = 'right'; for (let db = 0; db >= -DB_RANGE; db -= 20) { const yy = 8 + (-db / DB_RANGE) * ph; ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + pw, yy); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${db}`, padL - 4, yy + 3); } const { mag, size } = magnitudeSpectrum(y, 32768); let peak = 0; for (let i = 1; i < mag.length; i++) peak = Math.max(peak, mag[i]); if (peak <= 0) return; // overlays first so the trace draws on top ctx.setLineDash([4, 3]); for (let i = 0; i < modeF.length; i++) { const f = modeF[i]; if (f < F_LO || f > F_HI) continue; ctx.strokeStyle = 'rgba(232, 163, 61, 0.4)'; ctx.beginPath(); ctx.moveTo(fx(f), 8); ctx.lineTo(fx(f), 8 + ph); ctx.stroke(); } if (fPlay.length > 0 && fPlay[0] > 0) { for (let hnum = 1; hnum * fPlay[0] < F_HI; hnum++) { const f = hnum * fPlay[0]; if (f < F_LO) continue; ctx.strokeStyle = 'rgba(111, 191, 115, 0.35)'; ctx.beginPath(); ctx.moveTo(fx(f), 8); ctx.lineTo(fx(f), 8 + ph); ctx.stroke(); } } ctx.setLineDash([]); ctx.strokeStyle = PLOT_COLORS.trace; ctx.beginPath(); let started = false; for (let px = 0; px <= pw; px++) { const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO))); const bin = Math.min(mag.length - 1, Math.round((f / sampleRate) * size)); const fNext = 10 ** (Math.log10(F_LO) + ((px + 1) / pw) * (Math.log10(F_HI) - Math.log10(F_LO))); const binNext = Math.min( mag.length - 1, Math.max(bin + 1, Math.round((fNext / sampleRate) * size)), ); let m = 0; for (let b = bin; b < binNext; b++) m = Math.max(m, mag[b]); const db = 20 * Math.log10(m / peak + 1e-12); const yy = 8 + Math.min(ph, (-db / DB_RANGE) * ph); if (!started) { ctx.moveTo(padL + px, yy); started = true; } else { ctx.lineTo(padL + px, yy); } } ctx.stroke(); ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(232, 163, 61, 0.9)'; ctx.fillText('bore resonances', padL + 4, 18); ctx.fillStyle = 'rgba(111, 191, 115, 0.9)'; ctx.fillText('harmonics of the played note', padL + 94, 18); }, [y, sampleRate, modeF, fPlay], 200, ); return ; }