diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index bedb4a25..ca9bec08 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -40,11 +40,6 @@ const generic_params = [ * @example * n("0 1 2 3").s('east').osc() */ - // TODO: nOut does not work - // TODO: notes don't work as expected - // current "workaround" for notes: - // s('superpiano').n(""._asNumber()).osc() - // -> .n or .osc (or .superdirt) would need to convert note strings to numbers // also see https://github.com/tidalcycles/strudel/pull/63 ['f', 'n', 'The note or sample number to choose for a synth or sampleset'], ['f', 'note', 'The note or pitch to play a sound or synth with'], diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 0e3b7fb0..ac5fe31f 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -10,7 +10,7 @@ import Hap from './hap.mjs'; import State from './state.mjs'; import { unionWithObj } from './value.mjs'; -import { isNote, toMidi, compose, removeUndefineds, flatten, id, listRange, curry, mod } from './util.mjs'; +import { isNote, toMidi, compose, removeUndefineds, flatten, id, listRange, curry, mod, numeralArgs } from './util.mjs'; import drawLine from './drawLine.mjs'; /** @class Class representing a pattern. */ @@ -68,7 +68,7 @@ export class Pattern { return new Pattern((state) => { const newState = state.withSpan(func); if (!newState.span) { - return []; + return []; } return pat.query(newState); }); @@ -446,34 +446,8 @@ export class Pattern { return otherPat.fmap((b) => this.fmap((a) => func(a)(b)))._TrigzeroJoin(); } - _asNumber(dropfails = false, softfail = false) { - return this._withHap((hap) => { - const asNumber = Number(hap.value); - if (!isNaN(asNumber)) { - return hap.withValue(() => asNumber); - } - const specialValue = { - e: Math.E, - pi: Math.PI, - }[hap.value]; - if (typeof specialValue !== 'undefined') { - return hap.withValue(() => specialValue); - } - if (isNote(hap.value)) { - // set context type to midi to let the player know its meant as midi number and not as frequency - return new Hap(hap.whole, hap.part, toMidi(hap.value), { ...hap.context, type: 'midi' }); - } - if (dropfails) { - // return 'nothing' - return undefined; - } - if (softfail) { - // return original hap - return hap; - } - throw new Error('cannot parse as number: "' + hap.value + '"'); - return hap; - }); + _asNumber() { + return this.fmap(numeralArgs); } /** @@ -761,7 +735,7 @@ export class Pattern { const bpos = span.begin.sub(cycle).mul(factor).min(1); const epos = span.end.sub(cycle).mul(factor).min(1); if (bpos >= 1) { - return undefined; + return undefined; } return new TimeSpan(cycle.add(bpos), cycle.add(epos)); }; @@ -783,7 +757,7 @@ export class Pattern { }; return this.withQuerySpanMaybe(qf)._withHap(ef)._splitQueries(); } - + // Compress each cycle into the given timespan, leaving a gap _compress(b, e) { if (b.gt(e) || b.gt(1) || e.gt(1) || b.lt(0) || e.lt(0)) { @@ -1386,9 +1360,6 @@ function _composeOp(a, b, func) { // Make composers (function () { - const num = (pat) => pat._asNumber(); - const numOrString = (pat) => pat._asNumber(false, true); - // pattern composers const composers = { set: [(a, b) => b], @@ -1412,7 +1383,7 @@ function _composeOp(a, b, func) { * // Behind the scenes, the notes are converted to midi numbers: * // "48 52 55".add("<0 5 7 0>").note() */ - add: [(a, b) => a + b, numOrString], // support string concatenation + add: [numeralArgs((a, b) => a + b)], // support string concatenation /** * * Like add, but the given numbers are subtracted. @@ -1422,7 +1393,7 @@ function _composeOp(a, b, func) { * "0 2 4".sub("<0 1 2 3>").scale('C4 minor').note() * // See add for more information. */ - sub: [(a, b) => a - b, num], + sub: [numeralArgs((a, b) => a - b)], /** * * Multiplies each number by the given factor. @@ -1431,21 +1402,21 @@ function _composeOp(a, b, func) { * @example * "1 1.5 [1.66, <2 2.33>]".mul(150).freq() */ - mul: [(a, b) => a * b, num], + mul: [numeralArgs((a, b) => a * b)], /** * * Divides each number by the given factor. * @name div * @memberof Pattern */ - div: [(a, b) => a / b, num], - mod: [mod, num], - pow: [Math.pow, num], - _and: [(a, b) => a & b, num], - _or: [(a, b) => a | b, num], - _xor: [(a, b) => a ^ b, num], - _lshift: [(a, b) => a << b, num], - _rshift: [(a, b) => a >> b, num], + div: [numeralArgs((a, b) => a / b)], + mod: [numeralArgs(mod)], + pow: [numeralArgs(Math.pow)], + _and: [numeralArgs((a, b) => a & b)], + _or: [numeralArgs((a, b) => a | b)], + _xor: [numeralArgs((a, b) => a ^ b)], + _lshift: [numeralArgs((a, b) => a << b)], + _rshift: [numeralArgs((a, b) => a >> b)], // TODO - force numerical comparison if both look like numbers? lt: [(a, b) => a < b], diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 71ee9078..4369b89e 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -238,6 +238,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs); export const wchooseCycles = (...pairs) => _wchooseWith(rand, ...pairs).innerJoin(); +// this function expects pat to be a pattern of floats... export const perlinWith = (pat) => { const pata = pat.fmap(Math.floor); const patb = pat.fmap((t) => Math.floor(t) + 1); @@ -255,7 +256,7 @@ export const perlinWith = (pat) => { * s("bd sd,hh*4").cutoff(perlin.range(500,2000)) * */ -export const perlin = perlinWith(time); +export const perlin = perlinWith(time.fmap((v) => Number(v))); Pattern.prototype._degradeByWith = function (withPat, x) { return this.fmap((a) => (_) => a).appLeft(withPat._filterValues((v) => v > x)); diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index bbf4f92f..929581ae 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -47,6 +47,9 @@ import { import { steady } from '../signal.mjs'; +import controls from '../controls.mjs'; + +const { n } = controls; const st = (begin, end) => new State(ts(begin, end)); const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end)); const hap = (whole, part, value, context = {}) => new Hap(whole, part, value, context); @@ -138,7 +141,7 @@ describe('Pattern', () => { expect(pure('hello').query(st(0.5, 2.5)).length).toBe(3); }); it('Supports zero-width queries', () => { - expect(pure('hello').queryArc(0,0).length).toBe(1); + expect(pure('hello').queryArc(0, 0).length).toBe(1); }); }); describe('fmap()', () => { @@ -194,6 +197,9 @@ describe('Pattern', () => { sequence([11, [12, 13]], [21, [22, 23]], [31, [32, 33]]), ); }); + it('can add object patterns', () => { + sameFirst(n(sequence(1, [2, 3])).add(n(10)), n(sequence(11, [12, 13]))); + }); }); describe('keep()', () => { it('can structure In()', () => { @@ -376,9 +382,10 @@ describe('Pattern', () => { ); }); it('copes with breaking up events across cycles', () => { - expect(pure('a').slow(2)._fastGap(2)._setContext({}).query(st(0, 2))).toStrictEqual( - [hap(ts(0, 1), ts(0, 0.5), 'a'), hap(ts(0.5, 1.5), ts(1, 1.5), 'a')] - ); + expect(pure('a').slow(2)._fastGap(2)._setContext({}).query(st(0, 2))).toStrictEqual([ + hap(ts(0, 1), ts(0, 0.5), 'a'), + hap(ts(0.5, 1.5), ts(1, 1.5), 'a'), + ]); }); }); describe('_compressSpan()', () => { @@ -434,14 +441,14 @@ describe('Pattern', () => { // mini('eb3 [c3 g3]/2 ') always plays [c3 g3] }); it('Supports zero-length queries', () => { - expect(steady('a')._slow(1).queryArc(0,0) - ).toStrictEqual(steady('a').queryArc(0,0)) + expect(steady('a')._slow(1).queryArc(0, 0)).toStrictEqual(steady('a').queryArc(0, 0)); }); }); describe('slow()', () => { it('Supports zero-length queries', () => { - expect(steady('a').slow(1)._setContext({}).queryArc(0,0) - ).toStrictEqual(steady('a')._setContext({}).queryArc(0,0)) + expect(steady('a').slow(1)._setContext({}).queryArc(0, 0)).toStrictEqual( + steady('a')._setContext({}).queryArc(0, 0), + ); }); }); describe('inside', () => { @@ -812,10 +819,11 @@ describe('Pattern', () => { }); it('Squeezes to the correct cycle', () => { expect( - pure(time.struct(true))._squeezeJoin().queryArc(3,4).map(x => x.value) - ).toStrictEqual( - [Fraction(3.5)] - ) + pure(time.struct(true)) + ._squeezeJoin() + .queryArc(3, 4) + .map((x) => x.value), + ).toStrictEqual([Fraction(3.5)]); }); }); describe('ply', () => { @@ -868,9 +876,7 @@ describe('Pattern', () => { }); describe('range', () => { it('Can be patterned', () => { - expect(sequence(0, 0).range(sequence(0, 0.5), 1).firstCycle()).toStrictEqual( - sequence(0, 0.5).firstCycle(), - ); + expect(sequence(0, 0).range(sequence(0, 0.5), 1).firstCycle()).toStrictEqual(sequence(0, 0.5).firstCycle()); }); }); describe('range2', () => { diff --git a/packages/core/test/util.test.mjs b/packages/core/test/util.test.mjs index 9435b710..303a3645 100644 --- a/packages/core/test/util.test.mjs +++ b/packages/core/test/util.test.mjs @@ -5,7 +5,20 @@ This program is free software: you can redistribute it and/or modify it under th */ import { pure } from '../pattern.mjs'; -import { isNote, tokenizeNote, toMidi, fromMidi, mod, compose, getFrequency, getPlayableNoteValue } from '../util.mjs'; +import { + isNote, + tokenizeNote, + toMidi, + fromMidi, + mod, + compose, + getFrequency, + getPlayableNoteValue, + parseNumeral, + parseFractional, + numeralArgs, + fractionalArgs, +} from '../util.mjs'; import { describe, it, expect } from 'vitest'; describe('isNote', () => { @@ -92,16 +105,16 @@ describe('getFrequency', () => { expect(getFrequency(happify(57, { type: 'midi' }))).toEqual(220); }); it('should return frequencies unchanged', () => { - expect(getFrequency(happify(440, { type: 'frequency' }))).toEqual(440); + expect(getFrequency(happify(440, { type: 'frequency' }))).toEqual(440); expect(getFrequency(happify(432, { type: 'frequency' }))).toEqual(432); }); it('should turn object with a "freq" property into frequency', () => { - expect(getFrequency(happify({freq: 220}))).toEqual(220) - expect(getFrequency(happify({freq: 440}))).toEqual(440) + expect(getFrequency(happify({ freq: 220 }))).toEqual(220); + expect(getFrequency(happify({ freq: 440 }))).toEqual(440); }); it('should throw an error when given a non-note', () => { - expect(() => getFrequency(happify('Q'))).toThrowError(`not a note or frequency: Q`) - expect(() => getFrequency(happify('Z'))).toThrowError(`not a note or frequency: Z`) + expect(() => getFrequency(happify('Q'))).toThrowError(`not a note or frequency: Q`); + expect(() => getFrequency(happify('Z'))).toThrowError(`not a note or frequency: Z`); }); }); @@ -140,22 +153,72 @@ describe('compose', () => { describe('getPlayableNoteValue', () => { const happify = (val, context = {}) => pure(val).firstCycle()[0].setContext(context); it('should return object "note" property', () => { - expect(getPlayableNoteValue(happify({note: "a4"}))).toEqual('a4') + expect(getPlayableNoteValue(happify({ note: 'a4' }))).toEqual('a4'); }); it('should return object "n" property', () => { - expect(getPlayableNoteValue(happify({n: "a4"}))).toEqual('a4') + expect(getPlayableNoteValue(happify({ n: 'a4' }))).toEqual('a4'); }); it('should return object "value" property', () => { - expect(getPlayableNoteValue(happify({value: "a4"}))).toEqual('a4') + expect(getPlayableNoteValue(happify({ value: 'a4' }))).toEqual('a4'); }); it('should turn midi into frequency', () => { - expect(getPlayableNoteValue(happify(57, {type: 'midi'}))).toEqual(220) - }) + expect(getPlayableNoteValue(happify(57, { type: 'midi' }))).toEqual(220); + }); it('should return frequency value', () => { - expect(getPlayableNoteValue(happify(220, {type: 'frequency'}))).toEqual(220) - }) + expect(getPlayableNoteValue(happify(220, { type: 'frequency' }))).toEqual(220); + }); it('should throw an error if value is not an object, number, or string', () => { - expect(() => getPlayableNoteValue(happify(false))).toThrowError(`not a note: false`) - expect(() => getPlayableNoteValue(happify(undefined))).toThrowError(`not a note: undefined`) - }) -}); \ No newline at end of file + expect(() => getPlayableNoteValue(happify(false))).toThrowError(`not a note: false`); + expect(() => getPlayableNoteValue(happify(undefined))).toThrowError(`not a note: undefined`); + }); +}); + +describe('parseNumeral', () => { + it('should parse numbers as is', () => { + expect(parseNumeral(4)).toBe(4); + expect(parseNumeral(0)).toBe(0); + expect(parseNumeral(20)).toBe(20); + expect(parseNumeral('20')).toBe(20); + expect(parseNumeral(1.5)).toBe(1.5); + }); + it('should parse notes', () => { + expect(parseNumeral('c4')).toBe(60); + expect(parseNumeral('c#4')).toBe(61); + expect(parseNumeral('db4')).toBe(61); + }); + it('should throw an error for unknown strings', () => { + expect(() => parseNumeral('xyz')).toThrowError('cannot parse as numeral: "xyz"'); + }); +}); + +describe('parseFractional', () => { + it('should parse numbers as is', () => { + expect(parseFractional(4)).toBe(4); + expect(parseFractional(0)).toBe(0); + expect(parseFractional(20)).toBe(20); + expect(parseFractional('20')).toBe(20); + expect(parseFractional(1.5)).toBe(1.5); + }); + it('should parse fractional shorthands values', () => { + expect(parseFractional('w')).toBe(1); + expect(parseFractional('h')).toBe(0.5); + expect(parseFractional('q')).toBe(0.25); + expect(parseFractional('e')).toBe(0.125); + }); + it('should throw an error for unknown strings', () => { + expect(() => parseFractional('xyz')).toThrowError('cannot parse as fractional: "xyz"'); + }); +}); + +describe('numeralArgs', () => { + it('should convert function arguments to numbers', () => { + const add = numeralArgs((a, b) => a + b); + expect(add('c4', 2)).toBe(62); + }); +}); +describe('fractionalArgs', () => { + it('should convert function arguments to numbers', () => { + const add = fractionalArgs((a, b) => a + b); + expect(add('q', 2)).toBe(2.25); + }); +}); diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 15613638..5a8447eb 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -130,3 +130,46 @@ export function curry(func, overload) { } return fn; } + +export function parseNumeral(numOrString) { + const asNumber = Number(numOrString); + if (!isNaN(asNumber)) { + return asNumber; + } + if (isNote(numOrString)) { + return toMidi(numOrString); + } + throw new Error(`cannot parse as numeral: "${numOrString}"`); +} + +export function mapArgs(fn, mapFn) { + return (...args) => fn(...args.map(mapFn)); +} + +export function numeralArgs(fn) { + return mapArgs(fn, parseNumeral); +} + +export function parseFractional(numOrString) { + const asNumber = Number(numOrString); + if (!isNaN(asNumber)) { + return asNumber; + } + const specialValue = { + pi: Math.PI, + w: 1, + h: 0.5, + q: 0.25, + e: 0.125, + s: 0.0625, + t: 1 / 3, + f: 0.2, + x: 1 / 6, + }[numOrString]; + if (typeof specialValue !== 'undefined') { + return specialValue; + } + throw new Error(`cannot parse as fractional: "${numOrString}"`); +} + +export const fractionalArgs = (fn) => mapArgs(fn, parseFractional); diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index 72eb4e7f..4e1011b1 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import OSC from 'osc-js'; -import { Pattern } from '@strudel.cycles/core'; +import { parseNumeral, Pattern } from '@strudel.cycles/core'; const comm = new OSC(); comm.open(); @@ -31,6 +31,10 @@ Pattern.prototype.osc = function () { startedAt = Date.now() - currentTime * 1000; } const controls = Object.assign({}, { cps, cycle, delta }, hap.value); + // make sure n and note are numbers + controls.n && (controls.n = parseNumeral(controls.n)); + controls.note && (controls.note = parseNumeral(controls.note)); + const keyvals = Object.entries(controls).flat(); const ts = Math.floor(startedAt + (time + latency) * 1000); const message = new OSC.Message('/dirt/play', ...keyvals);