From 78091654f4a07e99469627a73c95ed526d4387dc Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 25 Sep 2023 13:49:27 +0200 Subject: [PATCH] Sequencer accepts data and parses events --- modules/event.js | 133 ++++++++++++++++++++++--------- modules/parse/parse-float-32.js | 11 +++ modules/parse/parse-float-64.js | 16 ++++ modules/parse/parse-frequency.js | 2 +- modules/parse/parse-gain.js | 1 + modules/parse/parse-note.js | 15 ++++ modules/sequencer.js | 31 ++++--- modules/sequencer/sequence.js | 55 +++++++------ modules/soundstage.js | 2 +- modules/test/events-test.js | 12 +-- modules/test/sequencer-test.js | 46 ++++++----- test/soundstage-test.json | 20 ++--- 12 files changed, 232 insertions(+), 112 deletions(-) create mode 100644 modules/parse/parse-float-32.js create mode 100644 modules/parse/parse-float-64.js create mode 100644 modules/parse/parse-note.js diff --git a/modules/event.js b/modules/event.js index 3579d89..db656d8 100755 --- a/modules/event.js +++ b/modules/event.js @@ -1,4 +1,20 @@ +import arg from '../../fn/modules/arg.js'; +import capture from '../../fn/modules/capture.js'; +import compose from '../../fn/modules/compose.js'; +import get from '../../fn/modules/get.js'; +import overload from '../../fn/modules/overload.js'; +import Pool from '../../fn/modules/pool.js'; +import remove from '../../fn/modules/remove.js'; +import toType from '../../fn/modules/to-type.js'; +import { bytesToSignedFloat } from '../../midi/modules/maths.js'; +import { toType as toTypeMIDI } from '../../midi/modules/data.js'; +import parseFloat64 from './parse/parse-float-64.js'; +import parseFloat32 from './parse/parse-float-32.js'; +import parseFrequency from './parse/parse-frequency.js'; +import parseGain from './parse/parse-gain.js'; +import parseNote from './parse/parse-note.js'; + /** Event(time, type, data...) @@ -80,21 +96,11 @@ sequence **/ -import capture from '../../fn/modules/capture.js'; -import compose from '../../fn/modules/compose.js'; -import get from '../../fn/modules/get.js'; -import overload from '../../fn/modules/overload.js'; -import Pool from '../../fn/modules/pool.js'; -import remove from '../../fn/modules/remove.js'; -import toType from '../../fn/modules/to-type.js'; -import { bytesToSignedFloat } from '../../midi/modules/maths.js'; -import { toType as toTypeMIDI } from '../../midi/modules/data.js'; -import parseEvent from './parse/parse-event.js'; - const assign = Object.assign; const define = Object.defineProperties; const getData = get('data'); + // --- const pitchBendRange = 2; @@ -120,27 +126,84 @@ function pitchToFloat(message) { } + + /** Event(time, type, ...) A constructor for event objects for internal use. **/ -export function Event(time, type) { - if (window.DEBUG && !isValidEvent(arguments)) { - throw new Error('Soundstage new Event() called with invalid arguments [' + Array.from(arguments).join(', ') + ']. ' + eventValidationHint(arguments)); - } +const tuning = 440; /* TEMP */ +const constructEventType = overload(arg(1), { + 'note': function() { + // frequency, gain, duration + this[2] = parseNote(arguments[2], tuning); + this[3] = parseGain(arguments[3]); + this[4] = parseFloat64(arguments[4]); + }, - const length = type === 'param' && arguments[4] === 'target' ? - 6 : - (lengths[type] || lengths.default) ; + 'start': function() { + // frequency, gain + this[2] = parseFrequency(arguments[2], tuning); + this[3] = parseGain(arguments[3]); + }, - this[0] = time; - this[1] = type; + 'stop': function() { + // frequency + this[2] = parseFrequency(arguments[2], tuning); + }, + + 'sequence': function() { + // name, target, duration + this[2] = arguments[2]; + this[3] = arguments[3]; + this[4] = parseFloat64(arguments[4]); + }, + + 'sequence-start': function() { + // name, target + this[2] = arguments[2]; + this[3] = arguments[3]; + }, + + 'sequence-stop': function() { + // name + this[2] = arguments[2]; + }, + + 'param': function() { + // name, value, [curve, [duration]] + this[2] = arguments[2]; + this[3] = parseFloat32(arguments[3]); + this[4] = arguments[4] || 'step'; + + if (arguments[4] === 'target') { + this[5] = parseFloat64(arguments[5]); + } + }, - let n = 1; - while (++n < length) { - this[n] = arguments[n]; + 'meter': function() { + // numerator, denominator + this[2] = parseInt(arguments[2], 10); + this[3] = parseInt(arguments[3], 10); + }, + + 'rate': function() { + // rate + this[2] = parseFloat64(arguments[2]); + }, + + default: function() { + this[2] = arguments[2]; } +}); + +export function Event(time, type) { + // Yes, WebAudio time is Float32, but this event may be a beat and I see + // little reason not to use full accuracy + this[0] = parseFloat64(time); + this[1] = type; + constructEventType.apply(this, arguments); } function reset() { @@ -163,9 +226,10 @@ assign(Event, { return new Event(...arguments); }, - from: function(data) { - return Event.of.apply(Event, data); - }, + from: overload(toType, { + string: (data) => Event.parse(data), + object: (data) => Event.of.apply(Event, data) + }), fromMIDI: overload(compose(toTypeMIDI, getData), { pitch: function(e) { @@ -189,19 +253,10 @@ assign(Event, { } }), - // "time type ..." - parse: capture(/^\s*([-\d\.e]+)\s+(\w+)\s+/, { - 2: (event, captures) => { - // time - event[0] = parseFloat(captures[1]); - // type - event[1] = captures[2]; - // parameters - parseEvent(event, captures); - // Convert to event object - return Event.from(event); - } - }, []), + parse: function(string) { + const data = string.split(/\s+/); + return new Event(...data); + }, stringify: function(event) { return Array.prototype.join.call(event, ' '); diff --git a/modules/parse/parse-float-32.js b/modules/parse/parse-float-32.js new file mode 100644 index 0000000..262a604 --- /dev/null +++ b/modules/parse/parse-float-32.js @@ -0,0 +1,11 @@ + +function throwParseFloat32() { + throw new Error('Cannot parse value "' + value + '" as Float32'); +} + +export default function parseFloat32(value) { + const number = Math.fround(value); + return Number.isNaN(number) ? + throwParseFloat32() : + number ; +} diff --git a/modules/parse/parse-float-64.js b/modules/parse/parse-float-64.js new file mode 100644 index 0000000..3cc0602 --- /dev/null +++ b/modules/parse/parse-float-64.js @@ -0,0 +1,16 @@ + +function throwParseNumber() { + throw new Error('Cannot parse value "' + value + '" as Number'); +} + +/** +parseFloat() +Like JavaScript's `parseFloat`, but with a check for `NaN`. +**/ + +export default function parseFloat64(value) { + const number = Number(value); + return Number.isNaN(number) ? + throwParseNumber() : + number ; +} diff --git a/modules/parse/parse-frequency.js b/modules/parse/parse-frequency.js index 997d2f3..7954a50 100644 --- a/modules/parse/parse-frequency.js +++ b/modules/parse/parse-frequency.js @@ -8,7 +8,7 @@ const rdigit = /^\d/; export default overload(toType, { 'number': id, - 'string': (value, tuning) => ( + 'string': (value, tuning = 440) => ( rdigit.test(value) ? value.slice(-2) === 'Hz' ? parseFloat(value) : floatToFrequency(tuning, value) : diff --git a/modules/parse/parse-gain.js b/modules/parse/parse-gain.js index a0c2e8a..1c37130 100644 --- a/modules/parse/parse-gain.js +++ b/modules/parse/parse-gain.js @@ -1,6 +1,7 @@ import toGain from '../../../fn/modules/to-gain.js'; import parseValue from '../../../fn/modules/parse-value.js'; +import parseFloat from './parse-float-64.js'; export default parseValue({ '': parseFloat, diff --git a/modules/parse/parse-note.js b/modules/parse/parse-note.js new file mode 100644 index 0000000..f6d0bd9 --- /dev/null +++ b/modules/parse/parse-note.js @@ -0,0 +1,15 @@ + +import id from '../../../fn/modules/id.js'; +import overload from '../../../fn/modules/overload.js'; +import toType from '../../../fn/modules/to-type.js'; +import { toNoteNumber } from '../../../midi/modules/data.js'; +import parseFloat from './parse-float-64.js'; + +const rdigit = /^\d/; + +export default overload(toType, { + 'number': id, + 'string': (value, tuning = 440) => ( + rdigit.test(value) ? parseFloat(value) : toNoteNumber(value) + ) +}); diff --git a/modules/sequencer.js b/modules/sequencer.js index 703de28..afaf184 100644 --- a/modules/sequencer.js +++ b/modules/sequencer.js @@ -10,7 +10,7 @@ import Event from './event.js'; import Clock from './clock.js'; import FrameStream from './sequencer/frame-stream.js'; import Meter from './sequencer/meter.js'; -import Sequence, { by0Float32 } from './sequencer/sequence.js'; +import Sequence from './sequencer/sequence.js'; import { print } from './print.js'; import { getDejitterTime } from './context.js'; @@ -24,6 +24,7 @@ import parseEvents from './parse/parse-events.js'; const assign = Object.assign; const create = Object.create; const define = Object.defineProperties; +const by0 = by(get(0)); /** @@ -56,26 +57,32 @@ Sequencer() ``` **/ -function sanitiseEvents(sequence) { - sequence.sequences && sequence.sequences.forEach(sanitiseEvents); - sequence.events = sequence.events - .map((data) => typeof data === 'string' ? Event.parse(data) : Event.from(data)) - .sort(by0Float32); +function parseSequence(data) { + return { + id: data.id, + label: data.label, + events: data.events.map(Event.from).sort(by0), + sequences: data.sequences && data.sequences.map(parseSequence) + }; } -export default function Sequencer(transport, output, events = [], sequences = []) { +export default function Sequencer(transport, output, data) { // .context // .start() // .stop() Playable.call(this, transport.context); this.transport = transport; - this.events = typeof events === 'string' ? - parseEvents(events).sort(by0Float32) : - events.map((data) => typeof data === 'string' ? Event.parse(data) : Event.from(data)).sort(by0Float32) ; - this.sequences = sequences; this.rate = transport.outputs.rate.offset; + this.events = data.events ? + data.events.map(Event.from).sort(by0) : + [] ; + + this.sequences = data.sequences ? + data.sequences.map(parseSequence) : + [] ; + const privates = Privates(this); privates.beat = 0; privates.output = output; @@ -137,7 +144,7 @@ assign(Sequencer.prototype, Meter.prototype, { .pipe(new Sequence(this, this.events, this.sequences, 'root')) // Error-check and consume output events .map(overload(get(1), { - // Do nothing, Sequencer doesn't respond to "start" + // Do nothing, Sequencer itself doesn't respond to "start" 'start': (event) => event.release(), // But perhaps it can respond to "stop", why not - ooo, because diff --git a/modules/sequencer/sequence.js b/modules/sequencer/sequence.js index a9a429f..b52dbae 100644 --- a/modules/sequencer/sequence.js +++ b/modules/sequencer/sequence.js @@ -1,18 +1,18 @@ -import by from '../../../fn/modules/by.js'; -import get from '../../../fn/modules/get.js'; -import matches from '../../../fn/modules/matches.js'; -import nothing from '../../../fn/modules/nothing.js'; -import { remove } from '../../../fn/modules/remove.js'; -import Privates from '../../../fn/modules/privates.js'; +import by from '../../../fn/modules/by.js'; +import get from '../../../fn/modules/get.js'; +import matches from '../../../fn/modules/matches.js'; +import nothing from '../../../fn/modules/nothing.js'; +import Privates from '../../../fn/modules/privates.js'; +import { remove } from '../../../fn/modules/remove.js'; import Stream, { pipe, stop } from '../../../fn/modules/stream.js'; +import { noteToFrequency } from '../../../midi/modules/data.js'; -import { print } from '../print.js'; -import Event, { isRateEvent, isValidEvent, getDuration, eventValidationHint } from '../event.js'; -import Playable from '../playable.js'; -import { beatAtLocation, locationAtBeat } from './location.js'; -import { getValueAtTime } from '../automate.js'; - +import Event, { isRateEvent, getDuration } from '../event.js'; +import Playable from '../playable.js'; +import { beatAtLocation, locationAtBeat } from './location.js'; +import { getValueAtTime } from '../automate.js'; +import { print } from '../print.js'; const A = Array.prototype; const assign = Object.assign; @@ -54,6 +54,10 @@ function indexEventAtBeat(events, beat) { return n; } +function throwDurationEventError(event) { + throw new Error('Cannot create start/stop events from ' + JSON.stringify(event)); +} + function processFrame(sequence, t2, b1, b2, events, latest, stopbuffer, buffer) { // Event index of first event after frame.b1 (we assume they sorted !!) let n = indexEventAtBeat(events, b1); @@ -141,9 +145,9 @@ function processFrame(sequence, t2, b1, b2, events, latest, stopbuffer, buffer) while (++n < buffer.length) { let event = buffer[n]; - if (!isValidEvent(event)) { + /*if (!isValidEvent(event)) { throw new Error('Invalid event ' + JSON.stringify(event) + '. ' + eventValidationHint(event)); - } + }*/ // Deal with events that have duration by creating -start and -stop events const duration = getDuration(event); @@ -152,14 +156,19 @@ function processFrame(sequence, t2, b1, b2, events, latest, stopbuffer, buffer) // Give stop a reference to start, renaming event type. Renames // 'note' to 'start'/'stop', and 'sequence' to // 'sequence-start'/'sequence-stop'. - const namePrefix = event[1] === 'note' ? '' : event[1] + '-' ; + const type = event[1]; - const startEvent - = new Event(event[0], namePrefix + 'start', event[2], event[3]); + const startEvent = type === 'note' ? + new Event(event[0], 'start', noteToFrequency(event[2], 440), event[3]) : + type === 'sequence' ? + new Event(event[0], 'sequence-start', event[2], event[3]) : + throwDurationEventError(event) ; - const stopEvent - = event.stopEvent - = new Event(event[0] + duration, namePrefix + 'stop', event[2]); + const stopEvent = type === 'note' ? + new Event(event[0] + duration, 'stop', startEvent[2]) : + type === 'sequence' ? + new Event(event[0], 'sequence-stop', event[2]) : + throwDurationEventError(event) ; stopEvent.startEvent = startEvent; buffer[n] = startEvent; @@ -168,7 +177,7 @@ function processFrame(sequence, t2, b1, b2, events, latest, stopbuffer, buffer) if (stopEvent[0] < b2) { // Make an attempt to preserve event order by jamming these at the // front, where they'll get sorted in front of any start events set - // to the same time + // to the same time. A bit dodgy. buffer.unshift(stopEvent); } else { @@ -183,11 +192,11 @@ function processFrame(sequence, t2, b1, b2, events, latest, stopbuffer, buffer) function readBufferEvent(sequence, stopbuffer, buffer, n) { const event = buffer[n]; const time = sequence.timeAtBeat(event[0]); - + /* if (!isValidEvent(event)) { throw new Error('Invalid event ' + JSON.stringify(event) + '. ' + eventValidationHint(event)); } - + */ // Syphon off events, create and start child sequences if (event[1] === 'sequence-start') { // This may extend the buffer with more events diff --git a/modules/soundstage.js b/modules/soundstage.js index bbb3d9d..77913b5 100755 --- a/modules/soundstage.js +++ b/modules/soundstage.js @@ -171,7 +171,7 @@ export default function Soundstage(data = defaultData, settings = nothing) { // .timeAtBeat(beat) // .beatAtBar(bar) // .barAtBeat(beat) - Sequencer.call(this, transport, automation, data.events, data.sequences); + Sequencer.call(this, transport, automation, data); privates.outputs = { default: merger, diff --git a/modules/test/events-test.js b/modules/test/events-test.js index 12ce05a..ec1b5f4 100644 --- a/modules/test/events-test.js +++ b/modules/test/events-test.js @@ -4,16 +4,16 @@ import context from '../context.js'; import parseEvents from '../parse/parse-events.js'; run('Event.of()', [ - [[0, 'note', 440, 1, 1]], - [[0, 'note', 440, 1, 1]], - [[0, 'note', 440, 1, 1]], - [[0, 'note', 440, 0.001, 1]], + [[0, 'note', 69, 1, 1]], + [[0, 'note', 69, 1, 1]], + [[0, 'note', 69, 1, 1]], + [[0, 'note', 69, 0.001, 1]], [[1, 'sequence', 'name', 'target', 8]], [[1.1, 'param', 'gain', 0, 'step']], [[8.012, 'param', 'gain', 0.5, 'target', 3]], [ - [0, 'note', 440, 1, 1], - [0, 'note', 440, 1, 1], + [0, 'note', 69, 1, 1], + [0, 'note', 69, 1, 1], [1, 'sequence', 'name', 'target', 8], [1.1, 'param', 'gain', 0, 'step'], [8.012, 'param', 'gain', 0.5, 'target', 3], diff --git a/modules/test/sequencer-test.js b/modules/test/sequencer-test.js index 287147e..3361e82 100644 --- a/modules/test/sequencer-test.js +++ b/modules/test/sequencer-test.js @@ -16,20 +16,23 @@ run('Sequencer()', function(test, done) { const transport = new Transport(context); const output = Stream.of().each((event) => test(event[1])); - const sequencer = new Sequencer(transport, output, [ - // Log events are not published to the output stream - [0.0, 'log', 'Log'], - // Nor are root events on root sequence - [0.1, 'note', 49, 1, 0.8], - // Nor are sequence events, which are consumed by the sequencer - [0.2, 'sequence', 'bob', 'path', 0.6] - ], [{ - id: 'bob', + const sequencer = new Sequencer(transport, output, { events: [ - [0, 'note', 54, 0.2, 0.3], - [0.2, 'param', 'gain', 1, 'exponential'] - ] - }]); + // Log events are not published to the output stream + [0.0, 'log', 'Log'], + // Nor are root events on root sequence + [0.1, 'note', 49, 1, 0.8], + // Nor are sequence events, which are consumed by the sequencer + [0.2, 'sequence', 'bob', 'path', 0.6] + ], + sequences: [{ + id: 'bob', + events: [ + [0, 'note', 54, 0.2, 0.3], + [0.2, 'param', 'gain', 1, 'exponential'] + ] + }] + }); window.sequencer = sequencer; @@ -62,14 +65,17 @@ run('Sequencer()', function(test, done) { const transport = new Transport(context); const output = Stream.of().each((event) => test(event[1])); - const sequencer = new Sequencer(transport, output, [ - [0, 'sequence', 'id', 'target', 3] - ], [{ - id: 'id', + const sequencer = new Sequencer(transport, output, { events: [ - [0, 'note', 50, 0.6, 0.5] - ] - }]); + [0, 'sequence', 'id', 'target', 3] + ], + sequences: [{ + id: 'id', + events: [ + [0, 'note', 50, 0.6, 0.5] + ] + }] + }); window.sequencer = sequencer; diff --git a/test/soundstage-test.json b/test/soundstage-test.json index 3dd014b..6ac1b1d 100644 --- a/test/soundstage-test.json +++ b/test/soundstage-test.json @@ -11,17 +11,17 @@ "sequences": [{ "id": "test", "events": [ - "0 note 800 0.6 0.1", - "0.25 note 400 0.7 0.1", - "0.5 note 600 0.8 0.1", - "0.75 note 300 0.8 0.1", - "1 note 1200 0.7 0.8", + "0 note 69 0.6 0.1", + "0.25 note 69 0.7 0.1", + "0.5 note 69 0.8 0.1", + "0.75 note 69 0.8 0.1", + "1 note 120 0.7 0.8", "2 detune -1 step ", - "2 note 800 0.6 0.2", - "2.25 note 400 0.7 0.2", - "2.5 note 600 0.8 0.2", - "2.75 note 300 0.8 0.2", - "3 note 400 0.7 0.8" + "2 note 40 0.6 0.2", + "2.25 note 20 0.7 0.2", + "2.5 note 30 0.8 0.2", + "2.75 note 60 0.8 0.2", + "3 note 80 0.7 0.8" ] }],