-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Feature/pitch shifter plugin #4043
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,48 @@ | ||||||
| import style from './style.css?inline'; | ||||||
| import { createPlugin } from '@/utils'; | ||||||
| import { onPlayerApiReady } from './renderer'; | ||||||
| import { t } from '@/i18n'; | ||||||
|
|
||||||
|
|
||||||
| /** | ||||||
| * π΅ Pitch Shifter Plugin (Tone.js + Solid.js Edition) | ||||||
| * Author: TheSakyo | ||||||
| * | ||||||
| * Provides real-time pitch shifting for YouTube Music using Tone.js, | ||||||
| * allowing users to raise or lower the key of a song dynamically. | ||||||
| */ | ||||||
| export type PitchShifterPluginConfig = { | ||||||
| /** Whether the plugin is enabled (active in the player). */ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| enabled: boolean; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
|
|
||||||
| /** Current pitch shift amount in semitones (-12 to +12). */ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| semitones: number; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| }; | ||||||
|
|
||||||
| export default createPlugin({ | ||||||
| // π§± βββββββββββββββ Plugin Metadata βββββββββββββββ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| name: () => t('plugins.pitch-shifter.name', 'Pitch Shifter'), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| description: () => t('plugins.pitch-shifter.description'), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
|
|
||||||
| /** Whether the app must restart when enabling/disabling the plugin. */ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| restartNeeded: false, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
|
|
||||||
| // βοΈ βββββββββββββββ Default Configuration βββββββββββββββ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| config: { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| enabled: false, // Plugin starts disabled by default | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| semitones: 0, // Neutral pitch (no shift) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
| } as PitchShifterPluginConfig, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <stylistic/no-tabs> reported by reviewdog πΆ |
||||||
|
|
||||||
| // π¨ βββββββββββββββ Plugin Stylesheet βββββββββββββββ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Suggested change
|
||||||
| /** Inline CSS loaded into the YT Music renderer for consistent styling. */ | ||||||
| stylesheets: [style], | ||||||
|
|
||||||
| // π§ βββββββββββββββ Renderer Logic βββββββββββββββ | ||||||
| /** | ||||||
| * The renderer is triggered once the YouTube Music player API is available. | ||||||
| * It handles all DOM interactions, UI injection, and audio processing. | ||||||
| */ | ||||||
| renderer: { | ||||||
| onPlayerApiReady, | ||||||
| }, | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| import { createSignal, onCleanup, createEffect } from "solid-js"; | ||
| import { render } from "solid-js/web"; | ||
| import * as Tone from "tone"; | ||
| import type { RendererContext } from "@/types/contexts"; | ||
| import type { PitchShifterPluginConfig } from "./index"; | ||
|
|
||
| /** | ||
| * π΅ Pitch Shifter Plugin (Tone.js + Solid.js Edition) | ||
| * β Real-time pitch updates | ||
| * β Single slider instance | ||
| * β Clean removal on disable | ||
| * β Dynamic slider color (cool β neutral β warm) | ||
| * β Glassmorphism-ready UI | ||
| * Author: TheSakyo | ||
| */ | ||
| export const onPlayerApiReady = async ( | ||
| _, | ||
| { getConfig, setConfig }: RendererContext<PitchShifterPluginConfig> | ||
| ) => { | ||
| console.log("[pitch-shifter] Renderer (Solid) initialized β "); | ||
|
|
||
| const userConfig = await getConfig(); | ||
| const [enabled, setEnabled] = createSignal(userConfig.enabled); | ||
| const [semitones, setSemitones] = createSignal(userConfig.semitones ?? 0); | ||
|
|
||
| let media: HTMLMediaElement | null = null; | ||
| let pitchShift: Tone.PitchShift | null = null; | ||
| let nativeSource: MediaStreamAudioSourceNode | null = null; | ||
| let mount: HTMLDivElement | null = null; | ||
|
|
||
| /** π§ Wait for <video> element */ | ||
| const waitForMedia = (): Promise<HTMLMediaElement> => | ||
| new Promise((resolve) => { | ||
| const check = () => { | ||
| const el = | ||
| document.querySelector("video") || | ||
| document.querySelector("audio") || | ||
| document.querySelector("ytmusic-player video"); | ||
| if (el) resolve(el as HTMLMediaElement); | ||
| else setTimeout(check, 400); | ||
| }; | ||
| check(); | ||
| }); | ||
|
|
||
| media = await waitForMedia(); | ||
| console.log("[pitch-shifter] Media found π§", media); | ||
|
|
||
| await Tone.start(); | ||
| const toneCtx = Tone.getContext(); | ||
| const stream = | ||
| (media as any).captureStream?.() || (media as any).mozCaptureStream?.(); | ||
| if (!stream) { | ||
| console.error("[pitch-shifter] β captureStream() unavailable"); | ||
| return; | ||
| } | ||
|
|
||
| /** ποΈ Setup pitch shifting (only once) */ | ||
| const setupPitchShift = () => { | ||
| if (pitchShift) return; | ||
| pitchShift = new Tone.PitchShift({ | ||
| pitch: semitones(), | ||
| windowSize: 0.1, | ||
| }).toDestination(); | ||
| nativeSource = toneCtx.createMediaStreamSource(stream); | ||
| Tone.connect(nativeSource, pitchShift); | ||
| media!.muted = true; | ||
| console.log("[pitch-shifter] Pitch processor active πΆ"); | ||
| }; | ||
|
|
||
| /** π΄ Teardown cleanly */ | ||
| const teardownPitchShift = () => { | ||
| pitchShift?.dispose(); | ||
| pitchShift = null; | ||
| nativeSource?.disconnect(); | ||
| nativeSource = null; | ||
| media!.muted = false; | ||
| console.log("[pitch-shifter] Pitch processor stopped π΄"); | ||
| }; | ||
|
|
||
| /** π¨ Solid component for slider UI */ | ||
| const PitchUI = () => { | ||
| /** π‘ Utility: compute slider gradient based on pitch */ | ||
| const getSliderGradient = (value: number) => { | ||
| // Map -12 β 0, 0 β 0.5, 12 β 1 | ||
| const normalized = (value + 12) / 24; | ||
| const cold = [77, 166, 255]; // blue | ||
| const neutral = [255, 77, 77]; // red | ||
| const warm = [255, 170, 51]; // orange | ||
|
|
||
| let color: number[]; | ||
| if (value < 0) { | ||
| // blend blue β red | ||
| const t = normalized * 2; | ||
| color = cold.map((c, i) => Math.round(c + (neutral[i] - c) * t)); | ||
| } else { | ||
| // blend red β orange | ||
| const t = (normalized - 0.5) * 2; | ||
| color = neutral.map((c, i) => Math.round(c + (warm[i] - c) * t)); | ||
| } | ||
| return `linear-gradient(90deg, rgb(${color.join(",")}) 0%, #fff 100%)`; | ||
| }; | ||
|
|
||
| /** ποΈ Update slider color when pitch changes */ | ||
| const updateSliderColor = (slider: HTMLInputElement, value: number) => { | ||
| slider.style.background = getSliderGradient(value); | ||
| }; | ||
|
|
||
| return ( | ||
| <div class="pitch-wrapper"> | ||
| <input | ||
| type="range" | ||
| min="-12" | ||
| max="12" | ||
| step="1" | ||
| value={semitones()} | ||
| class="pitch-slider" | ||
| onInput={(e) => { | ||
| const slider = e.target as HTMLInputElement; | ||
| const v = parseInt(slider.value); | ||
| setSemitones(v); | ||
| setConfig({ semitones: v }); | ||
| if (pitchShift) pitchShift.pitch = v; | ||
| updateSliderColor(slider, v); | ||
|
|
||
| const labelEl = document.querySelector(".pitch-label"); | ||
| if (labelEl) { | ||
| labelEl.classList.add("active"); | ||
| setTimeout(() => labelEl.classList.remove("active"), 200); | ||
| } | ||
| }} | ||
| ref={(el) => updateSliderColor(el, semitones())} | ||
| /> | ||
| <span class="pitch-label"> | ||
| {semitones() >= 0 ? "+" : ""} | ||
| {semitones()} semitones | ||
| </span> | ||
| <button | ||
| class="pitch-reset" | ||
| title="Reset pitch" | ||
| onClick={() => { | ||
| setSemitones(0); | ||
| setConfig({ semitones: 0 }); | ||
| if (pitchShift) pitchShift.pitch = 0; | ||
| const slider = document.querySelector( | ||
| ".pitch-slider" | ||
| ) as HTMLInputElement; | ||
| if (slider) updateSliderColor(slider, 0); | ||
|
|
||
| const labelEl = document.querySelector(".pitch-label"); | ||
| if (labelEl) { | ||
| labelEl.classList.add("active"); | ||
| setTimeout(() => labelEl.classList.remove("active"), 200); | ||
| } | ||
| }} | ||
| > | ||
| π | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| /** π§± Mount UI (only once) */ | ||
| const injectUI = () => { | ||
| const tabs = document.querySelector("tp-yt-paper-tabs.tab-header-container"); | ||
| if (tabs && tabs.parentElement && !document.querySelector(".pitch-wrapper")) { | ||
| mount = document.createElement("div"); | ||
| tabs.parentElement.insertBefore(mount, tabs); | ||
| render(() => <PitchUI />, mount); | ||
| console.log("[pitch-shifter] UI injected via Solid β "); | ||
| } | ||
| }; | ||
|
|
||
| /** π§Ή Remove UI on disable */ | ||
| const removeUI = () => { | ||
| const existing = document.querySelector(".pitch-wrapper"); | ||
| if (existing) { | ||
| existing.remove(); | ||
| mount = null; | ||
| console.log("[pitch-shifter] UI removed β"); | ||
| } | ||
| }; | ||
|
|
||
| /** π React to plugin state */ | ||
| createEffect(() => { | ||
| if (enabled()) { | ||
| setupPitchShift(); | ||
| injectUI(); | ||
| } else { | ||
| teardownPitchShift(); | ||
| removeUI(); | ||
| } | ||
| }); | ||
|
|
||
| /** β±οΈ Periodically sync config */ | ||
| const interval = setInterval(async () => { | ||
| const conf = await getConfig(); | ||
| if (conf.enabled !== enabled()) setEnabled(conf.enabled); | ||
| if (conf.semitones !== semitones()) setSemitones(conf.semitones); | ||
| }, 1000); | ||
|
|
||
| onCleanup(() => { | ||
| clearInterval(interval); | ||
| teardownPitchShift(); | ||
| removeUI(); | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| .pitch-wrapper { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 14px; | ||
| margin-bottom: 6px; | ||
| user-select: none; | ||
| font-family: "Inter", "Segoe UI", sans-serif; | ||
| background: rgba(255, 255, 255, 0.05); | ||
| backdrop-filter: blur(12px) saturate(180%); | ||
| border-radius: 12px; | ||
| padding: 8px 14px; | ||
| border: 1px solid rgba(255, 255, 255, 0.1); | ||
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); | ||
| transition: background 0.3s ease, transform 0.2s ease; | ||
| } | ||
|
|
||
| .pitch-wrapper:hover { | ||
| background: rgba(255, 255, 255, 0.08); | ||
| transform: translateY(-1px); | ||
| } | ||
|
|
||
| /* ποΈ Premium gradient slider */ | ||
| .pitch-slider { | ||
| width: 160px; | ||
| height: 5px; | ||
| appearance: none; | ||
| border-radius: 3px; | ||
| background: linear-gradient(90deg, #ff4d4d 0%, #ff8080 100%); | ||
| outline: none; | ||
| cursor: pointer; | ||
| transition: filter 0.2s ease, transform 0.2s ease; | ||
| } | ||
|
|
||
| .pitch-slider:hover { | ||
| filter: brightness(1.15); | ||
| transform: scaleX(1.03); | ||
| } | ||
|
|
||
| /* Chrome / Edge thumb */ | ||
| .pitch-slider::-webkit-slider-thumb { | ||
| appearance: none; | ||
| width: 16px; | ||
| height: 16px; | ||
| background: rgba(255, 255, 255, 0.9); | ||
| border-radius: 50%; | ||
| border: 2px solid #ff4d4d; | ||
| transition: all 0.25s ease; | ||
| box-shadow: 0 0 6px rgba(255, 77, 77, 0.5); | ||
| } | ||
|
|
||
| .pitch-slider::-webkit-slider-thumb:hover { | ||
| background: #ff4d4d; | ||
| border-color: #fff; | ||
| box-shadow: 0 0 12px rgba(255, 77, 77, 0.8); | ||
| transform: scale(1.15); | ||
| } | ||
|
|
||
| /* Firefox thumb */ | ||
| .pitch-slider::-moz-range-thumb { | ||
| width: 16px; | ||
| height: 16px; | ||
| background: rgba(255, 255, 255, 0.9); | ||
| border-radius: 50%; | ||
| border: 2px solid #ff4d4d; | ||
| transition: all 0.25s ease; | ||
| box-shadow: 0 0 6px rgba(255, 77, 77, 0.5); | ||
| } | ||
|
|
||
| .pitch-slider::-moz-range-thumb:hover { | ||
| background: #ff4d4d; | ||
| border-color: #fff; | ||
| box-shadow: 0 0 12px rgba(255, 77, 77, 0.8); | ||
| transform: scale(1.15); | ||
| } | ||
|
|
||
| /* π΅ Animated label */ | ||
| .pitch-label { | ||
| color: #fff; | ||
| font-size: 0.9rem; | ||
| min-width: 80px; | ||
| text-align: center; | ||
| letter-spacing: 0.4px; | ||
| text-shadow: 0 0 8px rgba(255, 255, 255, 0.1); | ||
| transition: transform 0.15s ease, opacity 0.15s ease; | ||
| } | ||
|
|
||
| .pitch-label.active { | ||
| transform: scale(1.25); | ||
| opacity: 0.7; | ||
| } | ||
|
|
||
| /* π Animated reset icon */ | ||
| .pitch-reset { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| background: none; | ||
| border: none; | ||
| font-size: 1.3rem; | ||
| color: #ff6666; | ||
| cursor: pointer; | ||
| transition: transform 0.4s ease, color 0.4s ease, filter 0.4s ease; | ||
| } | ||
|
|
||
| .pitch-reset:hover { | ||
| transform: rotate(360deg) scale(1.25); | ||
| color: #ffaaaa; | ||
| filter: drop-shadow(0 0 10px rgba(255, 77, 77, 0.7)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π« [eslint] <prettier/prettier> reported by reviewdog πΆ
Delete
β