// The bridge admittance the string is loaded by: the modal sum over the body // table, |Y(om)| = |sum_j i om a_j^2 / (om_j^2 - om^2 + 2 i zeta_j om_j om)|, // plus the resistive residual floor. This is the wolf-hunting plot: the // fundamentals of the gesture are overlaid, and a fundamental sitting on a // tall peak is a note the body will pull, split, and eat quickly. import { midiHz } from '../../params'; import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { bodyF: Float32Array; bodyZeta: Float32Array; bodyA: Float32Array; bodyMono: Float32Array; noteMidi: Float32Array; tuningTrim: number; residualY: number; } const F_LO = 30; const F_HI = 2000; const Y_LO = 1e-4; const Y_HI = 1e-1; export default function AdmittancePlot({ bodyF, bodyZeta, bodyA, bodyMono, noteMidi, tuningTrim, residualY, }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 44; const padB = 18; const pw = w - padL - 8; const ph = h - padB - 10; const top = 10; const fx = (f: number) => padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw; const yy = (v: number) => top + ph - ((Math.log10(v) - Math.log10(Y_LO)) / (Math.log10(Y_HI) - Math.log10(Y_LO))) * ph; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; for (const f of [30, 50, 100, 200, 500, 1000, 2000]) { const x = fx(f); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + ph); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5); } ctx.textAlign = 'right'; for (let e = -4; e <= -1; e++) { const v = 10 ** e; const y = yy(v); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`1e${e}`, padL - 4, y + 3); } // fundamentals of the gesture (deduplicated) const seen = new Set(); ctx.setLineDash([4, 3]); for (let i = 0; i < noteMidi.length; i++) { const m = Math.round(noteMidi[i]); if (seen.has(m)) continue; seen.add(m); const f = midiHz(m) * tuningTrim; if (f < F_LO || f > F_HI) continue; ctx.strokeStyle = 'rgba(111, 191, 115, 0.55)'; ctx.beginPath(); ctx.moveTo(fx(f), top); ctx.lineTo(fx(f), top + ph); ctx.stroke(); } ctx.setLineDash([]); // |Y| trace ctx.strokeStyle = PLOT_COLORS.accent; ctx.beginPath(); 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 om = 2 * Math.PI * f; let re = residualY; let im = 0; for (let j = 0; j < bodyF.length; j++) { const omj = 2 * Math.PI * bodyF[j]; const a2 = bodyA[j] * bodyA[j]; const dr = omj * omj - om * om; const di = 2 * bodyZeta[j] * omj * om; // i om a^2 / (dr + i di) const den = dr * dr + di * di; re += (om * a2 * di) / den; im += (om * a2 * dr) / den; } const mag = Math.max(Y_LO, Math.min(Y_HI, Math.hypot(re, im))); const x = padL + px; const y = yy(mag); if (px === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // label the peaks: A0 and the first corpus mode ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'left'; if (bodyF.length > 0 && bodyMono[0] > 0) { ctx.fillText('A0', fx(bodyF[0]) + 3, top + 12); } ctx.fillStyle = PLOT_COLORS.accent; ctx.fillText('|Y| at the bridge (s/kg)', padL + 6, top + 2); ctx.fillStyle = 'rgba(111, 191, 115, 0.9)'; ctx.fillText('ยท note fundamentals', padL + 130, top + 2); }, [bodyF, bodyZeta, bodyA, bodyMono, noteMidi, tuningTrim, residualY], 190, ); return ; }