-
+
diff --git a/src/app.js b/src/app.js
index 59a081b..0a92226 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();
});