diff --git a/js/audio-context.js b/js/audio-context.js index aff161e..a845c93 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -81,12 +81,14 @@ class AudioContextManager { this.analyser = null; this.filters = []; this.outputNode = null; + this.volumeNode = null; this.isInitialized = false; this.isEQEnabled = false; this.isMonoAudioEnabled = false; this.monoMergerNode = null; this.currentGains = new Array(16).fill(0); this.audio = null; + this.currentVolume = 1.0; // Callbacks for audio graph changes (for visualizers like Butterchurn) this._graphChangeCallbacks = []; @@ -170,6 +172,10 @@ class AudioContextManager { this.outputNode = this.audioContext.createGain(); this.outputNode.gain.value = 1; + // Create volume node + this.volumeNode = this.audioContext.createGain(); + this.volumeNode.gain.value = this.currentVolume; + // Create mono audio merger node this.monoMergerNode = this.audioContext.createChannelMerger(2); @@ -199,6 +205,11 @@ class AudioContextManager { // Disconnect everything first this.source.disconnect(); this.outputNode.disconnect(); + if (this.volumeNode) { + this.volumeNode.disconnect(); + } + this.analyser.disconnect(); + if (this.monoMergerNode) { try { this.monoMergerNode.disconnect(); @@ -207,13 +218,6 @@ class AudioContextManager { } } - // Only disconnect destination from analyser to preserve other taps (like Butterchurn) - try { - this.analyser.disconnect(this.audioContext.destination); - } catch { - // Ignore if not connected - } - let lastNode = this.source; // Apply mono audio if enabled @@ -234,15 +238,17 @@ class AudioContextManager { } if (this.isEQEnabled && this.filters.length > 0) { - // EQ enabled: lastNode -> EQ filters -> output -> analyser -> destination + // EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination lastNode.connect(this.filters[0]); this.outputNode.connect(this.analyser); - this.analyser.connect(this.audioContext.destination); + this.analyser.connect(this.volumeNode); + this.volumeNode.connect(this.audioContext.destination); console.log('[AudioContext] EQ connected'); } else { - // EQ disabled: lastNode -> analyser -> destination + // EQ disabled: lastNode -> analyser -> volume -> destination lastNode.connect(this.analyser); - this.analyser.connect(this.audioContext.destination); + this.analyser.connect(this.volumeNode); + this.volumeNode.connect(this.audioContext.destination); console.log('[AudioContext] EQ bypassed'); } @@ -313,6 +319,18 @@ class AudioContextManager { return this.isInitialized; } + /** + * Set the volume level (0.0 to 1.0) + * @param {number} value - Volume level + */ + setVolume(value) { + this.currentVolume = Math.max(0, Math.min(1, value)); + if (this.volumeNode && this.audioContext) { + const now = this.audioContext.currentTime; + this.volumeNode.gain.setTargetAtTime(this.currentVolume, now, 0.01); + } + } + /** * Toggle EQ on/off */ diff --git a/js/events.js b/js/events.js index 421b93e..e420f51 100644 --- a/js/events.js +++ b/js/events.js @@ -66,11 +66,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { scrobbler.updateNowPlaying(player.currentTrack); } - // Resume AudioContext for waveform on mobile (iOS) - if (waveformGenerator.audioContext.state === 'suspended') { - waveformGenerator.audioContext.resume(); - } - updateWaveform(); } diff --git a/js/player.js b/js/player.js index b246c7b..4839f4e 100644 --- a/js/player.js +++ b/js/player.js @@ -111,8 +111,17 @@ export class Player { // Calculate effective volume const effectiveVolume = curvedVolume * scale; - // Apply to audio element - this.audio.volume = Math.max(0, Math.min(1, effectiveVolume)); + // Apply to audio element and/or Web Audio graph + if (audioContextManager.isReady()) { + // If Web Audio is active, we apply volume there for better compatibility + // Especially on Linux where audio.volume might not affect the Web Audio graph + // We set audio.volume to 1.0 to avoid double-reduction, or keep it synced? + // Some browsers require audio.volume to be set for system media controls to show volume + this.audio.volume = 1.0; + audioContextManager.setVolume(effectiveVolume); + } else { + this.audio.volume = Math.max(0, Math.min(1, effectiveVolume)); + } } applyAudioEffects() { @@ -213,6 +222,7 @@ export class Player { // Must happen before audio.play() or audio won't route through Web Audio if (!audioContextManager.isReady()) { audioContextManager.init(this.audio); + this.applyReplayGain(); } await audioContextManager.resume(); @@ -233,6 +243,7 @@ export class Player { // Ensure audio context is active for iOS lock screen controls if (!audioContextManager.isReady()) { audioContextManager.init(this.audio); + this.applyReplayGain(); } await audioContextManager.resume(); this.playPrev(); @@ -242,6 +253,7 @@ export class Player { // Ensure audio context is active for iOS lock screen controls if (!audioContextManager.isReady()) { audioContextManager.init(this.audio); + this.applyReplayGain(); } await audioContextManager.resume(); this.playNext(); diff --git a/js/waveform.js b/js/waveform.js index 52666dd..002cecf 100644 --- a/js/waveform.js +++ b/js/waveform.js @@ -2,7 +2,9 @@ export class WaveformGenerator { constructor() { - this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + // Use OfflineAudioContext to prevent creating unnecessary OS audio streams + // decodeAudioData doesn't require a real-time AudioContext + this.audioContext = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100); this.cache = new Map(); }