|
|
|
|
@@ -63,41 +63,56 @@ let durationsLoaded = false; // Flag indicating when all durations are known
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load metadata for all tracks to get their durations
|
|
|
|
|
* Uses a temporary audio element to fetch duration without full download
|
|
|
|
|
* Uses parallel loading for better performance
|
|
|
|
|
* Falls back to 240 seconds (4 min) if metadata can't be loaded
|
|
|
|
|
*/
|
|
|
|
|
async function loadAllDurations() {
|
|
|
|
|
console.log('Loading track durations...');
|
|
|
|
|
trackDurations = [];
|
|
|
|
|
trackDurations = new Array(playlist.length);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < playlist.length; i++) {
|
|
|
|
|
try {
|
|
|
|
|
// For track 0, use the main audio element since loadTrack(0) already loads it
|
|
|
|
|
if (i === 0 && currentTrack === 0) {
|
|
|
|
|
// Wait for main audio to load metadata if not already
|
|
|
|
|
if (audio.duration && !isNaN(audio.duration)) {
|
|
|
|
|
trackDurations[i] = audio.duration;
|
|
|
|
|
} else {
|
|
|
|
|
// Wait for main audio metadata
|
|
|
|
|
trackDurations[i] = await new Promise((resolve) => {
|
|
|
|
|
if (audio.duration && !isNaN(audio.duration)) {
|
|
|
|
|
resolve(audio.duration);
|
|
|
|
|
} else {
|
|
|
|
|
audio.addEventListener('loadedmetadata', function onMeta() {
|
|
|
|
|
audio.removeEventListener('loadedmetadata', onMeta);
|
|
|
|
|
resolve(audio.duration);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Load track 0 from main audio element first
|
|
|
|
|
try {
|
|
|
|
|
if (currentTrack === 0) {
|
|
|
|
|
if (audio.duration && !isNaN(audio.duration)) {
|
|
|
|
|
trackDurations[0] = audio.duration;
|
|
|
|
|
} else {
|
|
|
|
|
// For other tracks, use temp audio element
|
|
|
|
|
trackDurations[i] = await getTrackDuration(playlist[i].url);
|
|
|
|
|
trackDurations[0] = await new Promise((resolve) => {
|
|
|
|
|
if (audio.duration && !isNaN(audio.duration)) {
|
|
|
|
|
resolve(audio.duration);
|
|
|
|
|
} else {
|
|
|
|
|
audio.addEventListener('loadedmetadata', function onMeta() {
|
|
|
|
|
audio.removeEventListener('loadedmetadata', onMeta);
|
|
|
|
|
resolve(audio.duration);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
console.log(`Track ${i + 1} duration: ${formatTime(trackDurations[i])}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn(`Failed to get duration for track ${i + 1}, using fallback`);
|
|
|
|
|
trackDurations[i] = 240; // 4 minute fallback
|
|
|
|
|
} else {
|
|
|
|
|
trackDurations[0] = await getTrackDuration(playlist[0].url);
|
|
|
|
|
}
|
|
|
|
|
console.log(`Track 1 duration: ${formatTime(trackDurations[0])}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Failed to get duration for track 1, using fallback');
|
|
|
|
|
trackDurations[0] = 240;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load remaining tracks in parallel for faster loading
|
|
|
|
|
if (playlist.length > 1) {
|
|
|
|
|
const otherTrackPromises = playlist.slice(1).map(async (track, index) => {
|
|
|
|
|
const trackIndex = index + 1;
|
|
|
|
|
try {
|
|
|
|
|
const duration = await getTrackDuration(track.url);
|
|
|
|
|
console.log(`Track ${trackIndex + 1} duration: ${formatTime(duration)}`);
|
|
|
|
|
return duration;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn(`Failed to get duration for track ${trackIndex + 1}, using fallback`);
|
|
|
|
|
return 240; // 4 minute fallback
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const otherDurations = await Promise.all(otherTrackPromises);
|
|
|
|
|
for (let i = 0; i < otherDurations.length; i++) {
|
|
|
|
|
trackDurations[i + 1] = otherDurations[i];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -227,14 +242,20 @@ function getTrackStartPosition(trackIndex) {
|
|
|
|
|
// ========================================
|
|
|
|
|
// SOUND EFFECTS MODULE - START
|
|
|
|
|
// Web Audio API synthesized sounds for tactile feedback
|
|
|
|
|
// Pre-generated noise buffers for better performance
|
|
|
|
|
// ========================================
|
|
|
|
|
const SoundEffects = {
|
|
|
|
|
ctx: null,
|
|
|
|
|
// Cached noise buffers - generated once, reused for all sounds
|
|
|
|
|
clickNoiseBuffer: null,
|
|
|
|
|
tapeWindNoiseBuffer: null,
|
|
|
|
|
|
|
|
|
|
// Initialize AudioContext (must be called after user gesture)
|
|
|
|
|
// Initialize AudioContext and pre-generate noise buffers (must be called after user gesture)
|
|
|
|
|
init() {
|
|
|
|
|
if (!this.ctx) {
|
|
|
|
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
// Pre-generate noise buffers on first init
|
|
|
|
|
this.generateNoiseBuffers();
|
|
|
|
|
}
|
|
|
|
|
// Resume if suspended (browser autoplay policy)
|
|
|
|
|
if (this.ctx.state === 'suspended') {
|
|
|
|
|
@@ -242,6 +263,29 @@ const SoundEffects = {
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Pre-generate all noise buffers once for reuse
|
|
|
|
|
generateNoiseBuffers() {
|
|
|
|
|
const sampleRate = this.ctx.sampleRate;
|
|
|
|
|
|
|
|
|
|
// Generate click noise buffer (20ms)
|
|
|
|
|
const clickBufferSize = Math.floor(sampleRate * 0.02);
|
|
|
|
|
this.clickNoiseBuffer = this.ctx.createBuffer(1, clickBufferSize, sampleRate);
|
|
|
|
|
const clickOutput = this.clickNoiseBuffer.getChannelData(0);
|
|
|
|
|
for (let i = 0; i < clickBufferSize; i++) {
|
|
|
|
|
clickOutput[i] = Math.random() * 2 - 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate tape wind noise buffer (1 second with wobble)
|
|
|
|
|
const windBufferSize = sampleRate * 1;
|
|
|
|
|
this.tapeWindNoiseBuffer = this.ctx.createBuffer(1, windBufferSize, sampleRate);
|
|
|
|
|
const windOutput = this.tapeWindNoiseBuffer.getChannelData(0);
|
|
|
|
|
for (let i = 0; i < windBufferSize; i++) {
|
|
|
|
|
const noise = Math.random() * 2 - 1;
|
|
|
|
|
const wobble = 1 + 0.1 * Math.sin(i / sampleRate * 20 * Math.PI * 2);
|
|
|
|
|
windOutput[i] = noise * wobble;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Play a mechanical button click sound
|
|
|
|
|
playButtonClick() {
|
|
|
|
|
this.init();
|
|
|
|
|
@@ -264,16 +308,9 @@ const SoundEffects = {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// High frequency "click" transient using cached noise buffer
|
|
|
|
|
const noise = this.ctx.createBufferSource();
|
|
|
|
|
noise.buffer = noiseBuffer;
|
|
|
|
|
noise.buffer = this.clickNoiseBuffer;
|
|
|
|
|
|
|
|
|
|
// Bandpass filter to shape the noise into a click
|
|
|
|
|
const filter = this.ctx.createBiquadFilter();
|
|
|
|
|
@@ -296,7 +333,7 @@ const SoundEffects = {
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Start continuous tape wind sound (for reel dragging)
|
|
|
|
|
// Uses looping noise buffer for seamless continuous playback
|
|
|
|
|
// Uses cached looping noise buffer for seamless continuous playback
|
|
|
|
|
startTapeWindLoop() {
|
|
|
|
|
this.init();
|
|
|
|
|
|
|
|
|
|
@@ -304,22 +341,10 @@ const SoundEffects = {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use cached tape wind noise buffer
|
|
|
|
|
this.tapeWindSource = this.ctx.createBufferSource();
|
|
|
|
|
this.tapeWindSource.buffer = noiseBuffer;
|
|
|
|
|
this.tapeWindSource.buffer = this.tapeWindNoiseBuffer;
|
|
|
|
|
this.tapeWindSource.loop = true;
|
|
|
|
|
|
|
|
|
|
// Bandpass filter for tape character
|
|
|
|
|
@@ -611,22 +636,49 @@ lightningBtn.addEventListener('click', () => {
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// TITLE SCROLL ANIMATION (JavaScript controlled bounce)
|
|
|
|
|
// Uses requestAnimationFrame for better performance
|
|
|
|
|
// Moves 1px at a time, bounces at edges, pauses with player
|
|
|
|
|
// ========================================
|
|
|
|
|
let titleScrollPosition = 0;
|
|
|
|
|
let titleScrollDirection = 1; // 1 = moving left (text shifts left), -1 = moving right
|
|
|
|
|
let titleScrollInterval = null;
|
|
|
|
|
let titleScrollRAF = null;
|
|
|
|
|
let titleScrollLastTime = 0;
|
|
|
|
|
const SCROLL_SPEED = 333; // milliseconds between 1px moves (1 second per ~3 pixels)
|
|
|
|
|
|
|
|
|
|
// Cached dimensions - updated only when track changes or on resize
|
|
|
|
|
let cachedContainerWidth = 0;
|
|
|
|
|
let cachedTextWidth = 0;
|
|
|
|
|
let cachedMaxScroll = 0;
|
|
|
|
|
let cachedTextLonger = false;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cache the container and text dimensions for title scroll
|
|
|
|
|
* Call this when track changes or window resizes
|
|
|
|
|
*/
|
|
|
|
|
function cacheTitleDimensions() {
|
|
|
|
|
cachedContainerWidth = trackName.offsetWidth;
|
|
|
|
|
cachedTextWidth = trackNameInner.offsetWidth;
|
|
|
|
|
cachedTextLonger = cachedTextWidth > cachedContainerWidth;
|
|
|
|
|
|
|
|
|
|
// Calculate max scroll
|
|
|
|
|
if (cachedTextLonger) {
|
|
|
|
|
cachedMaxScroll = cachedTextWidth - cachedContainerWidth;
|
|
|
|
|
} else {
|
|
|
|
|
cachedMaxScroll = cachedContainerWidth - cachedTextWidth;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startTitleScroll() {
|
|
|
|
|
if (titleScrollInterval) return; // Already scrolling
|
|
|
|
|
titleScrollInterval = setInterval(updateTitleScroll, SCROLL_SPEED);
|
|
|
|
|
if (titleScrollRAF) return; // Already scrolling
|
|
|
|
|
cacheTitleDimensions();
|
|
|
|
|
titleScrollLastTime = performance.now();
|
|
|
|
|
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopTitleScroll() {
|
|
|
|
|
if (titleScrollInterval) {
|
|
|
|
|
clearInterval(titleScrollInterval);
|
|
|
|
|
titleScrollInterval = null;
|
|
|
|
|
if (titleScrollRAF) {
|
|
|
|
|
cancelAnimationFrame(titleScrollRAF);
|
|
|
|
|
titleScrollRAF = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -635,46 +687,52 @@ function resetTitleScroll() {
|
|
|
|
|
titleScrollPosition = 0;
|
|
|
|
|
titleScrollDirection = 1;
|
|
|
|
|
trackNameInner.style.left = '0px';
|
|
|
|
|
cacheTitleDimensions();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* RAF-based scroll loop - tracks elapsed time to maintain consistent speed
|
|
|
|
|
*/
|
|
|
|
|
function titleScrollLoop(currentTime) {
|
|
|
|
|
const elapsed = currentTime - titleScrollLastTime;
|
|
|
|
|
|
|
|
|
|
// Only update position when enough time has passed (maintains same speed as before)
|
|
|
|
|
if (elapsed >= SCROLL_SPEED) {
|
|
|
|
|
titleScrollLastTime = currentTime - (elapsed % SCROLL_SPEED);
|
|
|
|
|
updateTitleScroll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
titleScrollRAF = requestAnimationFrame(titleScrollLoop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateTitleScroll() {
|
|
|
|
|
const containerWidth = trackName.offsetWidth;
|
|
|
|
|
const textWidth = trackNameInner.offsetWidth;
|
|
|
|
|
|
|
|
|
|
// Calculate max scroll - text scrolls until its right edge hits container's right edge
|
|
|
|
|
// If text is shorter than container, scroll until text's left edge hits container's right edge
|
|
|
|
|
let maxScroll;
|
|
|
|
|
if (textWidth > containerWidth) {
|
|
|
|
|
// Text is longer - scroll until right edge of text reaches right edge of container
|
|
|
|
|
maxScroll = textWidth - containerWidth;
|
|
|
|
|
} else {
|
|
|
|
|
// Text is shorter - scroll across the container width but keep text visible
|
|
|
|
|
// Text starts at left (0), scrolls right until left edge is at (containerWidth - textWidth)
|
|
|
|
|
maxScroll = containerWidth - textWidth;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Move 1px in current direction
|
|
|
|
|
titleScrollPosition += titleScrollDirection;
|
|
|
|
|
|
|
|
|
|
// Bounce at edges
|
|
|
|
|
if (titleScrollPosition >= maxScroll) {
|
|
|
|
|
titleScrollPosition = maxScroll;
|
|
|
|
|
if (titleScrollPosition >= cachedMaxScroll) {
|
|
|
|
|
titleScrollPosition = cachedMaxScroll;
|
|
|
|
|
titleScrollDirection = -1; // Start moving back
|
|
|
|
|
} else if (titleScrollPosition <= 0) {
|
|
|
|
|
titleScrollPosition = 0;
|
|
|
|
|
titleScrollDirection = 1; // Start moving forward
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply position
|
|
|
|
|
// If text is longer: shift text LEFT (negative value) so we see the end
|
|
|
|
|
// If text is shorter: shift text RIGHT (positive value) to scroll across display
|
|
|
|
|
if (textWidth > containerWidth) {
|
|
|
|
|
// Apply position using cached text length comparison
|
|
|
|
|
if (cachedTextLonger) {
|
|
|
|
|
trackNameInner.style.left = -titleScrollPosition + 'px';
|
|
|
|
|
} else {
|
|
|
|
|
trackNameInner.style.left = titleScrollPosition + 'px';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recache dimensions on window resize
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
if (titleScrollRAF) {
|
|
|
|
|
cacheTitleDimensions();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// AUDIO LOADING HELPERS - START
|
|
|
|
|
// Unified helpers for loading tracks and waiting for audio ready
|
|
|
|
|
@@ -1138,11 +1196,27 @@ function setTapeSizesAtProgress(progress) {
|
|
|
|
|
|
|
|
|
|
resetTapeSizes();
|
|
|
|
|
|
|
|
|
|
// Update time display and tape sizes
|
|
|
|
|
// Throttle timeupdate for better performance
|
|
|
|
|
let lastTimeUpdateTime = 0;
|
|
|
|
|
let lastFormattedTime = '';
|
|
|
|
|
const TIME_UPDATE_THROTTLE = 200; // Update at most 5 times per second
|
|
|
|
|
|
|
|
|
|
// Update time display and tape sizes (throttled)
|
|
|
|
|
audio.addEventListener('timeupdate', () => {
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
if (now - lastTimeUpdateTime < TIME_UPDATE_THROTTLE) return;
|
|
|
|
|
lastTimeUpdateTime = now;
|
|
|
|
|
|
|
|
|
|
const current = formatTime(audio.currentTime);
|
|
|
|
|
const duration = formatTime(audio.duration);
|
|
|
|
|
timeDisplay.textContent = `${current} / ${duration}`;
|
|
|
|
|
const formatted = `${current} / ${duration}`;
|
|
|
|
|
|
|
|
|
|
// Skip DOM update if time display hasn't changed
|
|
|
|
|
if (formatted !== lastFormattedTime) {
|
|
|
|
|
lastFormattedTime = formatted;
|
|
|
|
|
timeDisplay.textContent = formatted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTapeSizes();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|