From e128b02da1e1394f86a8398808c4ca42d1575ff0 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 1 Nov 2022 21:31:39 +0100 Subject: [PATCH] support object arithmetic --- packages/core/pattern.mjs | 23 ++++--- packages/core/signal.mjs | 3 +- packages/core/test/pattern.test.mjs | 36 ++++++----- packages/core/test/util.test.mjs | 96 ++++++++++++++++++++++++----- packages/core/util.mjs | 43 +++++++++++++ 5 files changed, 159 insertions(+), 42 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 0e3b7fb0..0e5a7ea5 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,8 +446,13 @@ export class Pattern { return otherPat.fmap((b) => this.fmap((a) => func(a)(b)))._TrigzeroJoin(); } + // TODO: refactor to parseNumber / withNumberArgs (see util) _asNumber(dropfails = false, softfail = false) { return this._withHap((hap) => { + // leave objects alone... this is needed to be able to do pat.add(n(2)) + if (!['number', 'string'].includes(typeof hap.value)) { + return hap; + } const asNumber = Number(hap.value); if (!isNaN(asNumber)) { return hap.withValue(() => asNumber); @@ -761,7 +766,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 +788,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)) { @@ -1387,7 +1392,7 @@ function _composeOp(a, b, func) { // Make composers (function () { const num = (pat) => pat._asNumber(); - const numOrString = (pat) => pat._asNumber(false, true); + // const numOrString = (pat) => pat._asNumber(false, true); // was used for add // pattern composers const composers = { @@ -1412,7 +1417,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), num], // support string concatenation /** * * Like add, but the given numbers are subtracted. @@ -1422,7 +1427,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), num], /** * * Multiplies each number by the given factor. @@ -1431,14 +1436,14 @@ 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), num], /** * * Divides each number by the given factor. * @name div * @memberof Pattern */ - div: [(a, b) => a / b, num], + div: [numeralArgs((a, b) => a / b), num], mod: [mod, num], pow: [Math.pow, num], _and: [(a, b) => a & b, num], 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..d793cdbf 100644 --- a/packages/core/test/util.test.mjs +++ b/packages/core/test/util.test.mjs @@ -5,7 +5,19 @@ 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, + numeralArgs, + fractionalArgs, +} from '../util.mjs'; import { describe, it, expect } from 'vitest'; describe('isNote', () => { @@ -92,16 +104,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 +152,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);