Initial commit
This commit is contained in:
111
website/src/lib/components/CodeEditor.svelte
Normal file
111
website/src/lib/components/CodeEditor.svelte
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>
|
||||
131
website/src/lib/components/CommandEntry.svelte
Normal file
131
website/src/lib/components/CommandEntry.svelte
Normal 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>
|
||||
114
website/src/lib/components/Nav.svelte
Normal file
114
website/src/lib/components/Nav.svelte
Normal 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>
|
||||
16
website/src/lib/components/Scope.svelte
Normal file
16
website/src/lib/components/Scope.svelte
Normal 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>
|
||||
122
website/src/lib/components/Sidebar.svelte
Normal file
122
website/src/lib/components/Sidebar.svelte
Normal 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
443
website/src/lib/doux.ts
Normal 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');
|
||||
132
website/src/lib/navigation.json
Normal file
132
website/src/lib/navigation.json
Normal 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
99
website/src/lib/scope.ts
Normal 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
37
website/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user