mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-28 07:46:45 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c805bd9a3b | |||
| 3015e7b203 | |||
| 7ac0cdc0f9 | |||
| 49234183a3 | |||
| ed7763df92 | |||
| b818a02f76 | |||
| 886f8449fd | |||
| 5de6643604 | |||
| 6283273d81 |
+4
-3
@@ -46,14 +46,15 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://strudel.tidalcycles.org",
|
"homepage": "https://strudel.tidalcycles.org",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dependency-tree": "^9.0.0",
|
|
||||||
"vitest": "^0.25.7",
|
|
||||||
"@strudel.cycles/core": "workspace:*",
|
"@strudel.cycles/core": "workspace:*",
|
||||||
"@strudel.cycles/mini": "workspace:*",
|
"@strudel.cycles/mini": "workspace:*",
|
||||||
"@strudel.cycles/tonal": "workspace:*",
|
"@strudel.cycles/tonal": "workspace:*",
|
||||||
"@strudel.cycles/transpiler": "workspace:*",
|
"@strudel.cycles/transpiler": "workspace:*",
|
||||||
"@strudel.cycles/webaudio": "workspace:*",
|
"@strudel.cycles/webaudio": "workspace:*",
|
||||||
"@strudel.cycles/xen": "workspace:*"
|
"@strudel.cycles/xen": "workspace:*",
|
||||||
|
"acorn": "^8.8.1",
|
||||||
|
"dependency-tree": "^9.0.0",
|
||||||
|
"vitest": "^0.25.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/ui": "^0.25.7",
|
"@vitest/ui": "^0.25.7",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
|
|||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pattern, sequence, registerControl } from './pattern.mjs';
|
import { Pattern, sequence, register } from './pattern.mjs';
|
||||||
|
|
||||||
const controls = {};
|
const controls = {};
|
||||||
const generic_params = [
|
const generic_params = [
|
||||||
@@ -78,8 +78,6 @@ const generic_params = [
|
|||||||
'gain',
|
'gain',
|
||||||
'a pattern of numbers that specify volume. Values less than 1 make the sound quieter. Values greater than 1 make the sound louder. For the linear equivalent, see @amp@.',
|
'a pattern of numbers that specify volume. Values less than 1 make the sound quieter. Values greater than 1 make the sound louder. For the linear equivalent, see @amp@.',
|
||||||
],
|
],
|
||||||
['f', 'gainpre'],
|
|
||||||
['f', 'gainpost'],
|
|
||||||
/**
|
/**
|
||||||
* Like {@link gain}, but linear.
|
* Like {@link gain}, but linear.
|
||||||
*
|
*
|
||||||
@@ -830,18 +828,12 @@ const generic_params = [
|
|||||||
|
|
||||||
// TODO: slice / splice https://www.youtube.com/watch?v=hKhPdO0RKDQ&list=PL2lW1zNIIwj3bDkh-Y3LUGDuRcoUigoDs&index=13
|
// TODO: slice / splice https://www.youtube.com/watch?v=hKhPdO0RKDQ&list=PL2lW1zNIIwj3bDkh-Y3LUGDuRcoUigoDs&index=13
|
||||||
|
|
||||||
const makeControl = function (name) {
|
controls.play = {}
|
||||||
const func = (...pats) => sequence(...pats).withValue((x) => ({ [name]: x }));
|
|
||||||
const setter = function (...pats) {
|
const makeControl = name => {
|
||||||
if (!pats.length) {
|
controls.play[name] = pat => pat.fmap(v => ({[name]: v}))
|
||||||
return this.fmap((value) => ({ [name]: value }));
|
return register(name, (v, pat) => pat.fmap(o => ({...o, ...{[name]: v}})))
|
||||||
}
|
}
|
||||||
return this.set(func(...pats));
|
|
||||||
};
|
|
||||||
Pattern.prototype[name] = setter;
|
|
||||||
registerControl(name, func);
|
|
||||||
return func;
|
|
||||||
};
|
|
||||||
|
|
||||||
generic_params.forEach(([type, name, description]) => {
|
generic_params.forEach(([type, name, description]) => {
|
||||||
controls[name] = makeControl(name);
|
controls[name] = makeControl(name);
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ export class Hap {
|
|||||||
setContext(context) {
|
setContext(context) {
|
||||||
return new Hap(this.whole, this.part, this.value, context);
|
return new Hap(this.whole, this.part, this.value, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureObjectValue() {
|
||||||
|
/* if (isNote(hap.value)) {
|
||||||
|
// supports primitive hap values that look like notes
|
||||||
|
hap.value = { note: hap.value };
|
||||||
|
} */
|
||||||
|
if (typeof this.value !== 'object') {
|
||||||
|
throw new Error(
|
||||||
|
`expected hap.value to be an object, but got "${this.value}". Hint: append .note() or .s() to the end`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Hap;
|
export default Hap;
|
||||||
|
|||||||
@@ -1643,7 +1643,6 @@ export function register(name, func, patternify = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Pattern.prototype[name] = function (...args) {
|
Pattern.prototype[name] = function (...args) {
|
||||||
args = args.map(reify);
|
|
||||||
// For methods that take a single argument (plus 'this'), allow
|
// For methods that take a single argument (plus 'this'), allow
|
||||||
// multiple arguments but sequence them
|
// multiple arguments but sequence them
|
||||||
if (arity === 2 && args.length !== 1) {
|
if (arity === 2 && args.length !== 1) {
|
||||||
@@ -1651,6 +1650,7 @@ export function register(name, func, patternify = true) {
|
|||||||
} else if (arity !== args.length + 1) {
|
} else if (arity !== args.length + 1) {
|
||||||
throw new Error(`.${name}() expects ${arity - 1} inputs but got ${args.length}.`);
|
throw new Error(`.${name}() expects ${arity - 1} inputs but got ${args.length}.`);
|
||||||
}
|
}
|
||||||
|
args = args.map(reify);
|
||||||
return pfunc(...args, this);
|
return pfunc(...args, this);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export const csound = register('csound', (instrument, pat) => {
|
|||||||
logger('[csound] not loaded yet', 'warning');
|
logger('[csound] not loaded yet', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof hap.value !== 'object') {
|
hap.ensureObjectValue();
|
||||||
throw new Error('csound only support objects as hap values');
|
|
||||||
}
|
|
||||||
let { gain = 0.8 } = hap.value;
|
let { gain = 0.8 } = hap.value;
|
||||||
gain *= 0.2;
|
gain *= 0.2;
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -5,8 +5,9 @@ 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, isPattern, isNote, getPlayableNoteValue, logger } from '@strudel.cycles/core';
|
import { Pattern, isPattern, logger } from '@strudel.cycles/core';
|
||||||
import { getAudioContext } from '@strudel.cycles/webaudio';
|
import { getAudioContext } from '@strudel.cycles/webaudio';
|
||||||
|
import { toMidi } from '@strudel.cycles/core';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -63,7 +64,7 @@ function getDevice(output, outputs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pattern.prototype.midi = function (output: string | number, channel = 1) {
|
// Pattern.prototype.midi = function (output: string | number, channel = 1) {
|
||||||
Pattern.prototype.midi = function (output, channel = 1) {
|
Pattern.prototype.midi = function (output) {
|
||||||
if (!supportsMidi()) {
|
if (!supportsMidi()) {
|
||||||
throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`);
|
throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`);
|
||||||
}
|
}
|
||||||
@@ -90,11 +91,6 @@ Pattern.prototype.midi = function (output, channel = 1) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.onTrigger((time, hap) => {
|
return this.onTrigger((time, hap) => {
|
||||||
let note = getPlayableNoteValue(hap);
|
|
||||||
const velocity = hap.context?.velocity ?? 0.9;
|
|
||||||
if (!isNote(note)) {
|
|
||||||
throw new Error('not a note: ' + note);
|
|
||||||
}
|
|
||||||
if (!midiReady) {
|
if (!midiReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,15 +102,34 @@ Pattern.prototype.midi = function (output, channel = 1) {
|
|||||||
.join(' | ')}`,
|
.join(' | ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// console.log('midi', value, output);
|
hap.ensureObjectValue();
|
||||||
|
|
||||||
|
// calculate time
|
||||||
const timingOffset = WebMidi.time - getAudioContext().currentTime * 1000;
|
const timingOffset = WebMidi.time - getAudioContext().currentTime * 1000;
|
||||||
time = time * 1000 + timingOffset;
|
time = time * 1000 + timingOffset;
|
||||||
// const inMs = '+' + (time - Tone.getContext().currentTime) * 1000;
|
|
||||||
// await enableWebMidi()
|
// destructure value
|
||||||
device.playNote(note, channel, {
|
const { note, nrpnn, nrpv, ccn, ccv, midichan = 1 } = hap.value;
|
||||||
time,
|
const velocity = hap.context?.velocity ?? 0.9; // TODO: refactor velocity
|
||||||
duration: hap.duration.valueOf() * 1000 - 5,
|
const duration = hap.duration.valueOf() * 1000 - 5;
|
||||||
attack: velocity,
|
|
||||||
});
|
if (note) {
|
||||||
|
const midiNumber = toMidi(note);
|
||||||
|
device.playNote(midiNumber, midichan, {
|
||||||
|
time,
|
||||||
|
duration,
|
||||||
|
attack: velocity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ccv && ccn) {
|
||||||
|
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||||
|
throw new Error('expected ccv to be a number between 0 and 1');
|
||||||
|
}
|
||||||
|
if (!['string', 'number'].includes(typeof ccn)) {
|
||||||
|
throw new Error('expected ccn to be a number or a string');
|
||||||
|
}
|
||||||
|
const scaled = Math.round(ccv * 127);
|
||||||
|
device.sendControlChange(ccn, scaled, midichan, { time });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ let startedAt = -1;
|
|||||||
*/
|
*/
|
||||||
Pattern.prototype.osc = function () {
|
Pattern.prototype.osc = function () {
|
||||||
return this.onTrigger(async (time, hap, currentTime, cps = 1) => {
|
return this.onTrigger(async (time, hap, currentTime, cps = 1) => {
|
||||||
|
hap.ensureObjectValue();
|
||||||
const osc = await connect();
|
const osc = await connect();
|
||||||
const cycle = hap.wholeOrPart().begin.valueOf();
|
const cycle = hap.wholeOrPart().begin.valueOf();
|
||||||
const delta = hap.duration.valueOf();
|
const delta = hap.duration.valueOf();
|
||||||
|
|||||||
@@ -193,21 +193,9 @@ function effectSend(input, effect, wet) {
|
|||||||
// export const webaudioOutput = async (t, hap, ct, cps) => {
|
// export const webaudioOutput = async (t, hap, ct, cps) => {
|
||||||
export const webaudioOutput = async (hap, deadline, hapDuration) => {
|
export const webaudioOutput = async (hap, deadline, hapDuration) => {
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
/* if (isNote(hap.value)) {
|
hap.ensureObjectValue();
|
||||||
// supports primitive hap values that look like notes
|
|
||||||
hap.value = { note: hap.value };
|
// calculate absolute time
|
||||||
} */
|
|
||||||
if (typeof hap.value !== 'object') {
|
|
||||||
logger(
|
|
||||||
`hap.value "${hap.value}" is not supported by webaudio output. Hint: append .note() or .s() to the end`,
|
|
||||||
'error',
|
|
||||||
);
|
|
||||||
/* throw new Error(
|
|
||||||
`hap.value "${hap.value}"" is not supported by webaudio output. Hint: append .note() or .s() to the end`,
|
|
||||||
); */
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// calculate correct time (tone.js workaround)
|
|
||||||
let t = ac.currentTime + deadline;
|
let t = ac.currentTime + deadline;
|
||||||
// destructure value
|
// destructure value
|
||||||
let {
|
let {
|
||||||
@@ -219,8 +207,6 @@ export const webaudioOutput = async (hap, deadline, hapDuration) => {
|
|||||||
n = 0,
|
n = 0,
|
||||||
note,
|
note,
|
||||||
gain = 0.8,
|
gain = 0.8,
|
||||||
gainpost = 1,
|
|
||||||
gainpre = 1,
|
|
||||||
// low pass
|
// low pass
|
||||||
lpf,
|
lpf,
|
||||||
cutoff = lpf,
|
cutoff = lpf,
|
||||||
@@ -375,26 +361,23 @@ export const webaudioOutput = async (hap, deadline, hapDuration) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// last gain
|
// last gain
|
||||||
const pre = gainNode(gainpre);
|
const post = gainNode(1);
|
||||||
chain.push(pre);
|
chain.push(post);
|
||||||
|
post.connect(getDestination());
|
||||||
|
|
||||||
// delay
|
// delay
|
||||||
let delaySend;
|
let delaySend;
|
||||||
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
||||||
const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
|
const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
|
||||||
delaySend = effectSend(pre, delyNode, delay);
|
delaySend = effectSend(post, delyNode, delay);
|
||||||
}
|
}
|
||||||
// reverb
|
// reverb
|
||||||
let reverbSend;
|
let reverbSend;
|
||||||
if (room > 0 && roomsize > 0) {
|
if (room > 0 && roomsize > 0) {
|
||||||
const reverbNode = getReverb(orbit, roomsize);
|
const reverbNode = getReverb(orbit, roomsize);
|
||||||
reverbSend = effectSend(pre, reverbNode, room);
|
reverbSend = effectSend(post, reverbNode, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = gainNode(gainpost);
|
|
||||||
chain.push(post);
|
|
||||||
post.connect(getDestination());
|
|
||||||
|
|
||||||
// connect chain elements together
|
// connect chain elements together
|
||||||
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
||||||
|
|
||||||
|
|||||||
Generated
+2
@@ -11,6 +11,7 @@ importers:
|
|||||||
'@strudel.cycles/webaudio': workspace:*
|
'@strudel.cycles/webaudio': workspace:*
|
||||||
'@strudel.cycles/xen': workspace:*
|
'@strudel.cycles/xen': workspace:*
|
||||||
'@vitest/ui': ^0.25.7
|
'@vitest/ui': ^0.25.7
|
||||||
|
acorn: ^8.8.1
|
||||||
c8: ^7.12.0
|
c8: ^7.12.0
|
||||||
canvas: ^2.11.0
|
canvas: ^2.11.0
|
||||||
dependency-tree: ^9.0.0
|
dependency-tree: ^9.0.0
|
||||||
@@ -32,6 +33,7 @@ importers:
|
|||||||
'@strudel.cycles/transpiler': link:packages/transpiler
|
'@strudel.cycles/transpiler': link:packages/transpiler
|
||||||
'@strudel.cycles/webaudio': link:packages/webaudio
|
'@strudel.cycles/webaudio': link:packages/webaudio
|
||||||
'@strudel.cycles/xen': link:packages/xen
|
'@strudel.cycles/xen': link:packages/xen
|
||||||
|
acorn: 8.8.2
|
||||||
dependency-tree: 9.0.0
|
dependency-tree: 9.0.0
|
||||||
vitest: 0.25.8_@vitest+ui@0.25.8
|
vitest: 0.25.8_@vitest+ui@0.25.8
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
|||||||
@@ -22,12 +22,35 @@ If no outputName is given, it uses the first midi output it finds.
|
|||||||
|
|
||||||
<MiniRepl
|
<MiniRepl
|
||||||
client:idle
|
client:idle
|
||||||
tune={`stack("<C^7 A7 Dm7 G7>".voicings('lefthand'), "<C3 A2 D3 G2>")
|
tune={`stack("<C^7 A7 Dm7 G7>".voicings('lefthand'), "<C3 A2 D3 G2>").note()
|
||||||
.midi()`}
|
.midi()`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
In the console, you will see a log of the available MIDI devices as soon as you run the code, e.g. `Midi connected! Using "Midi Through Port-0".`
|
In the console, you will see a log of the available MIDI devices as soon as you run the code, e.g. `Midi connected! Using "Midi Through Port-0".`
|
||||||
|
|
||||||
|
## midichan(number)
|
||||||
|
|
||||||
|
Selects the MIDI channel to use. If not used, `.midi` will use channel 1 by default.
|
||||||
|
|
||||||
|
## ccn && ccv
|
||||||
|
|
||||||
|
- `ccn` sets the cc number. Depends on your synths midi mapping
|
||||||
|
- `ccv` sets the cc value. normalized from 0 to 1.
|
||||||
|
|
||||||
|
<MiniRepl client:idle tune={`note("c a f e").ccn(74).ccv(sine.slow(4)).midi()`} />
|
||||||
|
|
||||||
|
In the above snippet, `ccn` is set to 74, which is the filter cutoff for many synths. `ccv` is controlled by a saw pattern.
|
||||||
|
Having everything in one pattern, the `ccv` pattern will be aligned to the note pattern, because the structure comes from the left by default.
|
||||||
|
But you can also control cc messages separately like this:
|
||||||
|
|
||||||
|
<MiniRepl
|
||||||
|
client:idle
|
||||||
|
tune={`stack(
|
||||||
|
note("c a f e"),
|
||||||
|
ccv(sine.segment(16).slow(4)).ccn(74)
|
||||||
|
).midi()`}
|
||||||
|
/>
|
||||||
|
|
||||||
# SuperDirt API
|
# SuperDirt API
|
||||||
|
|
||||||
In mainline tidal, the actual sound is generated via [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider.
|
In mainline tidal, the actual sound is generated via [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider.
|
||||||
|
|||||||
Reference in New Issue
Block a user