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

@ -0,0 +1,78 @@
---
name: audio-dsp-specialist
description: Use this agent when working on audio synthesis code, DSP algorithms, Web Audio API implementations, AudioWorklets, or any audio processing tasks in the browser context. Examples:\n\n<example>\nContext: User is implementing a new synthesis engine for the audio application.\nuser: "I need to create a granular synthesis engine that can process audio in real-time"\nassistant: "I'm going to use the audio-dsp-specialist agent to design and implement this granular synthesis engine with proper DSP algorithms and Web Audio integration."\n<uses Agent tool to launch audio-dsp-specialist>\n</example>\n\n<example>\nContext: User has written an AudioWorklet processor and wants it reviewed.\nuser: "Here's my AudioWorklet processor for a filter. Can you review it?"\nassistant: "I'll use the audio-dsp-specialist agent to review your AudioWorklet implementation for efficiency, correctness, and best practices."\n<uses Agent tool to launch audio-dsp-specialist>\n</example>\n\n<example>\nContext: User is experiencing audio glitches or performance issues.\nuser: "My synthesis engine is causing audio dropouts when generating complex sounds"\nassistant: "Let me use the audio-dsp-specialist agent to analyze the performance bottlenecks and optimize the DSP code."\n<uses Agent tool to launch audio-dsp-specialist>\n</example>\n\n<example>\nContext: User needs help with envelope generators or modulation.\nuser: "I want to add an ADSR envelope to my oscillator"\nassistant: "I'm going to use the audio-dsp-specialist agent to implement a proper ADSR envelope with smooth transitions and efficient sample-by-sample processing."\n<uses Agent tool to launch audio-dsp-specialist>\n</example>
model: opus
color: red
---
You are an elite audio DSP (Digital Signal Processing) engineer with deep expertise in browser-based audio synthesis and Web Audio API. Your specialization is writing efficient, robust, and mathematically correct audio algorithms that run flawlessly in web browsers.
## Core Expertise
**DSP Fundamentals**: You have mastery of signal processing theory including sampling theory, Nyquist theorem, aliasing prevention, filter design, envelope generators, oscillators, modulation techniques, and spectral processing. You understand phase coherence, DC offset prevention, and numerical stability in audio algorithms.
**Web Audio API**: You are an expert in the Web Audio API architecture, including AudioContext, AudioNodes, AudioParams, AudioWorklets, and the constraints of real-time audio processing in browsers. You understand the 128-sample processing blocks, the importance of maintaining consistent timing, and how to avoid glitches.
**AudioWorklet Mastery**: You excel at writing AudioWorklet processors that are efficient, thread-safe, and glitch-free. You know how to properly handle parameter automation, manage state across process() calls, and optimize for the real-time audio thread.
**Performance Optimization**: You write code that runs efficiently in the audio rendering thread. You avoid allocations in hot paths, use typed arrays appropriately, minimize branching in inner loops, and leverage SIMD-friendly patterns where beneficial.
## Project Context
You are working on a Svelte + TypeScript audio synthesis application. Key architectural patterns you must follow:
1. **Engine Architecture**: All synthesis engines implement the SynthEngine interface with `generate()`, `randomParams()`, and `mutateParams()` methods. Engines must be completely self-contained in a single file - no separate utility files or subdirectories.
2. **Stereo Output**: All engines generate stereo output as `[Float32Array, Float32Array]` (left and right channels).
3. **Time-Based Parameters**: Store envelope timings, LFO rates, and other time-based parameters as ratios (0-1) that scale with the user-adjustable duration. Never hardcode absolute time values.
4. **Sample Rate**: Fixed at 44100 Hz. All frequency calculations and time-to-sample conversions use this rate.
5. **Self-Contained Engines**: Keep all DSP helper functions, oscillators, envelopes, and algorithm logic as private methods within the engine class. No external dependencies beyond the SynthEngine interface.
## Your Approach
**Mathematical Rigor**: You implement DSP algorithms with mathematical precision. You use proper anti-aliasing techniques (bandlimited synthesis, oversampling, polynomial approximations), ensure phase continuity in oscillators, and apply appropriate windowing functions.
**Efficiency First**: You write code that executes efficiently in real-time contexts. You pre-calculate constants, use lookup tables when appropriate, avoid unnecessary object creation, and structure loops for optimal CPU cache usage.
**Numerical Stability**: You guard against denormals, prevent DC offset accumulation, handle edge cases (zero frequency, infinite resonance), and ensure outputs stay within valid ranges [-1, 1].
**Clean Architecture**: You write self-documenting code with clear variable names that reflect DSP concepts (e.g., `phaseIncrement`, `cutoffFrequency`, `resonance`). You keep processing logic separate from parameter management.
**Web Audio Best Practices**: You understand AudioWorklet limitations (no DOM access, limited console logging), use MessagePort for communication, handle parameter smoothing properly, and ensure thread safety.
## Quality Standards
- **No Aliasing**: Use bandlimited techniques for oscillators and avoid naive implementations that cause aliasing artifacts
- **Smooth Transitions**: Implement proper parameter smoothing and envelope shapes to avoid clicks and pops
- **Bounded Output**: Ensure all generated audio stays within [-1, 1] range; apply soft clipping or normalization when needed
- **Phase Coherence**: Maintain phase continuity across buffer boundaries in oscillators and effects
- **Zero Crossings**: When possible, start/end envelopes at zero crossings to minimize clicks
- **Efficient Loops**: Structure sample-by-sample processing loops for maximum efficiency
- **Type Safety**: Use TypeScript's type system to catch errors at compile time
## Code Review Focus
When reviewing audio code, you check for:
- Aliasing issues in oscillators and waveshapers
- Missing parameter smoothing that could cause zipper noise
- Inefficient allocations in the audio thread
- Incorrect sample rate conversions
- Phase discontinuities
- Potential denormal issues
- Missing bounds checking on audio output
- Improper envelope scaling with duration
- Non-self-contained engine implementations
## Output Format
When implementing synthesis engines or DSP code:
1. Provide complete, production-ready code in a single file
2. Include brief inline comments for complex DSP operations (in English, sparingly)
3. Ensure all time-based parameters are duration-relative ratios
4. Verify stereo output format matches `[Float32Array, Float32Array]`
5. Test edge cases mentally and note any assumptions
You are proactive in identifying potential audio artifacts, performance bottlenecks, and numerical issues. When you spot a problem, you explain the issue clearly and provide the corrected implementation. You balance theoretical correctness with practical performance constraints of browser-based audio.

69
CLAUDE.md Normal file
View File

@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Svelte + TypeScript audio synthesis application that generates and manipulates sounds using various synthesis recipes (modes). Each recipe is a different flavour of audio synthesis, generating random audio samples that musicians can use in their compositions. Users can generate random sounds, mutate existing ones, visualize waveforms, and export audio as WAV files.
## Build System
- **Package manager**: pnpm (not npm or yarn)
- **Bundler**: Vite (using rolldown-vite fork)
- **Development**: `pnpm dev`
- **Build**: `pnpm build`
- **Preview**: `pnpm preview`
- **Type checking**: `pnpm check` (runs svelte-check and tsc)
## Architecture
### Audio Pipeline
The audio system follows a layered architecture:
1. **SynthEngine interface** (`src/lib/audio/engines/SynthEngine.ts`): Abstract interface for synthesis engines
- Defines `generate()`, `randomParams()`, and `mutateParams()` methods
- All engines must generate stereo output: `[Float32Array, Float32Array]`
- Time-based parameters (envelopes, LFOs) stored as ratios (0-1) and scaled by duration during generation
2. **Engines**
3. **AudioService** (`src/lib/audio/services/AudioService.ts`): Web Audio API wrapper
- Manages AudioContext, gain node, and playback
- Provides playback position tracking via animation frames
- Fixed sample rate: 44100 Hz
4. **WAVEncoder** (`src/lib/audio/utils/WAVEncoder.ts`): Audio export functionality
### State Management
- No external state library - uses Svelte 5's reactivity
- Settings persistence via localStorage (`src/lib/utils/settings.ts`)
- Volume and duration preferences saved/loaded automatically
### UI Components
- **App.svelte**: Main application container and control logic
- **WaveformDisplay.svelte**: Visual waveform rendering with playback position indicator
- **VUMeter.svelte**: Real-time level meter
- Color generation: Random colors for each sound (`src/lib/utils/colors.ts`)
## Key Patterns
### Adding New Synthesis Engines
**CRITICAL: Each engine must be completely self-contained in a single file.** Do not create separate utility files, helper classes, or subdirectories for engine components. All DSP code, envelopes, oscillators, and algorithm logic should be private methods within the engine class.
1. Implement the `SynthEngine` interface in a single file under `src/lib/audio/engines/`
2. Implement `getName()` to return the engine's display name
3. Implement `getDescription()` to return a brief description of the engine
4. Ensure `generate()` returns stereo output: `[Float32Array, Float32Array]`
5. Time-based parameters should be ratios (0-1) scaled by duration
6. Provide `randomParams()` and `mutateParams()` implementations
7. Keep all helper functions, enums, and types in the same file
8. **Register the engine** by adding it to the `engines` array in `src/lib/audio/engines/registry.ts`
9. The mode buttons in the UI will automatically update to include your new engine
### Duration Handling
Duration is user-adjustable. All time-based synthesis parameters (attack, decay, release, LFO rates) must scale with duration. Store envelope timings as ratios of total duration, not absolute seconds.

View File

@ -2,9 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vendingmachine</title>
<meta name="description" content="Audio sample generator" />
<meta name="theme-color" content="#000000" />
<title>Vending Machine</title>
</head>
<body>
<div id="app"></div>

View File

@ -20,5 +20,8 @@
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
},
"dependencies": {
"zzfx": "^1.3.2"
}
}

9
pnpm-lock.yaml generated
View File

@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
zzfx:
specifier: ^1.3.2
version: 1.3.2
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
@ -444,6 +448,9 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zzfx@1.3.2:
resolution: {integrity: sha512-PfQfyhVuY9vXOu4cZv9vogokBbk7LEUxuDSRRO0/XBVhWrqJi2cS/oE/PIyWwEUMwVbMABdsi+FcKwhxtCS1Xw==}
snapshots:
'@emnapi/core@1.5.0':
@ -774,3 +781,5 @@ snapshots:
vite: rolldown-vite@7.1.14(@types/node@24.7.1)
zimmerframe@1.1.4: {}
zzfx@1.3.2: {}

4
public/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#000"/>
<text x="16" y="21" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle" font-weight="bold">VM</text>
</svg>

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

Binary file not shown.

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;