lethargic: vital preset merger and randomizer

2021-11-21

word count: 961

approx reading time: 5 mins

i made a small thingy that let's you merge and randomize vital presets from the browser. you can check it out here

the following is a bit of an explanation of how it works. the code is relatively simple so you should be able to follow along even if you don't know much js. hopefully it's interesting!

vital presets

vital presets are just json files with a ton (a ton) of fields. it's not the best, but it makes our lives a lot easier! here's an example of a preset. it's the init preset:

{
    "author": "<your name here>",
    "comments": "",
    "macro1": "MACRO 1",
    "macro2": "MACRO 2",
    "macro3": "MACRO 3",
    "macro4": "MACRO 4",
    "preset_name": "init",
    "preset_style": "",
    "settings": {
        "beats_per_minute": 2.0,
        "bypass": 0.0,
        "chorus_cutoff": 60.0,
        "chorus_delay_1": -9.0,
        "chorus_delay_2": -7.0,
        "chorus_dry_wet": 0.5,
        "chorus_feedback": 0.4000000059604645,
        ...
    },
    "synth_version": "1.0.7"
}

the settings object contains the important values that we want to play with. it's also huge, so im not gonna list all of the fields here

side note: samples and waveforms are stored in text using base 64 :)

code

the code is relatively simple, it first sets an onclick for the submit button, which takes care of loading the presets, randomizing them, merging them, and then saving the merged preset:

// input type=file
const files = document.getElementById("files");
// input type=range min=0 max=100
const random = document.getElementById("random");
// button
const button = document.getElementById("merge");

// main submit function
button.onclick = async function() {
    // stop if there are no files
    if (files.files.length == 0) {
        alert("please upload some presets to be merged :)");
        return;
    }

    // load all uploaded files at the same time, using `Promise.all`
    const presets = await Promise.all(
        // we have to do [...files.files] cause `files` does not have a `map`,
        // but it is iterable :)
        // js moment :)
        [...files.files].map(async f => {
            return JSON.parse(await f.text());
        })
    );
    // then we randomize the presets,
    // and we merge them all using the `reduce` method
    const output = presets.map(randomize).reduce(merge);

    savePreset(output);
};

as you can see, FileList is fun in that it's kinda an array, but not really cause fuck you :) you can do array access and also use the spread operator, but you can't actually map through it. to be able to map it, we have to first do [...files.files] so it becomes an array. i do not enjoy the js apis

continuing with js funny-ness, we have to make a small util function to check the types of stuff:

// js does not have a built in thing :)
// im not angry im just disappointed
function isNumeric(something) {
    return typeof(something) === 'number';
}

the rest of the functions are not much more complex, but they pull some dynamic-language tricks. the randomize function for example, maps over all of the numeric fields in the settings object, modifying them a random ammount:

function randomize(preset) {
    // don't do anything if we don't have to do anything
    if (random.value == 0) return;

    // go through all the keys in the settings object, and modify them only if they are numeric
    Object.keys(preset.settings).forEach((key) => {
        if (isNumeric(preset.settings[key])) {
            // we want to randomize a normalized ammount, so we do a bit of maths
            //
            // r = Math.random() - 0.5
            // r is in [-0.5, 0.5]
            // random = random.value / 100
            // random is in [0.0, 1.0]
            // change = (1 + r * random)
            // change is in [0.5, 1.5]
            // val = val * change
            preset.settings[key] *= 1 + (Math.random() - 0.5) * (random.value / 100);
        }
    });
    return preset;
}

since some fields have big values and others have really small values, it wouldn't make sense to modify them all by the same ammount. to normalize the randomization value, we do val = val * (1 + r), where r is a random number from -0.5 to 0.5. since we want to be able to control the ammount of randomization, we multiply r by the value in the slider

since the slider value goes from 0 to 100, we divide it by 100 to get a value from 0 to 1, which when we multiply it by r, it will give us control over how much we randomize each field. in the end, each field can go as low as 50% it's original value, to up to 150%

the merge function is similar, but we have to first get the union of the keys:

function merge(a, b) {
    // since not every preset might have all keys defined, we want to get a unique union list of the keys
    // we use the spread operator to put all the keys in an array, then make a `Set` to get uniqueness
    let keys = new Set([...Object.keys(a.settings), ...Object.keys(b.settings)]);

    // loop throuhg the keys, and if they're defined in both, set it as the average
    // if the key is not defined in either of them, we ignore it and leave whatever is in the first preset
    Array.from(keys).forEach((key) => {
        if ((a.settings[key] !== undefined && isNumeric(a.settings[key])) ||
            (b.settings[key] !== undefined && isNumeric(b.settings[key]))) {
            // we modify the value in the first preset, which is the one we return
            a.settings[key] = (a.settings[key] + b.settings[key]) * 0.5;
        }
    });

    return a;
}

finally, the savePreset function makes an <a> tag, and uses it to download the preset:

function savePreset(preset) {
    // we turn the preset into a json string, then make a data thingy out of it
    var dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(preset));
    // we create a button, set the data as the href, give it a name, and click it
    // this causes the preset to be downloaded as lethargic.vital
    var dlAnchorElem = document.createElement("a");
    dlAnchorElem.setAttribute("href", dataStr);
    dlAnchorElem.setAttribute("download", "lethargic.vital");
    dlAnchorElem.click();
}

and that's it really! there's not much going on tbh, but hopefully it was worth reading

i'd like to add more features, but if i do i will probably not edit this post, so if you read this and the code here and on the lethargic page are different, you know why

separator line, for decoration