clean interface

This commit is contained in:
2025-10-12 11:32:36 +02:00
parent 6c11c5756a
commit fcb784d403
13 changed files with 536 additions and 118 deletions

View File

@ -5,17 +5,18 @@
import WelcomeModal from "./lib/components/WelcomeModal.svelte";
import ProcessorPopup from "./lib/components/ProcessorPopup.svelte";
import { engines } from "./lib/audio/engines/registry";
import type { SynthEngine } from "./lib/audio/engines/SynthEngine";
import type { SynthEngine, PitchLock } from "./lib/audio/engines/SynthEngine";
import type { EngineType } from "./lib/audio/engines/SynthEngine";
import { AudioService } from "./lib/audio/services/AudioService";
import { downloadWAV } from "./lib/audio/utils/WAVEncoder";
import { loadVolume, saveVolume, loadDuration, saveDuration } from "./lib/utils/settings";
import { loadVolume, saveVolume, loadDuration, saveDuration, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency } from "./lib/utils/settings";
import { generateRandomColor } from "./lib/utils/colors";
import { getRandomProcessor } from "./lib/audio/processors/registry";
import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor";
import { Sample } from "./lib/audio/engines/Sample";
import { Input } from "./lib/audio/engines/Input";
import { createKeyboardHandler } from "./lib/utils/keyboard";
import { parseFrequencyInput, formatFrequency } from "./lib/utils/pitch";
let currentEngineIndex = $state(0);
const engine = $derived(engines[currentEngineIndex]);
@ -34,12 +35,18 @@
let popupTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
let isRecording = $state(false);
let isDragOver = $state(false);
let pitchLockEnabled = $state(loadPitchLockEnabled());
let pitchLockFrequency = $state(loadPitchLockFrequency());
let pitchLockInput = $state(formatFrequency(loadPitchLockFrequency()));
let pitchLockInputValid = $state(true);
const showDuration = $derived(engineType !== 'sample');
const showRandomButton = $derived(engineType === 'generative');
const showRecordButton = $derived(engineType === 'input');
const showFileDropZone = $derived(engineType === 'sample' && !currentBuffer);
const showMutateButton = $derived(engineType === 'generative' && !isProcessed && currentBuffer);
const showPitchLock = $derived(engineType === 'generative');
const pitchLock = $derived<PitchLock>({ enabled: pitchLockEnabled, frequency: pitchLockFrequency });
$effect(() => {
audioService.setVolume(volume);
@ -50,6 +57,14 @@
saveDuration(duration);
});
$effect(() => {
savePitchLockEnabled(pitchLockEnabled);
});
$effect(() => {
savePitchLockFrequency(pitchLockFrequency);
});
onMount(() => {
audioService.setPlaybackUpdateCallback((position) => {
playbackPosition = position;
@ -78,7 +93,7 @@
});
function generateRandom() {
currentParams = engine.randomParams();
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
regenerateBuffer();
@ -89,11 +104,28 @@
generateRandom();
return;
}
currentParams = engine.mutateParams(currentParams);
currentParams = engine.mutateParams(currentParams, 0.15, pitchLock);
waveformColor = generateRandomColor();
regenerateBuffer();
}
function handlePitchLockInput(event: Event) {
const input = event.target as HTMLInputElement;
pitchLockInput = input.value;
const parsed = parseFrequencyInput(pitchLockInput);
if (parsed !== null) {
pitchLockFrequency = parsed;
pitchLockInputValid = true;
} else {
pitchLockInputValid = false;
}
}
function togglePitchLock() {
pitchLockEnabled = !pitchLockEnabled;
}
function regenerateBuffer() {
if (!currentParams) return;
@ -154,7 +186,7 @@
try {
await engine.loadFile(file);
currentParams = engine.randomParams();
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
regenerateBuffer();
@ -170,7 +202,7 @@
try {
isRecording = true;
await engine.record(duration);
currentParams = engine.randomParams();
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
regenerateBuffer();
@ -247,9 +279,44 @@
{/each}
</div>
<div class="controls-group">
{#if showPitchLock}
<div class="control-item pitch-lock-control">
<div class="control-header">
<label for="pitch">Pitch</label>
<label class="custom-checkbox" title="Lock pitch across random/mutate">
<input
type="checkbox"
bind:checked={pitchLockEnabled}
/>
<span class="checkbox-box" class:checked={pitchLockEnabled}>
{#if pitchLockEnabled}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 5L4 8L9 2" stroke="currentColor" stroke-width="2" stroke-linecap="square"/>
</svg>
{/if}
</span>
<span class="checkbox-text">Lock</span>
</label>
</div>
<input
id="pitch"
type="text"
class="pitch-input"
class:locked={pitchLockEnabled}
class:invalid={!pitchLockInputValid}
value={pitchLockInput}
oninput={handlePitchLockInput}
placeholder="440 or A4"
title="Enter frequency (Hz) or note (e.g., A4, C#3)"
/>
</div>
{/if}
{#if showDuration}
<div class="slider-control duration-slider">
<label for="duration">Duration: {duration.toFixed(2)}s</label>
<div class="control-item">
<div class="control-header">
<label for="duration">Duration</label>
<span class="control-value-display">{duration.toFixed(2)}s</span>
</div>
<input
id="duration"
type="range"
@ -260,8 +327,11 @@
/>
</div>
{/if}
<div class="slider-control">
<label for="volume">Volume</label>
<div class="control-item">
<div class="control-header">
<label for="volume">Volume</label>
<span class="control-value-display">{Math.round(volume * 100)}%</span>
</div>
<input
id="volume"
type="range"
@ -431,26 +501,166 @@
.controls-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.75rem;
width: 100%;
}
.slider-control {
.control-item {
display: flex;
flex-direction: column;
gap: 0.35rem;
background-color: #0f0f0f;
padding: 0.5rem 0.65rem;
border: 1px solid #2a2a2a;
transition: border-color 0.2s;
}
.control-item:hover {
border-color: #3a3a3a;
}
.control-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.slider-control label {
font-size: 0.85rem;
.control-header label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #999;
font-weight: 500;
white-space: nowrap;
min-width: fit-content;
}
.slider-control input[type="range"] {
flex: 1;
.control-value-display {
font-size: 0.8rem;
color: #fff;
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 3.5rem;
text-align: right;
}
.control-item input[type="range"] {
width: 100%;
margin: 0;
}
.custom-checkbox {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
user-select: none;
}
.custom-checkbox input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1px solid #3a3a3a;
background-color: #1a1a1a;
transition: all 0.2s;
color: #0a0a0a;
}
.checkbox-box.checked {
background-color: #646cff;
border-color: #646cff;
color: #fff;
}
.custom-checkbox:hover .checkbox-box {
border-color: #4a4a4a;
}
.custom-checkbox:hover .checkbox-box.checked {
background-color: #535bf2;
border-color: #535bf2;
}
.checkbox-text {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #999;
font-weight: 500;
}
.custom-checkbox:hover .checkbox-text {
color: #aaa;
}
.pitch-input {
width: 100%;
min-width: 0;
padding: 0.35rem 0.5rem;
background-color: #1a1a1a;
border: 1px solid #3a3a3a;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
font-variant-numeric: tabular-nums;
box-sizing: border-box;
}
.pitch-input:focus {
outline: none;
border-color: #646cff;
background-color: #0a0a0a;
}
.pitch-input.locked {
border-color: #646cff;
background-color: #0a0a0a;
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.2);
}
.pitch-input.locked:focus {
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.4);
}
.pitch-input.invalid {
border-color: #ff4444;
box-shadow: 0 0 0 1px rgba(255, 68, 68, 0.2);
}
.pitch-input::placeholder {
color: #555;
font-weight: 400;
}
@media (min-width: 640px) {
.controls-group {
flex-direction: row;
flex-wrap: wrap;
}
.control-item {
min-width: 140px;
flex: 1;
}
.pitch-lock-control {
min-width: 160px;
}
.control-item input[type="range"] {
min-width: 80px;
}
}
@media (min-width: 768px) {
@ -484,17 +694,51 @@
}
.controls-group {
flex-direction: row;
width: auto;
flex-shrink: 0;
gap: 0.5rem;
flex-wrap: nowrap;
}
.slider-control input[type="range"] {
width: 80px;
.control-item {
min-width: 120px;
padding: 0.45rem 0.6rem;
}
.duration-slider input[type="range"] {
width: 150px;
.pitch-lock-control {
min-width: 140px;
}
.control-header label {
font-size: 0.7rem;
}
.control-value-display {
font-size: 0.75rem;
min-width: 3rem;
}
.checkbox-text {
font-size: 0.65rem;
}
.checkbox-box {
width: 13px;
height: 13px;
}
.checkbox-box svg {
width: 9px;
height: 9px;
}
.pitch-input {
font-size: 0.8rem;
padding: 0.3rem 0.45rem;
}
.control-item input[type="range"] {
min-width: 70px;
}
}
@ -503,18 +747,59 @@
max-width: 65%;
}
.slider-control input[type="range"] {
width: 100px;
.control-item {
min-width: 140px;
padding: 0.5rem 0.65rem;
}
.duration-slider input[type="range"] {
width: 200px;
.pitch-lock-control {
min-width: 160px;
}
.control-header label {
font-size: 0.75rem;
}
.control-value-display {
font-size: 0.8rem;
min-width: 3.5rem;
}
.checkbox-text {
font-size: 0.7rem;
}
.checkbox-box {
width: 14px;
height: 14px;
}
.checkbox-box svg {
width: 10px;
height: 10px;
}
.pitch-input {
font-size: 0.85rem;
padding: 0.35rem 0.5rem;
}
.control-item input[type="range"] {
min-width: 90px;
}
}
@media (min-width: 1280px) {
.duration-slider input[type="range"] {
width: 300px;
.control-item {
min-width: 160px;
}
.pitch-lock-control {
min-width: 180px;
}
.control-item input[type="range"] {
min-width: 110px;
}
}

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
enum EnvCurve {
Linear,
@ -380,9 +380,11 @@ export class AdditiveEngine implements SynthEngine<AdditiveParams> {
}
}
randomParams(): AdditiveParams {
randomParams(pitchLock?: PitchLock): AdditiveParams {
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
const baseFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
const baseFreq = pitchLock?.enabled
? pitchLock.frequency
: this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
const harmonicSeriesType = this.randomInt(0, 3) as HarmonicSeriesType;
const distributionStrategy = this.randomInt(0, 9) as DistributionStrategy;
@ -514,7 +516,7 @@ export class AdditiveEngine implements SynthEngine<AdditiveParams> {
};
}
mutateParams(params: AdditiveParams, mutationAmount: number = 0.15): AdditiveParams {
mutateParams(params: AdditiveParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): AdditiveParams {
const newHarmonicSeriesType = Math.random() < 0.08 ? this.randomInt(0, 3) as HarmonicSeriesType : params.harmonicSeriesType;
const newDistributionStrategy = Math.random() < 0.08 ? this.randomInt(0, 9) as DistributionStrategy : params.distributionStrategy;
const ratios = this.generateRatiosForStrategy(newDistributionStrategy);
@ -530,8 +532,10 @@ export class AdditiveEngine implements SynthEngine<AdditiveParams> {
}
}
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq: params.baseFreq,
baseFreq,
partials: newPartials,
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
brightness: this.mutateValue(params.brightness, mutationAmount, 0, 1),

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
interface BenjolinParams {
// Core oscillators
@ -808,7 +808,7 @@ export class Benjolin implements SynthEngine<BenjolinParams> {
}
}
randomParams(): BenjolinParams {
randomParams(pitchLock?: PitchLock): BenjolinParams {
// Choose a random preset configuration
const preset = Math.floor(Math.random() * PRESET_COUNT);
@ -818,7 +818,9 @@ export class Benjolin implements SynthEngine<BenjolinParams> {
// Generate full parameter set with preset biases
const params: BenjolinParams = {
// Core oscillators
osc1Freq: presetParams.osc1Freq ?? (20 + Math.random() * 800),
osc1Freq: pitchLock?.enabled
? pitchLock.frequency
: presetParams.osc1Freq ?? (20 + Math.random() * 800),
osc2Freq: presetParams.osc2Freq ?? (30 + Math.random() * 1200),
osc1Wave: presetParams.osc1Wave ?? Math.random(),
osc2Wave: presetParams.osc2Wave ?? Math.random(),
@ -876,17 +878,17 @@ export class Benjolin implements SynthEngine<BenjolinParams> {
return params;
}
mutateParams(params: BenjolinParams): BenjolinParams {
mutateParams(params: BenjolinParams, mutationAmount?: number, pitchLock?: PitchLock): BenjolinParams {
const mutated = { ...params };
// Determine mutation strength based on current "stability"
const stability = (params.crossMod1to2 + params.crossMod2to1) / 2 +
params.runglerChaos + params.evolutionDepth;
const mutationAmount = stability > 1.5 ? 0.05 : stability < 0.5 ? 0.2 : 0.1;
const mutAmount = mutationAmount ?? (stability > 1.5 ? 0.05 : stability < 0.5 ? 0.2 : 0.1);
// Helper for correlated mutations
const mutateValue = (value: number, min: number, max: number, correlation = 1): number => {
const delta = (Math.random() - 0.5) * mutationAmount * (max - min) * correlation;
const delta = (Math.random() - 0.5) * mutAmount * (max - min) * correlation;
return Math.max(min, Math.min(max, value + delta));
};
@ -895,9 +897,15 @@ export class Benjolin implements SynthEngine<BenjolinParams> {
if (strategy < 0.3) {
// Mutate frequency relationships
const freqRatio = mutated.osc2Freq / mutated.osc1Freq;
mutated.osc1Freq = mutateValue(mutated.osc1Freq, 20, 2000);
mutated.osc2Freq = mutated.osc1Freq * mutateValue(freqRatio, 0.5, 4);
if (pitchLock?.enabled) {
mutated.osc1Freq = pitchLock.frequency;
const freqRatio = mutated.osc2Freq / params.osc1Freq;
mutated.osc2Freq = mutated.osc1Freq * mutateValue(freqRatio, 0.5, 4);
} else {
const freqRatio = mutated.osc2Freq / mutated.osc1Freq;
mutated.osc1Freq = mutateValue(mutated.osc1Freq, 20, 2000);
mutated.osc2Freq = mutated.osc1Freq * mutateValue(freqRatio, 0.5, 4);
}
// Correlate cross-mod amounts
const crossModDelta = (Math.random() - 0.5) * mutationAmount;

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
enum OscillatorWaveform {
Sine,
@ -427,22 +427,33 @@ export class DubSiren implements SynthEngine<DubSirenParams> {
return [output, v1Next, v2Next];
}
randomParams(): DubSirenParams {
const freqPairs = [
[100, 1200],
[200, 800],
[300, 2000],
[50, 400],
[500, 3000],
[150, 600],
];
randomParams(pitchLock?: PitchLock): DubSirenParams {
let startFreq: number;
let endFreq: number;
const [startFreq, endFreq] = this.randomChoice(freqPairs);
const shouldReverse = Math.random() < 0.3;
if (pitchLock?.enabled) {
// When pitch locked, sweep around the locked frequency
startFreq = pitchLock.frequency;
endFreq = pitchLock.frequency * (Math.random() < 0.5 ? 0.5 : 2);
} else {
const freqPairs = [
[100, 1200],
[200, 800],
[300, 2000],
[50, 400],
[500, 3000],
[150, 600],
];
const [freq1, freq2] = this.randomChoice(freqPairs);
const shouldReverse = Math.random() < 0.3;
startFreq = shouldReverse ? freq2 : freq1;
endFreq = shouldReverse ? freq1 : freq2;
}
return {
startFreq: shouldReverse ? endFreq : startFreq,
endFreq: shouldReverse ? startFreq : endFreq,
startFreq,
endFreq,
sweepCurve: this.randomInt(0, 4) as SweepCurve,
waveform: this.randomInt(0, 4) as OscillatorWaveform,
pulseWidth: this.randomRange(0.1, 0.9),
@ -464,10 +475,27 @@ export class DubSiren implements SynthEngine<DubSirenParams> {
};
}
mutateParams(params: DubSirenParams, mutationAmount: number = 0.15): DubSirenParams {
mutateParams(params: DubSirenParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): DubSirenParams {
let startFreq: number;
let endFreq: number;
if (pitchLock?.enabled) {
// When pitch locked, keep one frequency at the locked value
if (Math.random() < 0.5) {
startFreq = pitchLock.frequency;
endFreq = this.mutateValue(params.endFreq, mutationAmount, 20, 5000);
} else {
startFreq = this.mutateValue(params.startFreq, mutationAmount, 20, 5000);
endFreq = pitchLock.frequency;
}
} else {
startFreq = this.mutateValue(params.startFreq, mutationAmount, 20, 5000);
endFreq = this.mutateValue(params.endFreq, mutationAmount, 20, 5000);
}
return {
startFreq: this.mutateValue(params.startFreq, mutationAmount, 20, 5000),
endFreq: this.mutateValue(params.endFreq, mutationAmount, 20, 5000),
startFreq,
endFreq,
sweepCurve: Math.random() < 0.1 ? this.randomInt(0, 4) as SweepCurve : params.sweepCurve,
waveform: Math.random() < 0.1 ? this.randomInt(0, 4) as OscillatorWaveform : params.waveform,
pulseWidth: this.mutateValue(params.pulseWidth, mutationAmount, 0.05, 0.95),

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
enum EnvCurve {
Linear,
@ -420,12 +420,14 @@ export class FourOpFM implements SynthEngine<FourOpFMParams> {
}
}
randomParams(): FourOpFMParams {
randomParams(pitchLock?: PitchLock): FourOpFMParams {
const algorithm = this.randomInt(0, 5) as Algorithm;
// More musical frequency ratios including inharmonic ones
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
const baseFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.9, 1.1);
const baseFreq = pitchLock?.enabled
? pitchLock.frequency
: this.randomChoice(baseFreqChoices) * this.randomRange(0.9, 1.1);
return {
baseFreq,
@ -481,9 +483,11 @@ export class FourOpFM implements SynthEngine<FourOpFMParams> {
};
}
mutateParams(params: FourOpFMParams, mutationAmount: number = 0.15): FourOpFMParams {
mutateParams(params: FourOpFMParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): FourOpFMParams {
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq: params.baseFreq,
baseFreq,
algorithm: Math.random() < 0.08 ? this.randomInt(0, 5) as Algorithm : params.algorithm,
operators: params.operators.map((op, i) =>
this.mutateOperator(op, mutationAmount, i === 3, params.algorithm)

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
type HarmonicMode =
| 'single' // Just fundamental
@ -323,7 +323,7 @@ export class KarplusStrong implements SynthEngine<KarplusStrongParams> {
}
}
randomParams(): KarplusStrongParams {
randomParams(pitchLock?: PitchLock): KarplusStrongParams {
// Musical frequencies (notes from E2 to E5)
const frequencies = [
82.41, 87.31, 92.50, 98.00, 103.83, 110.00, 116.54, 123.47, 130.81, 138.59,
@ -333,6 +333,10 @@ export class KarplusStrong implements SynthEngine<KarplusStrongParams> {
830.61, 880.00, 932.33, 987.77, 1046.50, 1108.73, 1174.66, 1244.51, 1318.51
];
const frequency = pitchLock?.enabled
? pitchLock.frequency
: frequencies[Math.floor(Math.random() * frequencies.length)];
// Randomly choose harmonic mode
// Weighted selection: favor more consonant intervals
const modes: HarmonicMode[] = [
@ -362,7 +366,7 @@ export class KarplusStrong implements SynthEngine<KarplusStrongParams> {
];
return {
frequency: frequencies[Math.floor(Math.random() * frequencies.length)],
frequency,
damping: 0.7 + Math.random() * 0.29, // 0.7 to 0.99
brightness: Math.random(), // 0 to 1
decayCharacter: (Math.random() * 2 - 1) * 0.8, // -0.8 to 0.8 (mostly natural darkening)
@ -378,15 +382,17 @@ export class KarplusStrong implements SynthEngine<KarplusStrongParams> {
};
}
mutateParams(params: KarplusStrongParams, mutationAmount: number = 0.15): KarplusStrongParams {
mutateParams(params: KarplusStrongParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): KarplusStrongParams {
const mutate = (value: number, range: number, min: number, max: number) => {
const delta = (Math.random() * 2 - 1) * range * mutationAmount;
return Math.max(min, Math.min(max, value + delta));
};
// Occasionally jump to harmonic/subharmonic
let newFreq = params.frequency;
if (Math.random() < 0.15) {
// Occasionally jump to harmonic/subharmonic (unless pitch locked)
let newFreq: number;
if (pitchLock?.enabled) {
newFreq = pitchLock.frequency;
} else if (Math.random() < 0.15) {
const multipliers = [0.5, 2, 1.5, 3];
newFreq = params.frequency * multipliers[Math.floor(Math.random() * multipliers.length)];
newFreq = Math.max(50, Math.min(2000, newFreq));

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
enum LFOWaveform {
Sine,
@ -386,9 +386,11 @@ export class Ring implements SynthEngine<RingParams> {
}
}
randomParams(): RingParams {
randomParams(pitchLock?: PitchLock): RingParams {
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
const carrierFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
const carrierFreq = pitchLock?.enabled
? pitchLock.frequency
: this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
const ratioChoices = [0.5, 0.707, 1, 1.414, 1.732, 2, 2.236, 3, 3.732, 5, 7, 11, 13, 17];
const modulatorRatio = this.randomChoice(ratioChoices);
@ -434,27 +436,29 @@ export class Ring implements SynthEngine<RingParams> {
};
}
mutateParams(params: RingParams, mutationAmount: number = 0.15): RingParams {
mutateParams(params: RingParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): RingParams {
const ratioChoices = [0.5, 0.707, 1, 1.414, 1.732, 2, 2.236, 3, 3.732, 5, 7, 11, 13, 17];
let modulatorFreq = params.modulatorFreq;
let secondModulatorFreq = params.secondModulatorFreq;
const carrierFreq = pitchLock?.enabled ? pitchLock.frequency : params.carrierFreq;
if (Math.random() < 0.1) {
const newRatio = this.randomChoice(ratioChoices);
modulatorFreq = params.carrierFreq * newRatio * this.randomRange(0.98, 1.02);
modulatorFreq = carrierFreq * newRatio * this.randomRange(0.98, 1.02);
} else {
modulatorFreq = this.mutateValue(params.modulatorFreq, mutationAmount, 20, 2000);
}
if (Math.random() < 0.1) {
const newRatio = this.randomChoice(ratioChoices);
secondModulatorFreq = params.carrierFreq * newRatio * this.randomRange(0.97, 1.03);
secondModulatorFreq = carrierFreq * newRatio * this.randomRange(0.97, 1.03);
} else {
secondModulatorFreq = this.mutateValue(params.secondModulatorFreq, mutationAmount, 20, 2000);
}
return {
carrierFreq: params.carrierFreq,
carrierFreq,
modulatorFreq,
secondModulatorFreq,
carrierLevel: this.mutateValue(params.carrierLevel, mutationAmount, 0.3, 1.0),

View File

@ -2,14 +2,20 @@
// The duration parameter should be used to scale time-based parameters (envelopes, LFOs, etc.)
// Time-based parameters should be stored as ratios (0-1) and scaled by duration during generation
// Engines must generate stereo output: [leftChannel, rightChannel]
// When pitch lock is provided, engines must use the locked frequency and preserve it across randomization/mutation
export type EngineType = 'generative' | 'sample' | 'input';
export interface PitchLock {
enabled: boolean;
frequency: number; // Frequency in Hz
}
export interface SynthEngine<T = any> {
getName(): string;
getDescription(): string;
getType(): EngineType;
generate(params: T, sampleRate: number, duration: number): [Float32Array, Float32Array];
randomParams(): T;
mutateParams(params: T, mutationAmount?: number): T;
generate(params: T, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array];
randomParams(pitchLock?: PitchLock): T;
mutateParams(params: T, mutationAmount?: number, pitchLock?: PitchLock): T;
}

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
enum EnvCurve {
Linear,
@ -356,11 +356,16 @@ export class TwoOpFM implements SynthEngine<TwoOpFMParams> {
}
}
randomParams(): TwoOpFMParams {
randomParams(pitchLock?: PitchLock): TwoOpFMParams {
const algorithm = this.randomInt(0, 2) as Algorithm;
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
const baseFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
let baseFreq: number;
if (pitchLock?.enabled) {
baseFreq = pitchLock.frequency;
} else {
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
baseFreq = this.randomChoice(baseFreqChoices) * this.randomRange(0.95, 1.05);
}
return {
baseFreq,
@ -433,9 +438,11 @@ export class TwoOpFM implements SynthEngine<TwoOpFMParams> {
};
}
mutateParams(params: TwoOpFMParams, mutationAmount: number = 0.15): TwoOpFMParams {
mutateParams(params: TwoOpFMParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): TwoOpFMParams {
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq: params.baseFreq,
baseFreq,
algorithm: Math.random() < 0.1 ? this.randomInt(0, 2) as Algorithm : params.algorithm,
operators: params.operators.map((op, i) =>
this.mutateOperator(op, mutationAmount, i === 1, params.algorithm)

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
interface WavetableParams {
bankIndex: number;
@ -211,12 +211,14 @@ export class WavetableEngine implements SynthEngine<WavetableParams> {
return [left, right];
}
randomParams(): WavetableParams {
randomParams(pitchLock?: PitchLock): WavetableParams {
const freqs = [110, 146.8, 220, 293.7, 440];
return {
bankIndex: Math.random(),
position: 0.2 + Math.random() * 0.6,
baseFreq: freqs[Math.floor(Math.random() * freqs.length)],
baseFreq: pitchLock?.enabled
? pitchLock.frequency
: freqs[Math.floor(Math.random() * freqs.length)],
filterCutoff: 0.5 + Math.random() * 0.4,
filterResonance: Math.random() * 0.5,
attack: 0.001 + Math.random() * 0.05,
@ -226,15 +228,17 @@ export class WavetableEngine implements SynthEngine<WavetableParams> {
};
}
mutateParams(params: WavetableParams): WavetableParams {
mutateParams(params: WavetableParams, mutationAmount?: number, pitchLock?: PitchLock): WavetableParams {
const mutate = (v: number, amount: number = 0.1) => {
return Math.max(0, Math.min(1, v + (Math.random() - 0.5) * amount));
};
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
bankIndex: Math.random() < 0.2 ? Math.random() : params.bankIndex,
position: mutate(params.position, 0.2),
baseFreq: params.baseFreq,
baseFreq,
filterCutoff: mutate(params.filterCutoff, 0.2),
filterResonance: mutate(params.filterResonance, 0.15),
attack: mutate(params.attack, 0.1),

View File

@ -1,4 +1,4 @@
import type { SynthEngine } from './SynthEngine';
import type { SynthEngine, PitchLock } from './SynthEngine';
// @ts-ignore
import { ZZFX } from 'zzfx';
@ -125,15 +125,18 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return [leftBuffer, rightBuffer];
}
randomParams(): ZzfxParams {
randomParams(pitchLock?: PitchLock): ZzfxParams {
const preset = Math.floor(Math.random() * 20);
const getFrequency = (min: number, max: number) =>
pitchLock?.enabled ? pitchLock.frequency : this.randomRange(min, max);
switch (preset) {
case 0: // Clean tones
return {
volume: 1,
randomness: this.randomRange(0, 0.05),
frequency: this.randomRange(200, 800),
frequency: getFrequency(200, 800),
attack: this.randomRange(0, 0.02),
sustain: this.randomRange(0.2, 0.5),
release: this.randomRange(0.1, 0.3),
@ -157,7 +160,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(400, 1200),
frequency: getFrequency(400, 1200),
attack: this.randomRange(0, 0.01),
sustain: this.randomRange(0.15, 0.35),
release: this.randomRange(0.05, 0.2),
@ -181,7 +184,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(300, 1000),
frequency: getFrequency(300, 1000),
attack: 0,
sustain: this.randomRange(0.2, 0.4),
release: this.randomRange(0.05, 0.15),
@ -205,7 +208,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0, 0.1),
frequency: this.randomRange(150, 600),
frequency: getFrequency(150, 600),
attack: this.randomRange(0, 0.03),
sustain: this.randomRange(0.15, 0.35),
release: this.randomRange(0.05, 0.2),
@ -229,7 +232,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(200, 1500),
frequency: getFrequency(200, 1500),
attack: 0,
sustain: this.randomRange(0.1, 0.25),
release: this.randomRange(0.02, 0.1),
@ -253,7 +256,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(40, 200),
frequency: getFrequency(40, 200),
attack: this.randomRange(0, 0.02),
sustain: this.randomRange(0.25, 0.5),
release: this.randomRange(0.1, 0.25),
@ -277,7 +280,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(300, 1200),
frequency: getFrequency(300, 1200),
attack: this.randomRange(0, 0.03),
sustain: this.randomRange(0.2, 0.4),
release: this.randomRange(0.08, 0.2),
@ -301,7 +304,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(800, 2000),
frequency: getFrequency(800, 2000),
attack: 0,
sustain: this.randomRange(0.05, 0.15),
release: this.randomRange(0.02, 0.08),
@ -325,7 +328,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0.1, 0.15),
frequency: this.randomRange(50, 150),
frequency: getFrequency(50, 150),
attack: 0,
sustain: this.randomRange(0.15, 0.3),
release: this.randomRange(0.15, 0.35),
@ -349,7 +352,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(500, 1000),
frequency: getFrequency(500, 1000),
attack: 0,
sustain: this.randomRange(0.05, 0.1),
release: this.randomRange(0.1, 0.2),
@ -373,7 +376,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(600, 1400),
frequency: getFrequency(600, 1400),
attack: 0,
sustain: this.randomRange(0.03, 0.08),
release: this.randomRange(0.02, 0.05),
@ -397,7 +400,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(200, 500),
frequency: getFrequency(200, 500),
attack: 0,
sustain: this.randomRange(0.2, 0.35),
release: this.randomRange(0.1, 0.2),
@ -421,7 +424,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(300, 600),
frequency: getFrequency(300, 600),
attack: 0,
sustain: this.randomRange(0.08, 0.15),
release: this.randomRange(0.05, 0.12),
@ -445,7 +448,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(400, 800),
frequency: getFrequency(400, 800),
attack: 0,
sustain: this.randomRange(0.3, 0.5),
release: this.randomRange(0.05, 0.1),
@ -469,7 +472,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(60, 150),
frequency: getFrequency(60, 150),
attack: this.randomRange(0, 0.02),
sustain: this.randomRange(0.3, 0.5),
release: this.randomRange(0.1, 0.2),
@ -493,7 +496,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(400, 800),
frequency: getFrequency(400, 800),
attack: 0,
sustain: this.randomRange(0.25, 0.4),
release: this.randomRange(0.15, 0.3),
@ -517,7 +520,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0.05, 0.1),
frequency: this.randomRange(200, 400),
frequency: getFrequency(200, 400),
attack: 0,
sustain: this.randomRange(0.05, 0.12),
release: this.randomRange(0.05, 0.15),
@ -541,7 +544,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: 0,
frequency: this.randomRange(400, 1000),
frequency: getFrequency(400, 1000),
attack: 0,
sustain: this.randomRange(0.2, 0.35),
release: this.randomRange(0.05, 0.12),
@ -565,7 +568,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0.08, 0.15),
frequency: this.randomRange(80, 250),
frequency: getFrequency(80, 250),
attack: 0,
sustain: this.randomRange(0.03, 0.08),
release: this.randomRange(0.05, 0.15),
@ -589,7 +592,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0, 0.05),
frequency: this.randomRange(1000, 2000),
frequency: getFrequency(1000, 2000),
attack: 0,
sustain: this.randomRange(0.15, 0.3),
release: this.randomRange(0.15, 0.3),
@ -613,7 +616,7 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
return {
volume: 1,
randomness: this.randomRange(0, 0.05),
frequency: this.randomRange(200, 1200),
frequency: getFrequency(200, 1200),
attack: this.randomRange(0, 0.03),
sustain: this.randomRange(0.15, 0.4),
release: this.randomRange(0.05, 0.2),
@ -635,11 +638,15 @@ export class ZzfxEngine implements SynthEngine<ZzfxParams> {
}
}
mutateParams(params: ZzfxParams, mutationAmount: number = 0.15): ZzfxParams {
mutateParams(params: ZzfxParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): ZzfxParams {
const frequency = pitchLock?.enabled
? pitchLock.frequency
: this.mutateValue(params.frequency, mutationAmount * 2, 40, 1500);
return {
volume: 1,
randomness: this.mutateValue(params.randomness, mutationAmount, 0, 0.1),
frequency: this.mutateValue(params.frequency, mutationAmount * 2, 40, 1500),
frequency,
attack: this.mutateValue(params.attack, mutationAmount, 0, 0.03),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 0.5),
release: this.mutateValue(params.release, mutationAmount, 0.02, 0.3),

33
src/lib/utils/pitch.ts Normal file
View File

@ -0,0 +1,33 @@
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const A4_FREQUENCY = 440;
const A4_MIDI_NOTE = 69;
export function noteToFrequency(noteName: string): number | null {
const match = noteName.match(/^([A-G]#?)(-?\d+)$/i);
if (!match) return null;
const [, note, octave] = match;
const noteIndex = NOTE_NAMES.indexOf(note.toUpperCase());
if (noteIndex === -1) return null;
const octaveNum = parseInt(octave, 10);
const midiNote = (octaveNum + 1) * 12 + noteIndex;
const semitonesDiff = midiNote - A4_MIDI_NOTE;
return A4_FREQUENCY * Math.pow(2, semitonesDiff / 12);
}
export function parseFrequencyInput(input: string): number | null {
const trimmed = input.trim();
const asNumber = parseFloat(trimmed);
if (!isNaN(asNumber) && asNumber > 0 && asNumber < 20000) {
return asNumber;
}
return noteToFrequency(trimmed);
}
export function formatFrequency(frequency: number): string {
return frequency.toFixed(2);
}

View File

@ -1,9 +1,13 @@
const DEFAULT_VOLUME = 0.7;
const DEFAULT_DURATION = 1.0;
const DEFAULT_PITCH_LOCK_ENABLED = false;
const DEFAULT_PITCH_LOCK_FREQUENCY = 440;
const STORAGE_KEYS = {
VOLUME: 'volume',
DURATION: 'duration',
PITCH_LOCK_ENABLED: 'pitchLockEnabled',
PITCH_LOCK_FREQUENCY: 'pitchLockFrequency',
} as const;
export function loadVolume(): number {
@ -23,3 +27,21 @@ export function loadDuration(): number {
export function saveDuration(duration: number): void {
localStorage.setItem(STORAGE_KEYS.DURATION, duration.toString());
}
export function loadPitchLockEnabled(): boolean {
const stored = localStorage.getItem(STORAGE_KEYS.PITCH_LOCK_ENABLED);
return stored ? stored === 'true' : DEFAULT_PITCH_LOCK_ENABLED;
}
export function savePitchLockEnabled(enabled: boolean): void {
localStorage.setItem(STORAGE_KEYS.PITCH_LOCK_ENABLED, enabled.toString());
}
export function loadPitchLockFrequency(): number {
const stored = localStorage.getItem(STORAGE_KEYS.PITCH_LOCK_FREQUENCY);
return stored ? parseFloat(stored) : DEFAULT_PITCH_LOCK_FREQUENCY;
}
export function savePitchLockFrequency(frequency: number): void {
localStorage.setItem(STORAGE_KEYS.PITCH_LOCK_FREQUENCY, frequency.toString());
}