diff --git a/src/App.svelte b/src/App.svelte index 5a6eef2..d3d9d27 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 | 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({ 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}
+ {#if showPitchLock} +
+
+ + +
+ +
+ {/if} {#if showDuration} -
- +
+
+ + {duration.toFixed(2)}s +
{/if} -
- +
+
+ + {Math.round(volume * 100)}% +
{ } } - 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 { }; } - 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 { } } + 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), diff --git a/src/lib/audio/engines/Benjolin.ts b/src/lib/audio/engines/Benjolin.ts index 2f84715..f8e388d 100644 --- a/src/lib/audio/engines/Benjolin.ts +++ b/src/lib/audio/engines/Benjolin.ts @@ -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 { } } - 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 { // 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 { 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 { 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; diff --git a/src/lib/audio/engines/DubSiren.ts b/src/lib/audio/engines/DubSiren.ts index a554165..e756158 100644 --- a/src/lib/audio/engines/DubSiren.ts +++ b/src/lib/audio/engines/DubSiren.ts @@ -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 { 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 { }; } - 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), diff --git a/src/lib/audio/engines/FourOpFM.ts b/src/lib/audio/engines/FourOpFM.ts index 7579df2..ba6f8d8 100644 --- a/src/lib/audio/engines/FourOpFM.ts +++ b/src/lib/audio/engines/FourOpFM.ts @@ -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 { } } - 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 { }; } - 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) diff --git a/src/lib/audio/engines/KarplusStrong.ts b/src/lib/audio/engines/KarplusStrong.ts index ecf463e..adf0369 100644 --- a/src/lib/audio/engines/KarplusStrong.ts +++ b/src/lib/audio/engines/KarplusStrong.ts @@ -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 { } } - 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 { 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 { ]; 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 { }; } - 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)); diff --git a/src/lib/audio/engines/Ring.ts b/src/lib/audio/engines/Ring.ts index 8d3c655..70cc844 100644 --- a/src/lib/audio/engines/Ring.ts +++ b/src/lib/audio/engines/Ring.ts @@ -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 { } } - 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 { }; } - 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), diff --git a/src/lib/audio/engines/SynthEngine.ts b/src/lib/audio/engines/SynthEngine.ts index 5c13d9d..279c777 100644 --- a/src/lib/audio/engines/SynthEngine.ts +++ b/src/lib/audio/engines/SynthEngine.ts @@ -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 { 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; } diff --git a/src/lib/audio/engines/TwoOpFM.ts b/src/lib/audio/engines/TwoOpFM.ts index 546a0b1..183a971 100644 --- a/src/lib/audio/engines/TwoOpFM.ts +++ b/src/lib/audio/engines/TwoOpFM.ts @@ -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 { } } - 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 { }; } - 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) diff --git a/src/lib/audio/engines/WavetableEngine.ts b/src/lib/audio/engines/WavetableEngine.ts index 5dd3540..a927e66 100644 --- a/src/lib/audio/engines/WavetableEngine.ts +++ b/src/lib/audio/engines/WavetableEngine.ts @@ -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 { 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 { }; } - 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), diff --git a/src/lib/audio/engines/ZzfxEngine.ts b/src/lib/audio/engines/ZzfxEngine.ts index e0bfe19..f29feec 100644 --- a/src/lib/audio/engines/ZzfxEngine.ts +++ b/src/lib/audio/engines/ZzfxEngine.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { } } - 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), diff --git a/src/lib/utils/pitch.ts b/src/lib/utils/pitch.ts new file mode 100644 index 0000000..0c5c0fc --- /dev/null +++ b/src/lib/utils/pitch.ts @@ -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); +} diff --git a/src/lib/utils/settings.ts b/src/lib/utils/settings.ts index aa720e3..e53f60b 100644 --- a/src/lib/utils/settings.ts +++ b/src/lib/utils/settings.ts @@ -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()); +}