From f81973af88c31828fc01c23ce36def8f0440e676 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Mon, 9 Feb 2026 14:04:40 +0000 Subject: [PATCH] pitch and speed in settings, back to ko-fi --- index.html | 70 +++++++++++++++++++++++++++++++++++++++++++-- js/audio-context.js | 10 +++++++ js/player.js | 60 +++++++++++++++++++++++++++++++++++++- js/settings.js | 38 ++++++++++++++++++++++++ js/storage.js | 49 +++++++++++++++++++++++++++++++ todo.md | 13 ++------- 6 files changed, 227 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index 6802835..b89d71c 100644 --- a/index.html +++ b/index.html @@ -3082,6 +3082,70 @@ + +
+
+ Playback Speed + Adjust playback speed (0.5x - 2.0x) +
+
+ + 1.0x +
+
+ + +
+
+ Pitch Shift + Shift pitch up or down (-12 to +12 semitones). Note: Disable "Preserve + Pitch" to hear the effect +
+
+ + 0 +
+
+ +
+
+ Preserve Pitch + Keep original pitch when changing playback speed +
+ +
+
@@ -3655,7 +3719,7 @@ flex-wrap: wrap; " > - +
@@ -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