From 3cd42426c64dccd315365e3cf3720349334c2202 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 17 Jan 2026 10:17:24 -0500 Subject: [PATCH] tweak alby panel, add sound FX --- .gitignore | 4 +- index.html | 445 +++++++++++++++++++++++++++++++++++++++++------ no-damage.html | 461 +++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 787 insertions(+), 123 deletions(-) diff --git a/.gitignore b/.gitignore index 9e170f9..1bf264d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -alby.html \ No newline at end of file +alby.html +.DS_Store +.old/ \ No newline at end of file diff --git a/index.html b/index.html index 85243f2..6a45014 100644 --- a/index.html +++ b/index.html @@ -675,27 +675,33 @@ display: block; } - /* Main slide-in panel container */ + /* Main floating modal container */ .alby-panel { position: fixed; - top: 0; - right: -400px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + opacity: 0; width: 380px; - height: 100vh; + max-height: 90vh; background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); - border-left: 6px solid #0a0a0a; + border: 6px solid #0a0a0a; + border-radius: 8px; box-shadow: inset 0 0 0 2px #3a3a3a, inset 0 0 50px rgba(0,0,0,0.9), - -10px 0 30px rgba(0,0,0,0.8); + 0 20px 60px rgba(0,0,0,0.8); z-index: 1001; - transition: right 0.3s ease-out; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; overflow-y: auto; font-family: 'Courier New', monospace; + pointer-events: none; } .alby-panel.active { - right: 0; + transform: translate(-50%, -50%) scale(1); + opacity: 1; + pointer-events: auto; } /* Noise texture overlay for worn appearance */ @@ -721,12 +727,13 @@ padding: 20px; background: linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%); border-bottom: 4px solid #000; + border-radius: 4px 4px 0 0; position: relative; } .alby-panel-title { color: #cc8800; - font-size: 16px; + font-size: 18px; font-weight: bold; text-shadow: 0 0 10px rgba(255, 170, 0, 0.5), @@ -779,7 +786,7 @@ /* Section labels with green CRT glow */ .alby-label { color: #00ff00; - font-size: 12px; + font-size: 14px; text-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00; @@ -814,7 +821,7 @@ .alby-address { color: #cc8800; - font-size: 13px; + font-size: 15px; text-shadow: 0 0 8px rgba(255, 170, 0, 0.5); word-break: break-all; position: relative; @@ -887,7 +894,7 @@ padding: 10px; color: #00ff00; font-family: 'Courier New', monospace; - font-size: 18px; + font-size: 20px; text-align: center; text-shadow: 0 0 10px #00ff00; box-shadow: inset 0 0 15px rgba(0,0,0,0.9); @@ -919,7 +926,7 @@ padding: 12px; color: #00ff00; font-family: 'Courier New', monospace; - font-size: 14px; + font-size: 16px; text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); box-shadow: inset 0 0 15px rgba(0,0,0,0.9); resize: none; @@ -952,7 +959,7 @@ border: 4px solid #000; color: #000; font-family: 'Courier New', monospace; - font-size: 16px; + font-size: 18px; font-weight: bold; letter-spacing: 2px; cursor: pointer; @@ -1148,6 +1155,221 @@ const videoPlayer = document.getElementById('videoPlayer'); const closeVideo = document.getElementById('closeVideo'); + // ======================================== + // SOUND EFFECTS MODULE - START + // Web Audio API synthesized sounds for tactile feedback + // ======================================== + const SoundEffects = { + ctx: null, + scrubOscillator: null, + scrubGain: null, + + // Initialize AudioContext (must be called after user gesture) + init() { + if (!this.ctx) { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } + // Resume if suspended (browser autoplay policy) + if (this.ctx.state === 'suspended') { + this.ctx.resume(); + } + }, + + // Play a mechanical button click sound + playButtonClick() { + this.init(); + const now = this.ctx.currentTime; + + // Create nodes for the click sound + const clickOsc = this.ctx.createOscillator(); + const clickGain = this.ctx.createGain(); + const noiseGain = this.ctx.createGain(); + + // Low frequency "thunk" component (80-150Hz) + clickOsc.type = 'sine'; + clickOsc.frequency.setValueAtTime(150, now); + clickOsc.frequency.exponentialRampToValueAtTime(80, now + 0.05); + + // Gain envelope for the thunk - fast attack, quick decay + clickGain.gain.setValueAtTime(0.6, now); + clickGain.gain.exponentialRampToValueAtTime(0.01, now + 0.08); + + clickOsc.connect(clickGain); + clickGain.connect(this.ctx.destination); + + // High frequency "click" transient using noise + const bufferSize = this.ctx.sampleRate * 0.02; // 20ms of noise + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const output = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + output[i] = Math.random() * 2 - 1; + } + + const noise = this.ctx.createBufferSource(); + noise.buffer = noiseBuffer; + + // Bandpass filter to shape the noise into a click + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 2000; + filter.Q.value = 1; + + noiseGain.gain.setValueAtTime(0.35, now); + noiseGain.gain.exponentialRampToValueAtTime(0.01, now + 0.03); + + noise.connect(filter); + filter.connect(noiseGain); + noiseGain.connect(this.ctx.destination); + + // Start and stop + clickOsc.start(now); + clickOsc.stop(now + 0.1); + noise.start(now); + noise.stop(now + 0.03); + }, + + // Play tape wind/fast-forward sound for a specified duration + // Uses filtered noise to simulate tape rushing past heads + playTapeWind(direction = 'forward', duration = 0.5) { + this.init(); + const now = this.ctx.currentTime; + + // Create noise buffer for tape wind sound + const sampleRate = this.ctx.sampleRate; + const bufferSize = sampleRate * duration; + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate); + const output = noiseBuffer.getChannelData(0); + + // Generate noise with slight amplitude modulation for realism + for (let i = 0; i < bufferSize; i++) { + // Base noise + const noise = Math.random() * 2 - 1; + // Subtle amplitude wobble to simulate motor variation + const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); + output[i] = noise * wobble; + } + + const noiseSource = this.ctx.createBufferSource(); + noiseSource.buffer = noiseBuffer; + + // Bandpass filter to shape noise into tape wind character + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 1200; // Center frequency for tape hiss + filter.Q.value = 0.5; // Wide band for natural sound + + // High shelf to add some brightness + const highShelf = this.ctx.createBiquadFilter(); + highShelf.type = 'highshelf'; + highShelf.frequency.value = 3000; + highShelf.gain.value = -6; // Reduce harshness + + // Gain with envelope + const gainNode = this.ctx.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(0.07, now + 0.03); // Quick attack + gainNode.gain.setValueAtTime(0.07, now + duration - 0.05); + gainNode.gain.linearRampToValueAtTime(0, now + duration); // Fade out + + // Connect chain + noiseSource.connect(filter); + filter.connect(highShelf); + highShelf.connect(gainNode); + gainNode.connect(this.ctx.destination); + + // Start and stop + noiseSource.start(now); + noiseSource.stop(now + duration); + }, + + // Start continuous tape wind sound (for reel dragging) + // Uses looping noise buffer for seamless continuous playback + startTapeWindLoop() { + this.init(); + + // Stop any existing loop + this.stopTapeWindLoop(); + + const now = this.ctx.currentTime; + const sampleRate = this.ctx.sampleRate; + + // Create 1 second of loopable noise + const bufferSize = sampleRate * 1; + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate); + const output = noiseBuffer.getChannelData(0); + + // Generate noise with amplitude modulation + for (let i = 0; i < bufferSize; i++) { + const noise = Math.random() * 2 - 1; + const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); + output[i] = noise * wobble; + } + + this.tapeWindSource = this.ctx.createBufferSource(); + this.tapeWindSource.buffer = noiseBuffer; + this.tapeWindSource.loop = true; + + // Bandpass filter for tape character + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 1200; + filter.Q.value = 0.5; + + // High shelf to reduce harshness + const highShelf = this.ctx.createBiquadFilter(); + highShelf.type = 'highshelf'; + highShelf.frequency.value = 3000; + highShelf.gain.value = -6; + + // Gain with fade in + this.tapeWindGain = this.ctx.createGain(); + this.tapeWindGain.gain.setValueAtTime(0, now); + this.tapeWindGain.gain.linearRampToValueAtTime(0.07, now + 0.05); + + // Connect chain + this.tapeWindSource.connect(filter); + filter.connect(highShelf); + highShelf.connect(this.tapeWindGain); + this.tapeWindGain.connect(this.ctx.destination); + + this.tapeWindSource.start(now); + }, + + // Stop the continuous tape wind sound + stopTapeWindLoop() { + if (this.tapeWindGain && this.ctx) { + const now = this.ctx.currentTime; + this.tapeWindGain.gain.linearRampToValueAtTime(0, now + 0.1); + + // Clean up after fade + const sourceToStop = this.tapeWindSource; + setTimeout(() => { + if (sourceToStop) { + try { + sourceToStop.stop(); + } catch(e) {} + } + }, 150); + + this.tapeWindSource = null; + this.tapeWindGain = null; + } + } + }; + // ======================================== + // SOUND EFFECTS MODULE - END + // ======================================== + + // ======================================== + // BUTTON CLICK SOUNDS - Add to all buttons + // Uses mousedown for immediate tactile feedback + // ======================================== + [playBtn, pauseBtn, stopBtn, prevBtn, nextBtn, ejectBtn, lightningBtn].forEach(btn => { + btn.addEventListener('mousedown', () => { + SoundEffects.playButtonClick(); + }); + }); + // Lightning button - opens Alby Lightning panel // (toggleAlbyPanel function defined in ALBY PANEL FUNCTIONALITY section below) lightningBtn.addEventListener('click', () => { @@ -1270,49 +1492,125 @@ // Previous track (prev button with bar) prevBtn.addEventListener('click', () => { console.log('Prev button clicked'); - currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; - audio.src = playlist[currentTrack].url; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - audio.load(); - audio.oncanplay = function() { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - audio.oncanplay = null; - }; + + const wasPlaying = !audio.paused; + + // Play tape wind sound + SoundEffects.playTapeWind('forward', 0.5); + + // Speed up audio to 4x during transition (if playing) + if (wasPlaying) { + audio.playbackRate = 4; + } + + // Speed up reel animation during transition + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // Delay before loading new track + setTimeout(() => { + // Reset animation speed and playback rate + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + audio.playbackRate = 1; + + currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; + audio.src = playlist[currentTrack].url; + trackNameInner.textContent = playlist[currentTrack].name; + resetTitleScroll(); // Reset title for new track + resetTapeSizes(); // Reset tape for new track + audio.load(); + audio.oncanplay = function() { + if (wasPlaying) { + const playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + startTitleScroll(); + }).catch(e => console.log('Play failed:', e)); + } + } else { + reelLeft.classList.remove('spinning'); + reelRight.classList.remove('spinning'); + tapeLeft.classList.remove('spinning'); + tapeRight.classList.remove('spinning'); + } + audio.oncanplay = null; + }; + }, 500); }); // Next track (next button with bar) nextBtn.addEventListener('click', () => { console.log('Next button clicked'); - currentTrack = (currentTrack + 1) % playlist.length; - audio.src = playlist[currentTrack].url; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - audio.load(); - audio.oncanplay = function() { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - audio.oncanplay = null; - }; + + const wasPlaying = !audio.paused; + + // Play tape wind sound + SoundEffects.playTapeWind('forward', 0.5); + + // Speed up audio to 4x during transition (if playing) + if (wasPlaying) { + audio.playbackRate = 4; + } + + // Speed up reel animation during transition + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // Delay before loading new track + setTimeout(() => { + // Reset animation speed and playback rate + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + audio.playbackRate = 1; + + currentTrack = (currentTrack + 1) % playlist.length; + audio.src = playlist[currentTrack].url; + trackNameInner.textContent = playlist[currentTrack].name; + resetTitleScroll(); // Reset title for new track + resetTapeSizes(); // Reset tape for new track + audio.load(); + audio.oncanplay = function() { + if (wasPlaying) { + const playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + startTitleScroll(); + }).catch(e => console.log('Play failed:', e)); + } + } else { + reelLeft.classList.remove('spinning'); + reelRight.classList.remove('spinning'); + tapeLeft.classList.remove('spinning'); + tapeRight.classList.remove('spinning'); + } + audio.oncanplay = null; + }; + }, 500); }); // Drag on reels to scrub audio @@ -1321,13 +1619,19 @@ let startTime = 0; let wasPlayingBeforeDrag = false; + let lastDragY = 0; + + let tapeWindPlaying = false; + function startDrag(e) { if (audio.duration) { e.preventDefault(); // Prevent text selection during drag isDragging = true; startY = e.clientY || e.touches[0].clientY; + lastDragY = startY; startTime = audio.currentTime; wasPlayingBeforeDrag = !audio.paused; + tapeWindPlaying = false; // Don't pause - let audio continue playing while scrubbing } } @@ -1336,10 +1640,35 @@ if (isDragging && audio.duration) { const currentY = e.clientY || e.touches[0].clientY; const deltaY = startY - currentY; + const instantDeltaY = lastDragY - currentY; + lastDragY = currentY; + const scrubAmount = (deltaY / 100) * audio.duration * 0.1; const newTime = Math.max(0, Math.min(audio.duration, startTime + scrubAmount)); audio.currentTime = newTime; + // Calculate scrub speed for audio effects + // Positive deltaY = dragging up = fast forward + // Negative deltaY = dragging down = rewind + const scrubSpeed = Math.min(Math.abs(instantDeltaY) / 20, 1); // Normalize to 0-1 + + // Start tape wind sound only when actually moving + if (scrubSpeed > 0.1 && !tapeWindPlaying) { + SoundEffects.startTapeWindLoop(); + tapeWindPlaying = true; + } else if (scrubSpeed <= 0.1 && tapeWindPlaying) { + SoundEffects.stopTapeWindLoop(); + tapeWindPlaying = false; + } + + // Bonus: Adjust playback rate for sped-up audio effect (both directions) + // Speed up to 4x when scrubbing in either direction + if (wasPlayingBeforeDrag && scrubSpeed > 0.1) { + audio.playbackRate = 1 + (scrubSpeed * 3); // 1x to 4x + } else if (wasPlayingBeforeDrag) { + audio.playbackRate = 1; + } + // Keep playing during scrub if (audio.paused && wasPlayingBeforeDrag) { audio.play(); @@ -1350,6 +1679,14 @@ function endDrag() { if (isDragging) { isDragging = false; + + // Stop tape wind sound and reset playback rate + if (tapeWindPlaying) { + SoundEffects.stopTapeWindLoop(); + tapeWindPlaying = false; + } + audio.playbackRate = 1; + // Continue playing if it was playing before if (wasPlayingBeforeDrag && audio.paused) { audio.play(); @@ -1576,13 +1913,15 @@ } }); - // Listen for successful payment and reset form fields + // Listen for successful payment, reset form fields, and close modal albySimpleBoost.addEventListener('success', (e) => { // Reset form to default values albyAmount.value = '1.0'; albyMemo.value = ''; updateAlbyCharCount(); updateAlbyBoostButton(); + // Close the modal after successful boost + toggleAlbyPanel(); }); // Escape key handler for Alby panel diff --git a/no-damage.html b/no-damage.html index 00c51d1..e100d5a 100644 --- a/no-damage.html +++ b/no-damage.html @@ -258,22 +258,6 @@ user-select: none; } - - /* Damage on cassette housing */ - /* commented out for this version - .cassette::before { - content: ''; - position: absolute; - top: 10px; - right: 20px; - width: 40px; - height: 30px; - background: radial-gradient(ellipse, rgba(255, 0, 255, 0.2) 0%, transparent 70%); - border: 2px solid rgba(255, 0, 255, 0.3); - border-radius: 3px; - pointer-events: none; - } - */ .cassette::after { content: ''; @@ -678,27 +662,33 @@ display: block; } - /* Main slide-in panel container */ + /* Main floating modal container */ .alby-panel { position: fixed; - top: 0; - right: -400px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + opacity: 0; width: 380px; - height: 100vh; + max-height: 90vh; background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 50%, #0f0f0f 100%); - border-left: 6px solid #0a0a0a; + border: 6px solid #0a0a0a; + border-radius: 8px; box-shadow: inset 0 0 0 2px #3a3a3a, inset 0 0 50px rgba(0,0,0,0.9), - -10px 0 30px rgba(0,0,0,0.8); + 0 20px 60px rgba(0,0,0,0.8); z-index: 1001; - transition: right 0.3s ease-out; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; overflow-y: auto; font-family: 'Courier New', monospace; + pointer-events: none; } .alby-panel.active { - right: 0; + transform: translate(-50%, -50%) scale(1); + opacity: 1; + pointer-events: auto; } /* Noise texture overlay for worn appearance */ @@ -724,12 +714,13 @@ padding: 20px; background: linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%); border-bottom: 4px solid #000; + border-radius: 4px 4px 0 0; position: relative; } .alby-panel-title { color: #cc8800; - font-size: 16px; + font-size: 18px; font-weight: bold; text-shadow: 0 0 10px rgba(255, 170, 0, 0.5), @@ -782,7 +773,7 @@ /* Section labels with green CRT glow */ .alby-label { color: #00ff00; - font-size: 12px; + font-size: 14px; text-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00; @@ -817,7 +808,7 @@ .alby-address { color: #cc8800; - font-size: 13px; + font-size: 15px; text-shadow: 0 0 8px rgba(255, 170, 0, 0.5); word-break: break-all; position: relative; @@ -890,7 +881,7 @@ padding: 10px; color: #00ff00; font-family: 'Courier New', monospace; - font-size: 18px; + font-size: 20px; text-align: center; text-shadow: 0 0 10px #00ff00; box-shadow: inset 0 0 15px rgba(0,0,0,0.9); @@ -922,7 +913,7 @@ padding: 12px; color: #00ff00; font-family: 'Courier New', monospace; - font-size: 14px; + font-size: 16px; text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); box-shadow: inset 0 0 15px rgba(0,0,0,0.9); resize: none; @@ -955,7 +946,7 @@ border: 4px solid #000; color: #000; font-family: 'Courier New', monospace; - font-size: 16px; + font-size: 18px; font-weight: bold; letter-spacing: 2px; cursor: pointer; @@ -1151,6 +1142,221 @@ const videoPlayer = document.getElementById('videoPlayer'); const closeVideo = document.getElementById('closeVideo'); + // ======================================== + // SOUND EFFECTS MODULE - START + // Web Audio API synthesized sounds for tactile feedback + // ======================================== + const SoundEffects = { + ctx: null, + scrubOscillator: null, + scrubGain: null, + + // Initialize AudioContext (must be called after user gesture) + init() { + if (!this.ctx) { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } + // Resume if suspended (browser autoplay policy) + if (this.ctx.state === 'suspended') { + this.ctx.resume(); + } + }, + + // Play a mechanical button click sound + playButtonClick() { + this.init(); + const now = this.ctx.currentTime; + + // Create nodes for the click sound + const clickOsc = this.ctx.createOscillator(); + const clickGain = this.ctx.createGain(); + const noiseGain = this.ctx.createGain(); + + // Low frequency "thunk" component (80-150Hz) + clickOsc.type = 'sine'; + clickOsc.frequency.setValueAtTime(150, now); + clickOsc.frequency.exponentialRampToValueAtTime(80, now + 0.05); + + // Gain envelope for the thunk - fast attack, quick decay + clickGain.gain.setValueAtTime(0.6, now); + clickGain.gain.exponentialRampToValueAtTime(0.01, now + 0.08); + + clickOsc.connect(clickGain); + clickGain.connect(this.ctx.destination); + + // High frequency "click" transient using noise + const bufferSize = this.ctx.sampleRate * 0.02; // 20ms of noise + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const output = noiseBuffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + output[i] = Math.random() * 2 - 1; + } + + const noise = this.ctx.createBufferSource(); + noise.buffer = noiseBuffer; + + // Bandpass filter to shape the noise into a click + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 2000; + filter.Q.value = 1; + + noiseGain.gain.setValueAtTime(0.35, now); + noiseGain.gain.exponentialRampToValueAtTime(0.01, now + 0.03); + + noise.connect(filter); + filter.connect(noiseGain); + noiseGain.connect(this.ctx.destination); + + // Start and stop + clickOsc.start(now); + clickOsc.stop(now + 0.1); + noise.start(now); + noise.stop(now + 0.03); + }, + + // Play tape wind/fast-forward sound for a specified duration + // Uses filtered noise to simulate tape rushing past heads + playTapeWind(direction = 'forward', duration = 0.5) { + this.init(); + const now = this.ctx.currentTime; + + // Create noise buffer for tape wind sound + const sampleRate = this.ctx.sampleRate; + const bufferSize = sampleRate * duration; + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate); + const output = noiseBuffer.getChannelData(0); + + // Generate noise with slight amplitude modulation for realism + for (let i = 0; i < bufferSize; i++) { + // Base noise + const noise = Math.random() * 2 - 1; + // Subtle amplitude wobble to simulate motor variation + const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); + output[i] = noise * wobble; + } + + const noiseSource = this.ctx.createBufferSource(); + noiseSource.buffer = noiseBuffer; + + // Bandpass filter to shape noise into tape wind character + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 1200; // Center frequency for tape hiss + filter.Q.value = 0.5; // Wide band for natural sound + + // High shelf to add some brightness + const highShelf = this.ctx.createBiquadFilter(); + highShelf.type = 'highshelf'; + highShelf.frequency.value = 3000; + highShelf.gain.value = -6; // Reduce harshness + + // Gain with envelope + const gainNode = this.ctx.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(0.07, now + 0.03); // Quick attack + gainNode.gain.setValueAtTime(0.07, now + duration - 0.05); + gainNode.gain.linearRampToValueAtTime(0, now + duration); // Fade out + + // Connect chain + noiseSource.connect(filter); + filter.connect(highShelf); + highShelf.connect(gainNode); + gainNode.connect(this.ctx.destination); + + // Start and stop + noiseSource.start(now); + noiseSource.stop(now + duration); + }, + + // Start continuous tape wind sound (for reel dragging) + // Uses looping noise buffer for seamless continuous playback + startTapeWindLoop() { + this.init(); + + // Stop any existing loop + this.stopTapeWindLoop(); + + const now = this.ctx.currentTime; + const sampleRate = this.ctx.sampleRate; + + // Create 1 second of loopable noise + const bufferSize = sampleRate * 1; + const noiseBuffer = this.ctx.createBuffer(1, bufferSize, sampleRate); + const output = noiseBuffer.getChannelData(0); + + // Generate noise with amplitude modulation + for (let i = 0; i < bufferSize; i++) { + const noise = Math.random() * 2 - 1; + const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2); + output[i] = noise * wobble; + } + + this.tapeWindSource = this.ctx.createBufferSource(); + this.tapeWindSource.buffer = noiseBuffer; + this.tapeWindSource.loop = true; + + // Bandpass filter for tape character + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 1200; + filter.Q.value = 0.5; + + // High shelf to reduce harshness + const highShelf = this.ctx.createBiquadFilter(); + highShelf.type = 'highshelf'; + highShelf.frequency.value = 3000; + highShelf.gain.value = -6; + + // Gain with fade in + this.tapeWindGain = this.ctx.createGain(); + this.tapeWindGain.gain.setValueAtTime(0, now); + this.tapeWindGain.gain.linearRampToValueAtTime(0.07, now + 0.05); + + // Connect chain + this.tapeWindSource.connect(filter); + filter.connect(highShelf); + highShelf.connect(this.tapeWindGain); + this.tapeWindGain.connect(this.ctx.destination); + + this.tapeWindSource.start(now); + }, + + // Stop the continuous tape wind sound + stopTapeWindLoop() { + if (this.tapeWindGain && this.ctx) { + const now = this.ctx.currentTime; + this.tapeWindGain.gain.linearRampToValueAtTime(0, now + 0.1); + + // Clean up after fade + const sourceToStop = this.tapeWindSource; + setTimeout(() => { + if (sourceToStop) { + try { + sourceToStop.stop(); + } catch(e) {} + } + }, 150); + + this.tapeWindSource = null; + this.tapeWindGain = null; + } + } + }; + // ======================================== + // SOUND EFFECTS MODULE - END + // ======================================== + + // ======================================== + // BUTTON CLICK SOUNDS - Add to all buttons + // Uses mousedown for immediate tactile feedback + // ======================================== + [playBtn, pauseBtn, stopBtn, prevBtn, nextBtn, ejectBtn, lightningBtn].forEach(btn => { + btn.addEventListener('mousedown', () => { + SoundEffects.playButtonClick(); + }); + }); + // Lightning button - opens Alby Lightning panel // (toggleAlbyPanel function defined in ALBY PANEL FUNCTIONALITY section below) lightningBtn.addEventListener('click', () => { @@ -1273,49 +1479,125 @@ // Previous track (prev button with bar) prevBtn.addEventListener('click', () => { console.log('Prev button clicked'); - currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; - audio.src = playlist[currentTrack].url; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - audio.load(); - audio.oncanplay = function() { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - audio.oncanplay = null; - }; + + const wasPlaying = !audio.paused; + + // Play tape wind sound + SoundEffects.playTapeWind('forward', 0.5); + + // Speed up audio to 4x during transition (if playing) + if (wasPlaying) { + audio.playbackRate = 4; + } + + // Speed up reel animation during transition + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // Delay before loading new track + setTimeout(() => { + // Reset animation speed and playback rate + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + audio.playbackRate = 1; + + currentTrack = (currentTrack - 1 + playlist.length) % playlist.length; + audio.src = playlist[currentTrack].url; + trackNameInner.textContent = playlist[currentTrack].name; + resetTitleScroll(); // Reset title for new track + resetTapeSizes(); // Reset tape for new track + audio.load(); + audio.oncanplay = function() { + if (wasPlaying) { + const playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + startTitleScroll(); + }).catch(e => console.log('Play failed:', e)); + } + } else { + reelLeft.classList.remove('spinning'); + reelRight.classList.remove('spinning'); + tapeLeft.classList.remove('spinning'); + tapeRight.classList.remove('spinning'); + } + audio.oncanplay = null; + }; + }, 500); }); // Next track (next button with bar) nextBtn.addEventListener('click', () => { console.log('Next button clicked'); - currentTrack = (currentTrack + 1) % playlist.length; - audio.src = playlist[currentTrack].url; - trackNameInner.textContent = playlist[currentTrack].name; - resetTitleScroll(); // Reset title for new track - resetTapeSizes(); // Reset tape for new track - audio.load(); - audio.oncanplay = function() { - const playPromise = audio.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - reelLeft.classList.add('spinning'); - reelRight.classList.add('spinning'); - tapeLeft.classList.add('spinning'); - tapeRight.classList.add('spinning'); - startTitleScroll(); - }).catch(e => console.log('Play failed:', e)); - } - audio.oncanplay = null; - }; + + const wasPlaying = !audio.paused; + + // Play tape wind sound + SoundEffects.playTapeWind('forward', 0.5); + + // Speed up audio to 4x during transition (if playing) + if (wasPlaying) { + audio.playbackRate = 4; + } + + // Speed up reel animation during transition + reelLeft.style.animationDuration = '0.3s'; + reelRight.style.animationDuration = '0.3s'; + tapeLeft.style.animationDuration = '0.3s'; + tapeRight.style.animationDuration = '0.3s'; + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + + // Delay before loading new track + setTimeout(() => { + // Reset animation speed and playback rate + reelLeft.style.animationDuration = ''; + reelRight.style.animationDuration = ''; + tapeLeft.style.animationDuration = ''; + tapeRight.style.animationDuration = ''; + audio.playbackRate = 1; + + currentTrack = (currentTrack + 1) % playlist.length; + audio.src = playlist[currentTrack].url; + trackNameInner.textContent = playlist[currentTrack].name; + resetTitleScroll(); // Reset title for new track + resetTapeSizes(); // Reset tape for new track + audio.load(); + audio.oncanplay = function() { + if (wasPlaying) { + const playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + reelLeft.classList.add('spinning'); + reelRight.classList.add('spinning'); + tapeLeft.classList.add('spinning'); + tapeRight.classList.add('spinning'); + startTitleScroll(); + }).catch(e => console.log('Play failed:', e)); + } + } else { + reelLeft.classList.remove('spinning'); + reelRight.classList.remove('spinning'); + tapeLeft.classList.remove('spinning'); + tapeRight.classList.remove('spinning'); + } + audio.oncanplay = null; + }; + }, 500); }); // Drag on reels to scrub audio @@ -1324,13 +1606,19 @@ let startTime = 0; let wasPlayingBeforeDrag = false; + let lastDragY = 0; + + let tapeWindPlaying = false; + function startDrag(e) { if (audio.duration) { e.preventDefault(); // Prevent text selection during drag isDragging = true; startY = e.clientY || e.touches[0].clientY; + lastDragY = startY; startTime = audio.currentTime; wasPlayingBeforeDrag = !audio.paused; + tapeWindPlaying = false; // Don't pause - let audio continue playing while scrubbing } } @@ -1339,10 +1627,35 @@ if (isDragging && audio.duration) { const currentY = e.clientY || e.touches[0].clientY; const deltaY = startY - currentY; + const instantDeltaY = lastDragY - currentY; + lastDragY = currentY; + const scrubAmount = (deltaY / 100) * audio.duration * 0.1; const newTime = Math.max(0, Math.min(audio.duration, startTime + scrubAmount)); audio.currentTime = newTime; + // Calculate scrub speed for audio effects + // Positive deltaY = dragging up = fast forward + // Negative deltaY = dragging down = rewind + const scrubSpeed = Math.min(Math.abs(instantDeltaY) / 20, 1); // Normalize to 0-1 + + // Start tape wind sound only when actually moving + if (scrubSpeed > 0.1 && !tapeWindPlaying) { + SoundEffects.startTapeWindLoop(); + tapeWindPlaying = true; + } else if (scrubSpeed <= 0.1 && tapeWindPlaying) { + SoundEffects.stopTapeWindLoop(); + tapeWindPlaying = false; + } + + // Bonus: Adjust playback rate for sped-up audio effect (both directions) + // Speed up to 4x when scrubbing in either direction + if (wasPlayingBeforeDrag && scrubSpeed > 0.1) { + audio.playbackRate = 1 + (scrubSpeed * 3); // 1x to 4x + } else if (wasPlayingBeforeDrag) { + audio.playbackRate = 1; + } + // Keep playing during scrub if (audio.paused && wasPlayingBeforeDrag) { audio.play(); @@ -1353,6 +1666,14 @@ function endDrag() { if (isDragging) { isDragging = false; + + // Stop tape wind sound and reset playback rate + if (tapeWindPlaying) { + SoundEffects.stopTapeWindLoop(); + tapeWindPlaying = false; + } + audio.playbackRate = 1; + // Continue playing if it was playing before if (wasPlayingBeforeDrag && audio.paused) { audio.play(); @@ -1579,13 +1900,15 @@ } }); - // Listen for successful payment and reset form fields + // Listen for successful payment, reset form fields, and close modal albySimpleBoost.addEventListener('success', (e) => { // Reset form to default values albyAmount.value = '1.0'; albyMemo.value = ''; updateAlbyCharCount(); updateAlbyBoostButton(); + // Close the modal after successful boost + toggleAlbyPanel(); }); // Escape key handler for Alby panel