// ==UserScript==
// @name Youtube HTML5 Karaoke
// @namespace https://github.com/heyqule/youtubekaraoke
// @version 1.4.0
// @description HTML5 Karaoke Vocal Control, support center channel cut on regular MV, left/right channel vocal/instrumental mixed MVs. Support: Youtube and Bilibili
// @description:zh HTML5 卡拉OK人声控制,支持常规MV中置声道切换,左右声道人声/器乐混合MV。支持:Youtube 和 Bilibili
// @description:ja HTML5 カラオケ ボーカル コントロール、通常の MV でのセンター チャンネル カット、左/右チャンネルのボーカル/インストゥルメンタル ミックス MV をサポート。サポート: Youtube, Bilibili
// @author heyqule
// @license GPLv3
// @match https://www.youtube.com/*
// @match https://www.bilibili.com/*
// @require https://code.jquery.com/jquery-4.0.0-beta.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/build/md5.min.js
// @grant unsafeWindow
// @grant GM.xmlHttpRequest
// @grant window.onurlchange
// @run-at document-end
// ==/UserScript==
(function($, md5) {
'use strict';
if (window.trustedTypes && window.trustedTypes.createPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: (string) => string,
createScript: (string) => string
});
}
const languages={
"zh":{
"title": "🎤 控制",
"off": "🎤: 关",
"on": "🎤: 开",
"vocal_l1": "人声衰减",
"vocal_l2": "(左 - 中1 - 中2 - 右)",
"high_pass": "高通",
"low_pass": "低通",
"mic_gain": "🎤 增益",
"mic_gain_desc": "从浏览器连接的麦克风有明显的延迟。 建议通过音频接口器去控制。",
},
//Ja by google translate
"ja":{
"title": "🎤 コントロール",
"off": "🎤: オフ",
"on": "🎤: オン",
"vocal_l1": "ボーカルの減衰",
"vocal_l2": "(左 - 中1 - 中2 - 右)",
"high_pass": "ハイパス",
"low_pass": "ローパス",
"mic_gain": "🎤 ゲイン",
"mic_gain_desc": "ブラウザから接続したマイクの遅延が顕著です。 オーディオインターフェース経由でコントロールすることをお勧めします。",
},
"en":{
"title": "🎤 Controls",
"off": "🎤: OFF",
"on": "🎤: ON",
"vocal_l1": "Vocal Attenuation",
"vocal_l2": "(left - center1 - center2 - right)",
"high_pass": "High Pass",
"low_pass": "Low Pass",
"mic_gain": "🎤 Gain",
"mic_gain_desc": "Mic connected from browser has noticeable delay. Recommend to connect mic through an audio interface.",
},
}
let lang = 'en';
//Youtube Handler
let mediaElement = 'video.html5-main-video';
let targetContainer = 'div.ytp-right-controls';
let UiAttachTo = 'div#primary div#player';
let youtubeDarkThemeUiAttachTo = 'div#primary div#alerts';
let buttonTag = '<button />';
let buttonClass = 'ytp-karaoke-button ytp-button';
let buttonStyle = 'position: relative; top:-1.5rem; padding-left:1rem; font-size:2rem; cursor: pointer;';
let urlChangePattern = 'watch';
let getSongId = function() {
let queryString = window.location.search;
let urlParams = new URLSearchParams(queryString);
return urlParams.get('v');
}
let isYoutubeDarkTheme = document.documentElement.hasAttribute('dark');
let darkThemeTextColor = ' color:#fff;';
let youtubeLang = document.documentElement.getAttribute('lang');
if (youtubeLang)
{
lang = (youtubeLang.indexOf("-") != -1 ? youtubeLang.split("-")[0] : 'en').toLocaleLowerCase();
}
if (/bilibili\.com/.test(window.location.href)) {
mediaElement = '#bilibili-player video';
targetContainer = 'div.bpx-player-control-bottom-right';
UiAttachTo = '#playerWrap';
buttonTag = '<div />';
buttonClass = 'bpx-player-ctrl-btn';
buttonStyle = 'position: relative; margin-right:1rem; font-size:1.5rem; cursor: pointer;';
urlChangePattern = 'video';
getSongId = function() {
let token = window.location.pathname;
return md5(token);
}
if (/bilibili\.com\/bangumi\/play/.test(window.location.href)) {
targetContainer = 'div.bpx-player-control-bottom-right';
UiAttachTo = '#bilibili-player-wrap';
urlChangePattern = 'bangumi/play';
}
}
let KaraokeUI = function ($) {
let _translate = function(label) {
return languages[lang][label] ?? languages["en"][label] ?? '{404 locale:'+label+'}';
}
let karaokeButton = $(buttonTag,{
title: _translate('off'),
id: 'karaoke-button',
class: buttonClass,
text: '🎤',
style: buttonStyle,
'aria-haspopup': 'true',
onClick: 'KaraokePluginSwitch();'
});
//Control Panel
let controlPanel, channelAdjustControl, highPassAdjustControl, lowPassAdjustControl, gainAdjustControl;
let highPassAdjustDisplay, lowPassAdjustDisplay
return {
menuUI : function() {
$(targetContainer).prepend(karaokeButton);
},
controlPanelUI : function(channelAdjustedValue, highPassAdjustedValue, lowPassAdjustedValue, gainAdjustedValue) {
let columnStyle = 'width:33%; display:inline-block;';
let titleStyle = '';
if (isYoutubeDarkTheme) {
columnStyle += darkThemeTextColor;
titleStyle = darkThemeTextColor;
}
controlPanel = $('<div>',{
id:"karaoke_controlpanel",
});
controlPanel.append($('<h3>',{
text: _translate('title'),
style: titleStyle
}));
channelAdjustControl = $('<input>',{
type: 'range',
id: 'channelshift',
min: 0,
max: 3,
value: channelAdjustedValue,
step: 1,
onchange: 'KaraokePluginChannelAdjust(this)'
});
highPassAdjustControl = $('<input>',{
type: 'range',
id: 'highpass',
min: 50,
max: 400,
value: highPassAdjustedValue,
step: 10,
onchange: 'KaraokePluginHighPassAdjust(this)'
});
lowPassAdjustControl = $('<input>',{
type: 'range',
id: 'lowpass',
min: 2000,
max: 8000,
value: lowPassAdjustedValue,
step: 200,
onchange: 'KaraokePluginLowPassAdjust(this)'
})
gainAdjustControl = $('<input>',{
type: 'range',
id: 'micgain',
min: 0,
max: 2,
value: gainAdjustedValue,
step: 0.1,
onchange: 'KaraokePluginMicGainAdjust(this)'
})
controlPanel.append(
$('<div>',{style: columnStyle}).
append('<label style="width:100px;">'+_translate('vocal_l1')+':</label><br />').
append('<label>'+_translate('vocal_l2')+'</label><br />').
append(channelAdjustControl).
append('<br />').
append('<label style="width:100px;">'+_translate('high_pass')+': <span id="KaraokeHighPassValue">'+highPassAdjustedValue+'</span> Hz</label><br />').
append(highPassAdjustControl).
append('<br />').
append('<label style="width:100px;">'+_translate('low_pass')+': <span id="KaraokeLowPassValue">'+lowPassAdjustedValue+'</span> Hz</label><br />').
append(lowPassAdjustControl)
);
let secondColumn = $('<div>',{style: columnStyle});
secondColumn.append('<label style="width:100px;">'+_translate('mic_gain')+': <span id="KaraokeGainValue">'+gainAdjustedValue+'</span></label><br />').
append(gainAdjustControl).
append('<p>'+_translate('mic_gain_desc')+'</p>');
controlPanel.append(secondColumn);
if (isYoutubeDarkTheme) {
controlPanel.insertBefore(youtubeDarkThemeUiAttachTo);
}
else
{
controlPanel.insertAfter(UiAttachTo);
}
highPassAdjustDisplay = $('#KaraokeHighPassValue');
lowPassAdjustDisplay = $('#KaraokeLowPassValue');
return controlPanel
},
setKaraokeButtonOn: function() {
karaokeButton.attr('title', _translate('on'));
},
setKaraokeButtonOff: function() {
karaokeButton.attr('title',_translate('off'));
},
getChannelAdjustControl: function() {
return channelAdjustControl
},
getHighPassAdjustControl: function() {
return highPassAdjustControl
},
getLowPassAdjustControl: function() {
return lowPassAdjustControl
},
getHighPassAdjustDisplay: function() {
return highPassAdjustDisplay;
},
getLowPassAdjustDisplay: function() {
return lowPassAdjustDisplay;
}
}
}(jQuery)
let KaraokePlugin = function ($, KaraokeUI) {
const MAX_CACHE_SIZE = 5000;
const MAX_RETRIES = 20;
const TIME_INTERVAL = 1500;
//webaudio elements
let audioContext, audioSource,micAudioContext, micSource;
let karaokeFilterOn = false;
let channelAdjustedValue = 1, gainAdjustedValue = 1;
let highPassAdjustedValue = 200, lowPassAdjustedValue = 6000
let trackSearchDialog = null;
let _createBiquadFilter = function(type,freq,qValue)
{
let filter = audioContext.createBiquadFilter();
filter.type = type;
filter.frequency.value = freq;
filter.Q.value = qValue;
return filter;
}
/**
* Cut common vocal frequencies @ center
* Algo origin: https://github.com/stanton119/YouTube-Karaoke
*/
let _cutCenterV1 = function()
{
//cutoff frequencies
let f1 = highPassAdjustedValue;
let f2 = lowPassAdjustedValue;
console.log('setting center cut v1 @'+f1+' - '+f2);
//splitter and gains
let splitter, gainL, gainR;
//biquadFilters
let filterLP1, filterHP1, filterLP2, filterHP2;
let filterLP3, filterHP3, filterLP4, filterHP4;
//phase inversion filter
splitter = audioContext.createChannelSplitter(2);
gainL = audioContext.createGain();
gainR = audioContext.createGain();
gainL.gain.value = 1;
gainR.gain.value = -1;
splitter.connect(gainL, 0);
splitter.connect(gainR, 1);
gainL.connect(audioContext.destination);
gainR.connect(audioContext.destination);
//biquad filters
filterLP1 = _createBiquadFilter("lowpass",f2,1);
filterLP2 = _createBiquadFilter("lowpass",f1,1);
filterLP3 = _createBiquadFilter("lowpass",f2,1);
filterLP4 = _createBiquadFilter("lowpass",f1,1);
filterHP1 = _createBiquadFilter("highpass",f1,1);
filterHP2 = _createBiquadFilter("highpass",f2,1);
filterHP3 = _createBiquadFilter("highpass",f1,1);
filterHP4 = _createBiquadFilter("highpass",f2,1);
//connect filters
audioSource.connect(filterLP1);
audioSource.connect(filterLP2);
audioSource.connect(filterHP2);
filterLP1.connect(filterLP3);
filterLP3.connect(filterHP1);
filterHP1.connect(filterHP3);
filterHP3.connect(splitter);
filterLP2.connect(filterLP4);
filterLP4.connect(audioContext.destination);
filterHP2.connect(filterHP4);
filterHP4.connect(audioContext.destination);
}
/**
* Cut common vocal frequencies @ center with preserve stereo field
* Algo origin: https://github.com/stanton119/YouTube-Karaoke
*/
let _cutCenterV2 = function()
{
//cutoff frequencies
let f1 = highPassAdjustedValue;
let f2 = lowPassAdjustedValue;
console.log('setting center cut with stereo field @'+f1+' - '+f2);
// stereo conversion
let merger = audioContext.createChannelMerger(2);
merger.connect(audioContext.destination);
// L_Out = (Mid+side)/2
let gainNodeMS1_05 = audioContext.createGain();
gainNodeMS1_05.gain.value = 0.5;
gainNodeMS1_05.connect(merger,0,0);
// R_Out = (Mid-side)/2
let gainNodeMS2_05 = audioContext.createGain();
gainNodeMS2_05.gain.value = 0.5;
gainNodeMS2_05.connect(merger,0,1);
let gainNodeS_1 = audioContext.createGain();
gainNodeS_1.gain.value = -1;
gainNodeS_1.connect(gainNodeMS2_05);
// create band stop filter using two cascaded biquads
// inputs -> FilterLP1 & FilterLP2
// outputs -> splitter & destinations
// Bandstop filter = LP + HP
let FilterLP1 = _createBiquadFilter('lowpass', f1, 1);
let FilterLP2 = _createBiquadFilter('lowpass', f1, 1);
FilterLP1.connect(FilterLP2);
let FilterHP1 = _createBiquadFilter('highpass', f2, 1);
let FilterHP2 = _createBiquadFilter('highpass', f2, 1);
FilterHP1.connect(FilterHP2);
// connect filters to left and right outputs
FilterLP2.connect(gainNodeMS1_05);
FilterHP2.connect(gainNodeMS1_05);
FilterLP2.connect(gainNodeMS2_05);
FilterHP2.connect(gainNodeMS2_05);
// band pass with gain, adds mids into the side channel
let gainNodeBP = audioContext.createGain();
gainNodeBP.gain.value = 1;
let FilterBP1 = _createBiquadFilter('lowpass', f2, 1);
let FilterBP2 = _createBiquadFilter('lowpass', f2, 1);
FilterBP2.connect(FilterBP1);
let FilterBP3 = _createBiquadFilter('highpass', f1, 1);
FilterBP3.connect(FilterBP2);
let FilterBP4 = _createBiquadFilter('highpass', f1, 1);
FilterBP4.connect(FilterBP3);
FilterBP1.connect(gainNodeBP);
gainNodeBP.connect(gainNodeS_1);
gainNodeBP.connect(gainNodeMS1_05);
// mid-side conversion
// split into L/R
let splitter = audioContext.createChannelSplitter(2);
// mid = L+R
splitter.connect(FilterLP1,0); // // L->filter
splitter.connect(FilterHP1,0);
splitter.connect(FilterLP1,1); // R->filter
splitter.connect(FilterHP1,1);
// side = L-R, 2 outputs, 2 destinations
let gainNodeR_1 = audioContext.createGain();
gainNodeR_1.gain.value = -1;
splitter.connect(gainNodeR_1,1);
gainNodeR_1.connect(gainNodeS_1);
splitter.connect(gainNodeS_1,0);
gainNodeR_1.connect(gainNodeMS1_05);
splitter.connect(gainNodeMS1_05,0);
gainNodeR_1.connect(FilterBP4);
splitter.connect(FilterBP4,0);
audioSource.connect(splitter);
}
/**
* Expand left channel to both channel, drop right channel
*/
let _cutRight = function()
{
console.log('setting right cut');
let splitter, merger;
splitter = audioContext.createChannelSplitter(2);
merger = audioContext.createChannelMerger(1);
splitter.connect(merger, 0);
audioSource.connect(splitter);
merger.connect(audioContext.destination);
}
/**
* Expand right channel to both channel, drop left channel
*/
let _cutLeft = function()
{
console.log('setting left cut');
let splitter,merger;
splitter = audioContext.createChannelSplitter(2);
merger = audioContext.createChannelMerger(1);
splitter.connect(merger, 1);
audioSource.connect(splitter);
merger.connect(audioContext.destination);
}
/**
* Handle Microphone gain. This only applicable to mic that connected to browser.
* @param amount
* @private
*/
let _micGain = function(amount)
{
let gainElement = $('#KaraokeGainValue')
gainElement.html(amount);
console.log(gainElement.html());
micSource.disconnect();
let micGain = micAudioContext.createGain();
micSource.connect( micGain );
micGain.connect( micAudioContext.destination );
micGain.gain.value = amount;
micSource.connect( micAudioContext.destination );
}
/**
* 0 = left cut, 1 = center cut v2, 2 = center cut v1, 2 = right cut
**/
let _adjustChannel = function()
{
console.log('channelAdjust:'+channelAdjustedValue);
_disconnectProcessors();
switch(channelAdjustedValue) {
case 0:
_cutLeft();
break;
case 1:
_cutCenterV2();
break;
case 2:
_cutCenterV1();
break;
case 3:
_cutRight();
break;
}
_saveSetting();
}
let _disconnectProcessors = function() {
console.log('disconnect audio processors');
audioSource.disconnect();
}
let _getSongId = function() {
return getSongId();
}
let _loadSetting = function() {
let songId = _getSongId();
if(typeof songId === undefined || songId === null) {
return;
}
let localSetting = localStorage.getItem(songId);
let savedItem = null;
if(localSetting !== null) {
savedItem = JSON.parse(localSetting);
}
console.log("Loading "+songId, savedItem);
if(savedItem !== null) {
touchLocalStorage(songId, savedItem);
}
}
let touchLocalStorage = function(songId, savedItem) {
channelAdjustedValue = savedItem.cv;
lowPassAdjustedValue = savedItem.lpv;
highPassAdjustedValue = savedItem.hpv;
savedItem.date = Date.now();
localStorage.setItem(songId, JSON.stringify(savedItem));
}
let _readjustControls = function() {
KaraokeUI.getChannelAdjustControl().val(channelAdjustedValue);
KaraokeUI.getHighPassAdjustControl().val(highPassAdjustedValue);
KaraokeUI.getLowPassAdjustControl().val(lowPassAdjustedValue);
KaraokeUI.getHighPassAdjustDisplay().html(highPassAdjustedValue.toString())
KaraokeUI.getLowPassAdjustDisplay().html(lowPassAdjustedValue.toString())
}
let _saveSetting = function() {
let songId = _getSongId();
if(songId === null) {
return;
}
let data = {
cv: channelAdjustedValue,
lpv: lowPassAdjustedValue,
hpv: highPassAdjustedValue,
date: Date.now()
}
console.log('Saving Setting: '+songId, data)
localStorage.setItem(songId, JSON.stringify(data));
_trimCache();
}
let _trimCache = function() {
if(localStorage.length > MAX_CACHE_SIZE) {
let sortableArray = [];
for (let i = 0; i < localStorage.length; i++) {
let jsonItem = localStorage.getItem(localStorage.key(i));
let item = JSON.parse(jsonItem);
if(typeof item.cv !== undefined)
{
sortableArray[localStorage.key(i)] = {
key: localStorage.key(i),
data: JSON.parse(localStorage.getItem(localStorage.key(i)))
};
}
}
sortableArray.sort((a, b) => (a.data.date > b.data.date) ? 1 : -1);
for (let i = 0; i < MAX_CACHE_SIZE/5; i++) {
localStorage.removeItem(sortableArray[i].key);
}
}
}
let _connectAudio = function(element) {
//setup audio routing
try {
window.AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext();
audioSource = audioContext.createMediaElementSource(element);
audioSource.connect(audioContext.destination);
} catch (e) {
console.error('Media element not found.');
console.error(e.message);
}
}
let _getVideoElement = function(mediaElement) {
let element = $(mediaElement)
if (typeof $(mediaElement)[0] !== 'undefined') {
element = $(mediaElement)[0]
}
return element;
}
return {
setupAudioSource : function ()
{
if(typeof _getVideoElement(mediaElement).tagName === 'undefined')
{
console.log('audio connecting via interval');
var retries = 0;
var intervalId = setInterval(function() {
console.log('audio connect retry: '+retries);
if(retries > 10) {
clearInterval(intervalId);
return this;
}
console.log(_getVideoElement(mediaElement));
if(_getVideoElement(mediaElement).tagName === 'VIDEO') {
console.log('audio connected');
_connectAudio(_getVideoElement(mediaElement));
clearInterval(intervalId);
return this;
}
retries++;
}, TIME_INTERVAL);
}
else
{
console.log('audio connected immediately');
_connectAudio(_getVideoElement(mediaElement));
}
return this;
},
setupMic: function() {
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function(stream) {
/* use the stream */
window.AudioContext = window.AudioContext || window.webkitAudioContext;
micAudioContext = new AudioContext();
console.log('Mic Latency:'+micAudioContext.baseLatency);
// Create an AudioNode from the stream.
micSource = micAudioContext.createMediaStreamSource( stream );
// Connect it to the destination to hear yourself (or any other node for processing!)
micSource.connect( micAudioContext.destination );
})
.catch(function(err) {
/* handle the error */
});
return this;
},
setupMenu: function()
{
if($(targetContainer).length === 0)
{
console.log('menu connecting via interval');
var retries = 0;
var intervalId = setInterval(function() {
console.log('menu retry: '+retries);
if(retries > 10) {
clearInterval(intervalId);
return this;
}
if($(targetContainer).length > 0) {
console.log('audio connected');
KaraokeUI.menuUI();
clearInterval(intervalId);
return this;
}
retries++;
}, TIME_INTERVAL);
}
else
{
console.log('menu connected immediately');
KaraokeUI.menuUI();
}
},
filterOn: function() {
console.log("Removing vocals");
_adjustChannel();
return this;
},
filterOff: function() {
console.log("Adding in vocals");
_disconnectProcessors();
audioSource.connect(audioContext.destination);
return this;
},
switch: function()
{
if(karaokeFilterOn)
{
karaokeFilterOn = false;
this.filterOff();
KaraokeUI.setKaraokeButtonOff();
this.removeControlPanel();
}
else
{
karaokeFilterOn = true;
this.filterOn();
KaraokeUI.setKaraokeButtonOn();
this.showControlPanel();
}
return this;
},
showControlPanel: function()
{
console.log('showpanel');
this.controlPanel = KaraokeUI.controlPanelUI(channelAdjustedValue,
highPassAdjustedValue, lowPassAdjustedValue, gainAdjustedValue);
_loadSetting();
return this;
},
removeControlPanel: function()
{
console.log('hidepanel');
this.controlPanel.remove();
return this;
},
isFilterOn: function() {
return karaokeFilterOn;
},
micGainAdjust: function(element)
{
gainAdjustedValue = $(element).val();
_micGain(gainAdjustedValue);
return this;
},
channelAdjust: function(element)
{
channelAdjustedValue = parseInt($(element).val());
_adjustChannel();
return this;
},
highPassAdjust: function(element)
{
highPassAdjustedValue = parseInt($(element).val());
KaraokeUI.getHighPassAdjustDisplay().html(highPassAdjustedValue.toString());
_adjustChannel()
return this;
},
lowPassAdjust: function(element)
{
lowPassAdjustedValue = parseInt($(element).val());
KaraokeUI.getLowPassAdjustDisplay().html(lowPassAdjustedValue.toString());
_adjustChannel()
return this;
},
loadSetting: function() {
_loadSetting();
}
};
}(jQuery, KaraokeUI);
if (typeof audioContext === 'undefined') {
console.log(mediaElement);
console.log(targetContainer);
console.log(UiAttachTo);
console.log("Loading setting");
KaraokePlugin.loadSetting();
console.log("setting up mic");
KaraokePlugin.setupMic();
console.log("setting up audio source");
KaraokePlugin.setupAudioSource(mediaElement);
console.log("setting up menu");
KaraokePlugin.setupMenu(targetContainer);
unsafeWindow.KaraokePluginSwitch = function() {
KaraokePlugin.switch();
}
unsafeWindow.KaraokePluginMicGainAdjust = function(element) {
KaraokePlugin.micGainAdjust(element);
}
unsafeWindow.KaraokePluginChannelAdjust = function(element) {
KaraokePlugin.channelAdjust(element);
}
unsafeWindow.KaraokePluginHighPassAdjust = function(element) {
KaraokePlugin.highPassAdjust(element);
}
unsafeWindow.KaraokePluginLowPassAdjust = function(element) {
KaraokePlugin.lowPassAdjust(element);
}
}
window.addEventListener("popstate", (event) => {
console.log('Event: popstate, reload setting');
KaraokePlugin.loadSetting();
if(KaraokePlugin.isFilterOn()) {
KaraokePlugin.switch();
KaraokePlugin.switch();
}
});
if (window.onurlchange === null) {
console.log('Url Change Event. Setup');
window.addEventListener('urlchange', (info) => {
console.log('Url Changed, reload setting.');
if (window.location.href.includes(urlChangePattern)) {
KaraokePlugin.loadSetting();
if(KaraokePlugin.isFilterOn()) {
KaraokePlugin.switch();
KaraokePlugin.switch();
}
}
});
}
})(jQuery, md5);