Next.js Piano
March 23, 2025
8 min read
piano
A piano app using next.js
used AI on some cases, not real app, but works
typescript
"use client"
import { useState, useEffect, useRef } from "react"
import { Slider } from "@/components/ui/slider"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Volume2, Music, AudioWaveformIcon as Waveform } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function EnhancedPiano() {
const [octave, setOctave] = useState(4)
const [volume, setVolume] = useState(0.5)
const [sustain, setSustain] = useState(false)
const [soundType, setSoundType] = useState("sine")
const [attack, setAttack] = useState(0.05)
const [release, setRelease] = useState(0.2)
const [reverb, setReverb] = useState(0.2)
const [showKeyLabels, setShowKeyLabels] = useState(true)
const audioContextRef = useRef<AudioContext | null>(null)
const oscillatorsRef = useRef<Record<string, OscillatorNode>>({})
const gainsRef = useRef<Record<string, GainNode>>({})
const reverbNodeRef = useRef<ConvolverNode | null>(null)
const whiteKeys = ["C", "D", "E", "F", "G", "A", "B"]
const blackKeys = ["C#", "D#", "F#", "G#", "A#"]
const keyboardMap: Record<string, string> = {
z: "C",
s: "C#",
x: "D",
d: "D#",
c: "E",
v: "F",
g: "F#",
b: "G",
h: "G#",
n: "A",
j: "A#",
m: "B",
",": "C5",
l: "C#5",
".": "D5",
";": "D#5",
"/": "E5",
a: "F4",
w: "F#4",
q: "G4",
2: "G#4",
"1": "A4",
"3": "A#4",
"4": "B4",
"5": "C5"
}
const getFrequency = (note: string): number => {
const notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
let octaveNum = octave
if (note.length > 1 && !isNaN(Number.parseInt(note.slice(-1)))) {
octaveNum = Number.parseInt(note.slice(-1))
note = note.slice(0, -1)
}
const noteIndex = notes.indexOf(note)
if (noteIndex === -1) return 440
const semitoneFromA4 = noteIndex - notes.indexOf("A") + (octaveNum - 4) * 12
return 440 * Math.pow(2, semitoneFromA4 / 12)
}
const createReverbImpulse = (audioContext: AudioContext, duration = 2, decay = 2): AudioBuffer => {
const sampleRate = audioContext.sampleRate
const length = sampleRate * duration
const impulse = audioContext.createBuffer(2, length, sampleRate)
const impulseL = impulse.getChannelData(0)
const impulseR = impulse.getChannelData(1)
for (let i = 0; i < length; i++) {
const n = i / length
const envelope = Math.pow(1 - n, decay)
impulseL[i] = (Math.random() * 2 - 1) * envelope
impulseR[i] = (Math.random() * 2 - 1) * envelope
}
return impulse
}
useEffect(() => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()
const reverbNode = audioContextRef.current.createConvolver()
reverbNode.buffer = createReverbImpulse(audioContextRef.current, 2, 2)
reverbNodeRef.current = reverbNode
}
if (reverbNodeRef.current && audioContextRef.current) {
reverbNodeRef.current.buffer = createReverbImpulse(audioContextRef.current, 1 + reverb * 3, 1 + reverb * 3)
}
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase()
if (keyboardMap[key] && !oscillatorsRef.current[key]) {
playNote(keyboardMap[key], key)
}
}
const handleKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase()
if (keyboardMap[key] && oscillatorsRef.current[key]) {
if (!sustain) {
stopNote(key)
}
}
}
window.addEventListener("keydown", handleKeyDown)
window.addEventListener("keyup", handleKeyUp)
return () => {
window.removeEventListener("keydown", handleKeyDown)
window.removeEventListener("keyup", handleKeyUp)
Object.keys(oscillatorsRef.current).forEach((key) => {
stopNote(key)
})
}
}, [octave, sustain, soundType, volume, reverb, attack, release])
const playNote = (note: string, keyId: string) => {
if (!audioContextRef.current) return
const ctx = audioContextRef.current
const now = ctx.currentTime
const oscillator = ctx.createOscillator()
oscillator.type = soundType as OscillatorType
oscillator.frequency.setValueAtTime(getFrequency(note), now)
const gainNode = ctx.createGain()
gainNode.gain.setValueAtTime(0, now)
gainNode.gain.linearRampToValueAtTime(volume, now + attack)
const mixerNode = ctx.createGain()
mixerNode.gain.value = 1
const dryNode = ctx.createGain()
dryNode.gain.value = 1 - reverb
const wetNode = ctx.createGain()
wetNode.gain.value = reverb
oscillator.connect(gainNode)
gainNode.connect(mixerNode)
mixerNode.connect(dryNode)
dryNode.connect(ctx.destination)
if (reverbNodeRef.current) {
mixerNode.connect(wetNode)
wetNode.connect(reverbNodeRef.current)
reverbNodeRef.current.connect(ctx.destination)
}
oscillator.start()
oscillatorsRef.current[keyId] = oscillator
gainsRef.current[keyId] = gainNode
if (soundType !== "sine") {
const detunedOsc = ctx.createOscillator()
detunedOsc.type = soundType as OscillatorType
detunedOsc.frequency.setValueAtTime(getFrequency(note), now)
detunedOsc.detune.setValueAtTime(5, now)
const detunedGain = ctx.createGain()
detunedGain.gain.setValueAtTime(0, now)
detunedGain.gain.linearRampToValueAtTime(volume * 0.5, now + attack)
detunedOsc.connect(detunedGain)
detunedGain.connect(mixerNode)
detunedOsc.start()
oscillatorsRef.current[keyId + "_detune"] = detunedOsc
gainsRef.current[keyId + "_detune"] = detunedGain
}
}
const stopNote = (keyId: string) => {
if (!gainsRef.current[keyId] || !audioContextRef.current) return
const ctx = audioContextRef.current
const now = ctx.currentTime
const gain = gainsRef.current[keyId]
gain.gain.cancelScheduledValues(now)
gain.gain.setValueAtTime(gain.gain.value, now)
gain.gain.linearRampToValueAtTime(0, now + release)
if (gainsRef.current[keyId + "_detune"]) {
const detuneGain = gainsRef.current[keyId + "_detune"]
detuneGain.gain.cancelScheduledValues(now)
detuneGain.gain.setValueAtTime(detuneGain.gain.value, now)
detuneGain.gain.linearRampToValueAtTime(0, now + release)
}
setTimeout(() => {
if (oscillatorsRef.current[keyId]) {
oscillatorsRef.current[keyId].stop()
delete oscillatorsRef.current[keyId]
delete gainsRef.current[keyId]
}
if (oscillatorsRef.current[keyId + "_detune"]) {
oscillatorsRef.current[keyId + "_detune"].stop()
delete oscillatorsRef.current[keyId + "_detune"]
delete gainsRef.current[keyId + "_detune"]
}
}, release * 1000 + 100)
}
const handleMouseDown = (note: string) => {
playNote(note, note)
}
const handleMouseUp = (note: string) => {
if (!sustain) {
stopNote(note)
}
}
const handleMouseLeave = (note: string) => {
if (!sustain) {
stopNote(note)
}
}
return (
<div className="flex flex-col items-center p-6 bg-gradient-to-b from-zinc-50 to-zinc-200 dark:from-zinc-800 dark:to-zinc-950 rounded-xl max-w-5xl mx-auto shadow-lg">
<h1 className="text-4xl font-bold mb-6 text-center bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-purple-600">Enhanced Piano</h1>
<Tabs defaultValue="basic" className="w-full mb-8">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic">Basic Controls</TabsTrigger>
<TabsTrigger value="advanced">Advanced Controls</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="w-full grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4 p-4 bg-white dark:bg-zinc-800 rounded-lg shadow-sm">
<div className="flex items-center space-x-4">
<Volume2 className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<Slider
value={[volume * 100]}
min={0}
max={100}
step={1}
onValueChange={(value) => setVolume(value[0] / 100)}
className="h-2"
/>
</div>
<span className="w-12 text-right font-mono">{Math.round(volume * 100)}%</span>
</div>
<div className="flex items-center space-x-4">
<Music className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<Select value={octave.toString()} onValueChange={(value) => setOctave(Number.parseInt(value))}>
<SelectTrigger className="bg-white dark:bg-zinc-700">
<SelectValue placeholder="Octave" />
</SelectTrigger>
<SelectContent>
{[2, 3, 4, 5, 6].map((o) => (
<SelectItem key={o} value={o.toString()}>
Octave {o}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Switch id="key-labels" checked={showKeyLabels} onCheckedChange={setShowKeyLabels} />
<Label htmlFor="key-labels">Show Key Labels</Label>
</div>
</div>
</div>
<div className="space-y-4 p-4 bg-white dark:bg-zinc-800 rounded-lg shadow-sm">
<div className="flex items-center space-x-4">
<Waveform className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<Select value={soundType} onValueChange={setSoundType}>
<SelectTrigger className="bg-white dark:bg-zinc-700">
<SelectValue placeholder="Sound Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sine">Sine (Soft)</SelectItem>
<SelectItem value="triangle">Triangle (Mellow)</SelectItem>
<SelectItem value="square">Square (Retro)</SelectItem>
<SelectItem value="sawtooth">Sawtooth (Bright)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Switch id="sustain" checked={sustain} onCheckedChange={setSustain} />
<Label htmlFor="sustain">Sustain</Label>
</div>
<Button
variant="outline"
className="ml-auto bg-white dark:bg-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-600 transition-colors"
onClick={() => {
Object.keys(oscillatorsRef.current).forEach((key) => {
stopNote(key)
})
}}
>
Stop All Notes
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="advanced" className="w-full grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4 p-4 bg-white dark:bg-zinc-800 rounded-lg shadow-sm">
<h3 className="font-medium text-lg mb-2">Sound Envelope</h3>
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="attack">Attack</Label>
<span className="text-xs text-zinc-500">{(attack * 1000).toFixed(0)}ms</span>
</div>
<Slider
id="attack"
value={[attack * 100]}
min={1}
max={50}
step={1}
onValueChange={(value) => setAttack(value[0] / 100)}
className="h-2"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="release">Release</Label>
<span className="text-xs text-zinc-500">{(release * 1000).toFixed(0)}ms</span>
</div>
<Slider
id="release"
value={[release * 100]}
min={1}
max={100}
step={1}
onValueChange={(value) => setRelease(value[0] / 100)}
className="h-2"
/>
</div>
</div>
</div>
<div className="space-y-4 p-4 bg-white dark:bg-zinc-800 rounded-lg shadow-sm">
<h3 className="font-medium text-lg mb-2">Effects</h3>
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between">
<Label htmlFor="reverb">Reverb</Label>
<span className="text-xs text-zinc-500">{Math.round(reverb * 100)}%</span>
</div>
<Slider
id="reverb"
value={[reverb * 100]}
min={0}
max={70}
step={1}
onValueChange={(value) => setReverb(value[0] / 100)}
className="h-2"
/>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<div className="relative w-full h-72 bg-gradient-to-b from-white to-zinc-100 dark:from-zinc-800 dark:to-zinc-900 rounded-lg overflow-hidden shadow-xl border border-zinc-300 dark:border-zinc-700">
<div className="flex h-full">
{Array.from({ length: 17 }, (_, i) => {
const noteIndex = i % 7
const currentOctave = Math.floor(i / 7) + octave
const note = `${whiteKeys[noteIndex]}${currentOctave}`
const keyboardKey = Object.entries(keyboardMap).find(([_, mappedNote]) => mappedNote === note)?.[0]
return (
<div
key={note}
className="flex-1 border-r border-zinc-300 dark:border-zinc-700 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-200 dark:to-zinc-300 hover:from-zinc-50 hover:to-zinc-100 dark:hover:from-zinc-300 dark:hover:to-zinc-400 active:from-zinc-100 active:to-zinc-200 dark:active:from-zinc-400 dark:active:to-zinc-500 transition-colors"
onMouseDown={() => handleMouseDown(note)}
onMouseUp={() => handleMouseUp(note)}
onMouseLeave={() => handleMouseLeave(note)}
>
<div className="h-full flex flex-col items-center">
<div className="flex-1"></div>
{showKeyLabels && (
<div className="mb-4 flex flex-col items-center">
<div className="text-xs font-medium text-zinc-800 dark:text-zinc-800">{note}</div>
{keyboardKey && (
<kbd className="mt-1 px-1.5 py-0.5 text-xs bg-zinc-200 dark:bg-zinc-500 rounded text-zinc-700 dark:text-zinc-200">
{keyboardKey}
</kbd>
)}
</div>
)}
</div>
</div>
)
})}
</div>
<div className="absolute top-0 left-0 w-full flex">
{Array.from({ length: 17 }, (_, i) => {
const octaveOffset = Math.floor(i / 7)
const position = i % 7
if (position === 2 || position === 6) {
return <div key={`spacer-${i}`} className="flex-1" />
}
const blackKeyIndex = position < 2 ? position : position - 1
const note = `${blackKeys[blackKeyIndex]}${octaveOffset + octave}`
const keyboardKey = Object.entries(keyboardMap).find(([_, mappedNote]) => mappedNote === note)?.[0]
return (
<div key={`black-${i}`} className="flex-1 flex justify-center">
{position !== 2 && position !== 6 && (
<div
className="w-2/3 h-40 bg-gradient-to-b from-zinc-900 to-black dark:from-zinc-800 dark:to-zinc-900 hover:from-zinc-800 hover:to-zinc-900 dark:hover:from-zinc-700 dark:hover:to-zinc-800 active:from-zinc-700 active:to-zinc-800 dark:active:from-zinc-600 dark:active:to-zinc-700 transition-colors rounded-b-md"
onMouseDown={() => handleMouseDown(note)}
onMouseUp={() => handleMouseUp(note)}
onMouseLeave={() => handleMouseLeave(note)}
>
{showKeyLabels && (
<div className="h-full flex flex-col items-center justify-end pb-3">
<div className="text-xs font-medium text-zinc-200">{note}</div>
{keyboardKey && (
<kbd className="mt-1 px-1.5 py-0.5 text-xs bg-zinc-700 rounded text-zinc-300">
{keyboardKey}
</kbd>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
<div className="mt-8 text-sm text-zinc-600 dark:text-zinc-400 max-w-2xl text-center">
<p className="mb-2">
Play using your mouse or keyboard. The sustain option keeps notes playing after releasing keys.
Try different sound types and effects for various timbres!
</p>
</div>
<details className="mt-4 w-full max-w-2xl">
<summary className="cursor-pointer text-sm font-medium text-blue-500 hover:text-blue-600 mb-2">
Show Keyboard Mapping
</summary>
<div className="p-4 bg-white dark:bg-zinc-800 rounded-lg shadow-sm">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{Object.entries(keyboardMap).map(([key, note]) => (
<div key={key} className="flex items-center space-x-2">
<kbd className="px-2 py-1 bg-zinc-200 dark:bg-zinc-700 rounded text-xs">{key}</kbd>
<span>=</span>
<span className="text-xs">{note}</span>
</div>
))}
</div>
</div>
</details>
</div>
)
}