diff --git a/packages/core/clockworker.js b/packages/core/clockworker.js index 4c33de292..bcaf28725 100644 --- a/packages/core/clockworker.js +++ b/packages/core/clockworker.js @@ -4,9 +4,9 @@ // import createClock from './zyklus.mjs'; function getTime() { - const precision = 10 ** 4; - const seconds = performance.now() / 1000; - return Math.round(seconds * precision) / precision; + const seconds = performance.now() * 0.001; + return seconds; + // return Math.round(seconds * precision) / precision; } let num_cycles_at_cps_change = 0; @@ -24,27 +24,20 @@ const sendMessage = (type, payload) => { const sendTick = (phase, duration, tick, time) => { const num_seconds_since_cps_change = num_ticks_since_cps_change * duration; - const tickdeadline = phase - time; const lastTick = time + tickdeadline; const num_cycles_since_cps_change = num_seconds_since_cps_change * cps; - const begin = num_cycles_at_cps_change + num_cycles_since_cps_change; const secondsSinceLastTick = time - lastTick - duration; - const eventLength = duration * cps; const end = begin + eventLength; - const cycle = begin + secondsSinceLastTick * cps; sendMessage('tick', { begin, end, cps, - tickdeadline, - num_cycles_at_cps_change, - num_seconds_at_cps_change, - num_seconds_since_cps_change, + time, cycle, }); num_ticks_since_cps_change++; diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs index 4600d0ef9..ad22cf006 100644 --- a/packages/core/neocyclist.mjs +++ b/packages/core/neocyclist.mjs @@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import { logger } from './logger.mjs'; +import { ClockCollator, cycleToSeconds } from './util.mjs'; export class NeoCyclist { constructor({ onTrigger, onToggle, getTime }) { @@ -13,79 +14,38 @@ export class NeoCyclist { this.lastTick = 0; // absolute time when last tick (clock callback) happened this.getTime = getTime; // get absolute time this.time_at_last_tick_message = 0; - - this.num_cycles_at_cps_change = 0; + // the clock of the worker and the audio context clock can drift apart over time + // aditionally, the message time of the worker pinging the callback to process haps can be inconsistent. + // we need to keep a rolling average of the time difference between the worker clock and audio context clock + // in order to schedule events consistently. + this.collator = new ClockCollator({ getTargetClockTime: getTime }); this.onToggle = onToggle; this.latency = 0.1; // fixed trigger time offset this.cycle = 0; this.id = Math.round(Date.now() * Math.random()); - this.worker_time_dif; this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); this.worker.port.start(); - this.channel = new BroadcastChannel('strudeltick'); - let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif - const maxWeight = 20; - const precision = 10 ** 3; //round off time diff to prevent accumulating outliers - - // the clock of the worker and the audio context clock can drift apart over time - // aditionally, the message time of the worker pinging the callback to process haps can be inconsistent. - // we need to keep a rolling weighted average of the time difference between the worker clock and audio context clock - // in order to schedule events consistently. - const setTimeReference = (num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline) => { - const time_dif = getTime() - (num_seconds_at_cps_change + num_seconds_since_cps_change) + tickdeadline; - if (this.worker_time_dif == null) { - this.worker_time_dif = time_dif; - } else { - const w = 1; //weight of new time diff; - const new_dif = - Math.round(((this.worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision; - - if (new_dif != this.worker_time_dif) { - // reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens - weight = 4; - } - this.worker_time_dif = new_dif; - } - weight = Math.min(weight + 1, maxWeight); - }; - const tickCallback = (payload) => { - const { - num_cycles_at_cps_change, - cps, - num_seconds_at_cps_change, - num_seconds_since_cps_change, - begin, - end, - tickdeadline, - cycle, - } = payload; + const { cps, begin, end, cycle, time } = payload; this.cps = cps; this.cycle = cycle; - - setTimeReference(num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline); - - processHaps(begin, end, num_cycles_at_cps_change, num_seconds_at_cps_change); - - this.time_at_last_tick_message = this.getTime(); + const currentTime = this.collator.calculateOffset(time) + time; + processHaps(begin, end, currentTime); + this.time_at_last_tick_message = currentTime; }; - const processHaps = (begin, end, num_cycles_at_cps_change, seconds_at_cps_change) => { + const processHaps = (begin, end, currentTime) => { if (this.started === false) { return; } const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); - haps.forEach((hap) => { if (hap.hasOnset()) { - const targetTime = - (hap.whole.begin - num_cycles_at_cps_change) / this.cps + - seconds_at_cps_change + - this.latency + - this.worker_time_dif; - const duration = hap.duration / this.cps; + const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); + const targetTime = timeUntilTrigger + currentTime + this.latency; + const duration = cycleToSeconds(hap.duration, this.cps); onTrigger?.(hap, 0, duration, this.cps, targetTime); } }); @@ -129,8 +89,8 @@ export class NeoCyclist { this.setStarted(true); } stop() { - this.worker_time_dif = null; logger('[cyclist] stop'); + this.collator.reset(); this.setStarted(false); } setPattern(pat, autostart = false) { diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index e47453687..afae87b13 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -386,6 +386,15 @@ export class Pattern { return this.fmap(func).squeezeJoin(); } + polyJoin = function () { + const pp = this; + return pp.fmap((p) => p.s_extend(pp.tactus.div(p.tactus))).outerJoin(); + }; + + polyBind(func) { + return this.fmap(func).polyJoin(); + } + ////////////////////////////////////////////////////////////////////// // Utility methods mainly for internal use @@ -754,6 +763,10 @@ export class Pattern { const otherPat = reify(other); return otherPat.fmap((b) => this.fmap((a) => func(a)(b))).restartJoin(); } + _opPoly(other, func) { + const otherPat = reify(other); + return this.fmap((b) => otherPat.fmap((a) => func(a)(b))).polyJoin(); + } ////////////////////////////////////////////////////////////////////// // End-user methods. @@ -1062,7 +1075,7 @@ function _composeOp(a, b, func) { func: [(a, b) => b(a)], }; - const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart']; + const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly']; // generate methods to do what and how for (const [what, [op, preprocess]] of Object.entries(composers)) { @@ -2944,11 +2957,14 @@ export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (facto export const fit = register('fit', (pat) => pat.withHaps((haps, state) => haps.map((hap) => - hap.withValue((v) => ({ - ...v, - speed: (state.controls._cps || 1) / hap.whole.duration, - unit: 'c', - })), + hap.withValue((v) => { + const slicedur = ('end' in v ? v.end : 1) - ('begin' in v ? v.begin : 0); + return { + ...v, + speed: ((state.controls._cps || 1) / hap.whole.duration) * slicedur, + unit: 'c', + }; + }), ), ), ); diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index f43fae34a..c0637383d 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -138,7 +138,7 @@ const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x))); const timeToRandsPrime = (seed, n) => { const result = []; // eslint-disable-next-line - for (let i = 0; i < n; ++n) { + for (let i = 0; i < n; ++i) { result.push(intSeedToRand(seed)); seed = xorwise(seed); } @@ -159,6 +159,50 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n); */ export const run = (n) => saw.range(0, n).floor().segment(n); +export const randrun = (n) => { + return signal((t) => { + // Without adding 0.5, the first cycle is always 0,1,2,3,... + const rands = timeToRands(t.floor().add(0.5), n); + const nums = rands + .map((n, i) => [n, i]) + .sort((a, b) => a[0] > b[0] - a[0] < b[0]) + .map((x) => x[1]); + const i = t.cyclePos().mul(n).floor() % n; + return nums[i]; + })._segment(n); +}; + +const _rearrangeWith = (ipat, n, pat) => { + const pats = [...Array(n).keys()].map((i) => pat.zoom(Fraction(i).div(n), Fraction(i + 1).div(n))); + return ipat.fmap((i) => pats[i].repeatCycles(n)._fast(n)).innerJoin(); +}; + +/** + * @name shuffle + * Slices a pattern into the given number of parts, then plays those parts in random order. + * Each part will be played exactly once per cycle. + * @example + * note("c d e f").sound("piano").shuffle(4) + * @example + * note("c d e f".shuffle(4), "g").sound("piano") + */ +export const shuffle = register('shuffle', (n, pat) => { + return _rearrangeWith(randrun(n), n, pat); +}); + +/** + * @name scramble + * Slices a pattern into the given number of parts, then plays those parts at random. Similar to `shuffle`, + * but parts might be played more than once, or not at all, per cycle. + * @example + * note("c d e f").sound("piano").scramble(4) + * @example + * note("c d e f".scramble(4), "g").sound("piano") + */ +export const scramble = register('scramble', (n, pat) => { + return _rearrangeWith(_irand(n)._segment(n), n, pat); +}); + /** * A continuous pattern of random numbers, between 0 and 1. * diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 798870f03..bc8440570 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -363,6 +363,76 @@ export function objectMap(obj, fn) { } return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)])); } +export function cycleToSeconds(cycle, cps) { + return cycle / cps; +} + +// utility for averaging two clocks together to account for drift +export class ClockCollator { + constructor({ + getTargetClockTime = getUnixTimeSeconds, + weight = 16, + offsetDelta = 0.005, + checkAfterTime = 2, + resetAfterTime = 8, + }) { + this.offsetTime; + this.timeAtPrevOffsetSample; + this.prevOffsetTimes = []; + this.getTargetClockTime = getTargetClockTime; + this.weight = weight; + this.offsetDelta = offsetDelta; + this.checkAfterTime = checkAfterTime; + this.resetAfterTime = resetAfterTime; + this.reset = () => { + this.prevOffsetTimes = []; + this.offsetTime = null; + this.timeAtPrevOffsetSample = null; + }; + } + calculateOffset(currentTime) { + const targetClockTime = this.getTargetClockTime(); + const diffBetweenTimeSamples = targetClockTime - this.timeAtPrevOffsetSample; + const newOffsetTime = targetClockTime - currentTime; + // recalcuate the diff from scratch if the clock has been paused for some time. + if (diffBetweenTimeSamples > this.resetAfterTime) { + this.reset(); + } + + if (this.offsetTime == null) { + this.offsetTime = newOffsetTime; + } + this.prevOffsetTimes.push(newOffsetTime); + if (this.prevOffsetTimes.length > this.weight) { + this.prevOffsetTimes.shift(); + } + + // after X time has passed, the average of the previous weight offset times is calculated and used as a stable reference + // for calculating the timestamp + if (this.timeAtPrevOffsetSample == null || diffBetweenTimeSamples > this.checkAfterTime) { + this.timeAtPrevOffsetSample = targetClockTime; + const rollingOffsetTime = averageArray(this.prevOffsetTimes); + //when the clock offsets surpass the delta, set the new reference time + if (Math.abs(rollingOffsetTime - this.offsetTime) > this.offsetDelta) { + this.offsetTime = rollingOffsetTime; + } + } + + return this.offsetTime; + } + + calculateTimestamp(currentTime, targetTime) { + return this.calculateOffset(currentTime) + targetTime; + } +} + +export function getPerformanceTimeSeconds() { + return performance.now() * 0.001; +} + +function getUnixTimeSeconds() { + return Date.now() * 0.001; +} // Floating point versions, see Fraction for rational versions // // greatest common divisor diff --git a/packages/desktopbridge/oscbridge.mjs b/packages/desktopbridge/oscbridge.mjs index 42a9a6d4a..9bead6d19 100644 --- a/packages/desktopbridge/oscbridge.mjs +++ b/packages/desktopbridge/oscbridge.mjs @@ -1,64 +1,36 @@ -import { parseNumeral, Pattern, averageArray } from '@strudel/core'; +import { Pattern, ClockCollator } from '@strudel/core'; +import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs'; import { Invoke } from './utils.mjs'; -let offsetTime; -let timeAtPrevOffsetSample; -let prevOffsetTimes = []; +const collator = new ClockCollator({}); -Pattern.prototype.osc = function () { - return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => { - hap.ensureObjectValue(); - const cycle = hap.wholeOrPart().begin.valueOf(); - const delta = hap.duration.valueOf(); - 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)); +export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) { + const controls = parseControlsFromHap(hap, cps); + const params = []; + const timestamp = collator.calculateTimestamp(currentTime, targetTime); - const params = []; + Object.keys(controls).forEach((key) => { + const val = controls[key]; + const value = typeof val === 'number' ? val.toString() : val; - const unixTimeSecs = Date.now() / 1000; - const newOffsetTime = unixTimeSecs - currentTime; - if (offsetTime == null) { - offsetTime = newOffsetTime; - } - prevOffsetTimes.push(newOffsetTime); - if (prevOffsetTimes.length > 8) { - prevOffsetTimes.shift(); - } - // every two seconds, the average of the previous 8 offset times is calculated and used as a stable reference - // for calculating the timestamp that will be sent to the backend - if (timeAtPrevOffsetSample == null || unixTimeSecs - timeAtPrevOffsetSample > 2) { - timeAtPrevOffsetSample = unixTimeSecs; - const rollingOffsetTime = averageArray(prevOffsetTimes); - //account for the js clock freezing or resets set the new offset - if (Math.abs(rollingOffsetTime - offsetTime) > 0.01) { - offsetTime = rollingOffsetTime; - } - } - - const timestamp = offsetTime + targetTime; - - Object.keys(controls).forEach((key) => { - const val = controls[key]; - const value = typeof val === 'number' ? val.toString() : val; - - if (value == null) { - return; - } - params.push({ - name: key, - value, - valueisnumber: typeof val === 'number', - }); - }); - - if (params.length === 0) { + if (value == null) { return; } - const message = { target: '/dirt/play', timestamp, params }; - setTimeout(() => { - Invoke('sendosc', { messagesfromjs: [message] }); + params.push({ + name: key, + value, + valueisnumber: typeof val === 'number', }); }); + + if (params.length === 0) { + return; + } + const message = { target: '/dirt/play', timestamp, params }; + setTimeout(() => { + Invoke('sendosc', { messagesfromjs: [message] }); + }); +} +Pattern.prototype.osc = function () { + return this.onTrigger(oscTriggerTauri); }; diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index 422e56da5..4b9ab14e7 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import OSC from 'osc-js'; -import { logger, parseNumeral, Pattern, getEventOffsetMs, isNote, noteToMidi } from '@strudel/core'; +import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core'; let connection; // Promise function connect() { @@ -34,6 +34,44 @@ function connect() { return connection; } +export function parseControlsFromHap(hap, cps) { + hap.ensureObjectValue(); + const cycle = hap.wholeOrPart().begin.valueOf(); + const delta = hap.duration.valueOf(); + const controls = Object.assign({}, { cps, cycle, delta }, hap.value); + // make sure n and note are numbers + controls.n && (controls.n = parseNumeral(controls.n)); + if (typeof controls.note !== 'undefined') { + if (isNote(controls.note)) { + controls.midinote = noteToMidi(controls.note, controls.octave || 3); + } else { + controls.note = parseNumeral(controls.note); + } + } + controls.bank && (controls.s = controls.bank + controls.s); + controls.roomsize && (controls.size = parseNumeral(controls.roomsize)); + // speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel, + // so we need to undo the adjustment before sending the message to superdirt. + controls.unit === 'c' && controls.speed != null && (controls.speed = controls.speed / cps); + const channels = controls.channels; + channels != undefined && (controls.channels = JSON.stringify(channels)); + return controls; +} + +const collator = new ClockCollator({}); + +export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) { + const osc = await connect(); + const controls = parseControlsFromHap(hap, cps); + const keyvals = Object.entries(controls).flat(); + + const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000); + const message = new OSC.Message('/dirt/play', ...keyvals); + const bundle = new OSC.Bundle([message], ts); + bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60 + osc.send(bundle); +} + /** * * Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software. @@ -44,33 +82,5 @@ function connect() { * @returns Pattern */ Pattern.prototype.osc = function () { - return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => { - hap.ensureObjectValue(); - const osc = await connect(); - const cycle = hap.wholeOrPart().begin.valueOf(); - const delta = hap.duration.valueOf(); - const controls = Object.assign({}, { cps, cycle, delta }, hap.value); - // make sure n and note are numbers - controls.n && (controls.n = parseNumeral(controls.n)); - if (typeof controls.note !== 'undefined') { - if (isNote(controls.note)) { - controls.midinote = noteToMidi(controls.note, controls.octave || 3); - } else { - controls.note = parseNumeral(controls.note); - } - } - controls.bank && (controls.s = controls.bank + controls.s); - controls.roomsize && (controls.size = parseNumeral(controls.roomsize)); - const keyvals = Object.entries(controls).flat(); - // time should be audio time of onset - // currentTime should be current time of audio context (slightly before time) - const offset = getEventOffsetMs(targetTime, currentTime); - - // timestamp in milliseconds used to trigger the osc bundle at a precise moment - const ts = Math.floor(Date.now() + offset); - const message = new OSC.Message('/dirt/play', ...keyvals); - const bundle = new OSC.Bundle([message], ts); - bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60 - osc.send(bundle); - }); + return this.onTrigger(oscTrigger); }; diff --git a/packages/osc/superdirtoutput.js b/packages/osc/superdirtoutput.js new file mode 100644 index 000000000..3f48e66bd --- /dev/null +++ b/packages/osc/superdirtoutput.js @@ -0,0 +1,10 @@ +import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs'; +import { isTauri } from '../desktopbridge/utils.mjs'; +import { oscTrigger } from './osc.mjs'; + +const trigger = isTauri() ? oscTriggerTauri : oscTrigger; + +export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => { + const currentTime = performance.now() / 1000; + return trigger(null, hap, currentTime, cps, targetTime); +}; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index dda7072eb..25a15f1b4 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -87,6 +87,10 @@ export const getAudioContext = () => { return audioContext; }; +export function getAudioContextCurrentTime() { + return getAudioContext().currentTime; +} + let workletsLoading; function loadWorklets() { if (!workletsLoading) { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 11b27c7a6..3d48e842a 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -6637,6 +6637,56 @@ exports[`runs examples > example "scope" example index 0 1`] = ` ] `; +exports[`runs examples > example "scramble +Slices a pattern into the given number of parts, then plays those parts at random. Similar to \`shuffle\`, +but parts might be played more than once, or not at all, per cycle." example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:e s:piano ]", + "[ 1/4 → 1/2 | note:d s:piano ]", + "[ 1/2 → 3/4 | note:c s:piano ]", + "[ 3/4 → 1/1 | note:d s:piano ]", + "[ 1/1 → 5/4 | note:e s:piano ]", + "[ 5/4 → 3/2 | note:c s:piano ]", + "[ 3/2 → 7/4 | note:d s:piano ]", + "[ 7/4 → 2/1 | note:e s:piano ]", + "[ 2/1 → 9/4 | note:f s:piano ]", + "[ 9/4 → 5/2 | note:f s:piano ]", + "[ 5/2 → 11/4 | note:c s:piano ]", + "[ 11/4 → 3/1 | note:c s:piano ]", + "[ 3/1 → 13/4 | note:d s:piano ]", + "[ 13/4 → 7/2 | note:d s:piano ]", + "[ 7/2 → 15/4 | note:f s:piano ]", + "[ 15/4 → 4/1 | note:e s:piano ]", +] +`; + +exports[`runs examples > example "scramble +Slices a pattern into the given number of parts, then plays those parts at random. Similar to \`shuffle\`, +but parts might be played more than once, or not at all, per cycle." example index 1 1`] = ` +[ + "[ 0/1 → 1/8 | note:e s:piano ]", + "[ 1/8 → 1/4 | note:d s:piano ]", + "[ 1/4 → 3/8 | note:c s:piano ]", + "[ 3/8 → 1/2 | note:d s:piano ]", + "[ 1/2 → 1/1 | note:g s:piano ]", + "[ 1/1 → 9/8 | note:e s:piano ]", + "[ 9/8 → 5/4 | note:c s:piano ]", + "[ 5/4 → 11/8 | note:d s:piano ]", + "[ 11/8 → 3/2 | note:e s:piano ]", + "[ 3/2 → 2/1 | note:g s:piano ]", + "[ 2/1 → 17/8 | note:f s:piano ]", + "[ 17/8 → 9/4 | note:f s:piano ]", + "[ 9/4 → 19/8 | note:c s:piano ]", + "[ 19/8 → 5/2 | note:c s:piano ]", + "[ 5/2 → 3/1 | note:g s:piano ]", + "[ 3/1 → 25/8 | note:d s:piano ]", + "[ 25/8 → 13/4 | note:d s:piano ]", + "[ 13/4 → 27/8 | note:f s:piano ]", + "[ 27/8 → 7/2 | note:e s:piano ]", + "[ 7/2 → 4/1 | note:g s:piano ]", +] +`; + exports[`runs examples > example "segment" example index 0 1`] = ` [ "[ 0/1 → 1/24 | note:40.25 ]", @@ -6853,6 +6903,56 @@ exports[`runs examples > example "shape" example index 0 1`] = ` ] `; +exports[`runs examples > example "shuffle +Slices a pattern into the given number of parts, then plays those parts in random order. +Each part will be played exactly once per cycle." example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:c s:piano ]", + "[ 1/4 → 1/2 | note:d s:piano ]", + "[ 1/2 → 3/4 | note:e s:piano ]", + "[ 3/4 → 1/1 | note:f s:piano ]", + "[ 1/1 → 5/4 | note:c s:piano ]", + "[ 5/4 → 3/2 | note:d s:piano ]", + "[ 3/2 → 7/4 | note:e s:piano ]", + "[ 7/4 → 2/1 | note:f s:piano ]", + "[ 2/1 → 9/4 | note:c s:piano ]", + "[ 9/4 → 5/2 | note:d s:piano ]", + "[ 5/2 → 11/4 | note:e s:piano ]", + "[ 11/4 → 3/1 | note:f s:piano ]", + "[ 3/1 → 13/4 | note:c s:piano ]", + "[ 13/4 → 7/2 | note:d s:piano ]", + "[ 7/2 → 15/4 | note:e s:piano ]", + "[ 15/4 → 4/1 | note:f s:piano ]", +] +`; + +exports[`runs examples > example "shuffle +Slices a pattern into the given number of parts, then plays those parts in random order. +Each part will be played exactly once per cycle." example index 1 1`] = ` +[ + "[ 0/1 → 1/8 | note:c s:piano ]", + "[ 1/8 → 1/4 | note:d s:piano ]", + "[ 1/4 → 3/8 | note:e s:piano ]", + "[ 3/8 → 1/2 | note:f s:piano ]", + "[ 1/2 → 1/1 | note:g s:piano ]", + "[ 1/1 → 9/8 | note:c s:piano ]", + "[ 9/8 → 5/4 | note:d s:piano ]", + "[ 5/4 → 11/8 | note:e s:piano ]", + "[ 11/8 → 3/2 | note:f s:piano ]", + "[ 3/2 → 2/1 | note:g s:piano ]", + "[ 2/1 → 17/8 | note:c s:piano ]", + "[ 17/8 → 9/4 | note:d s:piano ]", + "[ 9/4 → 19/8 | note:e s:piano ]", + "[ 19/8 → 5/2 | note:f s:piano ]", + "[ 5/2 → 3/1 | note:g s:piano ]", + "[ 3/1 → 25/8 | note:c s:piano ]", + "[ 25/8 → 13/4 | note:d s:piano ]", + "[ 13/4 → 27/8 | note:e s:piano ]", + "[ 27/8 → 7/2 | note:f s:piano ]", + "[ 7/2 → 4/1 | note:g s:piano ]", +] +`; + exports[`runs examples > example "silence" example index 0 1`] = `[]`; exports[`runs examples > example "sine" example index 0 1`] = ` diff --git a/website/src/pages/learn/input-output.mdx b/website/src/pages/learn/input-output.mdx index 0151e6ebc..93cbfdc56 100644 --- a/website/src/pages/learn/input-output.mdx +++ b/website/src/pages/learn/input-output.mdx @@ -45,7 +45,7 @@ But you can also control cc messages separately like this: $: ccv(sine.segment(16).slow(4)).ccn(74).midi()`} /> -# SuperDirt API +# OSC/SuperDirt API In mainline tidal, the actual sound is generated via [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider. Strudel also supports using [SuperDirt](https://github.com/musikinformatik/SuperDirt/) as a backend, although it requires some developer tooling to run. @@ -73,16 +73,14 @@ Now you're all set! If you now hear sound, congratulations! If not, you can get help on the [#strudel channel in the TidalCycles discord](https://discord.com/invite/HGEdXmRkzT). +Note: if you have the 'Audio Engine Target' in settings set to 'OSC', you do not need to add .osc() to the end of your pattern. + ### Pattern.osc ## SuperDirt Params -The following functions can be used with [SuperDirt](https://github.com/musikinformatik/SuperDirt/): - -`s n note freq channel orbit cutoff resonance hcutoff hresonance bandf bandq djf vowel cut begin end loop fadeTime speed unitA gain amp accelerate crush coarse delay lock leslie lrate lsize pan panspan pansplay room size dry shape squiz waveloss attack decay octave detune tremolodepth` - Please refer to [Tidal Docs](https://tidalcycles.org/) for more info.
diff --git a/website/src/repl/components/panel/AudioEngineTargetSelector.jsx b/website/src/repl/components/panel/AudioEngineTargetSelector.jsx new file mode 100644 index 000000000..ecf5d8175 --- /dev/null +++ b/website/src/repl/components/panel/AudioEngineTargetSelector.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { audioEngineTargets } from '../../../settings.mjs'; +import { SelectInput } from './SelectInput'; + +// Allows the user to select an audio interface for Strudel to play through +export function AudioEngineTargetSelector({ target, onChange, isDisabled }) { + const onTargetChange = (target) => { + onChange(target); + }; + const options = new Map([ + [audioEngineTargets.webaudio, audioEngineTargets.webaudio], + [audioEngineTargets.osc, audioEngineTargets.osc], + ]); + return ( +
+ + {target === audioEngineTargets.osc && ( +
+

+ ⚠ All events routed to OSC, audio is silenced! See{' '} + + Docs + +

+
+ )} +
+ ); +} diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index e2385dad6..bbaffbb27 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -3,6 +3,8 @@ import { themes } from '@strudel/codemirror'; import { isUdels, setGlobalAudioVolume } from '../../util.mjs'; import { ButtonGroup } from './Forms.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; +import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; +import { confirmDialog } from '../../util.mjs'; function Checkbox({ label, value, onChange, disabled = false }) { return ( @@ -78,6 +80,8 @@ const fontFamilyOptions = { galactico: 'galactico', }; +const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?'; + export function SettingsTab({ started }) { const { theme, @@ -96,20 +100,42 @@ export function SettingsTab({ started }) { fontFamily, panelPosition, audioDeviceName, + audioEngineTarget, audioVolume, } = useSettings(); const shouldAlwaysSync = isUdels(); + const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; return (
- {AudioContext.prototype.setSinkId != null && ( + {canChangeAudioDevice && ( settingsMap.setKey('audioDeviceName', audioDeviceName)} + onChange={(audioDeviceName) => { + confirmDialog(RELOAD_MSG).then((r) => { + if (r == true) { + settingsMap.setKey('audioDeviceName', audioDeviceName); + return window.location.reload(); + } + }); + }} /> )} + + { + confirmDialog(RELOAD_MSG).then((r) => { + if (r == true) { + settingsMap.setKey('audioEngineTarget', target); + return window.location.reload(); + } + }); + }} + /> + { - if (confirm('Changing this setting requires the window to reload itself. OK?')) { - settingsMap.setKey('isSyncEnabled', cbEvent.target.checked); - window.location.reload(); - } + const newVal = cbEvent.target.checked; + confirmDialog(RELOAD_MSG).then((r) => { + if (r) { + settingsMap.setKey('isSyncEnabled', newVal); + window.location.reload(); + } + }); }} disabled={shouldAlwaysSync} value={isSyncEnabled} @@ -220,9 +249,11 @@ export function SettingsTab({ started }) {