Initial commit

This commit is contained in:
2026-01-18 15:39:46 +01:00
commit 587f2bd7e7
106 changed files with 14918 additions and 0 deletions

298
website/src/app.css Normal file
View File

@@ -0,0 +1,298 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 13px;
line-height: 1.5;
color: #111;
background: #fff;
}
a {
color: #000;
text-decoration: underline;
}
a:hover {
color: #666;
}
code {
font-family: inherit;
background: #f5f5f5;
padding: 2px 6px;
border: 1px solid #ccc;
}
pre,
input,
textarea {
font-family: inherit;
font-size: 12px;
padding: 8px;
border: 1px solid #ccc;
background: #f5f5f5;
color: #111;
width: 100%;
outline: none;
resize: none;
}
button {
font-family: inherit;
font-size: 12px;
background: #f5f5f5;
color: #111;
border: 1px solid #ccc;
padding: 8px 16px;
cursor: pointer;
}
button:hover {
background: #eee;
}
h1,
h2,
h3 {
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.1em;
}
h1 {
font-size: 14px;
}
h2 {
font-size: 13px;
}
h3 {
font-size: 12px;
margin-top: 1em;
}
nav {
border-bottom: 1px solid #ccc;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
right: 0;
background: #fff;
z-index: 50;
}
nav h1 {
margin: 0;
}
.mic-btn {
padding: 6px 12px;
}
.mic-btn.mic-enabled {
background: #e8f5e8;
border-color: #8c8;
color: #2a5a2a;
}
.mic-btn:disabled {
cursor: wait;
opacity: 0.7;
}
.layout {
display: flex;
min-height: calc(100vh - 57px);
margin-top: 57px;
}
.sidebar {
width: 160px;
border-right: 1px solid #ccc;
padding: 12px 0;
flex-shrink: 0;
position: sticky;
top: 57px;
height: calc(100vh - 57px);
overflow-y: auto;
align-self: flex-start;
}
.sidebar-section {
padding: 8px 16px 8px;
font-size: 12px;
color: #000;
text-transform: uppercase;
letter-spacing: 0.15em;
border-bottom: 1px solid #ddd;
margin-bottom: 4px;
}
.sidebar a {
display: block;
padding: 4px 16px;
text-decoration: none;
color: #666;
}
.sidebar a:hover {
color: #000;
background: #f5f5f5;
}
.content {
flex: 1;
padding: 0 20px 40px;
}
.nav-scope {
flex: 1;
margin: 0 16px;
}
.nav-scope .scope {
height: 32px;
border: 1px solid #ccc;
background: #fff;
}
.scope canvas {
width: 100%;
height: 100%;
display: block;
}
.content section {
max-width: 700px;
margin: 0 auto;
}
.content h2 {
padding: 12px 0;
margin: 0;
}
.intro {
border-bottom: 1px solid #ccc;
padding-bottom: 1em;
margin-bottom: 1em;
}
ul,
ol {
padding-left: 20px;
}
li {
padding: 2px 0;
}
.repl {
display: flex;
gap: 8px;
margin: 1em 0;
}
.repl-editor {
flex: 1;
position: relative;
}
.repl-editor textarea {
display: block;
background: transparent;
color: transparent;
caret-color: #111;
position: relative;
z-index: 1;
}
.hl-pre {
position: absolute;
inset: 0;
margin: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.hl-slash {
color: #999;
}
.hl-command {
color: #000;
font-weight: bold;
}
.hl-number {
color: #c00;
}
.hl-comment {
color: #999;
font-style: italic;
}
.repl-controls {
display: flex;
}
.repl-controls button {
width: 48px;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes eval-flash {
from {
outline: 1px solid #999;
}
to {
outline: 1px solid transparent;
}
}
.evaluated {
animation: eval-flash 0.3s;
}
@media (max-width: 768px) {
.nav-scope {
position: fixed;
bottom: 48px;
left: 0;
right: 0;
margin: 0;
z-index: 100;
}
.nav-scope .scope {
height: 48px;
border: none;
}
.layout {
padding-bottom: 96px;
}
.sidebar {
display: none;
}
}

13
website/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="@sveltejs/kit" />
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

16
website/src/app.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>doux</title>
<meta name="description" content="Audio engine for live coding" />
<meta name="author" content="Raphaël Forment" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☁️</text></svg>" />
<script src="/coi-serviceworker.min.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

37
website/src/content/am.md Normal file
View File

@@ -0,0 +1,37 @@
---
title: "Amplitude Modulation"
slug: "am"
group: "synthesis"
order: 109
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Amplitude modulation multiplies the signal by a modulating oscillator. The formula preserves the original signal at depth 0: <code>signal &#42;= 1.0 + modulator &#42; depth</code>. This creates sidebands at <code>carrier ± modulator</code> frequencies while keeping the carrier present.
<CommandEntry name="am" type="number" min={0} default={0} unit="Hz">
AM oscillator frequency in Hz. When set above 0, an LFO modulates the signal amplitude.
<CodeEditor code={`/freq/300/am/4/amdepth/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="amdepth" type="number" min={0} max={1} default={0.5}>
Modulation depth (0-1). At 0, the signal is unchanged. At 1, the signal varies between 0 and 2x its amplitude.
<CodeEditor code={`/freq/300/am/2/amdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="amshape" type="string" default="sine">
AM LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/freq/300/am/4/amdepth/0.8/amshape/square`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Bandpass Filter"
slug: "bandpass"
group: "effects"
order: 112
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A bandpass filter attenuates frequencies outside a band around the center frequency. Each filter has its own ADSR envelope that modulates the center frequency.
<CommandEntry name="bpf" type="number" min={20} max={20000} unit="Hz">
Center frequency in Hz. Frequencies outside the band are attenuated.
<CodeEditor code={`/sound/saw/bpf/800`} rows={2} />
</CommandEntry>
<CommandEntry name="bpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Higher values narrow the passband.
<CodeEditor code={`/sound/saw/bpf/800/bpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="bpe" type="number" default={0}>
Envelope amount. Positive values sweep the center up, negative values sweep down.
<CodeEditor code={`/sound/saw/bpf/800/bpe/5/bpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="bpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpa/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="bpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpd/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="bps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/bpf/800/bpd/.2/bps/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="bpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,103 @@
---
title: "Basic"
slug: "basic"
group: "sources"
order: 0
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
These sources provide fundamental waveforms that can be combined and manipulated to create complex sounds. They are inspired by classic substractive synthesizers.
<CommandEntry name="sine" type="source">
Pure sine wave. The simplest waveform with no harmonics.
<CodeEditor code={`/sound/sine`} rows={2} />
<CodeEditor code={`/sound/sine/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="tri" type="source">
Triangle wave. The default source. Contains only odd harmonics with gentle rolloff.
<CodeEditor code={`/sound/tri`} rows={2} />
<CodeEditor code={`/sound/tri/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="saw" type="source">
Band-limited sawtooth wave. Rich in harmonics, bright and buzzy.
<CodeEditor code={`/sound/saw`} rows={2} />
<CodeEditor code={`/sound/saw/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="zaw" type="source">
Naive sawtooth with no anti-aliasing. Cheaper but more aliasing artifacts than saw.
<CodeEditor code={`/sound/zaw`} rows={2} />
<CodeEditor code={`/sound/zaw/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="pulse" type="source">
Band-limited pulse wave. Hollow sound with only odd harmonics. Use /pw to control pulse width.
<CodeEditor code={`/sound/pulse`} rows={2} />
<CodeEditor code={`/sound/pulse/pw/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="pulze" type="source">
Naive pulse with no anti-aliasing. Cheaper but more aliasing artifacts than pulse.
<CodeEditor code={`/sound/pulze`} rows={2} />
<CodeEditor code={`/sound/pulze/pw/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="white" type="source">
White noise. Equal energy at all frequencies.
<CodeEditor code={`/sound/white`} rows={2} />
<CodeEditor code={`/sound/white/lpf/2000`} rows={2} />
</CommandEntry>
<CommandEntry name="pink" type="source">
Pink noise (1/f). Equal energy per octave, more natural sounding.
<CodeEditor code={`/sound/pink`} rows={2} />
<CodeEditor code={`/sound/pink/lpf/4000`} rows={2} />
</CommandEntry>
<CommandEntry name="brown" type="source">
Brown/red noise (1/f^2). Deep rumbling, heavily weighted toward low frequencies.
<CodeEditor code={`/sound/brown`} rows={2} />
<CodeEditor code={`/sound/brown/hpf/100`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,43 @@
---
title: "Chorus"
slug: "chorus"
group: "effects"
order: 202
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A rich chorus effect that adds depth and movement to any sound.
<CommandEntry name="chorus" type="number" min={0} default={0} unit="Hz">
Chorus LFO rate in Hz.
<CodeEditor code={`/sound/saw/freq/100/chorus/0.1`} rows={2} />
<CodeEditor code={`/sound/saw/freq/100/chorus/0.05/chorusdepth/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="chorusdepth" type="number" min={0} max={1} default={0.5}>
Chorus modulation depth (0-1).
<CodeEditor code={`/sound/saw/freq/200/chorus/0.5/chorusdepth/0.3`} rows={2} />
<CodeEditor code={`/sound/pulse/freq/100/chorus/0.2/chorusdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="chorusdelay" type="number" min={0} default={20} unit="ms">
Chorus base delay time in milliseconds.
<CodeEditor code={`/sound/saw/freq/200/chorus/0.3/chorusdelay/20`} rows={2} />
<CodeEditor code={`/sound/saw/freq/200/chorus/0.3/chorusdelay/30`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,47 @@
---
title: "Comb Filter"
slug: "comb"
group: "effects"
order: 113
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Send effect with feedback comb filter. Creates pitched resonance, metallic timbres, and Karplus-Strong plucked sounds. Tail persists after voice ends.
<CommandEntry name="comb" type="number" min={0} max={1} default={0}>
Send amount to comb filter.
<CodeEditor code={`/sound/white/comb/1/combfreq/110/decay/.5/sustain/0`} rows={2} />
Noise into a tuned comb creates plucked string sounds (Karplus-Strong).
</CommandEntry>
<CommandEntry name="combfreq" type="number" min={20} max={20000} default={220} unit="Hz">
Resonant frequency. All voices share the same orbit comb.
<CodeEditor code={`/sound/saw/comb/0.5/combfreq/880/decay/.5/sustain/0`} rows={2} />
</CommandEntry>
<CommandEntry name="combfeedback" type="number" min={0} max={0.99} default={0.9}>
Feedback amount. Higher values create longer resonance.
<CodeEditor code={`/sound/white/comb/1/combfeedback/0.99/combfreq/220/decay/.5/sustain/0`} rows={2} />
</CommandEntry>
<CommandEntry name="combdamp" type="number" min={0} max={1} default={0.1}>
High-frequency damping. Higher values darken the sound over time.
<CodeEditor code={`/sound/white/comb/1/combfeedback/0.95/combdamp/0.4/combfreq/220/decay/.5/sustain/0`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,58 @@
---
title: "Delay"
slug: "delay"
group: "effects"
order: 203
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Stereo delay line with feedback (max 1 second at 48kHz, clamped to 0.95 feedback).
<CommandEntry name="delay" type="number" min={0} max={1} default={0}>
Send level to the delay bus.
<CodeEditor code={`/delay/.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delayfeedback" type="number" min={0} max={1} default={0.5}>
Feedback amount (clamped to 0.95 max). Output is fed back into input.
<CodeEditor code={`/delay/.5/delayfeedback/.8/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delaytime" type="number" min={0} default={0.25} unit="s">
Delay time in seconds (max ~1s at 48kHz).
<CodeEditor code={`/delay/.5/delaytime/.08/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delaytype" type="enum" default="standard" values={["standard", "pingpong", "tape", "multitap"]}>
<ul>
<li><strong>standard</strong> — Clean digital. Precise repeats.</li>
<li><strong>pingpong</strong> — Mono in, bounces L→R→L→R.</li>
<li><strong>tape</strong> — Each repeat darker. Analog warmth.</li>
<li><strong>multitap</strong> — 4 taps. Feedback 0=straight, 1=triplet, between=swing.</li>
</ul>
<CodeEditor code={`/sound/saw/delay/.6/dtype/std/delaytime/.15/delayfeedback/.7/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/pp/delaytime/.12/delayfeedback/.8/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.6/dtype/tape/delaytime/.2/delayfeedback/.9/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/multi/delaytime/.3/delayfeedback/0/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/multi/delaytime/.3/delayfeedback/1/d/.05`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,56 @@
---
title: "Envelope"
slug: "envelope"
group: "synthesis"
order: 102
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The envelope parameters control the shape of the gain envelope over time. It uses a typical ADSR envelope with exponential curves:
- **Attack**: Ramps from 0 to full amplitude. Uses <code></code> (slow start, fast finish).
- **Decay**: Falls from full amplitude to the sustain level. Uses <code>1-(1-x)²</code> (fast drop, slow finish).
- **Sustain**: Holds at a constant level while the note is held.
- **Release**: Falls from the sustain level to 0 when the note ends. Uses <code>1-(1-x)²</code> (fast drop, slow finish).
<CommandEntry name="attack" type="number" min={0} default={0.001} unit="s">
The duration (seconds) of the attack phase of the gain envelope.
<CodeEditor code={`/attack/.1`} rows={2} />
<CodeEditor code={`/attack/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="decay" type="number" min={0} default={0} unit="s">
The duration (seconds) of the decay phase of the gain envelope.
<CodeEditor code={`/decay/.1`} rows={2} />
<CodeEditor code={`/decay/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="sustain" type="number" min={0} max={1} default={1}>
The sustain level (0-1) of the gain envelope.
<CodeEditor code={`/decay/.1/sustain/.2`} rows={2} />
<CodeEditor code={`/decay/.1/sustain/.6`} rows={2} />
</CommandEntry>
<CommandEntry name="release" type="number" min={0} default={0.005} unit="s">
The duration (seconds) of the release phase of the gain envelope.
<CodeEditor code={`/duration/.25/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,43 @@
---
title: "Flanger"
slug: "flanger"
group: "effects"
order: 201
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
LFO-modulated delay (0.5-10ms) with feedback and linear interpolation. Output is 50% dry, 50% wet.
<CommandEntry name="flanger" type="number" min={0} default={0} unit="Hz">
Flanger LFO rate in Hz. Creates sweeping comb filter effect with short delay modulation.
<CodeEditor code={`/sound/saw/freq/100/flanger/0.5`} rows={2} />
<CodeEditor code={`/sound/tri/freq/200/flanger/2/flangerdepth/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="flangerdepth" type="number" min={0} max={1} default={0.5}>
Flanger modulation depth (0-1). Controls delay time sweep range.
<CodeEditor code={`/sound/saw/freq/100/flanger/1/flangerdepth/0.3`} rows={2} />
<CodeEditor code={`/sound/pulse/freq/80/flanger/0.5/flangerdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="flangerfeedback" type="number" min={0} max={0.95} default={0}>
Flanger feedback amount (0-0.95).
<CodeEditor code={`/sound/saw/freq/100/flanger/1/flangerfeedback/0.7`} rows={2} />
<CodeEditor code={`/sound/tri/freq/150/flanger/0.3/flangerdepth/0.5/flangerfeedback/0.9`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,83 @@
---
title: "Frequency Modulation"
slug: "frequency-modulation"
group: "synthesis"
order: 108
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Any source can be frequency modulated. Frequency modulation (FM) is a technique where the frequency of a carrier wave is varied by an audio signal. This creates complex timbres and can produce rich harmonics, from mellow timbres to harsh digital noise.
<CommandEntry name="fm" type="number" min={0} default={0}>
The frequency modulation index. FM multiplies the gain of the modulator, thus controls the amount of FM applied.
<CodeEditor code={`/fm/2/note/60\n\n/fm/4/note/63\n\n/fm/2/note/67`} rows={6} />
<CodeEditor code={`/voice/0/fm/2/time/0\n\n/voice/0/fm/4/time/1`} rows={4} />
</CommandEntry>
<CommandEntry name="fmh" type="number" default={1}>
The harmonic ratio of the frequency modulation. fmh*freq defines the modulation frequency. As a rule of thumb, numbers close to simple ratios sound more harmonic.
<CodeEditor code={`/fm/2/fmh/2/`} rows={2} />
<CodeEditor code={`/fm/0.5/fmh/1.5/`} rows={2} />
<CodeEditor code={`/fm/0.25/fmh/3/`} rows={2} />
</CommandEntry>
<CommandEntry name="fmshape" type="string" default="sine">
FM modulator waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). Different shapes create different harmonic spectra.
<CodeEditor code={`/fm/2/fmshape/saw`} rows={2} />
</CommandEntry>
<CommandEntry name="fmenv" type="number" default={0}>
Envelope amount of frequency envelope.
<CodeEditor code={`/fm/4/fmenv/4/fmd/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fma" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's attack phase.
<CodeEditor code={`/fm/4/fma/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fmd" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's decay phase.
<CodeEditor code={`/fm/4/fmd/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fms" type="number" min={0} max={1} default={1}>
The sustain level of the fm envelope.
<CodeEditor code={`/fm/4/fmd/0.25/fms/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="fmr" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's release phase.
<CodeEditor code={`/fm/4/fmr/1/release/1/duration/.1`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,21 @@
---
title: "Filter Type"
slug: "ftype"
group: "effects"
order: 114
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Controls the steepness of all filters. Higher dB/octave values create sharper transitions between passed and attenuated frequencies.
<CommandEntry name="ftype" type="enum" default="12db" values={["12db", "24db", "48db"]}>
Filter slope steepness. Higher dB/octave values create sharper cutoffs. Applies to all filter types (lowpass, highpass, bandpass).
<CodeEditor code={`/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/12db/d/.5\n\n/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/24db/time/1/d/.5\n\n/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/48db/time/2/d/.5`} rows={6} />
</CommandEntry>

View File

@@ -0,0 +1,45 @@
---
title: "Gain"
slug: "gain"
group: "synthesis"
order: 105
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The signal path is: oscillator → <code>gain &#42; velocity</code> → filters → distortion → modulation → phaser/flanger → <code>envelope &#42; postgain</code> → chorus → <code>pan</code>.
<CommandEntry name="gain" type="number" min={0} default={1}>
Pre-filter gain multiplier. Applied before filters and distortion, combined with <code>velocity</code> as <code>gain &#42; velocity</code>.
<CodeEditor code={`/sound/saw/gain/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="postgain" type="number" min={0} default={1}>
Post-effects gain multiplier. Applied after phaser/flanger, combined with the envelope as <code>envelope &#42; postgain</code>.
<CodeEditor code={`/sound/saw/postgain/0.2\n\n/sound/saw/postgain/1/time/0.25`} rows={4} />
</CommandEntry>
<CommandEntry name="velocity" type="number" min={0} max={1} default={1}>
Multiplied with <code>gain</code> before filters. Also passed as <code>accent</code> to Plaits engines.
<CodeEditor code={`/sound/saw/velocity/0.2\n\n/sound/saw/velocity/1/time/0.25`} rows={4} />
</CommandEntry>
<CommandEntry name="pan" type="number" min={0} max={1} default={0.5}>
Stereo position using constant-power panning: <code>left = cos(pan &#42; π/2)</code>, <code>right = sin(pan &#42; π/2)</code>. 0 = left, 0.5 = center, 1 = right.
<CodeEditor code={`/pan/0/freq/329\n\n/pan/1/freq/331`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Highpass Filter"
slug: "highpass"
group: "effects"
order: 111
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A highpass filter attenuates frequencies below the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency.
<CommandEntry name="hpf" type="number" min={20} max={20000} unit="Hz">
Cutoff frequency in Hz. Frequencies below this are attenuated.
<CodeEditor code={`/sound/saw/hpf/500`} rows={2} />
</CommandEntry>
<CommandEntry name="hpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Boosts frequencies near the cutoff.
<CodeEditor code={`/sound/saw/hpf/500/hpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="hpe" type="number" default={0}>
Envelope amount. Positive values sweep the cutoff up, negative values sweep down.
<CodeEditor code={`/sound/saw/hpf/500/hpe/5/hpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpa/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/hpf/500/hpd/.25/hps/.4`} rows={2} />
</CommandEntry>
<CommandEntry name="hpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

25
website/src/content/io.md Normal file
View File

@@ -0,0 +1,25 @@
---
title: "Io"
slug: "io"
group: "sources"
order: 2
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
This special source allows you to create a live audio input (microphone) source. Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally, envelopes are applied to the input signal too.
<CommandEntry name="live" type="source">
Live audio input (microphone). Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally.
<CodeEditor code={`/sound/live`} rows={2} />
<CodeEditor code={`/sound/live/lpf/800`} rows={2} />
<CodeEditor code={`/sound/live/verb/0.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,61 @@
---
title: "Lo-Fi"
slug: "lofi"
group: "effects"
order: 205
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Sample rate reduction, bit crushing, and waveshaping distortion.
<CommandEntry name="coarse" type="number" min={1} default={1}>
Sample rate reduction. Holds each sample for <code>n</code> samples, creating stair-stepping and aliasing artifacts.
<CodeEditor code={`/penv/36/pdec/.5/coarse/8`} rows={2} />
</CommandEntry>
<CommandEntry name="crush" type="number" min={1} max={16} default={16} unit="bits">
Bit depth reduction. Quantizes amplitude to <code>2^(bits-1)</code> levels, creating stepping distortion.
<CodeEditor code={`/penv/36/pdec/.5/crush/4`} rows={2} />
</CommandEntry>
<CommandEntry name="fold" type="number" min={1} default={1}>
Sine-based wavefold (Serge-style). At 1, near-passthrough. At 2, one fold per peak. At 4, two folds.
<CodeEditor code={`/sound/sine/fold/3`} rows={2} />
</CommandEntry>
<CommandEntry name="wrap" type="number" min={1} default={1}>
Wrap distortion. Signal wraps around creating harsh digital artifacts.
<CodeEditor code={`/sound/tri/wrap/2`} rows={2} />
</CommandEntry>
<CommandEntry name="distort" type="number" min={0} default={0}>
Soft-clipping waveshaper using <code>(1+k)&#42;x / (1+k&#42;|x|)</code> where <code>k = e^amount - 1</code>. Higher values add harmonic saturation.
<CodeEditor code={`/sound/sine/distort/4`} rows={2} />
</CommandEntry>
<CommandEntry name="distortvol" type="number" min={0} default={1}>
Output gain applied after distortion to compensate for increased level.
<CodeEditor code={`/sound/sine/distort/4/distortvol/.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Lowpass Filter"
slug: "lowpass"
group: "effects"
order: 110
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A lowpass filter attenuates frequencies above the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency.
<CommandEntry name="lpf" type="number" min={20} max={20000} unit="Hz">
Cutoff frequency in Hz. Frequencies above this are attenuated.
<CodeEditor code={`/sound/saw/lpf/200`} rows={2} />
</CommandEntry>
<CommandEntry name="lpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Boosts frequencies near the cutoff.
<CodeEditor code={`/sound/saw/lpf/200/lpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="lpe" type="number" default={0}>
Envelope amount. Positive values sweep the cutoff up, negative values sweep down.
<CodeEditor code={`/sound/saw/lpf/100/lpe/5/lpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpa/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/lpf/100/lpd/.25/lps/.4`} rows={2} />
</CommandEntry>
<CommandEntry name="lpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,63 @@
---
title: "Oscillator"
slug: "oscillator"
group: "synthesis"
order: 104
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
These parameters are dedicated to alter the nominal behavior of each oscillator. Some parameters are specific to certain oscillators, most others can be used with all oscillators.
<CommandEntry name="pw" type="number" min={0} max={1} default={0.5}>
The pulse width (between 0 and 1) of the pulse oscillator. The default is 0.5 (square wave). Only has an effect when used with <code>/sound/pulse</code> or <code>/sound/pulze</code>.
<CodeEditor code={`/sound/pulse/pw/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="spread" type="number" min={0} max={100} default={0}>
Stereo unison. Adds 6 detuned voices (7 total) with stereo panning. Works with sine, tri, saw, zaw, pulse, pulze.
<CodeEditor code={`/sound/saw/spread/30`} rows={2} />
</CommandEntry>
Inspired by the M8 Tracker's WavSynth, these parameters transform the oscillator phase to create new timbres from basic waveforms. They work with all basic oscillators (sine, tri, saw, zaw, pulse, pulze).
<CommandEntry name="size" type="number" min={0} max={256} default={0}>
Phase quantization steps. Creates stair-step waveforms similar to 8-bit sound chips. Set to 0 to disable, or 2-256 for increasing resolution. Lower values produce more lo-fi, chiptune-like sounds.
<CodeEditor code={`/sound/sine/size/8`} rows={2} />
</CommandEntry>
<CommandEntry name="mult" type="number" min={0.25} max={16} default={1}>
Phase multiplier that wraps the waveform multiple times per cycle. Creates hard-sync-like harmonic effects. A value of 2 doubles the frequency content, 4 quadruples it, etc.
<CodeEditor code={`/sound/saw/mult/4`} rows={2} />
</CommandEntry>
<CommandEntry name="warp" type="number" min={-1} max={1} default={0}>
Phase asymmetry using a power curve. Positive values compress the early phase and expand the late phase. Negative values do the opposite. Creates timbral variations without changing pitch.
<CodeEditor code={`/sound/tri/warp/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="mirror" type="number" min={0} max={1} default={0}>
Reflects the phase at the specified position. At 0.5, creates symmetric waveforms (a saw becomes triangle-like). Values closer to 0 or 1 create increasingly asymmetric reflections.
<CodeEditor code={`/sound/saw/mirror/.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,53 @@
---
title: "Phaser"
slug: "phaser"
group: "effects"
order: 200
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Two cascaded notch filters (offset by 282Hz) with LFO-modulated center frequency.
<CommandEntry name="phaser" type="number" min={0} default={0} unit="Hz">
Phaser LFO rate in Hz. Creates sweeping notch filter effect.
<CodeEditor code={`/sound/saw/freq/50/phaser/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/2/phaserdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="phaserdepth" type="number" min={0} max={1} default={0.5}>
Phaser effect intensity (0-1). Controls resonance and wet/dry mix.
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phaserdepth/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/0.25/phaserdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="phasersweep" type="number" min={0} default={2000} unit="Hz">
Phaser frequency sweep range in Hz. Default is 2000 (±2000Hz sweep).
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phasersweep/4000`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/0.5/phasersweep/500`} rows={2} />
</CommandEntry>
<CommandEntry name="phasercenter" type="number" min={20} max={20000} default={1000} unit="Hz">
Phaser center frequency in Hz. Default is 1000Hz.
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phasercenter/500`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/2/phasercenter/2000`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,49 @@
---
title: "Pitch Env"
slug: "pitch-env"
group: "synthesis"
order: 106
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
An ADSR envelope applied to pitch. The envelope runs with gate always on (no release phase during note). The frequency is multiplied by <code>2^(env &#42; penv / 12)</code>. When <code>psus = 1</code>, the envelope value is offset by -1 so sustained notes return to base pitch.
<CommandEntry name="penv" type="number" default={0} unit="semitones">
Pitch envelope depth in semitones. Positive values sweep up, negative values sweep down.
<CodeEditor code={`/penv/24/pdec/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="patt" type="number" min={0} default={0.001} unit="s">
Attack time. Duration to reach peak pitch offset.
<CodeEditor code={`/patt/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="pdec" type="number" min={0} default={0} unit="s">
Decay time. Duration to fall from peak to sustain level.
<CodeEditor code={`/pdec/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="psus" type="number" min={0} max={1} default={1}>
Sustain level. At 1.0, the envelope returns to base pitch after decay.
</CommandEntry>
<CommandEntry name="prel" type="number" min={0} default={0.005} unit="s">
Release time. Not typically audible since pitch envelope gate stays on.
</CommandEntry>

View File

@@ -0,0 +1,65 @@
---
title: "Pitch"
slug: "pitch"
group: "synthesis"
order: 100
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Pitch control for all sources, including audio samples.
<CommandEntry name="freq" type="number" min={20} max={20000} default={330} unit="Hz">
The frequency of the sound. Has no effect on noise.
<CodeEditor code={`/freq/400`} rows={2} />
<CodeEditor code={`/freq/800`} rows={2} />
<CodeEditor code={`/freq/1200`} rows={2} />
</CommandEntry>
<CommandEntry name="note" type="number" min={0} max={127} unit="midi">
The note (midi number) that should be played.
If both note and freq is set, freq wins.
<CodeEditor code={`/note/60\n\n/note/67`} rows={4} />
<CodeEditor code={`/note/48\n\n/note/60\n\n/note/63\n\n/note/67`} rows={8} />
</CommandEntry>
<CommandEntry name="speed" type="number" default={1}>
Multiplies with the source frequency or buffer playback speed.
<CodeEditor code={`/sound/saw/freq/220/speed/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/220/speed/1.5`} rows={2} />
</CommandEntry>
<CommandEntry name="detune" type="number" default={0} unit="cents">
Shifts the pitch by the given amount in cents. 100 cents = 1 semitone.
<CodeEditor code={`/freq/440/detune/50`} rows={2} />
<CodeEditor code={`/freq/440/detune/-50`} rows={2} />
</CommandEntry>
<CommandEntry name="glide" type="number" min={0} default={0} unit="s">
Creates a pitch slide when changing the frequency of an active voice.
Only has an effect when used with <code>voice</code>.
<CodeEditor code={`/voice/0/freq/220\n\n/voice/0/freq/330/glide/0.5/time/0.25`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,175 @@
---
title: "Complex"
slug: "plaits"
group: "sources"
order: 1
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Complex oscillator engines based on Mutable Instruments Plaits. All engines share three parameters (0 to 1):
- **harmonics** — harmonic content, structure, detuning, etc.
- **timbre** — brightness, tonal color, etc.
- **morph** — smooth transitions between variations, etc.
Each engine interprets these differently.
<CommandEntry name="modal" type="source">
Modal resonator (physical modeling). Simulates struck/plucked resonant bodies. harmonics: structure, timbre: brightness, morph: damping/decay.
<CodeEditor code={`/sound/modal/note/48`} rows={2} />
<CodeEditor code={`/sound/modal/note/36/harmonics/0.8/morph/0.2`} rows={2} />
<CodeEditor code={`/sound/modal/note/60/timbre/0.7/morph/0.5/verb/0.3`} rows={2} />
</CommandEntry>
<CommandEntry name="va" type="source">
Virtual analog. Classic waveforms with sync and crossfading. harmonics: detuning, timbre: variable square, morph: variable saw.
<CodeEditor code={`/sound/va/note/36`} rows={2} />
<CodeEditor code={`/sound/va/note/48/harmonics/0.3/morph/0.8`} rows={2} />
<CodeEditor code={`/sound/va/note/36/timbre/0.2/lpf/1000`} rows={2} />
</CommandEntry>
<CommandEntry name="ws" type="source">
Waveshaping oscillator. Asymmetric triangle through waveshaper and wavefolder. harmonics: waveshaper shape, timbre: fold amount, morph: waveform asymmetry.
<CodeEditor code={`/sound/ws/note/36`} rows={2} />
<CodeEditor code={`/sound/ws/note/48/timbre/0.7/harmonics/0.5`} rows={2} />
<CodeEditor code={`/sound/ws/note/36/morph/0.8/timbre/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="fm2" type="source">
Two-operator FM synthesis. harmonics: frequency ratio, timbre: modulation index, morph: feedback.
<CodeEditor code={`/sound/fm2/note/48`} rows={2} />
<CodeEditor code={`/sound/fm2/note/60/timbre/0.5/harmonics/0.3`} rows={2} />
<CodeEditor code={`/sound/fm2/note/36/morph/0.7/timbre/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="grain" type="source">
Granular formant oscillator. Simulates formants through windowed sines. harmonics: formant ratio, timbre: formant frequency, morph: formant width.
<CodeEditor code={`/sound/grain/note/48`} rows={2} />
<CodeEditor code={`/sound/grain/note/36/timbre/0.6/harmonics/0.4`} rows={2} />
<CodeEditor code={`/sound/grain/note/60/morph/0.3/timbre/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="additive" type="source">
Harmonic oscillator. Additive mixture of sine harmonics. harmonics: number of bumps, timbre: prominent harmonic index, morph: bump shape.
<CodeEditor code={`/sound/additive/note/48`} rows={2} />
<CodeEditor code={`/sound/additive/note/36/timbre/0.5/harmonics/0.3`} rows={2} />
<CodeEditor code={`/sound/additive/note/60/morph/0.8/timbre/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="wavetable" type="source">
Wavetable oscillator. Four banks of 8x8 waveforms. harmonics: bank selection, timbre: row index, morph: column index.
<CodeEditor code={`/sound/wavetable/note/48`} rows={2} />
<CodeEditor code={`/sound/wavetable/note/36/timbre/0.5/morph/0.5`} rows={2} />
<CodeEditor code={`/sound/wavetable/note/60/harmonics/0.3/timbre/0.7/morph/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="chord" type="source">
Four-note chord engine. Virtual analog or wavetable chords. harmonics: chord type, timbre: inversion/transposition, morph: waveform.
<CodeEditor code={`/sound/chord/note/48`} rows={2} />
<CodeEditor code={`/sound/chord/note/36/harmonics/0.5`} rows={2} />
<CodeEditor code={`/sound/chord/note/48/harmonics/0.3/morph/0.7/verb/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="swarm" type="source">
Granular cloud of 8 enveloped sawtooth oscillators. harmonics: pitch randomization, timbre: grain density, morph: grain duration/overlap.
<CodeEditor code={`/sound/swarm/note/36`} rows={2} />
<CodeEditor code={`/sound/swarm/note/48/harmonics/0.5/morph/0.3`} rows={2} />
<CodeEditor code={`/sound/swarm/note/36/timbre/0.4/harmonics/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="pnoise" type="source">
Filtered noise. Clocked noise through multimode filter. harmonics: filter type (LP/BP/HP), timbre: clock frequency, morph: filter resonance.
<CodeEditor code={`/sound/pnoise/note/48`} rows={2} />
<CodeEditor code={`/sound/pnoise/note/36/harmonics/0.5/morph/0.7`} rows={2} />
<CodeEditor code={`/sound/pnoise/note/60/timbre/0.8/morph/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="kick" type="source">
Analog bass drum. 808-style kick. harmonics: punch, timbre: tone, morph: decay.
<CodeEditor code={`/sound/kick/note/36`} rows={2} />
<CodeEditor code={`/sound/kick/note/36/morph/0.3`} rows={2} />
<CodeEditor code={`/sound/kick/note/36/timbre/0.5/harmonics/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="snare" type="source">
Analog snare drum. harmonics: tone/noise balance, timbre: drum mode balance, morph: decay.
<CodeEditor code={`/sound/snare/note/48`} rows={2} />
<CodeEditor code={`/sound/snare/note/48/morph/0.5`} rows={2} />
<CodeEditor code={`/sound/snare/note/48/harmonics/0.8/timbre/0.3`} rows={2} />
</CommandEntry>
<CommandEntry name="hihat" type="source">
Analog hihat. 808-style metallic hihat. harmonics: metallic tone, timbre: high-pass filter, morph: decay.
<CodeEditor code={`/sound/hihat/note/60`} rows={2} />
<CodeEditor code={`/sound/hihat/note/60/morph/0.2`} rows={2} />
<CodeEditor code={`/sound/hihat/note/60/harmonics/0.5/timbre/0.6`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,53 @@
---
title: "Reverb"
slug: "reverb"
group: "effects"
order: 204
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Dattorro plate reverb with 4 input diffusers and a cross-fed stereo tank.
<CommandEntry name="verb" type="number" min={0} max={1} default={0}>
Send level to the reverb bus.
<CodeEditor code={`/verb/0.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdecay" type="number" min={0} max={1} default={0.5}>
Tank feedback amount (clamped to 0.99 max). Controls tail length.
<CodeEditor code={`/verb/0.8/verbdecay/0.9/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdamp" type="number" min={0} max={1} default={0.5}>
One-pole lowpass in the tank feedback path. Higher values darken the tail.
<CodeEditor code={`/verb/0.7/verbdamp/0.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbpredelay" type="number" min={0} max={1} default={0}>
Delay before the diffusers (0-1 of max ~100ms). Creates space before reverb onset.
<CodeEditor code={`/verb/0.6/verbpredelay/0.3/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdiff" type="number" min={0} max={1} default={0.7}>
Allpass coefficients in both input and tank diffusers. Higher values smear transients.
<CodeEditor code={`/verb/0.7/verbdiff/0.9/duration/.1`} rows={2} />
</CommandEntry>

37
website/src/content/rm.md Normal file
View File

@@ -0,0 +1,37 @@
---
title: "Ring Modulation"
slug: "rm"
group: "synthesis"
order: 110
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Ring modulation is a crossfade between dry signal and full multiplication: <code>signal &#42;= (1.0 - depth) + modulator &#42; depth</code>. Unlike AM, ring modulation at full depth removes the carrier entirely, leaving only sum and difference frequencies at <code>carrier ± modulator</code>.
<CommandEntry name="rm" type="number" min={0} default={0} unit="Hz">
Ring modulation oscillator frequency in Hz. When set above 0, an LFO multiplies the signal.
<CodeEditor code={`/freq/300/rm/440/rmdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="rmdepth" type="number" min={0} max={1} default={1}>
Modulation depth (0-1). At 0, the signal is unchanged. At 1, full ring modulation with no dry signal.
<CodeEditor code={`/freq/300/rm/440/rmdepth/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="rmshape" type="string" default="sine">
Ring modulation LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/freq/300/rm/8/rmdepth/1/rmshape/sh`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,51 @@
---
title: "Sample"
slug: "sample"
group: "synthesis"
order: 111
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Doux can play back audio samples organized in folders. Point to a samples directory using the <code>--samples</code> flag. Each subfolder becomes a sample bank accessible via <code>/s/folder_name</code>. Use <code>/n/</code> to index into a folder.
<CommandEntry name="n" type="number" min={0} default={0}>
Sample index within the folder. If the index exceeds the number of samples, it wraps around using modulo. Samples in a folder are indexed starting from 0.
<CodeEditor code={`/s/crate_rd/n/0`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/2`} rows={2} />
</CommandEntry>
<CommandEntry name="begin" type="number" min={0} max={1} default={0}>
Sample start position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples.
<CodeEditor code={`/s/crate_rd/n/2/begin/0.0`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/2/begin/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="end" type="number" min={0} max={1} default={1}>
Sample end position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples.
<CodeEditor code={`/s/crate_rd/n/2/end/0.05`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/3/end/0.1/speed/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="cut" type="number" min={0}>
Choke group. Voices with the same cut value silence each other. Use for hi-hats where open should be cut by closed.
<CodeEditor code={`/s/crate_hh/n/0/cut/1\n\n/s/crate_hh/n/1/cut/1/time/.25`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,37 @@
---
title: "Timing"
slug: "timing"
group: "synthesis"
order: 101
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The engine clock starts at 0 and advances with each sample. Events with <code>time</code> are scheduled and fired when the clock reaches that value. The <code>duration</code> sets how long the gate stays open before triggering release. The <code>repeat</code> reschedules the event at regular intervals.
<CommandEntry name="time" type="number" min={0} default={0} unit="s">
The time at which the voice should start. Defaults to 0.
<CodeEditor code={`/freq/330/time/0\n\n/freq/440/time/0.5`} rows={4} />
</CommandEntry>
<CommandEntry name="duration" type="number" min={0} unit="s">
The duration (seconds) of the gate phase. If not set, the voice will play indefinitely, until released explicitly.
<CodeEditor code={`/duration/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="repeat" type="number" min={0} unit="s">
If set, the command is repeated within the given number of seconds.
<CodeEditor code={`/freq/330/time/0/duration/0.5/repeat/1\n\n/freq/440/time/0.5/duration/0.5/repeat/1`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,37 @@
---
title: "Vibrato"
slug: "vibrato"
group: "synthesis"
order: 107
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The pitch of every oscillator can be modulated by a vibrato effect. Vibrato is a technique where the pitch of a note is modulated slightly around a central pitch, creating a shimmering effect.
<CommandEntry name="vib" type="number" min={0} default={0} unit="Hz">
Vibrato frequency (in hertz).
<CodeEditor code={`/vib/8`} rows={2} />
</CommandEntry>
<CommandEntry name="vibmod" type="number" min={0} default={0} unit="semitones">
Vibrato modulation depth (semitones).
<CodeEditor code={`/vib/8/vibmod/24`} rows={2} />
</CommandEntry>
<CommandEntry name="vibshape" type="string" default="sine">
Vibrato LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/vib/4/vibmod/1/vibshape/tri`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,29 @@
---
title: "Voice"
slug: "voice"
group: "synthesis"
order: 103
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Doux is a polyphonic synthesizer with up to 32 simultaneous voices. By default, each event allocates a new voice automatically. When a voice finishes (envelope reaches zero), it is freed and recycled. The <code>voice</code> parameter lets you take manual control over voice allocation, enabling parameter updates on active voices (e.g., pitch slides with <code>glide</code>) or retriggering with <code>reset</code>.
<CommandEntry name="voice" type="number" min={0}>
The voice index to use. If set, voice allocation will be skipped and the selected voice will be used. If the voice is still active, the sent params will update the active voice.
<CodeEditor code={`/voice/0/freq/220\n\n/voice/0/freq/330/time/.5`} rows={4} />
</CommandEntry>
<CommandEntry name="reset" type="boolean" default={false}>
Only has an effect when used together with voice. If set to 1, the selected voice will be reset, even when it's still active. This will cause envelopes to retrigger for example.
<CodeEditor code={`/voice/0/freq/220/attack/.1\n\n/voice/0/freq/330/time/.25/reset/1`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { untrack } from 'svelte';
import { doux } from '$lib/doux';
import { startScope, stopScope, registerActiveEditor, unregisterActiveEditor } from '$lib/scope';
import { Play, Square } from 'lucide-svelte';
interface Props {
code?: string;
rows?: number;
}
let { code = '', rows = 3 }: Props = $props();
let textarea: HTMLTextAreaElement;
let active = $state(false);
let currentCode = $state(untrack(() => code));
let evaluated = $state(false);
const resetCallback = () => {
active = false;
};
const highlight = (code: string) =>
code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(\/\/.*)/g, '<span class="hl-comment">$1</span>')
.replace(
/(\/)([a-zA-Z_][a-zA-Z0-9_]*)/g,
'<span class="hl-slash">$1</span><span class="hl-command">$2</span>'
)
.replace(
/(\/)(-?[0-9]*\.?[0-9]+)/g,
'<span class="hl-slash">$1</span><span class="hl-number">$2</span>'
) + '\n';
let highlighted = $derived(highlight(currentCode));
function flash() {
evaluated = false;
setTimeout(() => {
evaluated = true;
}, 50);
}
async function run() {
flash();
await doux.ready;
doux.evaluate({ doux: 'reset_schedule' });
doux.evaluate({ doux: !active ? 'reset' : 'hush_endless' });
const blocks = currentCode.split('\n\n').filter(Boolean);
const msgs = await Promise.all(
blocks.map((block) => {
const event = doux.parsePath(block);
return doux.prepare({ doux: 'play', ...event });
})
);
if (!active) {
doux.evaluate({ doux: 'reset_time' });
registerActiveEditor(resetCallback);
active = true;
startScope();
}
msgs.forEach((msg) => doux.send(msg));
}
function stop() {
active = false;
unregisterActiveEditor(resetCallback);
stopScope();
doux.hush();
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.altKey) && e.key === 'Enter') {
run();
}
}
function handleScroll() {
const pre = textarea.previousElementSibling as HTMLPreElement;
if (pre) pre.scrollTop = textarea.scrollTop;
}
</script>
<div class="repl">
<div class="repl-editor">
<pre class="hl-pre" aria-hidden="true">{@html highlighted}</pre>
<textarea
bind:this={textarea}
bind:value={currentCode}
spellcheck="false"
{rows}
class:evaluated
onkeydown={handleKeydown}
onscroll={handleScroll}
></textarea>
</div>
<div class="repl-controls">
{#if active}
<button class="stop" onclick={stop}><Square size={16} /></button>
{:else}
<button class="play" onclick={run}><Play size={16} /></button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
name: string;
type?: "number" | "boolean" | "enum" | "source";
min?: number;
max?: number;
default?: number | string | boolean;
unit?: string;
values?: string[];
children: Snippet;
}
let {
name,
type,
min,
max,
default: defaultValue,
unit,
values,
children,
}: Props = $props();
function formatRange(): string | null {
if (min !== undefined && max !== undefined) {
return `${min}${max}`;
}
if (min !== undefined) {
return `≥${min}`;
}
if (max !== undefined) {
return `≤${max}`;
}
return null;
}
</script>
<details id={name}>
<summary>
<span class="name">{name}</span>
{#if type && type !== "source"}
<span class="meta">
<span class="type">{type}</span>
{#if formatRange()}
<span class="range"
>{formatRange()}{#if unit}
{unit}{/if}</span
>
{:else if unit}
<span class="unit">{unit}</span>
{/if}
{#if defaultValue !== undefined}
<span class="default">={defaultValue}</span>
{/if}
{#if values}
<span class="values">{values.join(" | ")}</span>
{/if}
</span>
{/if}
</summary>
<div class="content">
{@render children()}
</div>
</details>
<style>
details {
margin: 16px 0;
background: #f5f5f5;
border: 1px solid #ccc;
}
summary {
padding: 10px 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::before {
content: "▶";
font-size: 0.7em;
color: #999;
transition: transform 0.15s;
}
details[open] summary::before {
transform: rotate(90deg);
}
.name {
font-weight: bold;
}
.meta {
display: inline-flex;
gap: 6px;
font-size: 0.85em;
}
.meta span {
padding: 2px 6px;
background: #eee;
color: #666;
}
.type {
color: #666 !important;
}
.range {
color: #666 !important;
}
.default {
color: #999 !important;
}
.content {
padding: 0 14px 14px;
border-top: 1px solid #ddd;
}
</style>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { doux } from '$lib/doux';
import { Home, FileText, LifeBuoy, Terminal } from 'lucide-svelte';
import Scope from './Scope.svelte';
let micEnabled = $state(false);
let micLoading = $state(false);
async function toggleMic() {
if (micEnabled) {
doux.disableMic();
micEnabled = false;
} else {
micLoading = true;
await doux.enableMic();
micEnabled = true;
micLoading = false;
}
}
</script>
<nav>
<a href="/" class="nav-title"><h1>Doux</h1></a>
<div class="nav-links">
<a href="/" class="nav-link"><Home size={16} /> Home</a>
<a href="/reference" class="nav-link"><FileText size={16} /> Reference</a>
<a href="/native" class="nav-link"><Terminal size={16} /> Native</a>
<a href="/support" class="nav-link"><LifeBuoy size={16} /> Support</a>
</div>
<div class="nav-scope">
<Scope />
</div>
<button
class="mic-btn"
class:mic-enabled={micEnabled}
disabled={micLoading}
onclick={toggleMic}
>
{micLoading ? '...' : '🎤 Microphone'}
</button>
</nav>
<div class="nav-tabs">
<a href="/" class="nav-tab"><Home size={20} /></a>
<a href="/reference" class="nav-tab"><FileText size={20} /></a>
<a href="/native" class="nav-tab"><Terminal size={20} /></a>
<a href="/support" class="nav-tab"><LifeBuoy size={20} /></a>
</div>
<style>
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-title {
text-decoration: none;
margin-right: 16px;
}
.nav-title h1 {
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: #666;
}
.nav-link:hover {
color: #000;
}
.nav-tabs {
display: none;
}
.nav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: #666;
text-decoration: none;
}
.nav-tab:hover {
color: #000;
background: #f5f5f5;
}
@media (max-width: 768px) {
.nav-links {
display: none;
}
.nav-tabs {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: #fff;
border-top: 1px solid #ccc;
z-index: 100;
}
}
</style>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initScope } from '$lib/scope';
let { class: className = '' }: { class?: string } = $props();
let canvas: HTMLCanvasElement;
onMount(() => {
const cleanup = initScope(canvas);
return cleanup;
});
</script>
<div class="scope {className}">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
interface NavItem {
name: string;
category: string;
group: string;
}
interface Props {
items: NavItem[];
}
let { items }: Props = $props();
let expanded = $state<Record<string, boolean>>({});
function toggleCategory(category: string) {
expanded[category] = !expanded[category];
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const categoryNames: Record<string, string> = {
plaits: "Complex",
io: "Audio Input",
am: "Amplitude Modulation",
rm: "Ring Modulation",
lowpass: "Lowpass Filter",
highpass: "Highpass Filter",
bandpass: "Bandpass Filter",
ftype: "Filter Type",
};
function formatCategory(str: string): string {
return categoryNames[str] ?? capitalize(str);
}
const grouped = $derived.by(() => {
const groups: Record<string, Record<string, NavItem[]>> = {
sources: {},
synthesis: {},
effects: {},
};
for (const item of items) {
const group = item.group;
const category = item.category;
if (!groups[group]) continue;
if (!groups[group][category]) {
groups[group][category] = [];
}
groups[group][category].push(item);
}
return groups;
});
</script>
<aside class="sidebar">
{#each ["sources", "synthesis", "effects"] as group (group)}
<div class="sidebar-section">{capitalize(group)}</div>
{#each Object.entries(grouped[group]) as [category, navItems] (category)}
<button
class="category-toggle"
onclick={() => toggleCategory(category)}
>
{formatCategory(category)}
</button>
{#if expanded[category]}
<div class="commands">
{#each navItems as item (item.name)}
<a href="#{item.name}" class="command-link"
>{item.name}</a
>
{/each}
</div>
{/if}
{/each}
{/each}
</aside>
<style>
.sidebar-section {
margin-top: 16px;
}
.category-toggle {
display: block;
width: 100%;
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 4px 8px;
font-size: inherit;
font-family: inherit;
text-align: left;
}
.category-toggle:hover {
color: #000;
background: #f5f5f5;
}
.commands {
padding-left: 18px;
}
.command-link {
display: block;
color: #666;
text-decoration: none;
padding: 2px 8px;
font-size: 0.9em;
}
.command-link:hover {
color: #000;
background: #f5f5f5;
}
</style>

443
website/src/lib/doux.ts Normal file
View File

@@ -0,0 +1,443 @@
import type { DouxEvent, SoundInfo, ClockMessage, DouxOptions, PreparedMessage } from './types';
const soundMap = new Map<string, string[]>();
const loadedSounds = new Map<string, SoundInfo>();
const loadingSounds = new Map<string, Promise<SoundInfo>>();
let pcm_offset = 0;
const sources = [
'triangle', 'tri', 'sine', 'sawtooth', 'saw', 'zawtooth', 'zaw',
'pulse', 'square', 'pulze', 'zquare', 'white', 'pink', 'brown',
'live', 'livein', 'mic'
];
function githubPath(base: string, subpath = ''): string {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let [, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
path += '/main';
}
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
async function fetchSampleMap(url: string): Promise<[Record<string, string[]>, string] | undefined> {
if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json');
}
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
if (url.startsWith('shabda:')) {
const [, path] = url.split('shabda:');
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
}
if (url.startsWith('shabda/speech')) {
let [, path] = url.split('shabda/speech');
path = path.startsWith('/') ? path.substring(1) : path;
const [params, words] = path.split(':');
let gender = 'f';
let language = 'en-GB';
if (params) {
[language, gender] = params.split('/');
}
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
}
if (typeof fetch !== 'function') {
return;
}
const base = url.split('/').slice(0, -1).join('/');
const json = await fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.catch((error) => {
throw new Error(`error loading "${url}": ${error.message}`);
});
return [json, json._base || base];
}
export async function douxsamples(
sampleMap: string | Record<string, string[]>,
baseUrl?: string
): Promise<void> {
if (typeof sampleMap === 'string') {
const result = await fetchSampleMap(sampleMap);
if (!result) return;
const [json, base] = result;
return douxsamples(json, base);
}
Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') {
urls = urls.map((url) => baseUrl + url);
soundMap.set(key, urls);
}
});
}
const BLOCK_SIZE = 128;
const CHANNELS = 2;
const CLOCK_SIZE = 16;
// AudioWorklet processor code - runs in worklet context
const workletCode = `
const BLOCK_SIZE = 128;
const CHANNELS = 2;
const CLOCK_SIZE = 16;
let wasmExports = null;
let wasmMemory = null;
let output = null;
let input_buffer = null;
let event_input_ptr = 0;
let framebuffer = null;
let framebuffer_ptr = 0;
let frame_ptr = 0;
let frameIdx = 0;
let block = 0;
class DouxProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.active = true;
this.clock_active = options.processorOptions?.clock_active || false;
this.clockmsg = {
clock: true,
t0: 0,
t1: 0,
latency: (CLOCK_SIZE * BLOCK_SIZE) / sampleRate,
};
this.port.onmessage = async (e) => {
const { wasm, evaluate, event_input, panic, writePcm } = e.data;
if (wasm) {
const { instance } = await WebAssembly.instantiate(wasm, {});
wasmExports = instance.exports;
wasmMemory = wasmExports.memory;
wasmExports.doux_init(sampleRate);
event_input_ptr = wasmExports.get_event_input_pointer();
output = new Float32Array(
wasmMemory.buffer,
wasmExports.get_output_pointer(),
BLOCK_SIZE * CHANNELS,
);
input_buffer = new Float32Array(
wasmMemory.buffer,
wasmExports.get_input_buffer_pointer(),
BLOCK_SIZE * CHANNELS,
);
framebuffer_ptr = wasmExports.get_framebuffer_pointer();
frame_ptr = wasmExports.get_frame_pointer();
const framebufferLen = Math.floor((sampleRate / 60) * CHANNELS) * 4;
framebuffer = new Float32Array(framebufferLen);
this.port.postMessage({ ready: true, sampleRate });
} else if (writePcm) {
const { data, offset } = writePcm;
const pcm_ptr = wasmExports.get_sample_buffer_pointer();
const pcm_len = wasmExports.get_sample_buffer_len();
const pcm = new Float32Array(wasmMemory.buffer, pcm_ptr, pcm_len);
pcm.set(data, offset);
this.port.postMessage({ pcmWritten: offset });
} else if (evaluate && event_input) {
new Uint8Array(
wasmMemory.buffer,
event_input_ptr,
event_input.length,
).set(event_input);
wasmExports.evaluate();
} else if (panic) {
wasmExports.panic();
}
};
}
process(inputs, outputs, parameters) {
if (wasmExports && outputs[0][0]) {
if (input_buffer && inputs[0] && inputs[0][0]) {
for (let i = 0; i < inputs[0][0].length; i++) {
const offset = i * CHANNELS;
for (let c = 0; c < CHANNELS; c++) {
input_buffer[offset + c] = inputs[0][c]?.[i] ?? inputs[0][0][i];
}
}
}
wasmExports.dsp();
const out = outputs[0];
for (let i = 0; i < out[0].length; i++) {
const offset = i * CHANNELS;
for (let c = 0; c < CHANNELS; c++) {
out[c][i] = output[offset + c];
if (framebuffer) {
framebuffer[frameIdx * CHANNELS + c] = output[offset + c];
}
}
frameIdx = (frameIdx + 1) % (framebuffer.length / CHANNELS);
}
block++;
if (block % 8 === 0 && framebuffer) {
this.port.postMessage({
framebuffer: framebuffer.slice(),
frame: frameIdx,
});
}
if (this.clock_active && block % CLOCK_SIZE === 0) {
this.clockmsg.t0 = this.clockmsg.t1;
this.clockmsg.t1 = wasmExports.get_time();
this.port.postMessage(this.clockmsg);
}
}
return this.active;
}
}
registerProcessor("doux-processor", DouxProcessor);
`;
export class Doux {
base: string;
BLOCK_SIZE = BLOCK_SIZE;
CHANNELS = CHANNELS;
ready: Promise<void>;
sampleRate = 0;
frame: Int32Array = new Int32Array(1);
framebuffer: Float32Array = new Float32Array(0);
samplesReady: Promise<void> | null = null;
private initAudio: Promise<AudioContext>;
private worklet: AudioWorkletNode | null = null;
private encoder: TextEncoder | null = null;
private micSource: MediaStreamAudioSourceNode | null = null;
private micStream: MediaStream | null = null;
private onTick?: (msg: ClockMessage) => void;
constructor(options: DouxOptions = {}) {
this.base = options.base ?? '/';
this.onTick = options.onTick;
this.initAudio = new Promise((resolve) => {
if (typeof document === 'undefined') return;
document.addEventListener('click', async function init() {
const ac = new AudioContext();
await ac.resume();
resolve(ac);
document.removeEventListener('click', init);
});
});
this.ready = this.runWorklet();
}
private async initWorklet(): Promise<AudioWorkletNode> {
const ac = await this.initAudio;
const blob = new Blob([workletCode], { type: 'application/javascript' });
const dataURL = URL.createObjectURL(blob);
await ac.audioWorklet.addModule(dataURL);
const worklet = new AudioWorkletNode(ac, 'doux-processor', {
outputChannelCount: [CHANNELS],
processorOptions: { clock_active: !!this.onTick }
});
worklet.connect(ac.destination);
const res = await fetch(`${this.base}doux.wasm?t=${Date.now()}`);
const wasm = await res.arrayBuffer();
return new Promise((resolve) => {
worklet.port.onmessage = async (e) => {
if (e.data.ready) {
this.sampleRate = e.data.sampleRate;
this.frame = new Int32Array(1);
this.frame[0] = 0;
const framebufferLen = Math.floor((this.sampleRate / 60) * CHANNELS) * 4;
this.framebuffer = new Float32Array(framebufferLen);
this.samplesReady = douxsamples('https://samples.raphaelforment.fr');
resolve(worklet);
} else if (e.data.clock) {
this.onTick?.(e.data);
} else if (e.data.framebuffer) {
this.framebuffer.set(e.data.framebuffer);
this.frame[0] = e.data.frame;
}
};
worklet.port.postMessage({ wasm });
});
}
private async runWorklet(): Promise<void> {
const ac = await this.initAudio;
if (ac.state !== 'running') await ac.resume();
if (this.worklet) return;
this.worklet = await this.initWorklet();
}
parsePath(path: string): DouxEvent {
const chunks = path
.trim()
.split('\n')
.map((line) => line.split('//')[0])
.join('')
.split('/')
.filter(Boolean);
const pairs: [string, string | undefined][] = [];
for (let i = 0; i < chunks.length; i += 2) {
pairs.push([chunks[i].trim(), chunks[i + 1]?.trim()]);
}
return Object.fromEntries(pairs);
}
private encodeEvent(input: string | DouxEvent): Uint8Array {
if (!this.encoder) this.encoder = new TextEncoder();
const str =
typeof input === 'string'
? input
: Object.entries(input)
.map(([k, v]) => `${k}/${v}`)
.join('/');
return this.encoder.encode(str + '\0');
}
async evaluate(input: DouxEvent): Promise<void> {
const msg = await this.prepare(input);
return this.send(msg);
}
async hush(): Promise<void> {
await this.panic();
const ac = await this.initAudio;
ac.suspend();
}
async resume(): Promise<void> {
const ac = await this.initAudio;
if (ac.state !== 'running') await ac.resume();
}
async panic(): Promise<void> {
await this.ready;
this.worklet?.port.postMessage({ panic: true });
}
async enableMic(): Promise<void> {
await this.ready;
const ac = await this.initAudio;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = ac.createMediaStreamSource(stream);
if (this.worklet) source.connect(this.worklet);
this.micSource = source;
this.micStream = stream;
}
disableMic(): void {
if (this.micSource) {
this.micSource.disconnect();
this.micSource = null;
}
if (this.micStream) {
this.micStream.getTracks().forEach((t) => t.stop());
this.micStream = null;
}
}
async prepare(event: DouxEvent): Promise<PreparedMessage> {
await this.ready;
if (this.samplesReady) await this.samplesReady;
await this.maybeLoadFile(event);
const encoded = this.encodeEvent(event);
return {
evaluate: true,
event_input: encoded
};
}
async send(msg: PreparedMessage): Promise<void> {
await this.resume();
this.worklet?.port.postMessage(msg);
}
private async fetchSample(url: string): Promise<Float32Array> {
const ac = await this.initAudio;
const encoded = encodeURI(url);
const buffer = await fetch(encoded)
.then((res) => res.arrayBuffer())
.then((buf) => ac.decodeAudioData(buf));
return buffer.getChannelData(0);
}
private async loadSound(s: string, n = 0): Promise<SoundInfo> {
const soundKey = `${s}:${n}`;
if (loadedSounds.has(soundKey)) {
return loadedSounds.get(soundKey)!;
}
if (!loadingSounds.has(soundKey)) {
const urls = soundMap.get(s);
if (!urls) throw new Error(`sound ${s} not found in soundMap`);
const url = urls[n % urls.length];
const promise = this.fetchSample(url).then(async (data) => {
const offset = pcm_offset;
pcm_offset += data.length;
await this.sendPcmData(data, offset);
const info: SoundInfo = {
pcm_offset: offset,
frames: data.length,
channels: 1,
freq: 65.406
};
loadedSounds.set(soundKey, info);
return info;
});
loadingSounds.set(soundKey, promise);
}
return loadingSounds.get(soundKey)!;
}
private sendPcmData(data: Float32Array, offset: number): Promise<void> {
return new Promise((resolve) => {
const handler = (e: MessageEvent) => {
if (e.data.pcmWritten === offset) {
this.worklet?.port.removeEventListener('message', handler);
resolve();
}
};
this.worklet?.port.addEventListener('message', handler);
this.worklet?.port.postMessage({ writePcm: { data, offset } });
});
}
private async maybeLoadFile(event: DouxEvent): Promise<void> {
const s = event.s || event.sound;
if (!s || typeof s !== 'string') return;
if (sources.includes(s)) return;
if (!soundMap.has(s)) return;
const n = typeof event.n === 'string' ? parseInt(event.n) : event.n ?? 0;
const info = await this.loadSound(s, n);
event.file_pcm = info.pcm_offset;
event.file_frames = info.frames;
event.file_channels = info.channels;
event.file_freq = info.freq;
}
async play(path: string): Promise<void> {
await this.resume();
if (this.samplesReady) await this.samplesReady;
const event = this.parsePath(path);
await this.maybeLoadFile(event);
const encoded = this.encodeEvent(event);
const msg = {
evaluate: true,
event_input: encoded
};
this.worklet?.port.postMessage(msg);
}
}
// Singleton instance
export const doux = new Doux();
// Load default samples
douxsamples('github:eddyflux/crate');

View File

@@ -0,0 +1,132 @@
[
{ "name": "sine", "category": "basic", "group": "sources" },
{ "name": "tri", "category": "basic", "group": "sources" },
{ "name": "saw", "category": "basic", "group": "sources" },
{ "name": "zaw", "category": "basic", "group": "sources" },
{ "name": "pulse", "category": "basic", "group": "sources" },
{ "name": "pulze", "category": "basic", "group": "sources" },
{ "name": "white", "category": "basic", "group": "sources" },
{ "name": "pink", "category": "basic", "group": "sources" },
{ "name": "brown", "category": "basic", "group": "sources" },
{ "name": "modal", "category": "plaits", "group": "sources" },
{ "name": "va", "category": "plaits", "group": "sources" },
{ "name": "ws", "category": "plaits", "group": "sources" },
{ "name": "fm2", "category": "plaits", "group": "sources" },
{ "name": "grain", "category": "plaits", "group": "sources" },
{ "name": "additive", "category": "plaits", "group": "sources" },
{ "name": "wavetable", "category": "plaits", "group": "sources" },
{ "name": "chord", "category": "plaits", "group": "sources" },
{ "name": "swarm", "category": "plaits", "group": "sources" },
{ "name": "pnoise", "category": "plaits", "group": "sources" },
{ "name": "particle", "category": "plaits", "group": "sources" },
{ "name": "string", "category": "plaits", "group": "sources" },
{ "name": "speech", "category": "plaits", "group": "sources" },
{ "name": "kick", "category": "plaits", "group": "sources" },
{ "name": "snare", "category": "plaits", "group": "sources" },
{ "name": "hihat", "category": "plaits", "group": "sources" },
{ "name": "live", "category": "io", "group": "sources" },
{ "name": "freq", "category": "pitch", "group": "synthesis" },
{ "name": "note", "category": "pitch", "group": "synthesis" },
{ "name": "speed", "category": "pitch", "group": "synthesis" },
{ "name": "detune", "category": "pitch", "group": "synthesis" },
{ "name": "glide", "category": "pitch", "group": "synthesis" },
{ "name": "time", "category": "timing", "group": "synthesis" },
{ "name": "duration", "category": "timing", "group": "synthesis" },
{ "name": "repeat", "category": "timing", "group": "synthesis" },
{ "name": "attack", "category": "envelope", "group": "synthesis" },
{ "name": "decay", "category": "envelope", "group": "synthesis" },
{ "name": "sustain", "category": "envelope", "group": "synthesis" },
{ "name": "release", "category": "envelope", "group": "synthesis" },
{ "name": "voice", "category": "voice", "group": "synthesis" },
{ "name": "reset", "category": "voice", "group": "synthesis" },
{ "name": "pw", "category": "oscillator", "group": "synthesis" },
{ "name": "spread", "category": "oscillator", "group": "synthesis" },
{ "name": "size", "category": "oscillator", "group": "synthesis" },
{ "name": "mult", "category": "oscillator", "group": "synthesis" },
{ "name": "warp", "category": "oscillator", "group": "synthesis" },
{ "name": "mirror", "category": "oscillator", "group": "synthesis" },
{ "name": "timbre", "category": "plaits", "group": "synthesis" },
{ "name": "harmonics", "category": "plaits", "group": "synthesis" },
{ "name": "morph", "category": "plaits", "group": "synthesis" },
{ "name": "gain", "category": "gain", "group": "synthesis" },
{ "name": "postgain", "category": "gain", "group": "synthesis" },
{ "name": "velocity", "category": "gain", "group": "synthesis" },
{ "name": "pan", "category": "gain", "group": "synthesis" },
{ "name": "penv", "category": "pitch-env", "group": "synthesis" },
{ "name": "patt", "category": "pitch-env", "group": "synthesis" },
{ "name": "pdec", "category": "pitch-env", "group": "synthesis" },
{ "name": "psus", "category": "pitch-env", "group": "synthesis" },
{ "name": "prel", "category": "pitch-env", "group": "synthesis" },
{ "name": "vib", "category": "vibrato", "group": "synthesis" },
{ "name": "vibmod", "category": "vibrato", "group": "synthesis" },
{ "name": "vibshape", "category": "vibrato", "group": "synthesis" },
{ "name": "fmh", "category": "fm", "group": "synthesis" },
{ "name": "fm", "category": "fm", "group": "synthesis" },
{ "name": "fmshape", "category": "fm", "group": "synthesis" },
{ "name": "fmenv", "category": "fm", "group": "synthesis" },
{ "name": "fma", "category": "fm", "group": "synthesis" },
{ "name": "fmd", "category": "fm", "group": "synthesis" },
{ "name": "fms", "category": "fm", "group": "synthesis" },
{ "name": "fmr", "category": "fm", "group": "synthesis" },
{ "name": "am", "category": "am", "group": "synthesis" },
{ "name": "amdepth", "category": "am", "group": "synthesis" },
{ "name": "amshape", "category": "am", "group": "synthesis" },
{ "name": "rm", "category": "rm", "group": "synthesis" },
{ "name": "rmdepth", "category": "rm", "group": "synthesis" },
{ "name": "rmshape", "category": "rm", "group": "synthesis" },
{ "name": "n", "category": "sample", "group": "synthesis" },
{ "name": "begin", "category": "sample", "group": "synthesis" },
{ "name": "end", "category": "sample", "group": "synthesis" },
{ "name": "cut", "category": "sample", "group": "synthesis" },
{ "name": "lpf", "category": "lowpass", "group": "effects" },
{ "name": "lpq", "category": "lowpass", "group": "effects" },
{ "name": "lpe", "category": "lowpass", "group": "effects" },
{ "name": "lpa", "category": "lowpass", "group": "effects" },
{ "name": "lpd", "category": "lowpass", "group": "effects" },
{ "name": "lps", "category": "lowpass", "group": "effects" },
{ "name": "lpr", "category": "lowpass", "group": "effects" },
{ "name": "hpf", "category": "highpass", "group": "effects" },
{ "name": "hpq", "category": "highpass", "group": "effects" },
{ "name": "hpe", "category": "highpass", "group": "effects" },
{ "name": "hpa", "category": "highpass", "group": "effects" },
{ "name": "hpd", "category": "highpass", "group": "effects" },
{ "name": "hps", "category": "highpass", "group": "effects" },
{ "name": "hpr", "category": "highpass", "group": "effects" },
{ "name": "bpf", "category": "bandpass", "group": "effects" },
{ "name": "bpq", "category": "bandpass", "group": "effects" },
{ "name": "bpe", "category": "bandpass", "group": "effects" },
{ "name": "bpa", "category": "bandpass", "group": "effects" },
{ "name": "bpd", "category": "bandpass", "group": "effects" },
{ "name": "bps", "category": "bandpass", "group": "effects" },
{ "name": "bpr", "category": "bandpass", "group": "effects" },
{ "name": "comb", "category": "comb-filter", "group": "effects" },
{ "name": "combfreq", "category": "comb-filter", "group": "effects" },
{ "name": "combfeedback", "category": "comb-filter", "group": "effects" },
{ "name": "combdamp", "category": "comb-filter", "group": "effects" },
{ "name": "ftype", "category": "ftype", "group": "effects" },
{ "name": "phaser", "category": "phaser", "group": "effects" },
{ "name": "phaserdepth", "category": "phaser", "group": "effects" },
{ "name": "phasersweep", "category": "phaser", "group": "effects" },
{ "name": "phasercenter", "category": "phaser", "group": "effects" },
{ "name": "flanger", "category": "flanger", "group": "effects" },
{ "name": "flangerdepth", "category": "flanger", "group": "effects" },
{ "name": "flangerfeedback", "category": "flanger", "group": "effects" },
{ "name": "chorus", "category": "chorus", "group": "effects" },
{ "name": "chorusdepth", "category": "chorus", "group": "effects" },
{ "name": "chorusdelay", "category": "chorus", "group": "effects" },
{ "name": "delay", "category": "delay", "group": "effects" },
{ "name": "delayfeedback", "category": "delay", "group": "effects" },
{ "name": "delaytime", "category": "delay", "group": "effects" },
{ "name": "delaytype", "category": "delay", "group": "effects" },
{ "name": "verb", "category": "reverb", "group": "effects" },
{ "name": "verbdecay", "category": "reverb", "group": "effects" },
{ "name": "verbdamp", "category": "reverb", "group": "effects" },
{ "name": "verbpredelay", "category": "reverb", "group": "effects" },
{ "name": "verbdiff", "category": "reverb", "group": "effects" },
{ "name": "coarse", "category": "lofi", "group": "effects" },
{ "name": "crush", "category": "lofi", "group": "effects" },
{ "name": "fold", "category": "lofi", "group": "effects" },
{ "name": "wrap", "category": "lofi", "group": "effects" },
{ "name": "distort", "category": "lofi", "group": "effects" },
{ "name": "distortvol", "category": "lofi", "group": "effects" }
]

99
website/src/lib/scope.ts Normal file
View File

@@ -0,0 +1,99 @@
import { doux } from './doux';
let ctx: CanvasRenderingContext2D | null = null;
let raf: number | null = null;
const lerp = (v: number, min: number, max: number) => v * (max - min) + min;
const invLerp = (v: number, min: number, max: number) => (v - min) / (max - min);
const remap = (v: number, vmin: number, vmax: number, omin: number, omax: number) =>
lerp(invLerp(v, vmin, vmax), omin, omax);
function drawBuffer(
ctx: CanvasRenderingContext2D,
samples: Float32Array,
channels: number,
channel: number,
ampMin: number,
ampMax: number
) {
const lineWidth = 2;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = 'black';
const perChannel = samples.length / channels / 2;
const pingbuffer = doux.frame[0] > samples.length / 2;
const s0 = pingbuffer ? 0 : perChannel;
const s1 = pingbuffer ? perChannel : perChannel * 2;
const px0 = ctx.lineWidth;
const px1 = ctx.canvas.width - ctx.lineWidth;
const py0 = ctx.lineWidth;
const py1 = ctx.canvas.height - ctx.lineWidth;
ctx.beginPath();
for (let px = 1; px <= ctx.canvas.width; px++) {
const si = remap(px, px0, px1, s0, s1);
const idx = Math.floor(si) * channels + channel;
const amp = samples[idx];
if (amp >= 1) ctx.strokeStyle = 'red';
const py = remap(amp, ampMin, ampMax, py1, py0);
px === 1 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
}
function drawScope() {
if (!ctx) return;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let c = 0; c < doux.CHANNELS; c++) {
drawBuffer(ctx, doux.framebuffer, doux.CHANNELS, c, -1, 1);
}
raf = requestAnimationFrame(drawScope);
}
export function initScope(canvas: HTMLCanvasElement) {
function resize() {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
}
resize();
ctx = canvas.getContext('2d');
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}
export function startScope() {
if (!raf && ctx) {
drawScope();
}
}
export function stopScope() {
if (raf) {
cancelAnimationFrame(raf);
raf = null;
}
if (ctx) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
}
let activeResetCallback: (() => void) | null = null;
export function registerActiveEditor(resetCallback: () => void) {
if (activeResetCallback && activeResetCallback !== resetCallback) {
activeResetCallback();
}
activeResetCallback = resetCallback;
}
export function unregisterActiveEditor(resetCallback: () => void) {
if (activeResetCallback === resetCallback) {
activeResetCallback = null;
}
}
export function resetActiveEditor() {
if (activeResetCallback) {
activeResetCallback();
activeResetCallback = null;
}
}

37
website/src/lib/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface DouxEvent {
doux?: string;
s?: string;
sound?: string;
n?: string | number;
freq?: number;
wave?: string;
file_pcm?: number;
file_frames?: number;
file_channels?: number;
file_freq?: number;
[key: string]: string | number | undefined;
}
export interface SoundInfo {
pcm_offset: number;
frames: number;
channels: number;
freq: number;
}
export interface ClockMessage {
clock: boolean;
t0: number;
t1: number;
latency: number;
}
export interface DouxOptions {
onTick?: (msg: ClockMessage) => void;
base?: string;
}
export interface PreparedMessage {
evaluate: boolean;
event_input: Uint8Array;
}

View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,4 @@
// Layout doesn't need to load content - it's loaded by +page.ts
export function load() {
return {};
}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import "../app.css";
import Nav from "$lib/components/Nav.svelte";
import { doux } from "$lib/doux";
import { stopScope, resetActiveEditor } from "$lib/scope";
let { children } = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
resetActiveEditor();
stopScope();
doux.hush();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<Nav />
<div class="layout">
{@render children()}
</div>

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import CodeEditor from "$lib/components/CodeEditor.svelte";
</script>
<main class="tutorial">
<section class="intro">
<p>
A monolithic audio engine for live coding ported from C to Rust by <a
href="https://raphaelforment.fr">BuboBubo</a
>. The initial project is called
<a href="https://dough.strudel.cc/">Dough</a>, designed by
<a href="https://eddyflux.cc/">Felix Roos</a>
and al. Doux can run in a web browser via
<a href="https://webassembly.org/">WebAssembly</a>, or natively
using an an OSC server and/or a REPL. Doux is an opinionated, fixed
path, semi-modular synth that is remote-controlled via messages. It
was designed to be used with
<a href="https://strudel.cc">Strudel</a> and
<a href="https://tidalcycles.org">TidalCycles</a>.
<b
>This fork is a bit special: it adapts and specialize the engine
for integration with
<a href="https://sova.livecoding.fr">Sova</a>, a live coding
environment built in Rust</b
>.
</p>
<p>
<b>Important note</b>: this project is AGPL 3.0 licensed. We
encourage you to support the development of the original version
through the
<a href="https://opencollective.com/tidalcycles"
>TidalCycles Open Collective</a
>. See the license page for more information.
</p>
<a href="/support" class="support-link">License & Support</a>
</section>
<section>
<h2>Getting Started</h2>
<p>
Click anywhere on the page to start the audio context. Then click
inside a code block and press <code>Ctrl+Enter</code> to run it, or
<code>Escape</code> to stop. The easiest way to start is just to
specify a <code>sound</code> to use:
</p>
<CodeEditor code={`/sound/sine`} rows={2} />
<p>
You can set the pitch with the <code>/note</code> parameter (MIDI note
numbers):
</p>
<CodeEditor code={`/sound/sine/note/64`} rows={2} />
<p>
Or use frequency directly with <code>/freq</code>:
</p>
<CodeEditor code={`/sound/sine/freq/330`} rows={2} />
</section>
<section>
<h2>Omitting parameters</h2>
<p>
It is possible to omit a large number of parameters or to
under-specify a voice. Doux has preconfigured defaults for most of
the core parameters.
</p>
<CodeEditor code={`/freq/330`} rows={2} />
<CodeEditor code={`/spread/5/decay/0.5`} rows={2} />
<p>
The default voice is always a <code>tri</code> with sensible envelope
defaults.
</p>
</section>
<section>
<h2>Sound Sources</h2>
<p>
There are multiple sound sources you can use, detailed in the
reference. You can also import your own audio samples or use a live
input as a source.
</p>
<CodeEditor code={`/sound/tri`} rows={2} />
<CodeEditor code={`/sound/saw`} rows={2} />
<CodeEditor code={`/sound/pulse/pw/0.25`} rows={2} />
<CodeEditor code={`/sound/white`} rows={2} />
<CodeEditor code={`/sound/pink`} rows={2} />
<CodeEditor code={`/sound/analog`} rows={2} />
<p>Turn on your microphone (and beware of feedback!):</p>
<CodeEditor code={`/sound/live/gain/2`} rows={2} />
</section>
<section>
<h2>Envelopes</h2>
<p>
The amplitude envelope controls how the sound fades in and out. It
uses the classic ADSR model: attack, decay, sustain, release.
</p>
<CodeEditor
code={`/sound/saw/attack/0.5/decay/0.2/sustain/0.0/release/1`}
rows={2}
/>
<p>
<code>/attack</code> is the time (in seconds) to reach full volume.<br
/>
<code>/decay</code> is the time to fall to the sustain level.<br />
<code>/sustain</code> is the level held while the note is on (0-1).<br
/>
<code>/release</code> is the time to fade to silence after note off.
</p>
<p>Try changing each parameter to hear how it affects the sound.</p>
</section>
<section>
<h2>Filters</h2>
<p>
Most of the default sources are producing very rich timbres, full of
harmonics. You are likely to play a lot with filters to remove some
components of the spectrum. Doux has all the basic filters needed:
</p>
<CodeEditor code={`/sound/saw/lpf/800`} rows={2} />
<p>
All the basic filters come with a control over resonance <code
>/..q</code
>:
</p>
<CodeEditor code={`/sound/saw/lpf/800/lpq/10`} rows={2} />
<CodeEditor code={`/sound/white/hpf/2000`} rows={2} />
<CodeEditor code={`/sound/white/bpf/1000/bpq/20`} rows={2} />
</section>
<section>
<h2>Effects</h2>
<p>Doux includes several effects. Here's a sound with reverb:</p>
<CodeEditor code={`/sound/saw/note/48/reverb/0.8/decay/0.2`} rows={2} />
<p>And now another sound with a delay:</p>
<CodeEditor
code={`/sound/saw/note/60/delay/0.5/delaytime/0.3/delayfeedback/0.6/decay/0.5`}
rows={2}
/>
<p>If you stack up effects, it can become quite crazy:</p>
<CodeEditor
code={`/sound/saw/note/48/delay/0.5/delaytime/0.1/delayfeedback/0.8/decay/1.5/fanger/0.5/coarse/12/phaser/0.9/gain/1/phaserfeedback/0.9`}
rows={2}
/>
<p>
Note that the order in which the effects is applied is fixed by
default!
</p>
</section>
</main>
<style>
.tutorial {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.tutorial h2 {
margin-top: 2.5em;
margin-bottom: 0.5em;
color: #000;
}
.intro {
padding-bottom: 1em;
}
.support-link {
display: block;
margin: 1.5em 0;
padding: 12px 24px;
background: #f5f5f5;
border: 1px solid #ccc;
color: #000;
text-decoration: none;
text-align: center;
}
.support-link:hover {
border-color: #999;
background: #eee;
}
</style>

View File

@@ -0,0 +1,3 @@
export function load() {
return {};
}

View File

@@ -0,0 +1,167 @@
<main class="native-page">
<h1>Native</h1>
<p>
The web version is really fun if you want to build a website that uses a
robust open source audio engine. However, Doux was ported from C mostly
to be used natively. You will need to compile it yourself or use it
directly as it is integrated in <a href="https://sova.livecoding.fr"
>Sova</a
>. Read the instructions in the repo to learn how to compile it. You
will need the Rust toolchain that you can get using
<a href="https://rustup.rs/">Rustup</a>.
</p>
<br />
<section>
<h2>Binaries</h2>
<ul>
<li>
<code>doux</code> runs as an OSC server, listening on a configurable
port (default 57120). Use it with TidalCycles, Strudel, or Sova.
It will listen to any incoming message until it is killed.
</li>
<br />
<li>
REPL with readline support and command history saved to
<code>~/.doux_history</code>. Built-in commands:
<code>.quit</code>, <code>.reset</code>, <code>.hush</code>,
<code>.panic</code>, <code>.voices</code>, <code>.time</code>.
</li>
</ul>
</section>
<section>
<h2>Flags</h2>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Short</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--samples</code></td>
<td><code>-s</code></td>
<td>Directory containing audio samples</td>
</tr>
<tr>
<td><code>--list-devices</code></td>
<td></td>
<td>List audio devices and exit</td>
</tr>
<tr>
<td><code>--input</code></td>
<td><code>-i</code></td>
<td>Input device (name or index)</td>
</tr>
<tr>
<td><code>--output</code></td>
<td><code>-o</code></td>
<td>Output device (name or index)</td>
</tr>
<tr>
<td><code>--channels</code></td>
<td></td>
<td>Number of output channels (default: 2)</td>
</tr>
<tr>
<td><code>--buffer-size</code></td>
<td><code>-b</code></td>
<td>Audio buffer size in samples (default: system)</td>
</tr>
<tr>
<td><code>--port</code></td>
<td><code>-p</code></td>
<td>OSC port, <code>doux</code> only (default: 57120)</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Multichannel</h2>
<p>
The <code>--channels</code> flag enables multichannel output beyond
stereo. The number of channels is clamped to your device's maximum
supported count. The <code>orbit</code> parameter controls output routing.
With N output channels, there are N/2 stereo pairs. Voices on orbit 0
output to channels 01, orbit 1 to channels 23, and so on. When the
orbit exceeds the number of pairs, it wraps around via modulo. Effects
(delay, reverb) follow the same routing: each orbit's effect bus outputs
to its corresponding stereo pair.
</p>
</section>
<section>
<h2>Buffer size</h2>
<p>
The <code>--buffer-size</code> flag controls audio latency. Lower values
mean less latency but require more CPU. Common values: 64, 128, 256, 512,
1024. At 48kHz, 256 samples gives ~5.3ms latency. If unset, the system
chooses a default. Use lower values for live performance, higher values
for stability.
</p>
</section>
<section>
<h2>Sample loading</h2>
<p>
The <code>--samples</code> flag allows you to (lazy-)load audio
samples to play with. The engine expects a folder containing folders
of audio samples. Samples will be available using the folder name.
You can index into a folder by using the <code>/n/</code> command. Check
the reference to learn more about this.
</p>
</section>
</main>
<style>
.native-page {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.native-page h1 {
font-size: 18px;
margin-top: 0;
margin-bottom: 1.5em;
color: #000;
}
.native-page h2 {
margin-top: 2em;
margin-bottom: 0.5em;
color: #000;
}
.native-page section:first-of-type h2 {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
}
th,
td {
text-align: left;
padding: 8px 12px;
border: 1px solid #ccc;
}
th {
background: #f5f5f5;
}
code {
background: #f5f5f5;
padding: 2px 4px;
}
</style>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Component } from "svelte";
import Sidebar from "$lib/components/Sidebar.svelte";
interface Category {
path: string;
title: string;
slug: string;
group: string;
order: number;
component: Component;
}
interface NavItem {
name: string;
category: string;
group: string;
}
interface Props {
data: {
categories: Category[];
navigation: NavItem[];
};
}
let { data }: Props = $props();
</script>
<Sidebar items={data.navigation} />
<main class="content">
{#each data.categories as category}
{@const Component = category.component}
<section id={category.slug} class="category">
<h2 class="category-title">{category.title}</h2>
<Component />
</section>
{/each}
</main>
<style>
.category-title {
border-bottom: 1px solid #ccc;
padding-bottom: 8px;
margin-bottom: 24px;
font-size: 1.2em;
}
.category :global(h2:not(.category-title)) {
background: #f5f5f5;
border: 1px solid #ccc;
font-size: 1em;
font-weight: normal;
margin: 24px 0 8px;
padding: 8px 12px;
}
</style>

View File

@@ -0,0 +1,41 @@
import type { Component } from "svelte";
import navigation from "$lib/navigation.json";
const contentModules = import.meta.glob("/src/content/*.md", { eager: true });
interface ContentMetadata {
title: string;
slug: string;
group: string;
order: number;
}
interface ContentModule {
metadata: ContentMetadata;
default: Component;
}
export function load() {
const categories = Object.entries(contentModules).map(([path, module]) => {
const mod = module as ContentModule;
return {
path,
...mod.metadata,
component: mod.default,
};
});
const groupOrder = { sources: 0, synthesis: 1, effects: 2 };
categories.sort((a, b) => {
const groupDiff =
(groupOrder[a.group as keyof typeof groupOrder] ?? 99) -
(groupOrder[b.group as keyof typeof groupOrder] ?? 99);
if (groupDiff !== 0) return groupDiff;
return a.order - b.order;
});
return {
categories,
navigation,
};
}

View File

@@ -0,0 +1,105 @@
<script>
</script>
<main class="support-page">
<h1>License & Support</h1>
<section>
<p>
Doux is free & open source software under the
<a href="https://www.gnu.org/licenses/agpl-3.0.html"
>GNU Affero General Public License</a
> (AGPL-3.0). This means you are free to use, modify, and distribute Doux,
as long as any modifications are also released under the same license.
</p>
</section>
<section>
<p>
The original Dough project is part of the <a
href="https://tidalcycles.org">TidalCycles</a
> ecosystem. If you find Doux useful, consider supporting Dough instead
through Open Collective. Your contributions help maintain and improve
Dough and related live coding projects.
</p>
<a href="https://opencollective.com/tidalcycles" class="donate-link">
Support on Open Collective
</a>
</section>
<section>
<h2>Credits</h2>
<ul>
<li>
Doux is a Rust port of <a href="https://dough.strudel.cc/"
>Dough</a
>, originally written in C by
<a href="https://github.com/felixroos">Felix Roos</a>. This
project is AGPL 3.0 licensed, just like Doux!
</li>
<li>
<a href="https://github.com/sourcebox/mi-plaits-dsp-rs"
>mi-plaits-dsp-rs</a
>
is a Rust port of the code used by the
<a
href="https://pichenettes.github.io/mutable-instruments-documentation/modules/plaits/"
>Mutable Instruments Plaits</a
>.
<ul>
<li>
<b>Author:</b> Oliver Rockstedt
<a href="mailto:info@sourcebox.de">info@sourcebox.de</a>
</li>
<li>
<b>Original author:</b> Emilie Gillet
<a href="mailto:emilie.o.gillet@gmail.com"
>emilie.o.gillet@gmail.com</a
>
</li>
</ul>
</li>
</ul>
</section>
</main>
<style>
.support-page {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.support-page h1 {
font-size: 18px;
margin-top: 0;
margin-bottom: 1.5em;
color: #000;
}
.support-page h2 {
margin-top: 2em;
margin-bottom: 0.5em;
color: #000;
}
.support-page section:first-of-type h2 {
margin-top: 0;
}
.donate-link {
display: inline-block;
margin-top: 1em;
padding: 12px 24px;
background: #f5f5f5;
border: 1px solid #ccc;
color: #000;
text-decoration: none;
}
.donate-link:hover {
background: #eee;
}
</style>