sync without cyclist changes

This commit is contained in:
Jade (Rose) Rowland 2025-07-06 00:59:39 -04:00
parent 077556e7cb
commit d9e44dcda6
8 changed files with 261 additions and 82 deletions

View file

@ -448,13 +448,23 @@ export const { coarse } = registerControl('coarse');
* modulate the amplitude of a sound with a continuous waveform * modulate the amplitude of a sound with a continuous waveform
* *
* @name am * @name am
* @synonyms tremelo * @param {number | Pattern} speed modulation speed in HZ
* @param {number | Pattern} speed modulation speed in cycles
* @example * @example
* s("triangle").am("2").amshape("<tri saw ramp square>").amdepth(.5) * s("triangle").am("2").amshape("<tri saw ramp square>").amdepth(.5)
* *
*/ */
export const { am, tremolo } = registerControl(['am', 'amdepth', 'amskew', 'amphase'], 'tremolo'); export const { am, } = registerControl(['am', 'amdepth', 'amskew', 'amphase'],);
/**
* modulate the amplitude of a sound with a continuous waveform
*
* @name amsync
* @param {number | Pattern} cycles modulation speed in cycles
* @example
* s("triangle").am("2").amshape("<tri saw ramp square>").amdepth(.5)
*
*/
export const { amsync } = registerControl(['amsync', 'amdepth', 'amskew', 'amphase']);
/** /**
* depth of amplitude modulation * depth of amplitude modulation

View file

@ -0,0 +1,140 @@
/*
cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/cyclist.mjs>
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 createClock from './zyklus.mjs';
import { logger } from './logger.mjs';
export class Cyclist {
constructor({
interval,
onTrigger,
onToggle,
onError,
getTime,
latency = 0.1,
setInterval,
clearInterval,
beforeStart,
}) {
this.started = false;
this.beforeStart = beforeStart;
this.cps = 0.5;
this.num_ticks_since_cps_change = 0;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.lastBegin = 0; // query begin of last tick
this.lastEnd = 0; // query end of last tick
this.getTime = getTime; // get absolute time
this.num_cycles_at_cps_change = 0;
this.seconds_at_cps_change; // clock phase when cps was changed
this.onToggle = onToggle;
this.latency = latency; // fixed trigger time offset
this.clock = createClock(
getTime,
// called slightly before each cycle
(phase, duration, _, t) => {
if (this.num_ticks_since_cps_change === 0) {
this.num_cycles_at_cps_change = this.lastEnd;
this.seconds_at_cps_change = phase;
}
this.num_ticks_since_cps_change++;
const seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
const num_cycles_since_cps_change = seconds_since_cps_change * this.cps;
try {
const begin = this.lastEnd;
this.lastBegin = begin;
const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
this.lastEnd = end;
this.lastTick = phase;
if (phase < t) {
// avoid querying haps that are in the past anyway
console.log(`skip query: too late`);
return;
}
// query the pattern for events
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://codeberg.org/uzu/strudel/pulls/1004
const deadline = targetTime - phase;
// this onTrigger has another signature
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
this.cps = hap.value.cps;
this.num_ticks_since_cps_change = 0;
}
}
});
} catch (e) {
logger(`[cyclist] error: ${e.message}`);
onError?.(e);
}
},
interval, // duration of each cycle
0.1,
0.1,
setInterval,
clearInterval,
);
}
now() {
if (!this.started) {
return 0;
}
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
}
setStarted(v) {
this.started = v;
this.onToggle?.(v);
}
async start() {
await this.beforeStart?.();
this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = 0;
if (!this.pattern) {
throw new Error('Scheduler: no pattern set! call .setPattern first.');
}
logger('[cyclist] start');
this.clock.start();
this.setStarted(true);
}
pause() {
logger('[cyclist] pause');
this.clock.pause();
this.setStarted(false);
}
stop() {
logger('[cyclist] stop');
this.clock.stop();
this.lastEnd = 0;
this.setStarted(false);
}
async setPattern(pat, autostart = false) {
this.pattern = pat;
if (autostart && !this.started) {
await this.start();
}
}
setCps(cps = 0.5) {
if (this.cps === cps) {
return;
}
this.cps = cps;
this.num_ticks_since_cps_change = 0;
}
log(begin, end, haps) {
const onsets = haps.filter((h) => h.hasOnset());
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);
}
}

View file

@ -1,6 +1,6 @@
/* /*
cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs> cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/neocyclist.mjs>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/cyclist.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/cyclist.mjs>
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/>.
*/ */
@ -8,77 +8,50 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
export class Cyclist { export class Cyclist {
constructor({ constructor({ interval = 0.05, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) {
interval,
onTrigger,
onToggle,
onError,
getTime,
latency = 0.1,
setInterval,
clearInterval,
beforeStart,
}) {
this.started = false; this.started = false;
this.beforeStart = beforeStart;
this.cps = 0.5; this.cps = 0.5;
this.num_ticks_since_cps_change = 0; this.time_at_last_tick_message = 0;
this.lastTick = 0; // absolute time when last tick (clock callback) happened this.cycle = 0;
this.lastBegin = 0; // query begin of last tick
this.lastEnd = 0; // query end of last tick
this.getTime = getTime; // get absolute time this.getTime = getTime; // get absolute time
this.num_cycles_at_cps_change = 0; this.num_cycles_at_cps_change = 0;
this.seconds_at_cps_change; // clock phase when cps was changed this.seconds_at_cps_change; // clock phase when cps was changed
this.num_ticks_since_cps_change = 0;
this.onToggle = onToggle; this.onToggle = onToggle;
this.latency = latency; // fixed trigger time offset this.latency = latency; // fixed trigger time offset
this.interval = interval;
this.clock = createClock( this.clock = createClock(
getTime, getTime,
// called slightly before each cycle // called slightly before each cycle
(phase, duration, _, t) => { (lastTick, duration, _, time) => {
if (this.num_ticks_since_cps_change === 0) { if (this.started === false) {
this.num_cycles_at_cps_change = this.lastEnd; return;
this.seconds_at_cps_change = phase;
} }
this.num_ticks_since_cps_change++; const num_seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
const seconds_since_cps_change = this.num_ticks_since_cps_change * duration; const tickdeadline = lastTick - time;
const num_cycles_since_cps_change = seconds_since_cps_change * this.cps; const num_cycles_since_cps_change = num_seconds_since_cps_change * this.cps;
const begin = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
const secondsSinceLastTick = time - lastTick - duration;
const eventLength = duration * this.cps;
const end = begin + eventLength;
this.cycle = begin + secondsSinceLastTick * this.cps;
try { //account for latency and tick duration when using cycle calculations for audio downstream
const begin = this.lastEnd; const cycle_gap = (this.latency - duration) * this.cps;
this.lastBegin = begin;
const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
this.lastEnd = end;
this.lastTick = phase;
if (phase < t) { const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
// avoid querying haps that are in the past anyway haps.forEach((hap) => {
console.log(`skip query: too late`); if (hap.hasOnset()) {
return; let targetTime = (hap.whole.begin - this.num_cycles_at_cps_change) / this.cps;
targetTime = targetTime + this.latency + tickdeadline + time - num_seconds_since_cps_change;
const duration = hap.duration / this.cps;
onTrigger?.(hap, tickdeadline, duration, this.cps, targetTime, this.cycle - cycle_gap);
} }
});
// query the pattern for events this.time_at_last_tick_message = time;
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); this.num_ticks_since_cps_change++;
haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://codeberg.org/uzu/strudel/pulls/1004
const deadline = targetTime - phase;
// this onTrigger has another signature
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
this.cps = hap.value.cps;
this.num_ticks_since_cps_change = 0;
}
}
});
} catch (e) {
logger(`[cyclist] error: ${e.message}`);
onError?.(e);
}
}, },
interval, // duration of each cycle interval, // duration of each cycle
0.1, 0.1,
@ -91,17 +64,24 @@ export class Cyclist {
if (!this.started) { if (!this.started) {
return 0; return 0;
} }
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration; const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency; return this.cycle + gap;
} }
setCycle = (cycle) => {
this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = cycle;
};
setStarted(v) { setStarted(v) {
this.started = v; this.started = v;
this.setCycle(0);
this.onToggle?.(v); this.onToggle?.(v);
} }
async start() { start() {
await this.beforeStart?.();
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = 0; this.num_cycles_at_cps_change = 0;
if (!this.pattern) { if (!this.pattern) {
throw new Error('Scheduler: no pattern set! call .setPattern first.'); throw new Error('Scheduler: no pattern set! call .setPattern first.');
} }
@ -120,16 +100,18 @@ export class Cyclist {
this.lastEnd = 0; this.lastEnd = 0;
this.setStarted(false); this.setStarted(false);
} }
async setPattern(pat, autostart = false) { setPattern(pat, autostart = false) {
this.pattern = pat; this.pattern = pat;
if (autostart && !this.started) { if (autostart && !this.started) {
await this.start(); this.start();
} }
} }
setCps(cps = 0.5) { setCps(cps = 0.5) {
if (this.cps === cps) { if (this.cps === cps) {
return; return;
} }
const num_seconds_since_cps_change = this.num_ticks_since_cps_change * this.interval;
this.num_cycles_at_cps_change = this.num_cycles_at_cps_change + num_seconds_since_cps_change * cps;
this.cps = cps; this.cps = cps;
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
} }
@ -137,4 +119,4 @@ export class Cyclist {
const onsets = haps.filter((h) => h.hasOnset()); const onsets = haps.filter((h) => h.hasOnset());
console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`);
} }
} }

View file

@ -45,7 +45,7 @@ export class NeoCyclist {
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
const targetTime = timeUntilTrigger + currentTime + this.latency; const targetTime = timeUntilTrigger + currentTime + this.latency;
const duration = cycleToSeconds(hap.duration, this.cps); const duration = cycleToSeconds(hap.duration, this.cps);
onTrigger?.(hap, 0, duration, this.cps, targetTime); onTrigger?.(hap, 0, duration, this.cps, targetTime, this.cycle);
} }
}); });
}; };

View file

@ -244,16 +244,16 @@ export function repl({
export const getTrigger = export const getTrigger =
({ getTime, defaultOutput }) => ({ getTime, defaultOutput }) =>
async (hap, deadline, duration, cps, t) => { async (hap, deadline, duration, cps, t, cycle = 0) => {
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004 // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
try { try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps, t); await defaultOutput(hap, deadline, duration, cps, t, cycle);
} }
if (hap.context.onTrigger) { if (hap.context.onTrigger) {
// call signature of output / onTrigger is different... // call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t); await hap.context.onTrigger(hap, getTime(), cps, t, cycle);
} }
} catch (err) { } catch (err) {
logger(`[cyclist] error: ${err.message}`, 'error'); logger(`[cyclist] error: ${err.message}`, 'error');

View file

@ -45,6 +45,8 @@ export function setGainCurve(newGainCurveFunc) {
gainCurveFunc = newGainCurveFunc; gainCurveFunc = newGainCurveFunc;
} }
function aliasBankMap(aliasMap) { function aliasBankMap(aliasMap) {
// Make all bank keys lower case for case insensitivity // Make all bank keys lower case for case insensitivity
for (const key in aliasMap) { for (const key in aliasMap) {
@ -325,12 +327,29 @@ function getDelay(orbit, delaytime, delayfeedback, t, channels) {
return delays[orbit]; return delays[orbit];
} }
export function getLfo(audioContext, time, end, properties = {}) { export function getLfo(audioContext, begin, end, properties = {}) {
return getWorklet(audioContext, 'lfo-processor', { return getWorklet(audioContext, 'lfo-processor', {
frequency: 1, frequency: 1,
depth: 1, depth: 1,
skew: 0, skew: 0,
phaseoffset: 0, phaseoffset: 0,
time: begin,
begin,
end,
shape: 1,
dcoffset: -0.5,
...properties,
});
}
export function getSyncedLfo(audioContext, time, end, cps, cycle, properties = {}) {
const frequency = cycle/cps
return getWorklet(audioContext, 'lfo-processor', {
frequency,
depth: 1,
skew: 0,
phaseoffset: 0,
time, time,
end, end,
shape: 1, shape: 1,
@ -450,9 +469,10 @@ function mapChannelNumbers(channels) {
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
} }
export const superdough = async (value, t, hapDuration, cps = 0.5) => { export const superdough = async (value, t, hapDuration, cps = 0.5, cycle) => {
// new: t is always expected to be the absolute target onset time // new: t is always expected to be the absolute target onset time
const ac = getAudioContext(); const ac = getAudioContext();
let { stretch } = value; let { stretch } = value;
if (stretch != null) { if (stretch != null) {
//account for phase vocoder latency //account for phase vocoder latency
@ -707,11 +727,12 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol })); shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol })); // distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol }));
// am !== undefined && // am !== undefined &&
// chain.push( // chain.push(
// getWorklet(ac, 'am-processor', { // getWorklet(ac, 'am-processor', {
// speed: am, // speed: am,
// begin: t,
// depth: amdepth, // depth: amdepth,
// skew: amskew, // skew: amskew,
// phaseoffset: amphase, // phaseoffset: amphase,
@ -724,12 +745,21 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
if (am !== undefined) { if (am !== undefined) {
const amGain = new GainNode(ac, { gain: 1 }); const amGain = new GainNode(ac, { gain: 1 });
const frequency = cycleToSeconds(am, cps) const frequency = cps / am
const phaseoffset = cycleToSeconds(amphase, cps) const phaseoffset = cycleToSeconds(amphase, cps)
const time = cycle / cps
// console.info(cycle, time, frequency)
const lfo = getLfo(ac, t, t + hapDuration, { const lfo = getLfo(ac, t, t + hapDuration, {
skew: amskew, skew: amskew,
frequency: am * cps, frequency,
depth: amdepth, depth: amdepth,
time,
// dcoffset: 0, // dcoffset: 0,
shape: amshape, shape: amshape,
phaseoffset phaseoffset

View file

@ -85,6 +85,7 @@ const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor { class LFOProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
return [ return [
{ name: 'begin', defaultValue: 0 },
{ name: 'time', defaultValue: 0 }, { name: 'time', defaultValue: 0 },
{ name: 'end', defaultValue: 0 }, { name: 'end', defaultValue: 0 },
{ name: 'frequency', defaultValue: 0.5 }, { name: 'frequency', defaultValue: 0.5 },
@ -109,10 +110,14 @@ class LFOProcessor extends AudioWorkletProcessor {
} }
process(inputs, outputs, parameters) { process(inputs, outputs, parameters) {
const begin = parameters['begin'][0]
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
if (currentTime >= parameters.end[0]) { if (currentTime >= parameters.end[0]) {
return false; return false;
} }
if (currentTime <= begin) {
return true;
}
const output = outputs[0]; const output = outputs[0];
const frequency = parameters['frequency'][0]; const frequency = parameters['frequency'][0];
@ -159,6 +164,8 @@ class CoarseProcessor extends AudioWorkletProcessor {
const input = inputs[0]; const input = inputs[0];
const output = outputs[0]; const output = outputs[0];
const hasInput = !(input[0] === undefined); const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) { if (this.started && !hasInput) {
return false; return false;
@ -899,6 +906,7 @@ registerProcessor('byte-beat-processor', ByteBeatProcessor);
class AMProcessor extends AudioWorkletProcessor { class AMProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
return [ return [
{ name: 'begin', defaultValue: 0 },
{ name: 'cps', defaultValue: 0.5 }, { name: 'cps', defaultValue: 0.5 },
{ name: 'speed', defaultValue: 0.5 }, { name: 'speed', defaultValue: 0.5 },
{ name: 'cycle', defaultValue: 0 }, { name: 'cycle', defaultValue: 0 },
@ -925,10 +933,15 @@ class AMProcessor extends AudioWorkletProcessor {
const input = inputs[0]; const input = inputs[0];
const output = outputs[0]; const output = outputs[0];
const hasInput = !(input[0] === undefined); const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) { if (this.started && !hasInput) {
return false; return false;
} }
this.started = hasInput; this.started = hasInput;
if (currentTime <= parameters.begin[0]) {
return true;
}
const speed = parameters['speed'][0]; const speed = parameters['speed'][0];
const cps = parameters['cps'][0]; const cps = parameters['cps'][0];
@ -937,7 +950,7 @@ class AMProcessor extends AudioWorkletProcessor {
const skew = parameters['skew'][0]; const skew = parameters['skew'][0];
const phaseoffset = parameters['phaseoffset'][0]; const phaseoffset = parameters['phaseoffset'][0];
const frequency = speed * cps; const frequency = cps / speed
if (this.phase == null) { if (this.phase == null) {
const secondsPassed = cycle / cps; const secondsPassed = cycle / cps;
this.phase = _mod(secondsPassed * frequency + phaseoffset, 1); this.phase = _mod(secondsPassed * frequency + phaseoffset, 1);

View file

@ -17,8 +17,12 @@ const hap2value = (hap) => {
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004 // uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
// TODO: refactor output callbacks to eliminate deadline // TODO: refactor output callbacks to eliminate deadline
export const webaudioOutput = (hap, deadline, hapDuration, cps, t) => { export const webaudioOutput = (hap, deadline, hapDuration, cps, t, cycle) => {
return superdough(hap2value(hap), t, hapDuration, cps);
return superdough(hap2value(hap), t, hapDuration, cps,
hap.whole.begin.valueOf()
// cycle
);
}; };
export function webaudioRepl(options = {}) { export function webaudioRepl(options = {}) {