almost stable

This commit is contained in:
2025-10-11 14:04:36 +02:00
parent 58d1424adb
commit be7ba5fad8
22 changed files with 3546 additions and 168 deletions

View File

@ -1,25 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import WaveformDisplay from './lib/components/WaveformDisplay.svelte';
import VUMeter from './lib/components/VUMeter.svelte';
import { TwoOpFM, type TwoOpFMParams } from './lib/audio/engines/TwoOpFM';
import { AudioService } from './lib/audio/services/AudioService';
import { downloadWAV } from './lib/audio/utils/WAVEncoder';
import { loadVolume, saveVolume, loadDuration, saveDuration } from './lib/utils/settings';
import { generateRandomColor } from './lib/utils/colors';
import { onMount } from "svelte";
import WaveformDisplay from "./lib/components/WaveformDisplay.svelte";
import VUMeter from "./lib/components/VUMeter.svelte";
import { engines } from "./lib/audio/engines/registry";
import type { SynthEngine } 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 { generateRandomColor } from "./lib/utils/colors";
let currentMode = 'Mode 1';
const modes = ['Mode 1', 'Mode 2', 'Mode 3'];
let currentEngineIndex = 0;
let engine = engines[currentEngineIndex];
const engine = new TwoOpFM();
const audioService = new AudioService();
let currentParams: TwoOpFMParams | null = null;
let currentParams: any = null;
let currentBuffer: AudioBuffer | null = null;
let duration = loadDuration();
let volume = loadVolume();
let playbackPosition = 0;
let playbackPosition = -1;
let waveformColor = generateRandomColor();
let showModal = true;
onMount(() => {
audioService.setVolume(volume);
@ -62,7 +68,7 @@
function download() {
if (!currentBuffer) return;
downloadWAV(currentBuffer, 'synth-sound.wav');
downloadWAV(currentBuffer, "synth-sound.wav");
}
function handleVolumeChange(event: Event) {
@ -77,17 +83,83 @@
duration = parseFloat(target.value);
saveDuration(duration);
}
function switchEngine(index: number) {
currentEngineIndex = index;
engine = engines[index];
generateRandom();
}
async function closeModal() {
showModal = false;
await audioService.initialize();
}
function handleKeydown(event: KeyboardEvent) {
// Ignore if typing in an input
if (event.target instanceof HTMLInputElement) return;
const key = event.key.toLowerCase();
// Close modal with Escape key
if (key === "escape" && showModal) {
closeModal();
return;
}
switch (key) {
case "m":
mutate();
break;
case "r":
generateRandom();
break;
case "s":
download();
break;
case "arrowleft":
event.preventDefault();
const durationDecrement = event.shiftKey ? 1 : 0.05;
duration = Math.max(0.05, duration - durationDecrement);
saveDuration(duration);
break;
case "arrowright":
event.preventDefault();
const durationIncrement = event.shiftKey ? 1 : 0.05;
duration = Math.min(8, duration + durationIncrement);
saveDuration(duration);
break;
case "arrowdown":
event.preventDefault();
const volumeDecrement = event.shiftKey ? 0.2 : 0.05;
volume = Math.max(0, volume - volumeDecrement);
audioService.setVolume(volume);
saveVolume(volume);
break;
case "arrowup":
event.preventDefault();
const volumeIncrement = event.shiftKey ? 0.2 : 0.05;
volume = Math.min(1, volume + volumeIncrement);
audioService.setVolume(volume);
saveVolume(volume);
break;
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="container">
<div class="top-bar">
<div class="mode-buttons">
{#each modes as mode}
{#each engines as engine, index}
<button
class:active={currentMode === mode}
onclick={() => currentMode = mode}
class="engine-button"
class:active={currentEngineIndex === index}
data-description={engine.getDescription()}
onclick={() => switchEngine(index)}
>
{mode}
{engine.getName()}
</button>
{/each}
</div>
@ -124,22 +196,62 @@
<WaveformDisplay
buffer={currentBuffer}
color={waveformColor}
playbackPosition={playbackPosition}
{playbackPosition}
onclick={replaySound}
/>
<div class="bottom-controls">
<button onclick={generateRandom}>Random</button>
<button onclick={mutate}>Mutate</button>
<button onclick={download}>Download</button>
<button onclick={generateRandom}>Random (R)</button>
<button onclick={mutate}>Mutate (M)</button>
<button onclick={download}>Download (D)</button>
</div>
</div>
<div class="vu-meter-container">
<VUMeter
buffer={currentBuffer}
playbackPosition={playbackPosition}
/>
<VUMeter buffer={currentBuffer} {playbackPosition} />
</div>
</div>
{#if showModal}
<div
class="modal-overlay"
role="button"
tabindex="0"
onclick={closeModal}
onkeydown={(e) => e.key === "Enter" && closeModal()}
>
<div
class="modal-content"
role="dialog"
aria-labelledby="modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h1 id="modal-title">Vending Machine</h1>
<p class="description">
Oh, looks like you found a sound vending machine. This one seems
slightly broken and it seems that you can get sounds for free... Have
fun!
</p>
<div class="modal-links">
<p>
Created by <a
href="https://raphaelforment.fr"
target="_blank"
rel="noopener noreferrer">Raphaël Forment (BuboBubo)</a
>
</p>
<p>
Licensed under <a
href="https://www.gnu.org/licenses/gpl-3.0.html"
target="_blank"
rel="noopener noreferrer">GPL 3.0</a
>
</p>
</div>
<button class="modal-close" onclick={closeModal}>Start</button>
</div>
</div>
{/if}
</div>
<style>
@ -165,15 +277,39 @@
gap: 0.5rem;
}
.mode-buttons button {
.engine-button {
opacity: 0.7;
position: relative;
}
.mode-buttons button.active {
.engine-button.active {
opacity: 1;
border-color: #646cff;
}
.engine-button::after {
content: attr(data-description);
position: absolute;
top: calc(100% + 8px);
left: 0;
padding: 0.5rem 0.75rem;
background-color: #0a0a0a;
border: 1px solid #444;
color: #ccc;
font-size: 0.85rem;
width: 30vw;
white-space: normal;
word-wrap: break-word;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
}
.engine-button:hover::after {
opacity: 1;
}
.controls-group {
display: flex;
gap: 1rem;
@ -282,4 +418,76 @@
input[type="range"]::-moz-range-thumb:hover {
background: #ddd;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal-content {
background-color: #000;
border: 2px solid #fff;
padding: 2rem;
max-width: 500px;
width: 90%;
color: #fff;
}
.modal-content h1 {
margin: 0 0 1rem 0;
font-size: 2rem;
font-weight: bold;
}
.modal-content .description {
margin: 0 0 1.5rem 0;
line-height: 1.6;
color: #ccc;
}
.modal-links {
margin: 1.5rem 0;
padding: 1rem 0;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
}
.modal-links p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #ccc;
}
.modal-links a {
color: #646cff;
text-decoration: none;
}
.modal-links a:hover {
text-decoration: underline;
}
.modal-close {
margin-top: 1rem;
width: 100%;
padding: 0.75rem;
font-size: 1.1rem;
background-color: #fff;
color: #000;
border: none;
cursor: pointer;
font-weight: bold;
}
.modal-close:hover {
background-color: #ddd;
}
</style>

View File

@ -1,5 +1,14 @@
@font-face {
font-family: 'DepartureMono';
src: url('/fonts/DepartureMono-Regular.woff2') format('woff2'),
url('/fonts/DepartureMono-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: 'DepartureMono', monospace;
line-height: 1.5;
font-weight: 400;

View File

@ -1,10 +0,0 @@
<script lang="ts">
let count: number = $state(0)
const increment = () => {
count += 1
}
</script>
<button onclick={increment}>
count is {count}
</button>

View File

@ -0,0 +1,953 @@
import type { SynthEngine } from './SynthEngine';
interface BenjolinParams {
// Core oscillators
osc1Freq: number;
osc2Freq: number;
osc1Wave: number; // 0=tri, 1=saw, morphable
osc2Wave: number; // 0=tri, 1=pulse
// Cross modulation matrix
crossMod1to2: number;
crossMod2to1: number;
crossMod1toFilter: number;
crossMod2toRungler: number;
// Rungler parameters
runglerToOsc1: number;
runglerToOsc2: number;
runglerToFilter: number;
runglerBits: number; // 8, 12, or 16 bit modes
runglerFeedback: number;
runglerChaos: number; // Amount of XOR chaos
// Filter parameters
filterCutoff: number;
filterResonance: number;
filterMode: number; // 0=LP, 0.5=BP, 1=HP morphable
filterDrive: number; // Input overdrive
filterFeedback: number; // Self-oscillation amount
// Evolution parameters (new!)
evolutionRate: number; // How fast parameters drift
evolutionDepth: number; // How much they drift
chaosAttractorRate: number; // Secondary chaos source
chaosAttractorDepth: number;
// Modulation LFOs (new!)
lfo1Rate: number; // Ratio of duration
lfo1Depth: number;
lfo1Target: number; // 0=freq, 0.5=filter, 1=chaos
lfo2Rate: number;
lfo2Depth: number;
lfo2Wave: number; // 0=sine, 0.5=tri, 1=S&H
// Output shaping
wavefoldAmount: number;
distortionType: number; // 0=soft, 0.5=fold, 1=digital
stereoWidth: number;
outputGain: number;
// Envelope
envelopeAttack: number;
envelopeDecay: number;
envelopeSustain: number;
envelopeRelease: number;
envelopeToFilter: number;
envelopeToFold: number;
}
// Preset configuration count
const PRESET_COUNT = 18;
export class Benjolin implements SynthEngine<BenjolinParams> {
getName(): string {
return 'Bubolin';
}
getDescription(): string {
return 'Some kind of rungler/benjolin inspired generator';
}
generate(params: BenjolinParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(duration * sampleRate);
const left = new Float32Array(numSamples);
const right = new Float32Array(numSamples);
// Extract parameters with intelligent defaults
const osc1Freq = params.osc1Freq ?? 220;
const osc2Freq = params.osc2Freq ?? 330;
const osc1Wave = params.osc1Wave ?? 0;
const osc2Wave = params.osc2Wave ?? 0;
const crossMod1to2 = params.crossMod1to2 ?? 0.3;
const crossMod2to1 = params.crossMod2to1 ?? 0.3;
const crossMod1toFilter = params.crossMod1toFilter ?? 0.2;
const crossMod2toRungler = params.crossMod2toRungler ?? 0.2;
const runglerToOsc1 = params.runglerToOsc1 ?? 0.2;
const runglerToOsc2 = params.runglerToOsc2 ?? 0.2;
const runglerToFilter = params.runglerToFilter ?? 0.3;
const runglerBits = Math.floor(params.runglerBits ?? 8);
const runglerFeedback = params.runglerFeedback ?? 0.3;
const runglerChaos = params.runglerChaos ?? 0.5;
const filterCutoff = params.filterCutoff ?? 1000;
const filterResonance = params.filterResonance ?? 0.8;
const filterMode = params.filterMode ?? 0;
const filterDrive = params.filterDrive ?? 1.5;
const filterFeedback = params.filterFeedback ?? 0;
const evolutionRate = params.evolutionRate ?? 0.1;
const evolutionDepth = params.evolutionDepth ?? 0.2;
const chaosAttractorRate = params.chaosAttractorRate ?? 0.05;
const chaosAttractorDepth = params.chaosAttractorDepth ?? 0.3;
const lfo1Rate = params.lfo1Rate ?? 0.1;
const lfo1Depth = params.lfo1Depth ?? 0.2;
const lfo1Target = params.lfo1Target ?? 0.5;
const lfo2Rate = params.lfo2Rate ?? 0.3;
const lfo2Depth = params.lfo2Depth ?? 0.15;
const lfo2Wave = params.lfo2Wave ?? 0;
const wavefoldAmount = params.wavefoldAmount ?? 0;
const distortionType = params.distortionType ?? 0;
const stereoWidth = params.stereoWidth ?? 0.5;
const outputGain = params.outputGain ?? 0.5;
const envelopeAttack = params.envelopeAttack ?? 0.05;
const envelopeDecay = params.envelopeDecay ?? 0.15;
const envelopeSustain = params.envelopeSustain ?? 0.3;
const envelopeRelease = params.envelopeRelease ?? 0.5;
const envelopeToFilter = params.envelopeToFilter ?? 0.3;
const envelopeToFold = params.envelopeToFold ?? 0.2;
// Oscillator states
let osc1Phase = 0;
let osc2Phase = 0;
let osc1LastOutput = 0;
let osc2LastOutput = 0;
let osc2LastCrossing = false;
// Extended rungler state (up to 16-bit)
const runglerMask = (1 << runglerBits) - 1;
let runglerRegister = Math.floor(Math.random() * runglerMask);
let runglerCV = 0;
let runglerSmoothed = 0;
let runglerHistory = 0; // For feedback
const runglerSmoothFactor = 0.995;
// Filter states (dual state-variable filters)
let filter1LP = 0, filter1HP = 0, filter1BP = 0;
let filter2LP = 0, filter2HP = 0, filter2BP = 0;
let filterSelfOsc = 0;
// Evolution and chaos states
let evolutionPhase = 0;
let evolutionValue = 0;
let chaosX = 0.1, chaosY = 0.1, chaosZ = 0.1; // Lorenz attractor
let driftAccumulator = 0;
// LFO states
let lfo1Phase = 0;
let lfo2Phase = 0;
let lfo2SampleHold = 0;
let lfo2LastPhase = 0;
// Envelope follower for rungler
let runglerEnvelope = 0;
const envelopeFollowRate = 0.99;
// Wavefolder state
let wavefoldIntegrator = 0;
// DC blocker states
let dcBlockerX1L = 0, dcBlockerY1L = 0;
let dcBlockerX1R = 0, dcBlockerY1R = 0;
const dcBlockerCutoff = 20 / sampleRate;
const dcBlockerAlpha = 1 - dcBlockerCutoff;
// Envelope
const attackSamples = Math.floor(envelopeAttack * duration * sampleRate);
const decaySamples = Math.floor(envelopeDecay * duration * sampleRate);
const releaseSamples = Math.floor(envelopeRelease * duration * sampleRate);
const sustainSamples = Math.max(0, numSamples - attackSamples - decaySamples - releaseSamples);
// Stereo delay line for width
const delayLength = Math.floor(0.003 * sampleRate); // 3ms
const delayBuffer = new Float32Array(delayLength);
let delayIndex = 0;
for (let i = 0; i < numSamples; i++) {
// Calculate main envelope
let envelope = 0;
if (i < attackSamples) {
envelope = i / attackSamples;
} else if (i < attackSamples + decaySamples) {
const decayProgress = (i - attackSamples) / decaySamples;
envelope = 1 - decayProgress * (1 - envelopeSustain);
} else if (i < attackSamples + decaySamples + sustainSamples) {
envelope = envelopeSustain;
} else {
const releaseProgress = (i - attackSamples - decaySamples - sustainSamples) / releaseSamples;
envelope = envelopeSustain * (1 - releaseProgress);
}
// Update evolution (slow parameter drift)
evolutionPhase += evolutionRate / sampleRate;
evolutionValue = Math.sin(evolutionPhase * 2 * Math.PI) * evolutionDepth;
// Update chaos attractor (Lorenz system) with safety bounds
const dt = 0.01 * chaosAttractorRate;
const sigma = 10;
const rho = 28;
const beta = 8/3;
// Calculate derivatives
const dx = sigma * (chaosY - chaosX) * dt;
const dy = (chaosX * (rho - chaosZ) - chaosY) * dt;
const dz = (chaosX * chaosY - beta * chaosZ) * dt;
// Update with bounds checking
chaosX = Math.max(-50, Math.min(50, chaosX + dx));
chaosY = Math.max(-50, Math.min(50, chaosY + dy));
chaosZ = Math.max(-50, Math.min(50, chaosZ + dz));
// Check for NaN and reset if necessary
if (!isFinite(chaosX) || !isFinite(chaosY) || !isFinite(chaosZ)) {
chaosX = 0.1;
chaosY = 0.1;
chaosZ = 0.1;
}
const chaosValue = Math.tanh(chaosZ * 0.01) * chaosAttractorDepth;
// Update LFOs
lfo1Phase += lfo1Rate / sampleRate;
if (lfo1Phase >= 1) lfo1Phase -= 1;
const lfo1Value = Math.sin(lfo1Phase * 2 * Math.PI) * lfo1Depth;
lfo2Phase += lfo2Rate / sampleRate;
if (lfo2Phase >= 1) lfo2Phase -= 1;
// LFO2 waveform selection
let lfo2Value = 0;
if (lfo2Wave < 0.33) {
// Sine
lfo2Value = Math.sin(lfo2Phase * 2 * Math.PI);
} else if (lfo2Wave < 0.67) {
// Triangle
lfo2Value = lfo2Phase < 0.5 ? lfo2Phase * 4 - 1 : 3 - lfo2Phase * 4;
} else {
// Sample & Hold
if (lfo2Phase < lfo2LastPhase) {
lfo2SampleHold = Math.random() * 2 - 1;
}
lfo2Value = lfo2SampleHold;
}
lfo2LastPhase = lfo2Phase;
lfo2Value *= lfo2Depth;
// Track rungler envelope
runglerEnvelope = runglerEnvelope * envelopeFollowRate +
Math.abs(runglerCV) * (1 - envelopeFollowRate);
// Smooth rungler CV with drift
driftAccumulator += (Math.random() - 0.5) * 0.001;
driftAccumulator *= 0.999; // Decay
runglerSmoothed += (runglerCV - runglerSmoothed + driftAccumulator) * (1 - runglerSmoothFactor);
// Apply LFO1 based on target
let lfo1Modulation = 0;
if (lfo1Target < 0.33) {
lfo1Modulation = lfo1Value; // To frequency
} else if (lfo1Target < 0.67) {
lfo1Modulation = lfo1Value * 0.5; // To filter
} else {
lfo1Modulation = lfo1Value * 0.3; // To chaos
}
// Calculate oscillator frequencies with complex modulation
const osc1ModFreq = osc1Freq *
(1 + osc2LastOutput * crossMod2to1 * (2 + evolutionValue) +
runglerSmoothed * runglerToOsc1 * (1 + chaosValue) +
lfo1Modulation * (lfo1Target < 0.5 ? 1 : 0));
const osc2ModFreq = osc2Freq *
(1 + osc1LastOutput * crossMod1to2 * (2 + chaosValue) +
runglerSmoothed * runglerToOsc2 * (1 + evolutionValue) +
lfo2Value * 0.5);
// Clamp frequencies but allow wider range
const clampedOsc1Freq = Math.max(1, Math.min(12000, osc1ModFreq));
const clampedOsc2Freq = Math.max(1, Math.min(12000, osc2ModFreq));
// Update oscillator phases
const osc1PhaseInc = clampedOsc1Freq / sampleRate;
const osc2PhaseInc = clampedOsc2Freq / sampleRate;
osc1Phase += osc1PhaseInc;
osc2Phase += osc2PhaseInc;
if (osc1Phase >= 1) osc1Phase -= 1;
if (osc2Phase >= 1) osc2Phase -= 1;
// Generate morphable oscillator waveforms
const osc1Tri = this.polyBlepTriangle(osc1Phase, osc1PhaseInc);
const osc1Saw = this.polyBlepSaw(osc1Phase, osc1PhaseInc);
const osc1Output = osc1Tri * (1 - osc1Wave) + osc1Saw * osc1Wave;
const osc2Tri = this.polyBlepTriangle(osc2Phase, osc2PhaseInc);
const osc2Pulse = this.polyBlepPulse(osc2Phase, osc2PhaseInc, 0.5 + osc2Wave * 0.45);
const osc2Output = osc2Tri * (1 - osc2Wave) + osc2Pulse * osc2Wave;
// Enhanced rungler with feedback
const osc2Crossing = osc2Output > 0;
if (osc2Crossing && !osc2LastCrossing) {
// Shift with feedback
runglerRegister = (runglerRegister << 1) & runglerMask;
// Sample input with cross-modulation influence
const runglerInput = osc1Output + crossMod2toRungler * osc2Output;
if (runglerInput > runglerHistory * runglerFeedback) {
runglerRegister |= 1;
}
runglerHistory = runglerInput;
// Enhanced XOR chaos
let xorResult = 0;
const numBits = runglerBits - 1;
for (let bit = 0; bit < numBits; bit++) {
const bitA = (runglerRegister >> bit) & 1;
const bitB = (runglerRegister >> ((bit + 1) % runglerBits)) & 1;
const bitC = (runglerRegister >> ((bit + 3) % runglerBits)) & 1;
// Three-way XOR for more chaos
xorResult |= ((bitA ^ bitB ^ bitC) & 1) << bit;
}
// Mix direct register value with XOR chaos
const directValue = runglerRegister / runglerMask;
const chaosValueNorm = xorResult / runglerMask;
runglerCV = (directValue * (1 - runglerChaos) + chaosValueNorm * runglerChaos) * 2 - 1;
}
osc2LastCrossing = osc2Crossing;
// Dual state-variable filters with self-oscillation
const modulatedCutoff = filterCutoff *
(1 + runglerSmoothed * runglerToFilter * 0.5 +
osc1Output * crossMod1toFilter * 0.3 +
envelope * envelopeToFilter +
(lfo1Target > 0.33 && lfo1Target < 0.67 ? lfo1Modulation : 0) +
chaosValue * 0.2);
const clampedCutoff = Math.max(20, Math.min(sampleRate * 0.48, modulatedCutoff));
const filterFreq = 2 * Math.sin(Math.PI * clampedCutoff / sampleRate);
// Allow self-oscillation with safety limits
const baseQ = 1 / Math.max(0.05, 2 - filterResonance * 1.95);
const filterQ = Math.max(0.5, Math.min(20, baseQ + filterFeedback * 5));
// Drive the filter input with limiting
const filterInput = (osc1Output * 0.7 + osc2Output * 0.3) * Math.min(3, filterDrive);
const drivenInput = Math.tanh(filterInput);
// First filter stage with stability checks
const filter1Input = drivenInput - filter1LP - filterQ * filter1BP + filterSelfOsc * filterFeedback * 0.5;
filter1HP = Math.max(-5, Math.min(5, filter1Input));
filter1BP += filterFreq * filter1HP;
filter1LP += filterFreq * filter1BP;
// Prevent filter state explosion
filter1BP = Math.max(-2, Math.min(2, filter1BP));
filter1LP = Math.max(-2, Math.min(2, filter1LP));
// Check for NaN and reset if necessary
if (!isFinite(filter1LP) || !isFinite(filter1BP) || !isFinite(filter1HP)) {
filter1LP = 0;
filter1BP = 0;
filter1HP = 0;
}
// Second filter stage (in series for 24dB/oct)
const filter1Out = filter1LP * (1 - filterMode) +
filter1BP * (filterMode * 2 * (filterMode < 0.5 ? 1 : 0)) +
filter1HP * (filterMode > 0.5 ? (filterMode - 0.5) * 2 : 0);
const filter2Input = filter1Out - filter2LP - filterQ * 0.7 * filter2BP;
filter2HP = Math.max(-5, Math.min(5, filter2Input));
filter2BP += filterFreq * filter2HP;
filter2LP += filterFreq * filter2BP;
// Prevent filter state explosion
filter2BP = Math.max(-2, Math.min(2, filter2BP));
filter2LP = Math.max(-2, Math.min(2, filter2LP));
// Check for NaN and reset if necessary
if (!isFinite(filter2LP) || !isFinite(filter2BP) || !isFinite(filter2HP)) {
filter2LP = 0;
filter2BP = 0;
filter2HP = 0;
}
// Mix filter outputs with morphing
let filterOut = filter2LP * (1 - filterMode) +
filter2BP * (filterMode * 2 * (filterMode < 0.5 ? 1 : 0)) +
filter2HP * (filterMode > 0.5 ? (filterMode - 0.5) * 2 : 0);
// Track filter self-oscillation with limiting
filterSelfOsc = Math.tanh(filter2BP * 0.1);
// Wavefolding stage
let foldedSignal = filterOut;
if (wavefoldAmount > 0) {
const foldGain = 1 + wavefoldAmount * (4 + envelope * envelopeToFold * 2);
foldedSignal = this.wavefold(filterOut * foldGain, 2 + wavefoldAmount * 3) / foldGain;
// Integrate for smoother folding
wavefoldIntegrator += (foldedSignal - wavefoldIntegrator) * 0.1;
foldedSignal = foldedSignal * (1 - wavefoldAmount * 0.3) + wavefoldIntegrator * wavefoldAmount * 0.3;
}
// Distortion stage
let output = foldedSignal;
if (distortionType < 0.33) {
// Soft saturation
output = Math.tanh(output * (1 + distortionType * 3));
} else if (distortionType < 0.67) {
// Wavefolder
const foldAmount = (distortionType - 0.33) * 3;
output = this.wavefold(output * (1 + foldAmount * 2), 3);
} else {
// Digital decimation
const bitDepth = Math.floor(16 - (distortionType - 0.67) * 3 * 12);
const scale = Math.pow(2, bitDepth);
output = Math.floor(output * scale) / scale;
}
// Apply envelope and gain
output *= envelope * outputGain;
// Final soft limiting
output = Math.tanh(output * 0.9) * 0.95;
// DC blocking for left channel
let dcBlockerYL = output - dcBlockerX1L + dcBlockerAlpha * dcBlockerY1L;
dcBlockerX1L = output;
dcBlockerY1L = dcBlockerYL;
// Stereo processing
const monoSignal = dcBlockerYL;
// Create stereo width with micro-delay and phase differences
delayBuffer[delayIndex] = monoSignal + filter1BP * 0.05;
const delayedSignal = delayBuffer[(delayIndex + delayLength - Math.floor(stereoWidth * delayLength)) % delayLength];
delayIndex = (delayIndex + 1) % delayLength;
// Add evolving stereo movement
const stereoPan = Math.sin(i / sampleRate * 0.7 + runglerEnvelope * 3) * stereoWidth * 0.3;
left[i] = monoSignal * (1 - stereoPan * 0.5) + delayedSignal * stereoWidth * 0.2;
// Right channel with different filtering for width
const rightSignal = monoSignal * 0.7 + filter2BP * 0.15 + filterSelfOsc * 0.15;
// DC blocking for right channel
let dcBlockerYR = rightSignal - dcBlockerX1R + dcBlockerAlpha * dcBlockerY1R;
dcBlockerX1R = rightSignal;
dcBlockerY1R = dcBlockerYR;
right[i] = dcBlockerYR * (1 + stereoPan * 0.5) + delayedSignal * stereoWidth * 0.3;
// Store states for next iteration
osc1LastOutput = osc1Output;
osc2LastOutput = osc2Output;
}
return [left, right];
}
// Oscillator generation methods
private polyBlepSaw(phase: number, phaseInc: number): number {
let value = 2 * phase - 1;
value -= this.polyBlep(phase, phaseInc);
return value;
}
private polyBlepPulse(phase: number, phaseInc: number, pulseWidth: number): number {
let value = phase < pulseWidth ? 1 : -1;
value += this.polyBlep(phase, phaseInc);
value -= this.polyBlep((phase - pulseWidth + 1) % 1, phaseInc);
return value;
}
private polyBlepTriangle(phase: number, phaseInc: number): number {
let value = phase < 0.5 ? phase * 4 - 1 : 3 - phase * 4;
const polyBlepCorrection = this.polyBlepIntegral(phase, phaseInc) -
this.polyBlepIntegral((phase + 0.5) % 1, phaseInc);
value += polyBlepCorrection * 4;
return value;
}
private polyBlep(t: number, dt: number): number {
if (t < dt) {
t /= dt;
return t + t - t * t - 1;
} else if (t > 1 - dt) {
t = (t - 1) / dt;
return t * t + t + t + 1;
}
return 0;
}
private polyBlepIntegral(t: number, dt: number): number {
if (t < dt) {
t /= dt;
return (t * t * t) / 3 - t * t / 2 - t / 2;
} else if (t > 1 - dt) {
t = (t - 1) / dt;
return t * t * t / 3 + t * t / 2 + t / 2;
}
return 0;
}
// Wavefolding function - safe implementation
private wavefold(input: number, folds: number): number {
// Limit input to prevent numerical issues
const clampedInput = Math.max(-10, Math.min(10, input));
const threshold = 1 / Math.max(1, folds);
let folded = clampedInput;
// Maximum iterations to prevent infinite loop
const maxIterations = 10;
let iterations = 0;
while (Math.abs(folded) > threshold && iterations < maxIterations) {
if (folded > threshold) {
folded = threshold * 2 - folded;
} else if (folded < -threshold) {
folded = -threshold * 2 - folded;
}
iterations++;
}
// Final clamp to ensure output is bounded
return Math.max(-1, Math.min(1, folded * folds));
}
// Configuration-based parameter generation
private generatePresetParams(preset: number): Partial<BenjolinParams> {
switch (preset) {
case 0:
return {
osc1Freq: 50 + Math.random() * 150,
osc2Freq: 80 + Math.random() * 200,
crossMod1to2: 0.3 + Math.random() * 0.3,
crossMod2to1: 0.3 + Math.random() * 0.3,
runglerChaos: 0.6 + Math.random() * 0.3,
filterResonance: 0.7 + Math.random() * 0.25,
filterMode: Math.random() * 0.3,
evolutionRate: 0.1 + Math.random() * 0.2,
lfo1Rate: 0.05 + Math.random() * 0.1
};
case 1:
return {
osc1Freq: 200 + Math.random() * 800,
osc2Freq: 300 + Math.random() * 1200,
crossMod1to2: 0.6 + Math.random() * 0.4,
crossMod2to1: 0.6 + Math.random() * 0.4,
runglerToFilter: 0.5 + Math.random() * 0.5,
filterResonance: 0.8 + Math.random() * 0.15,
filterFeedback: 0.3 + Math.random() * 0.4,
filterDrive: 2 + Math.random() * 2,
wavefoldAmount: 0.3 + Math.random() * 0.4
};
case 2: {
const baseFreq = 50 + Math.random() * 100;
return {
osc1Freq: baseFreq,
osc2Freq: baseFreq * (Math.floor(Math.random() * 4) + 1.5),
runglerBits: Math.random() > 0.5 ? 8 : 4,
runglerChaos: 0.7 + Math.random() * 0.3,
lfo2Rate: 0.2 + Math.random() * 0.5,
lfo2Wave: 0.8 + Math.random() * 0.2,
envelopeAttack: 0.001 + Math.random() * 0.01,
envelopeDecay: 0.01 + Math.random() * 0.05
};
}
case 3:
return {
osc1Freq: 30 + Math.random() * 100,
osc2Freq: 40 + Math.random() * 120,
crossMod1to2: 0.1 + Math.random() * 0.2,
crossMod2to1: 0.1 + Math.random() * 0.2,
evolutionRate: 0.01 + Math.random() * 0.05,
evolutionDepth: 0.3 + Math.random() * 0.4,
filterMode: Math.random() * 0.5,
envelopeAttack: 0.3 + Math.random() * 0.4,
envelopeRelease: 0.4 + Math.random() * 0.4,
stereoWidth: 0.6 + Math.random() * 0.4
};
case 4: {
const metalFreq = 100 + Math.random() * 500;
return {
osc1Freq: metalFreq,
osc2Freq: metalFreq * (1.41 + Math.random() * 0.2),
osc1Wave: 0.7 + Math.random() * 0.3,
filterResonance: 0.85 + Math.random() * 0.1,
filterDrive: 1.5 + Math.random(),
distortionType: 0.5 + Math.random() * 0.5,
wavefoldAmount: 0.2 + Math.random() * 0.3
};
}
case 5:
return {
osc1Freq: 60 + Math.random() * 200,
osc2Freq: 90 + Math.random() * 300,
evolutionRate: 0.05 + Math.random() * 0.15,
chaosAttractorRate: 0.02 + Math.random() * 0.08,
chaosAttractorDepth: 0.2 + Math.random() * 0.3,
lfo1Depth: 0.1 + Math.random() * 0.2,
filterMode: 0.2 + Math.random() * 0.3,
envelopeToFilter: 0.3 + Math.random() * 0.4
};
case 6:
return {
runglerBits: Math.floor(4 + Math.random() * 12),
runglerFeedback: 0.5 + Math.random() * 0.5,
runglerChaos: 0.8 + Math.random() * 0.2,
distortionType: 0.7 + Math.random() * 0.3,
lfo2Wave: 0.7 + Math.random() * 0.3,
filterDrive: 3 + Math.random() * 2,
wavefoldAmount: 0.4 + Math.random() * 0.4
};
case 7:
return {
osc1Freq: 40 + Math.random() * 150,
osc2Freq: 60 + Math.random() * 200,
crossMod1to2: 0.05 + Math.random() * 0.15,
crossMod2to1: 0.05 + Math.random() * 0.15,
evolutionRate: 0.02 + Math.random() * 0.08,
filterMode: Math.random() * 0.4,
stereoWidth: 0.7 + Math.random() * 0.3,
envelopeAttack: 0.2 + Math.random() * 0.3,
outputGain: 0.3 + Math.random() * 0.3
};
case 8:
return {
osc1Freq: 20 + Math.random() * 40,
osc2Freq: 25 + Math.random() * 50,
crossMod1to2: 0.4 + Math.random() * 0.3,
crossMod2to1: 0.2 + Math.random() * 0.2,
filterCutoff: 80 + Math.random() * 200,
filterResonance: 0.6 + Math.random() * 0.3,
filterDrive: 2 + Math.random() * 2,
envelopeAttack: 0.001,
envelopeDecay: 0.05 + Math.random() * 0.1,
outputGain: 0.6 + Math.random() * 0.3
};
case 9: {
const bellFreq = 200 + Math.random() * 600;
return {
osc1Freq: bellFreq,
osc2Freq: bellFreq * (2.71 + Math.random() * 0.3),
osc1Wave: 0.9,
osc2Wave: 0.1,
filterResonance: 0.9 + Math.random() * 0.05,
filterMode: 0.7 + Math.random() * 0.3,
envelopeAttack: 0.001,
envelopeDecay: 0.02 + Math.random() * 0.03,
envelopeSustain: 0,
envelopeRelease: 0.3 + Math.random() * 0.4,
stereoWidth: 0.8 + Math.random() * 0.2
};
}
case 10:
return {
osc1Freq: 500 + Math.random() * 1500,
osc2Freq: 800 + Math.random() * 2000,
crossMod1to2: 0.8 + Math.random() * 0.2,
crossMod2to1: 0.8 + Math.random() * 0.2,
runglerChaos: 0.9 + Math.random() * 0.1,
runglerBits: 4,
filterDrive: 3 + Math.random() * 2,
distortionType: 0.8 + Math.random() * 0.2,
envelopeAttack: 0.001,
envelopeDecay: 0.01 + Math.random() * 0.02
};
case 11: {
const carrier = 100 + Math.random() * 300;
return {
osc1Freq: carrier,
osc2Freq: carrier * (Math.floor(Math.random() * 5) + 1),
crossMod1to2: 0.7 + Math.random() * 0.3,
crossMod2to1: 0.1 + Math.random() * 0.2,
osc1Wave: 0,
osc2Wave: 0,
filterMode: 0.1 + Math.random() * 0.2,
lfo1Target: 0,
lfo1Depth: 0.3 + Math.random() * 0.3,
lfo1Rate: 0.1 + Math.random() * 0.3
};
}
case 12:
return {
osc1Freq: 150 + Math.random() * 350,
osc2Freq: 200 + Math.random() * 400,
runglerBits: 16,
runglerToOsc1: 0.4 + Math.random() * 0.3,
runglerToOsc2: 0.4 + Math.random() * 0.3,
evolutionRate: 0.2 + Math.random() * 0.3,
chaosAttractorRate: 0.1 + Math.random() * 0.2,
envelopeAttack: 0.01 + Math.random() * 0.03,
envelopeDecay: 0.02 + Math.random() * 0.05,
wavefoldAmount: 0.1 + Math.random() * 0.2
};
case 13:
return {
osc1Freq: 80 + Math.random() * 200,
osc2Freq: 120 + Math.random() * 300,
filterCutoff: 200 + Math.random() * 800,
filterResonance: 0.85 + Math.random() * 0.1,
filterFeedback: 0.4 + Math.random() * 0.3,
lfo1Target: 0.5,
lfo1Rate: 0.3 + Math.random() * 0.4,
lfo1Depth: 0.5 + Math.random() * 0.3,
envelopeToFilter: 0.6 + Math.random() * 0.3
};
case 14:
return {
osc1Freq: 60 + Math.random() * 150,
osc2Freq: 100 + Math.random() * 250,
osc2Wave: 0.8 + Math.random() * 0.2,
filterDrive: 2.5 + Math.random() * 1.5,
filterMode: 0.6 + Math.random() * 0.4,
distortionType: 0.4 + Math.random() * 0.3,
envelopeAttack: 0.001,
envelopeDecay: 0.01 + Math.random() * 0.03,
envelopeSustain: 0.1 + Math.random() * 0.2,
envelopeRelease: 0.05 + Math.random() * 0.1
};
case 15: {
const aliasFreq = 5000 + Math.random() * 7000;
return {
osc1Freq: aliasFreq,
osc2Freq: aliasFreq * (1.01 + Math.random() * 0.02),
osc1Wave: 1,
osc2Wave: 0.5,
filterCutoff: 8000 + Math.random() * 4000,
filterMode: 0.8 + Math.random() * 0.2,
distortionType: 0.85 + Math.random() * 0.15,
outputGain: 0.3 + Math.random() * 0.2
};
}
case 16:
return {
osc1Freq: 100 + Math.random() * 200,
osc2Freq: 150 + Math.random() * 250,
crossMod1to2: 0.2 + Math.random() * 0.2,
crossMod2to1: 0.2 + Math.random() * 0.2,
lfo1Rate: 0.01 + Math.random() * 0.03,
lfo2Rate: 0.02 + Math.random() * 0.04,
lfo1Depth: 0.3 + Math.random() * 0.4,
lfo2Depth: 0.3 + Math.random() * 0.4,
evolutionRate: 0.005 + Math.random() * 0.02,
evolutionDepth: 0.4 + Math.random() * 0.4
};
case 17:
return {
osc1Freq: 100 + Math.random() * 400,
osc2Freq: 150 + Math.random() * 600,
crossMod1to2: 0.7 + Math.random() * 0.3,
crossMod2to1: 0.7 + Math.random() * 0.3,
runglerFeedback: 0.8 + Math.random() * 0.2,
filterFeedback: 0.6 + Math.random() * 0.3,
filterResonance: 0.9 + Math.random() * 0.05,
filterDrive: 3 + Math.random() * 2,
wavefoldAmount: 0.5 + Math.random() * 0.4,
distortionType: 0.3 + Math.random() * 0.4
};
default:
// Fallback to fully random
return {};
}
}
randomParams(): BenjolinParams {
// Choose a random preset configuration
const preset = Math.floor(Math.random() * PRESET_COUNT);
// Get preset-specific parameters
const presetParams = this.generatePresetParams(preset);
// Generate full parameter set with preset biases
const params: BenjolinParams = {
// Core oscillators
osc1Freq: presetParams.osc1Freq ?? (20 + Math.random() * 800),
osc2Freq: presetParams.osc2Freq ?? (30 + Math.random() * 1200),
osc1Wave: presetParams.osc1Wave ?? Math.random(),
osc2Wave: presetParams.osc2Wave ?? Math.random(),
// Cross modulation
crossMod1to2: presetParams.crossMod1to2 ?? Math.random() * 0.8,
crossMod2to1: presetParams.crossMod2to1 ?? Math.random() * 0.8,
crossMod1toFilter: presetParams.crossMod1toFilter ?? Math.random() * 0.5,
crossMod2toRungler: presetParams.crossMod2toRungler ?? Math.random() * 0.4,
// Rungler
runglerToOsc1: presetParams.runglerToOsc1 ?? Math.random() * 0.6,
runglerToOsc2: presetParams.runglerToOsc2 ?? Math.random() * 0.6,
runglerToFilter: presetParams.runglerToFilter ?? Math.random() * 0.8,
runglerBits: presetParams.runglerBits ?? (Math.random() > 0.7 ? 16 : Math.random() > 0.4 ? 12 : 8),
runglerFeedback: presetParams.runglerFeedback ?? Math.random() * 0.7,
runglerChaos: presetParams.runglerChaos ?? (0.3 + Math.random() * 0.7),
// Filter
filterCutoff: presetParams.filterCutoff ?? (100 + Math.random() * 3000),
filterResonance: presetParams.filterResonance ?? (0.3 + Math.random() * 0.65),
filterMode: presetParams.filterMode ?? Math.random(),
filterDrive: presetParams.filterDrive ?? (0.5 + Math.random() * 2.5),
filterFeedback: presetParams.filterFeedback ?? Math.random() * 0.5,
// Evolution
evolutionRate: presetParams.evolutionRate ?? Math.random() * 0.3,
evolutionDepth: presetParams.evolutionDepth ?? Math.random() * 0.5,
chaosAttractorRate: presetParams.chaosAttractorRate ?? Math.random() * 0.2,
chaosAttractorDepth: presetParams.chaosAttractorDepth ?? Math.random() * 0.5,
// LFOs
lfo1Rate: presetParams.lfo1Rate ?? Math.random() * 0.5,
lfo1Depth: presetParams.lfo1Depth ?? Math.random() * 0.4,
lfo1Target: presetParams.lfo1Target ?? Math.random(),
lfo2Rate: presetParams.lfo2Rate ?? Math.random() * 0.8,
lfo2Depth: presetParams.lfo2Depth ?? Math.random() * 0.3,
lfo2Wave: presetParams.lfo2Wave ?? Math.random(),
// Output shaping
wavefoldAmount: presetParams.wavefoldAmount ?? Math.random() * 0.6,
distortionType: presetParams.distortionType ?? Math.random(),
stereoWidth: presetParams.stereoWidth ?? (0.2 + Math.random() * 0.8),
outputGain: presetParams.outputGain ?? (0.2 + Math.random() * 0.5),
// Envelope
envelopeAttack: presetParams.envelopeAttack ?? (0.001 + Math.random() * 0.2),
envelopeDecay: presetParams.envelopeDecay ?? (0.01 + Math.random() * 0.3),
envelopeSustain: presetParams.envelopeSustain ?? Math.random(),
envelopeRelease: presetParams.envelopeRelease ?? (0.05 + Math.random() * 0.5),
envelopeToFilter: presetParams.envelopeToFilter ?? Math.random() * 0.6,
envelopeToFold: presetParams.envelopeToFold ?? Math.random() * 0.4
};
return params;
}
mutateParams(params: BenjolinParams): 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;
// 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;
return Math.max(min, Math.min(max, value + delta));
};
// Decide mutation strategy
const strategy = Math.random();
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);
// Correlate cross-mod amounts
const crossModDelta = (Math.random() - 0.5) * mutationAmount;
mutated.crossMod1to2 = Math.max(0, Math.min(1, mutated.crossMod1to2 + crossModDelta));
mutated.crossMod2to1 = Math.max(0, Math.min(1, mutated.crossMod2to1 + crossModDelta * 0.7));
} else if (strategy < 0.6) {
// Mutate filter characteristics
mutated.filterCutoff = mutateValue(mutated.filterCutoff, 100, 4000);
mutated.filterResonance = mutateValue(mutated.filterResonance, 0, 0.95);
mutated.filterMode = mutateValue(mutated.filterMode, 0, 1);
// Correlate filter drive and feedback
if (Math.random() > 0.5) {
mutated.filterDrive = mutateValue(mutated.filterDrive, 0.5, 4);
mutated.filterFeedback = mutateValue(mutated.filterFeedback, 0, 0.7, 0.5);
}
} else if (strategy < 0.8) {
// Mutate evolution and chaos
mutated.evolutionRate = mutateValue(mutated.evolutionRate, 0, 0.5);
mutated.evolutionDepth = mutateValue(mutated.evolutionDepth, 0, 0.8);
mutated.chaosAttractorRate = mutateValue(mutated.chaosAttractorRate, 0, 0.3);
mutated.chaosAttractorDepth = mutateValue(mutated.chaosAttractorDepth, 0, 0.8);
// Maybe change rungler behavior
if (Math.random() > 0.7) {
mutated.runglerChaos = mutateValue(mutated.runglerChaos, 0, 1);
mutated.runglerFeedback = mutateValue(mutated.runglerFeedback, 0, 0.9);
}
} else {
// Mutate output characteristics
mutated.wavefoldAmount = mutateValue(mutated.wavefoldAmount, 0, 0.8);
mutated.distortionType = mutateValue(mutated.distortionType, 0, 1);
mutated.stereoWidth = mutateValue(mutated.stereoWidth, 0, 1);
// Adjust envelope for new sound
const envMutation = Math.random();
if (envMutation < 0.5) {
mutated.envelopeAttack = mutateValue(mutated.envelopeAttack, 0.001, 0.5);
mutated.envelopeDecay = mutateValue(mutated.envelopeDecay, 0.01, 0.5);
} else {
mutated.envelopeSustain = mutateValue(mutated.envelopeSustain, 0, 1);
mutated.envelopeRelease = mutateValue(mutated.envelopeRelease, 0.01, 0.8);
}
}
// Always apply small random mutations to 2-3 other parameters
const paramNames = Object.keys(params) as (keyof BenjolinParams)[];
const numExtraMutations = 2 + Math.floor(Math.random() * 2);
for (let i = 0; i < numExtraMutations; i++) {
const paramName = paramNames[Math.floor(Math.random() * paramNames.length)];
const currentValue = mutated[paramName];
if (typeof currentValue === 'number') {
// Apply subtle mutation based on parameter type
if (paramName.includes('Freq')) {
mutated[paramName] = mutateValue(currentValue, 20, 2000, 0.5) as any;
} else if (paramName.includes('envelope')) {
mutated[paramName] = mutateValue(currentValue, 0, 1, 0.3) as any;
} else {
mutated[paramName] = mutateValue(currentValue, 0, 1, 0.2) as any;
}
}
}
return mutated;
}
}

View File

@ -0,0 +1,487 @@
import type { SynthEngine } from './SynthEngine';
enum OscillatorWaveform {
Sine,
Triangle,
Square,
Saw,
Pulse,
}
enum SweepCurve {
Linear,
Exponential,
Logarithmic,
Bounce,
Elastic,
}
enum FilterType {
None,
LowPass,
HighPass,
BandPass,
}
interface DubSirenParams {
startFreq: number;
endFreq: number;
sweepCurve: SweepCurve;
waveform: OscillatorWaveform;
pulseWidth: number;
harmonics: number;
harmonicSpread: number;
lfoRate: number;
lfoDepth: number;
filterType: FilterType;
filterFreq: number;
filterResonance: number;
filterSweepAmount: number;
attack: number;
decay: number;
sustain: number;
release: number;
feedback: number;
stereoWidth: number;
distortion: number;
}
export class DubSiren implements SynthEngine<DubSirenParams> {
private filterHistoryL1 = 0;
private filterHistoryL2 = 0;
private filterHistoryR1 = 0;
private filterHistoryR2 = 0;
private dcBlockerL = 0;
private dcBlockerR = 0;
private readonly DENORMAL_OFFSET = 1e-24;
getName(): string {
return 'Siren';
}
getDescription(): string {
return 'Siren generator with pitch sweeps, anti-aliased oscillators and stable filtering';
}
generate(params: DubSirenParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const invSampleRate = 1 / sampleRate;
// Initialize phases for oscillators
const numOscillators = 1 + params.harmonics;
const phasesL: number[] = new Array(numOscillators).fill(0);
const phasesR: number[] = new Array(numOscillators).fill(0);
// Stereo phase offset
const stereoPhaseOffset = Math.PI * params.stereoWidth * 0.1;
for (let i = 0; i < numOscillators; i++) {
phasesR[i] = stereoPhaseOffset * (i + 1);
}
// LFO setup
let lfoPhaseL = 0;
let lfoPhaseR = Math.PI * params.stereoWidth * 0.25;
const lfoIncrement = TAU * params.lfoRate * invSampleRate;
// Feedback buffers with slight delay for richness
const feedbackDelaySize = 64;
const feedbackBufferL = new Float32Array(feedbackDelaySize);
const feedbackBufferR = new Float32Array(feedbackDelaySize);
let feedbackIndex = 0;
// Reset filter state
this.filterHistoryL1 = 0;
this.filterHistoryL2 = 0;
this.filterHistoryR1 = 0;
this.filterHistoryR2 = 0;
this.dcBlockerL = 0;
this.dcBlockerR = 0;
// Envelope smoothing
let lastEnv = 0;
const envSmoothCoeff = 0.001;
for (let i = 0; i < numSamples; i++) {
const t = i / numSamples;
// Calculate and smooth envelope
const targetEnv = this.calculateEnvelope(
t * duration,
duration,
params.attack,
params.decay,
params.sustain,
params.release
);
const env = lastEnv + (targetEnv - lastEnv) * envSmoothCoeff;
lastEnv = env;
// Calculate pitch sweep
const sweepProgress = this.calculateSweepCurve(t, params.sweepCurve);
const currentFreq = params.startFreq + (params.endFreq - params.startFreq) * sweepProgress;
// LFO modulation (using fast approximation)
const lfoL = this.fastSin(lfoPhaseL);
const lfoR = this.fastSin(lfoPhaseR);
const pitchModL = 1 + lfoL * params.lfoDepth * 0.1;
const pitchModR = 1 + lfoR * params.lfoDepth * 0.1;
// Generate oscillators with harmonics
let sampleL = 0;
let sampleR = 0;
for (let osc = 0; osc < numOscillators; osc++) {
const harmonicMultiplier = 1 + osc * params.harmonicSpread;
const harmonicLevel = 1 / (osc + 1);
const freqL = currentFreq * harmonicMultiplier * pitchModL;
const freqR = currentFreq * harmonicMultiplier * pitchModR;
// Add delayed feedback to first oscillator
const fbL = osc === 0 ? feedbackBufferL[feedbackIndex] * params.feedback * 0.3 : 0;
const fbR = osc === 0 ? feedbackBufferR[feedbackIndex] * params.feedback * 0.3 : 0;
// Use bandlimited oscillators
const oscL = this.generateBandlimitedWaveform(
phasesL[osc],
params.waveform,
params.pulseWidth,
freqL,
sampleRate
);
const oscR = this.generateBandlimitedWaveform(
phasesR[osc],
params.waveform,
params.pulseWidth,
freqR,
sampleRate
);
sampleL += (oscL + fbL) * harmonicLevel;
sampleR += (oscR + fbR) * harmonicLevel;
// Update phases with proper wrapping
const phaseIncrementL = TAU * freqL * invSampleRate;
const phaseIncrementR = TAU * freqR * invSampleRate;
phasesL[osc] = (phasesL[osc] + phaseIncrementL) % TAU;
phasesR[osc] = (phasesR[osc] + phaseIncrementR) % TAU;
}
// Normalize with headroom
const normFactor = 0.5 / Math.sqrt(numOscillators);
sampleL *= normFactor;
sampleR *= normFactor;
// Apply soft saturation if needed
if (params.distortion > 0) {
sampleL = this.fastTanh(sampleL * (1 + params.distortion * 3)) * 0.8;
sampleR = this.fastTanh(sampleR * (1 + params.distortion * 3)) * 0.8;
}
// Apply filter with stability checks
if (params.filterType !== FilterType.None) {
const filterFreqMod = params.filterFreq * (1 + params.filterSweepAmount * sweepProgress * 2);
const filterFreqWithLFO = Math.min(filterFreqMod * (1 + lfoL * params.lfoDepth * 0.2), sampleRate * 0.45);
const safeResonance = Math.min(params.filterResonance, 0.98);
[sampleL, this.filterHistoryL1, this.filterHistoryL2] = this.applyStableFilter(
sampleL,
params.filterType,
filterFreqWithLFO,
safeResonance,
sampleRate,
this.filterHistoryL1,
this.filterHistoryL2
);
[sampleR, this.filterHistoryR1, this.filterHistoryR2] = this.applyStableFilter(
sampleR,
params.filterType,
filterFreqWithLFO,
safeResonance,
sampleRate,
this.filterHistoryR1,
this.filterHistoryR2
);
}
// Update feedback delay buffer
feedbackBufferL[feedbackIndex] = sampleL;
feedbackBufferR[feedbackIndex] = sampleR;
feedbackIndex = (feedbackIndex + 1) % feedbackDelaySize;
// DC blocking
const dcCutoff = 0.995;
const blockedL = sampleL - this.dcBlockerL;
const blockedR = sampleR - this.dcBlockerR;
this.dcBlockerL = sampleL - blockedL * dcCutoff;
this.dcBlockerR = sampleR - blockedR * dcCutoff;
// Apply envelope and final limiting
leftBuffer[i] = Math.max(-1, Math.min(1, blockedL * env));
rightBuffer[i] = Math.max(-1, Math.min(1, blockedR * env));
// Update LFO phases
lfoPhaseL = (lfoPhaseL + lfoIncrement) % TAU;
lfoPhaseR = (lfoPhaseR + lfoIncrement) % TAU;
}
return [leftBuffer, rightBuffer];
}
private generateBandlimitedWaveform(
phase: number,
waveform: OscillatorWaveform,
pulseWidth: number,
frequency: number,
sampleRate: number
): number {
const nyquist = sampleRate / 2;
const maxHarmonic = Math.floor(nyquist / frequency);
switch (waveform) {
case OscillatorWaveform.Sine:
return Math.sin(phase);
case OscillatorWaveform.Triangle:
// Bandlimited triangle using additive synthesis
let tri = 0;
const harmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= harmonics; h += 2) {
const sign = ((h - 1) / 2) % 2 === 0 ? 1 : -1;
tri += sign * Math.sin(phase * h) / (h * h);
}
return tri * (8 / (Math.PI * Math.PI));
case OscillatorWaveform.Square:
// Bandlimited square using additive synthesis
let square = 0;
const squareHarmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= squareHarmonics; h += 2) {
square += Math.sin(phase * h) / h;
}
return square * (4 / Math.PI);
case OscillatorWaveform.Saw:
// Bandlimited saw using additive synthesis
let saw = 0;
const sawHarmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= sawHarmonics; h++) {
saw += Math.sin(phase * h) / h;
}
return -saw * (2 / Math.PI);
case OscillatorWaveform.Pulse:
// Bandlimited pulse as difference of two saws
let pulse1 = 0;
let pulse2 = 0;
const pulseHarmonics = Math.min(maxHarmonic, 32);
const phaseShift = phase + Math.PI * 2 * pulseWidth;
for (let h = 1; h <= pulseHarmonics; h++) {
pulse1 += Math.sin(phase * h) / h;
pulse2 += Math.sin(phaseShift * h) / h;
}
return (pulse1 - pulse2) * (2 / Math.PI);
default:
return 0;
}
}
private calculateSweepCurve(t: number, curve: SweepCurve): number {
switch (curve) {
case SweepCurve.Linear:
return t;
case SweepCurve.Exponential:
return t * t;
case SweepCurve.Logarithmic:
return Math.sqrt(t);
case SweepCurve.Bounce:
return t < 0.5 ? t * 2 : 2 - t * 2;
case SweepCurve.Elastic:
const p = 0.3;
const s = p / 4;
if (t <= 0.001) return 0;
if (t >= 0.999) return 1;
return Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1;
default:
return t;
}
}
private calculateEnvelope(
t: number,
duration: number,
attack: number,
decay: number,
sustain: number,
release: number
): number {
const attackTime = attack * duration;
const decayTime = decay * duration;
const releaseTime = release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
// Exponential attack for smoother onset
const progress = t / attackTime;
return progress * progress;
} else if (t < sustainStart) {
const decayProgress = (t - attackTime) / decayTime;
return 1 - decayProgress * (1 - sustain);
} else if (t < releaseStart) {
return sustain;
} else {
const releaseProgress = (t - releaseStart) / releaseTime;
// Exponential release for smoother tail
return sustain * Math.pow(1 - releaseProgress, 2);
}
}
private fastSin(phase: number): number {
// Fast sine approximation using parabolic approximation
const x = (phase % (Math.PI * 2)) / Math.PI - 1;
const x2 = x * x;
return x * (1 - x2 * (0.16666 - x2 * 0.00833));
}
private fastTanh(x: number): number {
// Fast tanh approximation for soft clipping
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
private applyStableFilter(
input: number,
filterType: FilterType,
freq: number,
resonance: number,
sampleRate: number,
history1: number,
history2: number
): [number, number, number] {
// Add denormal prevention
input += this.DENORMAL_OFFSET;
// Improved state-variable filter with pre-warping
const w = Math.tan((Math.PI * freq) / sampleRate);
const g = w / (1 + w);
const k = 2 - 2 * resonance; // Stability-safe resonance scaling
// State variable filter equations
const v0 = input;
const v1 = history1;
const v2 = history2;
const v3 = v0 - v2;
const v1Next = v1 + g * (v3 - k * v1);
const v2Next = v2 + g * v1Next;
let output: number;
switch (filterType) {
case FilterType.LowPass:
output = v2Next;
break;
case FilterType.HighPass:
output = v0 - k * v1Next - v2Next;
break;
case FilterType.BandPass:
output = v1Next;
break;
default:
output = input;
}
// Remove denormal offset
output -= this.DENORMAL_OFFSET;
return [output, v1Next, v2Next];
}
randomParams(): DubSirenParams {
const freqPairs = [
[100, 1200],
[200, 800],
[300, 2000],
[50, 400],
[500, 3000],
[150, 600],
];
const [startFreq, endFreq] = this.randomChoice(freqPairs);
const shouldReverse = Math.random() < 0.3;
return {
startFreq: shouldReverse ? endFreq : startFreq,
endFreq: shouldReverse ? startFreq : endFreq,
sweepCurve: this.randomInt(0, 4) as SweepCurve,
waveform: this.randomInt(0, 4) as OscillatorWaveform,
pulseWidth: this.randomRange(0.1, 0.9),
harmonics: this.randomInt(0, 3),
harmonicSpread: this.randomRange(1.5, 3.0),
lfoRate: this.randomRange(0.5, 8),
lfoDepth: this.randomRange(0, 0.5),
filterType: this.randomInt(0, 3) as FilterType,
filterFreq: this.randomRange(200, 4000),
filterResonance: this.randomRange(0.1, 0.85), // Safer maximum
filterSweepAmount: this.randomRange(0, 1),
attack: this.randomRange(0.005, 0.1), // Minimum 220 samples
decay: this.randomRange(0.01, 0.2),
sustain: this.randomRange(0.3, 0.9),
release: this.randomRange(0.1, 0.4),
feedback: this.randomRange(0, 0.5),
stereoWidth: this.randomRange(0.2, 0.8),
distortion: this.randomRange(0, 0.3),
};
}
mutateParams(params: DubSirenParams, mutationAmount: number = 0.15): DubSirenParams {
return {
startFreq: this.mutateValue(params.startFreq, mutationAmount, 20, 5000),
endFreq: this.mutateValue(params.endFreq, mutationAmount, 20, 5000),
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),
harmonics: Math.random() < 0.15 ? this.randomInt(0, 3) : params.harmonics,
harmonicSpread: this.mutateValue(params.harmonicSpread, mutationAmount, 1, 4),
lfoRate: this.mutateValue(params.lfoRate, mutationAmount, 0.1, 12),
lfoDepth: this.mutateValue(params.lfoDepth, mutationAmount, 0, 0.7),
filterType: Math.random() < 0.1 ? this.randomInt(0, 3) as FilterType : params.filterType,
filterFreq: this.mutateValue(params.filterFreq, mutationAmount, 100, 8000),
filterResonance: this.mutateValue(params.filterResonance, mutationAmount, 0, 0.85),
filterSweepAmount: this.mutateValue(params.filterSweepAmount, mutationAmount, 0, 1),
attack: this.mutateValue(params.attack, mutationAmount, 0.005, 0.2),
decay: this.mutateValue(params.decay, mutationAmount, 0.01, 0.3),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 1),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.5),
feedback: this.mutateValue(params.feedback, mutationAmount, 0, 0.7),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
distortion: this.mutateValue(params.distortion, mutationAmount, 0, 0.5),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
private randomChoice<T>(choices: readonly T[]): T {
return choices[Math.floor(Math.random() * choices.length)];
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = (max - min) * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,527 @@
import type { SynthEngine } from './SynthEngine';
enum EnvCurve {
Linear,
Exponential,
Logarithmic,
SCurve,
}
enum LFOWaveform {
Sine,
Triangle,
Square,
Saw,
SampleHold,
RandomWalk,
}
enum Algorithm {
Cascade, // 1→2→3→4 (deep modulation)
DualStack, // (1→2) + (3→4) (two independent stacks)
Parallel, // 1+2+3+4 (additive)
TripleMod, // 1→2→3 + 4 (complex modulation + pure carrier)
Bell, // (1→3, 2→3) + 4 (two modulators converge)
Feedback, // 1→1→2→3→4 (self-modulation)
}
interface OperatorParams {
ratio: number;
level: number;
attack: number;
decay: number;
sustain: number;
release: number;
attackCurve: EnvCurve;
decayCurve: EnvCurve;
releaseCurve: EnvCurve;
}
interface LFOParams {
rate: number;
depth: number;
waveform: LFOWaveform;
target: 'pitch' | 'amplitude' | 'modIndex';
}
export interface FourOpFMParams {
baseFreq: number;
algorithm: Algorithm;
operators: [OperatorParams, OperatorParams, OperatorParams, OperatorParams];
lfo: LFOParams;
feedback: number;
stereoWidth: number;
}
export class FourOpFM implements SynthEngine<FourOpFMParams> {
private lfoSampleHoldValue = 0;
private lfoSampleHoldPhase = 0;
private lfoRandomWalkCurrent = 0;
private lfoRandomWalkTarget = 0;
// DC blocking filters for each channel
private dcBlockerL = 0;
private dcBlockerR = 0;
private readonly dcBlockerCutoff = 0.995;
getName(): string {
return '4-OP FM';
}
getDescription(): string {
return 'Four-operator FM synthesis with multiple algorithms, envelope curves, and LFO waveforms';
}
generate(params: FourOpFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
// More subtle stereo detuning
const detune = 1 + (params.stereoWidth * 0.001);
const leftFreq = params.baseFreq / detune;
const rightFreq = params.baseFreq * detune;
// Initialize operator phases for stereo with more musical offsets
const opPhasesL = [0, Math.PI * params.stereoWidth * 0.05, 0, 0];
const opPhasesR = [0, Math.PI * params.stereoWidth * 0.08, 0, 0];
let lfoPhaseL = 0;
let lfoPhaseR = Math.PI * params.stereoWidth * 0.25;
// Reset non-periodic LFO state
this.lfoSampleHoldValue = Math.random() * 2 - 1;
this.lfoSampleHoldPhase = 0;
this.lfoRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoRandomWalkTarget = Math.random() * 2 - 1;
let feedbackSampleL = 0;
let feedbackSampleR = 0;
// Get algorithm-specific gain compensation
const gainCompensation = this.getAlgorithmGainCompensation(params.algorithm);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
// Calculate envelopes for each operator
const env1 = this.calculateEnvelope(t, duration, params.operators[0]);
const env2 = this.calculateEnvelope(t, duration, params.operators[1]);
const env3 = this.calculateEnvelope(t, duration, params.operators[2]);
const env4 = this.calculateEnvelope(t, duration, params.operators[3]);
// Generate LFO modulation
const lfoL = this.generateLFO(lfoPhaseL, params.lfo.waveform, params.lfo.rate, sampleRate);
const lfoR = this.generateLFO(lfoPhaseR, params.lfo.waveform, params.lfo.rate, sampleRate);
const lfoModL = lfoL * params.lfo.depth;
const lfoModR = lfoR * params.lfo.depth;
// Apply LFO to target parameter
let pitchModL = 0, pitchModR = 0;
let ampModL = 1, ampModR = 1;
let modIndexMod = 0;
if (params.lfo.target === 'pitch') {
// More musical pitch modulation range
pitchModL = lfoModL * 0.02;
pitchModR = lfoModR * 0.02;
} else if (params.lfo.target === 'amplitude') {
// Tremolo effect
ampModL = 1 + lfoModL * 0.5;
ampModR = 1 + lfoModR * 0.5;
} else {
// Modulation index modulation
modIndexMod = lfoModL;
}
// Process algorithm - generate left and right samples
const [sampleL, sampleR] = this.processAlgorithm(
params.algorithm,
params.operators,
opPhasesL,
opPhasesR,
[env1, env2, env3, env4],
feedbackSampleL,
feedbackSampleR,
params.feedback,
modIndexMod
);
// Apply gain compensation and amplitude modulation
let outL = sampleL * gainCompensation * ampModL;
let outR = sampleR * gainCompensation * ampModR;
// Soft clipping for musical saturation
outL = this.softClip(outL);
outR = this.softClip(outR);
// DC blocking filter
const dcFilteredL = outL - this.dcBlockerL;
this.dcBlockerL += (1 - this.dcBlockerCutoff) * dcFilteredL;
const dcFilteredR = outR - this.dcBlockerR;
this.dcBlockerR += (1 - this.dcBlockerCutoff) * dcFilteredR;
leftBuffer[i] = dcFilteredL;
rightBuffer[i] = dcFilteredR;
// Store feedback samples (after soft clipping)
feedbackSampleL = outL;
feedbackSampleR = outR;
// Advance operator phases
for (let op = 0; op < 4; op++) {
const opFreqL = leftFreq * params.operators[op].ratio * (1 + pitchModL);
const opFreqR = rightFreq * params.operators[op].ratio * (1 + pitchModR);
opPhasesL[op] += (TAU * opFreqL) / sampleRate;
opPhasesR[op] += (TAU * opFreqR) / sampleRate;
// Wrap phases to prevent numerical issues
if (opPhasesL[op] > TAU * 1000) opPhasesL[op] -= TAU * 1000;
if (opPhasesR[op] > TAU * 1000) opPhasesR[op] -= TAU * 1000;
}
// Advance LFO phase
lfoPhaseL += (TAU * params.lfo.rate) / sampleRate;
lfoPhaseR += (TAU * params.lfo.rate) / sampleRate;
}
return [leftBuffer, rightBuffer];
}
private processAlgorithm(
algorithm: Algorithm,
operators: [OperatorParams, OperatorParams, OperatorParams, OperatorParams],
phasesL: number[],
phasesR: number[],
envelopes: number[],
feedbackL: number,
feedbackR: number,
feedbackAmount: number,
modIndexMod: number
): [number, number] {
// More musical modulation scaling
const baseModIndex = 2.5;
const modScale = baseModIndex * (1 + modIndexMod * 2);
switch (algorithm) {
case Algorithm.Cascade: {
// 1→2→3→4 - Deep FM chain
const fbAmountScaled = feedbackAmount * 0.8;
const mod1L = Math.sin(phasesL[0] + fbAmountScaled * feedbackL) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0] + fbAmountScaled * feedbackR) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod3L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const mod3R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const outL = Math.sin(phasesL[3] + modScale * 0.5 * mod3L) * envelopes[3] * operators[3].level;
const outR = Math.sin(phasesR[3] + modScale * 0.5 * mod3R) * envelopes[3] * operators[3].level;
return [outL, outR];
}
case Algorithm.DualStack: {
// (1→2) + (3→4) - Two parallel FM pairs
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const car1L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const car1R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod2L = Math.sin(phasesL[2]) * envelopes[2] * operators[2].level;
const mod2R = Math.sin(phasesR[2]) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3] + modScale * mod2L) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3] + modScale * mod2R) * envelopes[3] * operators[3].level;
// Mix with proper gain staging
return [(car1L + car2L) * 0.5, (car1R + car2R) * 0.5];
}
case Algorithm.Parallel: {
// 1+2+3+4 - Additive synthesis
let sumL = 0, sumR = 0;
for (let i = 0; i < 4; i++) {
sumL += Math.sin(phasesL[i]) * envelopes[i] * operators[i].level;
sumR += Math.sin(phasesR[i]) * envelopes[i] * operators[i].level;
}
// Scale by 1/sqrt(4) for constant power mixing
return [sumL * 0.5, sumR * 0.5];
}
case Algorithm.TripleMod: {
// 1→2→3 + 4 - Complex mod chain plus carrier
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const car1L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const car1R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3]) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3]) * envelopes[3] * operators[3].level;
return [(car1L * 0.7 + car2L * 0.3), (car1R * 0.7 + car2R * 0.3)];
}
case Algorithm.Bell: {
// (1→3, 2→3) + 4 - Bell-like tones
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1]) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1]) * envelopes[1] * operators[1].level;
const car1L = Math.sin(phasesL[2] + modScale * 0.6 * (mod1L + mod2L)) * envelopes[2] * operators[2].level;
const car1R = Math.sin(phasesR[2] + modScale * 0.6 * (mod1R + mod2R)) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3]) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3]) * envelopes[3] * operators[3].level;
return [(car1L + car2L) * 0.5, (car1R + car2R) * 0.5];
}
case Algorithm.Feedback: {
// 1→1→2→3→4 - Self-modulating cascade
const fbAmountScaled = Math.min(feedbackAmount * 0.7, 1.5);
const mod1L = Math.sin(phasesL[0] + fbAmountScaled * this.softClip(feedbackL * 2)) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0] + fbAmountScaled * this.softClip(feedbackR * 2)) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod3L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const mod3R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const outL = Math.sin(phasesL[3] + modScale * 0.5 * mod3L) * envelopes[3] * operators[3].level;
const outR = Math.sin(phasesR[3] + modScale * 0.5 * mod3R) * envelopes[3] * operators[3].level;
return [outL, outR];
}
default:
return [0, 0];
}
}
private getAlgorithmGainCompensation(algorithm: Algorithm): number {
// Compensate for different algorithm output levels
switch (algorithm) {
case Algorithm.Cascade:
case Algorithm.Feedback:
return 0.7;
case Algorithm.DualStack:
case Algorithm.TripleMod:
case Algorithm.Bell:
return 0.8;
case Algorithm.Parallel:
return 0.6;
default:
return 0.75;
}
}
private softClip(x: number): number {
// Musical soft clipping using tanh approximation
const absX = Math.abs(x);
if (absX < 0.7) return x;
if (absX > 3) return Math.sign(x) * 0.98;
// Fast tanh approximation for soft saturation
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
private calculateEnvelope(t: number, duration: number, op: OperatorParams): number {
const attackTime = op.attack * duration;
const decayTime = op.decay * duration;
const releaseTime = op.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
const progress = t / attackTime;
return this.applyCurve(progress, op.attackCurve);
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
const curvedProgress = this.applyCurve(progress, op.decayCurve);
return 1 - curvedProgress * (1 - op.sustain);
} else if (t < releaseStart) {
return op.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
const curvedProgress = this.applyCurve(progress, op.releaseCurve);
return op.sustain * (1 - curvedProgress);
}
}
private applyCurve(progress: number, curve: EnvCurve): number {
switch (curve) {
case EnvCurve.Linear:
return progress;
case EnvCurve.Exponential:
return Math.pow(progress, 3);
case EnvCurve.Logarithmic:
return Math.pow(progress, 0.33);
case EnvCurve.SCurve:
return (Math.sin((progress - 0.5) * Math.PI) + 1) / 2;
default:
return progress;
}
}
private generateLFO(phase: number, waveform: LFOWaveform, rate: number, sampleRate: number): number {
const normalizedPhase = (phase % (Math.PI * 2)) / (Math.PI * 2);
switch (waveform) {
case LFOWaveform.Sine:
return Math.sin(phase);
case LFOWaveform.Triangle:
return normalizedPhase < 0.5
? normalizedPhase * 4 - 1
: 3 - normalizedPhase * 4;
case LFOWaveform.Square:
return normalizedPhase < 0.5 ? 1 : -1;
case LFOWaveform.Saw:
return normalizedPhase * 2 - 1;
case LFOWaveform.SampleHold: {
const cyclesSinceLastHold = phase - this.lfoSampleHoldPhase;
if (cyclesSinceLastHold >= Math.PI * 2) {
this.lfoSampleHoldValue = Math.random() * 2 - 1;
this.lfoSampleHoldPhase = phase;
}
return this.lfoSampleHoldValue;
}
case LFOWaveform.RandomWalk: {
const interpolationSpeed = rate / sampleRate * 20;
const diff = this.lfoRandomWalkTarget - this.lfoRandomWalkCurrent;
this.lfoRandomWalkCurrent += diff * interpolationSpeed;
if (Math.abs(diff) < 0.01) {
this.lfoRandomWalkTarget = Math.random() * 2 - 1;
}
return this.lfoRandomWalkCurrent;
}
default:
return 0;
}
}
randomParams(): 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);
return {
baseFreq,
algorithm,
operators: [
this.randomOperator(true, algorithm),
this.randomOperator(false, algorithm),
this.randomOperator(false, algorithm),
this.randomOperator(false, algorithm),
],
lfo: {
rate: this.randomRange(0.1, 12),
depth: this.randomRange(0, 0.5),
waveform: this.randomInt(0, 5) as LFOWaveform,
target: this.randomChoice(['pitch', 'amplitude', 'modIndex'] as const),
},
feedback: this.randomRange(0, 1.5),
stereoWidth: this.randomRange(0.2, 0.8),
};
}
private randomOperator(isCarrier: boolean, algorithm: Algorithm): OperatorParams {
// More musical ratio choices including inharmonic ones
const harmonicRatios = [0.5, 1, 2, 3, 4, 5, 6, 7, 8];
const inharmonicRatios = [1.414, 1.732, 2.236, 3.14, 4.19, 5.13, 6.28];
const bellRatios = [0.56, 0.92, 1.19, 1.71, 2, 2.74, 3, 3.76, 4.07];
let ratio: number;
if (algorithm === Algorithm.Bell && Math.random() < 0.7) {
ratio = this.randomChoice(bellRatios);
} else if (Math.random() < 0.3) {
ratio = this.randomChoice(inharmonicRatios);
} else {
ratio = this.randomChoice(harmonicRatios);
}
// Add slight detuning for richness
ratio *= this.randomRange(0.998, 1.002);
// Carriers typically have lower levels in FM
const levelRange = isCarrier ? [0.3, 0.7] : [0.2, 0.8];
return {
ratio,
level: this.randomRange(levelRange[0], levelRange[1]),
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.02, 0.25),
sustain: this.randomRange(0.1, 0.8),
release: this.randomRange(0.05, 0.4),
attackCurve: this.randomInt(0, 3) as EnvCurve,
decayCurve: this.randomInt(0, 3) as EnvCurve,
releaseCurve: this.randomInt(0, 3) as EnvCurve,
};
}
mutateParams(params: FourOpFMParams, mutationAmount: number = 0.15): FourOpFMParams {
return {
baseFreq: params.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)
) as [OperatorParams, OperatorParams, OperatorParams, OperatorParams],
lfo: {
rate: this.mutateValue(params.lfo.rate, mutationAmount, 0.1, 20),
depth: this.mutateValue(params.lfo.depth, mutationAmount, 0, 0.7),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfo.waveform,
target: Math.random() < 0.08 ? this.randomChoice(['pitch', 'amplitude', 'modIndex'] as const) : params.lfo.target,
},
feedback: this.mutateValue(params.feedback, mutationAmount, 0, 2),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
};
}
private mutateOperator(op: OperatorParams, amount: number, isCarrier: boolean, algorithm: Algorithm): OperatorParams {
const harmonicRatios = [0.5, 1, 2, 3, 4, 5, 6, 7, 8];
const inharmonicRatios = [1.414, 1.732, 2.236, 3.14, 4.19, 5.13, 6.28];
const bellRatios = [0.56, 0.92, 1.19, 1.71, 2, 2.74, 3, 3.76, 4.07];
let newRatio = op.ratio;
if (Math.random() < 0.12) {
if (algorithm === Algorithm.Bell && Math.random() < 0.7) {
newRatio = this.randomChoice(bellRatios);
} else if (Math.random() < 0.3) {
newRatio = this.randomChoice(inharmonicRatios);
} else {
newRatio = this.randomChoice(harmonicRatios);
}
newRatio *= this.randomRange(0.998, 1.002);
}
return {
ratio: newRatio,
level: this.mutateValue(op.level, amount, 0.1, isCarrier ? 0.8 : 1.0),
attack: this.mutateValue(op.attack, amount, 0.001, 0.25),
decay: this.mutateValue(op.decay, amount, 0.01, 0.4),
sustain: this.mutateValue(op.sustain, amount, 0.05, 0.95),
release: this.mutateValue(op.release, amount, 0.02, 0.6),
attackCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.attackCurve,
decayCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.decayCurve,
releaseCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.releaseCurve,
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
private randomChoice<T>(choices: readonly T[]): T {
return choices[Math.floor(Math.random() * choices.length)];
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,454 @@
import type { SynthEngine } from './SynthEngine';
interface NoiseDrumParams {
// Noise characteristics
noiseColor: number;
noiseBurst: number;
burstCount: number;
// Filter section
filterFreq: number;
filterQ: number;
filterType: number;
filterEnvAmount: number;
filterEnvSpeed: number;
// Amplitude envelope
ampAttack: number;
ampDecay: number;
ampPunch: number;
// Pitch section (for tonal components)
pitchAmount: number;
pitchStart: number;
pitchDecay: number;
// Body resonance (adds tonal character)
bodyFreq: number;
bodyDecay: number;
bodyAmount: number;
// Noise modulation
noiseMod: number;
noiseModRate: number;
// Stereo and character
stereoSpread: number;
drive: number;
}
export class NoiseDrum implements SynthEngine {
getName(): string {
return 'Noise Drum';
}
getDescription(): string {
return 'Versatile noise-based percussion synthesizer inspired by classic drum machines';
}
randomParams(): NoiseDrumParams {
// Intelligently bias parameter ranges to create diverse percussion types
const filterBias = Math.random();
const decayBias = Math.random();
const tonalBias = Math.random();
return {
// Noise characteristics - varied colors and burst patterns
noiseColor: Math.random(),
noiseBurst: Math.random() * 0.7, // probability of burst pattern
burstCount: Math.random(), // 1-4 bursts
// Filter section - wide range from sub to high frequencies
filterFreq: filterBias < 0.3 ? Math.random() * 0.25 : // bass drum range
filterBias < 0.6 ? 0.25 + Math.random() * 0.35 : // snare/tom range
0.6 + Math.random() * 0.4, // hi-hat/cymbal range
filterQ: Math.random() * 0.85,
filterType: Math.random(), // lowpass, bandpass, highpass blend
filterEnvAmount: Math.random() * 0.7, // reduced from 0.9
filterEnvSpeed: 0.1 + Math.random() * 0.4, // min increased from 0.05
// Amplitude envelope - SHORT ATTACK for percussion character
ampAttack: Math.random() < 0.8 ? Math.random() * 0.03 : Math.random() * 0.08, // max 8%, not 25%
ampDecay: decayBias < 0.3 ? 0.15 + Math.random() * 0.25 : // short (hi-hat) - min increased
decayBias < 0.7 ? 0.35 + Math.random() * 0.25 : // medium (snare)
0.55 + Math.random() * 0.35, // long (cymbal/tom) - ensures sound continues
ampPunch: Math.random() * 0.7, // initial transient boost
// Pitch section - REDUCED for subtle tonal accent, not dominant tone
pitchAmount: tonalBias > 0.6 ? Math.random() * 0.35 : Math.random() * 0.15, // max 0.35, not 0.7
pitchStart: 0.3 + Math.random() * 0.7, // start higher in range (200-800Hz)
pitchDecay: 0.03 + Math.random() * 0.12, // slightly faster decay
// Body resonance - adds character and depth
bodyFreq: Math.random(),
bodyDecay: 0.2 + Math.random() * 0.45, // min increased
bodyAmount: Math.random() * 0.5, // reduced from 0.6
// Noise modulation - rhythmic variation
noiseMod: Math.random() * 0.5, // reduced from 0.6
noiseModRate: Math.random(),
// Stereo and character
stereoSpread: Math.random() * 0.4, // reduced from 0.45
drive: Math.random() * 0.45 // reduced from 0.5
};
}
mutateParams(params: NoiseDrumParams): NoiseDrumParams {
const mutate = (value: number, amount: number = 0.15): number => {
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
};
return {
noiseColor: mutate(params.noiseColor, 0.25),
noiseBurst: mutate(params.noiseBurst, 0.2),
burstCount: mutate(params.burstCount, 0.3),
filterFreq: mutate(params.filterFreq, 0.2),
filterQ: mutate(params.filterQ, 0.2),
filterType: mutate(params.filterType, 0.25),
filterEnvAmount: mutate(params.filterEnvAmount, 0.2),
filterEnvSpeed: mutate(params.filterEnvSpeed, 0.2),
ampAttack: mutate(params.ampAttack, 0.12),
ampDecay: mutate(params.ampDecay, 0.2),
ampPunch: mutate(params.ampPunch, 0.2),
pitchAmount: mutate(params.pitchAmount, 0.2),
pitchStart: mutate(params.pitchStart, 0.25),
pitchDecay: mutate(params.pitchDecay, 0.2),
bodyFreq: mutate(params.bodyFreq, 0.25),
bodyDecay: mutate(params.bodyDecay, 0.2),
bodyAmount: mutate(params.bodyAmount, 0.2),
noiseMod: mutate(params.noiseMod, 0.2),
noiseModRate: mutate(params.noiseModRate, 0.25),
stereoSpread: mutate(params.stereoSpread, 0.15),
drive: mutate(params.drive, 0.15)
};
}
generate(params: NoiseDrumParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const left = new Float32Array(numSamples);
const right = new Float32Array(numSamples);
const attackSamples = Math.floor(params.ampAttack * duration * sampleRate);
const decaySamples = Math.floor(params.ampDecay * duration * sampleRate);
// Filter frequency range: 40Hz to 10kHz (reduced from 12kHz for less harshness)
const baseFilterFreq = 40 + params.filterFreq * 9960;
// Q range: 0.5 to 10 (reduced from 12 for stability)
const filterQ = 0.5 + params.filterQ * 9.5;
// Pitch envelope: SWEEP DOWN from high to low (classic 808/909 style)
const pitchStartFreq = 60 + params.pitchStart * 540; // 60Hz to 600Hz (not 800Hz)
const pitchDecayTime = 0.01 + params.pitchDecay * 0.12;
// Body resonance frequency (60Hz to 500Hz) - reduced upper range
const bodyFreq = 60 + params.bodyFreq * 440;
const bodyDecayTime = 0.08 + params.bodyDecay * 0.4;
// Noise burst parameters - FIXED: burst envelope shouldn't kill the whole sound
const shouldBurst = params.noiseBurst > 0.3;
const numBursts = shouldBurst ? Math.floor(1 + params.burstCount * 3) : 1;
const burstIntensity = shouldBurst ? 0.5 + params.noiseBurst * 0.5 : 0; // how much burst affects sound
// Noise modulation rate (3Hz to 50Hz) - increased minimum
const noiseModFreq = 3 + params.noiseModRate * 47;
for (let channel = 0; channel < 2; channel++) {
const output = channel === 0 ? left : right;
// Stereo spread affects multiple parameters
const spreadFactor = channel === 0 ? 1 - params.stereoSpread * 0.15 : 1 + params.stereoSpread * 0.15;
const spreadPhase = channel === 0 ? 0 : params.stereoSpread * 0.08;
// Noise generator state
const pinkState = new Float32Array(7);
let brownState = 0;
// Filter state (main filter)
let filterState1 = 0;
let filterState2 = 0;
// Body resonance filter state
let bodyState1 = 0;
let bodyState2 = 0;
// DC blocker state
let dcBlockerX = 0;
let dcBlockerY = 0;
// Pitch oscillator phase
let pitchPhase = Math.random() * Math.PI * 2; // random start phase
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const phase = i / numSamples;
// Burst envelope for clap/layered sounds - FIXED: doesn't kill the sound
let burstEnv = 1.0;
if (shouldBurst && numBursts > 1 && burstIntensity > 0) {
let burstSum = 0;
const burstDuration = 0.04; // fixed short duration per burst
const burstSpan = Math.min(duration * 0.15, 0.12); // bursts span 15% of duration, max 120ms
for (let b = 0; b < numBursts; b++) {
const burstStart = (b / numBursts) * burstSpan + spreadPhase * 0.01;
const burstTime = t - burstStart;
if (burstTime >= 0 && burstTime < burstDuration) {
const burstPhase = burstTime / burstDuration;
const burstAmp = Math.exp(-burstPhase * 25) * (1 - b * 0.25 / numBursts);
burstSum += burstAmp;
}
}
// Blend between continuous (1.0) and burst (burstSum)
burstEnv = (1 - burstIntensity) + burstIntensity * Math.min(burstSum, 1.5);
}
// Generate noise
const whiteNoise = Math.random() * 2 - 1;
brownState = this.updateBrownState(brownState, whiteNoise);
const noise = this.selectNoiseColor(params.noiseColor, whiteNoise, pinkState, brownState);
// Noise modulation for texture variation
const noiseMod = Math.sin(2 * Math.PI * noiseModFreq * t);
const noiseModAmount = 1 - params.noiseMod * (noiseMod * 0.5 + 0.5) * 0.6;
const modulatedNoise = noise * noiseModAmount;
// Amplitude envelope
let ampEnv = this.amplitudeEnvelope(i, attackSamples, decaySamples, numSamples);
// Add punch (transient boost)
if (i < attackSamples * 3) {
const punchPhase = i / (attackSamples * 3);
const punchBoost = Math.exp(-punchPhase * 12) * params.ampPunch * 0.25;
ampEnv = ampEnv * (1 + punchBoost);
}
// Apply burst envelope
ampEnv *= burstEnv;
// Exponential pitch envelope - SWEEP DOWN (classic drum pitch behavior)
const pitchEnv = Math.exp(-phase / pitchDecayTime);
const currentPitchFreq = pitchStartFreq * (0.3 + pitchEnv * 0.7); // sweeps DOWN from start to 30% of start
// Generate tonal component (sine oscillator with pitch envelope)
const pitchIncrement = (2 * Math.PI * currentPitchFreq) / sampleRate;
pitchPhase += pitchIncrement;
const tonal = Math.sin(pitchPhase) * params.pitchAmount * ampEnv;
// Combine noise and tonal - NOISE DOMINANT
// At max pitchAmount (0.35), noise stays at ~0.88, tonal at 0.35
// This ensures noise is ALWAYS the primary component
const combined = modulatedNoise * (1 - params.pitchAmount * 0.35) + tonal;
// Filter envelope (exponential decay for filter sweep)
const filterEnv = Math.exp(-phase / (0.02 + params.filterEnvSpeed * 0.25));
// Modulated filter frequency - REDUCED sweep range
const freqMod = 1 + params.filterEnvAmount * filterEnv * 2; // reduced from 3x to 2x
const modulatedFreq = Math.min(
baseFilterFreq * spreadFactor * freqMod,
sampleRate * 0.45
);
// Apply main filter with type blending
const filtered = this.applyFilterBlend(
combined,
modulatedFreq,
filterQ,
params.filterType,
sampleRate,
filterState1,
filterState2
);
filterState1 = filtered.state1;
filterState2 = filtered.state2;
// Body resonance (adds tonal character like drum shell resonance)
let sample = filtered.output;
if (params.bodyAmount > 0.05) {
const bodyEnv = Math.exp(-phase / bodyDecayTime);
const bodyFiltered = this.stateVariableFilter(
sample,
bodyFreq * spreadFactor,
6 + params.bodyAmount * 10, // reduced resonance range
sampleRate,
bodyState1,
bodyState2
);
bodyState1 = bodyFiltered.state1;
bodyState2 = bodyFiltered.state2;
// Blend body resonance - SUBTLE
sample = sample * (1 - params.bodyAmount * 0.4) +
bodyFiltered.output * params.bodyAmount * 0.6 * bodyEnv;
}
// Apply amplitude envelope
sample *= ampEnv;
// Drive/saturation for more aggressive sounds
if (params.drive > 0.1) {
const driveAmount = 1 + params.drive * 0.8; // reduced from 1.0
sample = this.softClip(sample * driveAmount) / driveAmount;
}
// DC blocking filter
const dcBlocked = this.dcBlocker(sample, dcBlockerX, dcBlockerY);
dcBlockerX = sample;
dcBlockerY = dcBlocked.y;
sample = dcBlocked.output;
// Final output scaling
output[i] = this.softClip(sample * 0.95);
}
}
return [left, right];
}
private updateBrownState(brownState: number, whiteNoise: number): number {
return (brownState + whiteNoise * 0.02) * 0.98;
}
private selectNoiseColor(
colorParam: number,
whiteNoise: number,
pinkState: Float32Array,
brownState: number
): number {
if (colorParam < 0.33) {
return whiteNoise;
} else if (colorParam < 0.66) {
// Pink noise using Paul Kellett's filter
pinkState[0] = 0.99886 * pinkState[0] + whiteNoise * 0.0555179;
pinkState[1] = 0.99332 * pinkState[1] + whiteNoise * 0.0750759;
pinkState[2] = 0.96900 * pinkState[2] + whiteNoise * 0.1538520;
pinkState[3] = 0.86650 * pinkState[3] + whiteNoise * 0.3104856;
pinkState[4] = 0.55000 * pinkState[4] + whiteNoise * 0.5329522;
pinkState[5] = -0.7616 * pinkState[5] - whiteNoise * 0.0168980;
const pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3] +
pinkState[4] + pinkState[5] + pinkState[6] + whiteNoise * 0.5362;
pinkState[6] = whiteNoise * 0.115926;
return pink * 0.11;
} else {
return brownState * 2.5;
}
}
private amplitudeEnvelope(
sample: number,
attackSamples: number,
decaySamples: number,
totalSamples: number
): number {
if (sample < attackSamples && attackSamples > 0) {
const attackPhase = sample / attackSamples;
return attackPhase * attackPhase * (3 - 2 * attackPhase);
} else {
const decayStart = attackSamples;
const decayLength = totalSamples - attackSamples;
const decayPhase = (sample - decayStart) / decayLength;
const decayRate = Math.max(decaySamples / totalSamples, 0.05); // increased minimum from 0.001
return Math.exp(-decayPhase / decayRate);
}
}
private stateVariableFilter(
input: number,
cutoff: number,
resonance: number,
sampleRate: number,
state1: number,
state2: number
): { output: number; state1: number; state2: number } {
const normalizedFreq = Math.min(cutoff / sampleRate, 0.48);
const f = 2 * Math.sin(Math.PI * normalizedFreq);
const q = Math.max(1 / Math.min(resonance, 15), 0.01);
const lowpass = state2 + f * state1;
const highpass = input - lowpass - q * state1;
const bandpass = f * highpass + state1;
const newState1 = Math.abs(bandpass) > 1e-10 ? bandpass : 0;
const newState2 = Math.abs(lowpass) > 1e-10 ? lowpass : 0;
return {
output: bandpass,
state1: newState1,
state2: newState2
};
}
private applyFilterBlend(
input: number,
cutoff: number,
resonance: number,
filterType: number,
sampleRate: number,
state1: number,
state2: number
): { output: number; state1: number; state2: number } {
const normalizedFreq = Math.min(cutoff / sampleRate, 0.48);
const f = 2 * Math.sin(Math.PI * normalizedFreq);
const q = Math.max(1 / Math.min(resonance, 15), 0.01);
const lowpass = state2 + f * state1;
const highpass = input - lowpass - q * state1;
const bandpass = f * highpass + state1;
const newState1 = Math.abs(bandpass) > 1e-10 ? bandpass : 0;
const newState2 = Math.abs(lowpass) > 1e-10 ? lowpass : 0;
// Blend between filter types based on filterType parameter
let output: number;
if (filterType < 0.33) {
// Lowpass
output = lowpass;
} else if (filterType < 0.66) {
// Bandpass
output = bandpass;
} else {
// Highpass
output = highpass;
}
return {
output,
state1: newState1,
state2: newState2
};
}
private dcBlocker(input: number, prevX: number, prevY: number): { output: number; y: number } {
const y = input - prevX + 0.995 * prevY;
return { output: y, y };
}
private softClip(x: number): number {
if (x > 1) {
return 1;
} else if (x < -1) {
return -1;
} else if (x > 0.66) {
return (3 - (2 - 3 * x) ** 2) / 3;
} else if (x < -0.66) {
return -(3 - (2 - 3 * -x) ** 2) / 3;
} else {
return x;
}
}
}

View File

@ -0,0 +1,505 @@
import type { SynthEngine } from './SynthEngine';
enum LFOWaveform {
Sine,
Triangle,
Square,
Saw,
SampleHold,
RandomWalk,
}
enum EnvCurve {
Linear,
Exponential,
Logarithmic,
}
interface RingEnvelope {
attack: number;
decay: number;
sustain: number;
release: number;
curve: EnvCurve;
}
interface RingLFO {
rate: number;
depth: number;
waveform: LFOWaveform;
}
export interface RingParams {
carrierFreq: number;
modulatorFreq: number;
secondModulatorFreq: number;
carrierLevel: number;
modulatorLevel: number;
secondModulatorLevel: number;
dryWet: number;
dryWetEvolution: number;
envelope: RingEnvelope;
lfoAmp: RingLFO;
lfoPitch: RingLFO;
lfoMix: RingLFO;
feedback: number;
harmonics: number;
stereoWidth: number;
}
export class Ring implements SynthEngine<RingParams> {
private lfoAmpSampleHoldValue = 0;
private lfoAmpSampleHoldPhase = 0;
private lfoAmpRandomWalkCurrent = 0;
private lfoAmpRandomWalkTarget = 0;
private lfoPitchSampleHoldValue = 0;
private lfoPitchSampleHoldPhase = 0;
private lfoPitchRandomWalkCurrent = 0;
private lfoPitchRandomWalkTarget = 0;
private lfoMixSampleHoldValue = 0;
private lfoMixSampleHoldPhase = 0;
private lfoMixRandomWalkCurrent = 0;
private lfoMixRandomWalkTarget = 0;
private lfoAmpPrevValue = 0;
private lfoPitchPrevValue = 0;
private lfoMixPrevValue = 0;
private dcBlockerX1L = 0;
private dcBlockerX1R = 0;
private dcBlockerY1L = 0;
private dcBlockerY1R = 0;
getName(): string {
return 'Ring';
}
getDescription(): string {
return 'Complex ring modulator with dual modulators, multiple LFOs, feedback, and evolving timbres';
}
generate(params: RingParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const detune = 1 + (params.stereoWidth * 0.003);
const carrierFreqL = params.carrierFreq / detune;
const carrierFreqR = params.carrierFreq * detune;
const modulatorFreqL = params.modulatorFreq / detune;
const modulatorFreqR = params.modulatorFreq * detune;
const secondModFreqL = params.secondModulatorFreq / (detune * 1.1);
const secondModFreqR = params.secondModulatorFreq * (detune * 1.1);
let carrierPhaseL = 0;
let carrierPhaseR = Math.PI * params.stereoWidth * 0.1;
let modulatorPhaseL = 0;
let modulatorPhaseR = Math.PI * params.stereoWidth * 0.15;
let secondModPhaseL = Math.PI * params.stereoWidth * 0.08;
let secondModPhaseR = Math.PI * params.stereoWidth * 0.22;
let lfoAmpPhaseL = 0;
let lfoAmpPhaseR = Math.PI * params.stereoWidth * 0.2;
let lfoPitchPhaseL = Math.PI * 0.3;
let lfoPitchPhaseR = Math.PI * 0.7;
let lfoMixPhaseL = Math.PI * 0.5;
let lfoMixPhaseR = Math.PI * 0.9;
this.lfoAmpSampleHoldValue = Math.random() * 2 - 1;
this.lfoAmpSampleHoldPhase = 0;
this.lfoAmpRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoAmpRandomWalkTarget = Math.random() * 2 - 1;
this.lfoPitchSampleHoldValue = Math.random() * 2 - 1;
this.lfoPitchSampleHoldPhase = 0;
this.lfoPitchRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoPitchRandomWalkTarget = Math.random() * 2 - 1;
this.lfoMixSampleHoldValue = Math.random() * 2 - 1;
this.lfoMixSampleHoldPhase = 0;
this.lfoMixRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoMixRandomWalkTarget = Math.random() * 2 - 1;
this.lfoAmpPrevValue = 0;
this.lfoPitchPrevValue = 0;
this.lfoMixPrevValue = 0;
this.dcBlockerX1L = 0;
this.dcBlockerX1R = 0;
this.dcBlockerY1L = 0;
this.dcBlockerY1R = 0;
let feedbackL = 0;
let feedbackR = 0;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const progress = t / duration;
const envelope = this.calculateEnvelope(t, duration, params.envelope);
const lfoAmpLRaw = this.generateLFO(lfoAmpPhaseL, params.lfoAmp.waveform, params.lfoAmp.rate, sampleRate, 'amp');
const lfoAmpRRaw = this.generateLFO(lfoAmpPhaseR, params.lfoAmp.waveform, params.lfoAmp.rate, sampleRate, 'amp');
const lfoPitchLRaw = this.generateLFO(lfoPitchPhaseL, params.lfoPitch.waveform, params.lfoPitch.rate, sampleRate, 'pitch');
const lfoPitchRRaw = this.generateLFO(lfoPitchPhaseR, params.lfoPitch.waveform, params.lfoPitch.rate, sampleRate, 'pitch');
const lfoMixLRaw = this.generateLFO(lfoMixPhaseL, params.lfoMix.waveform, params.lfoMix.rate, sampleRate, 'mix');
const lfoMixRRaw = this.generateLFO(lfoMixPhaseR, params.lfoMix.waveform, params.lfoMix.rate, sampleRate, 'mix');
const lfoSmoothing = 0.998;
const lfoAmpAvg = (lfoAmpLRaw + lfoAmpRRaw) * 0.5;
const lfoPitchAvg = (lfoPitchLRaw + lfoPitchRRaw) * 0.5;
const lfoMixAvg = (lfoMixLRaw + lfoMixRRaw) * 0.5;
this.lfoAmpPrevValue = this.lfoAmpPrevValue * lfoSmoothing + lfoAmpAvg * (1 - lfoSmoothing);
this.lfoPitchPrevValue = this.lfoPitchPrevValue * lfoSmoothing + lfoPitchAvg * (1 - lfoSmoothing);
this.lfoMixPrevValue = this.lfoMixPrevValue * lfoSmoothing + lfoMixAvg * (1 - lfoSmoothing);
const lfoAmpL = this.lfoAmpPrevValue;
const lfoAmpR = this.lfoAmpPrevValue;
const lfoPitchL = this.lfoPitchPrevValue;
const lfoPitchR = this.lfoPitchPrevValue;
const mixMod = this.lfoMixPrevValue * params.lfoMix.depth;
const ampModL = Math.max(0.2, 1 + lfoAmpL * params.lfoAmp.depth * 0.5);
const ampModR = Math.max(0.2, 1 + lfoAmpR * params.lfoAmp.depth * 0.5);
const pitchModL = Math.max(0.5, Math.min(1.5, 1 + lfoPitchL * params.lfoPitch.depth * 0.05));
const pitchModR = Math.max(0.5, Math.min(1.5, 1 + lfoPitchR * params.lfoPitch.depth * 0.05));
const currentDryWet = params.dryWet + (progress * params.dryWetEvolution * (1 - params.dryWet));
const effectiveDryWet = Math.max(0, Math.min(1, currentDryWet + mixMod * 0.3));
const feedbackAmount = params.feedback * 0.6;
const feedbackLLimited = this.softClip(feedbackL) * feedbackAmount;
const feedbackRLimited = this.softClip(feedbackR) * feedbackAmount;
let carrierL = Math.sin(carrierPhaseL + feedbackLLimited * Math.PI) * params.carrierLevel;
let carrierR = Math.sin(carrierPhaseR + feedbackRLimited * Math.PI) * params.carrierLevel;
if (params.harmonics > 0.1) {
const harmonicGain = params.harmonics * 0.25;
carrierL += Math.sin(carrierPhaseL * 2) * params.carrierLevel * harmonicGain;
carrierL += Math.sin(carrierPhaseL * 3) * params.carrierLevel * harmonicGain * 0.5;
carrierR += Math.sin(carrierPhaseR * 2) * params.carrierLevel * harmonicGain;
carrierR += Math.sin(carrierPhaseR * 3) * params.carrierLevel * harmonicGain * 0.5;
const harmonicNorm = 1 / (1 + params.harmonics * 0.375);
carrierL *= harmonicNorm;
carrierR *= harmonicNorm;
}
const modulatorL = Math.sin(modulatorPhaseL) * params.modulatorLevel;
const modulatorR = Math.sin(modulatorPhaseR) * params.modulatorLevel;
const secondModL = Math.sin(secondModPhaseL) * params.secondModulatorLevel;
const secondModR = Math.sin(secondModPhaseR) * params.secondModulatorLevel;
const ring1L = carrierL * modulatorL;
const ring1R = carrierR * modulatorR;
const ring2L = carrierL * secondModL;
const ring2R = carrierR * secondModR;
const doubleRingL = ring1L * secondModL * 0.4;
const doubleRingR = ring1R * secondModR * 0.4;
const complexRingL = (ring1L * 0.5 + ring2L * 0.3 + doubleRingL * 0.2) * ampModL;
const complexRingR = (ring1R * 0.5 + ring2R * 0.3 + doubleRingR * 0.2) * ampModR;
const dryL = carrierL * (1 - effectiveDryWet);
const dryR = carrierR * (1 - effectiveDryWet);
const wetL = complexRingL * effectiveDryWet;
const wetR = complexRingR * effectiveDryWet;
let outL = (dryL + wetL) * envelope * 0.5;
let outR = (dryR + wetR) * envelope * 0.5;
outL = this.dcBlocker(outL, 'L');
outR = this.dcBlocker(outR, 'R');
outL = this.softClip(outL * 1.2);
outR = this.softClip(outR * 1.2);
leftBuffer[i] = outL;
rightBuffer[i] = outR;
feedbackL = outL;
feedbackR = outR;
carrierPhaseL += (TAU * carrierFreqL * pitchModL) / sampleRate;
carrierPhaseR += (TAU * carrierFreqR * pitchModR) / sampleRate;
modulatorPhaseL += (TAU * modulatorFreqL) / sampleRate;
modulatorPhaseR += (TAU * modulatorFreqR) / sampleRate;
secondModPhaseL += (TAU * secondModFreqL) / sampleRate;
secondModPhaseR += (TAU * secondModFreqR) / sampleRate;
lfoAmpPhaseL += (TAU * params.lfoAmp.rate) / sampleRate;
lfoAmpPhaseR += (TAU * params.lfoAmp.rate) / sampleRate;
lfoPitchPhaseL += (TAU * params.lfoPitch.rate) / sampleRate;
lfoPitchPhaseR += (TAU * params.lfoPitch.rate) / sampleRate;
lfoMixPhaseL += (TAU * params.lfoMix.rate) / sampleRate;
lfoMixPhaseR += (TAU * params.lfoMix.rate) / sampleRate;
if (carrierPhaseL > TAU * 1000) carrierPhaseL -= TAU * 1000;
if (carrierPhaseR > TAU * 1000) carrierPhaseR -= TAU * 1000;
if (modulatorPhaseL > TAU * 1000) modulatorPhaseL -= TAU * 1000;
if (modulatorPhaseR > TAU * 1000) modulatorPhaseR -= TAU * 1000;
if (secondModPhaseL > TAU * 1000) secondModPhaseL -= TAU * 1000;
if (secondModPhaseR > TAU * 1000) secondModPhaseR -= TAU * 1000;
}
return [leftBuffer, rightBuffer];
}
private calculateEnvelope(t: number, duration: number, env: RingEnvelope): number {
const attackTime = env.attack * duration;
const decayTime = env.decay * duration;
const releaseTime = env.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
const progress = t / attackTime;
return this.applyCurve(progress, env.curve);
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
const curvedProgress = this.applyCurve(progress, env.curve);
return 1 - curvedProgress * (1 - env.sustain);
} else if (t < releaseStart) {
return env.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
const curvedProgress = this.applyCurve(progress, env.curve);
return env.sustain * (1 - curvedProgress);
}
}
private applyCurve(progress: number, curve: EnvCurve): number {
switch (curve) {
case EnvCurve.Linear:
return progress;
case EnvCurve.Exponential:
return Math.pow(progress, 3);
case EnvCurve.Logarithmic:
return Math.pow(progress, 0.33);
default:
return progress;
}
}
private softClip(x: number): number {
if (x > 1) {
return 1 - Math.exp(-(x - 1));
} else if (x < -1) {
return -1 + Math.exp(x + 1);
}
return x;
}
private dcBlocker(input: number, channel: 'L' | 'R'): number {
const R = 0.995;
if (channel === 'L') {
const output = input - this.dcBlockerX1L + R * this.dcBlockerY1L;
this.dcBlockerX1L = input;
this.dcBlockerY1L = output;
return output;
} else {
const output = input - this.dcBlockerX1R + R * this.dcBlockerY1R;
this.dcBlockerX1R = input;
this.dcBlockerY1R = output;
return output;
}
}
private generateLFO(phase: number, waveform: LFOWaveform, rate: number, sampleRate: number, lfoType: 'amp' | 'pitch' | 'mix'): number {
const normalizedPhase = (phase % (Math.PI * 2)) / (Math.PI * 2);
switch (waveform) {
case LFOWaveform.Sine:
return Math.sin(phase);
case LFOWaveform.Triangle:
return normalizedPhase < 0.5
? normalizedPhase * 4 - 1
: 3 - normalizedPhase * 4;
case LFOWaveform.Square:
return normalizedPhase < 0.5 ? 1 : -1;
case LFOWaveform.Saw:
return normalizedPhase * 2 - 1;
case LFOWaveform.SampleHold: {
if (lfoType === 'amp') {
const cyclesSinceLastHold = phase - this.lfoAmpSampleHoldPhase;
if (cyclesSinceLastHold >= Math.PI * 2) {
this.lfoAmpSampleHoldValue = Math.random() * 2 - 1;
this.lfoAmpSampleHoldPhase = phase;
}
return this.lfoAmpSampleHoldValue;
} else if (lfoType === 'pitch') {
const cyclesSinceLastHold = phase - this.lfoPitchSampleHoldPhase;
if (cyclesSinceLastHold >= Math.PI * 2) {
this.lfoPitchSampleHoldValue = Math.random() * 2 - 1;
this.lfoPitchSampleHoldPhase = phase;
}
return this.lfoPitchSampleHoldValue;
} else {
const cyclesSinceLastHold = phase - this.lfoMixSampleHoldPhase;
if (cyclesSinceLastHold >= Math.PI * 2) {
this.lfoMixSampleHoldValue = Math.random() * 2 - 1;
this.lfoMixSampleHoldPhase = phase;
}
return this.lfoMixSampleHoldValue;
}
}
case LFOWaveform.RandomWalk: {
const interpolationSpeed = rate / sampleRate * 20;
if (lfoType === 'amp') {
const diff = this.lfoAmpRandomWalkTarget - this.lfoAmpRandomWalkCurrent;
this.lfoAmpRandomWalkCurrent += diff * interpolationSpeed;
if (Math.abs(diff) < 0.01) {
this.lfoAmpRandomWalkTarget = Math.random() * 2 - 1;
}
return this.lfoAmpRandomWalkCurrent;
} else if (lfoType === 'pitch') {
const diff = this.lfoPitchRandomWalkTarget - this.lfoPitchRandomWalkCurrent;
this.lfoPitchRandomWalkCurrent += diff * interpolationSpeed;
if (Math.abs(diff) < 0.01) {
this.lfoPitchRandomWalkTarget = Math.random() * 2 - 1;
}
return this.lfoPitchRandomWalkCurrent;
} else {
const diff = this.lfoMixRandomWalkTarget - this.lfoMixRandomWalkCurrent;
this.lfoMixRandomWalkCurrent += diff * interpolationSpeed;
if (Math.abs(diff) < 0.01) {
this.lfoMixRandomWalkTarget = Math.random() * 2 - 1;
}
return this.lfoMixRandomWalkCurrent;
}
}
default:
return 0;
}
}
randomParams(): 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 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);
const modulatorFreq = carrierFreq * modulatorRatio * this.randomRange(0.98, 1.02);
const secondModulatorRatio = this.randomChoice(ratioChoices);
const secondModulatorFreq = carrierFreq * secondModulatorRatio * this.randomRange(0.97, 1.03);
return {
carrierFreq,
modulatorFreq,
secondModulatorFreq,
carrierLevel: this.randomRange(0.5, 0.9),
modulatorLevel: this.randomRange(0.4, 0.8),
secondModulatorLevel: this.randomRange(0.3, 0.7),
dryWet: this.randomRange(0.3, 0.8),
dryWetEvolution: this.randomRange(-0.3, 0.7),
envelope: {
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.05, 0.35),
sustain: this.randomRange(0.2, 0.8),
release: this.randomRange(0.1, 0.6),
curve: this.randomInt(0, 2) as EnvCurve,
},
lfoAmp: {
rate: this.randomRange(0.3, 12),
depth: this.randomRange(0.1, 0.9),
waveform: this.randomInt(0, 5) as LFOWaveform,
},
lfoPitch: {
rate: this.randomRange(0.1, 8),
depth: this.randomRange(0.2, 1.0),
waveform: this.randomInt(0, 5) as LFOWaveform,
},
lfoMix: {
rate: this.randomRange(0.05, 5),
depth: this.randomRange(0.1, 0.8),
waveform: this.randomInt(0, 5) as LFOWaveform,
},
feedback: this.randomRange(0, 0.7),
harmonics: this.randomRange(0, 0.8),
stereoWidth: this.randomRange(0.3, 0.95),
};
}
mutateParams(params: RingParams, mutationAmount: number = 0.15): 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;
if (Math.random() < 0.1) {
const newRatio = this.randomChoice(ratioChoices);
modulatorFreq = params.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);
} else {
secondModulatorFreq = this.mutateValue(params.secondModulatorFreq, mutationAmount, 20, 2000);
}
return {
carrierFreq: params.carrierFreq,
modulatorFreq,
secondModulatorFreq,
carrierLevel: this.mutateValue(params.carrierLevel, mutationAmount, 0.3, 1.0),
modulatorLevel: this.mutateValue(params.modulatorLevel, mutationAmount, 0.2, 1.0),
secondModulatorLevel: this.mutateValue(params.secondModulatorLevel, mutationAmount, 0.1, 0.9),
dryWet: this.mutateValue(params.dryWet, mutationAmount, 0, 1),
dryWetEvolution: this.mutateValue(params.dryWetEvolution, mutationAmount, -0.5, 1.0),
envelope: {
attack: this.mutateValue(params.envelope.attack, mutationAmount, 0.001, 0.25),
decay: this.mutateValue(params.envelope.decay, mutationAmount, 0.02, 0.5),
sustain: this.mutateValue(params.envelope.sustain, mutationAmount, 0.1, 0.95),
release: this.mutateValue(params.envelope.release, mutationAmount, 0.05, 0.8),
curve: Math.random() < 0.05 ? this.randomInt(0, 2) as EnvCurve : params.envelope.curve,
},
lfoAmp: {
rate: this.mutateValue(params.lfoAmp.rate, mutationAmount, 0.1, 20),
depth: this.mutateValue(params.lfoAmp.depth, mutationAmount, 0, 1),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfoAmp.waveform,
},
lfoPitch: {
rate: this.mutateValue(params.lfoPitch.rate, mutationAmount, 0.05, 15),
depth: this.mutateValue(params.lfoPitch.depth, mutationAmount, 0, 1),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfoPitch.waveform,
},
lfoMix: {
rate: this.mutateValue(params.lfoMix.rate, mutationAmount, 0.02, 10),
depth: this.mutateValue(params.lfoMix.depth, mutationAmount, 0, 1),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfoMix.waveform,
},
feedback: this.mutateValue(params.feedback, mutationAmount, 0, 1),
harmonics: this.mutateValue(params.harmonics, mutationAmount, 0, 1),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
private randomChoice<T>(choices: readonly T[]): T {
return choices[Math.floor(Math.random() * choices.length)];
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -3,7 +3,8 @@
// Time-based parameters should be stored as ratios (0-1) and scaled by duration during generation
// Engines must generate stereo output: [leftChannel, rightChannel]
export interface SynthEngine<T = any> {
name: string;
getName(): string;
getDescription(): string;
generate(params: T, sampleRate: number, duration: number): [Float32Array, Float32Array];
randomParams(): T;
mutateParams(params: T, mutationAmount?: number): T;

View File

@ -1,123 +0,0 @@
import type { SynthEngine } from './SynthEngine';
export interface TwoOpFMParams {
carrierFreq: number;
modRatio: number;
modIndex: number;
attack: number; // 0-1, ratio of total duration
decay: number; // 0-1, ratio of total duration
sustain: number; // 0-1, amplitude level
release: number; // 0-1, ratio of total duration
vibratoRate: number; // Hz
vibratoDepth: number; // 0-1, pitch modulation depth
stereoWidth: number; // 0-1, amount of stereo separation
}
export class TwoOpFM implements SynthEngine<TwoOpFMParams> {
name = '2-OP FM';
generate(params: TwoOpFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const detune = 1 + (params.stereoWidth * 0.002);
const leftFreq = params.carrierFreq / detune;
const rightFreq = params.carrierFreq * detune;
const modulatorFreq = params.carrierFreq * params.modRatio;
let carrierPhaseL = 0;
let carrierPhaseR = Math.PI * params.stereoWidth * 0.1;
let modulatorPhaseL = 0;
let modulatorPhaseR = 0;
let vibratoPhaseL = 0;
let vibratoPhaseR = Math.PI * params.stereoWidth * 0.3;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const envelope = this.calculateEnvelope(t, duration, params);
const vibratoL = Math.sin(vibratoPhaseL) * params.vibratoDepth;
const vibratoR = Math.sin(vibratoPhaseR) * params.vibratoDepth;
const carrierFreqL = leftFreq * (1 + vibratoL);
const carrierFreqR = rightFreq * (1 + vibratoR);
const modulatorL = Math.sin(modulatorPhaseL);
const modulatorR = Math.sin(modulatorPhaseR);
const carrierL = Math.sin(carrierPhaseL + params.modIndex * modulatorL);
const carrierR = Math.sin(carrierPhaseR + params.modIndex * modulatorR);
leftBuffer[i] = carrierL * envelope;
rightBuffer[i] = carrierR * envelope;
carrierPhaseL += (TAU * carrierFreqL) / sampleRate;
carrierPhaseR += (TAU * carrierFreqR) / sampleRate;
modulatorPhaseL += (TAU * modulatorFreq) / sampleRate;
modulatorPhaseR += (TAU * modulatorFreq) / sampleRate;
vibratoPhaseL += (TAU * params.vibratoRate) / sampleRate;
vibratoPhaseR += (TAU * params.vibratoRate) / sampleRate;
}
return [leftBuffer, rightBuffer];
}
private calculateEnvelope(t: number, duration: number, params: TwoOpFMParams): number {
const attackTime = params.attack * duration;
const decayTime = params.decay * duration;
const releaseTime = params.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const decayProgress = (t - attackTime) / decayTime;
return 1 - decayProgress * (1 - params.sustain);
} else if (t < releaseStart) {
return params.sustain;
} else {
const releaseProgress = (t - releaseStart) / releaseTime;
return params.sustain * (1 - releaseProgress);
}
}
randomParams(): TwoOpFMParams {
return {
carrierFreq: this.randomRange(100, 800),
modRatio: this.randomRange(0.5, 8),
modIndex: this.randomRange(0, 10),
attack: this.randomRange(0.01, 0.15),
decay: this.randomRange(0.05, 0.2),
sustain: this.randomRange(0.3, 0.9),
release: this.randomRange(0.1, 0.4),
vibratoRate: this.randomRange(3, 8),
vibratoDepth: this.randomRange(0, 0.03),
stereoWidth: this.randomRange(0.3, 0.8),
};
}
mutateParams(params: TwoOpFMParams, mutationAmount: number = 0.15): TwoOpFMParams {
return {
carrierFreq: this.mutateValue(params.carrierFreq, mutationAmount, 50, 1000),
modRatio: this.mutateValue(params.modRatio, mutationAmount, 0.25, 10),
modIndex: this.mutateValue(params.modIndex, mutationAmount, 0, 15),
attack: this.mutateValue(params.attack, mutationAmount, 0.001, 0.3),
decay: this.mutateValue(params.decay, mutationAmount, 0.01, 0.4),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 1.0),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.6),
vibratoRate: this.mutateValue(params.vibratoRate, mutationAmount, 2, 12),
vibratoDepth: this.mutateValue(params.vibratoDepth, mutationAmount, 0, 0.05),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0.0, 1.0),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,173 @@
import type { SynthEngine } from './SynthEngine';
// @ts-ignore
import { ZZFX } from 'zzfx';
interface ZzfxParams {
volume: number;
randomness: number;
frequency: number;
attack: number;
sustain: number;
release: number;
shape: number;
shapeCurve: number;
slide: number;
deltaSlide: number;
pitchJump: number;
pitchJumpTime: number;
repeatTime: number;
noise: number;
modulation: number;
bitCrush: number;
delay: number;
sustainVolume: number;
decay: number;
tremolo: number;
}
export class ZzfxEngine implements SynthEngine<ZzfxParams> {
getName(): string {
return 'ZzFX';
}
getDescription(): string {
return 'Retro 8-bit sound effects generator with pitch bending, noise, and bit crushing';
}
generate(params: ZzfxParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
// ZZFX uses 44100 sample rate internally
const zzfxSampleRate = 44100;
// Generate samples using ZZFX.buildSamples
// Scale time parameters to seconds for ZZFX (it expects seconds, not ratios)
const samples = ZZFX.buildSamples(
params.volume,
params.randomness,
params.frequency,
params.attack * duration,
params.sustain * duration,
params.release * duration,
params.shape,
params.shapeCurve,
params.slide,
params.deltaSlide,
params.pitchJump,
params.pitchJumpTime * duration,
params.repeatTime * duration,
params.noise,
params.modulation,
params.bitCrush,
params.delay * duration,
params.sustainVolume,
params.decay * duration,
params.tremolo
);
// Calculate the exact number of samples we need
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
// Handle sample rate conversion if needed
const resampleRatio = sampleRate / zzfxSampleRate;
// Fill buffers with resampled/stretched samples to match exact duration
for (let i = 0; i < numSamples; i++) {
// Calculate the source index with resampling
const sourceIndex = Math.floor(i / resampleRatio);
if (sourceIndex < samples.length) {
// Linear interpolation for smoother resampling
const fraction = (i / resampleRatio) - sourceIndex;
const nextIndex = Math.min(sourceIndex + 1, samples.length - 1);
const interpolatedSample = samples[sourceIndex] * (1 - fraction) +
samples[nextIndex] * fraction;
// Left channel
leftBuffer[i] = interpolatedSample;
// Right channel with slight delay for stereo width
const stereoDelay = Math.floor(sampleRate * 0.001); // 1ms delay
const delayedIndex = Math.max(0, sourceIndex - stereoDelay);
if (delayedIndex < samples.length) {
const delayedSample = samples[delayedIndex];
rightBuffer[i] = interpolatedSample * 0.8 + delayedSample * 0.2;
} else {
rightBuffer[i] = interpolatedSample;
}
} else {
// Fill with silence if we've run out of samples
leftBuffer[i] = 0;
rightBuffer[i] = 0;
}
}
return [leftBuffer, rightBuffer];
}
randomParams(): ZzfxParams {
// Adjusted ranges to produce more audible sounds
return {
volume: 1,
randomness: this.randomRange(0, 0.2),
frequency: this.randomRange(100, 2000), // More audible frequency range
attack: this.randomRange(0, 0.05), // Shorter attack for more immediate sound
sustain: this.randomRange(0.1, 0.4), // Ensure minimum sustain
release: this.randomRange(0.05, 0.3), // Reasonable release
shape: this.randomInt(0, 5),
shapeCurve: this.randomRange(0.5, 2), // Less extreme curve
slide: this.randomRange(-0.3, 0.3), // Less extreme slide
deltaSlide: this.randomRange(-0.1, 0.1),
pitchJump: this.randomRange(-500, 500),
pitchJumpTime: this.randomRange(0, 1),
repeatTime: this.randomRange(0, 0.2),
noise: this.randomRange(0, 0.5), // Less noise
modulation: this.randomRange(0, 10), // Less extreme modulation
bitCrush: this.randomRange(0, 8), // Much less bit crushing
delay: this.randomRange(0, 0.1),
sustainVolume: 1,
decay: this.randomRange(0, 0.1), // Shorter decay
tremolo: this.randomRange(0, 0.3), // Less tremolo
};
}
mutateParams(params: ZzfxParams, mutationAmount: number = 0.15): ZzfxParams {
return {
volume: 1,
randomness: this.mutateValue(params.randomness, mutationAmount, 0, 0.2),
frequency: this.mutateValue(params.frequency, mutationAmount * 2, 100, 2000),
attack: this.mutateValue(params.attack, mutationAmount, 0, 0.05),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 0.4),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.3),
shape: Math.random() < 0.1 ? this.randomInt(0, 5) : params.shape,
shapeCurve: this.mutateValue(params.shapeCurve, mutationAmount, 0.5, 2),
slide: this.mutateValue(params.slide, mutationAmount, -0.3, 0.3),
deltaSlide: this.mutateValue(params.deltaSlide, mutationAmount, -0.1, 0.1),
pitchJump: this.mutateValue(params.pitchJump, mutationAmount * 3, -500, 500),
pitchJumpTime: this.mutateValue(params.pitchJumpTime, mutationAmount, 0, 1),
repeatTime: this.mutateValue(params.repeatTime, mutationAmount, 0, 0.2),
noise: this.mutateValue(params.noise, mutationAmount, 0, 0.5),
modulation: this.mutateValue(params.modulation, mutationAmount, 0, 10),
bitCrush: this.mutateValue(params.bitCrush, mutationAmount, 0, 8),
delay: this.mutateValue(params.delay, mutationAmount, 0, 0.1),
sustainVolume: 1,
decay: this.mutateValue(params.decay, mutationAmount, 0, 0.1),
tremolo: this.mutateValue(params.tremolo, mutationAmount, 0, 0.3),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = (max - min) * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -0,0 +1,16 @@
import type { SynthEngine } from './SynthEngine';
import { FourOpFM } from './FourOpFM';
import { DubSiren } from './DubSiren';
import { Benjolin } from './Benjolin';
import { ZzfxEngine } from './ZzfxEngine';
import { NoiseDrum } from './NoiseDrum';
import { Ring } from './Ring';
export const engines: SynthEngine[] = [
new FourOpFM(),
new DubSiren(),
new Benjolin(),
new ZzfxEngine(),
new NoiseDrum(),
new Ring(),
];

View File

@ -22,6 +22,13 @@ export class AudioService {
return DEFAULT_SAMPLE_RATE;
}
async initialize(): Promise<void> {
const ctx = this.getContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
}
setVolume(volume: number): void {
if (this.gainNode) {
this.gainNode.gain.value = Math.max(0, Math.min(1, volume));
@ -51,11 +58,15 @@ export class AudioService {
this.startTime = ctx.currentTime;
this.isPlaying = true;
this.currentSource = source;
source.onended = () => {
// Guard against ghost callbacks from old sources
if (source !== this.currentSource) return;
this.isPlaying = false;
if (this.onPlaybackUpdate) {
this.onPlaybackUpdate(0);
this.onPlaybackUpdate(-1);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
@ -64,7 +75,6 @@ export class AudioService {
};
source.start();
this.currentSource = source;
this.updatePlaybackPosition();
}
@ -93,5 +103,8 @@ export class AudioService {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.onPlaybackUpdate) {
this.onPlaybackUpdate(-1);
}
}
}

View File

@ -102,7 +102,7 @@
}
}
if (playbackPosition > 0 && buffer) {
if (playbackPosition >= 0 && buffer) {
const duration = buffer.length / buffer.sampleRate;
const x = (playbackPosition / duration) * width;