UNPKG

u-he-preset-randomizer

Version:

Create random u-he synth presets through randomization and merging of your existing presets.

532 lines 23 kB
/** * @file Core randomization logic for preset generation. * Provides functions to generate fully random presets, randomize existing presets, * and merge multiple presets with statistical weighting. */ import chalk from 'chalk'; import { adjectives, colors, names, uniqueNamesGenerator, } from 'unique-names-generator'; import { getDictionaryOfNames } from './analyzer.js'; import { getBinaryTemplates, getTemplateBinary, pickWeightedTemplate, } from './utils/binaryTemplates.js'; /** * Generates fully randomized presets based on statistical distributions from the preset library. */ export function generateFullyRandomPresets(presetLibrary, paramModel, config) { if (presetLibrary.presets.length === 0) { throw new Error('No presets available for randomization.'); } const newPresetLibrary = { ...presetLibrary, userPresetsFolder: `${presetLibrary.userPresetsFolder}/RANDOM`, presets: [], }; const templates = config.binaryTemplate && config.synth ? getBinaryTemplates(config.synth) : []; for (let i = 0; i < (config.amount ?? 16); i++) { const basePreset = getRandomArrayItem(presetLibrary.presets); if (!basePreset) { console.error(chalk.red('Error: Could not get random preset from library')); continue; } const randomPreset = structuredClone(basePreset); // Randomize binary section if enabled (e.g. for Zebralette 3) if (config.binaryTemplate && templates.length > 0) { const template = pickWeightedTemplate(templates); if (template) { const binary = getTemplateBinary(template.path); if (binary) { randomPreset.binary = binary; } } } else if (config.binary) { const presetsWithBinary = presetLibrary.presets.filter((p) => p.binary && p.binary.length > 0); if (presetsWithBinary.length > 0) { const randomBinaryPreset = getRandomArrayItem(presetsWithBinary); if (randomBinaryPreset) { randomPreset.binary = randomBinaryPreset.binary; } } } if (config.stable) { const presetPerSectionMap = {}; for (const param of randomPreset.params) { const paramModelEntry = paramModel[param.id]; if (!paramModelEntry) { console.error(chalk.red(`Error: Unknown parameter ${param.id} in preset ${randomPreset.filePath}, missing in analyzed parameter model`)); continue; } if (!presetPerSectionMap[param.section]) { const randomSectionPreset = getRandomArrayItem(presetLibrary.presets); if (randomSectionPreset) { presetPerSectionMap[param.section] = randomSectionPreset; } else { continue; } } if (paramModelEntry.type === 'string' || paramModelEntry.distinctValues.length <= 2) { // Do not randomize string type values or values that only have 1 or 2 distinct values continue; } if (paramModelEntry.keepStable) { // Do not randomize if parameter is marked as to be kept stable continue; } const sectionPreset = presetPerSectionMap[param.section]; const randomNewParam = sectionPreset?.params.find((el) => el.id === param.id); if (randomNewParam) { param.value = randomNewParam.value; } else { // If the preset doesn't have the particular param, fall back to a fully random value const randomValue = getRandomValue(paramModelEntry, !config.creative); if (randomValue !== undefined) { param.value = randomValue; } } } } else { for (const param of randomPreset.params) { const paramModelEntry = paramModel[param.id]; if (!paramModelEntry) { console.error(chalk.red(`Error: Unknown parameter ${param.id} in preset ${randomPreset.filePath}, missing in analyzed parameter model`)); continue; } if (paramModelEntry.keepStable === 'always') { continue; } const randomValue = getRandomValue(paramModelEntry, !config.creative); if (randomValue !== undefined) { param.value = randomValue; } } } let randomName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, names], separator: ' ', style: 'capital', }); if (config.dictionary) { const names = getDictionaryOfNames(presetLibrary); randomName = `${getRandomArrayItem(names)} ${getRandomArrayItem(names)} ${getRandomArrayItem(names)}`; } randomPreset.filePath = `/Fully Random/RND ${randomName}.h2p`; if (config.category && config.category !== true) { randomPreset.filePath = `/Fully Random/${config.category.split(':').join(' ')}/RND ${randomName}.h2p`; } randomPreset.presetName = `RND ${randomName}`; randomPreset.meta = [ { key: 'Author', value: 'Random Generator', }, { key: 'Description', value: `Fully random preset. ${getPresetDescriptionSuffix(config)}`, }, ]; if (config.category) { randomPreset.meta.push({ key: 'Categories', value: [config.category], }); } newPresetLibrary.presets.push(randomPreset); } return newPresetLibrary; } /** * Randomize a given preset, with specific randomization ratio */ export function generateRandomizedPresets(presetLibrary, paramModel, config) { const newPresetLibrary = { ...presetLibrary, userPresetsFolder: `${presetLibrary.userPresetsFolder}/RANDOM`, presets: [], }; // Handle multiple presets if (Array.isArray(config.preset)) { const totalAmount = config.amount ?? 8; const amountPerPreset = Math.ceil(totalAmount / config.preset.length); for (const presetString of config.preset) { const basePreset = findBasePreset(presetLibrary, presetString); if (!basePreset) { throw new Error(`No preset with name ${presetString} found!`); } generateVariationsForPreset(basePreset, amountPerPreset, newPresetLibrary, paramModel, config, presetLibrary); } // Trim to exact amount if we generated too many if (newPresetLibrary.presets.length > totalAmount) { newPresetLibrary.presets = newPresetLibrary.presets.slice(0, totalAmount); } } else { // Handle single preset (existing behavior) const basePreset = findBasePreset(presetLibrary, config.preset); if (!basePreset) { throw new Error(`No preset with name ${config.preset ?? ''} found!`); } generateVariationsForPreset(basePreset, config.amount ?? 8, newPresetLibrary, paramModel, config, presetLibrary); } return newPresetLibrary; } function findBasePreset(presetLibrary, presetString) { // If no preset given or "?" is passed, choose a random preset if (presetString === '?' || !presetString) { return getRandomArrayItem(presetLibrary.presets); } else if (presetString.startsWith('?')) { return getRandomArrayItem(presetLibrary.presets.filter((el) => { const searchString = presetString.split('?').join('').toLowerCase(); return el?.filePath?.toLowerCase().includes(searchString); })); } else { return presetLibrary.presets.find((el) => { return el.filePath.includes(presetString); }); } } function generateVariationsForPreset(basePreset, amount, newPresetLibrary, paramModel, config, presetLibrary) { for (let i = 0; i < amount; i++) { const randomPreset = randomizePreset(basePreset, paramModel, config); let randomName = uniqueNamesGenerator({ dictionaries: [adjectives, colors], separator: ' ', style: 'capital', }); if (config.dictionary) { const names = getDictionaryOfNames(presetLibrary); randomName = `${getRandomArrayItem(names)} ${getRandomArrayItem(names)}`; } randomPreset.filePath = `/Randomized Preset/${randomPreset.presetName}/RND ${randomName} ${randomPreset.presetName}.h2p`; randomPreset.presetName = `RND ${randomName} ${randomPreset.presetName}`; const descriptionMeta = randomPreset.meta.find((el) => el.key === 'Description'); if (descriptionMeta && typeof descriptionMeta.value === 'string') { descriptionMeta.value += `. Randomized existing preset: ${randomPreset.presetName}. ${getPresetDescriptionSuffix(config)}`; } newPresetLibrary.presets.push(randomPreset); } } /** * Merge multiple presets together, with randomization amount */ export function generateMergedPresets(presetLibrary, paramModel, config) { const newPresetLibrary = { ...presetLibrary, userPresetsFolder: `${presetLibrary.userPresetsFolder}/RANDOM`, presets: [], }; let mergePresets = []; const mergeConfig = config.merge ?? []; const mergeArray = Array.isArray(mergeConfig) ? mergeConfig : [mergeConfig]; for (const presetTitle of mergeArray) { // If no preset given or "?" is passed, choose a random preset if (presetTitle === '?') { const randomPreset = getRandomArrayItem(presetLibrary.presets); if (randomPreset) { mergePresets.push(randomPreset); } } else if (presetTitle === '*') { mergePresets = presetLibrary.presets.filter((el) => { return el?.presetName; }); break; } else if (presetTitle.startsWith('*')) { mergePresets.push(...presetLibrary.presets.filter((el) => { const searchString = presetTitle.split('*').join('').toLowerCase(); return el?.filePath?.toLowerCase().includes(searchString); })); } else if (presetTitle.startsWith('?')) { const randomPreset = getRandomArrayItem(presetLibrary.presets.filter((el) => { const searchString = presetTitle.split('?').join('').toLowerCase(); return el?.filePath?.toLowerCase().includes(searchString); })); if (randomPreset) { mergePresets.push(randomPreset); } } else { const mergePreset = presetLibrary.presets.find((el) => { return el.filePath.includes(presetTitle); }); if (!mergePreset) { throw new Error(`No preset with name "${presetTitle}" found!`); } mergePresets.push(mergePreset); } } if (mergePresets.length < 2) { throw new Error('Merge presets only works when at least two presets have been chosen'); } // Validate preset compatibility validateMergeCompatibility(mergePresets); for (let i = 0; i < (config.amount ?? 8); i++) { const basePreset = getRandomArrayItem(mergePresets); if (!basePreset) { console.error(chalk.red('Error: Could not get random preset from merge list')); continue; } let newPreset = structuredClone(basePreset); // Create random ratios, that still add up to 1 total const mergeRatios = calculateRandomMergeRatios(mergePresets.length); for (const param of newPreset.params) { const paramModelEntry = paramModel[param.id]; if (!paramModelEntry) { console.error(chalk.red(`Error: Unknown parameter ${param.id} in preset ${newPreset.filePath}, missing in analyzed parameter model`)); continue; } if (config.stable) { if (paramModelEntry.type === 'string' || paramModelEntry.distinctValues.length <= 2) { // Do not randomize string type values or values that only have 1 or 2 distinct values continue; } if (paramModelEntry.keepStable) { // Do not randomize if parameter is marked as to be kept stable continue; } } if (paramModelEntry.keepStable === 'always') { continue; } const oldParamValue = param.value; let newParamValue = oldParamValue; if (param.type === 'string') { // Randomly pick a string enum value from one of the merge patches const pick = getRandomArrayItem(mergePresets); const findParam = pick?.params.find((el) => el.id === param.id); if (findParam?.value) { newParamValue = findParam.value; } } else { newParamValue = 0; for (const [i, preset] of mergePresets.entries()) { const findParam = preset.params.find((el) => el.id === param.id); const ratio = mergeRatios[i] ?? 0; if (findParam?.value) { newParamValue += findParam.value * ratio; } else { newParamValue += oldParamValue * ratio; } } if (param.type === 'integer') { newParamValue = Math.round(newParamValue); } else if (param.type === 'float') { newParamValue = Math.trunc(newParamValue * 100) / 100; } } if (typeof newParamValue === 'object') { console.error(chalk.red('Error: New param value is object, but should not be.'), newPreset.filePath, newParamValue); } param.value = newParamValue; } if (config.randomness) { newPreset = randomizePreset(newPreset, paramModel, config); } let randomName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, names], separator: ' ', style: 'capital', }); if (config.dictionary) { const names = getDictionaryOfNames(presetLibrary); randomName = `${getRandomArrayItem(names)} ${getRandomArrayItem(names)} ${getRandomArrayItem(names)}`; } newPreset.filePath = `/Merged Preset/RND ${randomName}.h2p`; newPreset.presetName = `RND ${randomName}`; newPreset.meta = [ { key: 'Author', value: 'Random Generator', }, { key: 'Description', value: `Merged preset, based on ${mergePresets .map((el) => el.presetName) .join(', ')}. ${getPresetDescriptionSuffix(config)}`, }, ]; if (config.category) { newPreset.meta.push({ key: 'Categories', value: [config.category], }); } newPresetLibrary.presets.push(newPreset); } return newPresetLibrary; } ////////////////////////////////////////// // HELPER FUNCTIONS // ////////////////////////////////////////// export function getRandomArrayItem(list) { if (list.length === 0) return undefined; return list[Math.floor(Math.random() * list.length)]; } /** * Validates that presets are compatible for merging by checking parameter structure * @param presets Array of presets to validate for merge compatibility * @throws Error if presets are incompatible */ export function validateMergeCompatibility(presets) { if (presets.length < 2) return; const basePreset = presets[0]; if (!basePreset) return; // Build a set of parameter IDs from the base preset const baseParamIds = new Set(basePreset.params.map((p) => p.id)); const baseParamCount = basePreset.params.length; // Check each preset for compatibility for (let i = 1; i < presets.length; i++) { const preset = presets[i]; if (!preset) continue; const presetParamIds = new Set(preset.params.map((p) => p.id)); const presetParamCount = preset.params.length; // Calculate overlap const intersection = new Set([...baseParamIds].filter((id) => presetParamIds.has(id))); const overlapPercent = (intersection.size / baseParamCount) * 100; // Warn if low overlap (< 80%) if (overlapPercent < 80) { console.warn(chalk.yellow(`Warning: Presets may be incompatible for merging.\n` + ` Base: "${basePreset.presetName}" (${baseParamCount} params)\n` + ` Comparing: "${preset.presetName}" (${presetParamCount} params)\n` + ` Overlap: ${intersection.size} parameters (${overlapPercent.toFixed(1)}%)\n` + ` This may produce unexpected results. Consider using presets from the same synth/version.`)); } // Error if very low overlap (< 50%) if (overlapPercent < 50) { throw new Error(`Presets are incompatible for merging (< 50% parameter overlap).\n` + ` "${basePreset.presetName}" vs "${preset.presetName}"\n` + ` These presets appear to be from different synths or versions.`); } } } /** * Get a random value from the parameter model, with optional frequency weighting * @param paramModelEntry The parameter model entry containing values and frequencies * @param useFrequencyWeighting If true, values that appear more often in presets are more likely to be selected * @returns A random value, or undefined if no values available */ export function getRandomValue(paramModelEntry, useFrequencyWeighting) { if (!paramModelEntry || paramModelEntry.values.length === 0) { return undefined; } // Creative mode: uniform distribution (all distinct values equally likely) if (!useFrequencyWeighting) { return getRandomArrayItem(paramModelEntry.values); } // Non-creative mode: frequency-weighted distribution // Values that appear more often in the library are more likely to be selected if (!paramModelEntry.frequencies) { // Fallback to uniform if frequencies not available return getRandomArrayItem(paramModelEntry.values); } // Build cumulative frequency array for weighted selection const values = paramModelEntry.values; const frequencies = paramModelEntry.frequencies; let totalFrequency = 0; const cumulativeFrequencies = []; for (const value of values) { const freq = frequencies[String(value)] ?? 1; totalFrequency += freq; cumulativeFrequencies.push(totalFrequency); } // Select based on weighted random const random = Math.random() * totalFrequency; for (let i = 0; i < cumulativeFrequencies.length; i++) { if (random < (cumulativeFrequencies[i] ?? 0)) { return values[i]; } } // Fallback (shouldn't happen) return values[values.length - 1]; } export function calculateRandomMergeRatios(amount) { const randomNumbers = []; let randomTotal = 0; for (let i = 0; i < amount; i++) { const rnd = Math.random(); randomNumbers.push(rnd); randomTotal += rnd; } const mergeRatios = randomNumbers.map((el) => { return el / randomTotal; }); return mergeRatios; } export function randomizePreset(basePreset, paramModel, config) { const randomness = config.randomness ?? 20; const randomRatio = Math.min(Math.max(0, randomness / 100), 100); const stableRatio = 1 - randomRatio; const randomPreset = structuredClone(basePreset); for (const param of randomPreset.params) { const paramModelEntry = paramModel[param.id]; if (!paramModelEntry) { console.error(chalk.red(`Error: Unknown parameter ${param.id} in preset ${randomPreset.filePath}, missing in analyzed parameter model`)); continue; } if (config.stable) { if (paramModelEntry.type === 'string' || paramModelEntry.distinctValues.length <= 2) { // Do not randomize string type values or values that only have 1 or 2 distinct values continue; } if (paramModelEntry.keepStable) { // Do not randomize if parameter is marked as to be kept stable continue; } } if (paramModelEntry.keepStable === 'always') { continue; } const randomParamValue = getRandomValue(paramModelEntry, !config.creative); if (randomParamValue === undefined) { continue; } const oldParamValue = param.value; if (randomParamValue !== oldParamValue) { let newValue = randomParamValue; if (param.type !== 'string') { newValue = oldParamValue * stableRatio + randomParamValue * randomRatio; if (param.type === 'integer') { newValue = Math.round(newValue); } else if (param.type === 'float') { newValue = Math.trunc(newValue * 100) / 100; } } else { // Randomly decide between the two values by randomness ratio if (Math.random() > randomRatio) { newValue = oldParamValue; // Revert to old value if random threshold is not met } } param.value = newValue; } } return randomPreset; } export function getPresetDescriptionSuffix(config) { const niceDate = new Date().toISOString().split('T')[0]; let suffix = `Generated by https://github.com/Fannon/u-he-preset-randomizer on ${niceDate} .`; if (config.category) { suffix += ` Based on presets of category: ${config.category}.`; } return suffix; } //# sourceMappingURL=randomizer.js.map