almost stable
This commit is contained in:
78
.claude/agents/audio-dsp-specialist.md
Normal file
78
.claude/agents/audio-dsp-specialist.md
Normal 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
69
CLAUDE.md
Normal 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.
|
||||
@ -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>
|
||||
|
||||
@ -20,5 +20,8 @@
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"zzfx": "^1.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -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
4
public/favicon.svg
Normal 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 |
BIN
public/fonts/DepartureMono-Regular.woff
Normal file
BIN
public/fonts/DepartureMono-Regular.woff
Normal file
Binary file not shown.
BIN
public/fonts/DepartureMono-Regular.woff2
Normal file
BIN
public/fonts/DepartureMono-Regular.woff2
Normal file
Binary file not shown.
264
src/App.svelte
264
src/App.svelte
@ -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>
|
||||
|
||||
11
src/app.css
11
src/app.css
@ -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;
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<script lang="ts">
|
||||
let count: number = $state(0)
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>
|
||||
count is {count}
|
||||
</button>
|
||||
953
src/lib/audio/engines/Benjolin.ts
Normal file
953
src/lib/audio/engines/Benjolin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
487
src/lib/audio/engines/DubSiren.ts
Normal file
487
src/lib/audio/engines/DubSiren.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
527
src/lib/audio/engines/FourOpFM.ts
Normal file
527
src/lib/audio/engines/FourOpFM.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
454
src/lib/audio/engines/NoiseDrum.ts
Normal file
454
src/lib/audio/engines/NoiseDrum.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
505
src/lib/audio/engines/Ring.ts
Normal file
505
src/lib/audio/engines/Ring.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
173
src/lib/audio/engines/ZzfxEngine.ts
Normal file
173
src/lib/audio/engines/ZzfxEngine.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
16
src/lib/audio/engines/registry.ts
Normal file
16
src/lib/audio/engines/registry.ts
Normal 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(),
|
||||
];
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackPosition > 0 && buffer) {
|
||||
if (playbackPosition >= 0 && buffer) {
|
||||
const duration = buffer.length / buffer.sampleRate;
|
||||
const x = (playbackPosition / duration) * width;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user