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>
  )
}