Freesound Speed Control

Quick speed buttons and custom playback rate input to Freesound players

Size

13.2 KB

Version

0.0.1

Created

Oct 22, 2025

Updated

1 day ago

1// ==UserScript==
2// @name		Freesound Speed Control
3// @namespace		https://freesound.org/
4// @version		0.0.1
5// @description		Quick speed buttons and custom playback rate input to Freesound players
6// @author		Who me
7// @match		*://*.freesound.org/*
8// @icon		https://freesound.org/static/img/favicon.ico
9// @grant		GM_addStyle
10// ==/UserScript==
11(function () {
12    'use strict';
13    const SPEEDS = [.25, .5, 1, 2, 3, 4, 8, 12, 16];
14    const STORE_KEY = 'fs_speed';
15    const MIN_RATE = 0.1;
16    const MAX_RATE = 16; // more might not work
17
18    GM_addStyle(`
19.tm-speedbar.top-left {
20  position: absolute;
21  top: 4px;
22  left: 4px;
23  margin: 0;
24  z-index: 1000;
25  pointer-events: none;
26}
27.tm-speedbar.top-left * { pointer-events: auto; }
28.tm-speedbar--fallback {
29  position: relative;
30  z-index: 1;
31  display: inline-flex;
32  margin: .4rem 0 .2rem;
33}
34.tm-speed-btn, .tm-speed-input {
35  font: inherit;
36  line-height: 1;
37  padding: .05rem .05rem;
38  border-radius: .2rem;
39  border: 1px solid rgba(0,128,0,.3);
40  background: rgba(144,238,144,.75);
41  cursor: pointer;
42  color: #006400;
43}
44.tm-speed-btn:hover { background: rgba(144,238,144,.95); }
45.tm-speed-btn.tm-active { border-color: #00aa00; background: rgba(50,205,50,.85); }
46.tm-speed-input { width: 3.5rem; text-align: center; }
47.tm-speed-label { opacity: .9; font-size: 0.8em; margin-right: .1rem; user-select: none; }
48  `);
49
50    const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
51    const saveRate = (rate) => localStorage.setItem(STORE_KEY, String(rate));
52    const loadRate = () => {
53        const v = parseFloat(localStorage.getItem(STORE_KEY) || '1');
54        return Number.isFinite(v) ? clamp(v, MIN_RATE, MAX_RATE) : 1;
55    };
56
57    // Session-global override set ONLY by "Apply-to-all"
58    let GLOBAL_RATE = null;
59
60    function findAudio(root) {
61        let audio = root.querySelector('audio');
62        if (!audio) {
63            const maybe = root.nextElementSibling;
64            if (maybe && maybe.tagName === 'AUDIO') audio = maybe;
65        }
66        return audio;
67    }
68
69    // NOTE: makeButton no longer tries to touch any input directly (prevents cross-player bleed).
70    function makeButton(rate, apply) {
71        const btn = document.createElement('button');
72        btn.type = 'button';
73        btn.className = 'tm-speed-btn';
74        btn.textContent = `${rate}`;
75        btn.dataset.rate = String(rate);
76        btn.addEventListener('click', () => apply(rate));
77        return btn;
78    }
79
80    // Only the first enhanced player should auto-load the saved/default rate
81    let appliedInitialOnce = false;
82
83    function enhancePlayer(player) {
84        if (!player || player.__tmEnhanced) return;
85        const audio = findAudio(player);
86        if (!audio) return;
87
88        player.__tmEnhanced = true;
89
90        const controlsBar =
91              player.querySelector('.bw-player__controls') ||
92              player.querySelector('.bw-player__controls--big');
93
94        const bar = document.createElement('div');
95        bar.className = 'tm-speedbar top-left';
96        if (!controlsBar) bar.classList.add('tm-speedbar--fallback');
97
98        player.style.position = 'relative';
99        player.prepend(bar);
100
101        const label = document.createElement('span');
102        label.className = 'tm-speed-label';
103        label.textContent = '';
104        bar.appendChild(label);
105
106        // Create custom input first so inner closures can reference it safely
107        const input = document.createElement('input');
108        input.className = 'tm-speed-input';
109        input.type = 'number';
110        input.step = '0.1';
111        input.min = String(MIN_RATE);
112        input.max = String(MAX_RATE);
113        input.placeholder = 'custom×';
114        input.title = `Enter custom speed (${MIN_RATE}${MAX_RATE})`;
115        bar.appendChild(input);
116
117        // Buttons (they call setRate; setRate/applyRate/syncUI update input & highlight)
118        const rateBtns = SPEEDS.map((r) => makeButton(r, setRate));
119        rateBtns.forEach((b) => bar.appendChild(b));
120
121        function handleInputEvent() {
122            const val = clamp(parseFloat(input.value || 'NaN'), MIN_RATE, MAX_RATE);
123            if (Number.isFinite(val)) setRate(val);
124        }
125        input.addEventListener('input', handleInputEvent);
126        input.addEventListener('change', handleInputEvent);
127        input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); });
128
129        // --- Reverse playback toggle (unchanged logic) ---
130        const reverseBtn = document.createElement('button');
131        reverseBtn.type = 'button';
132        reverseBtn.className = 'tm-speed-btn';
133        reverseBtn.textContent = '⇋';
134        reverseBtn.title = 'Toggle reverse playback';
135        bar.appendChild(reverseBtn);
136
137        let reverseCtx = null;
138        let reverseSource = null;
139        let reverseRAF = null;
140        let reverseStartOffset = 0;
141        let reverseDuration = 0;
142        let reverseStartCtxTime = 0;
143
144        const indicator = player.querySelector('.bw-player__progress-indicator');
145        const indicatorContainer = player.querySelector('.bw-player__progress-indicator-container');
146
147        function stopReverse(commitTime = false) {
148            if (reverseSource) {
149                try { reverseSource.stop(); } catch (_) {}
150                try { reverseSource.disconnect(); } catch (_) {}
151                reverseSource = null;
152            }
153            if (reverseRAF) {
154                cancelAnimationFrame(reverseRAF);
155                reverseRAF = null;
156            }
157            if (indicator) indicator.style.transform = '';
158
159            if (commitTime && reverseDuration > 0 && reverseCtx) {
160                const elapsed = (reverseCtx.currentTime - reverseStartCtxTime) * (audio.playbackRate || 1);
161                const T = Math.max(0, reverseStartOffset - elapsed);
162                audio.currentTime = T;
163            }
164
165            reverseBtn.classList.remove('tm-active');
166        }
167
168        reverseBtn.addEventListener('click', async () => {
169            if (reverseSource) {
170                stopReverse(true);
171                audio.play();
172                return;
173            }
174            audio.pause();
175            stopReverse();
176
177            if (!reverseCtx) reverseCtx = new (window.AudioContext || window.webkitAudioContext)();
178            try {
179                if (!player.__tmReverseBuffer) {
180                    const resp = await fetch(audio.currentSrc);
181                    const ab = await resp.arrayBuffer();
182                    const decoded = await reverseCtx.decodeAudioData(ab);
183                    for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
184                        decoded.getChannelData(ch).reverse();
185                    }
186                    player.__tmReverseBuffer = decoded;
187                }
188
189                const decoded = player.__tmReverseBuffer;
190                reverseDuration = decoded.duration;
191                reverseStartOffset = audio.currentTime > 0 ? audio.currentTime : reverseDuration;
192                const reversedOffset = reverseDuration - reverseStartOffset;
193
194                reverseSource = reverseCtx.createBufferSource();
195                reverseSource.buffer = decoded;
196                reverseSource.playbackRate.value = audio.playbackRate || 1;
197                reverseSource.loop = audio.loop;
198                reverseSource.connect(reverseCtx.destination);
199                reverseSource.start(0, reversedOffset);
200
201                reverseStartCtxTime = reverseCtx.currentTime;
202                reverseBtn.classList.add('tm-active');
203
204                if (indicator && indicatorContainer) {
205                    const width = indicatorContainer.offsetWidth || 0;
206                    const step = () => {
207                        if (!reverseSource) return;
208                        const rate = audio.playbackRate || 1;
209                        const elapsed = (reverseCtx.currentTime - reverseStartCtxTime) * rate;
210
211                        let T;
212                        if (reverseSource.loop) {
213                            const cycleTime = (reverseStartOffset - elapsed) % reverseDuration;
214                            T = (cycleTime <= 0 ? cycleTime + reverseDuration : cycleTime);
215                        } else {
216                            T = Math.max(0, reverseStartOffset - elapsed);
217                        }
218
219                        const percent = T / reverseDuration;
220                        const x = -((1 - percent) * width);
221                        indicator.style.transform = `translateX(${x}px)`;
222
223                        if (!reverseSource.loop && T <= 0) {
224                            stopReverse(true);
225                            return;
226                        }
227                        reverseRAF = requestAnimationFrame(step);
228                    };
229                    reverseRAF = requestAnimationFrame(step);
230                }
231
232                reverseSource.onended = reverseSource.loop ? null : () => stopReverse(true);
233            } catch (err) {
234                console.error('[FS Speed] Reverse playback failed:', err);
235                stopReverse();
236            }
237        });
238
239        audio.addEventListener('play', () => stopReverse());
240        const stopBtn = player.querySelector('.bw-icon-stop');
241        if (stopBtn) stopBtn.closest('button')?.addEventListener('click', () => stopReverse());
242
243        audio.addEventListener('ratechange', () => {
244            if (reverseSource) reverseSource.playbackRate.value = audio.playbackRate || 1;
245        });
246
247        // Local stop
248        const stopLocalBtn = document.createElement('button');
249        stopLocalBtn.type = 'button';
250        stopLocalBtn.className = 'tm-speed-btn';
251        stopLocalBtn.textContent = '⏹';
252        stopLocalBtn.title = 'Stop playback for this player';
253        bar.appendChild(stopLocalBtn);
254        stopLocalBtn.addEventListener('click', () => {
255            stopReverse();
256            try { audio.pause(); } catch(_) {}
257            audio.currentTime = 0;
258        });
259
260        // Apply-to-all
261        const applyBtn = document.createElement('button');
262        applyBtn.type = 'button';
263        applyBtn.className = 'tm-speed-btn';
264        applyBtn.textContent = '⇉';
265        applyBtn.title = 'Apply current speed to all players on this page';
266        bar.appendChild(applyBtn);
267
268        applyBtn.addEventListener('click', () => {
269            const rate = audio.playbackRate || 1;
270            GLOBAL_RATE = rate;   // session override
271            saveRate(rate);       // persist for reloads
272
273            // Update every player on the page now
274            document.querySelectorAll('.bw-player').forEach((pl) => {
275                if (pl === player) return;
276                if (pl.__tmApplyRate) {
277                    pl.__tmApplyRate(rate); // will sync buttons + inputs
278                } else {
279                    const a = findAudio(pl);
280                    if (a) {
281                        try { a.playbackRate = rate; } catch(_) {}
282                    }
283                }
284            });
285        });
286
287        // --- Core rule: PLAY uses what's in THIS player's input field ---
288        audio.addEventListener('play', () => {
289            const val = clamp(parseFloat(input.value || 'NaN'), MIN_RATE, MAX_RATE);
290            if (Number.isFinite(val)) {
291                applyRate(val);
292            } else {
293                // if input is empty/invalid, fall back to session-global (if set) or current audio rate
294                const eff = GLOBAL_RATE !== null ? GLOBAL_RATE : (audio.playbackRate || 1);
295                applyRate(eff);
296            }
297        });
298
299        // Keep UI in sync if something else changes the playbackRate
300        audio.addEventListener('ratechange', syncUI);
301
302        // Optional: improve pitch handling
303        try {
304            audio.preservesPitch = false;
305            audio.mozPreservesPitch = false;
306            audio.webkitPreservesPitch = false;
307        } catch (_) {}
308
309        function setRate(v) {
310            const rate = clamp(v, MIN_RATE, MAX_RATE);
311            applyRate(rate);
312            saveRate(rate); // for next page load
313        }
314
315        function applyRate(v) {
316            player.__tmApplyRate = applyRate;
317            const rate = clamp(v, MIN_RATE, MAX_RATE);
318            try { audio.playbackRate = rate; } catch (e) {
319                console.warn('[FS Speed] Failed to set rate', e);
320            }
321            syncUI();
322        }
323
324        function syncUI() {
325            const r = Math.round((audio.playbackRate || 1) * 100) / 100;
326            rateBtns.forEach((b) => {
327                const same = Math.abs(parseFloat(b.dataset.rate) - r) < 0.001;
328                b.classList.toggle('tm-active', same);
329            });
330            input.value = String(r);
331        }
332
333        // --- Initial per-player setup ---
334        const initRate = (GLOBAL_RATE !== null ? GLOBAL_RATE : loadRate());
335        applyRate(initRate);
336    }
337
338    // Enhance existing players
339    document.querySelectorAll('.bw-player').forEach(enhancePlayer);
340
341    // Enhance dynamically added players
342    const mo = new MutationObserver((muts) => {
343        for (const m of muts) {
344            m.addedNodes.forEach((n) => {
345                if (!(n instanceof HTMLElement)) return;
346                if (n.matches && n.matches('.bw-player')) enhancePlayer(n);
347                n.querySelectorAll && n.querySelectorAll('.bw-player').forEach(enhancePlayer);
348            });
349        }
350    });
351    mo.observe(document.documentElement, { childList: true, subtree: true });
352
353})();
Freesound Speed Control | Robomonkey