Compare commits

..

32 Commits

Author SHA1 Message Date
Jade (Rose) Rowland 79875ebc50 fix branch 2025-11-05 01:26:36 -05:00
Switch Angel AKA Jade Rose a1bd905c06 Merge branch 'main' into sync_midi_timing 2025-11-05 07:25:06 +01:00
froos 8f24ce223f Merge pull request 'Add stick button mappings to gamepad implementation and improve docs' (#1696) from kaosuryoko/strudel:gamepad-stick-button into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1696
2025-11-04 19:36:13 +01:00
froos 51648d3e6e Merge pull request 'Refactor sound stopping and triggering logic in SoundsTab component' (#1688) from IJOL/strudel:bugfix/multi-sample-loading into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1688
2025-11-04 19:29:34 +01:00
froos d4e67e1c20 Merge pull request 'Add setting to toggle pattern auto-start on pattern change' (#1690) from moumar/strudel:pattern-auto-start-setting into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1690
2025-11-04 19:13:20 +01:00
froos edf4739cc6 Merge pull request 'Voicings JSDoc' (#1686) from sharkeys_lunchbox/strudel:main into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1686
2025-11-04 18:58:50 +01:00
froos 3e636bd6f0 Merge branch 'main' into main 2025-11-04 18:55:34 +01:00
moumar 809eb7132c codeformat 2025-11-02 15:20:31 +01:00
froos dd6429007e Merge branch 'main' into bugfix/multi-sample-loading 2025-11-02 14:56:30 +01:00
froos e211d5a6bb Merge branch 'main' into pattern-auto-start-setting 2025-11-02 14:44:31 +01:00
Jade (Rose) Rowland 91e1cc136e working 2025-11-01 23:34:10 -04:00
Switch Angel AKA Jade Rose 92e3a80e65 Merge pull request 'FIX: Loading local samples uses too much memory' (#1706) from jade/fix_sample_loading into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1706
2025-11-01 07:50:39 +01:00
Jade (Rose) Rowland 4f046ae883 cf 2025-11-01 02:40:40 -04:00
Jade (Rose) Rowland 2b575d50b9 temporarily disable sample autocomplete 2025-11-01 02:39:43 -04:00
Jade (Rose) Rowland 7b92874909 fix broken array 2025-11-01 01:55:05 -04:00
Jade (Rose) Rowland 10bfe32a2d rm console 2025-11-01 01:48:42 -04:00
Jade (Rose) Rowland a41b07c67a fixed build 2025-11-01 01:44:32 -04:00
Jade (Rose) Rowland 20027d7208 Merge branch 'jade/fix_sample_loading' of ssh://codeberg.org/uzu/strudel into jade/fix_sample_loading 2025-11-01 01:32:35 -04:00
Jade (Rose) Rowland f38792c569 format 2025-11-01 01:32:24 -04:00
Switch Angel AKA Jade Rose 2c2bfd7070 Merge branch 'main' into jade/fix_sample_loading 2025-11-01 06:31:25 +01:00
Jade (Rose) Rowland 484c5e6cdd working 2025-11-01 01:26:25 -04:00
Jason Dufair a036c4f921 Merge remote-tracking branch 'upstream/main' 2025-10-30 18:57:36 -04:00
Jason Dufair c0e7971be7 Merge remote-tracking branch 'upstream/main' 2025-10-27 09:26:47 -04:00
kaosuryoko b491c42af5 Merge branch 'main' into gamepad-stick-button 2025-10-27 00:36:50 +01:00
Greg Ervin ee43d85f29 Add stick button mappings to gamepad implementation and improve docs 2025-10-26 13:31:13 -05:00
moumar fcd83cce55 Re-evaluate pattern if activated 2025-10-26 14:07:36 +01:00
moumar fddf2ff2a7 Set pattern auto-start to true by default 2025-10-26 14:06:31 +01:00
Jason Dufair 6d4787ee33 Test snapshot 2025-10-24 09:21:04 -04:00
Jason Dufair 1a362bd8ea Fix example 2025-10-24 09:19:02 -04:00
moumar 20bbda30f8 Add setting to toggle pattern auto-start on pattern change 2025-10-23 23:15:21 +02:00
Ignacio Ortega a8c613b7eb Refactor sound stopping and triggering logic in SoundsTab component 2025-10-23 19:04:29 +02:00
Jason Dufair fd0a910bfb Voicings JSDoc 2025-10-23 11:11:21 -04:00
19 changed files with 330 additions and 154 deletions
+14 -10
View File
@@ -1,8 +1,10 @@
import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html';
import { Scale } from '@tonaljs/tonal';
import { soundMap } from 'superdough';
//TODO: fix tonal scale import
// import { Scale } from '@tonaljs/tonal';
// import { soundMap } from '@strudel/webaudio';
let soundMap = undefined;
import { complex } from '@strudel/tonal';
const escapeHtml = (str) => {
@@ -79,7 +81,9 @@ const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
export function bankCompletions() {
const soundDict = soundMap.get();
// TODO: FIX IMPORT
const soundDict = soundMap?.get() ?? {};
const banks = new Set();
for (const key of Object.keys(soundDict)) {
const [bank, suffix] = key.split('_');
@@ -90,13 +94,13 @@ export function bankCompletions() {
.map((name) => ({ label: name, type: 'bank' }));
}
// Attempt to get all scale names from Tonal
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
let scaleCompletions = [];
try {
scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
} catch (e) {
console.warn('[autocomplete] Could not load scale names from Tonal:', e);
}
// try {
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
// } catch (e) {
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
// }
// Valid mode values for voicing
const modeCompletions = [
@@ -268,7 +272,7 @@ function soundHandler(context) {
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const soundNames = Object.keys(soundMap.get()).sort();
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length;
+53 -15
View File
@@ -72,15 +72,6 @@ export function registerControl(names, ...aliases) {
return bag;
}
// Constructs a superdirt effect bus control
const bus = register('bus', function (busid, control_name, busval, pat) {
return pat[control_name]('c' + busid).withValue((val) => {
const newval = { ...val };
newval['^' + control_name] = [busid, busval];
return newval;
});
});
/**
* Select a sound / sample by name. When using mininotation, you can also optionally supply 'n' and 'gain' parameters
* separated by ':'.
@@ -1759,17 +1750,64 @@ export const { semitone } = registerControl('semitone');
// TODO: synth param
export const { voice } = registerControl('voice');
// 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');
// 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');
// 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');
// 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');
// 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');
// 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']);
/**
+3 -2
View File
@@ -58,10 +58,11 @@ export class Cyclist {
// query the pattern for events
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' });
haps.forEach((hap) => {
if (hap.hasOnset() || hap.context.processParts) {
if (hap.hasOnset()) {
const targetTime =
(hap.part.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;
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
+1 -5
View File
@@ -16,7 +16,7 @@ export class Hap {
then the whole will be returned as None, in which case the given
value will have been sampled from the point halfway between the
start and end of the 'part' timespan.
The context is to store metadata, including a list of source code locations causing the event.
The context is to store a list of source code locations causing the event.
The word 'Event' is more or less a reserved word in javascript, hence this
class is named called 'Hap'.
@@ -161,10 +161,6 @@ export class Hap {
return new Hap(this.whole, this.part, this.value, context);
}
withContext(f) {
return new Hap(this.whole, this.part, this.value, f(this.context));
}
ensureObjectValue() {
/* if (isNote(hap.value)) {
// supports primitive hap values that look like notes
-5
View File
@@ -62,12 +62,7 @@ export class Pattern {
this.__steps = steps === undefined ? undefined : Fraction(steps);
}
_processParts() {
return this.withHap((hap) => hap.withContext((c) => ({ ...c, processParts: true })));
}
setSteps(steps) {
// TODO shouldn't this be pure and return a new pattern?
this._steps = steps;
return this;
}
+6
View File
@@ -40,6 +40,12 @@ const pattern = sequence([
- D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- 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
- Left Stick
+4
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` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | 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
+4
View File
@@ -14,6 +14,10 @@ export const buttonMap = {
rt: 7,
back: 8,
start: 9,
l3: 10,
ls: 10,
r3: 11,
rs: 11,
u: 12,
up: 12,
d: 13,
+59 -42
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 { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
import { Pattern, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core';
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:
export const { WebMidi } = _WebMidi;
@@ -190,7 +191,7 @@ function mapCC(mapping, value) {
}
// 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) {
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');
}
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
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
function sendProgramChange(progNum, device, midichan, targetTime) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 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
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
function sendSysex(sysexid, sysexdata, device, targetTime) {
if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 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)) {
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
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 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) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
}
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
scheduleAtTime(() => {
device.sendNRPN(nrpnn, nrpv, midichan);
}, targetTime);
}
// 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) {
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
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
function sendAftertouch(miditouch, device, midichan, targetTime) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 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
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
function sendNote(note, velocity, duration, device, midichan, targetTime) {
if (note == null || note === '') {
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)) {
throw new Error('duration must be a positive number');
}
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
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 = {
// Default configuration values
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
midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity
@@ -333,18 +345,13 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((hap, currentTime, cps, targetTime) => {
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
logger('Midi not enabled');
return;
}
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
let {
note,
@@ -380,7 +387,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) {
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') {
// Add warning when a non-existent midimap is specified
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
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
if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeOffsetString);
sendProgramChange(progNum, device, midichan, targetTime);
}
// Handle sysex
@@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, timeOffsetString);
sendSysex(sysexid, sysexdata, device, targetTime);
}
// Handle control change
if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, timeOffsetString);
sendCC(ccn, ccv, device, midichan, targetTime);
}
// Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
}
// Handle midibend
if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeOffsetString);
sendPitchBend(midibend, device, midichan, targetTime);
}
// Handle miditouch
if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeOffsetString);
sendAftertouch(miditouch, device, midichan, targetTime);
}
// Handle midicmd
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
device.sendStart({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
}
if (['clock', 'midiClock'].includes(midicmd)) {
device.sendClock({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) {
device.sendStart({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) {
device.sendStop({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) {
device.sendContinue({ time: timeOffsetString });
scheduleAtTime(() => {
device.sendContinue();
}, targetTime);
} else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
sendProgramChange(midicmd[1], device, midichan, targetTime);
} else if (midicmd[0] === 'cc') {
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') {
if (midicmd.length === 3) {
const [_, id, data] = midicmd;
sendSysex(id, data, device, timeOffsetString);
sendSysex(id, data, device, targetTime);
}
}
}
+7 -35
View File
@@ -52,7 +52,6 @@ export function parseControlsFromHap(hap, cps) {
controls.unit === 'c' && controls.speed != null && (controls.speed = controls.speed / cps);
const channels = controls.channels;
channels != undefined && (controls.channels = JSON.stringify(channels));
return controls;
}
@@ -64,42 +63,16 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const keyvals = Object.entries(controls).flat();
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
if ('oschost' in hap.value) {
msg['host'] = hap.value['oschost'];
}
if ('oscport' in hap.value) {
msg['port'] = hap.value['oscport'];
}
ws.send(JSON.stringify(msg));
}
export async function superdirtTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect();
const controls = parseControlsFromHap(hap, cps);
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
let oschost = '127.0.0.1';
let oscport = 57120;
let bushost = oschost;
let busport = 57110;
for (const [k, v] of Object.entries(controls)) {
if (k.startsWith('^')) {
const bus_idval = v;
const msg = JSON.stringify({
oschost: bushost,
oscport: busport,
address: '/c_set',
args: bus_idval,
timestamp: ts,
});
// console.log('bus message', msg);
ws.send(msg);
// So they aren't sent with trigger messages
delete controls[k];
}
}
if (hap.hasOnset()) {
const keyvals = Object.entries(controls).flat();
ws.send(JSON.stringify({ oschost, oscport, address: '/dirt/play', args: keyvals, timestamp: ts }));
}
}
/**
*
* Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software.
@@ -110,4 +83,3 @@ export async function superdirtTrigger(hap, currentTime, cps = 1, targetTime) {
* @returns Pattern
*/
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
export const superdirt = register('superdirt', (pat) => pat._processParts().onTrigger(superdirtTrigger));
+13 -9
View File
@@ -12,13 +12,18 @@ import { WebSocketServer } from 'ws';
import osc from 'osc';
const WS_PORT = 8080; // WebSocket server port
const OSC_REMOTE_IP = '127.0.0.1';
const OSC_REMOTE_PORT = 57120;
const udpPort = new osc.UDPPort({
localAddress: '0.0.0.0',
localPort: 0,
remoteAddress: OSC_REMOTE_IP,
remotePort: OSC_REMOTE_PORT,
});
udpPort.open();
console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`);
udpPort.on('error', (e) => {
console.log('Error: ', e);
@@ -31,24 +36,23 @@ wss.on('connection', (ws) => {
console.log('New WebSocket connection');
ws.on('message', (message) => {
let oschost = '127.0.0.1';
let oscport = 57120;
let osc_host = '127.0.0.1';
let osc_port = 57120;
try {
const data = JSON.parse(message);
if ('oschost' in data) {
oschost = data['oschost'];
delete data['oschost'];
if ('host' in data) {
osc_host = data['host'];
}
if ('oscport' in data) {
oscport = data['oscport'];
delete data['oscport'];
if ('port' in data) {
osc_port = data['port'];
}
let msg = { address: data['address'], args: data['args'] };
if ('timestamp' in data) {
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
}
udpPort.send(msg, oschost, oscport);
udpPort.send(msg, osc_host, osc_port);
} catch (err) {
console.error('Error parsing message:', err);
}
+5
View File
@@ -280,6 +280,11 @@ export function getVibratoOscillator(param, value, t) {
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
// a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
-1
View File
@@ -41,7 +41,6 @@ Tune.prototype.tonicize = function(newTonic) {
this.tonic = newTonic
}
/* Return data in the mode you are in (freq, ratio, or midi) */
Tune.prototype.note = function(input,octave){
+123
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`] = `
[
"[ 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`] = `
[
"[ 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`] = `
[
"[ 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`] = `
[
"[ 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`] = `
[
"[ -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`] = `
[
"[ 0/1 → 1/8 | s:hh speed:0.5 ]",
@@ -79,13 +79,11 @@ const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
const autoResetPatternOnChange = !isUdels();
function UserPatterns({ context }) {
const activePattern = useActivePattern();
const viewingPatternStore = useViewingPatternData();
const viewingPatternData = parseJSON(viewingPatternStore);
const { userPatterns, patternFilter } = useSettings();
const { userPatterns, patternFilter, patternAutoStart } = useSettings();
const viewingPatternID = viewingPatternData?.id;
return (
<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">
{/* {patternFilter === patternFilterName.user && ( */}
<PatternButtons
onClick={(id) =>
updateCodeWindow(
context,
{ ...userPatterns[id], collection: userPattern.collection },
autoResetPatternOnChange,
)
}
onClick={(id) => {
updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart);
if (context.started && activePattern === id) {
context.handleEvaluate();
}
}}
patterns={userPatterns}
started={context.started}
activePattern={activePattern}
@@ -188,17 +186,14 @@ function FeaturedPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.featured);
const { patternAutoStart } = useSettings();
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={featuredPageNum}
patternOnClick={(id) => {
updateCodeWindow(
context,
{ ...patterns[id], collection: patternFilterName.featured },
autoResetPatternOnChange,
);
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.featured }, patternAutoStart);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetFeaturedPatterns(pageNum - 1);
@@ -213,13 +208,14 @@ function LatestPatterns({ context }) {
const examplePatterns = useExamplePatterns();
const collections = examplePatterns.collections;
const patterns = collections.get(patternFilterName.public);
const { patternAutoStart } = useSettings();
return (
<PatternPageWithPagination
patterns={patterns}
context={context}
initialPage={latestPageNum}
patternOnClick={(id) => {
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, patternAutoStart);
}}
paginationOnChange={async (pageNum) => {
await loadAndSetPublicPatterns(pageNum - 1);
@@ -112,6 +112,7 @@ export function SettingsTab({ started }) {
multiChannelOrbits,
isTabIndentationEnabled,
isMultiCursorEnabled,
patternAutoStart,
} = useSettings();
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@@ -304,6 +305,11 @@ export function SettingsTab({ started }) {
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
value={isCSSAnimationDisabled}
/>
<Checkbox
label="Auto-start pattern on pattern change"
onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
value={patternAutoStart}
/>
</FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings">
@@ -62,11 +62,9 @@ export function SoundsTab() {
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
const ref = trigRef.current;
trigRef.current = undefined;
t?.then((ref) => {
ref?.stop(getAudioContext().currentTime + 0.01);
});
ref?.stop?.(getAudioContext().currentTime + 0.01);
});
return (
<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,
};
soundPreviewIdx++;
const time = ctx.currentTime + 0.05;
const onended = () => trigRef.current?.node?.disconnect();
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
trigRef.current.then((ref) => {
connectToDestination(ref?.node);
});
try {
// Pre-load the sample by calling onTrigger with a future time
// 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);
}
}}
>
{' '}
+1 -5
View File
@@ -92,11 +92,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
async function blobToDataUrl(blob) {
return new Promise((resolve) => {
var reader = new FileReader();
reader.onload = function (event) {
resolve(event.target.result);
};
reader.readAsDataURL(blob);
resolve(URL.createObjectURL(blob));
});
}
+5
View File
@@ -96,6 +96,11 @@ export function useSettings() {
isPanelOpen: parseBoolean(state.isPanelOpen),
userPatterns: userPatterns,
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
patternAutoStart: isUdels()
? false
: state.patternAutoStart === undefined
? true
: parseBoolean(state.patternAutoStart),
};
}