almost stable
This commit is contained in:
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