Compare commits

..

18 Commits

Author SHA1 Message Date
Jade (Rose) Rowland 9258857e36 Merge branch 'main' into show12
Resolves conflicts in packages/superdough/sampler.mjs (merged imports and
getSampleInfo/getDuration additions from main) and website/src/settings.mjs
(non-overlapping additions auto-merged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01559pA7fMAMyY9H91PaewXz
2026-09-14 02:35:56 -04:00
Jade (Rose) Rowland ad7e301394 repeat charactar 2026-07-30 12:13:47 +01:00
Jade (Rose) Rowland 7acb9a3ac7 patterntaborder 2026-04-09 17:26:37 -07:00
Jade (Rose) Rowland 9665b233a1 sort by pattern string 2026-04-03 23:41:46 -04:00
Jade (Rose) Rowland 6c67851909 fix merge conflict 2026-02-16 01:42:51 -05:00
Jade (Rose) Rowland 109ea3093b Merge branch 'soloshortcut' into show5 2026-02-11 19:56:37 -05:00
Jade (Rose) Rowland 8d991ad8ae Merge branch 'sample_note' into show5 2026-02-11 19:53:20 -05:00
Jade (Rose) Rowland 0b69ce21c7 jsdoc 2026-02-08 21:11:10 -05:00
Jade (Rose) Rowland 4f05674cb3 working 2026-02-08 20:32:59 -05:00
Jade (Rose) Rowland feab3f5c86 freq 2026-02-08 19:25:10 -05:00
Jade (Rose) Rowland 90c05ec38a fix edge cases 2026-02-08 14:12:21 -05:00
Jade (Rose) Rowland 7e0eed87cb normalize shortcuts 2026-01-27 17:21:36 -05:00
Jade (Rose) Rowland 1a1197f67a Merge branch 'main' into soloshortcut 2026-01-22 21:11:31 -05:00
Jade (Rose) Rowland 3c5afb32f3 scroll to center 2026-01-22 02:04:27 -05:00
Jade (Rose) Rowland 76cbd23859 Merge branch 'soloshortcut' of ssh://codeberg.org/uzu/strudel into soloshortcut 2026-01-22 00:25:41 -05:00
Jade (Rose) Rowland b436ae789c rm dead code 2026-01-22 00:25:28 -05:00
Switch Angel AKA Jade Rose 275731afc7 Merge branch 'main' into soloshortcut 2026-01-22 06:22:17 +01:00
Jade (Rose) Rowland 5cb2214d88 working 2026-01-22 00:20:44 -05:00
35 changed files with 555 additions and 247 deletions
+76 -3
View File
@@ -11,6 +11,7 @@ import {
keymap, keymap,
lineNumbers, lineNumbers,
} from '@codemirror/view'; } from '@codemirror/view';
import {repeatCharKeymap} from './repeatcharacter.mjs';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core'; import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw'; import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
@@ -20,11 +21,16 @@ import { evalBlock } from './block_utilities.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { jumpToCharacter } from './labelJump.mjs';
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { isTooltipEnabled } from './tooltip.mjs';
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs'; import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
import {
deleteAllInlineBeforeCharacter,
InsertCharBeforeChar,
jumpToCharacter,
jumpToNextCharacter,
} from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -75,6 +81,10 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
decode: JSON.parse, decode: JSON.parse,
}); });
const ANON_LABEL = '$';
const SOLO_LABEL = 'S';
const MUTE_LABEL = '_';
// https://codemirror.net/docs/guide/ // https://codemirror.net/docs/guide/
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) { export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
const settings = codemirrorSettings.get(); const settings = codemirrorSettings.get();
@@ -102,6 +112,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)), EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }), drawSelection({ cursorBlinkRate: 0 }),
repeatCharKeymap,
Prec.highest( Prec.highest(
keymap.of([ keymap.of([
{ {
@@ -138,12 +149,74 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
}, },
{ {
key: 'Alt-w', key: 'Alt-w',
run: (view) => jumpToCharacter(view, '$', 1), run: (view) => jumpToNextCharacter(view, ANON_LABEL, 1),
}, },
{ {
key: 'Alt-q', key: 'Alt-q',
run: (view) => jumpToCharacter(view, '$', -1), run: (view) => {
return jumpToNextCharacter(view, ANON_LABEL, -1);
}, },
},
// clear all muted
{
key: `Alt-Ctrl-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, MUTE_LABEL + ANON_LABEL);
},
},
// clear all solod
{
key: `Alt-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, SOLO_LABEL + ANON_LABEL);
},
},
// clear all solo and mute
{
key: `Ctrl-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, ANON_LABEL);
},
},
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-${num}`,
run: (view) => {
return jumpToCharacter(view, ANON_LABEL, i);
},
};
}),
// handle solo toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, SOLO_LABEL, i);
},
};
}),
// handle mute toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Ctrl-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, MUTE_LABEL, i);
},
};
}),
// Handle clearing mutes and solos 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Ctrl-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, '', i);
},
};
}),
/* { /* {
key: 'Ctrl-Shift-.', key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()), run: () => (onPanic ? onPanic() : onStop?.()),
+131 -9
View File
@@ -1,18 +1,54 @@
import { EditorSelection } from '@codemirror/state'; import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search'; import { SearchCursor } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
export function jumpToCharacter(view, character, direction = 1) { /**
const { state, dispatch } = view; * gets all of the positions of a character in a document, excluding commented out lines
const pos = state.selection.main.head; * @param { EditorState} state
* @param {String} character
* @returns {number[]}
*/
function getCharacterPositions(state, character) {
const cursor = new SearchCursor(state.doc, character); const cursor = new SearchCursor(state.doc, character);
let characterPositions = []; const characterPositions = [];
let jumpPos;
while (!cursor.next().done) { while (!cursor.next().done) {
const linestartpos = state.doc.lineAt(cursor.value.to).from
if (!isLineCommentedOut(state, linestartpos)) {
characterPositions.push(cursor.value.to); characterPositions.push(cursor.value.to);
} }
}
return characterPositions;
}
function isLineCommentedOut(state, pos) {
const line = state.doc.lineAt(pos);
// remove white space
pos = line.from + line.text.search(/\S/)
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, 1)
return node.name.includes("Comment")
}
/**
* jump to the next character in a document
* @param {EditorView} view
* @param {String} character
* @param {number} direction 0 or 1
* @returns {boolean}
*/
export function jumpToNextCharacter(view, character, direction = 1) {
const { state, dispatch } = view;
const pos = state.selection.main.head;
let jumpPos;
const characterPositions = getCharacterPositions(state, character);
if (!characterPositions.length) { if (!characterPositions.length) {
return false; return true;
} }
if (direction > 0) { if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
@@ -21,11 +57,97 @@ export function jumpToCharacter(view, character, direction = 1) {
} }
if (jumpPos == null) { if (jumpPos == null) {
return false; return true;
} }
const selection = EditorSelection.cursor(jumpPos - 1);
dispatch({ dispatch({
selection: EditorSelection.cursor(jumpPos - 1), selection,
scrollIntoView: true, effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
}); });
return true; return true;
} }
/**
*
* @param {EditorView} view
* @param {String} character
* @param {number} index the instance of the character
* @returns {true}
*/
export function jumpToCharacter(view, character, index) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const pos = characterPositions.at(index) ?? characterPositions.at(-1);
if (pos == null) {
return true;
}
const selection = EditorSelection.cursor(pos - 1);
dispatch({
selection,
effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
});
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @returns {true}
*/
export function deleteAllInlineBeforeCharacter(view, character) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const changes = [];
characterPositions.forEach((pos) => {
const line = state.doc.lineAt(pos);
if (state.doc.sliceString(line.from, line.from + 2) === COMMENT_STRING) {
return;
}
changes.push({
from: line.from,
to: pos - 1,
insert: '',
});
});
dispatch({ changes });
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @param {String} character2
* @param {number} index
* @returns {true}
*/
export function InsertCharBeforeChar(view, character, character2, index) {
const { state, dispatch } = view;
const changes = [];
const characterPositions = getCharacterPositions(state, character);
const labelpos = characterPositions.at(index) ?? characterPositions.at(-1);
const line = state.doc.lineAt(labelpos);
//delete preceeding characters
changes.push({
from: line.from,
to: labelpos - 1,
insert: '',
});
changes.push({
insert: character2,
from: line.from,
});
dispatch({ changes });
return true;
}
+55
View File
@@ -0,0 +1,55 @@
import { Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
const repeatMode = new Compartment();
function repeatPreviousChar(times) {
return ({ state, dispatch }) => {
const { from, empty } = state.selection.main;
if (!empty || from === 0) return false;
const prevChar = state.doc.sliceString(from - 1, from);
const text = Array(times).fill(prevChar).join(" ");
dispatch(state.update({
changes: {
from,
insert: " " + text
}
}));
return true;
};
}
function exitRepeatMode(view) {
view.dispatch({
effects: repeatMode.reconfigure([])
});
}
const digitBindings = Array.from({ length: 9 }, (_, i) => ({
key: String(i + 1),
run(view) {
repeatPreviousChar(i + 1)(view);
exitRepeatMode(view);
return true;
}
}));
export const repeatCharKeymap = [
repeatMode.of([]),
keymap.of([
{
key: "Alt-r",
run(view) {
view.dispatch({
effects: repeatMode.reconfigure(keymap.of(digitBindings))
});
return true;
}
}
])
];
+7 -2
View File
@@ -10,7 +10,7 @@ import { errorLogger, logger } from './logger.mjs';
export class Cyclist { export class Cyclist {
constructor({ constructor({
interval, interval,
onSchedule, onTrigger,
onToggle, onToggle,
onError, onError,
getTime, getTime,
@@ -63,7 +63,12 @@ export class Cyclist {
if (hap.hasOnset()) { if (hap.hasOnset()) {
const targetTime = const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency; (hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
onSchedule?.(hap, targetTime, this.cps); const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://codeberg.org/uzu/strudel/pulls/1004
const deadline = targetTime - phase;
// this onTrigger has another signature
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
if (hap.value.cps !== undefined && this.cps != hap.value.cps) { if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
this.cps = hap.value.cps; this.cps = hap.value.cps;
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
+3 -2
View File
@@ -8,7 +8,7 @@ import { logger } from './logger.mjs';
import { ClockCollator, cycleToSeconds } from './util.mjs'; import { ClockCollator, cycleToSeconds } from './util.mjs';
export class NeoCyclist { export class NeoCyclist {
constructor({ onSchedule, onToggle, getTime }) { constructor({ onTrigger, onToggle, getTime }) {
this.started = false; this.started = false;
this.cps = 0.5; this.cps = 0.5;
this.getTime = getTime; // get absolute time this.getTime = getTime; // get absolute time
@@ -43,7 +43,8 @@ export class NeoCyclist {
if (hap.hasOnset()) { if (hap.hasOnset()) {
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
const targetTime = timeUntilTrigger + currentTime + this.latency; const targetTime = timeUntilTrigger + currentTime + this.latency;
onSchedule?.(hap, targetTime, this.cps); const duration = cycleToSeconds(hap.duration, this.cps);
onTrigger?.(hap, 0, duration, this.cps, targetTime);
} }
}); });
}; };
+27 -26
View File
@@ -27,7 +27,6 @@ import {
import drawLine from './drawLine.mjs'; import drawLine from './drawLine.mjs';
import { errorLogger, logger } from './logger.mjs'; import { errorLogger, logger } from './logger.mjs';
import { strudelScope } from './evaluate.mjs'; import { strudelScope } from './evaluate.mjs';
import { getTime } from './schedulerState.mjs';
let stringParser; let stringParser;
@@ -873,41 +872,23 @@ export class Pattern {
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Context methods - ones that deal with metadata // Context methods - ones that deal with metadata
/** onTrigger(onTrigger, dominant = true) {
* Attaches a function that is called for each hap onset.
* @name onSchedule
* @memberof Pattern
* @example
* s("bd sd").onSchedule((hap, time, cps) => {
* console.log('hap scheduled', time, cps);
* })
*/
onSchedule(onSchedule, dominant = true) {
return this.withHap((hap) => return this.withHap((hap) =>
hap.setContext({ hap.setContext({
...hap.context, ...hap.context,
onSchedule: (scheduleHap, t, cps) => { onTrigger: (...args) => {
// run previously set trigger, if it exists // run previously set trigger, if it exists
hap.context.onSchedule?.(scheduleHap, t, cps); hap.context.onTrigger?.(...args);
onSchedule(scheduleHap, t, cps); onTrigger(...args);
}, },
// if dominantScheduler is set to true, the default output (webaudio) will be disabled // if dominantTrigger is set to true, the default output (webaudio) will be disabled
// when using multiple triggers, you cannot flip this flag to false again! // when using multiple triggers, you cannot flip this flag to false again!
// example: x.csound('CooLSynth').log() as well as x.log().csound('CooLSynth') should work the same // example: x.csound('CooLSynth').log() as well as x.log().csound('CooLSynth') should work the same
dominantScheduler: hap.context.dominantScheduler || dominant, dominantTrigger: hap.context.dominantTrigger || dominant,
}), }),
); );
} }
// legacy:
onTrigger(onTrigger, dominant = true) {
console.warn('onTrigger is deprecated.. use onSchedule(hap, time, cps) instead');
return this.onSchedule((hap, t, cps) => {
const currentTime = getTime();
return onTrigger(hap, currentTime, cps, t);
}, dominant);
}
/** /**
* Writes the content of the current event to the console (visible in the side menu). * Writes the content of the current event to the console (visible in the side menu).
* @tags visualization * @tags visualization
@@ -917,7 +898,7 @@ export class Pattern {
* s("bd sd").log() * s("bd sd").log()
*/ */
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) { log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
return this.onSchedule((...args) => { return this.onTrigger((...args) => {
logger(func(...args), undefined, getData(...args)); logger(func(...args), undefined, getData(...args));
}, false); }, false);
} }
@@ -3654,6 +3635,26 @@ export const slice = register(
false, // turns off auto-patternification false, // turns off auto-patternification
); );
/**
*
* make something happen on event time
* uses browser timeout which is innacurate for audio tasks
* @name onTriggerTime
* @tags external_io
* @memberof Pattern
* @returns Pattern
* @example
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
*/
Pattern.prototype.onTriggerTime = function (func) {
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
const diff = targetTime - currentTime;
window.setTimeout(() => {
func(hap);
}, diff * 1000);
}, false);
};
/** /**
* Works the same as slice, but changes the playback speed of each slice to match the duration of its step. * Works the same as slice, but changes the playback speed of each slice to match the duration of its step.
* @name splice * @name splice
+14 -11
View File
@@ -7,7 +7,7 @@ import {
setIsStarted, setIsStarted,
setPattern as exposeSchedulerPattern, setPattern as exposeSchedulerPattern,
setTime, setTime,
setSchedulerFunc, setTriggerFunc,
} from './schedulerState.mjs'; } from './schedulerState.mjs';
import { evalScope } from './evaluate.mjs'; import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
@@ -55,7 +55,7 @@ export function repl({
}; };
const schedulerOptions = { const schedulerOptions = {
onSchedule: getScheduler({ defaultOutput }), onTrigger: getTrigger({ defaultOutput, getTime }),
getTime, getTime,
onToggle: (started) => { onToggle: (started) => {
updateState({ started }); updateState({ started });
@@ -73,7 +73,7 @@ export function repl({
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome // NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler = const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions); sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
setSchedulerFunc(schedulerOptions.onSchedule); setTriggerFunc(schedulerOptions.onTrigger);
setCpsFunc(() => scheduler.cps); setCpsFunc(() => scheduler.cps);
let pPatterns = {}; let pPatterns = {};
let anonymousIndex = 0; let anonymousIndex = 0;
@@ -560,17 +560,20 @@ export function repl({
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state }; return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
} }
export const getScheduler = export const getTrigger =
({ defaultOutput }) => ({ getTime, defaultOutput }) =>
async (hap, t, cps) => { async (hap, deadline, duration, cps, t) => {
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
try { try {
if (!hap.context.onSchedule || !hap.context.dominantScheduler) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, t, cps); await defaultOutput(hap, deadline, duration, cps, t);
} }
if (hap.context.onSchedule) { if (hap.context.onTrigger) {
await hap.context.onSchedule(hap, t, cps); // call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t);
} }
} catch (err) { } catch (err) {
errorLogger(err, 'getScheduler'); errorLogger(err, 'getTrigger');
} }
}; };
+5 -5
View File
@@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
let time; let time;
let cpsFunc; let cpsFunc;
let pattern; let pattern;
let schedulerFunc; let triggerFunc;
let isStarted; let isStarted;
export function getTime() { export function getTime() {
if (!time) { if (!time) {
@@ -36,12 +36,12 @@ export function getPattern() {
return pattern; return pattern;
} }
export function setSchedulerFunc(func) { export function setTriggerFunc(func) {
schedulerFunc = func; triggerFunc = func;
} }
export function getSchedulerFunc() { export function getTriggerFunc() {
return schedulerFunc; return triggerFunc;
} }
export function setIsStarted(val) { export function setIsStarted(val) {
+1 -1
View File
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
} }
export const speak = register('speak', function (lang, voice, pat) { export const speak = register('speak', function (lang, voice, pat) {
return pat.onSchedule((hap) => { return pat.onTrigger((hap) => {
triggerSpeech(hap.value, lang, voice); triggerSpeech(hap.value, lang, voice);
}); });
}); });
+2 -2
View File
@@ -1363,7 +1363,7 @@ describe('Pattern', () => {
// Force a trigger // Force a trigger
haps.forEach((hap) => { haps.forEach((hap) => {
hap.context?.onSchedule?.(hap); hap.context?.onTrigger?.(hap);
}); });
expect(mockConsoleLog).toHaveBeenCalledWith( expect(mockConsoleLog).toHaveBeenCalledWith(
@@ -1381,7 +1381,7 @@ describe('Pattern', () => {
// Force a trigger // Force a trigger
haps.forEach((hap) => { haps.forEach((hap) => {
hap.context?.onSchedule?.(hap); hap.context?.onTrigger?.(hap);
}); });
expect(mockConsoleLog).toHaveBeenCalledWith( expect(mockConsoleLog).toHaveBeenCalledWith(
+3 -5
View File
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
instrument = instrument || 'triangle'; instrument = instrument || 'triangle';
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
// TODO: find a alternative way to wait for csound to load (to wait with first time playback) // TODO: find a alternative way to wait for csound to load (to wait with first time playback)
return pat.onSchedule((hap, targetTime) => { return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
if (!_csound) { if (!_csound) {
logger('[csound] not loaded yet', 'warning'); logger('[csound] not loaded yet', 'warning');
return; return;
@@ -36,10 +36,9 @@ export const csound = register('csound', (instrument, pat) => {
const controls = Object.entries({ ...hap.value, freq }) const controls = Object.entries({ ...hap.value, freq })
.flat() .flat()
.join('/'); .join('/');
// TODO: find out how to send a precise ctx based time // TODO: find out how to send a precise ctx based time
// http://www.csounds.com/manual/html/i.html // http://www.csounds.com/manual/html/i.html
const timeOffset = targetTime - getAudioContext().currentTime; // latency ? const timeOffset = targetTime - currentTime; // latency ?
//const timeOffset = time_deprecate - getAudioContext().currentTime //const timeOffset = time_deprecate - getAudioContext().currentTime
const params = [ const params = [
`"${instrument}"`, // p1: instrument name `"${instrument}"`, // p1: instrument name
@@ -145,7 +144,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
p1 = `"${instrument}"`; p1 = `"${instrument}"`;
} }
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
return pat.onSchedule((hap, targetTime) => { return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
if (!_csound) { if (!_csound) {
logger('[csound] not loaded yet', 'warning'); logger('[csound] not loaded yet', 'warning');
return; return;
@@ -153,7 +152,6 @@ export const csoundm = register('csoundm', (instrument, pat) => {
if (typeof hap.value !== 'object') { if (typeof hap.value !== 'object') {
throw new Error('csound only support objects as hap values'); throw new Error('csound only support objects as hap values');
} }
const currentTime = getAudioContext().currentTime;
// Time in seconds counting from now. // Time in seconds counting from now.
const p2 = targetTime - currentTime; const p2 = targetTime - currentTime;
const p3 = hap.duration.valueOf() + 0; const p3 = hap.duration.valueOf() + 0;
+1 -3
View File
@@ -1,4 +1,3 @@
import { getAudioContext } from '@strudel/webaudio/index.mjs';
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core'; import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
@@ -7,8 +6,7 @@ const OFF_MESSAGE = 0x80;
const CC_MESSAGE = 0xb0; const CC_MESSAGE = 0xb0;
Pattern.prototype.midi = function (output) { Pattern.prototype.midi = function (output) {
return this.onSchedule((hap, targetTime, cps) => { return this.onTrigger((hap, currentTime, cps, targetTime) => {
const currentTime = getAudioContext().currentTime;
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value; let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
//magic number to get audio engine to line up, can probably be calculated somehow //magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = 34; const latencyMs = 34;
+3 -5
View File
@@ -1,14 +1,12 @@
import { Pattern, ClockCollator } from '@strudel/core'; import { Pattern, ClockCollator } from '@strudel/core';
import { parseControlsFromHap } from '@strudel/osc/osc.mjs'; import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
import { getAudioContext } from '@strudel/webaudio/index.mjs';
const collator = new ClockCollator({}); const collator = new ClockCollator({});
export async function oscSchedulerTauri(hap, targetTime, cps = 1) { export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
const controls = parseControlsFromHap(hap, cps); const controls = parseControlsFromHap(hap, cps);
const params = []; const params = [];
const currentTime = getAudioContext().currentTime;
const timestamp = collator.calculateTimestamp(currentTime, targetTime); const timestamp = collator.calculateTimestamp(currentTime, targetTime);
Object.keys(controls).forEach((key) => { Object.keys(controls).forEach((key) => {
@@ -34,5 +32,5 @@ export async function oscSchedulerTauri(hap, targetTime, cps = 1) {
}); });
} }
Pattern.prototype.osc = function () { Pattern.prototype.osc = function () {
return this.onSchedule(oscSchedulerTauri); return this.onTrigger(oscTriggerTauri);
}; };
-1
View File
@@ -23,7 +23,6 @@
}, },
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/webaudio": "workspace:*",
"@tauri-apps/api": "^2.2.0" "@tauri-apps/api": "^2.2.0"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
+2 -2
View File
@@ -37,7 +37,7 @@ export function initDough() {
return D.ready; return D.ready;
} }
export async function doughScheduler(hap, targetTime, cps = 1) { export async function doughTrigger(hap, _currentTime, cps = 1, targetTime) {
const offset = D.context_offset?.[0]; const offset = D.context_offset?.[0];
if (!offset) { if (!offset) {
return; // not ready return; // not ready
@@ -71,5 +71,5 @@ export async function doughScheduler(hap, targetTime, cps = 1) {
export const dough = register('dough', (pat) => { export const dough = register('dough', (pat) => {
initDough(); initDough();
ensureMinimalOutput(); ensureMinimalOutput();
return pat.onSchedule(doughScheduler); return pat.onTrigger(doughTrigger);
}); });
+7 -6
View File
@@ -13,7 +13,7 @@ import {
getIsStarted, getIsStarted,
getPattern, getPattern,
getTime, getTime,
getSchedulerFunc, getTriggerFunc,
isPattern, isPattern,
logger, logger,
ref, ref,
@@ -342,7 +342,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
ensureMinimalOutput(); ensureMinimalOutput();
return this.sortHapsByPart().onSchedule((hap, targetTime, cps) => { return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
@@ -570,8 +570,8 @@ const kListeners = {};
function _triggerKeyboard(input, cps, now, latencyCycles) { function _triggerKeyboard(input, cps, now, latencyCycles) {
const pattern = getPattern(); const pattern = getPattern();
const schedule = getSchedulerFunc(); const trigger = getTriggerFunc();
if (!pattern || !schedule) { if (!pattern || !trigger) {
return false; return false;
} }
const t = now + latencyCycles; const t = now + latencyCycles;
@@ -588,7 +588,8 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
return; return;
} }
const t = ctxNow + (hap.whole.begin - now) / cps; const t = ctxNow + (hap.whole.begin - now) / cps;
schedule(hap, t, cps); const duration = hap.duration / cps;
trigger(hap, t - ctxNow, duration, cps, t);
}); });
return true; return true;
@@ -623,7 +624,7 @@ export async function midikeys(input) {
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
const key = `${input}_${note}`; const key = `${input}_${note}`;
const cps = getCps() ?? 0.5; const cps = getCps() ?? 0.5;
const triggerAvailable = !!(getPattern() && getSchedulerFunc()); const triggerAvailable = !!(getPattern() && getTriggerFunc());
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
const now = getTime(); const now = getTime();
const t = now + latencySeconds * cps; const t = now + latencySeconds * cps;
+6 -3
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Pattern, getTime, createParams } from '@strudel/core'; import { Pattern, isPattern, createParams } from '@strudel/core';
import Paho from 'paho-mqtt'; import Paho from 'paho-mqtt';
const connections = {}; const connections = {};
@@ -80,7 +80,8 @@ Pattern.prototype.mqtt = function (
} }
cx.connect(props); cx.connect(props);
} }
return this.onSchedule((hap, targetTime, cps) => { return this.withHap((hap) => {
const onTrigger = (hap, currentTime, cps, targetTime) => {
let msg_topic = topic; let msg_topic = topic;
if (!cx || !cx.isConnected()) { if (!cx || !cx.isConnected()) {
return; return;
@@ -108,11 +109,13 @@ Pattern.prototype.mqtt = function (
message = new Paho.Message(message); message = new Paho.Message(message);
message.destinationName = msg_topic; message.destinationName = msg_topic;
const offset = (targetTime - getTime() + latency) * 1000; const offset = (targetTime - currentTime + latency) * 1000;
window.setTimeout(function () { window.setTimeout(function () {
cx.send(message); cx.send(message);
}, offset); }, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
}); });
}; };
+2 -2
View File
@@ -55,7 +55,7 @@ export function parseControlsFromHap(hap, cps) {
return controls; return controls;
} }
export async function oscScheduler(hap, targetTime, cps = 1) { export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect(); const ws = await connect();
const timeMs = getClockBridge().getPerformanceTime(targetTime); const timeMs = getClockBridge().getPerformanceTime(targetTime);
@@ -90,5 +90,5 @@ export async function oscScheduler(hap, targetTime, cps = 1) {
*/ */
export const osc = register('osc', (pat) => { export const osc = register('osc', (pat) => {
ensureMinimalOutput(); ensureMinimalOutput();
return pat.onSchedule(oscScheduler); return pat.onTrigger(oscTrigger);
}); });
+6 -3
View File
@@ -1,7 +1,10 @@
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs'; /* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
import { isTauri } from '../desktopbridge/utils.mjs'; */ import { isTauri } from '../desktopbridge/utils.mjs'; */
import { oscScheduler } from './osc.mjs'; import { oscTrigger } from './osc.mjs';
const scheduler = /* isTauri() ? oscTriggerTauri : */ oscScheduler; const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
export const superdirtOutput = (hap, targetTime, cps) => scheduler(hap, targetTime, cps); export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
const currentTime = performance.now() / 1000;
return trigger(hap, currentTime, cps, targetTime);
};
+6 -3
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Pattern, getTime } from '@strudel/core'; import { Pattern, isPattern } from '@strudel/core';
var writeMessagers = {}; var writeMessagers = {};
var choosing = false; var choosing = false;
@@ -64,10 +64,11 @@ function crc16(data) {
} }
Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') { Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') {
return this.withHap((hap) => {
if (!(name in writeMessagers)) { if (!(name in writeMessagers)) {
getWriter(name, br); getWriter(name, br);
} }
return this.onSchedule((hap, targetTime) => { const onTrigger = (hap, currentTime, _cps, targetTime) => {
var message = ''; var message = '';
var chk = 0; var chk = 0;
if (typeof hap.value === 'object') { if (typeof hap.value === 'object') {
@@ -104,10 +105,12 @@ Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids
} else { } else {
message = hap.value; message = hap.value;
} }
const offset = (targetTime - getTime() + latency) * 1000; const offset = (targetTime - currentTime + latency) * 1000;
window.setTimeout(function () { window.setTimeout(function () {
writeMessagers[name](message, chk); writeMessagers[name](message, chk);
}, offset); }, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
}); });
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato'; import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
Pattern.prototype.soundfont = function (sf, n = 0) { Pattern.prototype.soundfont = function (sf, n = 0) {
return this.onSchedule((h, targetTime) => { return this.onTrigger((h, ct, cps, targetTime) => {
const ctx = getAudioContext(); const ctx = getAudioContext();
const note = getPlayableNoteValue(h); const note = getPlayableNoteValue(h);
const preset = sf.presets[n % sf.presets.length]; const preset = sf.presets[n % sf.presets.length];
+2 -3
View File
@@ -74,7 +74,6 @@ export const rawdsp = async (code) => {
worklet.node.connect(ac.destination); worklet.node.connect(ac.destination);
}; };
export function rawdspScheduler(hap, time, cps) { export function rawdspTrigger(hap, currentTime, cps, targetTime) {
const currentTime = getAudioContext().currentTime; window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
window.postMessage({ time, dough: hap.value, currentTime, duration: hap.duration, cps });
} }
+30 -4
View File
@@ -1,4 +1,4 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { BASE_MIDI_NOTE, getBaseURL, getCommonSampleInfo, noteToFreq, noteToMidi } from './util.mjs';
import { registerSound, registerWaveTable, soundMap } from './index.mjs'; import { registerSound, registerWaveTable, soundMap } from './index.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { import {
@@ -14,6 +14,13 @@ import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer> const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer> const loadCache = {}; // string: Promise<ArrayBuffer>
/**
*
* @typedef {Object} SampleMetaData
* @property {string} url
* @property {midi} number
*/
export const getCachedBuffer = (url) => bufferCache[url]; export const getCachedBuffer = (url) => bufferCache[url];
function humanFileSize(bytes, si) { function humanFileSize(bytes, si) {
@@ -55,8 +62,9 @@ export const getDur = getDuration;
export function getSampleInfo(hapValue, bank) { export function getSampleInfo(hapValue, bank) {
const { speed = 1.0 } = hapValue; const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank); const { transpose, url, index, midi, label, baseFrequency } = getCommonSampleInfo(hapValue, bank);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
const playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, url, index, midi, label, playbackRate }; return { transpose, url, index, midi, label, playbackRate };
} }
@@ -179,7 +187,12 @@ export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '')
if (baseUrl.startsWith('github:')) { if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, ''); baseUrl = githubPath(baseUrl, '');
} }
const fullUrl = (v) => baseUrl + v; /**
*
* @param {string} v
* @returns {SampleMetaData}
*/
const fullUrl = (v) => ({ url: baseUrl + v, midi: extractMidiNoteFromString(v) });
if (Array.isArray(value)) { if (Array.isArray(value)) {
//return [key, value.map(replaceUrl)]; //return [key, value.map(replaceUrl)];
value = value.map(fullUrl); value = value.map(fullUrl);
@@ -390,3 +403,16 @@ export function registerSampleSource(key, bank, params) {
registerSample(key, bank, params); registerSample(key, bank, params);
} }
} }
export function extractMidiNoteFromString(str) {
const regex = /_([a-gA-G])([#b])?([1-9])?\b/;
const match = str.match(regex);
if (match == null) {
return BASE_MIDI_NOTE;
}
const base = match[1].toUpperCase();
const accidental = match[2] ?? '';
const octave_str = match[3] ?? '';
const parsedVal = base + accidental + octave_str;
return noteToMidi(parsedVal);
}
+4
View File
@@ -1048,3 +1048,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
} }
}); });
}; };
export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps);
};
+42 -10
View File
@@ -19,7 +19,12 @@ const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => { export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0; return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
}; };
/**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) { if (!pc) {
@@ -29,6 +34,11 @@ export const noteToMidi = (note, defaultOctave = 3) => {
const offset = getAccidentalsOffset(acc); const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
/**
*
* @param {number} n
* @returns {number}
*/
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
return Math.pow(2, (n - 69) / 12) * 440; return Math.pow(2, (n - 69) / 12) * 440;
}; };
@@ -38,7 +48,17 @@ export const freqToMidi = (freq) => {
return (12 * Math.log(freq / 440)) / Math.LN2 + 69; return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
}; };
export const valueToMidi = (value, fallbackValue) => { /**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToFreq = (note, defaultOctave = 3) => {
return midiToFreq(noteToMidi(note, defaultOctave));
};
function __valueToMidi(value) {
if (typeof value !== 'object') { if (typeof value !== 'object') {
throw new Error('valueToMidi: expected object value'); throw new Error('valueToMidi: expected object value');
} }
@@ -52,10 +72,15 @@ export const valueToMidi = (value, fallbackValue) => {
if (typeof note === 'number') { if (typeof note === 'number') {
return note; return note;
} }
if (!fallbackValue) { return;
}
export const valueToMidi = (value, fallbackValue) => {
const parsedValue = __valueToMidi(value) ?? fallbackValue;
if (parsedValue == null) {
throw new Error('valueToMidi: expected freq or note to be set'); throw new Error('valueToMidi: expected freq or note to be set');
} }
return fallbackValue; return parsedValue;
}; };
export function nanFallback(value, fallback = 0, silent) { export function nanFallback(value, fallback = 0, silent) {
@@ -84,15 +109,18 @@ export function secondsToCycle(t, cps) {
// deduces relevant info for sample loading from hap.value and sample definition // deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function // it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format) // hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export const BASE_MIDI_NOTE = 36;
export function getCommonSampleInfo(hapValue, bank) { export function getCommonSampleInfo(hapValue, bank) {
const { s, n = 0 } = hapValue; const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36); const maybeMidiNote = __valueToMidi(hapValue);
let transpose = midi - 36; // C3 is middle C; const midi = maybeMidiNote ?? BASE_MIDI_NOTE;
let url; let transpose = midi - BASE_MIDI_NOTE; // C3 is middle C;
let index = 0; let index = 0;
let samplemeta;
if (Array.isArray(bank)) { if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length); index = getSoundIndex(n, bank.length);
url = bank[index]; samplemeta = bank[index];
} else { } else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi; const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes // object format will expect keys as notes
@@ -104,10 +132,14 @@ export function getCommonSampleInfo(hapValue, bank) {
); );
transpose = -midiDiff(closest); // semitones to repitch transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length); index = getSoundIndex(n, bank[closest].length);
url = bank[closest][index]; samplemeta = bank[closest][index];
} }
const label = `${s}:${index}`; const label = `${s}:${index}`;
return { transpose, url, index, midi, label }; if (maybeMidiNote != null) {
transpose = transpose + (BASE_MIDI_NOTE - samplemeta.midi);
}
return { transpose, index, midi, label, url: samplemeta.url };
} }
/** Selects entries from `source` and renames them via `map` /** Selects entries from `source` and renames them via `map`
+1 -1
View File
@@ -20,7 +20,7 @@ const soundMap = new Map();
const loadedSounds = new Map(); const loadedSounds = new Map();
Pattern.prototype.supradough = function () { Pattern.prototype.supradough = function () {
return this.onSchedule((hap, begin, cps) => { return this.onTrigger((hap, __, cps, begin) => {
hap.value._begin = begin; hap.value._begin = begin;
hap.value._duration = hap.duration / cps; hap.value._duration = hap.duration / cps;
!doughWorklet && initDoughWorklet(); !doughWorklet && initDoughWorklet();
+3 -24
View File
@@ -9,7 +9,7 @@ import {
superdough, superdough,
getAudioContext, getAudioContext,
setLogger, setLogger,
rawdspScheduler, rawdspTrigger,
registerWorklet, registerWorklet,
setAudioContext, setAudioContext,
initAudio, initAudio,
@@ -33,8 +33,7 @@ const hap2value = (hap) => {
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004 // uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
// TODO: refactor output callbacks to eliminate deadline // TODO: refactor output callbacks to eliminate deadline
export const webaudioOutput = (hap, t, cps) => { export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
const hapDuration = hap.duration / cps;
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf()); return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
}; };
@@ -175,7 +174,7 @@ export function webaudioRepl(options = {}) {
} }
Pattern.prototype.rawdsp = function () { Pattern.prototype.rawdsp = function () {
return this.onSchedule(rawdspScheduler, 1); return this.onTrigger(rawdspTrigger, 1);
}; };
function audioBufferToWav(buffer, opt) { function audioBufferToWav(buffer, opt) {
@@ -272,23 +271,3 @@ function writeString(view, offset, string) {
view.setUint8(offset + i, string.charCodeAt(i)); view.setUint8(offset + i, string.charCodeAt(i));
} }
} }
/**
*
* make something happen on event time
* uses browser timeout which is innacurate for audio tasks
* @name onTriggerTime
* @tags external_io
* @memberof Pattern
* @returns Pattern
* @example
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
*/
Pattern.prototype.onTriggerTime = function (func) {
return this.onSchedule((hap, targetTime) => {
const diff = targetTime - getAudioContext().currentTime;
window.setTimeout(() => {
func(hap);
}, diff * 1000);
}, false);
};
-13
View File
@@ -8522,19 +8522,6 @@ exports[`runs examples > example "often" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "onSchedule" example index 0 1`] = `
[
"[ 0/1 → 1/2 | s:bd ]",
"[ 1/2 → 1/1 | s:sd ]",
"[ 1/1 → 3/2 | s:bd ]",
"[ 3/2 → 2/1 | s:sd ]",
"[ 2/1 → 5/2 | s:bd ]",
"[ 5/2 → 3/1 | s:sd ]",
"[ 3/1 → 7/2 | s:bd ]",
"[ 7/2 → 4/1 | s:sd ]",
]
`;
exports[`runs examples > example "onTriggerTime" example index 0 1`] = ` exports[`runs examples > example "onTriggerTime" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:bd ]", "[ 0/1 → 1/8 | s:bd ]",
+3 -3
View File
@@ -22,7 +22,7 @@ export function MiniRepl({
tunes, tunes,
hideHeader = false, hideHeader = false,
canvasHeight = 100, canvasHeight = 100,
onSchedule, onTrigger,
punchcard, punchcard,
punchcardLabels = true, punchcardLabels = true,
claviature, claviature,
@@ -58,8 +58,8 @@ export function MiniRepl({
drawTime, drawTime,
drawContext, drawContext,
editPattern: (pat, id) => { editPattern: (pat, id) => {
if (onSchedule) { if (onTrigger) {
pat = pat.onSchedule(onSchedule, false); pat = pat.onTrigger(onTrigger, false);
} }
if (claviature) { if (claviature) {
pat = pat.onPaint((ctx, time, haps, drawTime) => { pat = pat.onPaint((ctx, time, haps, drawTime) => {
@@ -186,9 +186,9 @@ These functions are more low level, probably not needed by the live coder.
# Other # Other
## onSchedule ## onTrigger
<JsDoc client:idle name="Pattern#onSchedule" h={0} /> <JsDoc client:idle name="Pattern#onTrigger" h={0} />
## log ## log
+10 -6
View File
@@ -17,7 +17,7 @@ Besides a UI for playback control and meta information, the main part of the REP
1. The user writes and updates code. Each update transpiles and evaluates it to create a `Pattern` instance 1. The user writes and updates code. Each update transpiles and evaluates it to create a `Pattern` instance
2. While the REPL is running, the `Scheduler` queries the active `Pattern` by a regular interval, generating `Events` (also known as `Haps` in Strudel) for the next time span. 2. While the REPL is running, the `Scheduler` queries the active `Pattern` by a regular interval, generating `Events` (also known as `Haps` in Strudel) for the next time span.
3. For each scheduling tick, all generated `Events` are triggered by calling their `onSchedule` method, which is set by the output. 3. For each scheduling tick, all generated `Events` are triggered by calling their `onTrigger` method, which is set by the output.
<img src="https://codeberg.org/uzu/strudel/raw/branch/talk/talk/public/strudelflow.png" width="600" /> <img src="https://codeberg.org/uzu/strudel/raw/branch/talk/talk/public/strudelflow.png" width="600" />
@@ -134,7 +134,10 @@ let minLatency = 0.1; // min time before a hap should trigger
setInterval(() => { setInterval(() => {
const haps = pattern.queryArc(time, time + interval); const haps = pattern.queryArc(time, time + interval);
time += interval; // increment time time += interval; // increment time
haps.forEach((hap) => onSchedule(hap, hap.whole.begin + minLatency)); haps.forEach((hap) => {
const deadline = hap.whole.begin - time + minLatency;
onTrigger(hap, deadline, duration);
});
}, interval * 1000); // query each "interval" seconds }, interval * 1000); // query each "interval" seconds
``` ```
@@ -178,19 +181,20 @@ This example creates the custom control parameters `x` and `y` which are then us
### Outputs ### Outputs
Now that we know how the value of an hap is manipulated using control parameters, we can look at how outputs can use that value to generate anything. The scheduler above was calling the `onSchedule` function which is used to implement the output. A very simple version of the web audio output could look like this: Now that we know how the value of an event is manipulated using control parameters, we can look at how outputs can use that value to generate anything. The scheduler above was calling the `onTrigger` function which is used to implement the output. A very simple version of the web audio output could look like this:
```js ```js
function onSchedule(hap, time, cps) { function onTrigger(hap, deadline, duration) {
const { note } = hap.value; const { note } = hap.value;
const time = getAudioContext().currentTime + deadline;
const o = getAudioContext().createOscillator(); const o = getAudioContext().createOscillator();
o.frequency.value = getFreq(note); o.frequency.value = getFreq(note);
o.start(time); o.start(time);
o.stop(time + hap.duration / cps); o.stop(time + event.duration);
o.connect(getAudioContext().destination); o.connect(getAudioContext().destination);
} }
``` ```
The above example will create an `OscillatorNode` for each hap, where the frequency is controlled by the `note` param. In essence, this is how the WebAudio API output of Strudel works, only with many more parameters to control synths, samples and effects. The above example will create an `OscillatorNode` for each event, where the frequency is controlled by the `note` param. In essence, this is how the WebAudio API output of Strudel works, only with many more parameters to control synths, samples and effects.
I want to help, how do I contribute to the [Docs](/technical-manual/docs)? I want to help, how do I contribute to the [Docs](/technical-manual/docs)?
+1 -1
View File
@@ -628,7 +628,7 @@ export const debugAudiograph = async (argOptions = {}) => {
/*global all*/ /*global all*/
all((pat) => all((pat) =>
pat.onSchedule(async (hap, t, cps) => { pat.onTrigger(async (hap, duration, cps, t) => {
hap_count++; hap_count++;
const key = Object.entries(hap.value) const key = Object.entries(hap.value)
.map((param) => param.join('/')) .map((param) => param.join('/'))
@@ -9,9 +9,7 @@ import {
userPattern, userPattern,
} from '../../../user_pattern_utils.mjs'; } from '../../../user_pattern_utils.mjs';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { getMetadata } from '../../../metadata_parser.js';
import { useExamplePatterns } from '../../useExamplePatterns.jsx'; import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
import { useSettings } from '../../../settings.mjs'; import { useSettings } from '../../../settings.mjs';
import { ActionButton } from '../button/action-button.jsx'; import { ActionButton } from '../button/action-button.jsx';
import { Pagination } from '../pagination/Pagination.jsx'; import { Pagination } from '../pagination/Pagination.jsx';
@@ -20,8 +18,13 @@ import { useDebounce } from '../usedebounce.jsx';
import cx from '@src/cx.mjs'; import cx from '@src/cx.mjs';
import { Textbox } from '@src/repl/components/panel/SettingsTab.jsx'; import { Textbox } from '@src/repl/components/panel/SettingsTab.jsx';
const PATTERN_SORT = {
"NEWEST": "most recent",
"A-Z": "A-Z",
}
export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
const meta = useMemo(() => getMetadata(pattern.code), [pattern]); const meta = pattern.meta
let title = meta.title; let title = meta.title;
if (title == null) { if (title == null) {
@@ -58,6 +61,10 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
return ( return (
<div className="p-2"> <div className="p-2">
{Object.values(patterns) {Object.values(patterns)
.sort((a, b) => {
return (b.meta.title ?? "").localeCompare(a.meta.title ?? "")
})
.reverse() .reverse()
.map((pattern) => { .map((pattern) => {
const id = pattern.id; const id = pattern.id;
@@ -93,7 +100,7 @@ export function PatternsTab({ context }) {
} }
return Object.fromEntries( return Object.fromEntries(
Object.entries(userPatterns).filter(([_key, pattern]) => { Object.entries(userPatterns).filter(([_key, pattern]) => {
const meta = getMetadata(pattern.code); const meta = pattern.meta;
// Search for specific meta keys // Search for specific meta keys
const searchLowercaseTrimmed = search.trim().toLowerCase(); const searchLowercaseTrimmed = search.trim().toLowerCase();
+13 -12
View File
@@ -1,4 +1,4 @@
import { registerSampleSource } from '@strudel/webaudio'; import { extractMidiNoteFromString, registerSampleSource } from '@strudel/webaudio';
import { isAudioFile } from './files.mjs'; import { isAudioFile } from './files.mjs';
import { logger } from '@strudel/core'; import { logger } from '@strudel/core';
@@ -47,7 +47,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
Promise.all( Promise.all(
[...soundFiles] [...soundFiles]
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' })) .sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
.map((soundFile, i) => { .map((soundFile) => {
const title = soundFile.title; const title = soundFile.title;
if (!isAudioFile(title)) { if (!isAudioFile(title)) {
return; return;
@@ -58,25 +58,26 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user'; splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
const blob = soundFile.blob; const blob = soundFile.blob;
return blobToDataUrl(blob).then((soundPath) => { return blobToDataUrl(blob).then((path) => {
const titlePathMap = sounds.get(parentDirectory) ?? new Map(); const sampleInfoMap = sounds.get(parentDirectory) ?? new Map();
const midi = extractMidiNoteFromString(title);
/** @type {import('@strudel/webaudio').SampleMetaData} */
const samplemetadata = { url: path, midi };
sampleInfoMap.set(title, samplemetadata);
titlePathMap.set(title, soundPath); sounds.set(parentDirectory, sampleInfoMap);
sounds.set(parentDirectory, titlePathMap);
return; return;
}); });
}), }),
) )
.then(() => { .then(() => {
sounds.forEach((titlePathMap, key) => { sounds.forEach((sampleInfoMap, key) => {
const value = Array.from(titlePathMap.keys()) const bank = Array.from(sampleInfoMap.keys())
.sort((a, b) => { .sort((a, b) => {
return a.localeCompare(b); return a.localeCompare(b);
}) })
.map((title) => titlePathMap.get(title)); .map((title) => sampleInfoMap.get(title));
registerSampleSource(key, bank, { prebake: false });
registerSampleSource(key, value, { prebake: false });
}); });
logger('imported sounds registered!', 'success'); logger('imported sounds registered!', 'success');
+7 -1
View File
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react';
import { register } from '@strudel/core'; import { register } from '@strudel/core';
import { isUdels } from './repl/util.mjs'; import { isUdels } from './repl/util.mjs';
import { computed } from 'nanostores'; import { computed } from 'nanostores';
import { getMetadata } from './metadata_parser';
export const audioEngineTargets = { export const audioEngineTargets = {
webaudio: 'webaudio', webaudio: 'webaudio',
@@ -18,6 +19,10 @@ export const soundFilterType = {
ALL: 'all', ALL: 'all',
}; };
export const PATTERN_SORT = {
"NEWEST": "most recent",
"A-Z": "A-Z",
}
const initialPrebakeScript = `// Prebake script const initialPrebakeScript = `// Prebake script
// //
// This is code that is loaded before your pattern is run. // This is code that is loaded before your pattern is run.
@@ -86,6 +91,7 @@ export const $settings = computed(settingsMap, (state) => {
Object.keys(userPatterns).forEach((key) => { Object.keys(userPatterns).forEach((key) => {
const data = userPatterns[key]; const data = userPatterns[key];
data.id = data.id ?? key; data.id = data.id ?? key;
data.meta = getMetadata(data.code)
userPatterns[key] = data; userPatterns[key] = data;
}); });
return { return {
@@ -138,7 +144,7 @@ export const setIsZen = (active) => settingsMap.setKey('isZen', !!active);
const patternSetting = (key) => const patternSetting = (key) =>
register(key, (value, pat) => register(key, (value, pat) =>
pat.onSchedule(() => { pat.onTrigger(() => {
value = Array.isArray(value) ? value.join(' ') : value; value = Array.isArray(value) ? value.join(' ') : value;
if (value !== settingsMap.get()[key]) { if (value !== settingsMap.get()[key]) {
settingsMap.setKey(key, value); settingsMap.setKey(key, value);