skills/awwwards-animations/references/audio-reactive.md

18 KiB

Audio Reactive Animations

Tone.js y Web Audio API para experiencias sonoras interactivas estilo Awwwards.

Table of Contents

  1. Decision Matrix
  2. Tone.js Setup
  3. React Integration
  4. Synths & Sounds
  5. Audio Reactive Visuals
  6. Scroll Audio
  7. Hover & Click Sounds
  8. Web Audio API Native

Decision Matrix

Necesidad Herramienta Por qué
Sintetizadores/música Tone.js API musical completa
Efectos simples (clicks) Web Audio API nativo Sin dependencias
Audio reactivo al scroll Tone.js + ScrollTrigger Sync con animaciones
Visualizador de audio Web Audio Analyzer FFT data
Samples/loops Tone.Sampler Fácil de usar

Tone.js Setup

Instalación

npm install tone

Importación

import * as Tone from 'tone'

// O importar módulos específicos
import { Synth, FMSynth, Sampler, Transport, Destination } from 'tone'

React Integration

Audio Context Requirement

IMPORTANTE: El audio web requiere interacción del usuario para iniciar.

'use client'

import { useState, useCallback } from 'react'
import * as Tone from 'tone'

export function AudioProvider({ children }: { children: React.ReactNode }) {
  const [isAudioReady, setIsAudioReady] = useState(false)

  const initAudio = useCallback(async () => {
    await Tone.start()
    setIsAudioReady(true)
    console.log('Audio context started')
  }, [])

  return (
    <>
      {!isAudioReady && (
        <button
          onClick={initAudio}
          className="fixed bottom-4 right-4 z-50 px-4 py-2 bg-white text-black rounded-full"
        >
          Enable Sound
        </button>
      )}
      {children}
    </>
  )
}

Custom Hook: useTone

'use client'

import { useRef, useEffect, useCallback, useState } from 'react'
import * as Tone from 'tone'

export function useTone() {
  const [isReady, setIsReady] = useState(false)

  const start = useCallback(async () => {
    if (Tone.context.state !== 'running') {
      await Tone.start()
    }
    setIsReady(true)
  }, [])

  return { isReady, start, Tone }
}

Custom Hook: useSynth

'use client'

import { useRef, useEffect, useMemo } from 'react'
import * as Tone from 'tone'

export function useSynth(type: 'synth' | 'fm' | 'am' | 'membrane' = 'synth') {
  const synthRef = useRef<Tone.Synth | Tone.FMSynth | Tone.AMSynth | Tone.MembraneSynth | null>(null)

  useEffect(() => {
    // Crear synth según tipo
    switch (type) {
      case 'fm':
        synthRef.current = new Tone.FMSynth().toDestination()
        break
      case 'am':
        synthRef.current = new Tone.AMSynth().toDestination()
        break
      case 'membrane':
        synthRef.current = new Tone.MembraneSynth().toDestination()
        break
      default:
        synthRef.current = new Tone.Synth().toDestination()
    }

    return () => {
      synthRef.current?.dispose()
    }
  }, [type])

  const play = (note: string = 'C4', duration: string = '8n') => {
    if (Tone.context.state === 'running') {
      synthRef.current?.triggerAttackRelease(note, duration)
    }
  }

  return { play, synth: synthRef }
}

Synths & Sounds

Tipos de Sintetizadores

'use client'

import { useEffect, useRef } from 'react'
import * as Tone from 'tone'

export function SynthDemo() {
  const synthsRef = useRef<{
    basic: Tone.Synth | null
    fm: Tone.FMSynth | null
    am: Tone.AMSynth | null
    membrane: Tone.MembraneSynth | null
    pluck: Tone.PluckSynth | null
    metal: Tone.MetalSynth | null
  }>({
    basic: null,
    fm: null,
    am: null,
    membrane: null,
    pluck: null,
    metal: null,
  })

  useEffect(() => {
    // Synth básico (saw/sine/square wave)
    synthsRef.current.basic = new Tone.Synth({
      oscillator: { type: 'sine' },
      envelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.8 },
    }).toDestination()

    // FM Synth (metallic, bells)
    synthsRef.current.fm = new Tone.FMSynth({
      modulationIndex: 10,
      harmonicity: 3,
    }).toDestination()

    // AM Synth (tremolo effect)
    synthsRef.current.am = new Tone.AMSynth().toDestination()

    // Membrane Synth (kicks, drums)
    synthsRef.current.membrane = new Tone.MembraneSynth({
      pitchDecay: 0.05,
      octaves: 4,
    }).toDestination()

    // Pluck Synth (guitar-like)
    synthsRef.current.pluck = new Tone.PluckSynth().toDestination()

    // Metal Synth (hi-hats, cymbals)
    synthsRef.current.metal = new Tone.MetalSynth({
      frequency: 200,
      envelope: { attack: 0.001, decay: 0.1, release: 0.1 },
      harmonicity: 5.1,
      modulationIndex: 32,
      resonance: 4000,
      octaves: 1.5,
    }).toDestination()

    return () => {
      Object.values(synthsRef.current).forEach((s) => s?.dispose())
    }
  }, [])

  const playNote = async (type: keyof typeof synthsRef.current, note = 'C4') => {
    await Tone.start()
    synthsRef.current[type]?.triggerAttackRelease(note, '8n')
  }

  return (
    <div className="flex gap-2">
      <button onClick={() => playNote('basic')}>Basic</button>
      <button onClick={() => playNote('fm')}>FM</button>
      <button onClick={() => playNote('am')}>AM</button>
      <button onClick={() => playNote('membrane', 'C2')}>Kick</button>
      <button onClick={() => playNote('pluck')}>Pluck</button>
      <button onClick={() => playNote('metal')}>Metal</button>
    </div>
  )
}

Efectos de Audio

// Crear cadena de efectos
const reverb = new Tone.Reverb({ decay: 2, wet: 0.5 }).toDestination()
const delay = new Tone.FeedbackDelay('8n', 0.5).connect(reverb)
const distortion = new Tone.Distortion(0.4).connect(delay)
const synth = new Tone.Synth().connect(distortion)

// O conectar en serie
synth.chain(distortion, delay, reverb, Tone.Destination)

Sampler (Samples de Audio)

'use client'

import { useEffect, useRef } from 'react'
import * as Tone from 'tone'

export function useSampler(samples: Record<string, string>) {
  const samplerRef = useRef<Tone.Sampler | null>(null)
  const [isLoaded, setIsLoaded] = useState(false)

  useEffect(() => {
    samplerRef.current = new Tone.Sampler({
      urls: samples,
      onload: () => setIsLoaded(true),
    }).toDestination()

    return () => samplerRef.current?.dispose()
  }, [samples])

  const play = (note: string, duration?: string) => {
    if (isLoaded && Tone.context.state === 'running') {
      samplerRef.current?.triggerAttackRelease(note, duration || '8n')
    }
  }

  return { play, isLoaded }
}

// Uso
const { play, isLoaded } = useSampler({
  C4: '/sounds/piano-c4.mp3',
  E4: '/sounds/piano-e4.mp3',
  G4: '/sounds/piano-g4.mp3',
})

Audio Reactive Visuals

Analyzer + Canvas

'use client'

import { useRef, useEffect } from 'react'
import * as Tone from 'tone'

export function AudioVisualizer() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const analyzerRef = useRef<Tone.Analyser | null>(null)
  const playerRef = useRef<Tone.Player | null>(null)

  useEffect(() => {
    // Crear analyzer
    analyzerRef.current = new Tone.Analyser('waveform', 256)

    // Player conectado al analyzer
    playerRef.current = new Tone.Player({
      url: '/audio/track.mp3',
      loop: true,
    }).connect(analyzerRef.current)

    analyzerRef.current.toDestination()

    // Animation loop
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!

    function draw() {
      const values = analyzerRef.current?.getValue() as Float32Array

      ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'
      ctx.fillRect(0, 0, canvas.width, canvas.height)

      ctx.lineWidth = 2
      ctx.strokeStyle = '#00f5ff'
      ctx.beginPath()

      const sliceWidth = canvas.width / values.length
      let x = 0

      for (let i = 0; i < values.length; i++) {
        const v = (values[i] + 1) / 2 // Normalize -1 to 1 → 0 to 1
        const y = v * canvas.height

        if (i === 0) ctx.moveTo(x, y)
        else ctx.lineTo(x, y)

        x += sliceWidth
      }

      ctx.stroke()
      requestAnimationFrame(draw)
    }

    draw()

    return () => {
      playerRef.current?.dispose()
      analyzerRef.current?.dispose()
    }
  }, [])

  const togglePlay = async () => {
    await Tone.start()
    if (playerRef.current?.state === 'started') {
      playerRef.current.stop()
    } else {
      playerRef.current?.start()
    }
  }

  return (
    <div>
      <canvas ref={canvasRef} width={600} height={200} className="bg-black" />
      <button onClick={togglePlay}>Play/Pause</button>
    </div>
  )
}

FFT Bars Visualizer

'use client'

import { useRef, useEffect } from 'react'
import * as Tone from 'tone'

export function FFTVisualizer() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const fftRef = useRef<Tone.FFT | null>(null)

  useEffect(() => {
    fftRef.current = new Tone.FFT(64)

    // Conectar micrófono o audio
    const mic = new Tone.UserMedia().connect(fftRef.current)
    mic.open()

    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!
    const barCount = 64

    function draw() {
      const values = fftRef.current?.getValue() as Float32Array

      ctx.fillStyle = '#0a0a0a'
      ctx.fillRect(0, 0, canvas.width, canvas.height)

      const barWidth = canvas.width / barCount
      const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4']

      for (let i = 0; i < barCount; i++) {
        // FFT values are in dB, normalize to 0-1
        const value = (values[i] + 140) / 140
        const barHeight = value * canvas.height

        ctx.fillStyle = colors[i % colors.length]
        ctx.fillRect(
          i * barWidth,
          canvas.height - barHeight,
          barWidth - 2,
          barHeight
        )
      }

      requestAnimationFrame(draw)
    }

    draw()

    return () => {
      mic.close()
      fftRef.current?.dispose()
    }
  }, [])

  return <canvas ref={canvasRef} width={600} height={300} className="bg-black" />
}

Scroll Audio

Audio Reactivo al Scroll con GSAP

'use client'

import { useRef, useEffect } from 'react'
import * as Tone from 'tone'
import { gsap, ScrollTrigger, useGSAP } from '@/lib/gsap'

export function ScrollAudio() {
  const containerRef = useRef<HTMLDivElement>(null)
  const synthRef = useRef<Tone.Synth | null>(null)
  const filterRef = useRef<Tone.Filter | null>(null)

  useEffect(() => {
    filterRef.current = new Tone.Filter(200, 'lowpass').toDestination()
    synthRef.current = new Tone.Synth({
      oscillator: { type: 'sawtooth' },
    }).connect(filterRef.current)

    return () => {
      synthRef.current?.dispose()
      filterRef.current?.dispose()
    }
  }, [])

  useGSAP(() => {
    // Cambiar frecuencia del filtro con scroll
    ScrollTrigger.create({
      trigger: containerRef.current,
      start: 'top top',
      end: 'bottom bottom',
      onUpdate: (self) => {
        // Mapear progreso a frecuencia (200Hz - 5000Hz)
        const freq = 200 + self.progress * 4800
        filterRef.current?.frequency.rampTo(freq, 0.1)
      },
    })

    // Trigger notas en secciones específicas
    const sections = gsap.utils.toArray<HTMLElement>('.audio-section')
    const notes = ['C4', 'E4', 'G4', 'B4']

    sections.forEach((section, i) => {
      ScrollTrigger.create({
        trigger: section,
        start: 'top center',
        onEnter: async () => {
          await Tone.start()
          synthRef.current?.triggerAttackRelease(notes[i % notes.length], '8n')
        },
      })
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef} className="h-[400vh]">
      {[1, 2, 3, 4].map((i) => (
        <div key={i} className="audio-section h-screen flex items-center justify-center">
          Section {i}
        </div>
      ))}
    </div>
  )
}

Pitch Basado en Scroll Progress

'use client'

import { useRef, useEffect } from 'react'
import * as Tone from 'tone'

export function ScrollPitch() {
  const oscillatorRef = useRef<Tone.Oscillator | null>(null)
  const isPlayingRef = useRef(false)

  useEffect(() => {
    oscillatorRef.current = new Tone.Oscillator({
      frequency: 220,
      type: 'sine',
    }).toDestination()

    const handleScroll = () => {
      const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight)
      // Mapear scroll a frecuencia (110Hz - 880Hz = 2 octavas)
      const freq = 110 * Math.pow(2, scrollPercent * 2)
      oscillatorRef.current?.frequency.rampTo(freq, 0.05)
    }

    window.addEventListener('scroll', handleScroll)

    return () => {
      window.removeEventListener('scroll', handleScroll)
      oscillatorRef.current?.dispose()
    }
  }, [])

  const toggleSound = async () => {
    await Tone.start()
    if (isPlayingRef.current) {
      oscillatorRef.current?.stop()
    } else {
      oscillatorRef.current?.start()
    }
    isPlayingRef.current = !isPlayingRef.current
  }

  return (
    <button onClick={toggleSound} className="fixed bottom-4 right-4">
      Toggle Scroll Sound
    </button>
  )
}

Hover & Click Sounds

Hook para UI Sounds

'use client'

import { useRef, useEffect, useCallback } from 'react'
import * as Tone from 'tone'

const UI_SOUNDS = {
  hover: { note: 'G5', duration: '32n', synth: 'pluck' },
  click: { note: 'C4', duration: '16n', synth: 'membrane' },
  success: { note: 'C5', duration: '8n', synth: 'fm' },
  error: { note: 'A2', duration: '4n', synth: 'membrane' },
}

export function useUISound() {
  const synthsRef = useRef<{
    pluck: Tone.PluckSynth | null
    membrane: Tone.MembraneSynth | null
    fm: Tone.FMSynth | null
  }>({ pluck: null, membrane: null, fm: null })

  useEffect(() => {
    synthsRef.current.pluck = new Tone.PluckSynth().toDestination()
    synthsRef.current.membrane = new Tone.MembraneSynth().toDestination()
    synthsRef.current.fm = new Tone.FMSynth().toDestination()

    // Reducir volumen para UI sounds
    Object.values(synthsRef.current).forEach((s) => {
      if (s) s.volume.value = -12
    })

    return () => {
      Object.values(synthsRef.current).forEach((s) => s?.dispose())
    }
  }, [])

  const play = useCallback(async (type: keyof typeof UI_SOUNDS) => {
    if (Tone.context.state !== 'running') return

    const config = UI_SOUNDS[type]
    const synth = synthsRef.current[config.synth as keyof typeof synthsRef.current]
    synth?.triggerAttackRelease(config.note, config.duration)
  }, [])

  return { play }
}

// Uso en componente
export function SoundButton({ children }: { children: React.ReactNode }) {
  const { play } = useUISound()

  return (
    <button
      onMouseEnter={() => play('hover')}
      onClick={() => play('click')}
      className="px-4 py-2 bg-white text-black rounded"
    >
      {children}
    </button>
  )
}

Magnetic Button con Sonido

'use client'

import { useRef, useState } from 'react'
import { motion } from 'motion/react'
import * as Tone from 'tone'

export function MagneticSoundButton({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLButtonElement>(null)
  const [position, setPosition] = useState({ x: 0, y: 0 })
  const synthRef = useRef<Tone.PluckSynth | null>(null)

  // Inicializar synth
  useState(() => {
    synthRef.current = new Tone.PluckSynth().toDestination()
    synthRef.current.volume.value = -15
  })

  const handleMouse = async (e: React.MouseEvent) => {
    const { left, top, width, height } = ref.current!.getBoundingClientRect()
    const x = (e.clientX - left - width / 2) * 0.3
    const y = (e.clientY - top - height / 2) * 0.3
    setPosition({ x, y })

    // Pitch basado en posición
    await Tone.start()
    const note = Math.round(60 + (x / 50) * 12) // MIDI note
    const freq = Tone.Frequency(note, 'midi').toFrequency()
    synthRef.current?.triggerAttackRelease(freq, '32n')
  }

  return (
    <motion.button
      ref={ref}
      onMouseMove={handleMouse}
      onMouseLeave={() => setPosition({ x: 0, y: 0 })}
      animate={position}
      transition={{ type: 'spring', stiffness: 150, damping: 15 }}
      className="px-8 py-4 bg-white text-black rounded-full"
    >
      {children}
    </motion.button>
  )
}

Web Audio API Native

Sin Tone.js (Lightweight)

'use client'

import { useRef, useCallback } from 'react'

export function useNativeAudio() {
  const contextRef = useRef<AudioContext | null>(null)

  const getContext = useCallback(() => {
    if (!contextRef.current) {
      contextRef.current = new AudioContext()
    }
    return contextRef.current
  }, [])

  const playTone = useCallback((frequency: number, duration: number = 0.1) => {
    const ctx = getContext()
    const oscillator = ctx.createOscillator()
    const gainNode = ctx.createGain()

    oscillator.connect(gainNode)
    gainNode.connect(ctx.destination)

    oscillator.frequency.value = frequency
    oscillator.type = 'sine'

    gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
    gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)

    oscillator.start(ctx.currentTime)
    oscillator.stop(ctx.currentTime + duration)
  }, [getContext])

  const playClick = useCallback(() => playTone(800, 0.05), [playTone])
  const playHover = useCallback(() => playTone(1200, 0.03), [playTone])

  return { playTone, playClick, playHover }
}

Best Practices

1. Siempre Esperar Interacción del Usuario

// El audio NO funciona sin interacción
document.addEventListener('click', async () => {
  await Tone.start()
}, { once: true })

2. Dispose de Recursos

useEffect(() => {
  const synth = new Tone.Synth().toDestination()
  return () => synth.dispose() // Importante!
}, [])

3. Volumen Apropiado para UI

synth.volume.value = -12 // -12dB para efectos sutiles

4. Respetar Preferencias del Usuario

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const isMuted = localStorage.getItem('audio-muted') === 'true'

Recursos