Skip to content

Commit 358f3b7

Browse files
committed
✨ Add Pitch Shifter plugin (Tone.js )
1 parent 4f31d47 commit 358f3b7

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

src/plugins/pitch-shifter/index.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import style from './style.css?inline';
2+
import { createPlugin } from '@/utils';
3+
import { onPlayerApiReady } from './renderer';
4+
import { t } from '@/i18n';
5+
6+
7+
/**
8+
* 🎵 Pitch Shifter Plugin (Tone.js + Solid.js Edition)
9+
* Author: TheSakyo
10+
*
11+
* Provides real-time pitch shifting for YouTube Music using Tone.js,
12+
* allowing users to raise or lower the key of a song dynamically.
13+
*/
14+
export type PitchShifterPluginConfig = {
15+
/** Whether the plugin is enabled (active in the player). */
16+
enabled: boolean;
17+
18+
/** Current pitch shift amount in semitones (-12 to +12). */
19+
semitones: number;
20+
};
21+
22+
export default createPlugin({
23+
// 🧱 ─────────────── Plugin Metadata ───────────────
24+
name: () => t('plugins.pitch-shifter.name', 'Pitch Shifter'),
25+
description: () => t('plugins.pitch-shifter.description'),
26+
27+
/** Whether the app must restart when enabling/disabling the plugin. */
28+
restartNeeded: false,
29+
30+
// ⚙️ ─────────────── Default Configuration ───────────────
31+
config: {
32+
enabled: false, // Plugin starts disabled by default
33+
semitones: 0, // Neutral pitch (no shift)
34+
} as PitchShifterPluginConfig,
35+
36+
// 🎨 ─────────────── Plugin Stylesheet ───────────────
37+
/** Inline CSS loaded into the YT Music renderer for consistent styling. */
38+
stylesheets: [style],
39+
40+
// 🎧 ─────────────── Renderer Logic ───────────────
41+
/**
42+
* The renderer is triggered once the YouTube Music player API is available.
43+
* It handles all DOM interactions, UI injection, and audio processing.
44+
*/
45+
renderer: {
46+
onPlayerApiReady,
47+
},
48+
});
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { createSignal, onCleanup, createEffect } from "solid-js";
2+
import { render } from "solid-js/web";
3+
import * as Tone from "tone";
4+
import type { RendererContext } from "@/types/contexts";
5+
import type { PitchShifterPluginConfig } from "./index";
6+
7+
/**
8+
* 🎵 Pitch Shifter Plugin (Tone.js + Solid.js Edition)
9+
* ✅ Real-time pitch updates
10+
* ✅ Single slider instance
11+
* ✅ Clean removal on disable
12+
* ✅ Dynamic slider color (cool → neutral → warm)
13+
* ✅ Glassmorphism-ready UI
14+
* Author: TheSakyo
15+
*/
16+
export const onPlayerApiReady = async (
17+
_,
18+
{ getConfig, setConfig }: RendererContext<PitchShifterPluginConfig>
19+
) => {
20+
console.log("[pitch-shifter] Renderer (Solid) initialized ✅");
21+
22+
const userConfig = await getConfig();
23+
const [enabled, setEnabled] = createSignal(userConfig.enabled);
24+
const [semitones, setSemitones] = createSignal(userConfig.semitones ?? 0);
25+
26+
let media: HTMLMediaElement | null = null;
27+
let pitchShift: Tone.PitchShift | null = null;
28+
let nativeSource: MediaStreamAudioSourceNode | null = null;
29+
let mount: HTMLDivElement | null = null;
30+
31+
/** 🎧 Wait for <video> element */
32+
const waitForMedia = (): Promise<HTMLMediaElement> =>
33+
new Promise((resolve) => {
34+
const check = () => {
35+
const el =
36+
document.querySelector("video") ||
37+
document.querySelector("audio") ||
38+
document.querySelector("ytmusic-player video");
39+
if (el) resolve(el as HTMLMediaElement);
40+
else setTimeout(check, 400);
41+
};
42+
check();
43+
});
44+
45+
media = await waitForMedia();
46+
console.log("[pitch-shifter] Media found 🎧", media);
47+
48+
await Tone.start();
49+
const toneCtx = Tone.getContext();
50+
const stream =
51+
(media as any).captureStream?.() || (media as any).mozCaptureStream?.();
52+
if (!stream) {
53+
console.error("[pitch-shifter] ❌ captureStream() unavailable");
54+
return;
55+
}
56+
57+
/** 🎚️ Setup pitch shifting (only once) */
58+
const setupPitchShift = () => {
59+
if (pitchShift) return;
60+
pitchShift = new Tone.PitchShift({
61+
pitch: semitones(),
62+
windowSize: 0.1,
63+
}).toDestination();
64+
nativeSource = toneCtx.createMediaStreamSource(stream);
65+
Tone.connect(nativeSource, pitchShift);
66+
media!.muted = true;
67+
console.log("[pitch-shifter] Pitch processor active 🎶");
68+
};
69+
70+
/** 📴 Teardown cleanly */
71+
const teardownPitchShift = () => {
72+
pitchShift?.dispose();
73+
pitchShift = null;
74+
nativeSource?.disconnect();
75+
nativeSource = null;
76+
media!.muted = false;
77+
console.log("[pitch-shifter] Pitch processor stopped 📴");
78+
};
79+
80+
/** 🎨 Solid component for slider UI */
81+
const PitchUI = () => {
82+
/** 💡 Utility: compute slider gradient based on pitch */
83+
const getSliderGradient = (value: number) => {
84+
// Map -12 → 0, 0 → 0.5, 12 → 1
85+
const normalized = (value + 12) / 24;
86+
const cold = [77, 166, 255]; // blue
87+
const neutral = [255, 77, 77]; // red
88+
const warm = [255, 170, 51]; // orange
89+
90+
let color: number[];
91+
if (value < 0) {
92+
// blend blue → red
93+
const t = normalized * 2;
94+
color = cold.map((c, i) => Math.round(c + (neutral[i] - c) * t));
95+
} else {
96+
// blend red → orange
97+
const t = (normalized - 0.5) * 2;
98+
color = neutral.map((c, i) => Math.round(c + (warm[i] - c) * t));
99+
}
100+
return `linear-gradient(90deg, rgb(${color.join(",")}) 0%, #fff 100%)`;
101+
};
102+
103+
/** 🎚️ Update slider color when pitch changes */
104+
const updateSliderColor = (slider: HTMLInputElement, value: number) => {
105+
slider.style.background = getSliderGradient(value);
106+
};
107+
108+
return (
109+
<div class="pitch-wrapper">
110+
<input
111+
type="range"
112+
min="-12"
113+
max="12"
114+
step="1"
115+
value={semitones()}
116+
class="pitch-slider"
117+
onInput={(e) => {
118+
const slider = e.target as HTMLInputElement;
119+
const v = parseInt(slider.value);
120+
setSemitones(v);
121+
setConfig({ semitones: v });
122+
if (pitchShift) pitchShift.pitch = v;
123+
updateSliderColor(slider, v);
124+
125+
const labelEl = document.querySelector(".pitch-label");
126+
if (labelEl) {
127+
labelEl.classList.add("active");
128+
setTimeout(() => labelEl.classList.remove("active"), 200);
129+
}
130+
}}
131+
ref={(el) => updateSliderColor(el, semitones())}
132+
/>
133+
<span class="pitch-label">
134+
{semitones() >= 0 ? "+" : ""}
135+
{semitones()} semitones
136+
</span>
137+
<button
138+
class="pitch-reset"
139+
title="Reset pitch"
140+
onClick={() => {
141+
setSemitones(0);
142+
setConfig({ semitones: 0 });
143+
if (pitchShift) pitchShift.pitch = 0;
144+
const slider = document.querySelector(
145+
".pitch-slider"
146+
) as HTMLInputElement;
147+
if (slider) updateSliderColor(slider, 0);
148+
149+
const labelEl = document.querySelector(".pitch-label");
150+
if (labelEl) {
151+
labelEl.classList.add("active");
152+
setTimeout(() => labelEl.classList.remove("active"), 200);
153+
}
154+
}}
155+
>
156+
🔄
157+
</button>
158+
</div>
159+
);
160+
};
161+
162+
/** 🧱 Mount UI (only once) */
163+
const injectUI = () => {
164+
const tabs = document.querySelector("tp-yt-paper-tabs.tab-header-container");
165+
if (tabs && tabs.parentElement && !document.querySelector(".pitch-wrapper")) {
166+
mount = document.createElement("div");
167+
tabs.parentElement.insertBefore(mount, tabs);
168+
render(() => <PitchUI />, mount);
169+
console.log("[pitch-shifter] UI injected via Solid ✅");
170+
}
171+
};
172+
173+
/** 🧹 Remove UI on disable */
174+
const removeUI = () => {
175+
const existing = document.querySelector(".pitch-wrapper");
176+
if (existing) {
177+
existing.remove();
178+
mount = null;
179+
console.log("[pitch-shifter] UI removed ❌");
180+
}
181+
};
182+
183+
/** 🔁 React to plugin state */
184+
createEffect(() => {
185+
if (enabled()) {
186+
setupPitchShift();
187+
injectUI();
188+
} else {
189+
teardownPitchShift();
190+
removeUI();
191+
}
192+
});
193+
194+
/** ⏱️ Periodically sync config */
195+
const interval = setInterval(async () => {
196+
const conf = await getConfig();
197+
if (conf.enabled !== enabled()) setEnabled(conf.enabled);
198+
if (conf.semitones !== semitones()) setSemitones(conf.semitones);
199+
}, 1000);
200+
201+
onCleanup(() => {
202+
clearInterval(interval);
203+
teardownPitchShift();
204+
removeUI();
205+
});
206+
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
.pitch-wrapper {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
gap: 14px;
6+
margin-bottom: 6px;
7+
user-select: none;
8+
font-family: "Inter", "Segoe UI", sans-serif;
9+
background: rgba(255, 255, 255, 0.05);
10+
backdrop-filter: blur(12px) saturate(180%);
11+
border-radius: 12px;
12+
padding: 8px 14px;
13+
border: 1px solid rgba(255, 255, 255, 0.1);
14+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
15+
transition: background 0.3s ease, transform 0.2s ease;
16+
}
17+
18+
.pitch-wrapper:hover {
19+
background: rgba(255, 255, 255, 0.08);
20+
transform: translateY(-1px);
21+
}
22+
23+
/* 🎚️ Premium gradient slider */
24+
.pitch-slider {
25+
width: 160px;
26+
height: 5px;
27+
appearance: none;
28+
border-radius: 3px;
29+
background: linear-gradient(90deg, #ff4d4d 0%, #ff8080 100%);
30+
outline: none;
31+
cursor: pointer;
32+
transition: filter 0.2s ease, transform 0.2s ease;
33+
}
34+
35+
.pitch-slider:hover {
36+
filter: brightness(1.15);
37+
transform: scaleX(1.03);
38+
}
39+
40+
/* Chrome / Edge thumb */
41+
.pitch-slider::-webkit-slider-thumb {
42+
appearance: none;
43+
width: 16px;
44+
height: 16px;
45+
background: rgba(255, 255, 255, 0.9);
46+
border-radius: 50%;
47+
border: 2px solid #ff4d4d;
48+
transition: all 0.25s ease;
49+
box-shadow: 0 0 6px rgba(255, 77, 77, 0.5);
50+
}
51+
52+
.pitch-slider::-webkit-slider-thumb:hover {
53+
background: #ff4d4d;
54+
border-color: #fff;
55+
box-shadow: 0 0 12px rgba(255, 77, 77, 0.8);
56+
transform: scale(1.15);
57+
}
58+
59+
/* Firefox thumb */
60+
.pitch-slider::-moz-range-thumb {
61+
width: 16px;
62+
height: 16px;
63+
background: rgba(255, 255, 255, 0.9);
64+
border-radius: 50%;
65+
border: 2px solid #ff4d4d;
66+
transition: all 0.25s ease;
67+
box-shadow: 0 0 6px rgba(255, 77, 77, 0.5);
68+
}
69+
70+
.pitch-slider::-moz-range-thumb:hover {
71+
background: #ff4d4d;
72+
border-color: #fff;
73+
box-shadow: 0 0 12px rgba(255, 77, 77, 0.8);
74+
transform: scale(1.15);
75+
}
76+
77+
/* 🎵 Animated label */
78+
.pitch-label {
79+
color: #fff;
80+
font-size: 0.9rem;
81+
min-width: 80px;
82+
text-align: center;
83+
letter-spacing: 0.4px;
84+
text-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
85+
transition: transform 0.15s ease, opacity 0.15s ease;
86+
}
87+
88+
.pitch-label.active {
89+
transform: scale(1.25);
90+
opacity: 0.7;
91+
}
92+
93+
/* 🔄 Animated reset icon */
94+
.pitch-reset {
95+
display: flex;
96+
align-items: center;
97+
justify-content: center;
98+
background: none;
99+
border: none;
100+
font-size: 1.3rem;
101+
color: #ff6666;
102+
cursor: pointer;
103+
transition: transform 0.4s ease, color 0.4s ease, filter 0.4s ease;
104+
}
105+
106+
.pitch-reset:hover {
107+
transform: rotate(360deg) scale(1.25);
108+
color: #ffaaaa;
109+
filter: drop-shadow(0 0 10px rgba(255, 77, 77, 0.7));
110+
}

0 commit comments

Comments
 (0)