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})();