// The jet inflow envelope over the whole gesture: the mean flow rises with // the breath envelope, the oscillation rides on it once the note locks, and // the attack chiff is visible as the ragged region before lock. Note onsets // are marked with the frequency each note locked to. import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { qPlot: Float32Array; qPlotRate: number; noteOn: Float32Array; fPlay: Float32Array; durationS: number; } export default function BreathPlot({ qPlot, qPlotRate, noteOn, fPlay, durationS }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 40; const padB = 16; const pw = w - padL - 8; const ph = h - padB - 12; const top = 10; let qMax = 0; for (let i = 0; i < qPlot.length; i++) qMax = Math.max(qMax, qPlot[i]); if (qMax <= 0) qMax = 1; const tx = (t: number) => padL + (t / durationS) * pw; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; const tStep = durationS > 4 ? 1.0 : durationS > 1.5 ? 0.5 : 0.1; for (let t = 0; t <= durationS + 1e-9; t += tStep) { const x = tx(t); 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(`${t.toFixed(1)}s`, x, h - 4); } ctx.textAlign = 'right'; ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${(qMax * 1000).toFixed(2)} L/s`, padL - 4, top + 4); ctx.fillText('0', padL - 4, top + ph); // per-pixel min/max envelope of the decimated jet flow ctx.strokeStyle = PLOT_COLORS.trace; ctx.lineWidth = 1; for (let px = 0; px < pw; px++) { const i0 = Math.floor((px / pw) * durationS * qPlotRate); const i1 = Math.min(qPlot.length, Math.floor(((px + 1) / pw) * durationS * qPlotRate) + 1); if (i0 >= qPlot.length) break; let lo = Infinity; let hi = -Infinity; for (let i = i0; i < i1; i++) { lo = Math.min(lo, qPlot[i]); hi = Math.max(hi, qPlot[i]); } if (lo > hi) continue; const y0 = top + ph - (hi / qMax) * ph * 0.92; const y1 = top + ph - (lo / qMax) * ph * 0.92; ctx.beginPath(); ctx.moveTo(padL + px + 0.5, y0); ctx.lineTo(padL + px + 0.5, Math.max(y0 + 0.5, y1)); ctx.stroke(); } // note onsets with locked frequency ctx.textAlign = 'left'; for (let i = 0; i < noteOn.length; i++) { const x = tx(noteOn[i]); ctx.strokeStyle = 'rgba(232, 163, 61, 0.6)'; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + ph); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = PLOT_COLORS.accent; const f = fPlay[i]; ctx.fillText(f > 0 ? `${f.toFixed(0)} Hz` : 'did not speak', x + 3, top + 10); } }, [qPlot, qPlotRate, noteOn, fPlay, durationS], 150, ); return ; }