Stem Player Emulator

Emulator for Kanye West's stem player

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