mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-25 15:06:05 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d92d93e37d | |||
| 685937911c | |||
| 31a164a09f | |||
| be8b4d7873 | |||
| 60388e5798 | |||
| 88823d26b1 | |||
| e4099946cd | |||
| 3a49ba6eb1 | |||
| eac45d06b3 | |||
| 8674b61c2c | |||
| af27364c45 |
@@ -11,6 +11,7 @@ export class Cyclist {
|
||||
constructor({
|
||||
interval,
|
||||
onTrigger,
|
||||
onPrepare,
|
||||
onToggle,
|
||||
onError,
|
||||
getTime,
|
||||
@@ -18,6 +19,7 @@ export class Cyclist {
|
||||
setInterval,
|
||||
clearInterval,
|
||||
beforeStart,
|
||||
prepareTime = 4,
|
||||
}) {
|
||||
this.started = false;
|
||||
this.beforeStart = beforeStart;
|
||||
@@ -26,6 +28,8 @@ export class Cyclist {
|
||||
this.lastTick = 0; // absolute time when last tick (clock callback) happened
|
||||
this.lastBegin = 0; // query begin of last tick
|
||||
this.lastEnd = 0; // query end of last tick
|
||||
this.preparedUntil = 0;
|
||||
this.prepareTime = prepareTime;
|
||||
this.getTime = getTime; // get absolute time
|
||||
this.num_cycles_at_cps_change = 0;
|
||||
this.seconds_at_cps_change; // clock phase when cps was changed
|
||||
@@ -85,6 +89,33 @@ export class Cyclist {
|
||||
setInterval,
|
||||
clearInterval,
|
||||
);
|
||||
|
||||
onPrepare
|
||||
? (this.prepClock = createClock(
|
||||
getTime,
|
||||
(phase, duration, _, t) => {
|
||||
try {
|
||||
const start = Math.max(t, this.preparedUntil);
|
||||
const end = t + this.prepareTime;
|
||||
this.preparedUntil = end;
|
||||
|
||||
const haps = this.pattern.queryArc(start, end, { _cps: 1 });
|
||||
|
||||
haps.forEach((hap) => {
|
||||
onPrepare?.(hap);
|
||||
});
|
||||
} catch (e) {
|
||||
logger(`[cyclist] error: ${e.message}`);
|
||||
onError?.(e);
|
||||
}
|
||||
},
|
||||
1, // duration of each cycle
|
||||
1,
|
||||
0,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
))
|
||||
: null;
|
||||
}
|
||||
now() {
|
||||
if (!this.started) {
|
||||
@@ -106,16 +137,19 @@ export class Cyclist {
|
||||
}
|
||||
logger('[cyclist] start');
|
||||
this.clock.start();
|
||||
this.prepClock?.start();
|
||||
this.setStarted(true);
|
||||
}
|
||||
pause() {
|
||||
logger('[cyclist] pause');
|
||||
this.clock.pause();
|
||||
this.prepClock?.pause();
|
||||
this.setStarted(false);
|
||||
}
|
||||
stop() {
|
||||
logger('[cyclist] stop');
|
||||
this.clock.stop();
|
||||
this.prepClock?.stop();
|
||||
this.lastEnd = 0;
|
||||
this.setStarted(false);
|
||||
}
|
||||
@@ -130,6 +164,7 @@ export class Cyclist {
|
||||
return;
|
||||
}
|
||||
this.cps = cps;
|
||||
this.preparedUntil = 0;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
}
|
||||
log(begin, end, haps) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
|
||||
export function repl({
|
||||
defaultOutput,
|
||||
defaultPrepare,
|
||||
onEvalError,
|
||||
beforeEval,
|
||||
beforeStart,
|
||||
@@ -47,6 +48,7 @@ export function repl({
|
||||
|
||||
const schedulerOptions = {
|
||||
onTrigger: getTrigger({ defaultOutput, getTime }),
|
||||
onPrepare: getPrepare({ defaultPrepare }),
|
||||
getTime,
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
@@ -238,3 +240,13 @@ export const getTrigger =
|
||||
logger(`[cyclist] error: ${err.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
export const getPrepare =
|
||||
({ defaultPrepare }) =>
|
||||
async (hap) => {
|
||||
try {
|
||||
await defaultPrepare(hap);
|
||||
} catch (err) {
|
||||
logger(`[cyclist] error: ${err.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,6 +92,29 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
|
||||
- `deadline`: seconds until the sound should play (0 = immediate)
|
||||
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
|
||||
|
||||
### prepare(value)
|
||||
|
||||
Informs superdough that a sound will be needed in the future.
|
||||
If the sound is a sample that is not loaded yet, it will be fetched.
|
||||
Otherwise does nothing.
|
||||
`value` has a syntax identical to the one used `superdough()`.
|
||||
|
||||
```js
|
||||
prepare({ s: 'bd', delay: 0.5 });
|
||||
|
||||
// some time later
|
||||
|
||||
superdough({ s: 'bd', delay: 0.5 }, 0, 1);
|
||||
```
|
||||
|
||||
Can be awaited to ensure that a given sound is ready to play.
|
||||
|
||||
```js
|
||||
const sound = { s: 'hh' };
|
||||
await prepare(sound);
|
||||
superdough(sound, 0, 1);
|
||||
```
|
||||
|
||||
### registerSynthSounds()
|
||||
|
||||
Loads the default waveforms `sawtooth`, `square`, `triangle` and `sine`. Use them like this:
|
||||
|
||||
@@ -265,19 +265,28 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
|
||||
processSampleMap(
|
||||
sampleMap,
|
||||
(key, bank) =>
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
||||
type: 'sample',
|
||||
samples: bank,
|
||||
baseUrl,
|
||||
prebake,
|
||||
tag,
|
||||
}),
|
||||
registerSound(
|
||||
key,
|
||||
(t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank),
|
||||
{
|
||||
type: 'sample',
|
||||
samples: bank,
|
||||
baseUrl,
|
||||
prebake,
|
||||
tag,
|
||||
},
|
||||
(hapValue) => onPrepareSample(hapValue, bank),
|
||||
),
|
||||
baseUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const cutGroups = [];
|
||||
|
||||
export async function onPrepareSample(hapValue, bank, resolveUrl) {
|
||||
await getSampleBuffer(hapValue, bank, resolveUrl);
|
||||
}
|
||||
|
||||
export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
let {
|
||||
s,
|
||||
|
||||
@@ -30,9 +30,9 @@ export function setMultiChannelOrbits(bool) {
|
||||
|
||||
export const soundMap = map();
|
||||
|
||||
export function registerSound(key, onTrigger, data = {}) {
|
||||
export function registerSound(key, onTrigger, data = {}, onPrepare = () => {}) {
|
||||
key = key.toLowerCase().replace(/\s+/g, '_');
|
||||
soundMap.setKey(key, { onTrigger, data });
|
||||
soundMap.setKey(key, { onTrigger, data, onPrepare });
|
||||
}
|
||||
|
||||
let gainCurveFunc = (val) => val;
|
||||
@@ -757,3 +757,13 @@ export const superdough = async (value, t, hapDuration, cps) => {
|
||||
export const superdoughTrigger = (t, hap, ct, cps) => {
|
||||
superdough(hap, t - ct, hap.duration / cps, cps);
|
||||
};
|
||||
|
||||
export const prepare = async (value) => {
|
||||
const { onPrepare } = getSound(value.s);
|
||||
if (onPrepare) {
|
||||
if (value.bank && value.s) {
|
||||
value.s = `${value.bank}_${value.s}`;
|
||||
}
|
||||
await onPrepare(value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough';
|
||||
import { superdough, prepare, getAudioContext, setLogger, doughTrigger } from 'superdough';
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
|
||||
setLogger(logger);
|
||||
@@ -20,6 +20,8 @@ export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(h
|
||||
export const webaudioOutput = (hap, deadline, hapDuration, cps, t) =>
|
||||
superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration);
|
||||
|
||||
export const webaudioPrepare = (hap) => prepare(hap2value(hap));
|
||||
|
||||
Pattern.prototype.webaudio = function () {
|
||||
return this.onTrigger(webaudioOutputTrigger);
|
||||
};
|
||||
@@ -28,6 +30,7 @@ export function webaudioRepl(options = {}) {
|
||||
options = {
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
defaultOutput: webaudioOutput,
|
||||
defaultPrepare: webaudioPrepare,
|
||||
...options,
|
||||
};
|
||||
return repl(options);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// A small UI update to improve the "Sounds" section
|
||||
// — better organization with folder grouping, the ability to star ⭐ favorite sounds,
|
||||
// and a slightly cleaner interface. Next step: making it easier to preview individual sounds within folders,
|
||||
// since right now you only see the total count without an easy way to listen to them one by one.
|
||||
|
||||
import useEvent from '@src/useEvent.mjs';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { settingsMap, useSettings } from '../../../settings.mjs';
|
||||
import { ButtonGroup } from './Forms.jsx';
|
||||
import ImportSoundsButton from './ImportSoundsButton.jsx';
|
||||
@@ -19,61 +14,41 @@ export function SoundsTab() {
|
||||
const sounds = useStore(soundMap);
|
||||
const { soundsFilter } = useSettings();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Adds persistent favorites using localStorage. Enhances user experience by allowing them to mark and recall sounds.
|
||||
// (better way than using localStorage?)
|
||||
const [starred, setStarred] = useState(() => new Set(JSON.parse(localStorage.getItem('starredSounds') || '[]')));
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('starredSounds', JSON.stringify(Array.from(starred)));
|
||||
}, [starred]); // Automatically saves favorites when updated
|
||||
|
||||
const soundEntries = useMemo(() => {
|
||||
if (!sounds) return [];
|
||||
return Object.entries(sounds)
|
||||
.filter(([key]) => !key.startsWith('_'))
|
||||
.filter(([name]) => name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [sounds, search]);
|
||||
if (!sounds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filtered = Object.entries(sounds)
|
||||
.filter(([key]) => !key.startsWith('_'))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.filter(([name]) => name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (soundsFilter === 'user') {
|
||||
return soundEntries.filter(([_, { data }]) => !data.prebake);
|
||||
return filtered.filter(([_, { data }]) => !data.prebake);
|
||||
}
|
||||
if (soundsFilter === 'drums') {
|
||||
return soundEntries.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
|
||||
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
|
||||
}
|
||||
if (soundsFilter === 'samples') {
|
||||
return soundEntries.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
|
||||
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
|
||||
}
|
||||
if (soundsFilter === 'synths') {
|
||||
return soundEntries.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
|
||||
}
|
||||
if (soundsFilter === 'starred') {
|
||||
return soundEntries.filter(([name]) => starred.has(name));
|
||||
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
|
||||
}
|
||||
if (soundsFilter === 'importSounds') {
|
||||
return [];
|
||||
}
|
||||
return soundEntries;
|
||||
}, [soundEntries, soundsFilter, starred]);
|
||||
|
||||
// Organizes sound list into collapsible folders for better navigation and visual clarity.
|
||||
const groupedByFolder = useMemo(() => {
|
||||
const groups = {};
|
||||
for (const [name, { data, onTrigger }] of filteredEntries) {
|
||||
const folder = data.folder || name.split('(')[0];
|
||||
if (!groups[folder]) groups[folder] = [];
|
||||
groups[folder].push([name, { data, onTrigger }]);
|
||||
}
|
||||
return groups;
|
||||
}, [filteredEntries]);
|
||||
return filtered;
|
||||
}, [sounds, soundsFilter, search]);
|
||||
|
||||
// holds mutable ref to current triggered sound
|
||||
const trigRef = useRef();
|
||||
|
||||
// stop current sound on mouseup
|
||||
useEvent('mouseup', () => {
|
||||
const t = trigRef.current;
|
||||
trigRef.current = undefined;
|
||||
@@ -81,7 +56,6 @@ export function SoundsTab() {
|
||||
ref?.stop(getAudioContext().currentTime + 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full text-foreground">
|
||||
<Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
|
||||
@@ -96,60 +70,42 @@ export function SoundsTab() {
|
||||
synths: 'Synths',
|
||||
user: 'User',
|
||||
importSounds: 'import-sounds',
|
||||
starred: '⭐',
|
||||
}}
|
||||
></ButtonGroup>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
|
||||
{Object.entries(groupedByFolder).map(([folder, entries]) => (
|
||||
<details key={folder} className="mb-2">
|
||||
<summary className="cursor-pointer font-semibold">{folder}</summary>
|
||||
<div className="pl-4">
|
||||
{entries.map(([name, { data, onTrigger }]) => (
|
||||
<div key={name} className="flex justify-between mr-5 items-center py-1">
|
||||
<span
|
||||
className="cursor-pointer hover:opacity-50"
|
||||
onMouseDown={async () => {
|
||||
const ctx = getAudioContext();
|
||||
const params = {
|
||||
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
||||
s: name,
|
||||
clip: 1,
|
||||
release: 0.5,
|
||||
sustain: 1,
|
||||
duration: 0.5,
|
||||
};
|
||||
const time = ctx.currentTime + 0.05;
|
||||
const onended = () => trigRef.current?.node?.disconnect();
|
||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
||||
trigRef.current.then((ref) => {
|
||||
connectToDestination(ref?.node);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{name} {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
|
||||
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
|
||||
</span>
|
||||
<button
|
||||
className="ml-2 text-yellow-400 hover:text-yellow-600"
|
||||
onClick={() => {
|
||||
setStarred((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{starred.has(name) ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
|
||||
{!Object.keys(groupedByFolder).length && soundsFilter === 'importSounds' ? (
|
||||
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
|
||||
{soundEntries.map(([name, { data, onTrigger }]) => {
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="cursor-pointer hover:opacity-50"
|
||||
onMouseDown={async () => {
|
||||
const ctx = getAudioContext();
|
||||
const params = {
|
||||
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
||||
s: name,
|
||||
clip: 1,
|
||||
release: 0.5,
|
||||
sustain: 1,
|
||||
duration: 0.5,
|
||||
};
|
||||
const time = ctx.currentTime + 0.05;
|
||||
const onended = () => trigRef.current?.node?.disconnect();
|
||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
||||
trigRef.current.then((ref) => {
|
||||
connectToDestination(ref?.node);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
{name}
|
||||
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
|
||||
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{!soundEntries.length && soundsFilter === 'importSounds' ? (
|
||||
<div className="prose dark:prose-invert min-w-full pt-2 pb-8 px-4">
|
||||
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
|
||||
<p>
|
||||
@@ -192,9 +148,10 @@ export function SoundsTab() {
|
||||
sample as it appears under the "user" tab.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!Object.keys(groupedByFolder).length && soundsFilter !== 'importSounds'
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{!soundEntries.length && soundsFilter !== 'importSounds'
|
||||
? 'No custom sounds loaded in this pattern (yet).'
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContextCurrentTime,
|
||||
webaudioOutput,
|
||||
webaudioPrepare,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
@@ -63,6 +64,7 @@ export function useReplContext() {
|
||||
const { isSyncEnabled, audioEngineTarget } = useSettings();
|
||||
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
|
||||
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
|
||||
const defaultPrepare = shouldUseWebaudio ? webaudioPrepare : undefined;
|
||||
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
|
||||
|
||||
const init = useCallback(() => {
|
||||
@@ -71,6 +73,7 @@ export function useReplContext() {
|
||||
const editor = new StrudelMirror({
|
||||
sync: isSyncEnabled,
|
||||
defaultOutput,
|
||||
defaultPrepare,
|
||||
getTime,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
|
||||
Reference in New Issue
Block a user