@@ -3930,7 +3994,9 @@
If Monochrome has been useful to you and you're able to, consider making a donation.
It helps pay for the domain, and you get to support us :)
-
+
+
+
diff --git a/js/audio-context.js b/js/audio-context.js
index c30c067..d16c721 100644
--- a/js/audio-context.js
+++ b/js/audio-context.js
@@ -95,6 +95,16 @@ class AudioContextManager {
this._loadSettings();
}
+ /**
+ * Calculate playback rate for pitch shift
+ * @param {number} semitones - Pitch shift in semitones (-12 to +12)
+ * @returns {number} - Playback rate multiplier
+ */
+ getPitchRate(semitones) {
+ // Convert semitones to playback rate: rate = 2^(semitones/12)
+ return Math.pow(2, semitones / 12);
+ }
+
/**
* Register a callback to be called when audio graph is reconnected
* @param {Function} callback - Function to call when graph changes
diff --git a/js/player.js b/js/player.js
index 8f3ce90..6e46246 100644
--- a/js/player.js
+++ b/js/player.js
@@ -9,7 +9,13 @@ import {
getTrackYearDisplay,
createQualityBadgeHTML,
} from './utils.js';
-import { queueManager, replayGainSettings, trackDateSettings, exponentialVolumeSettings } from './storage.js';
+import {
+ queueManager,
+ replayGainSettings,
+ trackDateSettings,
+ exponentialVolumeSettings,
+ audioEffectsSettings,
+} from './storage.js';
import { audioContextManager } from './audio-context.js';
export class Player {
@@ -109,6 +115,56 @@ export class Player {
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
}
+ applyAudioEffects() {
+ const speed = audioEffectsSettings.getSpeed();
+ const pitchShift = audioEffectsSettings.getPitch();
+ const preservePitch = audioEffectsSettings.getPreservePitch();
+
+ // Calculate pitch rate: 2^(semitones/12)
+ const pitchRate = Math.pow(2, pitchShift / 12);
+
+ // When preservePitch is enabled, playbackRate only affects speed
+ // When disabled, playbackRate affects both speed and pitch
+ // To shift pitch without changing speed (when preservePitch is off),
+ // we need to compensate the speed
+ if (preservePitch) {
+ // Pitch is preserved, playbackRate controls speed only
+ if (this.audio.playbackRate !== speed) {
+ this.audio.playbackRate = speed;
+ }
+ } else {
+ // playbackRate affects both speed and pitch
+ // Combine speed and pitch: finalRate = speed * pitchRate
+ const finalRate = speed * pitchRate;
+ if (this.audio.playbackRate !== finalRate) {
+ this.audio.playbackRate = finalRate;
+ }
+ }
+
+ // Apply pitch preservation setting
+ if (this.audio.preservesPitch !== preservePitch) {
+ this.audio.preservesPitch = preservePitch;
+ }
+ }
+
+ setPlaybackSpeed(speed) {
+ const validSpeed = Math.max(0.5, Math.min(2.0, parseFloat(speed) || 1.0));
+ audioEffectsSettings.setSpeed(validSpeed);
+ this.applyAudioEffects();
+ }
+
+ setPitchShift(semitones) {
+ const validPitch = Math.max(-12, Math.min(12, parseInt(semitones, 10) || 0));
+ audioEffectsSettings.setPitch(validPitch);
+ // For now, pitch shift is informational only
+ // Full implementation would require Web Audio API pitch shifting
+ }
+
+ setPreservePitch(enabled) {
+ audioEffectsSettings.setPreservePitch(enabled);
+ this.applyAudioEffects();
+ }
+
loadQueueState() {
const savedState = queueManager.getQueue();
if (savedState) {
@@ -387,6 +443,7 @@ export class Player {
this.currentRgValues = null;
this.applyReplayGain();
+ this.applyAudioEffects();
this.audio.src = streamUrl;
@@ -425,6 +482,7 @@ export class Player {
streamUrl = URL.createObjectURL(track.file);
this.currentRgValues = null; // No replaygain for local files yet
this.applyReplayGain();
+ this.applyAudioEffects();
this.audio.src = streamUrl;
diff --git a/js/settings.js b/js/settings.js
index b2e9010..1ebabdb 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -26,6 +26,7 @@ import {
fontSettings,
monoAudioSettings,
exponentialVolumeSettings,
+ audioEffectsSettings,
} from './storage.js';
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
import { getButterchurnPresets } from './visualizers/butterchurn.js';
@@ -786,6 +787,43 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
+ // ========================================
+ // Audio Effects (Playback Speed & Pitch)
+ // ========================================
+ const playbackSpeedSlider = document.getElementById('playback-speed-slider');
+ const playbackSpeedValue = document.getElementById('playback-speed-value');
+ if (playbackSpeedSlider && playbackSpeedValue) {
+ playbackSpeedSlider.value = audioEffectsSettings.getSpeed();
+ playbackSpeedValue.textContent = playbackSpeedSlider.value + 'x';
+
+ playbackSpeedSlider.addEventListener('input', (e) => {
+ const speed = e.target.value;
+ playbackSpeedValue.textContent = speed + 'x';
+ player.setPlaybackSpeed(speed);
+ });
+ }
+
+ const pitchShiftSlider = document.getElementById('pitch-shift-slider');
+ const pitchShiftValue = document.getElementById('pitch-shift-value');
+ if (pitchShiftSlider && pitchShiftValue) {
+ pitchShiftSlider.value = audioEffectsSettings.getPitch();
+ pitchShiftValue.textContent = (pitchShiftSlider.value > 0 ? '+' : '') + pitchShiftSlider.value;
+
+ pitchShiftSlider.addEventListener('input', (e) => {
+ const pitch = e.target.value;
+ pitchShiftValue.textContent = (pitch > 0 ? '+' : '') + pitch;
+ player.setPitchShift(pitch);
+ });
+ }
+
+ const preservePitchToggle = document.getElementById('preserve-pitch-toggle');
+ if (preservePitchToggle) {
+ preservePitchToggle.checked = audioEffectsSettings.getPreservePitch();
+ preservePitchToggle.addEventListener('change', (e) => {
+ player.setPreservePitch(e.target.checked);
+ });
+ }
+
// ========================================
// 16-Band Equalizer Settings
// ========================================
diff --git a/js/storage.js b/js/storage.js
index d7af400..f1c2118 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -895,6 +895,55 @@ export const exponentialVolumeSettings = {
},
};
+export const audioEffectsSettings = {
+ SPEED_KEY: 'audio-effects-speed',
+ PITCH_KEY: 'audio-effects-pitch',
+ PRESERVE_PITCH_KEY: 'audio-effects-preserve-pitch',
+
+ // Playback speed (0.5 to 2.0, default 1.0)
+ getSpeed() {
+ try {
+ const val = parseFloat(localStorage.getItem(this.SPEED_KEY));
+ return isNaN(val) ? 1.0 : Math.max(0.5, Math.min(2.0, val));
+ } catch {
+ return 1.0;
+ }
+ },
+
+ setSpeed(speed) {
+ const validSpeed = Math.max(0.5, Math.min(2.0, parseFloat(speed) || 1.0));
+ localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
+ },
+
+ // Pitch shift (-12 to +12 semitones, default 0)
+ getPitch() {
+ try {
+ const val = parseInt(localStorage.getItem(this.PITCH_KEY), 10);
+ return isNaN(val) ? 0 : Math.max(-12, Math.min(12, val));
+ } catch {
+ return 0;
+ }
+ },
+
+ setPitch(pitch) {
+ const validPitch = Math.max(-12, Math.min(12, parseInt(pitch, 10) || 0));
+ localStorage.setItem(this.PITCH_KEY, validPitch.toString());
+ },
+
+ // Preserve pitch when changing speed (default true)
+ getPreservePitch() {
+ try {
+ return localStorage.getItem(this.PRESERVE_PITCH_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+ },
+
+ setPreservePitch(enabled) {
+ localStorage.setItem(this.PRESERVE_PITCH_KEY, enabled ? 'true' : 'false');
+ },
+};
+
export const sidebarSettings = {
STORAGE_KEY: 'monochrome-sidebar-collapsed',
diff --git a/todo.md b/todo.md
index 97d3c92..711ece7 100644
--- a/todo.md
+++ b/todo.md
@@ -3,17 +3,10 @@
Sorted by ease of implementation (easiest to hardest):
- [ ] Update notifications: Add ability to show the update popup in settings, with an option to automatically update (enabled by default)
-- [ ] Volume curve option: Add setting to switch between exponential and linear volume scaling
-- [ ] Custom fonts: Allow users to change fonts in settings or add custom fonts from Google Fonts or direct links
-- [ ] Audio effects: Add ability to change pitch and playback speed, plus effects like reverb, delay, and bitcrushing
+- [ ] Audio effects: Add ability to change pitch and playback speed,
+plus effects like reverb, delay, and bitcrushing
- [ ] Customizable EQ: Allow users to change the number of EQ bands and their range (-30 to 30), with a drag-to-adjust interface similar to FL Studio's velocity editor
[ ] SoundCloud support: Integrate SoundCloud through SoundCloak
-[ ] Qobuz support: Integrate Qobuz through Qobuz-DL
-
----
-
-## Bug Fixes
-
-- [ ] Next track and casting buttons overlap on some resolutions
+[ ] Qobuz support: Integrate Qobuz through Qobuz-DL
\ No newline at end of file