"use client"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "motion/react"; type StringHarpProps = { /** Frequency of each string in Hz, longest string first. */ notes?: number[]; /** Play the strings. Browsers stay silent until the first press or key. */ sound?: boolean; /** Called with the string index on every pluck. */ onPluck?: (index: number) => void; /** Read out by screen readers. */ label?: string; className?: string; }; const W = 440; const H = 340; const FIRST = 98; // Longest string. const LAST = 340; // Shortest string. const D_PENTATONIC = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26, 28].map((s) => 146.83 * 2 ** (s / 12)); const ease = (t: number) => t * t * (3 - 2 * t); const neckY = (x: number) => { const t = (x - 50) / 345; return 36 + 80 * ease(Math.min(1, t)) - 18 * Math.sin(Math.PI * t); }; const boxY = (x: number) => 288 - (x - 60) * 0.5; const curve = (off: number) => (x: number, peak = 0.5) => (a: number, b: number) => { const y = a + (b - a) * peak; return `M${x} ${a}Q${x + off * 2} ${y} ${x} ${b}`; }; const NECK = (() => { const top: string[] = []; const low: string[] = []; for (let x = 44; x <= 398; x += 6) { const y = neckY(x); const thick = 7 + 5 * ((x - 44) / 354); top.push(`${x} ${(y - thick).toFixed(1)}`); low.unshift(`${x} ${(y + thick).toFixed(1)}`); } return `M${top.join("L")}L${low.join("L")}Z`; })(); /* A short bright noise burst fed through a decaying loop: the Karplus-Strong pluck. */ function pluckBuffer(audio: AudioContext, hz: number) { const rate = audio.sampleRate; const size = Math.round(rate / hz); const length = Math.floor(rate * 1.8); const buffer = audio.createBuffer(1, length, rate); const out = buffer.getChannelData(0); const ring = new Float32Array(size); let soft = 0; for (let i = 0; i < size; i++) { soft = soft * 0.5 + (Math.random() * 2 - 1) * 0.5; ring[i] = soft; } for (let n = 0; n < length; n++) { const i = n % size; out[n] = ring[i]; ring[i] = (ring[i] + ring[(i + 1) % size]) * 0.4985; } return buffer; } type Engine = { pluck: (i: number, push: number) => void; move: (x: number, y: number) => void; leave: () => void; unlock: () => void }; /* StringHarp: a small walnut lever harp. Sweep across the strings and each one bends, shivers and rings at its own pitch; red strings mark the octaves like on a real harp. */ export function StringHarp({ notes = D_PENTATONIC, sound = true, onPluck, label = "Harp", className = "" }: StringHarpProps) { const reduce = useReducedMotion(); const svg = useRef(null); const lines = useRef<(SVGPathElement | null)[]>([]); const hits = useRef<(SVGRectElement | null)[]>([]); const engine = useRef(null); const callback = useRef(onPluck); const [focus, setFocus] = useState(0); const [armed, setArmed] = useState(false); const n = notes.length; const xAt = (i: number) => (n > 1 ? FIRST + (i * (LAST - FIRST)) / (n - 1) : (FIRST + LAST) / 2); useEffect(() => { callback.current = onPluck; }, [onPluck]); useEffect(() => { const xs = notes.map((_, i) => (n > 1 ? FIRST + (i * (LAST - FIRST)) / (n - 1) : (FIRST + LAST) / 2)); const off = new Float32Array(n); const vel = new Float32Array(n); const buffers = new Map(); let audio: AudioContext | undefined; let unlocked = false; let raf = 0; let then = 0; let lastX: number | null = null; const draw = (i: number) => { lines.current[i]?.setAttribute("d", curve(off[i])(xs[i])(neckY(xs[i]) + 2, boxY(xs[i]) - 3)); }; const tick = (now: number) => { const dt = Math.min(0.033, (now - then) / 1000 || 0.016); then = now; let moving = false; for (let i = 0; i < n; i++) { if (off[i] === 0 && vel[i] === 0) continue; const k = 1500 + 1700 * (i / n); // Shorter strings shiver faster. const steps = 4; for (let s = 0; s < steps; s++) { vel[i] += (-k * off[i] - 5.5 * vel[i]) * (dt / steps); off[i] += vel[i] * (dt / steps); } if (Math.abs(off[i]) < 0.04 && Math.abs(vel[i]) < 2) { off[i] = 0; vel[i] = 0; } else moving = true; draw(i); } raf = moving ? requestAnimationFrame(tick) : 0; }; const ring = (i: number, push: number) => { if (!sound || !unlocked || !audio) return; const hz = notes[i]; let buffer = buffers.get(hz); if (!buffer) { buffer = pluckBuffer(audio, hz); buffers.set(hz, buffer); } const source = audio.createBufferSource(); const gain = audio.createGain(); source.buffer = buffer; gain.gain.value = 0.28 + Math.min(0.42, Math.abs(push) * 0.035); source.connect(gain).connect(audio.destination); source.start(); }; const pluck = (i: number, push: number) => { ring(i, push); callback.current?.(i); if (reduce) { lines.current[i]?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 320 }); return; } off[i] = push; vel[i] = 0; if (!raf) { then = performance.now(); raf = requestAnimationFrame(tick); } }; engine.current = { pluck, move(x, y) { if (lastX !== null && lastX !== x) { const dir = Math.sign(x - lastX); const push = dir * (4 + Math.min(9, Math.abs(x - lastX) * 0.3)); const order = xs.map((_, i) => i).filter((i) => (lastX! - xs[i]) * (x - xs[i]) <= 0 && y > neckY(xs[i]) && y < boxY(xs[i])); if (dir < 0) order.reverse(); order.forEach((i) => pluck(i, push)); } lastX = x; }, leave() { lastX = null; }, unlock() { if (!sound) return; audio ??= new AudioContext(); void audio.resume(); unlocked = true; setArmed(true); }, }; return () => { cancelAnimationFrame(raf); engine.current = null; void audio?.close(); }; }, [notes, n, sound, reduce]); const local = (e: React.PointerEvent) => { const box = svg.current!.getBoundingClientRect(); return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H }; }; const onDown = (e: React.PointerEvent) => { const { x, y } = local(e); engine.current?.unlock(); let near = -1; for (let i = 0; i < n; i++) if (Math.abs(xAt(i) - x) < 9 && y > neckY(xAt(i)) && y < boxY(xAt(i))) near = i; if (near >= 0) engine.current?.pluck(near, 8 * (x < xAt(near) ? -1 : 1)); engine.current?.leave(); engine.current?.move(x, y); }; const onKey = (i: number) => (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); engine.current?.unlock(); engine.current?.pluck(i, 9); } const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (step) { e.preventDefault(); const next = (i + step + n) % n; setFocus(next); hits.current[next]?.focus(); } }; return ( e.preventDefault()} onPointerMove={(e) => { const { x, y } = local(e); engine.current?.move(x, y); }} onPointerLeave={() => engine.current?.leave()} > {/* Sound box, then the spruce face the strings end on. */} {[150, 225, 300].map((x) => ( ))} {/* Strings sit under the neck so the pins cover their tops. */} {notes.map((_, i) => { const x = xAt(i); const top = neckY(x); const bottom = boxY(x); const octave = i % 5 === 0; return ( { lines.current[i] = el; }} d={curve(0)(x)(top + 2, bottom - 3)} fill="none" stroke={octave ? "#c5352c" : "#ecd9a8"} strokeWidth={2.1 - (i / n) * 0.8} strokeLinecap="round" /> ); })} {/* Neck, pins, pillar. */} {notes.map((_, i) => ( ))} {sound && !armed && ( Press for sound )} {/* One focus stop per string; arrow keys walk along them. */} {notes.map((_, i) => { const x = xAt(i); const top = neckY(x); return ( { hits.current[i] = el; }} x={x - 7} y={top} width="14" height={boxY(x) - top} fill="transparent" role="button" aria-label={`String ${i + 1} of ${n}`} tabIndex={i === focus ? 0 : -1} onFocus={() => setFocus(i)} onKeyDown={onKey(i)} /> ); })} ); }