Compare commits

..

9 Commits

Author SHA1 Message Date
alex c805bd9a3b change controls to be functions rather than patterns 2023-02-25 15:29:35 +00:00
Felix Roos 3015e7b203 Merge pull request #480 from tidalcycles/fix-array-args
Fix array args
2023-02-25 14:26:49 +01:00
Felix Roos 7ac0cdc0f9 fix: lint error 2023-02-25 14:18:23 +01:00
Felix Roos 49234183a3 Merge remote-tracking branch 'origin/main' into fix-array-args 2023-02-25 14:13:55 +01:00
Felix Roos ed7763df92 fix: reify after sequence to preserve array
ref https://github.com/tidalcycles/strudel/issues/383#issuecomment-1399514090
2023-02-25 14:13:39 +01:00
Felix Roos b818a02f76 Merge pull request #478 from tidalcycles/midi-fleshout
midi cc support
2023-02-25 12:33:22 +01:00
Felix Roos 886f8449fd add midichan + docs 2023-02-25 12:23:01 +01:00
Felix Roos 5de6643604 midi: support ccn and ccv 2023-02-24 10:15:21 +01:00
Felix Roos 6283273d81 add helper hap.ensureObjectValue 2023-02-24 10:15:06 +01:00
10 changed files with 91 additions and 63 deletions
+4 -3
View File
@@ -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",
+7 -15
View File
@@ -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);
+13
View File
@@ -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;
+1 -1
View File
@@ -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);
}; };
+1 -3
View File
@@ -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
View File
@@ -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 });
}
}); });
}; };
+1
View File
@@ -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();
+8 -25
View File
@@ -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]);
+2
View File
@@ -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:
+24 -1
View File
@@ -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.