# 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 && ( Enable Sound )} {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(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 ( playNote('basic')}>Basic playNote('fm')}>FM playNote('am')}>AM playNote('membrane', 'C2')}>Kick playNote('pluck')}>Pluck playNote('metal')}>Metal ) } ``` ### 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) { const samplerRef = useRef(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(null) const analyzerRef = useRef(null) const playerRef = useRef(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 ( Play/Pause ) } ``` ### FFT Bars Visualizer ```tsx 'use client' import { useRef, useEffect } from 'react' import * as Tone from 'tone' export function FFTVisualizer() { const canvasRef = useRef(null) const fftRef = useRef(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 } ``` ## 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(null) const synthRef = useRef(null) const filterRef = useRef(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('.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 ( {[1, 2, 3, 4].map((i) => ( Section {i} ))} ) } ``` ### Pitch Basado en Scroll Progress ```tsx 'use client' import { useRef, useEffect } from 'react' import * as Tone from 'tone' export function ScrollPitch() { const oscillatorRef = useRef(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 ( Toggle Scroll Sound ) } ``` ## 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 ( play('hover')} onClick={() => play('click')} className="px-4 py-2 bg-white text-black rounded" > {children} ) } ``` ### 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(null) const [position, setPosition] = useState({ x: 0, y: 0 }) const synthRef = useRef(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 ( 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} ) } ``` ## Web Audio API Native ### Sin Tone.js (Lightweight) ```tsx 'use client' import { useRef, useCallback } from 'react' export function useNativeAudio() { const contextRef = useRef(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