YouTube Live Pitch Shifter

Real-time pitch shifter for YouTube videos with ±60 semitones and ±133 cents range

Size

19.1 KB

Version

1.1.3

Created

Oct 24, 2025

Updated

21 days ago

1// ==UserScript==
2// @name		YouTube Live Pitch Shifter
3// @description		Real-time pitch shifter for YouTube videos with ±60 semitones and ±133 cents range
4// @version		1.1.3
5// @match		https://*.youtube.com/*
6// @icon		https://www.youtube.com/s/desktop/02cdcf97/img/favicon_32x32.png
7// @grant		GM.getValue
8// @grant		GM.setValue
9// ==/UserScript==
10(function() {
11    'use strict';
12
13    let audioContext = null;
14    let sourceNode = null;
15    let pitchShifterNode = null;
16    let gainNode = null;
17    let videoElement = null;
18    let isInitialized = false;
19    
20    // Pitch shift parameters
21    let semitones = 0;
22    let cents = 0;
23    let isEnabled = false;
24
25    // High-quality audio settings (1080p equivalent)
26    const BUFFER_SIZE = 16384; // Ultra high quality buffer
27    const SAMPLE_RATE = 48000; // High sample rate for best quality
28
29    function createPitchShifter() {
30        if (!audioContext) {
31            audioContext = new (window.AudioContext || window.webkitAudioContext)({
32                sampleRate: SAMPLE_RATE,
33                latencyHint: 'playback'
34            });
35        }
36
37        if (!pitchShifterNode) {
38            pitchShifterNode = audioContext.createScriptProcessor(BUFFER_SIZE, 2, 2);
39            gainNode = audioContext.createGain();
40            
41            // Circular buffer for pitch shifting without speed change
42            let leftBuffer = new Float32Array(BUFFER_SIZE * 8);
43            let rightBuffer = new Float32Array(BUFFER_SIZE * 8);
44            let writeIndex = 0;
45            let readIndex = 0;
46            let initialized = false;
47            
48            pitchShifterNode.onaudioprocess = function(e) {
49                const inputL = e.inputBuffer.getChannelData(0);
50                const inputR = e.inputBuffer.getChannelData(1);
51                const outputL = e.outputBuffer.getChannelData(0);
52                const outputR = e.outputBuffer.getChannelData(1);
53                
54                const pitchRatio = Math.pow(2, (semitones + cents / 100) / 12);
55                
56                if (Math.abs(pitchRatio - 1.0) < 0.001 || !isEnabled) {
57                    // No pitch shift or disabled, pass through
58                    outputL.set(inputL);
59                    outputR.set(inputR);
60                    return;
61                }
62                
63                const bufferLength = inputL.length;
64                const grainSize = 4096; // Window size for overlap-add
65                const hopSize = Math.floor(grainSize / 4); // 75% overlap
66                
67                // Write input to circular buffer
68                for (let i = 0; i < bufferLength; i++) {
69                    leftBuffer[writeIndex] = inputL[i];
70                    rightBuffer[writeIndex] = inputR[i];
71                    writeIndex = (writeIndex + 1) % leftBuffer.length;
72                }
73                
74                // Initialize read position on first run
75                if (!initialized) {
76                    readIndex = (writeIndex - grainSize * 2 + leftBuffer.length) % leftBuffer.length;
77                    initialized = true;
78                }
79                
80                // Generate output using time-stretching with pitch shift
81                for (let i = 0; i < bufferLength; i++) {
82                    // Calculate read position with pitch ratio applied
83                    const readPos = readIndex + (i * pitchRatio);
84                    const idx = Math.floor(readPos) % leftBuffer.length;
85                    const frac = readPos - Math.floor(readPos);
86                    
87                    // Cubic interpolation indices
88                    const i0 = (idx - 1 + leftBuffer.length) % leftBuffer.length;
89                    const i1 = idx;
90                    const i2 = (idx + 1) % leftBuffer.length;
91                    const i3 = (idx + 2) % leftBuffer.length;
92                    
93                    // Apply Hann window for smooth grain transitions
94                    const windowPos = (i % hopSize) / hopSize;
95                    const window = 0.5 * (1 - Math.cos(2 * Math.PI * windowPos));
96                    
97                    // Cubic interpolation for left channel
98                    const yL0 = leftBuffer[i0];
99                    const yL1 = leftBuffer[i1];
100                    const yL2 = leftBuffer[i2];
101                    const yL3 = leftBuffer[i3];
102                    
103                    const cL0 = yL1;
104                    const cL1 = 0.5 * (yL2 - yL0);
105                    const cL2 = yL0 - 2.5 * yL1 + 2 * yL2 - 0.5 * yL3;
106                    const cL3 = 0.5 * (yL3 - yL0) + 1.5 * (yL1 - yL2);
107                    
108                    outputL[i] = (((cL3 * frac + cL2) * frac + cL1) * frac + cL0) * window;
109                    
110                    // Cubic interpolation for right channel
111                    const yR0 = rightBuffer[i0];
112                    const yR1 = rightBuffer[i1];
113                    const yR2 = rightBuffer[i2];
114                    const yR3 = rightBuffer[i3];
115                    
116                    const cR0 = yR1;
117                    const cR1 = 0.5 * (yR2 - yR0);
118                    const cR2 = yR0 - 2.5 * yR1 + 2 * yR2 - 0.5 * yR3;
119                    const cR3 = 0.5 * (yR3 - yR0) + 1.5 * (yR1 - yR2);
120                    
121                    outputR[i] = (((cR3 * frac + cR2) * frac + cR1) * frac + cR0) * window;
122                }
123                
124                // Advance read position to maintain sync (no speed change)
125                readIndex = (readIndex + bufferLength) % leftBuffer.length;
126            };
127        }
128    }
129
130    function connectAudio() {
131        if (isInitialized) return;
132        
133        videoElement = document.querySelector('video');
134        if (!videoElement) {
135            console.log('YouTube Live Pitch Shifter: Video element not found');
136            return;
137        }
138
139        try {
140            createPitchShifter();
141            
142            // Create media element source
143            sourceNode = audioContext.createMediaElementSource(videoElement);
144            
145            // Connect: source -> pitchShifter -> gain -> destination
146            sourceNode.connect(pitchShifterNode);
147            pitchShifterNode.connect(gainNode);
148            gainNode.connect(audioContext.destination);
149            
150            isInitialized = true;
151            console.log('YouTube Live Pitch Shifter: Audio pipeline connected (Ultra High Quality Mode)');
152        } catch (error) {
153            console.error('YouTube Live Pitch Shifter: Error connecting audio:', error);
154        }
155    }
156
157    async function loadSettings() {
158        semitones = await GM.getValue('pitchShifter_semitones', 0);
159        cents = await GM.getValue('pitchShifter_cents', 0);
160        isEnabled = await GM.getValue('pitchShifter_enabled', false);
161    }
162
163    async function saveSettings() {
164        await GM.setValue('pitchShifter_semitones', semitones);
165        await GM.setValue('pitchShifter_cents', cents);
166        await GM.setValue('pitchShifter_enabled', isEnabled);
167    }
168
169    function updatePitchDisplay(semitonesValue, centsValue) {
170        const semitonesDisplay = document.getElementById('yt-pitch-semitones-value');
171        const centsDisplay = document.getElementById('yt-pitch-cents-value');
172        const totalDisplay = document.getElementById('yt-pitch-total-value');
173        
174        if (semitonesDisplay) {
175            semitonesDisplay.textContent = semitonesValue > 0 ? `+${semitonesValue}` : semitonesValue;
176        }
177        if (centsDisplay) {
178            centsDisplay.textContent = centsValue > 0 ? `+${centsValue.toFixed(2)}` : centsValue.toFixed(2);
179        }
180        if (totalDisplay) {
181            const total = semitonesValue + centsValue / 100;
182            totalDisplay.textContent = total > 0 ? `+${total.toFixed(2)}` : total.toFixed(2);
183        }
184    }
185
186    function createUI() {
187        // Check if UI already exists
188        if (document.getElementById('yt-pitch-shifter-container')) return;
189
190        const container = document.createElement('div');
191        container.id = 'yt-pitch-shifter-container';
192        container.innerHTML = `
193            <div id="yt-pitch-shifter-panel">
194                <div class="yt-pitch-header">
195                    <span class="yt-pitch-title">🎵 Live Pitch Shifter</span>
196                    <span class="yt-pitch-quality">Ultra HQ</span>
197                </div>
198                
199                <div class="yt-pitch-control">
200                    <label class="yt-pitch-label">
201                        Semitones: <span id="yt-pitch-semitones-value">0</span>
202                    </label>
203                    <input type="range" id="yt-pitch-semitones-slider" 
204                           min="-60" max="60" value="0" step="1">
205                    <div class="yt-pitch-range">-60 ↔ +60</div>
206                </div>
207                
208                <div class="yt-pitch-control">
209                    <label class="yt-pitch-label">
210                        Cents: <span id="yt-pitch-cents-value">0.00</span>
211                    </label>
212                    <input type="range" id="yt-pitch-cents-slider" 
213                           min="-133" max="133" value="0" step="0.01">
214                    <div class="yt-pitch-range">-133.00 ↔ +133.00</div>
215                </div>
216                
217                <div class="yt-pitch-total">
218                    Total Shift: <span id="yt-pitch-total-value">0.00</span> semitones
219                </div>
220                
221                <div class="yt-pitch-buttons">
222                    <button id="yt-pitch-enable" class="yt-pitch-btn yt-pitch-btn-enable">Enable</button>
223                    <button id="yt-pitch-reset" class="yt-pitch-btn">Reset</button>
224                </div>
225            </div>
226        `;
227
228        // Add styles
229        const style = document.createElement('style');
230        style.textContent = `
231            #yt-pitch-shifter-container {
232                position: fixed;
233                top: 80px;
234                right: 20px;
235                z-index: 9999;
236                font-family: "YouTube Sans", "Roboto", sans-serif;
237            }
238            
239            #yt-pitch-shifter-panel {
240                background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
241                border: 2px solid #0f3460;
242                border-radius: 12px;
243                padding: 20px;
244                min-width: 320px;
245                box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
246                backdrop-filter: blur(10px);
247            }
248            
249            .yt-pitch-header {
250                display: flex;
251                justify-content: space-between;
252                align-items: center;
253                margin-bottom: 20px;
254                padding-bottom: 12px;
255                border-bottom: 2px solid #0f3460;
256            }
257            
258            .yt-pitch-title {
259                font-size: 18px;
260                font-weight: bold;
261                color: #ffffff;
262                text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
263            }
264            
265            .yt-pitch-quality {
266                background: linear-gradient(135deg, #e94560 0%, #ff6b6b 100%);
267                color: #ffffff;
268                padding: 4px 12px;
269                border-radius: 12px;
270                font-size: 11px;
271                font-weight: bold;
272                letter-spacing: 0.5px;
273                box-shadow: 0 2px 8px rgba(233, 69, 96, 0.4);
274            }
275            
276            .yt-pitch-control {
277                margin-bottom: 20px;
278            }
279            
280            .yt-pitch-label {
281                display: block;
282                color: #e0e0e0;
283                font-size: 14px;
284                font-weight: 600;
285                margin-bottom: 8px;
286            }
287            
288            #yt-pitch-semitones-value,
289            #yt-pitch-cents-value {
290                color: #4ecca3;
291                font-weight: bold;
292                font-size: 16px;
293                text-shadow: 0 0 10px rgba(78, 204, 163, 0.5);
294            }
295            
296            input[type="range"] {
297                width: 100%;
298                height: 6px;
299                border-radius: 3px;
300                background: linear-gradient(to right, #0f3460 0%, #4ecca3 50%, #0f3460 100%);
301                outline: none;
302                -webkit-appearance: none;
303                margin: 8px 0;
304            }
305            
306            input[type="range"]::-webkit-slider-thumb {
307                -webkit-appearance: none;
308                appearance: none;
309                width: 20px;
310                height: 20px;
311                border-radius: 50%;
312                background: linear-gradient(135deg, #4ecca3 0%, #3ba87f 100%);
313                cursor: pointer;
314                box-shadow: 0 0 15px rgba(78, 204, 163, 0.6);
315                border: 2px solid #ffffff;
316                transition: all 0.2s ease;
317            }
318            
319            input[type="range"]::-webkit-slider-thumb:hover {
320                transform: scale(1.2);
321                box-shadow: 0 0 20px rgba(78, 204, 163, 0.9);
322            }
323            
324            input[type="range"]::-moz-range-thumb {
325                width: 20px;
326                height: 20px;
327                border-radius: 50%;
328                background: linear-gradient(135deg, #4ecca3 0%, #3ba87f 100%);
329                cursor: pointer;
330                box-shadow: 0 0 15px rgba(78, 204, 163, 0.6);
331                border: 2px solid #ffffff;
332                transition: all 0.2s ease;
333            }
334            
335            input[type="range"]::-moz-range-thumb:hover {
336                transform: scale(1.2);
337                box-shadow: 0 0 20px rgba(78, 204, 163, 0.9);
338            }
339            
340            .yt-pitch-range {
341                color: #888;
342                font-size: 11px;
343                text-align: center;
344                margin-top: 4px;
345            }
346            
347            .yt-pitch-total {
348                background: rgba(78, 204, 163, 0.1);
349                border: 1px solid #4ecca3;
350                border-radius: 8px;
351                padding: 12px;
352                text-align: center;
353                color: #e0e0e0;
354                font-size: 14px;
355                margin-bottom: 16px;
356            }
357            
358            #yt-pitch-total-value {
359                color: #4ecca3;
360                font-weight: bold;
361                font-size: 18px;
362                text-shadow: 0 0 10px rgba(78, 204, 163, 0.5);
363            }
364            
365            .yt-pitch-buttons {
366                display: flex;
367                gap: 10px;
368            }
369            
370            .yt-pitch-btn {
371                flex: 1;
372                background: linear-gradient(135deg, #e94560 0%, #d63447 100%);
373                color: #ffffff;
374                border: none;
375                border-radius: 8px;
376                padding: 10px 20px;
377                font-size: 14px;
378                font-weight: bold;
379                cursor: pointer;
380                transition: all 0.3s ease;
381                box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3);
382            }
383            
384            .yt-pitch-btn:hover {
385                transform: translateY(-2px);
386                box-shadow: 0 6px 20px rgba(233, 69, 96, 0.5);
387            }
388            
389            .yt-pitch-btn:active {
390                transform: translateY(0);
391            }
392            
393            .yt-pitch-btn-enable {
394                background: linear-gradient(135deg, #4ecca3 0%, #3ba87f 100%);
395            }
396            
397            .yt-pitch-btn-enable.active {
398                background: linear-gradient(135deg, #e94560 0%, #d63447 100%);
399            }
400        `;
401        document.head.appendChild(style);
402        document.body.appendChild(container);
403
404        // Add event listeners
405        const semitonesSlider = document.getElementById('yt-pitch-semitones-slider');
406        const centsSlider = document.getElementById('yt-pitch-cents-slider');
407        const resetBtn = document.getElementById('yt-pitch-reset');
408        const enableBtn = document.getElementById('yt-pitch-enable');
409
410        enableBtn.addEventListener('click', function() {
411            isEnabled = !isEnabled;
412            if (isEnabled) {
413                enableBtn.textContent = 'Disable';
414                enableBtn.classList.add('active');
415                connectAudio();
416            } else {
417                enableBtn.textContent = 'Enable';
418                enableBtn.classList.remove('active');
419                // Disconnect and reset audio
420                if (audioContext) {
421                    audioContext.close();
422                    audioContext = null;
423                    pitchShifterNode = null;
424                    sourceNode = null;
425                    isInitialized = false;
426                }
427                // Reload page to restore original audio
428                location.reload();
429            }
430            saveSettings();
431        });
432
433        semitonesSlider.addEventListener('input', function() {
434            semitones = parseInt(this.value);
435            updatePitchDisplay(semitones, cents);
436            saveSettings();
437            connectAudio(); // Ensure audio is connected
438        });
439
440        centsSlider.addEventListener('input', function() {
441            cents = parseFloat(this.value);
442            updatePitchDisplay(semitones, cents);
443            saveSettings();
444            connectAudio(); // Ensure audio is connected
445        });
446
447        resetBtn.addEventListener('click', function() {
448            semitones = 0;
449            cents = 0;
450            semitonesSlider.value = 0;
451            centsSlider.value = 0;
452            updatePitchDisplay(0, 0);
453            saveSettings();
454        });
455
456        // Load saved settings
457        loadSettings().then(() => {
458            semitonesSlider.value = semitones;
459            centsSlider.value = cents;
460            updatePitchDisplay(semitones, cents);
461            
462            if (isEnabled) {
463                enableBtn.textContent = 'Disable';
464                enableBtn.classList.add('active');
465                connectAudio();
466            }
467        });
468
469        console.log('YouTube Live Pitch Shifter: UI created');
470    }
471
472    function init() {
473        console.log('YouTube Live Pitch Shifter: Initializing...');
474        
475        // Wait for video element to be available
476        const checkVideo = setInterval(() => {
477            const video = document.querySelector('video');
478            if (video) {
479                clearInterval(checkVideo);
480                createUI();
481                
482                // Connect audio when video starts playing
483                video.addEventListener('play', () => {
484                    if (!isInitialized) {
485                        connectAudio();
486                    }
487                });
488                
489                // Try to connect immediately if video is already playing
490                if (!video.paused) {
491                    connectAudio();
492                }
493            }
494        }, 1000);
495    }
496
497    // Initialize when page loads
498    if (document.readyState === 'loading') {
499        document.addEventListener('DOMContentLoaded', init);
500    } else {
501        init();
502    }
503
504    // Re-initialize on YouTube navigation
505    let lastUrl = location.href;
506    new MutationObserver(() => {
507        const url = location.href;
508        if (url !== lastUrl) {
509            lastUrl = url;
510            isInitialized = false;
511            sourceNode = null;
512            setTimeout(init, 1000);
513        }
514    }).observe(document.body, { subtree: true, childList: true });
515
516})();
YouTube Live Pitch Shifter | Robomonkey