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

741 lines
18 KiB
Markdown

# Audio Reactive Animations
Tone.js y Web Audio API para experiencias sonoras interactivas estilo Awwwards.
## Table of Contents
1. [Decision Matrix](#decision-matrix)
2. [Tone.js Setup](#tonejs-setup)
3. [React Integration](#react-integration)
4. [Synths & Sounds](#synths--sounds)
5. [Audio Reactive Visuals](#audio-reactive-visuals)
6. [Scroll Audio](#scroll-audio)
7. [Hover & Click Sounds](#hover--click-sounds)
8. [Web Audio API Native](#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
```bash
npm install tone
```
### Importación
```tsx
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.
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
// 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)
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'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
```tsx
'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)
```tsx
'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
```tsx
// El audio NO funciona sin interacción
document.addEventListener('click', async () => {
await Tone.start()
}, { once: true })
```
### 2. Dispose de Recursos
```tsx
useEffect(() => {
const synth = new Tone.Synth().toDestination()
return () => synth.dispose() // Importante!
}, [])
```
### 3. Volumen Apropiado para UI
```tsx
synth.volume.value = -12 // -12dB para efectos sutiles
```
### 4. Respetar Preferencias del Usuario
```tsx
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const isMuted = localStorage.getItem('audio-muted') === 'true'
```
## Recursos
- [Tone.js](https://tonejs.github.io/)
- [Tone.js GitHub Wiki](https://github.com/Tonejs/Tone.js/wiki)
- [Web Audio API MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
- [Reactronica](https://reactronica.com/) - React + Tone.js