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

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;
}