diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 66f8d0db..bc844057 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -370,7 +370,7 @@ export function cycleToSeconds(cycle, cps) { // utility for averaging two clocks together to account for drift export class ClockCollator { constructor({ - getTargetClockTime = () => Date.now() * 0.001, + getTargetClockTime = getUnixTimeSeconds, weight = 16, offsetDelta = 0.005, checkAfterTime = 2, @@ -426,6 +426,14 @@ export class ClockCollator { } } +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 // export const gcd = function (x, y, ...z) { diff --git a/packages/desktopbridge/oscbridge.mjs b/packages/desktopbridge/oscbridge.mjs index 42a9a6d4..9bead6d1 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 422e56da..ab58e32c 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,41 @@ 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)); + 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 +79,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 00000000..3f48e66b --- /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 01569788..50c93322 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/website/src/pages/learn/input-output.mdx b/website/src/pages/learn/input-output.mdx index 0151e6eb..93cbfdc5 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 00000000..ecf5d817 --- /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 e1d047ea..768af4ff 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 } 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,19 +100,41 @@ export function SettingsTab({ started }) { fontFamily, panelPosition, audioDeviceName, + audioEngineTarget, } = 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(); + } + }); + }} + /> + settingsMap.setKey('theme', theme)} /> @@ -193,10 +219,13 @@ export function SettingsTab({ started }) { { - 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} @@ -207,9 +236,11 @@ export function SettingsTab({ started }) {