Compare commits

..

9 Commits

Author SHA1 Message Date
Felix Roos f1a541caec fix: accurate midi scheduling on chromium 2026-08-12 20:48:01 +02:00
Alex McLean ebc25467c0 Merge pull request 'Added some advice for creating a new project using strudel' (#2090) from name into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2090
2026-07-29 16:48:34 +02:00
alex a866cb2189 format 2026-07-29 15:06:56 +01:00
alex 890d69b23e tweaks from feedback 2026-07-29 12:25:30 +01:00
alex 8e187abf24 add copyright notice 2026-07-29 10:48:11 +01:00
alex 2d014b47c8 add advice for creating a new project to faq 2026-07-29 09:20:08 +01:00
alex daafee7527 advice for creating a new project using strudel 2026-07-29 09:11:41 +01:00
yaxu c150752372 revert 85e6d436ef
revert motors workshop page
(committed to main by mistake!)
2026-06-21 20:47:04 +02:00
Alex McLean e248bf85f3 Merge pull request 'Creates a pattern of numbers in base x from a number or pattern of numbers @stretchyboy' (#2075) from stretchyboy-base-function into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2075
2026-06-21 20:45:28 +02:00
5 changed files with 93 additions and 64 deletions
+11 -1
View File
@@ -52,7 +52,17 @@ Strudel is a project handmade by humans, with thought and nuance.
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself. If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels. There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels.
## Creating and sharing a new project using strudel
Strudel is free/open source software, and we are also happy to see people making use of it within the following terms.
Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).)
Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
You are also encouraged to connect with the community and understand our aims and values.
## Report a Bug ## Report a Bug
+70 -60
View File
@@ -171,6 +171,21 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
return Math.pow(normalized, exp); return Math.pow(normalized, exp);
} }
const isFirefox = navigator?.userAgent?.includes('Firefox');
// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
// the scheduleAtTime approach is still jittery, but the best we can be on firefox
// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
function timedSend(timeMs, fn) {
if (isFirefox) {
const audioTime = getAudioContext().currentTime + (timeMs - performance.now()) / 1000;
scheduleAtTime(() => {
fn(undefined);
}, audioTime);
} else {
fn(timeMs);
}
}
function mapCC(mapping, value) { function mapCC(mapping, value) {
return Object.keys(value) return Object.keys(value)
.filter((key) => !!mapping[getControlName(key)]) .filter((key) => !!mapping[getControlName(key)])
@@ -182,7 +197,7 @@ function mapCC(mapping, value) {
} }
// sends a cc message to the given device on the given channel // sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, targetTime) { function sendCC(ccn, ccv, device, midichan, timeMs) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1'); throw new Error('expected ccv to be a number between 0 and 1');
} }
@@ -190,23 +205,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
throw new Error('expected ccn to be a number or a string'); throw new Error('expected ccn to be a number or a string');
} }
const scaled = Math.round(ccv * 127); const scaled = Math.round(ccv * 127);
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
device.sendControlChange(ccn, scaled, midichan);
}, targetTime);
} }
// sends a program change message to the given device on the given channel // sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, targetTime) { function sendProgramChange(progNum, device, midichan, timeMs) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) { if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127'); throw new Error('expected progNum (program change) to be a number between 0 and 127');
} }
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
device.sendProgramChange(progNum, midichan);
}, targetTime);
} }
// sends a sysex message to the given device on the given channel // sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, targetTime) { function sendSysex(sysexid, sysexdata, device, timeMs) {
if (Array.isArray(sysexid)) { if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255'); throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -221,13 +232,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255'); throw new Error('all sysex bytes must be integers between 0 and 255');
} }
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
device.sendSysex(sysexid, sysexdata);
}, targetTime);
} }
// sends a NRPN message to the given device on the given channel // sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) { function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
if (Array.isArray(nrpnn)) { if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255'); throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -235,34 +244,29 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) { } else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers'); throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
} }
scheduleAtTime(() => {
device.sendNRPN(nrpnn, nrpv, midichan); timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
}, targetTime);
} }
// sends a pitch bend message to the given device on the given channel // sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, targetTime) { function sendPitchBend(midibend, device, midichan, timeMs) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) { if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1'); throw new Error('expected midibend to be a number between -1 and 1');
} }
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
device.sendPitchBend(midibend, midichan);
}, targetTime);
} }
// sends a channel aftertouch message to the given device on the given channel // sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, targetTime) { function sendAftertouch(miditouch, device, midichan, timeMs) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) { if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1'); throw new Error('expected miditouch to be a number between 0 and 1');
} }
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
device.sendChannelAftertouch(miditouch, midichan);
}, targetTime);
} }
// sends a note message to the given device on the given channel // sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, targetTime) { function sendNote(note, velocity, duration, device, midichan, timeMs) {
if (note == null || note === '') { if (note == null || note === '') {
throw new Error('note cannot be null or empty'); throw new Error('note cannot be null or empty');
} }
@@ -273,11 +277,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
throw new Error('duration must be a positive number'); throw new Error('duration must be a positive number');
} }
const midiNumber = typeof note === 'number' ? note : noteToMidi(note); const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity, duration }); const midiNote = new Note(midiNumber, { attack: velocity });
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
device.playNote(midiNote, midichan); timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
}, targetTime);
} }
/** /**
@@ -287,8 +290,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
* @param {object} options Additional MIDI configuration options * @param {object} options Additional MIDI configuration options
* @example * @example
* note("c4").midichan(1).midi('IAC Driver Bus 1') * note("c4").midichan(1).midi('IAC Driver Bus 1')
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
*/ */
Pattern.prototype.midi = function (midiport, options = {}) { Pattern.prototype.midi = function (midiport, options = {}) {
@@ -337,11 +338,28 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
}); });
return this.onTrigger((hap, _currentTime, cps, targetTime) => { let p; // filtered clock offset
let lastOffset;
return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
} }
const { contextTime, performanceTime } = getAudioContext().getOutputTimestamp();
if (!contextTime || !performanceTime) {
logger('[midi] skip midi event: not ready yet?');
return;
}
const cutoff = 0.01;
const offset = performanceTime - contextTime * 1000;
p ??= offset; // first input is initial offset
if (offset !== lastOffset) {
// update filter only when offset changes
p = offset * cutoff + p * (1 - cutoff); // onepole iir filter to smooth drift :)
}
lastOffset = offset;
const timeMs = targetTime * 1000 + p; // this is now correct in performance time
hap.ensureObjectValue(); hap.ensureObjectValue();
// midi event values from hap with configurable defaults // midi event values from hap with configurable defaults
@@ -379,7 +397,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls // if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) { if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value); const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime)); ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs));
} else if (midimap !== 'default') { } else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified // Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`); logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -388,15 +406,17 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// Handle note // Handle note
if (note !== undefined && !midiConfig.isController) { if (note !== undefined && !midiConfig.isController) {
// note off messages will often a few ms arrive late, // note off messages will often a few ms arrive late,
// try to prevent glitching by subtracting noteOffsetMs from the duration length // try to prevent glitching by subtracting at max noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs; const hapDuration = (hap.duration.valueOf() / cps) * 1000;
const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
const duration = hapDuration - offset;
sendNote(note, velocity, duration, device, midichan, targetTime); sendNote(note, velocity, duration, device, midichan, timeMs);
} }
// Handle program change // Handle program change
if (progNum !== undefined) { if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, targetTime); sendProgramChange(progNum, device, midichan, timeMs);
} }
// Handle sysex // Handle sysex
@@ -406,63 +426,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00 // if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) { if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, targetTime); sendSysex(sysexid, sysexdata, device, timeMs);
} }
// Handle control change // Handle control change
if (ccv !== undefined && ccn !== undefined) { if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, targetTime); sendCC(ccn, ccv, device, midichan, timeMs);
} }
// Handle NRPN non-registered parameter number // Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) { if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, targetTime); sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
} }
// Handle midibend // Handle midibend
if (midibend !== undefined) { if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, targetTime); sendPitchBend(midibend, device, midichan, timeMs);
} }
// Handle miditouch // Handle miditouch
if (miditouch !== undefined) { if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, targetTime); sendAftertouch(miditouch, device, midichan, timeMs);
} }
// Handle midicmd // Handle midicmd
if (hap.whole.begin + 0 === 0) { if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info // we need to start here because we have the timing info
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
device.sendStart();
}, targetTime);
} }
if (['clock', 'midiClock'].includes(midicmd)) { if (['clock', 'midiClock'].includes(midicmd)) {
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) { } else if (['start'].includes(midicmd)) {
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) { } else if (['stop'].includes(midicmd)) {
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) { } else if (['continue'].includes(midicmd)) {
scheduleAtTime(() => { timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
device.sendContinue();
}, targetTime);
} else if (Array.isArray(midicmd)) { } else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') { if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, targetTime); sendProgramChange(midicmd[1], device, midichan, timeMs);
} else if (midicmd[0] === 'cc') { } else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) { if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime); sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
} }
} else if (midicmd[0] === 'sysex') { } else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) { if (midicmd.length === 3) {
const [_, id, data] = midicmd; const [_, id, data] = midicmd;
sendSysex(id, data, device, targetTime); sendSysex(id, data, device, timeMs);
} }
} }
} }
-1
View File
@@ -68,7 +68,6 @@ export const SIDEBAR: Sidebar = {
{ text: 'Pattern Effects', link: 'workshop/pattern-effects' }, { text: 'Pattern Effects', link: 'workshop/pattern-effects' },
{ text: 'Recap', link: 'workshop/recap' }, { text: 'Recap', link: 'workshop/recap' },
{ text: 'Workshop in German', link: 'de/workshop/getting-started' }, { text: 'Workshop in German', link: 'de/workshop/getting-started' },
{ text: 'Tanglebot workshop', link: 'workshop/motors' },
], ],
'Making Sound': [ 'Making Sound': [
{ text: 'Samples', link: 'learn/samples' }, { text: 'Samples', link: 'learn/samples' },
+9
View File
@@ -52,6 +52,15 @@ There are multiple ways to load your sample collection. Some methods are good fo
- Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed. - Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed.
- Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples) - Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples)
## Can I create a new project based on Strudel?
Strudel is free/open source software, and we are always happy to see people making use of it within the following terms:
- Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).)
- Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
You are also encouraged to connect with [the community](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values.
## Can I use Strudel with AI/LLM tools? ## Can I use Strudel with AI/LLM tools?
You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license. You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license.
@@ -36,8 +36,9 @@ export function WelcomeTab({ context }) {
<a href="https://tidalcycles.org/" target="_blank"> <a href="https://tidalcycles.org/" target="_blank">
tidalcycles tidalcycles
</a> </a>
, which is a popular live coding language for music, written in Haskell. Strudel is free/open source software: , which is a popular live coding language for music, written in Haskell. Strudel is free/open source software,
you can redistribute and/or modify it under the terms of the{' '} with copyright owned by its [contributors](https://codeberg.org/uzu/strudel/activity/contributors). You can
redistribute and/or modify it under the terms of the{' '}
<a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank"> <a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank">
GNU Affero General Public License GNU Affero General Public License
</a> </a>