Merge branch 'main' into scale-docs

This commit is contained in:
ondras 2025-11-06 08:16:34 +01:00
commit f8943dbc94
12 changed files with 291 additions and 74 deletions

View file

@ -1750,17 +1750,64 @@ export const { semitone } = registerControl('semitone');
// TODO: synth param // TODO: synth param
export const { voice } = registerControl('voice'); export const { voice } = registerControl('voice');
// voicings // https://codeberg.org/uzu/strudel/issues/506 // voicings // https://codeberg.org/uzu/strudel/issues/506
// chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings /**
* The chord to voice
* @name chord
* @param {string | Pattern} symbols chord symbols to voice e.g., C, Eb, Fm7, G7. The symbols can be defined via addVoicings
* @example
* chord("<Am C D F Am E Am E>").voicing()
**/
export const { chord } = registerControl('chord'); export const { chord } = registerControl('chord');
// which dictionary to use for the voicings /**
* Which dictionary to use for the voicings. This falls back to the default dictionary if not provided
*
* @name dictionary
* @param {string} dictionaryName which dictionary (having been defined with `addVoicings`) to use
* @example
* addVoicings('house', {
'': ['7 12 16', '0 7 16', '4 7 12'],
'm': ['0 3 7']
})
chord("<Am C D F Am E Am E>")
.dict('house').anchor(66)
.voicing().room(.5)
**/
export const { dictionary, dict } = registerControl('dictionary', 'dict'); export const { dictionary, dict } = registerControl('dictionary', 'dict');
// the top note to align the voicing to, defaults to c5 /** The top note to align the voicing to. Defaults to c5
*
* @name anchor
* @param {string | Pattern} anchorNote the note to align the voicings to
* @example
* anchor("<c4 g4 c5 g5>").chord("C").voicing()
**/
export const { anchor } = registerControl('anchor'); export const { anchor } = registerControl('anchor');
// how the voicing is offset from the anchored position /**
* Sets how the voicing is offset from the anchored position
*
* @name offset
* @param {number | Pattern} shift the amount to shift the voicing up or down
* @example
* chord("<Am C D F Am E Am E>").offset("<0 1 2 3 4 5>") // alter the voicing each time
**/
export const { offset } = registerControl('offset'); export const { offset } = registerControl('offset');
// how many octaves are voicing steps spread apart, defaults to 1 /**
* How many octaves are voicing steps spread apart, defaults to 1
*
* @name octaves
* @param {number | Pattern} count the number of octaves
* @example
* chord("<Am C D F Am E Am E>").octaves("<2 4>").voicing()
**/
export const { octaves } = registerControl('octaves'); export const { octaves } = registerControl('octaves');
// below = anchor note will be removed from the voicing, useful for melody harmonization /**
* Remove anchor note from the voicing. Useful for melody harmonization
*
* @name mode
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
**/
export const { mode } = registerControl(['mode', 'anchor']); export const { mode } = registerControl(['mode', 'anchor']);
/** /**

View file

@ -40,6 +40,12 @@ const pattern = sequence([
- D-Pad - D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) - `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`) - Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
- Stick Buttons
- `l3`, `r3` (or `ls`, `rs`)
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
- System Buttons
- `start`, `back` (or uppercase `START`, `BACK`)
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
### Analog Sticks ### Analog Sticks
- Left Stick - Left Stick

View file

@ -29,6 +29,10 @@ The gamepad module provides access to buttons and analog sticks as normalized si
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` | | | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) | | D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) | | | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
### Analog Sticks ### Analog Sticks

View file

@ -14,6 +14,10 @@ export const buttonMap = {
rt: 7, rt: 7,
back: 8, back: 8,
start: 9, start: 9,
l3: 10,
ls: 10,
r3: 11,
rs: 11,
u: 12, u: 12,
up: 12, up: 12,
d: 13, d: 13,

View file

@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as _WebMidi from 'webmidi'; import * as _WebMidi from 'webmidi';
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core'; import { Pattern, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { scheduleAtTime } from '../superdough/helpers.mjs';
// if you use WebMidi from outside of this package, make sure to import that instance: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@ -190,7 +191,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, timeOffsetString) { function sendCC(ccn, ccv, device, midichan, targetTime) {
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');
} }
@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
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);
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
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, timeOffsetString) { function sendProgramChange(progNum, device, midichan, targetTime) {
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');
} }
device.sendProgramChange(progNum, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
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, timeOffsetString) { function sendSysex(sysexid, sysexdata, device, targetTime) {
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');
@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
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');
} }
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString }); scheduleAtTime(() => {
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, timeOffsetString) { function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
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');
@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
} 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, { time: timeOffsetString }); device.sendNRPN(nrpnn, nrpv, midichan);
}, 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, timeOffsetString) { function sendPitchBend(midibend, device, midichan, targetTime) {
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');
} }
device.sendPitchBend(midibend, midichan, { time: timeOffsetString }); scheduleAtTime(() => {
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, timeOffsetString) { function sendAftertouch(miditouch, device, midichan, targetTime) {
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');
} }
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
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, timeOffsetString) { function sendNote(note, velocity, duration, device, midichan, targetTime) {
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');
} }
@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
if (duration != null && (typeof duration !== 'number' || duration < 0)) { if (duration != null && (typeof duration !== 'number' || duration < 0)) {
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, duration });
device.playNote(midiNote, midichan, {
time: timeOffsetString, scheduleAtTime(() => {
}); device.playNote(midiNote, midichan);
}, targetTime);
} }
/** /**
@ -309,7 +322,6 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = { let midiConfig = {
// Default configuration values // Default configuration values
isController: false, // Disable sending notes for midi controllers isController: false, // Disable sending notes for midi controllers
latencyMs: 34, // Default latency to get audio engine to line up in ms
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
midichannel: 1, // Default MIDI channel midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity velocity: 0.9, // Default velocity
@ -333,18 +345,13 @@ 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) => { return this.onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
} }
hap.ensureObjectValue(); hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = midiConfig.latencyMs;
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// midi event values from hap with configurable defaults // midi event values from hap with configurable defaults
let { let {
note, note,
@ -380,7 +387,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, timeOffsetString)); ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
} 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(', ')}`);
@ -392,12 +399,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// try to prevent glitching by subtracting noteOffsetMs from the duration length // try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs; const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
sendNote(note, velocity, duration, device, midichan, timeOffsetString); sendNote(note, velocity, duration, device, midichan, targetTime);
} }
// Handle program change // Handle program change
if (progNum !== undefined) { if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeOffsetString); sendProgramChange(progNum, device, midichan, targetTime);
} }
// Handle sysex // Handle sysex
@ -407,53 +414,63 @@ 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, timeOffsetString); sendSysex(sysexid, sysexdata, device, targetTime);
} }
// Handle control change // Handle control change
if (ccv !== undefined && ccn !== undefined) { if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, timeOffsetString); sendCC(ccn, ccv, device, midichan, targetTime);
} }
// 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, timeOffsetString); sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
} }
// Handle midibend // Handle midibend
if (midibend !== undefined) { if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeOffsetString); sendPitchBend(midibend, device, midichan, targetTime);
} }
// Handle miditouch // Handle miditouch
if (miditouch !== undefined) { if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeOffsetString); sendAftertouch(miditouch, device, midichan, targetTime);
} }
// 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
device.sendStart({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} }
if (['clock', 'midiClock'].includes(midicmd)) { if (['clock', 'midiClock'].includes(midicmd)) {
device.sendClock({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) { } else if (['start'].includes(midicmd)) {
device.sendStart({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) { } else if (['stop'].includes(midicmd)) {
device.sendStop({ time: timeOffsetString }); scheduleAtTime(() => {
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) { } else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString }); scheduleAtTime(() => {
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, timeOffsetString); sendProgramChange(midicmd[1], device, midichan, targetTime);
} 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, timeOffsetString); sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
} }
} 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, timeOffsetString); sendSysex(id, data, device, targetTime);
} }
} }
} }

View file

@ -280,6 +280,11 @@ export function getVibratoOscillator(param, value, t) {
return vibratoOscillator; return vibratoOscillator;
} }
} }
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities // ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :) // a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {

View file

@ -41,7 +41,6 @@ Tune.prototype.tonicize = function(newTonic) {
this.tonic = newTonic this.tonic = newTonic
} }
/* Return data in the mode you are in (freq, ratio, or midi) */ /* Return data in the mode you are in (freq, ratio, or midi) */
Tune.prototype.note = function(input,octave){ Tune.prototype.note = function(input,octave){

View file

@ -840,6 +840,31 @@ exports[`runs examples > example "amp" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "anchor" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:E2 ]",
"[ 0/1 → 1/1 | note:C3 ]",
"[ 0/1 → 1/1 | note:E3 ]",
"[ 0/1 → 1/1 | note:G3 ]",
"[ 0/1 → 1/1 | note:C4 ]",
"[ 1/1 → 2/1 | note:C3 ]",
"[ 1/1 → 2/1 | note:G3 ]",
"[ 1/1 → 2/1 | note:C4 ]",
"[ 1/1 → 2/1 | note:E4 ]",
"[ 1/1 → 2/1 | note:G4 ]",
"[ 2/1 → 3/1 | note:E3 ]",
"[ 2/1 → 3/1 | note:C4 ]",
"[ 2/1 → 3/1 | note:E4 ]",
"[ 2/1 → 3/1 | note:G4 ]",
"[ 2/1 → 3/1 | note:C5 ]",
"[ 3/1 → 4/1 | note:C4 ]",
"[ 3/1 → 4/1 | note:G4 ]",
"[ 3/1 → 4/1 | note:C5 ]",
"[ 3/1 → 4/1 | note:E5 ]",
"[ 3/1 → 4/1 | note:G5 ]",
]
`;
exports[`runs examples > example "apply" example index 0 1`] = ` exports[`runs examples > example "apply" example index 0 1`] = `
[ [
"[ 0/1 → 1/1 | note:C3 ]", "[ 0/1 → 1/1 | note:C3 ]",
@ -1847,6 +1872,31 @@ exports[`runs examples > example "chop" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "chord" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:A3 ]",
"[ 0/1 → 1/1 | note:C4 ]",
"[ 0/1 → 1/1 | note:E4 ]",
"[ 0/1 → 1/1 | note:A4 ]",
"[ 0/1 → 1/1 | note:C5 ]",
"[ 1/1 → 2/1 | note:E3 ]",
"[ 1/1 → 2/1 | note:C4 ]",
"[ 1/1 → 2/1 | note:E4 ]",
"[ 1/1 → 2/1 | note:G4 ]",
"[ 1/1 → 2/1 | note:C5 ]",
"[ 2/1 → 3/1 | note:D3 ]",
"[ 2/1 → 3/1 | note:A3 ]",
"[ 2/1 → 3/1 | note:D4 ]",
"[ 2/1 → 3/1 | note:Gb4 ]",
"[ 2/1 → 3/1 | note:A4 ]",
"[ 3/1 → 4/1 | note:F3 ]",
"[ 3/1 → 4/1 | note:C4 ]",
"[ 3/1 → 4/1 | note:F4 ]",
"[ 3/1 → 4/1 | note:A4 ]",
"[ 3/1 → 4/1 | note:C5 ]",
]
`;
exports[`runs examples > example "chorus" example index 0 1`] = ` exports[`runs examples > example "chorus" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]", "[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]",
@ -2739,6 +2789,23 @@ exports[`runs examples > example "detune" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "dictionary" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:A3 room:0.5 ]",
"[ 0/1 → 1/1 | note:C4 room:0.5 ]",
"[ 0/1 → 1/1 | note:E4 room:0.5 ]",
"[ 1/1 → 2/1 | note:G3 room:0.5 ]",
"[ 1/1 → 2/1 | note:C4 room:0.5 ]",
"[ 1/1 → 2/1 | note:E4 room:0.5 ]",
"[ 2/1 → 3/1 | note:A3 room:0.5 ]",
"[ 2/1 → 3/1 | note:D4 room:0.5 ]",
"[ 2/1 → 3/1 | note:Gb4 room:0.5 ]",
"[ 3/1 → 4/1 | note:A3 room:0.5 ]",
"[ 3/1 → 4/1 | note:C4 room:0.5 ]",
"[ 3/1 → 4/1 | note:F4 room:0.5 ]",
]
`;
exports[`runs examples > example "distort" example index 0 1`] = ` exports[`runs examples > example "distort" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:hh distort:0 ]", "[ 0/1 → 1/8 | s:hh distort:0 ]",
@ -6496,6 +6563,28 @@ exports[`runs examples > example "miditouch" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "mode" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:E3 ]",
"[ 0/1 → 1/1 | note:C4 ]",
"[ 0/1 → 1/1 | note:E4 ]",
"[ 0/1 → 1/1 | note:G4 ]",
"[ 0/1 → 1/1 | note:C5 ]",
"[ 1/1 → 2/1 | note:C5 ]",
"[ 1/1 → 2/1 | note:G5 ]",
"[ 1/1 → 2/1 | note:C6 ]",
"[ 1/1 → 2/1 | note:E6 ]",
"[ 2/1 → 3/1 | note:E3 ]",
"[ 2/1 → 3/1 | note:C4 ]",
"[ 2/1 → 3/1 | note:E4 ]",
"[ 2/1 → 3/1 | note:G4 ]",
"[ 3/1 → 4/1 | note:C5 ]",
"[ 3/1 → 4/1 | note:G5 ]",
"[ 3/1 → 4/1 | note:C6 ]",
"[ 3/1 → 4/1 | note:E6 ]",
]
`;
exports[`runs examples > example "morph" example index 0 1`] = ` exports[`runs examples > example "morph" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:hh ]", "[ 0/1 → 1/8 | s:hh ]",
@ -6814,6 +6903,31 @@ exports[`runs examples > example "octave" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "octaves" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:A3 ]",
"[ 0/1 → 1/1 | note:C4 ]",
"[ 0/1 → 1/1 | note:E4 ]",
"[ 0/1 → 1/1 | note:A4 ]",
"[ 0/1 → 1/1 | note:C5 ]",
"[ 1/1 → 2/1 | note:E3 ]",
"[ 1/1 → 2/1 | note:C4 ]",
"[ 1/1 → 2/1 | note:E4 ]",
"[ 1/1 → 2/1 | note:G4 ]",
"[ 1/1 → 2/1 | note:C5 ]",
"[ 2/1 → 3/1 | note:D3 ]",
"[ 2/1 → 3/1 | note:A3 ]",
"[ 2/1 → 3/1 | note:D4 ]",
"[ 2/1 → 3/1 | note:Gb4 ]",
"[ 2/1 → 3/1 | note:A4 ]",
"[ 3/1 → 4/1 | note:F3 ]",
"[ 3/1 → 4/1 | note:C4 ]",
"[ 3/1 → 4/1 | note:F4 ]",
"[ 3/1 → 4/1 | note:A4 ]",
"[ 3/1 → 4/1 | note:C5 ]",
]
`;
exports[`runs examples > example "off" example index 0 1`] = ` exports[`runs examples > example "off" example index 0 1`] = `
[ [
"[ -5/24 ⇜ (0/1 → 1/8) | note:62 ]", "[ -5/24 ⇜ (0/1 → 1/8) | note:62 ]",
@ -6847,6 +6961,15 @@ exports[`runs examples > example "off" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "offset" example index 0 1`] = `
[
"[ 0/1 → 1/1 | chord:Am offset:0 ]",
"[ 1/1 → 2/1 | chord:C offset:1 ]",
"[ 2/1 → 3/1 | chord:D offset:2 ]",
"[ 3/1 → 4/1 | chord:F offset:3 ]",
]
`;
exports[`runs examples > example "often" example index 0 1`] = ` exports[`runs examples > example "often" example index 0 1`] = `
[ [
"[ 0/1 → 1/8 | s:hh speed:0.5 ]", "[ 0/1 → 1/8 | s:hh speed:0.5 ]",

View file

@ -79,13 +79,11 @@ const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset); context.handleUpdate(patternData, reset);
}; };
const autoResetPatternOnChange = !isUdels();
function UserPatterns({ context }) { function UserPatterns({ context }) {
const activePattern = useActivePattern(); const activePattern = useActivePattern();
const viewingPatternStore = useViewingPatternData(); const viewingPatternStore = useViewingPatternData();
const viewingPatternData = parseJSON(viewingPatternStore); const viewingPatternData = parseJSON(viewingPatternStore);
const { userPatterns, patternFilter } = useSettings(); const { userPatterns, patternFilter, patternAutoStart } = useSettings();
const viewingPatternID = viewingPatternData?.id; const viewingPatternID = viewingPatternData?.id;
return ( return (
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 "> <div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
@ -135,13 +133,13 @@ function UserPatterns({ context }) {
<div className="overflow-auto h-full bg-background p-2 rounded-md"> <div className="overflow-auto h-full bg-background p-2 rounded-md">
{/* {patternFilter === patternFilterName.user && ( */} {/* {patternFilter === patternFilterName.user && ( */}
<PatternButtons <PatternButtons
onClick={(id) => onClick={(id) => {
updateCodeWindow( updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart);
context,
{ ...userPatterns[id], collection: userPattern.collection }, if (context.started && activePattern === id) {
autoResetPatternOnChange, context.handleEvaluate();
) }
} }}
patterns={userPatterns} patterns={userPatterns}
started={context.started} started={context.started}
activePattern={activePattern} activePattern={activePattern}
@ -188,17 +186,14 @@ function FeaturedPatterns({ context }) {
const examplePatterns = useExamplePatterns(); const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections; const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.featured); const patterns = collections.get(patternFilterName.featured);
const { patternAutoStart } = useSettings();
return ( return (
<PatternPageWithPagination <PatternPageWithPagination
patterns={patterns} patterns={patterns}
context={context} context={context}
initialPage={featuredPageNum} initialPage={featuredPageNum}
patternOnClick={(id) => { patternOnClick={(id) => {
updateCodeWindow( updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.featured }, patternAutoStart);
context,
{ ...patterns[id], collection: patternFilterName.featured },
autoResetPatternOnChange,
);
}} }}
paginationOnChange={async (pageNum) => { paginationOnChange={async (pageNum) => {
await loadAndSetFeaturedPatterns(pageNum - 1); await loadAndSetFeaturedPatterns(pageNum - 1);
@ -213,13 +208,14 @@ function LatestPatterns({ context }) {
const examplePatterns = useExamplePatterns(); const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections; const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.public); const patterns = collections.get(patternFilterName.public);
const { patternAutoStart } = useSettings();
return ( return (
<PatternPageWithPagination <PatternPageWithPagination
patterns={patterns} patterns={patterns}
context={context} context={context}
initialPage={latestPageNum} initialPage={latestPageNum}
patternOnClick={(id) => { patternOnClick={(id) => {
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange); updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, patternAutoStart);
}} }}
paginationOnChange={async (pageNum) => { paginationOnChange={async (pageNum) => {
await loadAndSetPublicPatterns(pageNum - 1); await loadAndSetPublicPatterns(pageNum - 1);

View file

@ -112,6 +112,7 @@ export function SettingsTab({ started }) {
multiChannelOrbits, multiChannelOrbits,
isTabIndentationEnabled, isTabIndentationEnabled,
isMultiCursorEnabled, isMultiCursorEnabled,
patternAutoStart,
} = useSettings(); } = useSettings();
const shouldAlwaysSync = isUdels(); const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@ -304,6 +305,11 @@ export function SettingsTab({ started }) {
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)} onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
value={isCSSAnimationDisabled} value={isCSSAnimationDisabled}
/> />
<Checkbox
label="Auto-start pattern on pattern change"
onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
value={patternAutoStart}
/>
</FormItem> </FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem> <FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings"> <FormItem label="Reset Settings">

View file

@ -62,11 +62,9 @@ export function SoundsTab() {
// stop current sound on mouseup // stop current sound on mouseup
useEvent('mouseup', () => { useEvent('mouseup', () => {
const t = trigRef.current; const ref = trigRef.current;
trigRef.current = undefined; trigRef.current = undefined;
t?.then((ref) => { ref?.stop?.(getAudioContext().currentTime + 0.01);
ref?.stop(getAudioContext().currentTime + 0.01);
});
}); });
return ( return (
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground"> <div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
@ -124,12 +122,19 @@ export function SoundsTab() {
duration: 0.5, duration: 0.5,
}; };
soundPreviewIdx++; soundPreviewIdx++;
const time = ctx.currentTime + 0.05;
const onended = () => trigRef.current?.node?.disconnect(); const onended = () => trigRef.current?.node?.disconnect();
trigRef.current = Promise.resolve(onTrigger(time, params, onended)); try {
trigRef.current.then((ref) => { // Pre-load the sample by calling onTrigger with a future time
connectToDestination(ref?.node); // This triggers the loading but schedules playback for later
}); const time = ctx.currentTime + 0.5; // Give 500ms for loading
const ref = await onTrigger(time, params, onended);
trigRef.current = ref;
if (ref?.node) {
connectToDestination(ref.node);
}
} catch (err) {
console.warn('Failed to trigger sound:', err);
}
}} }}
> >
{' '} {' '}

View file

@ -96,6 +96,11 @@ export function useSettings() {
isPanelOpen: parseBoolean(state.isPanelOpen), isPanelOpen: parseBoolean(state.isPanelOpen),
userPatterns: userPatterns, userPatterns: userPatterns,
multiChannelOrbits: parseBoolean(state.multiChannelOrbits), multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
patternAutoStart: isUdels()
? false
: state.patternAutoStart === undefined
? true
: parseBoolean(state.patternAutoStart),
}; };
} }