// ==UserScript==
// @name Stem Player Emulator
// @namespace https://www.stemplayer.com/
// @version 0.7
// @description Emulator for Kanye West's stem player
// @author krystalgamer
// @match https://www.stemplayer.com/*
// @match https://www.kanyewest.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=stemplayer.com
// @run-at document-start
// @grant none
// ==/UserScript==
(function() {
'use strict';
let mode = 'mp3';
class MessageType{
static ACK = 0;
static NAK = 1;
static CONNECT = 2;
static DISCONNECT = 3;
static CONTROL = 4;
static RESPONSE = 5;
static FILE_HEADER = 6;
static FILE_BODY = 7;
static ABORT = 8;
};
class ControlType{
static REBOOT = 0;
static VERSION = 1;
static GET_STORAGE_INFO = 2;
static GET_TRACKS_INFO = 3;
static GET_DEVICE_CONFIG = 4;
static GET_ALBUM_CONFIG = 5;
static GET_TRACK_CONFIG = 6;
static GET_ALBUM_COVER = 7;
static ADD_ALBUM = 8;
static DELETE_ALBUM = 9;
static DELETE_TRACK = 10;
static GET_MUSIC_FILE = 11;
static GET_RECORDING_SLOTS = 12;
static GET_RECORDING = 13;
static DELETE_RECORDING = 14;
static RENAME_ALBUM = 15;
static MOVE_TRACK = 16;
static GET_STATE_OF_CHARGE = 17;
};
class FakeUSBInTransferResult{
constructor(data){
this.status = "ok";
this.data = data;
}
};
function createResponse(type, payload){
const l = payload.length + 1;
return new DataView(new Uint8Array([ l&255, (l>>8) & 255, type, ...payload]).buffer);
}
function jsonToUint8(j){
return new Uint8Array([...new TextEncoder().encode(JSON.stringify(j)), 0]);//needs extra null byte because .slice is wrong and trims last byte
}
async function base64_arraybuffer (data) {
// Use a FileReader to generate a base64 data URI
const base64url = await new Promise((r) => {
const reader = new FileReader()
reader.onload = () => r(reader.result)
reader.readAsDataURL(new Blob([data]))
})
/*
The result looks like
"data:application/octet-stream;base64,<your base64 data>",
so we split off the beginning:
*/
return base64url;
}
class FileDownloaderState{
static START = 0;
static HEADER = 1;
static BODY = 2;
static END = 3;
static FINISHED = 4;
};
class FileDownloader{
constructor(type, file){
this.file = file;
this.type = type;
this.state = FileDownloaderState.START;
}
handleOut(data){
console.log('out maquina');
switch(data.type){
case MessageType.ACK:
this.state = (this.state == FileDownloaderState.START ? FileDownloaderState.HEADER : this.state == FileDownloaderState.HEADER ? FileDownloaderState.BODY : this.state == FileDownloaderState.BODY ? FileDownloaderState.END : FileDownloaderState.FINISHED);
break;
default:
console.log('FUCKKKKKKK ' + data);
console.dir(data);
}
}
handleIn(){
switch(this.state){
case FileDownloaderState.HEADER:
return createResponse(MessageType.FILE_HEADER, jsonToUint8({'size': this.file.length, 'type':this.type}));
case FileDownloaderState.BODY:
const res = createResponse(MessageType.FILE_BODY, new Uint8Array([this.file.length & 255, (this.file.length >> 8) & 255, 0, 0, 0, ...this.file]));
this.state = FileDownloaderState.FINISHED;
return res;
case FileDownloaderState.END:
return createResponse(MessageType.ACK, new Uint8Array());
default:
console.log('DOWNLOADER INVALID STATE ' + this.state);
}
}
isFinished(){
return this.state == FileDownloaderState.FINISHED;
}
}
class Stem{
constructor(number, size, track){
this.track = track;
this.number = number;
this.size = size;
this.written = 0;
this.content = new Uint8Array(this.size);
}
addContent(content){
this.content.set(content, this.written);
this.written += content.length;
console.log('Content is at ' + this.written+'/'+this.size);
if(this.written > this.size){
console.error('WENT OVERBOARD ' + this.written + ' yikes ' + this.size);
}
return {'done': this.isFull(), 'data':this.content, 'name':this.track.album.name+'-'+this.track.name+'-'+this.number+'.mp3'};
}
getCoolName(){
return [this.track.album.getCoolName(), this.track.getCoolName(), this.number].join('_') + '.'+mode;
}
saveToDisk(){
if(!this.isFull()){
console.warn('Someone tried to save to disk without data being fully here');
return;
}
base64_arraybuffer(this.content).then( (data) => {
const element = document.createElement('a');
element.setAttribute('href', data);
element.setAttribute('download', this.getCoolName());
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element)
});
}
isFull(){
return this.written == this.size;
}
};
class Track{
constructor(name, album){
this.album = album;
this.name = name;
this.stems = {};
this.config = null;
}
addStem(number, size){
const n = parseInt(number);
const stem = new Stem(number, size, this);
this.stems[n] = stem;
console.warn(this.stems);
return stem;
}
addConfig(config){
this.config = config;
}
getCoolName(){
return this.config.metadata.title;
}
};
class Album{
constructor(name){
this.name = name;
this.tracks = {};
this.config = null;
}
addConfig(config){
this.config = config;
}
addTrack(name){
if(name in this.tracks){
return this.tracks[name];
}
const t = new Track(name, this);
this.tracks[name] = t;
return t;
}
getInfo(){
return {'a':this.name, 'c':[]};
}
getCoolName(){
return this.config.title;
}
};
class StemEmulator{
constructor(){
this.last = null;
this.downloader = null;
this.fileHandler = null;
this.albums = {};
}
unpackData(data){
const l = data[0] + (data[1] << 8);
const type = data[2];
return {
type: type,
payload: data.slice(3, 3+(l-1))
};
}
handleOut(data){
//console.log('got this ' + data);
let un = null;
if(data.constructor.name == 'Uint8Array'){
un = this.unpackData(data);
}
else{
debugger;
console.dir(data);
}
if(this.downloader != null){
this.last = null;
this.downloader.handleOut(un);
this.downloader = (this.downloader.isFinished() ? null : this.downloader);
return;
}
this.last = un;
}
getTracksInfo(){
return {'l':Object.values(this.albums).map((e) => {return e.getInfo();})};
}
/*
bootloaderVersion: (e = n.sent()).blver,
bluetoothVersion: e.btver,
appVersion: e.appver,
serialNumber: e.sn
*/
generateValidSerialNumber(){
let res = '';
for(let i = 0; i<24; i++){
res += parseInt(Math.random()*10);
}
return res;
}
getDeviceInfo(){
return {
'appver': "1.0.1636",
'btver': "1.24.1405",
'blver': "0.1.1311",
'sn': '002800273330510139323636'
};
}
getStorageInfo(){
return {'free': 1000000000, 'size':1000000000 };
}
handleInControl(){
switch(this.last.payload[0]){
case ControlType.GET_TRACKS_INFO:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getTracksInfo())]));
case ControlType.GET_RECORDING_SLOTS:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getTracksInfo())]));
case ControlType.VERSION:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getDeviceInfo())])); //check late
case ControlType.GET_DEVICE_CONFIG:
this.downloader = new FileDownloader('binary', new Uint8Array(20));
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getTracksInfo())])); //check late
case ControlType.GET_STORAGE_INFO:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getStorageInfo())]));
case ControlType.GET_STATE_OF_CHARGE:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.getTracksInfo())]));
case ControlType.ADD_ALBUM:
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0]]));
case ControlType.GET_ALBUM_CONFIG:
const p = JSON.parse(new TextDecoder().decode(this.last.payload.slice(1,-1)));
return createResponse(MessageType.RESPONSE, new Uint8Array([this.last.payload[0], ...jsonToUint8(this.albums[p.album].config)]));
default:
console.warn('unsupported control type ' + this.last.payload[0]);
break;
}
}
handleIn(){
let res = null;
if(this.downloader != null){
this.last = null;
res = this.downloader.handleIn();
this.downloader = (this.downloader.isFinished() ? null : this.downloader);
return new FakeUSBInTransferResult(res);
}
if(this.last == null){
console.log('not implemented');
}
switch(this.last.type){
case MessageType.ACK:
case MessageType.CONNECT:
res = createResponse(MessageType.ACK, new Uint8Array());
const stems = [];
console.warn(this.albums);
Object.values(this.albums).forEach( (album) => {
console.log('Processing album ' + album.name);
Object.values(album.tracks).forEach( (track) => {
console.log('Processing track ' + [album.name,track.name].join());
Object.values(track.stems).forEach( (stem) => {
console.log('Processing stem ' + [album.name,track.name, stem.number].join());
stems.push(stem);
});
});
});
stems.forEach( (stem) => stem.saveToDisk());
this.albums = {};
break;
case MessageType.CONTROL:
res = this.handleInControl();
break;
case MessageType.FILE_HEADER:
res = createResponse(MessageType.ACK, new Uint8Array());
const decoded = new TextDecoder().decode(this.last.payload).slice(0,-1);
console.log(decoded);
const p = JSON.parse(decoded);
switch(p.type){
case 'album-config':
this.albums[p.album] = new Album(p.album);;
this.fileHandler = (content) => {
const config = JSON.parse(new TextDecoder().decode(content));
//console.warn(config);
this.albums[p.album].addConfig(config);
return {'done':true, 'data':null};
};
break;
case 'stem-audio-mp3':
const track = this.albums[p.album].addTrack(p.track);
const stem = track.addStem(parseInt(p.stem), parseInt(p.size));
this.fileHandler = (content) => {
return stem.addContent(content);
};
break;
case 'track-config':
this.fileHandler = (content) => {
const config = JSON.parse(new TextDecoder().decode(content.slice(0, -1)));
//console.warn(config);
this.albums[p.album].tracks[p.track].addConfig(config);
return {'done':true, 'data':null};
};
break;
default:
this.fileHandler = null;
console.warn('need to handle file of type ' + p.type);
console.dir(p);
break;
}
break;
case MessageType.FILE_BODY:
if(this.fileHandler == null){
console.log('GOT BODY but have no handler');
}
else{
const dataSize = ((parseInt(this.last.payload[1]) << 8) + parseInt(this.last.payload[0]));
const data = this.last.payload.slice(5, 5+dataSize);
const res = this.fileHandler(data);
if(res.done == true){
this.fileHandler = null;
if(res.data != null){
//console.warn('Will download now');
console.dir(res);
/*
base64_arraybuffer(res.data).then( (data) => {
const element = document.createElement('a');
element.setAttribute('href', data);
element.setAttribute('download', res.name);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element)
});
*/
}
}
}
res = createResponse(MessageType.ACK, new Uint8Array());
break;
default:
console.warn('unsupported mesesage ' + this.last.type);
this.last = null;
break;
}
return new FakeUSBInTransferResult(res);
}
};
function emptyPromise(){
return new Promise((res, _) => { res(); });
}
function createProxy(f){
return new Proxy(f,
{
get: function(obj, name) {
if (name in obj){
return obj[name];
}
else{
if(name == 'then') return obj;
if(name == 'buffer') return 'cona';
console.log('read request to ' + name + ' property');
return null;
}
},
set: function(obj, name, value) {
console.log('write request to ' + name + ' property with ' + value + ' value');
},
});
}
class FakeUSBOutTransferResult{
constructor(bytesWritten){
this.status = "ok";
this.bytesWritten = bytesWritten;
}
};
class FakeUSB{
constructor(){
this.opened = true;
this.emulator = new StemEmulator();
}
open(){
return emptyPromise();
}
selectConfiguration(config){
//console.log(config);
return emptyPromise();
}
claimInterface(interfaceNumber){
//console.log(interfaceNumber);
return emptyPromise();
}
transferOut(endpointNumber, data){
this.emulator.handleOut(data);
return new Promise((res,_) => { res(new FakeUSBOutTransferResult(data.length)); });
}
transferIn(endpointNumber, length){
//console.log('reading ' + length);
return new Promise((res, _) => { res(this.emulator.handleIn()); } );
}
};
function createFakeUSB(){
return createProxy(new FakeUSB());
}
if(navigator.usb == undefined){
navigator.usb = { addEventListener: () => {}};
}
navigator.usb.getDevices = () => new Promise((res, _) => { res([]); } );
const origRequestDevice = navigator.usb.requestDevice;
navigator.usb.requestDevice = (ignore) => {
console.log('aqui');
return new Promise((res, _) => { res(createFakeUSB()); } );
};
let oldFetch = fetch;
function newFetch(){
for(let i = 0; i< arguments.length; i++){
console.dir(arguments[i]);
console.log(typeof(arguments[i]));
}
let url = arguments[0];
if(typeof(arguments[0]) == 'string' && mode == 'wav'){
url = arguments[0].replace('codec=mp3', 'codec=wav');
}
if(arguments.length == 2){
return oldFetch(url, arguments[1]);
}
return oldFetch(url);
}
window.fetch = newFetch;
function modeStr(){
return 'Current mode: ' + mode;
}
const but = document.createElement('button');
but.style.zIndex = 9999;
but.innerHTML = modeStr();
but.addEventListener('click', (e) => {
mode = mode == 'mp3' ? 'wav' : 'mp3';
e.srcElement.innerHTML = modeStr();
});
if(!!window.InstallTrigger){
window.InstallTrigger = undefined;
}
if(!!window['safari']){
window['safari'] = {};
}
if(!!window.opr){
window.opr = undefined;
}
if(!!window.opera){
window.opera = undefined;
}
Object.defineProperty(navigator, 'userAgent', {
value: navigator.userAgent.replaceAll(' OPR/', '').replaceAll('SamsungBrowser', ''),
configurable: false
});
if(window.chrome == undefined){
window.chrome = {loadTimes:{}};
}
if (document.readyState == "complete" || document.readyState == "loaded" || document.readyState == "interactive") {
document.body.prepend(but)
} else {
document.addEventListener("DOMContentLoaded", function(event) {
document.body.prepend(but)
});
}
})();