diff options
| author | Yuval Adam <_@yuv.al> | 2025-02-27 14:41:11 +0100 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2025-02-27 14:41:11 +0100 |
| commit | aedb666cbf969cd56442424af63a4163393fc430 (patch) | |
| tree | f956108783a0363bac05344aca35da343c20bec3 | |
| parent | 8b4a63947c4943124d43f75ecef944ed2cb5fd31 (diff) | |
soundscape is crazy good
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | index.html | 9 | ||||
| -rw-r--r-- | soundscape/index.html | 275 | ||||
| -rw-r--r-- | soundscape/soundscape.js | 515 |
4 files changed, 801 insertions, 0 deletions
@@ -11,6 +11,7 @@ A collection of cute & creative web experiments and visual experiences. - [Digital Zen Garden](/zengarden/) - A meditative drag-and-drop experience where users can arrange elements with subtle animations. - [BTC Ticker](/btctick/) - Live Bitcoin price visualization with a cool animated chart showing price history. - [Interactive Particle System](/particles/) - A full-screen interactive particle system where particles respond to cursor movement, clicks, and device tilt. +- [Ambient Soundscape Generator](/soundscape/) - Create beautiful, generative ambient soundscapes with customizable musical scales and parameters that play continuously in the background. ## 🚀 Local Development @@ -23,6 +24,7 @@ To view the experiments locally: - Digital Zen Garden: `zengarden/index.html` - BTC Ticker: `btctick/index.html` - Interactive Particle System: `particles/index.html` + - Ambient Soundscape Generator: `soundscape/index.html` ## 💻 Technologies @@ -33,6 +33,7 @@ .container { max-width: 800px; padding: 2rem; + margin: 2rem; background-color: rgba(255, 255, 255, 0.85); border-radius: 20px; box-shadow: 0 10px 25px rgba(90, 61, 92, 0.2); @@ -148,6 +149,14 @@ device tilt. </div> </a> + + <a href="soundscape/" class="experiment-card"> + <div class="experiment-title">Ambient Soundscape Generator</div> + <div class="experiment-description"> + Create beautiful, generative ambient soundscapes with customizable musical scales, modality, and + parameters that play continuously in the background. + </div> + </a> </div> <footer> diff --git a/soundscape/index.html b/soundscape/index.html new file mode 100644 index 0000000..17da4e7 --- /dev/null +++ b/soundscape/index.html @@ -0,0 +1,275 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Ambient Soundscape Generator | Vibes</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=VT323&family=Fredoka:wght@400;600&display=swap" + rel="stylesheet"> + <style> + body { + font-family: 'Fredoka', sans-serif; + background-color: #d4f0f7; + color: #2a5674; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + text-align: center; + background-image: + linear-gradient(45deg, #d4f0f7 25%, transparent 25%), + linear-gradient(-45deg, #d4f0f7 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #d4f0f7 75%), + linear-gradient(-45deg, transparent 75%, #d4f0f7 75%); + background-size: 20px 20px; + background-color: #e8f7fc; + } + + .container { + max-width: 800px; + padding: 2rem; + margin: 2rem; + background-color: rgba(255, 255, 255, 0.85); + border-radius: 20px; + box-shadow: 0 10px 25px rgba(42, 86, 116, 0.2); + border: 5px solid #a5c8e8; + image-rendering: pixelated; + } + + h1 { + font-family: 'VT323', monospace; + font-size: 3.5rem; + margin-bottom: 1rem; + background: linear-gradient(45deg, #6ec4ff, #737ff5); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 3px 3px 0px rgba(110, 196, 255, 0.3); + letter-spacing: 2px; + } + + p { + font-size: 1.2rem; + line-height: 1.6; + margin-bottom: 2rem; + color: #4a7295; + } + + .controls { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 2rem; + } + + .control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + text-align: left; + } + + label { + font-size: 1.1rem; + font-weight: 600; + color: #2a5674; + } + + select, + input[type="range"] { + padding: 0.5rem; + border-radius: 10px; + border: 3px solid #a5c8e8; + background-color: #f0f9ff; + font-family: 'Fredoka', sans-serif; + font-size: 1rem; + color: #2a5674; + transition: all 0.3s ease; + } + + select:hover, + input[type="range"]:hover { + border-color: #6ec4ff; + } + + .button-group { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1rem; + } + + button { + padding: 0.8rem 1.5rem; + border-radius: 15px; + border: none; + background: #a5c8e8; + color: #2a5674; + font-family: 'Fredoka', sans-serif; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 3px 3px 0px #6ec4ff; + border: 3px dashed #737ff5; + } + + button:hover { + transform: translateY(-3px); + box-shadow: 5px 5px 0px #6ec4ff; + background: #c1d9f0; + } + + button.active { + background: #6ec4ff; + color: white; + } + + .visualizer { + width: 100%; + height: 150px; + background-color: rgba(165, 200, 232, 0.3); + border-radius: 15px; + margin-top: 2rem; + overflow: hidden; + position: relative; + } + + canvas { + width: 100%; + height: 100%; + display: block; + } + + footer { + margin-top: 2rem; + color: #4a7295; + font-size: 0.9rem; + font-family: 'VT323', monospace; + letter-spacing: 1px; + } + + .home-link { + display: inline-block; + margin-top: 1rem; + color: #2a5674; + text-decoration: none; + font-weight: 600; + transition: all 0.3s ease; + } + + .home-link:hover { + color: #6ec4ff; + transform: translateY(-2px); + } + + .note-indicators { + display: flex; + justify-content: space-around; + margin-top: 1rem; + } + + .note { + width: 20px; + height: 20px; + border-radius: 50%; + background-color: #a5c8e8; + transition: all 0.3s ease; + } + + .note.active { + background-color: #6ec4ff; + transform: scale(1.3); + box-shadow: 0 0 10px rgba(110, 196, 255, 0.8); + } + </style> +</head> + +<body> + <div class="container"> + <h1>Ambient Soundscape Generator</h1> + <p>Create beautiful, generative ambient soundscapes that play continuously in the background. Adjust the musical + scale, modality, and other parameters to craft your perfect sonic atmosphere.</p> + + <div class="controls"> + <div class="control-group"> + <label for="scale">Musical Scale:</label> + <select id="scale"> + <option value="major">Major</option> + <option value="minor">Minor</option> + <option value="pentatonic">Pentatonic</option> + <option value="blues">Blues</option> + <option value="chromatic">Chromatic</option> + <option value="dorian">Dorian</option> + <option value="phrygian">Phrygian</option> + <option value="lydian">Lydian</option> + <option value="mixolydian">Mixolydian</option> + <option value="locrian">Locrian</option> + </select> + </div> + + <div class="control-group"> + <label for="root">Root Note:</label> + <select id="root"> + <option value="C">C</option> + <option value="C#">C#</option> + <option value="D">D</option> + <option value="D#">D#</option> + <option value="E">E</option> + <option value="F">F</option> + <option value="F#">F#</option> + <option value="G">G</option> + <option value="G#">G#</option> + <option value="A">A</option> + <option value="A#">A#</option> + <option value="B">B</option> + </select> + </div> + + <div class="control-group"> + <label for="tempo">Tempo:</label> + <input type="range" id="tempo" min="40" max="120" value="60"> + <div id="tempo-value">60 BPM</div> + </div> + + <div class="control-group"> + <label for="density">Note Density:</label> + <input type="range" id="density" min="1" max="10" value="5"> + <div id="density-value">Medium</div> + </div> + + <div class="control-group"> + <label for="reverb">Reverb Amount:</label> + <input type="range" id="reverb" min="0" max="1" step="0.1" value="0.5"> + <div id="reverb-value">Medium</div> + </div> + </div> + + <div class="button-group"> + <button id="play-button">Play</button> + <button id="stop-button">Stop</button> + <button id="randomize-button">Randomize</button> + </div> + + <div class="visualizer"> + <canvas id="visualizer-canvas"></canvas> + </div> + + <div class="note-indicators" id="note-indicators"> + <!-- Note indicators will be added here dynamically --> + </div> + + <footer> + Part of the <a href="../" class="home-link">Vibes</a> collection of web experiments + </footer> + </div> + + <script src="soundscape.js"></script> +</body> + +</html>
\ No newline at end of file diff --git a/soundscape/soundscape.js b/soundscape/soundscape.js new file mode 100644 index 0000000..72f02fe --- /dev/null +++ b/soundscape/soundscape.js @@ -0,0 +1,515 @@ +/** + * Ambient Soundscape Generator + * A generative music system that creates pleasant, continuous soundscapes + * using Web Audio API. + */ + +// Initialize audio context +let audioContext; +let masterGainNode; +let reverbNode; +let analyser; +let playing = false; +let noteScheduler; +let visualizerInterval; + +// Musical parameters +const scales = { + major: [0, 2, 4, 5, 7, 9, 11], + minor: [0, 2, 3, 5, 7, 8, 10], + pentatonic: [0, 2, 4, 7, 9], + blues: [0, 3, 5, 6, 7, 10], + chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + dorian: [0, 2, 3, 5, 7, 9, 10], + phrygian: [0, 1, 3, 5, 7, 8, 10], + lydian: [0, 2, 4, 6, 7, 9, 11], + mixolydian: [0, 2, 4, 5, 7, 9, 10], + locrian: [0, 1, 3, 5, 6, 8, 10] +}; + +const rootNotes = { + 'C': 60, + 'C#': 61, + 'D': 62, + 'D#': 63, + 'E': 64, + 'F': 65, + 'F#': 66, + 'G': 67, + 'G#': 68, + 'A': 69, + 'A#': 70, + 'B': 71 +}; + +// DOM elements +const playButton = document.getElementById('play-button'); +const stopButton = document.getElementById('stop-button'); +const randomizeButton = document.getElementById('randomize-button'); +const scaleSelect = document.getElementById('scale'); +const rootSelect = document.getElementById('root'); +const tempoSlider = document.getElementById('tempo'); +const tempoValue = document.getElementById('tempo-value'); +const densitySlider = document.getElementById('density'); +const densityValue = document.getElementById('density-value'); +const reverbSlider = document.getElementById('reverb'); +const reverbValue = document.getElementById('reverb-value'); +const visualizerCanvas = document.getElementById('visualizer-canvas'); +const noteIndicatorsContainer = document.getElementById('note-indicators'); + +// Canvas context +const canvasCtx = visualizerCanvas.getContext('2d'); +let canvasWidth, canvasHeight; + +// Active notes for visualization +const activeNotes = new Set(); +const noteIndicators = []; + +// Initialize the application +function init() { + // Set up event listeners + playButton.addEventListener('click', startSoundscape); + stopButton.addEventListener('click', stopSoundscape); + randomizeButton.addEventListener('click', randomizeSettings); + + tempoSlider.addEventListener('input', updateTempoValue); + densitySlider.addEventListener('input', updateDensityValue); + reverbSlider.addEventListener('input', updateReverbValue); + + // Initialize UI values + updateTempoValue(); + updateDensityValue(); + updateReverbValue(); + + // Create note indicators + createNoteIndicators(); + + // Set canvas dimensions + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + // Initialize with random settings + randomizeSettings(); +} + +// Create note indicators based on the selected scale +function createNoteIndicators() { + noteIndicatorsContainer.innerHTML = ''; + noteIndicators.length = 0; + + const currentScale = scales[scaleSelect.value]; + const numNotes = currentScale.length; + + for (let i = 0; i < numNotes; i++) { + const noteElement = document.createElement('div'); + noteElement.className = 'note'; + noteIndicatorsContainer.appendChild(noteElement); + noteIndicators.push(noteElement); + } +} + +// Update note indicators when scale changes +function updateNoteIndicators() { + createNoteIndicators(); +} + +// Start the soundscape +function startSoundscape() { + if (playing) return; + + // Initialize audio context if it doesn't exist + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + + // Create master gain node + masterGainNode = audioContext.createGain(); + masterGainNode.gain.value = 0.5; + + // Create analyzer for visualization + analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; + + // Create reverb + setupReverb(); + } + + // Resume audio context if suspended + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + + // Update UI + playButton.classList.add('active'); + stopButton.classList.remove('active'); + + // Start scheduling notes + playing = true; + scheduleNotes(); + + // Start visualizer + startVisualizer(); +} + +// Set up the reverb effect +function setupReverb() { + // Create a convolver node for reverb + const convolver = audioContext.createConvolver(); + + // Create impulse response + const rate = audioContext.sampleRate; + const length = rate * 3; // 3 seconds + const impulse = audioContext.createBuffer(2, length, rate); + const impulseL = impulse.getChannelData(0); + const impulseR = impulse.getChannelData(1); + + // Fill the buffer with white noise with an exponential decay + for (let i = 0; i < length; i++) { + const decay = Math.pow(0.5, i / (rate * 0.5)); + impulseL[i] = (Math.random() * 2 - 1) * decay; + impulseR[i] = (Math.random() * 2 - 1) * decay; + } + + convolver.buffer = impulse; + + // Create wet/dry mix + const reverbAmount = parseFloat(reverbSlider.value); + const wetGain = audioContext.createGain(); + wetGain.gain.value = reverbAmount; + + const dryGain = audioContext.createGain(); + dryGain.gain.value = 1 - reverbAmount; + + // Create a gain node to control the final output + const reverbMix = audioContext.createGain(); + + // Connect the nodes + masterGainNode.connect(wetGain); + masterGainNode.connect(dryGain); + + wetGain.connect(convolver); + convolver.connect(reverbMix); + dryGain.connect(reverbMix); + + reverbMix.connect(analyser); + analyser.connect(audioContext.destination); + + // Store the nodes for later updates + reverbNode = { + wetGain: wetGain, + dryGain: dryGain, + convolver: convolver, + reverbMix: reverbMix + }; +} + +// Update reverb when slider changes +function updateReverbValue() { + const reverb = reverbSlider.value; + let reverbText; + + if (reverb <= 0.3) { + reverbText = 'Dry'; + } else if (reverb <= 0.7) { + reverbText = 'Medium'; + } else { + reverbText = 'Wet'; + } + + reverbValue.textContent = reverbText; + + // Update reverb if already playing + if (playing && reverbNode) { + const reverbAmount = parseFloat(reverb); + reverbNode.wetGain.gain.value = reverbAmount; + reverbNode.dryGain.gain.value = 1 - reverbAmount; + } +} + +// Stop the soundscape +function stopSoundscape() { + if (!playing) return; + + // Clear schedulers + clearTimeout(noteScheduler); + clearInterval(visualizerInterval); + + // Update UI + playButton.classList.remove('active'); + stopButton.classList.add('active'); + + // Stop all sound + if (audioContext && masterGainNode) { + masterGainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1); + setTimeout(() => { + if (audioContext) { + masterGainNode.disconnect(); + if (reverbNode) { + reverbNode.wetGain.disconnect(); + reverbNode.dryGain.disconnect(); + reverbNode.convolver.disconnect(); + reverbNode.reverbMix.disconnect(); + } + analyser.disconnect(); + audioContext.close(); + audioContext = null; + reverbNode = null; + } + }, 1000); + } + + playing = false; + activeNotes.clear(); + updateNoteIndicatorsDisplay(); +} + +// Schedule notes based on current settings +function scheduleNotes() { + if (!playing) return; + + const tempo = parseInt(tempoSlider.value); + const density = parseInt(densitySlider.value); + const rootNote = rootNotes[rootSelect.value]; + const currentScale = scales[scaleSelect.value]; + + // Calculate time between notes based on tempo and density + const noteDuration = 60 / tempo * 1000; // in milliseconds + const noteInterval = noteDuration / density; + + // Schedule a batch of notes + for (let i = 0; i < density; i++) { + setTimeout(() => { + if (playing && Math.random() < 0.7) { // 70% chance of playing a note + playRandomNote(rootNote, currentScale); + } + }, i * noteInterval); + } + + // Schedule the next batch + noteScheduler = setTimeout(scheduleNotes, noteDuration); +} + +// Play a random note from the selected scale +function playRandomNote(rootNote, scale) { + if (!audioContext) return; + + // Choose a random octave (-1, 0, or 1 relative to root) + const octaveOffset = Math.floor(Math.random() * 3) - 1; + + // Choose a random note from the scale + const scaleIndex = Math.floor(Math.random() * scale.length); + const noteOffset = scale[scaleIndex]; + + // Calculate the final MIDI note number + const midiNote = rootNote + noteOffset + (octaveOffset * 12); + + // Convert MIDI note to frequency + const frequency = 440 * Math.pow(2, (midiNote - 69) / 12); + + // Create oscillator + const oscillator = audioContext.createOscillator(); + oscillator.type = chooseRandomWaveform(); + oscillator.frequency.value = frequency; + + // Create envelope + const envelope = audioContext.createGain(); + envelope.gain.value = 0; + + // Random duration between 2 and 8 seconds + const duration = 2 + Math.random() * 6; + + // Random pan position + const panner = audioContext.createStereoPanner(); + panner.pan.value = Math.random() * 2 - 1; // -1 to 1 + + // Connect nodes + oscillator.connect(envelope); + envelope.connect(panner); + panner.connect(masterGainNode); + + // Apply envelope + const now = audioContext.currentTime; + const attackTime = 0.2 + Math.random() * 0.3; + const releaseTime = 0.5 + Math.random() * 1.5; + + envelope.gain.setValueAtTime(0, now); + envelope.gain.linearRampToValueAtTime(0.2 + Math.random() * 0.1, now + attackTime); + envelope.gain.setValueAtTime(0.2 + Math.random() * 0.1, now + duration - releaseTime); + envelope.gain.linearRampToValueAtTime(0, now + duration); + + // Start and stop the oscillator + oscillator.start(now); + oscillator.stop(now + duration); + + // Clean up when done + oscillator.onended = () => { + oscillator.disconnect(); + envelope.disconnect(); + panner.disconnect(); + }; + + // Update active notes for visualization + activeNotes.add(scaleIndex); + updateNoteIndicatorsDisplay(); + + // Remove from active notes after the note is done + setTimeout(() => { + activeNotes.delete(scaleIndex); + updateNoteIndicatorsDisplay(); + }, duration * 1000); +} + +// Choose a random waveform type +function chooseRandomWaveform() { + const waveforms = ['sine', 'triangle', 'sine', 'sine']; // More sine for smoother sound + return waveforms[Math.floor(Math.random() * waveforms.length)]; +} + +// Start the visualizer +function startVisualizer() { + if (visualizerInterval) { + clearInterval(visualizerInterval); + } + + visualizerInterval = setInterval(drawVisualizer, 30); // ~30fps +} + +// Draw the visualizer +function drawVisualizer() { + if (!analyser) return; + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyser.getByteTimeDomainData(dataArray); + + // Clear the canvas + canvasCtx.fillStyle = 'rgba(165, 200, 232, 0.2)'; + canvasCtx.fillRect(0, 0, canvasWidth, canvasHeight); + + // Draw the waveform + canvasCtx.lineWidth = 2; + canvasCtx.strokeStyle = '#6ec4ff'; + canvasCtx.beginPath(); + + const sliceWidth = canvasWidth / bufferLength; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i] / 128.0; + const y = v * canvasHeight / 2; + + if (i === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + + x += sliceWidth; + } + + canvasCtx.lineTo(canvasWidth, canvasHeight / 2); + canvasCtx.stroke(); + + // Draw circles for active frequencies + const freqData = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(freqData); + + for (let i = 0; i < 20; i++) { + const freqIndex = Math.floor(i * bufferLength / 100); + const freqValue = freqData[freqIndex]; + const radius = freqValue / 10; + + if (radius > 1) { + canvasCtx.beginPath(); + const x = Math.random() * canvasWidth; + const y = Math.random() * canvasHeight; + const gradient = canvasCtx.createRadialGradient(x, y, 0, x, y, radius); + gradient.addColorStop(0, 'rgba(110, 196, 255, 0.8)'); + gradient.addColorStop(1, 'rgba(110, 196, 255, 0)'); + canvasCtx.fillStyle = gradient; + canvasCtx.arc(x, y, radius, 0, Math.PI * 2); + canvasCtx.fill(); + } + } +} + +// Resize the canvas to match its container +function resizeCanvas() { + const container = visualizerCanvas.parentElement; + canvasWidth = container.clientWidth; + canvasHeight = container.clientHeight; + + visualizerCanvas.width = canvasWidth; + visualizerCanvas.height = canvasHeight; +} + +// Update the note indicators display +function updateNoteIndicatorsDisplay() { + for (let i = 0; i < noteIndicators.length; i++) { + if (activeNotes.has(i)) { + noteIndicators[i].classList.add('active'); + } else { + noteIndicators[i].classList.remove('active'); + } + } +} + +// Randomize all settings +function randomizeSettings() { + // Randomize scale + const scaleKeys = Object.keys(scales); + scaleSelect.value = scaleKeys[Math.floor(Math.random() * scaleKeys.length)]; + + // Randomize root note + const rootKeys = Object.keys(rootNotes); + rootSelect.value = rootKeys[Math.floor(Math.random() * rootKeys.length)]; + + // Randomize tempo (40-120 BPM) + tempoSlider.value = 40 + Math.floor(Math.random() * 80); + updateTempoValue(); + + // Randomize density (1-10) + densitySlider.value = 1 + Math.floor(Math.random() * 10); + updateDensityValue(); + + // Randomize reverb (0.2-0.8) + reverbSlider.value = (0.2 + Math.random() * 0.6).toFixed(1); + updateReverbValue(); + + // Update note indicators + updateNoteIndicators(); + + // Update reverb if already playing + if (playing && reverbNode) { + const reverbAmount = parseFloat(reverbSlider.value); + reverbNode.wetGain.gain.value = reverbAmount; + reverbNode.dryGain.gain.value = 1 - reverbAmount; + } +} + +// Update tempo display +function updateTempoValue() { + const tempo = tempoSlider.value; + tempoValue.textContent = `${tempo} BPM`; +} + +// Update density display +function updateDensityValue() { + const density = densitySlider.value; + let densityText; + + if (density <= 3) { + densityText = 'Sparse'; + } else if (density <= 7) { + densityText = 'Medium'; + } else { + densityText = 'Dense'; + } + + densityValue.textContent = densityText; +} + +// Event listener for scale changes +scaleSelect.addEventListener('change', updateNoteIndicators); + +// Initialize the application when the page loads +window.addEventListener('load', init); |
