Merge branch 'main' into jade/gaincurve

This commit is contained in:
Jade (Rose) Rowland 2025-04-27 02:10:30 -04:00
commit 697f0a82e6
33 changed files with 591 additions and 332 deletions

View File

@ -11,7 +11,9 @@ export const flashField = StateField.define({
for (let e of tr.effects) {
if (e.is(setFlash)) {
if (e.value && tr.newDoc.length > 0) {
const mark = Decoration.mark({ attributes: { style: `background-color: #FFCA2880` } });
const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
});
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
} else {
flash = Decoration.set([]);

View File

@ -5,16 +5,15 @@ import whitescreen, { settings as whitescreenSettings } from './themes/whitescre
import teletext, { settings as teletextSettings } from './themes/teletext.mjs';
import algoboy, { settings as algoboySettings } from './themes/algoboy.mjs';
import CutiePi, { settings as CutiePiSettings } from './themes/CutiePi.mjs';
import terminal, { settings as terminalSettings } from './themes/terminal.mjs';
import abcdef, { settings as abcdefSettings } from './themes/abcdef.mjs';
import sonicPink, { settings as sonicPinkSettings } from './themes/sonic-pink.mjs';
import redText, { settings as redTextSettings } from './themes/red-text.mjs';
import greenText, { settings as greenTextSettings } from './themes/green-text.mjs';
import androidstudio, { settings as androidstudioSettings } from './themes/androidstudio.mjs';
import atomone, { settings as atomOneSettings } from './themes/atomone.mjs';
import aura, { settings as auraSettings } from './themes/aura.mjs';
import bespin, { settings as bespinSettings } from './themes/bespin.mjs';
import darcula, { settings as darculaSettings } from './themes/darcula.mjs';
import dracula, { settings as draculaSettings } from './themes/dracula.mjs';
import duotoneDark, { settings as duotoneDarkSettings } from './themes/duotoneDark.mjs';
import duotoneLight, { settings as duotoneLightSettings } from './themes/duotoneLight.mjs';
import eclipse, { settings as eclipseSettings } from './themes/eclipse.mjs';
import githubDark, { settings as githubDarkSettings } from './themes/githubDark.mjs';
import githubLight, { settings as githubLightSettings } from './themes/githubLight.mjs';
@ -32,52 +31,47 @@ import tokyoNightStorm, { settings as tokyoNightStormSettings } from './themes/t
import tokyoNightDay, { settings as tokyoNightDaySettings } from './themes/tokyoNightDay.mjs';
import vscodeDark, { settings as vscodeDarkSettings } from './themes/vscodeDark.mjs';
import vscodeLight, { settings as vscodeLightSettings } from './themes/vscodeLight.mjs';
// import xcodeDark, { settings as xcodeDarkSettings } from './themes/xcodeDark.mjs';
import xcodeLight, { settings as xcodeLightSettings } from './themes/xcodeLight.mjs';
import bbedit, { settings as bbeditSettings } from './themes/bbedit.mjs';
import noctisLilac, { settings as noctisLilacSettings } from './themes/noctisLilac.mjs';
import { setTheme } from '@strudel/draw';
export const themes = {
strudelTheme,
bluescreen,
blackscreen,
whitescreen,
teletext,
algoboy,
androidstudio,
atomone,
aura,
bbedit,
blackscreen,
bluescreen,
CutiePi,
darcula,
dracula,
// todo: optimize
// bespin,
//abcdef,
androidstudio,
duotoneDark,
eclipse,
githubDark,
CutiePi,
githubLight,
greenText,
gruvboxDark,
gruvboxLight,
sonicPink,
materialDark,
nord,
materialLight,
monokai,
noctisLilac,
nord,
redText,
solarizedDark,
solarizedLight,
sublime,
teletext,
tokyoNight,
tokyoNightDay,
tokyoNightStorm,
vscodeDark,
//xcodeDark,
// LIGHT
bbedit,
//duotoneLight,
eclipse,
githubLight,
gruvboxLight,
materialLight,
vscodeLight,
noctisLilac,
solarizedLight,
tokyoNightDay,
whitescreen,
xcodeLight,
};
@ -88,21 +82,19 @@ export const settings = {
whitescreen: whitescreenSettings,
teletext: teletextSettings,
algoboy: algoboySettings,
terminal: terminalSettings,
abcdef: abcdefSettings,
androidstudio: androidstudioSettings,
atomone: atomOneSettings,
aura: auraSettings,
bbedit: bbeditSettings,
bespin: bespinSettings,
darcula: darculaSettings,
dracula: draculaSettings,
duotoneLight: duotoneLightSettings,
duotoneDark: duotoneDarkSettings,
eclipse: eclipseSettings,
CutiePi: CutiePiSettings,
sonicPink: sonicPinkSettings,
githubLight: githubLightSettings,
githubDark: githubDarkSettings,
greenText: greenTextSettings,
gruvboxDark: gruvboxDarkSettings,
gruvboxLight: gruvboxLightSettings,
materialDark: materialDarkSettings,
@ -110,6 +102,7 @@ export const settings = {
noctisLilac: noctisLilacSettings,
nord: nordSettings,
monokai: monokaiSettings,
redText: redTextSettings,
solarizedLight: solarizedLightSettings,
solarizedDark: solarizedDarkSettings,
sublime: sublimeSettings,
@ -118,7 +111,6 @@ export const settings = {
vscodeDark: vscodeDarkSettings,
vscodeLight: vscodeLightSettings,
xcodeLight: xcodeLightSettings,
//xcodeDark: xcodeDarkSettings,
tokyoNightDay: tokyoNightDaySettings,
};

View File

@ -1,55 +0,0 @@
/**
* @name abcdef
* @author codemirror.net
* https://codemirror.net/5/theme/abcdef.css
*/
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
export const settings = {
background: '#0f0f0f',
lineBackground: '#0f0f0f99',
foreground: '#defdef',
caret: '#00FF00',
selection: '#515151',
selectionMatch: '#515151',
gutterBackground: '#555',
gutterForeground: '#FFFFFF',
lineHighlight: '#314151',
};
export default createTheme({
theme: 'dark',
settings: {
background: '#0f0f0f',
foreground: '#defdef',
caret: '#00FF00',
selection: '#515151',
selectionMatch: '#515151',
// gutterBackground: '#555',
gutterBackground: 'transparent',
/* gutterForeground: '#FFFFFF', */
gutterForeground: '#7a7b7c',
lineHighlight: '#0a6bcb3d',
},
styles: [
{ tag: t.labelName, color: 'inherit' },
{ tag: t.keyword, color: 'darkgoldenrod', fontWeight: 'bold' },
{ tag: t.atom, color: '#77F' },
{ tag: t.comment, color: '#7a7b7c', fontStyle: 'italic' },
{ tag: t.number, color: 'violet' },
{ tag: t.definition(t.variableName), color: '#fffabc' },
{ tag: t.variableName, color: '#abcdef' },
{ tag: t.function(t.variableName), color: '#fffabc' },
{ tag: t.typeName, color: '#FFDD44' },
{ tag: t.tagName, color: '#def' },
{ tag: t.string, color: '#2b4' },
{ tag: t.meta, color: '#C9F' },
// { tag: t.qualifier, color: '#FFF700' },
// { tag: t.builtin, color: '#30aabc' },
{ tag: t.bracket, color: '#8a8a8a' },
{ tag: t.attributeName, color: '#DDFF00' },
{ tag: t.heading, color: 'aquamarine', fontWeight: 'bold' },
{ tag: t.link, color: 'blueviolet', fontWeight: 'bold' },
],
});

View File

@ -54,6 +54,7 @@ export default createTheme({
tag: [t.keyword, t.tagName, t.arithmeticOperator],
color: palette[1],
},
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: palette[0] },
{ tag: [t.function(t.variableName), t.propertyName], color: palette[0] },
{ tag: t.atom, color: palette[1] },
],

View File

@ -38,12 +38,14 @@ export default createTheme({
tag: [t.function(t.variableName), t.function(t.propertyName), t.url, t.processingInstruction],
color: 'hsl(207, 82%, 66%)',
},
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: 'hsl( 29, 54%, 61%)' },
{ tag: [t.tagName, t.heading], color: '#e06c75' },
{ tag: t.comment, color: '#54636D' },
{ tag: [t.variableName, t.propertyName, t.labelName], color: 'hsl(220, 14%, 71%)' },
{ tag: [t.attributeName, t.number], color: 'hsl( 29, 54%, 61%)' },
{ tag: t.className, color: 'hsl( 39, 67%, 69%)' },
{ tag: t.keyword, color: 'hsl(286, 60%, 67%)' },
{ tag: [t.string, t.regexp, t.special(t.propertyName)], color: '#98c379' },
],
});

View File

@ -1,39 +0,0 @@
// this is different from https://thememirror.net/bespin
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
export const settings = {
background: '#28211c',
lineBackground: '#28211c99',
foreground: '#9d9b97',
caret: '#797977',
selection: '#36312e',
selectionMatch: '#4f382b',
gutterBackground: '#28211c',
gutterForeground: '#666666',
lineHighlight: 'rgba(255, 255, 255, 0.1)',
};
export default createTheme({
theme: 'dark',
settings: {
background: '#28211c',
foreground: '#9d9b97',
caret: '#797977',
selection: '#4f382b',
selectionMatch: '#4f382b',
gutterBackground: '#28211c',
gutterForeground: '#666666',
lineHighlight: '#ffffff1a',
},
styles: [
{ tag: [t.atom, t.number, t.link, t.bool], color: '#9b859d' },
{ tag: t.comment, color: '#937121' },
{ tag: [t.keyword, t.tagName], color: '#cf6a4c' },
{ tag: t.string, color: '#f9ee98' },
{ tag: t.bracket, color: '#9d9b97' },
{ tag: [t.variableName], color: '#5ea6ea' },
{ tag: t.definition(t.variableName), color: '#cf7d34' },
{ tag: [t.function(t.variableName), t.className], color: '#cf7d34' },
{ tag: [t.propertyName, t.attributeName], color: '#54be0d' },
],
});

View File

@ -0,0 +1,39 @@
/**
* @name Atom One
* Atom One dark syntax theme
*
* https://github.com/atom/one-dark-syntax
*/
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
const hex = ['#000000', '#8ed675', '#56bd2a', '#54636D', '#171717'];
export const settings = {
background: hex[0],
lineBackground: 'transparent',
foreground: hex[2],
selection: hex[4],
selectionMatch: hex[0],
gutterBackground: hex[0],
gutterForeground: hex[3],
gutterBorder: 'transparent',
lineHighlight: hex[0],
};
export default createTheme({
theme: 'dark',
settings,
styles: [
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.url, t.processingInstruction],
color: hex[2],
},
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: hex[1] },
{ tag: t.comment, color: hex[3] },
{ tag: [t.variableName, t.propertyName, t.labelName], color: hex[2] },
{ tag: [t.attributeName, t.number], color: hex[1] },
{ tag: t.keyword, color: hex[2] },
{ tag: [t.string, t.regexp, t.special(t.propertyName)], color: hex[1] },
],
});

39
packages/codemirror/themes/red-text.mjs vendored Normal file
View File

@ -0,0 +1,39 @@
/**
* @name Atom One
* Atom One dark syntax theme
*
* https://github.com/atom/one-dark-syntax
*/
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
const hex = ['#000000', '#ff5356', '#bd312a', '#54636D', '#171717'];
export const settings = {
background: hex[0],
lineBackground: 'transparent',
foreground: hex[2],
selection: hex[4],
selectionMatch: hex[0],
gutterBackground: hex[0],
gutterForeground: hex[3],
gutterBorder: 'transparent',
lineHighlight: hex[0],
};
export default createTheme({
theme: 'dark',
settings,
styles: [
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.url, t.processingInstruction],
color: hex[2],
},
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: hex[1] },
{ tag: t.comment, color: hex[3] },
{ tag: [t.variableName, t.propertyName, t.labelName], color: hex[2] },
{ tag: [t.attributeName, t.number], color: hex[1] },
{ tag: t.keyword, color: hex[2] },
{ tag: [t.string, t.regexp, t.special(t.propertyName)], color: hex[1] },
],
});

View File

@ -0,0 +1,39 @@
/**
* @name Atom One
* Atom One dark syntax theme
*
* https://github.com/atom/one-dark-syntax
*/
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
const hex = ['#1e1e1e', '#fbde2d', '#ff1493', '#4c83ff', '#ededed', '#cccccc', '#ffffff30', '#dc2f8c'];
export const settings = {
background: '#000000',
lineBackground: 'transparent',
foreground: hex[4],
selection: hex[6],
gutterBackground: hex[0],
gutterForeground: hex[5],
gutterBorder: 'transparent',
lineHighlight: hex[0],
};
export default createTheme({
theme: 'dark',
settings,
styles: [
{
tag: [t.function(t.variableName), t.function(t.propertyName), t.url, t.processingInstruction],
color: hex[4],
},
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: hex[3] },
{ tag: t.comment, color: '#54636D' },
{ tag: [t.variableName, t.propertyName, t.labelName], color: hex[4] },
{ tag: [t.attributeName, t.number], color: hex[3] },
{ tag: t.keyword, color: hex[1] },
{ tag: [t.string, t.regexp, t.special(t.propertyName)], color: hex[2] },
],
});

View File

@ -5,14 +5,11 @@ export const settings = {
background: '#222',
lineBackground: '#22222299',
foreground: '#fff',
// foreground: '#75baff',
caret: '#ffcc00',
selection: 'rgba(128, 203, 196, 0.5)',
selectionMatch: '#036dd626',
// lineHighlight: '#8a91991a', // original
lineHighlight: '#00000050',
gutterBackground: 'transparent',
// gutterForeground: '#8a919966',
gutterForeground: '#8a919966',
};
@ -20,6 +17,7 @@ export default createTheme({
theme: 'dark',
settings,
styles: [
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: '#89ddff' },
{ tag: t.labelName, color: '#89ddff' },
{ tag: t.keyword, color: '#c792ea' },
{ tag: t.operator, color: '#89ddff' },

View File

@ -1,34 +0,0 @@
import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs';
export const settings = {
background: '#292A30',
lineBackground: '#292A3099',
foreground: '#CECFD0',
caret: '#fff',
selection: '#727377',
selectionMatch: '#727377',
lineHighlight: '#2F3239',
};
export default createTheme({
theme: 'dark',
settings: {
background: '#292A30',
foreground: '#CECFD0',
caret: '#fff',
selection: '#727377',
selectionMatch: '#727377',
lineHighlight: '#ffffff0f',
},
styles: [
{ tag: [t.comment, t.quote], color: '#7F8C98' },
{ tag: [t.keyword], color: '#FF7AB2', fontWeight: 'bold' },
{ tag: [t.string, t.meta], color: '#FF8170' },
{ tag: [t.typeName], color: '#DABAFF' },
{ tag: [t.definition(t.variableName)], color: '#6BDFFF' },
{ tag: [t.name], color: '#6BAA9F' },
{ tag: [t.variableName], color: '#ACF2E4' },
{ tag: [t.regexp, t.link], color: '#FF8170' },
],
});

View File

@ -1780,3 +1780,32 @@ export const as = register('as', (mapping, pat) => {
return v;
});
});
/**
* Allows you to scrub an audio file like a tape loop by passing values that represents the position in the audio file
* in the optional array syntax ex: "0.5:2", the second value controls the speed of playback
* @name scrub
* @memberof Pattern
* @returns Pattern
* @example
* samples('github:switchangel/pad')
* s("swpad:0").scrub("{0.1!2 .25@3 0.7!2 <0.8:1.5>}%8")
* @example
* samples('github:yaxu/clean-breaks/main');
* s("amen/4").fit().scrub("{0@3 0@2 4@3}%8".div(16))
*/
export const scrub = register(
'scrub',
(beginPat, pat) => {
return beginPat.outerBind((v) => {
if (!Array.isArray(v)) {
v = [v];
}
const [beginVal, speedMultiplier = 1] = v;
return pat.begin(beginVal).mul(speed(speedMultiplier)).clip(1);
});
},
false,
);

View File

@ -2105,11 +2105,13 @@ export const linger = register(
/**
* Samples the pattern at a rate of n events per cycle. Useful for turning a continuous pattern into a discrete one.
* @name segment
* @synonyms seg
* @param {number} segments number of segments per cycle
* @example
* note(saw.range(40,52).segment(24))
*/
export const segment = register('segment', function (rate, pat) {
export const { segment, seg } = register(['segment', 'seg'], function (rate, pat) {
return pat.struct(pure(true)._fast(rate)).setSteps(rate);
});
@ -2485,16 +2487,24 @@ export const bypass = register(
);
/**
* Loops the pattern inside at `offset` for `cycles`.
* Loops the pattern inside an `offset` for `cycles`.
* If you think of the entire span of time in cycles as a ribbon, you can cut a single piece and loop it.
* @name ribbon
* @synonym rib
* @param {number} offset start point of loop in cycles
* @param {number} cycles loop length in cycles
* @example
* note("<c d e f>").ribbon(1, 2).fast(2)
* note("<c d e f>").ribbon(1, 2)
* @example
* // Looping a portion of randomness
* note(irand(8).segment(4).scale('C3 minor')).ribbon(1337, 2)
* n(irand(8).segment(4)).scale("c:pentatonic").ribbon(1337, 2)
* @example
* // rhythm generator
* s("bd!16?").ribbon(29,.5)
*/
export const ribbon = register('ribbon', (offset, cycles, pat) => pat.early(offset).restart(pure(1).slow(cycles)));
export const { ribbon, rib } = register(['ribbon', 'rib'], (offset, cycles, pat) =>
pat.early(offset).restart(pure(1).slow(cycles)),
);
export const hsla = register('hsla', (h, s, l, a, pat) => {
return pat.color(`hsla(${h}turn,${s * 100}%,${l * 100}%,${a})`);

View File

@ -526,6 +526,9 @@ export const degradeByWith = register(
* s("hh*8").degradeBy(0.2)
* @example
* s("[hh?0.2]*8")
* @example
* //beat generator
* s("bd").segment(16).degradeBy(.5).ribbon(16,1)
*/
export const degradeBy = register(
'degradeBy',

View File

@ -37,7 +37,7 @@ function connect() {
export function parseControlsFromHap(hap, cps) {
hap.ensureObjectValue();
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
const delta = hap.duration.valueOf() / cps;
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));

View File

@ -212,6 +212,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
constantNode.onended = () => {
onComplete();
};
return constantNode;
}
const mod = (freq, range = 1, type = 'sine') => {
const ctx = getAudioContext();

View File

@ -344,7 +344,9 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
};
let envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd);
const stop = (endTime, playWholeBuffer) => {};
const stop = (endTime) => {
bufferSource.stop(endTime);
};
const handle = { node: out, bufferSource, stop };
// cut groups

View File

@ -14,13 +14,21 @@ import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
export function setMaxPolyphony(polyphony) {
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
}
export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) {
soundMap.setKey(key.toLowerCase(), { onTrigger, data });
key = key.toLowerCase().replace(/\s+/g, '_');
soundMap.setKey(key, { onTrigger, data });
}
let gainCurveFunc = (val) => Math.pow(val, 2);
let gainCurveFunc = (val) => Math.pow(val, 1);
export function applyGainCurve(val) {
return gainCurveFunc(val);
@ -95,9 +103,21 @@ export function getSound(s) {
return soundMap.get()[s.toLowerCase()];
}
export const getAudioDevices = async () => {
await navigator.mediaDevices.getUserMedia({ audio: true });
let mediaDevices = await navigator.mediaDevices.enumerateDevices();
mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default');
const devicesMap = new Map();
devicesMap.set(DEFAULT_AUDIO_DEVICE_NAME, '');
mediaDevices.forEach((device) => {
devicesMap.set(device.label, device.deviceId);
});
return devicesMap;
};
const defaultDefaultValues = {
s: 'triangle',
gain: 1,
gain: .8,
postgain: 1,
density: '.03',
ftype: '12db',
@ -165,18 +185,40 @@ export function getAudioContextCurrentTime() {
let workletsLoading;
function loadWorklets() {
if (!workletsLoading) {
workletsLoading = getAudioContext().audioWorklet.addModule(workletsUrl);
const audioCtx = getAudioContext();
workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
}
return workletsLoading;
}
// this function should be called on first user interaction (to avoid console warning)
export async function initAudio(options = {}) {
const { disableWorklets = false } = options;
const { disableWorklets = false, maxPolyphony, audioDeviceName = DEFAULT_AUDIO_DEVICE_NAME } = options;
setMaxPolyphony(maxPolyphony);
if (typeof window === 'undefined') {
return;
}
await getAudioContext().resume();
const audioCtx = getAudioContext();
if (audioDeviceName != null && audioDeviceName != DEFAULT_AUDIO_DEVICE_NAME) {
try {
const devices = await getAudioDevices();
const id = devices.get(audioDeviceName);
const isValidID = (id ?? '').length > 0;
if (audioCtx.sinkId !== id && isValidID) {
await audioCtx.setSinkId(id);
}
logger(
`[superdough] Audio Device set to ${audioDeviceName}, it might take a few seconds before audio plays on all output channels`,
);
} catch {
logger('[superdough] failed to set audio interface', 'warning');
}
}
await audioCtx.resume();
if (disableWorklets) {
logger('[superdough]: AudioWorklets disabled with disableWorklets');
return;
@ -384,6 +426,8 @@ export function resetGlobalEffects() {
analysersData = {};
}
let activeSoundSources = new Map();
export const superdough = async (value, t, hapDuration) => {
const ac = getAudioContext();
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
@ -487,14 +531,26 @@ export const superdough = async (value, t, hapDuration) => {
distortvol = applyGainCurve(distortvol);
delay = applyGainCurve(delay);
velocity = applyGainCurve(velocity);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
const chainID = Math.round(Math.random() * 1000000);
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next();
const source = ch.value[1];
const chainID = ch.value[0];
const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
source?.stop?.(endTime);
activeSoundSources.delete(chainID);
}
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
let audioNodes = [];
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended
const onended = () => {
toDisconnect.forEach((n) => n?.disconnect());
};
if (bank && s) {
s = `${bank}_${s}`;
value.s = s;
@ -506,10 +562,15 @@ export const superdough = async (value, t, hapDuration) => {
sourceNode = source(t, value, hapDuration);
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
const soundHandle = await onTrigger(t, value, onended);
const onEnded = () => {
audioNodes.forEach((n) => n?.disconnect());
activeSoundSources.delete(chainID);
};
const soundHandle = await onTrigger(t, value, onEnded);
if (soundHandle) {
sourceNode = soundHandle.node;
soundHandle.stop(t + hapDuration);
activeSoundSources.set(chainID, soundHandle);
}
} else {
throw new Error(`sound ${s} not found! Is it loaded?`);
@ -640,6 +701,7 @@ export const superdough = async (value, t, hapDuration) => {
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
delaySend = effectSend(post, delyNode, delay);
audioNodes.push(delaySend);
}
// reverb
let reverbSend;
@ -657,6 +719,7 @@ export const superdough = async (value, t, hapDuration) => {
}
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR);
reverbSend = effectSend(post, reverbNode, room);
audioNodes.push(reverbSend);
}
// analyser
@ -664,14 +727,12 @@ export const superdough = async (value, t, hapDuration) => {
if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
}
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
// toDisconnect = all the node that should be disconnected in onended callback
// this is crucial for performance
toDisconnect = chain.concat([delaySend, reverbSend, analyserSend]);
audioNodes = audioNodes.concat(chain);
};
export const superdoughTrigger = (t, hap, ct, cps) => {

View File

@ -25,6 +25,10 @@ const getFrequencyFromValue = (value) => {
return Number(freq);
};
function destroyAudioWorkletNode(node) {
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
}
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
const noises = ['pink', 'white', 'brown', 'crackle'];
@ -63,7 +67,9 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
stop: (releaseTime) => {},
stop: (endTime) => {
stop(endTime);
},
};
},
{ type: 'synth', prebake: true },
@ -110,10 +116,12 @@ export function registerSynthSounds() {
let envGain = gainNode(1);
envGain = o.connect(envGain);
webAudioTimeout(
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
let timeoutNode = webAudioTimeout(
ac,
() => {
o.disconnect();
destroyAudioWorkletNode(o);
envGain.disconnect();
onended();
fm?.stop();
@ -123,11 +131,11 @@ export function registerSynthSounds() {
end,
);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
return {
node: envGain,
stop: (time) => {},
stop: (time) => {
timeoutNode.stop(time);
},
};
},
{ prebake: true, type: 'synth' },
@ -169,10 +177,12 @@ export function registerSynthSounds() {
let envGain = gainNode(1);
envGain = o.connect(envGain);
webAudioTimeout(
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
let timeoutNode = webAudioTimeout(
ac,
() => {
o.disconnect();
destroyAudioWorkletNode(o);
envGain.disconnect();
onended();
fm?.stop();
@ -182,11 +192,11 @@ export function registerSynthSounds() {
end,
);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
return {
node: envGain,
stop: (time) => {},
stop: (time) => {
timeoutNode.stop(time);
},
};
},
{ prebake: true, type: 'synth' },
@ -229,7 +239,9 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
stop: (releaseTime) => {},
stop: (endTime) => {
stop(endTime);
},
};
},
{ type: 'synth', prebake: true },

View File

@ -710,6 +710,9 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
}
process(inputs, outputs, params) {
if (this.disconnected) {
return false;
}
if (currentTime <= params.begin[0]) {
return true;
}

View File

@ -2322,6 +2322,39 @@ exports[`runs examples > example "degradeBy" example index 1 1`] = `
]
`;
exports[`runs examples > example "degradeBy" example index 2 1`] = `
[
"[ 1/8 → 3/16 | s:bd ]",
"[ 1/4 → 5/16 | s:bd ]",
"[ 5/16 → 3/8 | s:bd ]",
"[ 1/2 → 9/16 | s:bd ]",
"[ 9/16 → 5/8 | s:bd ]",
"[ 11/16 → 3/4 | s:bd ]",
"[ 15/16 → 1/1 | s:bd ]",
"[ 9/8 → 19/16 | s:bd ]",
"[ 5/4 → 21/16 | s:bd ]",
"[ 21/16 → 11/8 | s:bd ]",
"[ 3/2 → 25/16 | s:bd ]",
"[ 25/16 → 13/8 | s:bd ]",
"[ 27/16 → 7/4 | s:bd ]",
"[ 31/16 → 2/1 | s:bd ]",
"[ 17/8 → 35/16 | s:bd ]",
"[ 9/4 → 37/16 | s:bd ]",
"[ 37/16 → 19/8 | s:bd ]",
"[ 5/2 → 41/16 | s:bd ]",
"[ 41/16 → 21/8 | s:bd ]",
"[ 43/16 → 11/4 | s:bd ]",
"[ 47/16 → 3/1 | s:bd ]",
"[ 25/8 → 51/16 | s:bd ]",
"[ 13/4 → 53/16 | s:bd ]",
"[ 53/16 → 27/8 | s:bd ]",
"[ 7/2 → 57/16 | s:bd ]",
"[ 57/16 → 29/8 | s:bd ]",
"[ 59/16 → 15/4 | s:bd ]",
"[ 63/16 → 4/1 | s:bd ]",
]
`;
exports[`runs examples > example "delay" example index 0 1`] = `
[
"[ 0/1 → 1/2 | s:bd delay:0 ]",
@ -7123,35 +7156,76 @@ exports[`runs examples > example "rev" example index 0 1`] = `
exports[`runs examples > example "ribbon" example index 0 1`] = `
[
"[ 0/1 → 1/2 | note:d ]",
"[ 1/2 → 1/1 | note:e ]",
"[ 1/1 → 3/2 | note:d ]",
"[ 3/2 → 2/1 | note:e ]",
"[ 2/1 → 5/2 | note:d ]",
"[ 5/2 → 3/1 | note:e ]",
"[ 3/1 → 7/2 | note:d ]",
"[ 7/2 → 4/1 | note:e ]",
"[ 0/1 → 1/1 | note:d ]",
"[ 1/1 → 2/1 | note:e ]",
"[ 2/1 → 3/1 | note:d ]",
"[ 3/1 → 4/1 | note:e ]",
]
`;
exports[`runs examples > example "ribbon" example index 1 1`] = `
[
"[ 0/1 → 1/4 | note:G3 ]",
"[ 0/1 → 1/4 | note:A3 ]",
"[ 1/4 → 1/2 | note:C3 ]",
"[ 1/2 → 3/4 | note:D3 ]",
"[ 3/4 → 1/1 | note:F3 ]",
"[ 1/1 → 5/4 | note:Eb3 ]",
"[ 5/4 → 3/2 | note:Ab3 ]",
"[ 3/2 → 7/4 | note:G3 ]",
"[ 7/4 → 2/1 | note:C4 ]",
"[ 2/1 → 9/4 | note:G3 ]",
"[ 3/4 → 1/1 | note:G3 ]",
"[ 1/1 → 5/4 | note:E3 ]",
"[ 5/4 → 3/2 | note:C4 ]",
"[ 3/2 → 7/4 | note:A3 ]",
"[ 7/4 → 2/1 | note:E4 ]",
"[ 2/1 → 9/4 | note:A3 ]",
"[ 9/4 → 5/2 | note:C3 ]",
"[ 5/2 → 11/4 | note:D3 ]",
"[ 11/4 → 3/1 | note:F3 ]",
"[ 3/1 → 13/4 | note:Eb3 ]",
"[ 13/4 → 7/2 | note:Ab3 ]",
"[ 7/2 → 15/4 | note:G3 ]",
"[ 15/4 → 4/1 | note:C4 ]",
"[ 11/4 → 3/1 | note:G3 ]",
"[ 3/1 → 13/4 | note:E3 ]",
"[ 13/4 → 7/2 | note:C4 ]",
"[ 7/2 → 15/4 | note:A3 ]",
"[ 15/4 → 4/1 | note:E4 ]",
]
`;
exports[`runs examples > example "ribbon" example index 2 1`] = `
[
"[ 1/16 → 1/8 | s:bd ]",
"[ 1/8 → 3/16 | s:bd ]",
"[ 3/16 → 1/4 | s:bd ]",
"[ 1/4 → 5/16 | s:bd ]",
"[ 3/8 → 7/16 | s:bd ]",
"[ 9/16 → 5/8 | s:bd ]",
"[ 5/8 → 11/16 | s:bd ]",
"[ 11/16 → 3/4 | s:bd ]",
"[ 3/4 → 13/16 | s:bd ]",
"[ 7/8 → 15/16 | s:bd ]",
"[ 17/16 → 9/8 | s:bd ]",
"[ 9/8 → 19/16 | s:bd ]",
"[ 19/16 → 5/4 | s:bd ]",
"[ 5/4 → 21/16 | s:bd ]",
"[ 11/8 → 23/16 | s:bd ]",
"[ 25/16 → 13/8 | s:bd ]",
"[ 13/8 → 27/16 | s:bd ]",
"[ 27/16 → 7/4 | s:bd ]",
"[ 7/4 → 29/16 | s:bd ]",
"[ 15/8 → 31/16 | s:bd ]",
"[ 33/16 → 17/8 | s:bd ]",
"[ 17/8 → 35/16 | s:bd ]",
"[ 35/16 → 9/4 | s:bd ]",
"[ 9/4 → 37/16 | s:bd ]",
"[ 19/8 → 39/16 | s:bd ]",
"[ 41/16 → 21/8 | s:bd ]",
"[ 21/8 → 43/16 | s:bd ]",
"[ 43/16 → 11/4 | s:bd ]",
"[ 11/4 → 45/16 | s:bd ]",
"[ 23/8 → 47/16 | s:bd ]",
"[ 49/16 → 25/8 | s:bd ]",
"[ 25/8 → 51/16 | s:bd ]",
"[ 51/16 → 13/4 | s:bd ]",
"[ 13/4 → 53/16 | s:bd ]",
"[ 27/8 → 55/16 | s:bd ]",
"[ 57/16 → 29/8 | s:bd ]",
"[ 29/8 → 59/16 | s:bd ]",
"[ 59/16 → 15/4 | s:bd ]",
"[ 15/4 → 61/16 | s:bd ]",
"[ 31/8 → 63/16 | s:bd ]",
]
`;
@ -7797,6 +7871,52 @@ but parts might be played more than once, or not at all, per cycle." example ind
]
`;
exports[`runs examples > example "scrub" example index 0 1`] = `
[
"[ 0/1 → 1/8 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 1/8 → 1/4 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 1/4 → 5/8 | s:swpad n:0 begin:0.25 speed:1 clip:1 ]",
"[ 5/8 → 3/4 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 3/4 → 7/8 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 7/8 → 1/1 | s:swpad n:0 begin:0.8 speed:1.5 clip:1 ]",
"[ 1/1 → 9/8 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 9/8 → 5/4 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 5/4 → 13/8 | s:swpad n:0 begin:0.25 speed:1 clip:1 ]",
"[ 13/8 → 7/4 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 7/4 → 15/8 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 15/8 → 2/1 | s:swpad n:0 begin:0.8 speed:1.5 clip:1 ]",
"[ 2/1 → 17/8 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 17/8 → 9/4 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 9/4 → 21/8 | s:swpad n:0 begin:0.25 speed:1 clip:1 ]",
"[ 21/8 → 11/4 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 11/4 → 23/8 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 23/8 → 3/1 | s:swpad n:0 begin:0.8 speed:1.5 clip:1 ]",
"[ 3/1 → 25/8 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 25/8 → 13/4 | s:swpad n:0 begin:0.1 speed:1 clip:1 ]",
"[ 13/4 → 29/8 | s:swpad n:0 begin:0.25 speed:1 clip:1 ]",
"[ 29/8 → 15/4 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 15/4 → 31/8 | s:swpad n:0 begin:0.7 speed:1 clip:1 ]",
"[ 31/8 → 4/1 | s:swpad n:0 begin:0.8 speed:1.5 clip:1 ]",
]
`;
exports[`runs examples > example "scrub" example index 1 1`] = `
[
"[ 0/1 → 3/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 3/8 → 5/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 5/8 → 1/1 | s:amen speed:0.25 unit:c begin:0.25 clip:1 ]",
"[ 1/1 → 11/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 11/8 → 13/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 13/8 → 2/1 | s:amen speed:0.25 unit:c begin:0.25 clip:1 ]",
"[ 2/1 → 19/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 19/8 → 21/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 21/8 → 3/1 | s:amen speed:0.25 unit:c begin:0.25 clip:1 ]",
"[ 3/1 → 27/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 27/8 → 29/8 | s:amen speed:0.25 unit:c begin:0 clip:1 ]",
"[ 29/8 → 4/1 | s:amen speed:0.25 unit:c begin:0.25 clip:1 ]",
]
`;
exports[`runs examples > example "segment" example index 0 1`] = `
[
"[ 0/1 → 1/24 | note:40 ]",

View File

@ -24,7 +24,7 @@ With the new stepwise `stepcat` function, the steps of the two patterns will be
By default, steps are counted according to the 'top level' in mini-notation. For example `"a [b c] d e"` has five events in it per cycle, but is counted as four steps, where `[b c]` is counted as a single step.
However, you can mark a different metrical level to count steps relative to, using a `^` at the start of a sub-pattern. If we do this to the subpattern in our example: `"a [^b c] d e"`, then the pattern is now counted as having _eight_ steps. This is because 'b' and 'c' are each counted as single steps, and the events in the pattenr are twice as long, and so counted as two steps each.
However, you can mark a different metrical level to count steps relative to, using a `^` at the start of a sub-pattern. If we do this to the subpattern in our example: `"a [^b c] d e"`, then the pattern is now counted as having _eight_ steps. This is because 'b' and 'c' are each counted as single steps, and the events in the pattern are twice as long, and so counted as two steps each.
## Pacing the steps

View File

@ -1,12 +1,14 @@
---
import HeadCommon from '@components/HeadCommon.astro';
import { Udels } from '../../components/Udels/Udels.jsx';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
---
<body class="m-0">
<Udels client:only="react" />
</body>
<html lang="en" class="m-0">
<head>
<HeadCommon />
<title>Strudel UDELS</title>
</head>
<body class="m-0">
<Udels client:only="react" />
</body>
</html>

View File

@ -4,5 +4,9 @@ export default function UserFacingErrorMessage(Props) {
if (error == null) {
return;
}
return <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>;
return (
<div className="text-background px-2 py-1 bg-foreground w-full ml-auto">
Error: {error.message || 'Unknown Error :-/'}
</div>
);
}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { getAudioDevices, setAudioDevice } from '../../util.mjs';
import { SelectInput } from './SelectInput';
import { getAudioDevices } from '@strudel/webaudio';
const initdevices = new Map();
@ -21,9 +22,7 @@ export function AudioDeviceSelector({ audioDeviceName, onChange, isDisabled }) {
if (!devicesInitialized) {
return;
}
const deviceID = devices.get(deviceName);
onChange(deviceName);
setAudioDevice(deviceID);
};
const options = new Map();
Array.from(devices.keys()).forEach((deviceName) => {

View File

@ -20,25 +20,42 @@ export default function ImportSoundsButton({ onComplete }) {
});
return (
<label
style={{ alignItems: 'center' }}
className="flex bg-background ml-2 pl-2 pr-2 max-w-[300px] rounded-md hover:opacity-50 whitespace-nowrap cursor-pointer"
>
<input
disabled={isUploading}
ref={fileUploadRef}
id="audio_file"
style={{ display: 'none' }}
type="file"
directory=""
webkitdirectory=""
multiple
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
onChange={() => {
onChange();
}}
/>
{isUploading ? 'importing...' : 'import sounds'}
</label>
<div>
<label
style={{ alignItems: 'center', borderColor: 'red', border: 1 }}
className="flex bg-background p-4 w-fit rounded-xl hover:opacity-50 whitespace-nowrap cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6 mr-2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v7.5a2.25 2.25 0 0 0 2.25 2.25h7.5a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75m0-3-3-3m0 0-3 3m3-3v11.25m6-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v7.5a2.25 2.25 0 0 1-2.25 2.25h-7.5a2.25 2.25 0 0 1-2.25-2.25v-.75"
/>
</svg>
<input
disabled={isUploading}
ref={fileUploadRef}
id="audio_file"
style={{ display: 'none' }}
type="file"
directory=""
webkitdirectory=""
multiple
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
onChange={() => {
onChange();
}}
/>
{isUploading ? 'importing...' : 'import sounds folder'}
</label>
</div>
);
}

View File

@ -5,7 +5,7 @@ export function SelectInput({ value, options, onChange, onClick, isDisabled }) {
<select
disabled={isDisabled}
onClick={onClick}
className="p-2 bg-background rounded-md text-foreground"
className="p-2 bg-background rounded-md text-foreground border-foreground"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>

View File

@ -1,10 +1,12 @@
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror';
import { Textbox } from '../textbox/Textbox.jsx';
import { isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs';
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony } from '@strudel/webaudio';
function Checkbox({ label, value, onChange, disabled = false }) {
return (
@ -18,7 +20,7 @@ function Checkbox({ label, value, onChange, disabled = false }) {
function SelectInput({ value, options, onChange }) {
return (
<select
className="p-2 bg-background rounded-md text-foreground"
className="p-2 bg-background rounded-md text-foreground border-foreground"
value={value}
onChange={(e) => onChange(e.target.value)}
>
@ -53,7 +55,7 @@ function NumberSlider({ value, onChange, step = 1, ...rest }) {
);
}
function FormItem({ label, children }) {
function FormItem({ label, children, sublabel }) {
return (
<div className="grid gap-2">
<label>{label}</label>
@ -105,6 +107,7 @@ export function SettingsTab({ started }) {
audioDeviceName,
audioEngineTarget,
togglePanelTrigger,
maxPolyphony,
} = useSettings();
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@ -139,6 +142,26 @@ export function SettingsTab({ started }) {
}}
/>
</FormItem>
<FormItem label="Maximum Polyphony">
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? DEFAULT_MAX_POLYPHONY : v;
setMaxPolyphony(v);
settingsMap.setKey('maxPolyphony', v);
}}
onChange={(v) => {
v = Math.max(1, parseInt(v));
settingsMap.setKey('maxPolyphony', isNaN(v) ? undefined : v);
}}
type="number"
placeholder=""
value={maxPolyphony ?? ''}
/>
</FormItem>
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
@ -179,25 +202,7 @@ export function SettingsTab({ started }) {
value={togglePanelTrigger}
onChange={(value) => settingsMap.setKey('togglePanelTrigger', value)}
items={{ click: 'Click', hover: 'Hover' }}
></ButtonGroup>
{/* <Checkbox
label="Click"
onChange={(cbEvent) => {
if (cbEvent.target.checked) {
settingsMap.setKey('togglePanelTrigger', 'click');
}
}}
value={togglePanelTrigger != 'hover'}
/>
<Checkbox
label="Hover"
onChange={(cbEvent) => {
if (cbEvent.target.checked) {
settingsMap.setKey('togglePanelTrigger', 'hover');
}
}}
value={togglePanelTrigger == 'hover'}
/> */}
</FormItem>
<FormItem label="More Settings">
<Checkbox

View File

@ -14,6 +14,8 @@ export function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const [search, setSearch] = useState('');
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
const soundEntries = useMemo(() => {
if (!sounds) {
@ -37,6 +39,9 @@ export function SoundsTab() {
if (soundsFilter === 'synths') {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
if (soundsFilter === 'importSounds') {
return [];
}
return filtered;
}, [sounds, soundsFilter, search]);
@ -51,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)} />
@ -65,9 +69,9 @@ export function SoundsTab() {
drums: 'drum-machines',
synths: 'Synths',
user: 'User',
importSounds: 'import-sounds',
}}
></ButtonGroup>
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
</div>
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
@ -101,7 +105,55 @@ export function SoundsTab() {
</span>
);
})}
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
{!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>
To import sounds into strudel, they must be contained{' '}
<a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds`} target="_blank">
within a folder or subfolder
</a>
. The best way to do this is to upload a samples folder containing subfolders of individual sounds or
soundbanks (see diagram below).{' '}
</p>
<pre className="bg-background" key={'sample-diagram'}>
{`└─ samples <-- import this folder
swoop
swoopshort.wav
swooplong.wav
swooptight.wav
smash
smashhigh.wav
smashlow.wav
smashmiddle.wav`}
</pre>
<p>
The name of a subfolder corresponds to the sound name under the user tab. Multiple samples within a
subfolder are all labelled with the same name, but can be accessed using .n( ) - remember sounds are
zero-indexed and in alphabetical order!
</p>
<p>
For more information, and other ways to use your own sounds in strudel,{' '}
<a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds`} target="_blank">
check out the docs
</a>
!
</p>
<h3>Preview Sounds</h3>
<pre className="bg-background" key={'sample-preview'}>
n("0 1 2 3 4 5").s("sample-name")
</pre>
<p>
Paste the line above into the main editor to hear the uploaded folder. Remember to use the name of your
sample as it appears under the "user" tab.
</p>
</div>
) : (
''
)}
{!soundEntries.length && soundsFilter !== 'importSounds'
? 'No custom sounds loaded in this pattern (yet).'
: ''}
</div>
</div>
);

View File

@ -3,7 +3,10 @@ import cx from '@src/cx.mjs';
export function Textbox({ onChange, className, ...inputProps }) {
return (
<input
className={cx('p-1 bg-background rounded-md my-2 border-foreground', className)}
className={cx(
'p-2 bg-background rounded-md border-foreground text-foreground placeholder-foreground',
className,
)}
onChange={(e) => onChange(e.target.value)}
{...inputProps}
/>

View File

@ -14,7 +14,7 @@ import {
resetLoadedSounds,
initAudioOnFirstClick,
} from '@strudel/webaudio';
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
import { setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
import { clearHydra } from '@strudel/hydra';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -28,7 +28,7 @@ import {
setViewingPatternData,
} from '../user_pattern_utils.mjs';
import { superdirtOutput } from '@strudel/osc/superdirtoutput';
import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs';
import { audioEngineTargets } from '../settings.mjs';
import { useStore } from '@nanostores/react';
import { prebake } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
@ -36,11 +36,11 @@ import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get();
const { latestCode, maxPolyphony, audioDeviceName } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
if (typeof window !== 'undefined') {
audioReady = initAudioOnFirstClick();
audioReady = initAudioOnFirstClick({ maxPolyphony, audioDeviceName });
modulesLoading = loadModules();
presets = prebake();
drawContext = getDrawContext();
@ -159,20 +159,6 @@ export function useReplContext() {
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
// on first load, set stored audio device if possible
useEffect(() => {
const { audioDeviceName } = _settings;
if (audioDeviceName !== defaultAudioDeviceName) {
getAudioDevices().then((devices) => {
const deviceID = devices.get(audioDeviceName);
if (deviceID == null) {
return;
}
setAudioDevice(deviceID);
});
}
}, []);
//
// UI Actions
//

View File

@ -1,6 +1,6 @@
import { evalScope, hash2code, logger } from '@strudel/core';
import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs';
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, setVersionDefaults } from '@strudel/webaudio';
import { settingPatterns } from '../settings.mjs';
import { setVersionDefaults } from '@strudel/webaudio';
import { getMetadata } from '../metadata_parser';
import { isTauri } from '../tauri.mjs';
import './Repl.css';
@ -159,38 +159,6 @@ export const isUdels = () => {
return window.top?.location?.pathname.includes('udels');
};
export const getAudioDevices = async () => {
await navigator.mediaDevices.getUserMedia({ audio: true });
let mediaDevices = await navigator.mediaDevices.enumerateDevices();
mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default');
const devicesMap = new Map();
devicesMap.set(defaultAudioDeviceName, '');
mediaDevices.forEach((device) => {
devicesMap.set(device.label, device.deviceId);
});
return devicesMap;
};
export const setAudioDevice = async (id) => {
let audioCtx = getAudioContext();
if (audioCtx.sinkId === id) {
return;
}
await audioCtx.suspend();
await audioCtx.close();
audioCtx = setDefaultAudioContext();
await audioCtx.resume();
const isValidID = (id ?? '').length > 0;
if (isValidID) {
try {
await audioCtx.setSinkId(id);
} catch {
logger('failed to set audio interface', 'warning');
}
}
initializeAudioOutput();
};
export function setVersionDefaultsFrom(code) {
try {
const metadata = getMetadata(code);

View File

@ -3,8 +3,6 @@ import { useStore } from '@nanostores/react';
import { register } from '@strudel/core';
import { isUdels } from './repl/util.mjs';
export const defaultAudioDeviceName = 'System Standard';
export const audioEngineTargets = {
webaudio: 'webaudio',
osc: 'osc',
@ -36,10 +34,10 @@ export const defaultSettings = {
isPanelOpen: true,
togglePanelTrigger: 'click', //click | hover
userPatterns: '{}',
audioDeviceName: defaultAudioDeviceName,
audioEngineTarget: audioEngineTargets.webaudio,
isButtonRowHidden: false,
isCSSAnimationDisabled: false,
maxPolyphony: 128,
};
let search = null;