diff --git a/src/components/mixer/ConvolverIRCurve.tsx b/src/components/mixer/ConvolverIRCurve.tsx new file mode 100644 index 00000000..190c992e --- /dev/null +++ b/src/components/mixer/ConvolverIRCurve.tsx @@ -0,0 +1,265 @@ +/** + * ConvolverIRCurve — Impulse response waveform visualization for convolution reverb. + * Shows pre-delay gap, early reflection region, and decay tail. + * Inspired by FabFilter Pro-R and Altiverb IR displays. + */ +import { useRef, useEffect } from 'react'; +import { + generateIREnvelope, + getIRReflections, + getERBoundary, + getIRLength, +} from '../../utils/convolverIR'; +import { fillBackground, GRID_COLOR, LABEL_AREA_BG } from '../../utils/canvasTheme'; +import type { FactoryIRType } from '../../types/project'; + +interface ConvolverIRCurveProps { + irType: FactoryIRType; + preDelay: number; // Pre-delay in ms + wet: number; // Dry/wet mix 0–1 + width?: number; + height?: number; + color: string; +} + +export function ConvolverIRCurve({ + irType, + preDelay, + wet, + width = 160, + height = 100, + color, +}: ConvolverIRCurveProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + } + + const labelH = 13; + const drawH = height - labelH; + const PAD_LEFT = 2; + const totalLength = getIRLength(irType, preDelay); + + const xForT = (t: number) => PAD_LEFT + ((t / totalLength) * (width - PAD_LEFT)); + const PAD_TOP = 2; + const yForAmp = (a: number) => PAD_TOP + (drawH - PAD_TOP * 2) * (1 - a * 0.85); + + // ── Background ────────────────────────────────────────────────────────── + ctx.clearRect(0, 0, width, height); + fillBackground(ctx, width, height); + + // Label area + ctx.fillStyle = LABEL_AREA_BG; + ctx.fillRect(0, drawH, width, labelH); + + // ── Grid ──────────────────────────────────────────────────────────────── + ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)'; + ctx.lineWidth = 0.5; + for (const amp of [0.75, 0.5, 0.25]) { + const y = yForAmp(amp); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + // Time labels + const gridStep = totalLength <= 0.5 ? 0.1 : totalLength <= 1.5 ? 0.25 : totalLength <= 3 ? 0.5 : 1; + ctx.font = '7px monospace'; + for (let t = gridStep; t < totalLength - gridStep * 0.3; t += gridStep) { + const x = xForT(t); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, drawH); + ctx.stroke(); + // Tick + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, drawH); + ctx.lineTo(x, drawH + 3); + ctx.stroke(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.textAlign = 'center'; + const label = totalLength <= 0.5 ? `${(t * 1000).toFixed(0)}ms` : `${t.toFixed(1)}s`; + ctx.fillText(label, x, height - 2); + } + + // ── Pre-delay region ──────────────────────────────────────────────────── + const preDelayS = preDelay / 1000; + if (preDelayS > 0.001) { + const pdX = xForT(preDelayS); + ctx.fillStyle = 'rgba(255, 255, 255, 0.02)'; + ctx.fillRect(PAD_LEFT, 0, pdX - PAD_LEFT, drawH); + ctx.strokeStyle = `${color}50`; + ctx.lineWidth = 0.5; + ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(pdX, 2); + ctx.lineTo(pdX, drawH - 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // ── ER boundary marker ────────────────────────────────────────────────── + const erBoundary = getERBoundary(irType, preDelay); + const erX = xForT(erBoundary); + if (erX > PAD_LEFT + 5 && erX < width - 10) { + ctx.strokeStyle = `${color}30`; + ctx.lineWidth = 0.5; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(erX, 2); + ctx.lineTo(erX, drawH - 2); + ctx.stroke(); + ctx.setLineDash([]); + // ER label + ctx.font = '6px monospace'; + ctx.fillStyle = `${color}60`; + ctx.textAlign = 'left'; + ctx.fillText('ER', erX + 2, 9); + ctx.fillText('TAIL', erX + 2, 17); + } + + // ── IR envelope fill ──────────────────────────────────────────────────── + const pts = generateIREnvelope(irType, preDelay, 200); + + // Waveform-style: draw symmetric around center for bipolar look + const centerY = drawH * 0.5 + 2; + + // Fill area (top half) + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const x = xForT(pts[i].t); + const halfH = pts[i].amplitude * drawH * 0.42 * wet; + if (i === 0) ctx.moveTo(x, centerY - halfH); + else ctx.lineTo(x, centerY - halfH); + } + // Bottom half (mirror) + for (let i = pts.length - 1; i >= 0; i--) { + const x = xForT(pts[i].t); + const halfH = pts[i].amplitude * drawH * 0.42 * wet; + ctx.lineTo(x, centerY + halfH); + } + ctx.closePath(); + + const fillGrad = ctx.createLinearGradient(0, centerY - drawH * 0.4, 0, centerY + drawH * 0.4); + fillGrad.addColorStop(0, `${color}35`); + fillGrad.addColorStop(0.5, `${color}18`); + fillGrad.addColorStop(1, `${color}35`); + ctx.fillStyle = fillGrad; + ctx.fill(); + + // ── IR envelope stroke (top edge with glow) ───────────────────────────── + ctx.save(); + ctx.shadowBlur = 4; + ctx.shadowColor = `${color}60`; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const x = xForT(pts[i].t); + const halfH = pts[i].amplitude * drawH * 0.42 * wet; + if (i === 0) ctx.moveTo(x, centerY - halfH); + else ctx.lineTo(x, centerY - halfH); + } + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.stroke(); + // Bottom edge + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const x = xForT(pts[i].t); + const halfH = pts[i].amplitude * drawH * 0.42 * wet; + if (i === 0) ctx.moveTo(x, centerY + halfH); + else ctx.lineTo(x, centerY + halfH); + } + ctx.strokeStyle = `${color}80`; + ctx.lineWidth = 0.75; + ctx.stroke(); + ctx.restore(); + + // ── Early reflection spikes ───────────────────────────────────────────── + const reflections = getIRReflections(irType, preDelay); + for (let i = 0; i < reflections.length; i++) { + const { t, amplitude } = reflections[i]; + if (t > totalLength) break; + + const x = xForT(t); + const spikeH = amplitude * drawH * 0.42 * wet; + + // Vertical spike line (both directions from center) + const spikeGrad = ctx.createLinearGradient(x, centerY, x, centerY - spikeH); + spikeGrad.addColorStop(0, `${color}00`); + spikeGrad.addColorStop(0.5, `${color}90`); + spikeGrad.addColorStop(1, `${color}ff`); + ctx.strokeStyle = spikeGrad; + ctx.lineWidth = i < 3 ? 1.5 : 1; + + // Top spike + ctx.beginPath(); + ctx.moveTo(x, centerY); + ctx.lineTo(x, centerY - spikeH); + ctx.stroke(); + // Bottom spike + ctx.beginPath(); + ctx.moveTo(x, centerY); + ctx.lineTo(x, centerY + spikeH); + ctx.stroke(); + + // Tip dots + if (i < 4) { + const dotR = i === 0 ? 2 : 1.5; + ctx.beginPath(); + ctx.arc(x, centerY - spikeH, dotR, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + } + } + + // ── Center line ───────────────────────────────────────────────────────── + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(PAD_LEFT, centerY); + ctx.lineTo(width, centerY); + ctx.stroke(); + + // ── Type badge ────────────────────────────────────────────────────────── + const badgeLabels: Record = { + smallRoom: 'ROOM', + largeHall: 'HALL', + plate: 'PLATE', + spring: 'SPRING', + }; + const badge = badgeLabels[irType]; + ctx.font = '7px monospace'; + const badgeW = ctx.measureText(badge).width + 6; + ctx.fillStyle = `${color}20`; + ctx.beginPath(); + ctx.roundRect(width - badgeW - 2, height - 12, badgeW, 10, 2); + ctx.fill(); + ctx.fillStyle = `${color}cc`; + ctx.textAlign = 'center'; + ctx.fillText(badge, width - badgeW / 2 - 2, height - 4); + }, [irType, preDelay, wet, width, height, color]); + + return ( + + ); +} diff --git a/src/utils/__tests__/convolverIR.test.ts b/src/utils/__tests__/convolverIR.test.ts new file mode 100644 index 00000000..d7a3a746 --- /dev/null +++ b/src/utils/__tests__/convolverIR.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + generateIREnvelope, + getIRReflections, + getERBoundary, + getIRLength, +} from '../convolverIR'; + +describe('convolverIR', () => { + describe('generateIREnvelope', () => { + it('returns correct number of points', () => { + const pts = generateIREnvelope('smallRoom', 0, 100); + expect(pts).toHaveLength(101); // 0..100 inclusive + }); + + it('starts at zero amplitude during pre-delay', () => { + const pts = generateIREnvelope('largeHall', 50, 200); + const preDelayPts = pts.filter((p) => p.t < 0.05); + for (const p of preDelayPts) { + expect(p.amplitude).toBe(0); + } + }); + + it('decays over time after pre-delay', () => { + const pts = generateIREnvelope('smallRoom', 0, 200); + const earlyAmp = pts[10].amplitude; + const lateAmp = pts[180].amplitude; + expect(earlyAmp).toBeGreaterThan(lateAmp); + }); + + it('amplitude stays within 0–1 range', () => { + for (const irType of ['smallRoom', 'largeHall', 'plate', 'spring'] as const) { + const pts = generateIREnvelope(irType, 20); + for (const p of pts) { + expect(p.amplitude).toBeGreaterThanOrEqual(0); + expect(p.amplitude).toBeLessThanOrEqual(1); + } + } + }); + }); + + describe('getIRReflections', () => { + it('returns early reflection spikes', () => { + const reflections = getIRReflections('largeHall', 0); + expect(reflections.length).toBeGreaterThan(0); + expect(reflections.length).toBeLessThanOrEqual(12); + }); + + it('offsets spikes by pre-delay', () => { + const withoutPD = getIRReflections('plate', 0); + const withPD = getIRReflections('plate', 50); + for (let i = 0; i < withoutPD.length; i++) { + expect(withPD[i].t).toBeGreaterThan(withoutPD[i].t); + } + }); + + it('reflection amplitudes decrease over time', () => { + const reflections = getIRReflections('smallRoom', 0); + for (let i = 1; i < reflections.length; i++) { + expect(reflections[i].amplitude).toBeLessThan(reflections[i - 1].amplitude); + } + }); + }); + + describe('getERBoundary', () => { + it('returns positive value for all IR types', () => { + for (const irType of ['smallRoom', 'largeHall', 'plate', 'spring'] as const) { + expect(getERBoundary(irType, 0)).toBeGreaterThan(0); + } + }); + + it('includes pre-delay offset', () => { + const boundary0 = getERBoundary('largeHall', 0); + const boundary50 = getERBoundary('largeHall', 50); + expect(boundary50 - boundary0).toBeCloseTo(0.05, 2); // 50ms = 0.05s + }); + }); + + describe('getIRLength', () => { + it('largeHall is longer than smallRoom', () => { + expect(getIRLength('largeHall', 0)).toBeGreaterThan(getIRLength('smallRoom', 0)); + }); + + it('includes pre-delay in total length', () => { + const base = getIRLength('plate', 0); + const withPD = getIRLength('plate', 100); + expect(withPD - base).toBeCloseTo(0.1, 2); // 100ms = 0.1s + }); + }); +}); diff --git a/src/utils/__tests__/limiterCurve.test.ts b/src/utils/__tests__/limiterCurve.test.ts new file mode 100644 index 00000000..849eae72 --- /dev/null +++ b/src/utils/__tests__/limiterCurve.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { generateLimiterCurve } from '../limiterCurve'; + +describe('limiterCurve', () => { + describe('generateLimiterCurve', () => { + it('returns correct number of points', () => { + const pts = generateLimiterCurve(-0.3, 0, 'transparent', -48, 6, 100); + expect(pts).toHaveLength(101); + }); + + it('output never exceeds ceiling', () => { + const ceiling = -0.3; + const pts = generateLimiterCurve(ceiling, 12, 'aggressive'); + for (const p of pts) { + expect(p.y).toBeLessThanOrEqual(ceiling + 0.01); + } + }); + + it('below threshold, output equals input + gain', () => { + const pts = generateLimiterCurve(0, 0, 'transparent', -48, 6, 200); + // Well below ceiling, output should track input + const lowPt = pts.find((p) => p.x === -48); + expect(lowPt).toBeDefined(); + expect(lowPt!.y).toBeCloseTo(-48, 0); + }); + + it('gain shifts the transfer curve', () => { + const noGain = generateLimiterCurve(-0.3, 0, 'transparent'); + const withGain = generateLimiterCurve(-0.3, 6, 'transparent'); + // At a low input level, gained output should be higher + const idx = 10; + expect(withGain[idx].y).toBeGreaterThan(noGain[idx].y); + }); + + it('warm and aggressive styles produce different curves', () => { + const warm = generateLimiterCurve(-1, 6, 'warm'); + const aggressive = generateLimiterCurve(-1, 6, 'aggressive'); + + // Both should have the same number of points + expect(warm).toHaveLength(aggressive.length); + + // They should differ in the knee/limiting region + let hasDifference = false; + for (let i = 0; i < warm.length; i++) { + if (Math.abs(warm[i].y - aggressive[i].y) > 0.01) { + hasDifference = true; + break; + } + } + expect(hasDifference).toBe(true); + }); + + it('all styles limit to ceiling', () => { + for (const style of ['transparent', 'aggressive', 'warm'] as const) { + const pts = generateLimiterCurve(-0.5, 12, style); + const lastPt = pts[pts.length - 1]; + expect(lastPt.y).toBeLessThanOrEqual(-0.5 + 0.01); + } + }); + + it('output never exceeds input + gain (no expansion)', () => { + for (const style of ['transparent', 'aggressive', 'warm'] as const) { + const gain = 6; + const pts = generateLimiterCurve(-0.3, gain, style); + for (const p of pts) { + expect(p.y).toBeLessThanOrEqual(p.x + gain + 0.01); + } + } + }); + }); +}); diff --git a/src/utils/convolverIR.ts b/src/utils/convolverIR.ts new file mode 100644 index 00000000..a34ac582 --- /dev/null +++ b/src/utils/convolverIR.ts @@ -0,0 +1,106 @@ +/** + * convolverIR.ts — Pure math for convolver impulse response visualization. + * + * Generates a synthetic IR envelope for each factory IR type, + * with early-reflection and tail regions marked. + */ + +import type { FactoryIRType } from '../types/project'; + +export interface IREnvelopePoint { + t: number; // Time in seconds + amplitude: number; // 0–1 +} + +/** IR profile: early reflection pattern + decay characteristics */ +interface IRProfile { + /** Early reflection end time (seconds) */ + erEnd: number; + /** Total IR length (seconds) */ + length: number; + /** ER density (number of early spikes) */ + erCount: number; + /** Decay rate (higher = faster) */ + decayRate: number; +} + +const IR_PROFILES: Record = { + smallRoom: { erEnd: 0.03, length: 0.4, erCount: 8, decayRate: 12 }, + largeHall: { erEnd: 0.08, length: 2.5, erCount: 12, decayRate: 2.5 }, + plate: { erEnd: 0.01, length: 1.5, erCount: 4, decayRate: 3.5 }, + spring: { erEnd: 0.02, length: 1.0, erCount: 6, decayRate: 4.0 }, +}; + +/** + * Generate synthetic IR envelope for visualization. + * Returns points representing the IR waveform shape. + * + * @param irType Factory IR preset type + * @param preDelay Pre-delay in milliseconds (converted to seconds internally) + */ +export function generateIREnvelope( + irType: FactoryIRType, + preDelay: number, + /** Number of intervals; returns steps + 1 points including both endpoints */ + steps: number = 160, +): IREnvelopePoint[] { + const profile = IR_PROFILES[irType]; + const preDelayS = preDelay / 1000; // ms → seconds + const totalLength = preDelayS + profile.length; + const points: IREnvelopePoint[] = []; + + for (let i = 0; i <= steps; i++) { + const t = (totalLength * i) / steps; + const tAfterPD = t - preDelayS; + + if (tAfterPD < 0) { + points.push({ t, amplitude: 0 }); + continue; + } + + // Exponential decay envelope + const decay = Math.exp(-tAfterPD * profile.decayRate); + // Add slight random-looking variation using deterministic sine waves + const variation = 1 + 0.15 * Math.sin(tAfterPD * 47) * Math.sin(tAfterPD * 23); + const amplitude = Math.max(0, decay * variation); + + points.push({ t, amplitude: Math.min(1, amplitude) }); + } + + return points; +} + +/** + * Get early reflection spike positions for the IR type. + * Returns array of {time, amplitude} for ER spikes. + */ +export function getIRReflections( + irType: FactoryIRType, + preDelay: number, +): Array<{ t: number; amplitude: number }> { + const profile = IR_PROFILES[irType]; + const preDelayS = preDelay / 1000; + const spikes: Array<{ t: number; amplitude: number }> = []; + + for (let i = 0; i < profile.erCount; i++) { + const fraction = (i + 1) / (profile.erCount + 1); + const t = preDelayS + fraction * profile.erEnd; + // Amplitude decreases with each reflection + const amplitude = 0.95 * Math.pow(0.72, i); + spikes.push({ t, amplitude }); + } + + return spikes; +} + +/** Get the ER boundary time for drawing the region separator */ +export function getERBoundary(irType: FactoryIRType, preDelay: number): number { + const profile = IR_PROFILES[irType]; + return preDelay / 1000 + profile.erEnd; +} + +/** Get total IR display length in seconds */ +export function getIRLength(irType: FactoryIRType, preDelay: number): number { + const profile = IR_PROFILES[irType]; + return preDelay / 1000 + profile.length; +}