mirror of
https://codeberg.org/uzu/strudel
synced 2026-09-19 04:06:16 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dc19fb9bf | |||
| 4a158db9d2 | |||
| 55839e7ee8 | |||
| 9aaa9bee6b | |||
| 1834d689ef | |||
| 77a7a148f0 | |||
| 52931e879d | |||
| d5c9be1c30 | |||
| 2b30ab3008 | |||
| 13cbeb3f67 | |||
| 352d8659ec |
@@ -1,10 +1,8 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { h } from './html';
|
||||
//TODO: fix tonal scale import
|
||||
// import { Scale } from '@tonaljs/tonal';
|
||||
// import { soundMap } from '@strudel/webaudio';
|
||||
let soundMap = undefined;
|
||||
import { Scale } from '@tonaljs/tonal';
|
||||
import { soundMap } from 'superdough';
|
||||
import { complex } from '@strudel/tonal';
|
||||
|
||||
const escapeHtml = (str) => {
|
||||
@@ -81,9 +79,7 @@ const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
export function bankCompletions() {
|
||||
// TODO: FIX IMPORT
|
||||
const soundDict = soundMap?.get() ?? {};
|
||||
|
||||
const soundDict = soundMap.get();
|
||||
const banks = new Set();
|
||||
for (const key of Object.keys(soundDict)) {
|
||||
const [bank, suffix] = key.split('_');
|
||||
@@ -94,13 +90,13 @@ export function bankCompletions() {
|
||||
.map((name) => ({ label: name, type: 'bank' }));
|
||||
}
|
||||
|
||||
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
||||
// Attempt to get all scale names from Tonal
|
||||
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 = [
|
||||
@@ -272,7 +268,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;
|
||||
|
||||
+15
-53
@@ -72,6 +72,15 @@ 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 ':'.
|
||||
@@ -1750,64 +1759,17 @@ export const { semitone } = registerControl('semitone');
|
||||
// TODO: synth param
|
||||
export const { voice } = registerControl('voice');
|
||||
// voicings // https://codeberg.org/uzu/strudel/issues/506
|
||||
/**
|
||||
* 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()
|
||||
**/
|
||||
// chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings
|
||||
export const { chord } = registerControl('chord');
|
||||
/**
|
||||
* 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)
|
||||
**/
|
||||
// which dictionary to use for the voicings
|
||||
export const { dictionary, dict } = registerControl('dictionary', 'dict');
|
||||
/** 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()
|
||||
**/
|
||||
// the top note to align the voicing to, defaults to c5
|
||||
export const { anchor } = registerControl('anchor');
|
||||
/**
|
||||
* 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
|
||||
**/
|
||||
// how the voicing is offset from the anchored position
|
||||
export const { offset } = registerControl('offset');
|
||||
/**
|
||||
* 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()
|
||||
**/
|
||||
// how many octaves are voicing steps spread apart, defaults to 1
|
||||
export const { octaves } = registerControl('octaves');
|
||||
/**
|
||||
* 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()
|
||||
*
|
||||
**/
|
||||
// below = anchor note will be removed from the voicing, useful for melody harmonization
|
||||
export const { mode } = registerControl(['mode', 'anchor']);
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,11 +58,10 @@ 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()) {
|
||||
if (hap.hasOnset() || hap.context.processParts) {
|
||||
const targetTime =
|
||||
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
|
||||
(hap.part.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
|
||||
|
||||
@@ -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 a list of source code locations causing the event.
|
||||
The context is to store metadata, including 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,6 +161,10 @@ 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
|
||||
|
||||
@@ -62,7 +62,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,6 @@ 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
|
||||
|
||||
@@ -29,10 +29,6 @@ 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
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ export const buttonMap = {
|
||||
rt: 7,
|
||||
back: 8,
|
||||
start: 9,
|
||||
l3: 10,
|
||||
ls: 10,
|
||||
r3: 11,
|
||||
rs: 11,
|
||||
u: 12,
|
||||
up: 12,
|
||||
d: 13,
|
||||
|
||||
+42
-59
@@ -5,10 +5,9 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as _WebMidi from 'webmidi';
|
||||
import { Pattern, isPattern, logger, ref } from '@strudel/core';
|
||||
import { Pattern, getEventOffsetMs, 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;
|
||||
@@ -191,7 +190,7 @@ function mapCC(mapping, value) {
|
||||
}
|
||||
|
||||
// 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, timeOffsetString) {
|
||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||
throw new Error('expected ccv to be a number between 0 and 1');
|
||||
}
|
||||
@@ -199,23 +198,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||
throw new Error('expected ccn to be a number or a string');
|
||||
}
|
||||
const scaled = Math.round(ccv * 127);
|
||||
scheduleAtTime(() => {
|
||||
device.sendControlChange(ccn, scaled, midichan);
|
||||
}, targetTime);
|
||||
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a program change message to the given device on the given channel
|
||||
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendProgramChange(progNum, midichan);
|
||||
}, targetTime);
|
||||
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a sysex message to the given device on the given channel
|
||||
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||
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');
|
||||
@@ -230,13 +225,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendSysex(sysexid, sysexdata);
|
||||
}, targetTime);
|
||||
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// 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, timeOffsetString) {
|
||||
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');
|
||||
@@ -244,34 +237,28 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||
} 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');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||
}, targetTime);
|
||||
|
||||
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a pitch bend message to the given device on the given channel
|
||||
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||
throw new Error('expected midibend to be a number between -1 and 1');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendPitchBend(midibend, midichan);
|
||||
}, targetTime);
|
||||
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// sends a channel aftertouch message to the given device on the given channel
|
||||
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||
}
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.sendChannelAftertouch(miditouch, midichan);
|
||||
}, targetTime);
|
||||
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
|
||||
}
|
||||
|
||||
// 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, timeOffsetString) {
|
||||
if (note == null || note === '') {
|
||||
throw new Error('note cannot be null or empty');
|
||||
}
|
||||
@@ -281,12 +268,12 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
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 });
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.playNote(midiNote, midichan);
|
||||
}, targetTime);
|
||||
device.playNote(midiNote, midichan, {
|
||||
time: timeOffsetString,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +309,7 @@ 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
|
||||
@@ -345,13 +333,18 @@ 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,
|
||||
@@ -387,7 +380,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, targetTime));
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
|
||||
} else if (midimap !== 'default') {
|
||||
// Add warning when a non-existent midimap is specified
|
||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||
@@ -399,12 +392,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, targetTime);
|
||||
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle program change
|
||||
if (progNum !== undefined) {
|
||||
sendProgramChange(progNum, device, midichan, targetTime);
|
||||
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle sysex
|
||||
@@ -414,63 +407,53 @@ 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, targetTime);
|
||||
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle control change
|
||||
if (ccv !== undefined && ccn !== undefined) {
|
||||
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle NRPN non-registered parameter number
|
||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle midibend
|
||||
if (midibend !== undefined) {
|
||||
sendPitchBend(midibend, device, midichan, targetTime);
|
||||
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle miditouch
|
||||
if (miditouch !== undefined) {
|
||||
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
||||
}
|
||||
|
||||
// Handle midicmd
|
||||
if (hap.whole.begin + 0 === 0) {
|
||||
// we need to start here because we have the timing info
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
device.sendStart({ time: timeOffsetString });
|
||||
}
|
||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendClock();
|
||||
}, targetTime);
|
||||
device.sendClock({ time: timeOffsetString });
|
||||
} else if (['start'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
device.sendStart({ time: timeOffsetString });
|
||||
} else if (['stop'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStop();
|
||||
}, targetTime);
|
||||
device.sendStop({ time: timeOffsetString });
|
||||
} else if (['continue'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendContinue();
|
||||
}, targetTime);
|
||||
device.sendContinue({ time: timeOffsetString });
|
||||
} else if (Array.isArray(midicmd)) {
|
||||
if (midicmd[0] === 'progNum') {
|
||||
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
||||
} else if (midicmd[0] === 'cc') {
|
||||
if (midicmd.length === 2) {
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
||||
}
|
||||
} else if (midicmd[0] === 'sysex') {
|
||||
if (midicmd.length === 3) {
|
||||
const [_, id, data] = midicmd;
|
||||
sendSysex(id, data, device, targetTime);
|
||||
sendSysex(id, data, device, timeOffsetString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-7
@@ -52,6 +52,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -63,16 +64,42 @@ 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.
|
||||
@@ -83,3 +110,4 @@ export async function oscTrigger(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));
|
||||
|
||||
+9
-13
@@ -12,18 +12,13 @@ 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);
|
||||
@@ -36,23 +31,24 @@ wss.on('connection', (ws) => {
|
||||
console.log('New WebSocket connection');
|
||||
|
||||
ws.on('message', (message) => {
|
||||
let osc_host = '127.0.0.1';
|
||||
let osc_port = 57120;
|
||||
let oschost = '127.0.0.1';
|
||||
let oscport = 57120;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if ('host' in data) {
|
||||
osc_host = data['host'];
|
||||
if ('oschost' in data) {
|
||||
oschost = data['oschost'];
|
||||
delete data['oschost'];
|
||||
}
|
||||
if ('port' in data) {
|
||||
osc_port = data['port'];
|
||||
if ('oscport' in data) {
|
||||
oscport = data['oscport'];
|
||||
delete data['oscport'];
|
||||
}
|
||||
let msg = { address: data['address'], args: data['args'] };
|
||||
if ('timestamp' in data) {
|
||||
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
|
||||
}
|
||||
|
||||
udpPort.send(msg, osc_host, osc_port);
|
||||
udpPort.send(msg, oschost, oscport);
|
||||
} catch (err) {
|
||||
console.error('Error parsing message:', err);
|
||||
}
|
||||
|
||||
@@ -280,11 +280,6 @@ 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) {
|
||||
|
||||
@@ -41,6 +41,7 @@ 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){
|
||||
|
||||
@@ -840,31 +840,6 @@ 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 ]",
|
||||
@@ -1872,31 +1847,6 @@ 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 ]",
|
||||
@@ -2789,23 +2739,6 @@ 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 ]",
|
||||
@@ -6563,28 +6496,6 @@ 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 ]",
|
||||
@@ -6903,31 +6814,6 @@ 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 ]",
|
||||
@@ -6961,15 +6847,6 @@ 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,11 +79,13 @@ 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, patternAutoStart } = useSettings();
|
||||
const { userPatterns, patternFilter } = useSettings();
|
||||
const viewingPatternID = viewingPatternData?.id;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
|
||||
@@ -133,13 +135,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 }, patternAutoStart);
|
||||
|
||||
if (context.started && activePattern === id) {
|
||||
context.handleEvaluate();
|
||||
}
|
||||
}}
|
||||
onClick={(id) =>
|
||||
updateCodeWindow(
|
||||
context,
|
||||
{ ...userPatterns[id], collection: userPattern.collection },
|
||||
autoResetPatternOnChange,
|
||||
)
|
||||
}
|
||||
patterns={userPatterns}
|
||||
started={context.started}
|
||||
activePattern={activePattern}
|
||||
@@ -186,14 +188,17 @@ 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 }, patternAutoStart);
|
||||
updateCodeWindow(
|
||||
context,
|
||||
{ ...patterns[id], collection: patternFilterName.featured },
|
||||
autoResetPatternOnChange,
|
||||
);
|
||||
}}
|
||||
paginationOnChange={async (pageNum) => {
|
||||
await loadAndSetFeaturedPatterns(pageNum - 1);
|
||||
@@ -208,14 +213,13 @@ 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 }, patternAutoStart);
|
||||
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
|
||||
}}
|
||||
paginationOnChange={async (pageNum) => {
|
||||
await loadAndSetPublicPatterns(pageNum - 1);
|
||||
|
||||
@@ -112,7 +112,6 @@ export function SettingsTab({ started }) {
|
||||
multiChannelOrbits,
|
||||
isTabIndentationEnabled,
|
||||
isMultiCursorEnabled,
|
||||
patternAutoStart,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
@@ -305,11 +304,6 @@ 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,9 +62,11 @@ export function SoundsTab() {
|
||||
|
||||
// stop current sound on mouseup
|
||||
useEvent('mouseup', () => {
|
||||
const ref = trigRef.current;
|
||||
const t = trigRef.current;
|
||||
trigRef.current = undefined;
|
||||
ref?.stop?.(getAudioContext().currentTime + 0.01);
|
||||
t?.then((ref) => {
|
||||
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">
|
||||
@@ -122,19 +124,12 @@ export function SoundsTab() {
|
||||
duration: 0.5,
|
||||
};
|
||||
soundPreviewIdx++;
|
||||
const time = ctx.currentTime + 0.05;
|
||||
const onended = () => trigRef.current?.node?.disconnect();
|
||||
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);
|
||||
}
|
||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
||||
trigRef.current.then((ref) => {
|
||||
connectToDestination(ref?.node);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
|
||||
@@ -92,7 +92,11 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
return new Promise((resolve) => {
|
||||
resolve(URL.createObjectURL(blob));
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +96,6 @@ export function useSettings() {
|
||||
isPanelOpen: parseBoolean(state.isPanelOpen),
|
||||
userPatterns: userPatterns,
|
||||
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
|
||||
patternAutoStart: isUdels()
|
||||
? false
|
||||
: state.patternAutoStart === undefined
|
||||
? true
|
||||
: parseBoolean(state.patternAutoStart),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user