"use client"; import { useRef, useState, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; type ToneKeyProps = { children: ReactNode; className?: string; onClick?: () => void; /** Notes played in turn, one per press. Letter, optional #, octave. */ notes?: string[]; /** Keeps the key silent. */ muted?: boolean; }; type Glyph = { id: number; drift: number }; const STEPS: Record = { C: -9, D: -7, E: -5, F: -4, G: -2, A: 0, B: 2 }; function toHz(note: string) { const m = /^([A-G])(#?)(\d)$/.exec(note); if (!m) return 440; const fromA4 = STEPS[m[1]] + (m[2] ? 1 : 0) + (Number(m[3]) - 4) * 12; return 440 * 2 ** (fromA4 / 12); } let audio: AudioContext | undefined; function play(hz: number) { audio ??= new AudioContext(); const now = audio.currentTime; const osc = audio.createOscillator(); const gain = audio.createGain(); osc.type = "triangle"; osc.frequency.value = hz; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.22, now + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.9); osc.connect(gain).connect(audio.destination); osc.start(now); osc.stop(now + 0.92); void audio.resume(); } /* ToneKey: one ivory piano key as a button. Each press dips the key and plays the next note. */ export function ToneKey({ children, className = "min-w-44 rounded-b-lg px-8 pb-4 pt-12 text-[15px] font-semibold text-[#2a2620]", onClick, notes = ["C4", "E4", "G4", "C5"], muted = false, }: ToneKeyProps) { const reduce = useReducedMotion(); const [down, setDown] = useState(false); const [glyphs, setGlyphs] = useState([]); const turn = useRef(0); const next = useRef(0); const strike = () => { if (!muted && notes.length) play(toHz(notes[turn.current++ % notes.length])); if (reduce) return; const id = next.current++; setGlyphs((g) => [...g, { id, drift: id % 2 ? 18 : -18 }]); }; return ( {glyphs.map((g) => ( setGlyphs((all) => all.filter((o) => o.id !== g.id))} > ♪ ))} { setDown(true); strike(); }} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setDown(true)} onKeyUp={() => setDown(false)} onBlur={() => setDown(false)} onClick={(e) => { // detail 0 = Enter or Space. A pointer already struck on the way down. if (e.detail === 0) strike(); onClick?.(); }} animate={{ rotateX: down && !reduce ? -7 : 0, boxShadow: down ? "0 2px 0 #b9b19e, 0 3px 6px rgba(0,0,0,0.4)" : "0 7px 0 #b9b19e, 0 12px 16px rgba(0,0,0,0.45)", }} transition={{ type: "spring", stiffness: 800, damping: 30 }} className={`relative block origin-top bg-gradient-to-b from-[#f7f2e6] to-[#ebe3d0] ${className}`} > {children} ); }