diff --git a/jsdoc.config.json b/jsdoc.config.json index 1e287280..ca9c3d81 100644 --- a/jsdoc.config.json +++ b/jsdoc.config.json @@ -1,7 +1,7 @@ { "source": { "includePattern": ".+\\.(js(doc|x)?|mjs)$", - "excludePattern": "node_modules|shift-parser|shift-reducer|shift-traverser" + "excludePattern": "node_modules|shift-parser|shift-reducer|shift-traverser|dist" }, "plugins": ["plugins/markdown"], "opts": { diff --git a/package-lock.json b/package-lock.json index b3c2a24f..1cca99d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12495,10 +12495,10 @@ }, "packages/midi": { "name": "@strudel.cycles/midi", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", "tone": "^14.7.77", "webmidi": "^3.0.21" } @@ -12519,12 +12519,12 @@ }, "packages/mini": { "name": "@strudel.cycles/mini", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@strudel.cycles/core": "^0.3.2", "@strudel.cycles/eval": "^0.3.2", - "@strudel.cycles/tone": "^0.3.2" + "@strudel.cycles/tone": "^0.3.3" }, "devDependencies": { "peggy": "^2.0.1" @@ -12540,13 +12540,14 @@ }, "packages/react": { "name": "@strudel.cycles/react", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@codemirror/lang-javascript": "^6.1.1", "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/eval": "^0.3.2", - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", + "@strudel.cycles/transpiler": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "@uiw/codemirror-themes": "^4.12.4", "@uiw/react-codemirror": "^4.12.4", "react-hook-inview": "^4.5.0" @@ -12621,11 +12622,11 @@ }, "packages/soundfonts": { "name": "@strudel.cycles/soundfonts", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/webaudio": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "sfumato": "^0.1.2", "soundfont2": "^0.4.0" }, @@ -12653,7 +12654,7 @@ }, "packages/tonal": { "name": "@strudel.cycles/tonal", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@strudel.cycles/core": "^0.3.2", @@ -12678,7 +12679,7 @@ }, "packages/tone": { "name": "@strudel.cycles/tone", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@strudel.cycles/core": "^0.3.2", @@ -12709,7 +12710,7 @@ }, "packages/webaudio": { "name": "@strudel.cycles/webaudio", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-or-later", "dependencies": { "@strudel.cycles/core": "^0.3.2" @@ -14427,7 +14428,7 @@ "@strudel.cycles/midi": { "version": "file:packages/midi", "requires": { - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", "tone": "^14.7.77", "webmidi": "^3.0.21" }, @@ -14448,7 +14449,7 @@ "requires": { "@strudel.cycles/core": "^0.3.2", "@strudel.cycles/eval": "^0.3.2", - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", "peggy": "^2.0.1" } }, @@ -14463,8 +14464,9 @@ "requires": { "@codemirror/lang-javascript": "^6.1.1", "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/eval": "^0.3.2", - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", + "@strudel.cycles/transpiler": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", "@uiw/codemirror-themes": "^4.12.4", @@ -14520,7 +14522,7 @@ "version": "file:packages/soundfonts", "requires": { "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/webaudio": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "node-fetch": "^3.2.6", "sfumato": "^0.1.2", "soundfont2": "^0.4.0" @@ -20343,8 +20345,9 @@ "requires": { "@codemirror/lang-javascript": "^6.1.1", "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/eval": "^0.3.2", - "@strudel.cycles/tone": "^0.3.2", + "@strudel.cycles/tone": "^0.3.3", + "@strudel.cycles/transpiler": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", "@uiw/codemirror-themes": "^4.12.4", diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index 66105d0a..562a91b0 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import createClock from './zyklus.mjs'; +import { logger } from './logger.mjs'; export class Cyclist { worker; @@ -13,8 +14,10 @@ export class Cyclist { cps = 1; // TODO getTime; phase = 0; - constructor({ interval, onTrigger, onError, getTime, latency = 0.1 }) { + constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1 }) { this.getTime = getTime; + this.onToggle = onToggle; + this.latency = latency; const round = (x) => Math.round(x * 1000) / 1000; this.clock = createClock( getTime, @@ -28,9 +31,7 @@ export class Cyclist { const time = getTime(); try { const haps = this.pattern.queryArc(begin, end); // get Haps - // console.log('haps', haps.map((hap) => hap.value.n).join(' ')); haps.forEach((hap) => { - // console.log('hap', hap.value.n, hap.part.begin); if (hap.part.begin.equals(hap.whole.begin)) { const deadline = hap.whole.begin + this.origin - time + latency; const duration = hap.duration * 1; @@ -38,7 +39,7 @@ export class Cyclist { } }); } catch (e) { - console.warn('scheduler error', e); + logger(`[cyclist] error: ${e.message}`); onError?.(e); } }, // called slightly before each cycle @@ -46,24 +47,29 @@ export class Cyclist { ); } getPhase() { - return this.phase; + return this.getTime() - this.origin - this.latency; + } + setStarted(v) { + this.started = v; + this.onToggle?.(v); } start() { if (!this.pattern) { throw new Error('Scheduler: no pattern set! call .setPattern first.'); } + logger('[cyclist] start'); this.clock.start(); - this.started = true; + this.setStarted(true); } pause() { - this.clock.stop(); - delete this.origin; - this.started = false; + logger('[cyclist] pause'); + this.clock.pause(); + this.setStarted(false); } stop() { - delete this.origin; + logger('[cyclist] stop'); this.clock.stop(); - this.started = false; + this.setStarted(false); } setPattern(pat, autostart = false) { this.pattern = pat; diff --git a/packages/tone/draw.mjs b/packages/core/draw.mjs similarity index 91% rename from packages/tone/draw.mjs rename to packages/core/draw.mjs index 58bc4399..9ef34e3a 100644 --- a/packages/tone/draw.mjs +++ b/packages/core/draw.mjs @@ -4,8 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Tone } from './tone.mjs'; -import { Pattern } from '@strudel.cycles/core'; +import { Pattern, getTime } from './index.mjs'; export const getDrawContext = (id = 'test-canvas') => { let canvas = document.querySelector('#' + id); @@ -28,7 +27,7 @@ Pattern.prototype.draw = function (callback, { from, to, onQuery }) { let cycle, events = []; const animate = (time) => { - const t = Tone.getTransport().seconds; + const t = getTime(); if (from !== undefined && to !== undefined) { const currentCycle = Math.floor(t); if (cycle !== currentCycle) { @@ -50,9 +49,9 @@ Pattern.prototype.draw = function (callback, { from, to, onQuery }) { return this; }; -export const cleanupDraw = () => { +export const cleanupDraw = (clearScreen = true) => { const ctx = getDrawContext(); - ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + clearScreen && ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); if (window.strudelAnimation) { cancelAnimationFrame(window.strudelAnimation); } diff --git a/packages/core/evaluate.mjs b/packages/core/evaluate.mjs index eea5a90b..35c47e42 100644 --- a/packages/core/evaluate.mjs +++ b/packages/core/evaluate.mjs @@ -4,9 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import * as strudel from '@strudel.cycles/core'; - -const { isPattern, Pattern } = strudel; +import { isPattern, Pattern } from './index.mjs'; let scoped = false; export const evalScope = async (...args) => { diff --git a/packages/core/fraction.mjs b/packages/core/fraction.mjs index 5204395d..d28d051d 100644 --- a/packages/core/fraction.mjs +++ b/packages/core/fraction.mjs @@ -55,7 +55,8 @@ Fraction.prototype.min = function (other) { return this.lt(other) ? this : other; }; -Fraction.prototype.show = function () { +Fraction.prototype.show = function (/* excludeWhole = false */) { + // return this.toFraction(excludeWhole); return this.s * this.n + '/' + this.d; }; diff --git a/packages/core/hap.mjs b/packages/core/hap.mjs index a0107d87..ba95d132 100644 --- a/packages/core/hap.mjs +++ b/packages/core/hap.mjs @@ -85,9 +85,13 @@ export class Hap { ); } - showWhole() { + showWhole(compact = false) { return `${this.whole == undefined ? '~' : this.whole.show()}: ${ - typeof this.value === 'object' ? JSON.stringify(this.value) : this.value + typeof this.value === 'object' + ? compact + ? JSON.stringify(this.value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ') + : JSON.stringify(this.value) + : this.value }`; } diff --git a/packages/core/index.mjs b/packages/core/index.mjs index 33a95a77..b9fd7061 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -7,6 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th import controls from './controls.mjs'; export * from './euclid.mjs'; import Fraction from './fraction.mjs'; +import { logger } from './logger.mjs'; export { Fraction, controls }; export * from './hap.mjs'; export * from './pattern.mjs'; @@ -17,15 +18,17 @@ export * from './util.mjs'; export * from './speak.mjs'; export * from './evaluate.mjs'; export * from './repl.mjs'; +export * from './logger.mjs'; +export * from './time.mjs'; +export * from './draw.mjs'; +export * from './pianoroll.mjs'; +export * from './ui.mjs'; export { default as drawLine } from './drawLine.mjs'; export { default as gist } from './gist.js'; // below won't work with runtime.mjs (json import fails) /* import * as p from './package.json'; export const version = p.version; */ -console.log( - '%c // 🌀 @strudel.cycles/core loaded 🌀', // keep "//" for runnable snapshot source.. - 'background-color: black;color:white;padding:4px;border-radius:15px', -); +logger('🌀 @strudel.cycles/core loaded 🌀'); if (globalThis._strudelLoaded) { console.warn( `@strudel.cycles/core was loaded more than once... diff --git a/packages/core/logger.mjs b/packages/core/logger.mjs new file mode 100644 index 00000000..caca9d2b --- /dev/null +++ b/packages/core/logger.mjs @@ -0,0 +1,18 @@ +export const logKey = 'strudel.log'; + +export function logger(message, type, data = {}) { + console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px'); + if (typeof CustomEvent !== 'undefined') { + document.dispatchEvent( + new CustomEvent(logKey, { + detail: { + message, + type, + data, + }, + }), + ); + } +} + +logger.key = logKey; diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 3db6d720..c3211ee7 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -12,6 +12,7 @@ import { unionWithObj } from './value.mjs'; import { compose, removeUndefineds, flatten, id, listRange, curry, mod, numeralArgs, parseNumeral } from './util.mjs'; import drawLine from './drawLine.mjs'; +import { logger } from './logger.mjs'; let stringParser; // parser is expected to turn a string into a pattern @@ -1328,22 +1329,25 @@ export class Pattern { .unit('c') .slow(factor); } - onTrigger(onTrigger) { - return this._withHap((hap) => hap.setContext({ ...hap.context, onTrigger })); - } - log(func = id) { + onTrigger(onTrigger, dominant = true) { return this._withHap((hap) => hap.setContext({ ...hap.context, onTrigger: (...args) => { - if (hap.context.onTrigger) { + if (!dominant && hap.context.onTrigger) { hap.context.onTrigger(...args); } - console.log(func(...args)); + onTrigger(...args); }, + // we need this to know later if the default trigger should still fire + dominantTrigger: dominant, }), ); } + + log(func = (_, hap) => `[hap] ${hap.showWhole(true)}`) { + return this.onTrigger((...args) => logger(func(...args)), false); + } logValues(func = id) { return this.log((_, hap) => func(hap.value)); } diff --git a/packages/tone/pianoroll.mjs b/packages/core/pianoroll.mjs similarity index 99% rename from packages/tone/pianoroll.mjs rename to packages/core/pianoroll.mjs index 7f46785d..f9c198e9 100644 --- a/packages/tone/pianoroll.mjs +++ b/packages/core/pianoroll.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Pattern } from '@strudel.cycles/core'; +import { Pattern } from './index.mjs'; const scale = (normalized, min, max) => normalized * (max - min) + min; const getValue = (e) => { diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 69a9255d..7cb4b522 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -1,23 +1,59 @@ import { Cyclist } from './cyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; +import { logger } from './logger.mjs'; +import { setTime } from './time.mjs'; -export function repl({ interval, defaultOutput, onSchedulerError, onEvalError, onEval, getTime, transpiler }) { - const scheduler = new Cyclist({ interval, onTrigger: defaultOutput, onError: onSchedulerError, getTime }); - const evaluate = async (code) => { +export function repl({ + interval, + defaultOutput, + onSchedulerError, + onEvalError, + beforeEval, + afterEval, + getTime, + transpiler, + onToggle, +}) { + const scheduler = new Cyclist({ + interval, + onTrigger: async (hap, deadline, duration) => { + try { + if (!hap.context.onTrigger || !hap.context.dominantTrigger) { + await defaultOutput(hap, deadline, duration); + } + if (hap.context.onTrigger) { + const cps = 1; + // call signature of output / onTrigger is different... + await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps); + } + } catch (err) { + logger(`[cyclist] error: ${err.message}`, 'error'); + } + }, + onError: onSchedulerError, + getTime, + onToggle, + }); + setTime(() => scheduler.getPhase()); // TODO: refactor? + const evaluate = async (code, autostart = true) => { if (!code) { throw new Error('no code to evaluate'); } try { + beforeEval({ code }); const { pattern } = await _evaluate(code, transpiler); - scheduler.setPattern(pattern, true); - onEval?.({ - pattern, - code, - }); + logger(`[eval] code updated`); + scheduler.setPattern(pattern, autostart); + afterEval({ code, pattern }); + return pattern; } catch (err) { - console.warn(`eval error: ${err.message}`); + // console.warn(`[repl] eval error: ${err.message}`); + logger(`[eval] error: ${err.message}`, 'error'); onEvalError?.(err); } }; - return { scheduler, evaluate }; + const stop = () => scheduler.stop(); + const start = () => scheduler.start(); + const pause = () => scheduler.pause(); + return { scheduler, evaluate, start, stop, pause }; } diff --git a/packages/core/speak.mjs b/packages/core/speak.mjs index caad36be..2e9ba80a 100644 --- a/packages/core/speak.mjs +++ b/packages/core/speak.mjs @@ -32,11 +32,8 @@ function speak(words, lang, voice) { } Pattern.prototype._speak = function (lang, voice) { - return this._withHap((hap) => { - const onTrigger = (time, hap) => { - speak(hap.value, lang, voice); - }; - return hap.setContext({ ...hap.context, onTrigger }); + return this.onTrigger((_, hap) => { + speak(hap.value, lang, voice); }); }; diff --git a/packages/core/time.mjs b/packages/core/time.mjs new file mode 100644 index 00000000..80daaf53 --- /dev/null +++ b/packages/core/time.mjs @@ -0,0 +1,11 @@ +let time; +export function getTime() { + if (!time) { + throw new Error('no time set! use setTime to define a time source'); + } + return time(); +} + +export function setTime(func) { + time = func; +} diff --git a/packages/tone/ui.mjs b/packages/core/ui.mjs similarity index 87% rename from packages/tone/ui.mjs rename to packages/core/ui.mjs index ace29ac1..68a4a6cb 100644 --- a/packages/tone/ui.mjs +++ b/packages/core/ui.mjs @@ -4,19 +4,14 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Tone } from './tone.mjs'; - -export const hideHeader = () => { - document.getElementById('header').style = 'display:none'; -}; +import { getTime } from './time.mjs'; function frame(callback) { if (window.strudelAnimation) { cancelAnimationFrame(window.strudelAnimation); } const animate = (animationTime) => { - const toneTime = Tone.getTransport().seconds; - callback(animationTime, toneTime); + callback(animationTime, getTime()); window.strudelAnimation = requestAnimationFrame(animate); }; requestAnimationFrame(animate); @@ -51,6 +46,7 @@ export const cleanupUi = () => { const container = document.getElementById('code'); if (container) { container.style = ''; - container.className = 'grow relative'; // has to match App.tsx + // TODO: find a way to remove that duplication.. + container.className = 'grow flex text-gray-100 relative overflow-auto cursor-text pb-0'; // has to match App.tsx } }; diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 85b11b90..7b8a72d1 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -55,9 +55,13 @@ export const midi2note = (n) => { export const mod = (n, m) => ((n % m) + m) % m; export const getPlayableNoteValue = (hap) => { - let { value: note, context } = hap; + let { value, context } = hap; + let note = value; if (typeof note === 'object' && !Array.isArray(note)) { note = note.note || note.n || note.value; + if (note === undefined) { + throw new Error(`cannot find a playable note for ${JSON.stringify(value)}`); + } } // if value is number => interpret as midi number as long as its not marked as frequency if (typeof note === 'number' && context.type !== 'frequency') { diff --git a/packages/core/zyklus.mjs b/packages/core/zyklus.mjs index 31a9533f..3d45b741 100644 --- a/packages/core/zyklus.mjs +++ b/packages/core/zyklus.mjs @@ -31,10 +31,11 @@ function createClock( }; let intervalID; const start = () => { + clear(); // just in case start was called more than once onTick(); intervalID = setInterval(onTick, interval * 1000); }; - const clear = () => clearInterval(intervalID); + const clear = () => intervalID !== undefined && clearInterval(intervalID); const pause = () => clear(); const stop = () => { tick = 0; diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 41247dbf..c59757fb 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -4,14 +4,19 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { isNote } from 'tone'; import * as _WebMidi from 'webmidi'; -import { Pattern, isPattern } from '@strudel.cycles/core'; -import { Tone } from '@strudel.cycles/tone'; +import { Pattern, isPattern, isNote, getPlayableNoteValue, logger } from '@strudel.cycles/core'; +import { getAudioContext } from '@strudel.cycles/webaudio'; + // if you use WebMidi from outside of this package, make sure to import that instance: export const { WebMidi } = _WebMidi; -export function enableWebMidi() { +export function enableWebMidi(options = {}) { + const { onReady, onConnected, onDisconnected } = options; + + if (typeof navigator.requestMIDIAccess !== 'function') { + throw new Error('Your Browser does not support WebMIDI.'); + } return new Promise((resolve, reject) => { if (WebMidi.enabled) { // if already enabled, just resolve WebMidi @@ -22,6 +27,14 @@ export function enableWebMidi() { if (err) { reject(err); } + WebMidi.addListener('connected', (e) => { + onConnected?.(WebMidi); + }); + // Reacting when a device becomes unavailable + WebMidi.addListener('disconnected', (e) => { + onDisconnected?.(WebMidi, e); + }); + onReady?.(WebMidi); resolve(WebMidi); }); }); @@ -30,7 +43,21 @@ export function enableWebMidi() { const outputByName = (name) => WebMidi.getOutputByName(name); // Pattern.prototype.midi = function (output: string | number, channel = 1) { -Pattern.prototype.midi = function (output, channel = 1) { +Pattern.prototype.midi = async function (output, channel = 1) { + await enableWebMidi({ + onConnected: ({ outputs }) => + logger(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`), + onDisconnected: ({ outputs }) => + logger(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`), + onReady: ({ outputs }) => { + const chosenOutput = output ?? outputs[0]; + const otherOutputs = outputs + .filter((o) => o.name !== chosenOutput.name) + .map((o) => `'${o.name}'`) + .join(' | '); + logger(`Midi connected! Using "${chosenOutput.name}". ${otherOutputs ? `Also available: ${otherOutputs}` : ''}`); + }, + }); if (isPattern(output?.constructor?.name)) { throw new Error( `.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${ @@ -38,46 +65,42 @@ Pattern.prototype.midi = function (output, channel = 1) { }')`, ); } - return this._withHap((hap) => { - // const onTrigger = (time: number, hap: any) => { - const onTrigger = (time, hap) => { - let note = hap.value; - const velocity = hap.context?.velocity ?? 0.9; - if (!isNote(note)) { - throw new Error('not a note: ' + note); - } - if (!WebMidi.enabled) { - throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`); - } - if (!WebMidi.outputs.length) { - throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); - } - let device; - if (typeof output === 'number') { - device = WebMidi.outputs[output]; - } else if (typeof output === 'string') { - device = outputByName(output); - } else { - device = WebMidi.outputs[0]; - } - if (!device) { - throw new Error( - `🔌 MIDI device '${output ? output : ''}' not found. Use one of ${WebMidi.outputs - .map((o) => `'${o.name}'`) - .join(' | ')}`, - ); - } - // console.log('midi', value, output); - const timingOffset = WebMidi.time - Tone.getContext().currentTime * 1000; - time = time * 1000 + timingOffset; - // const inMs = '+' + (time - Tone.getContext().currentTime) * 1000; - // await enableWebMidi() - device.playNote(note, channel, { - time, - duration: hap.duration.valueOf() * 1000 - 5, - velocity, - }); - }; - return hap.setContext({ ...hap.context, onTrigger }); + return this.onTrigger((time, hap) => { + let note = getPlayableNoteValue(hap); + const velocity = hap.context?.velocity ?? 0.9; + if (!isNote(note)) { + throw new Error('not a note: ' + note); + } + if (!WebMidi.enabled) { + throw new Error(`🎹 WebMidi is not enabled. Supported Browsers: https://caniuse.com/?search=webmidi`); + } + if (!WebMidi.outputs.length) { + throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`); + } + let device; + if (typeof output === 'number') { + device = WebMidi.outputs[output]; + } else if (typeof output === 'string') { + device = outputByName(output); + } else { + device = WebMidi.outputs[0]; + } + if (!device) { + throw new Error( + `🔌 MIDI device '${output ? output : ''}' not found. Use one of ${WebMidi.outputs + .map((o) => `'${o.name}'`) + .join(' | ')}`, + ); + } + // console.log('midi', value, output); + const timingOffset = WebMidi.time - getAudioContext().currentTime * 1000; + time = time * 1000 + timingOffset; + // const inMs = '+' + (time - Tone.getContext().currentTime) * 1000; + // await enableWebMidi() + device.playNote(note, channel, { + time, + duration: hap.duration.valueOf() * 1000 - 5, + attack: velocity, + }); }); }; diff --git a/packages/mini/krill-parser.js b/packages/mini/krill-parser.js index cdfd4ae4..c4a5d09f 100644 --- a/packages/mini/krill-parser.js +++ b/packages/mini/krill-parser.js @@ -32,7 +32,7 @@ function peg$padEnd(str, targetLength, padString) { } peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; + var str = "peg error: " + this.message; if (this.location) { var src = null; var k; diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index 4e1011b1..15909203 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -5,10 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th */ import OSC from 'osc-js'; -import { parseNumeral, Pattern } from '@strudel.cycles/core'; +import { logger, parseNumeral, Pattern } from '@strudel.cycles/core'; + +let connection; // Promise +function connect() { + if (!connection) { + // make sure this runs only once + connection = new Promise((resolve, reject) => { + const osc = new OSC(); + osc.open(); + osc.on('open', () => { + const url = osc.options?.plugin?.socket?.url; + logger(`[osc] connected${url ? ` to ${url}` : ''}`); + resolve(osc); + }); + osc.on('close', () => { + connection = undefined; // allows new connection afterwards + console.log('[osc] disconnected'); + reject('OSC connection closed'); + }); + osc.on('error', (err) => reject(err)); + }).catch((err) => { + connection = undefined; + throw new Error('Could not connect to OSC server. Is it running?'); + }); + } + return connection; +} -const comm = new OSC(); -comm.open(); const latency = 0.1; let startedAt = -1; @@ -20,28 +44,25 @@ let startedAt = -1; * @memberof Pattern * @returns Pattern */ -Pattern.prototype.osc = function () { - return this._withHap((hap) => { - const onTrigger = (time, hap, currentTime, cps) => { - const cycle = hap.wholeOrPart().begin.valueOf(); - const delta = hap.duration.valueOf(); - // time should be audio time of onset - // currentTime should be current time of audio context (slightly before time) - if (startedAt < 0) { - startedAt = Date.now() - currentTime * 1000; - } - 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)); - - const keyvals = Object.entries(controls).flat(); - const ts = Math.floor(startedAt + (time + latency) * 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 - comm.send(bundle); - }; - return hap.setContext({ ...hap.context, onTrigger }); +Pattern.prototype.osc = async function () { + const osc = await connect(); + return this.onTrigger((time, hap, currentTime, cps = 1) => { + const cycle = hap.wholeOrPart().begin.valueOf(); + const delta = hap.duration.valueOf(); + // time should be audio time of onset + // currentTime should be current time of audio context (slightly before time) + if (startedAt < 0) { + startedAt = Date.now() - currentTime * 1000; + } + 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)); + const keyvals = Object.entries(controls).flat(); + const ts = Math.floor(startedAt + (time + latency) * 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); }); }; diff --git a/packages/react/dist/index.cjs.js b/packages/react/dist/index.cjs.js index f0471083..32711cd0 100644 --- a/packages/react/dist/index.cjs.js +++ b/packages/react/dist/index.cjs.js @@ -1,3 +1 @@ -"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const t=require("react"),Z=require("@uiw/react-codemirror"),S=require("@codemirror/view"),A=require("@codemirror/state"),ee=require("@codemirror/lang-javascript"),p=require("@lezer/highlight"),te=require("@uiw/codemirror-themes"),se=require("react-hook-inview"),B=require("@strudel.cycles/eval"),C=require("@strudel.cycles/tone"),z=require("@strudel.cycles/core"),oe=require("@strudel.cycles/webaudio"),T=require("@strudel.cycles/midi"),j=e=>e&&typeof e=="object"&&"default"in e?e:{default:e},y=j(t),re=j(Z),ne=te.createTheme({theme:"dark",settings:{background:"#222",foreground:"#75baff",caret:"#ffcc00",selection:"rgba(128, 203, 196, 0.5)",selectionMatch:"#036dd626",lineHighlight:"#8a91991a",gutterBackground:"transparent",gutterForeground:"#676e95"},styles:[{tag:p.tags.keyword,color:"#c792ea"},{tag:p.tags.operator,color:"#89ddff"},{tag:p.tags.special(p.tags.variableName),color:"#eeffff"},{tag:p.tags.typeName,color:"#f07178"},{tag:p.tags.atom,color:"#f78c6c"},{tag:p.tags.number,color:"#ff5370"},{tag:p.tags.definition(p.tags.variableName),color:"#82aaff"},{tag:p.tags.string,color:"#c3e88d"},{tag:p.tags.special(p.tags.string),color:"#f07178"},{tag:p.tags.comment,color:"#7d8799"},{tag:p.tags.variableName,color:"#f07178"},{tag:p.tags.tagName,color:"#ff5370"},{tag:p.tags.bracket,color:"#a2a1a4"},{tag:p.tags.meta,color:"#ffcb6b"},{tag:p.tags.attributeName,color:"#c792ea"},{tag:p.tags.propertyName,color:"#c792ea"},{tag:p.tags.className,color:"#decb6b"},{tag:p.tags.invalid,color:"#ffffff"}]});const W=A.StateEffect.define(),ae=A.StateField.define({create(){return S.Decoration.none},update(e,o){try{for(let r of o.effects)if(r.is(W))if(r.value){const n=S.Decoration.mark({attributes:{style:"background-color: #FFCA2880"}});e=S.Decoration.set([n.range(0,o.newDoc.length)])}else e=S.Decoration.set([]);return e}catch(r){return console.warn("flash error",r),e}},provide:e=>S.EditorView.decorations.from(e)}),$=e=>{e.dispatch({effects:W.of(!0)}),setTimeout(()=>{e.dispatch({effects:W.of(!1)})},200)},P=A.StateEffect.define(),ce=A.StateField.define({create(){return S.Decoration.none},update(e,o){try{for(let r of o.effects)if(r.is(P)){const n=r.value.map(a=>(a.context.locations||[]).map(({start:l,end:c})=>{const s=a.context.color||"#FFCA28";let u=o.newDoc.line(l.line).from+l.column,i=o.newDoc.line(c.line).from+c.column;const g=o.newDoc.length;return u>g||i>g?void 0:S.Decoration.mark({attributes:{style:`outline: 1.5px solid ${s};`}}).range(u,i)})).flat().filter(Boolean)||[];e=S.Decoration.set(n,!0)}return e}catch{return S.Decoration.set([])}},provide:e=>S.EditorView.decorations.from(e)}),ie=[ee.javascript(),ne,ce,ae];function K({value:e,onChange:o,onViewChanged:r,onSelectionChange:n,options:a,editorDidMount:l}){const c=t.useCallback(i=>{o?.(i)},[o]),s=t.useCallback(i=>{r?.(i)},[r]),u=t.useCallback(i=>{i.selectionSet&&n&&n?.(i.state.selection)},[n]);return y.default.createElement(y.default.Fragment,null,y.default.createElement(re.default,{value:e,onChange:c,onCreateEditor:s,onUpdate:u,extensions:ie}))}function U(e){const{onEvent:o,onQuery:r,onSchedule:n,ready:a=!0,onDraw:l}=e,[c,s]=t.useState(!1),u=1,i=()=>Math.floor(C.Tone.getTransport().seconds/u),g=(h=i())=>{const M=new z.TimeSpan(h,h+1),v=r?.(new z.State(M))||[];n?.(v,h);const m=M.begin.valueOf();C.Tone.getTransport().cancel(m);const D=(h+1)*u-.5,_=Math.max(C.Tone.getTransport().seconds,D)+.1;C.Tone.getTransport().schedule(()=>{g(h+1)},_),v?.filter(E=>E.part.begin.equals(E.whole?.begin)).forEach(E=>{C.Tone.getTransport().schedule(x=>{o(x,E,C.Tone.getContext().currentTime),C.Tone.Draw.schedule(()=>{l?.(x,E)},x)},E.part.begin.valueOf())})};t.useEffect(()=>{a&&g()},[o,n,r,l,a]);const b=async()=>{s(!0),await C.Tone.start(),C.Tone.getTransport().start("+0.1")},f=()=>{C.Tone.getTransport().pause(),s(!1)};return{start:b,stop:f,onEvent:o,started:c,setStarted:s,toggle:()=>c?f():b(),query:g,activeCycle:i}}function Q(e){return t.useEffect(()=>(window.addEventListener("message",e),()=>window.removeEventListener("message",e)),[e]),t.useCallback(o=>window.postMessage(o,"*"),[])}let le=()=>Math.floor((1+Math.random())*65536).toString(16).substring(1);const ue=e=>encodeURIComponent(btoa(e));function J({tune:e,autolink:o=!0,onEvent:r,onDraw:n}){const a=t.useMemo(()=>le(),[]),[l,c]=t.useState(e),[s,u]=t.useState(),[i,g]=t.useState(""),[b,f]=t.useState(),[k,h]=t.useState(!1),[M,v]=t.useState(""),[m,D]=t.useState(),_=t.useMemo(()=>l!==s||b,[l,s,b]),E=t.useCallback(w=>g(d=>d+`${d?` - -`:""}${w}`),[]),x=t.useMemo(()=>{if(s&&!s.includes("strudel disable-highlighting"))return(w,d)=>n?.(w,d,s)},[s,n]),L=t.useMemo(()=>s&&s.includes("strudel hide-header"),[s]),q=t.useMemo(()=>s&&s.includes("strudel hide-console"),[s]),R=U({onDraw:x,onEvent:t.useCallback((w,d,Y)=>{try{r?.(d),d.context.logs?.length&&d.context.logs.forEach(E);const{onTrigger:F=oe.webaudioOutputTrigger}=d.context;F(w,d,Y,1)}catch(F){console.warn(F),F.message="unplayable event: "+F?.message,E(F.message)}},[r,E]),onQuery:t.useCallback(w=>{try{return m?.query(w)||[]}catch(d){return console.warn(d),d.message="query error: "+d.message,f(d),[]}},[m]),onSchedule:t.useCallback((w,d)=>X(w),[]),ready:!!m&&!!s}),O=Q(({data:{from:w,type:d}})=>{d==="start"&&w!==a&&(R.setStarted(!1),u(void 0))}),I=t.useCallback(async(w=l)=>{if(s&&!_){f(void 0),R.start();return}try{h(!0);const d=await B.evaluate(w);R.start(),O({type:"start",from:a}),D(()=>d.pattern),o&&(window.location.hash="#"+encodeURIComponent(btoa(l))),v(ue(l)),f(void 0),u(w),h(!1)}catch(d){d.message="evaluation error: "+d.message,console.warn(d),f(d)}},[s,_,l,R,o,a,O]),X=(w,d)=>{w.length};return{hideHeader:L,hideConsole:q,pending:k,code:l,setCode:c,pattern:m,error:b,cycle:R,setPattern:D,dirty:_,log:i,togglePlay:()=>{R.started?R.stop():I()},setActiveCode:u,activateCode:I,activeCode:s,pushLog:E,hash:M}}function H(...e){return e.filter(Boolean).join(" ")}function G({view:e,pattern:o,active:r,getTime:n}){const a=t.useRef([]),l=t.useRef();t.useEffect(()=>{if(e)if(o&&r){let s=function(){try{const u=n(),g=[Math.max(l.current||u,u-1/10),u+1/60];l.current=u+1/60,a.current=a.current.filter(f=>f.whole.end>u);const b=o.queryArc(...g).filter(f=>f.hasOnset());a.current=a.current.concat(b),e.dispatch({effects:P.of(a.current)})}catch{e.dispatch({effects:P.of([])})}c=requestAnimationFrame(s)},c=requestAnimationFrame(s);return()=>{cancelAnimationFrame(c)}}else a.current=[],e.dispatch({effects:P.of([])})},[o,r,e])}const de="_container_3i85k_1",fe="_header_3i85k_5",ge="_buttons_3i85k_9",he="_button_3i85k_9",me="_buttonDisabled_3i85k_17",pe="_error_3i85k_21",be="_body_3i85k_25",N={container:de,header:fe,buttons:ge,button:he,buttonDisabled:me,error:pe,body:be};function V({type:e}){return y.default.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",className:"sc-h-5 sc-w-5",viewBox:"0 0 20 20",fill:"currentColor"},{refresh:y.default.createElement("path",{fillRule:"evenodd",d:"M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z",clipRule:"evenodd"}),play:y.default.createElement("path",{fillRule:"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z",clipRule:"evenodd"}),pause:y.default.createElement("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z",clipRule:"evenodd"})}[e])}function ye({tune:e,hideOutsideView:o=!1,init:r,onEvent:n,enableKeyboard:a}){const{code:l,setCode:c,pattern:s,activeCode:u,activateCode:i,evaluateOnly:g,error:b,cycle:f,dirty:k,togglePlay:h,stop:M}=J({tune:e,autolink:!1,onEvent:n});t.useEffect(()=>{r&&g()},[e,r]);const[v,m]=t.useState(),[D,_]=se.useInView({threshold:.01}),E=t.useRef(),x=t.useMemo(()=>((_||!o)&&(E.current=!0),_||E.current),[_,o]);return G({view:v,pattern:s,active:f.started&&!u?.includes("strudel disable-highlighting"),getTime:()=>C.Tone.getTransport().seconds}),t.useLayoutEffect(()=>{if(a){const L=async q=>{(q.ctrlKey||q.altKey)&&(q.code==="Enter"?(q.preventDefault(),$(v),await i()):q.code==="Period"&&(f.stop(),q.preventDefault()))};return window.addEventListener("keydown",L,!0),()=>window.removeEventListener("keydown",L,!0)}},[a,s,l,i,f,v]),y.default.createElement("div",{className:N.container,ref:D},y.default.createElement("div",{className:N.header},y.default.createElement("div",{className:N.buttons},y.default.createElement("button",{className:H(N.button,f.started?"sc-animate-pulse":""),onClick:()=>h()},y.default.createElement(V,{type:f.started?"pause":"play"})),y.default.createElement("button",{className:H(k?N.button:N.buttonDisabled),onClick:()=>i()},y.default.createElement(V,{type:"refresh"}))),b&&y.default.createElement("div",{className:N.error},b.message)),y.default.createElement("div",{className:N.body},x&&y.default.createElement(K,{value:l,onChange:c,onViewChanged:m})))}function we(e,o,r=.05,n=.1,a=.1){let l=0,c=0,s=10**4,u=.01;const i=m=>r=m(r);a=a||n/2;const g=()=>{const m=e(),D=m+n+a;for(c===0&&(c=m+u);c=m&&o(c,r,l),c{g(),b=setInterval(g,n*1e3)},k=()=>clearInterval(b);return{setDuration:i,start:f,stop:()=>{l=0,c=0,k()},pause:()=>k(),duration:r,getPhase:()=>c}}class ve{worker;pattern;started=!1;cps=1;getTime;phase=0;constructor({interval:o,onTrigger:r,onError:n,getTime:a,latency:l=.1}){this.getTime=a;const c=s=>Math.round(s*1e3)/1e3;this.clock=we(a,(s,u,i)=>{i===0&&(this.origin=s);const g=c(s-this.origin);this.phase=g-l;const b=c(g+u),f=a();try{this.pattern.queryArc(g,b).forEach(h=>{if(h.part.begin.equals(h.whole.begin)){const M=h.whole.begin+this.origin-f+l,v=h.duration*1;r?.(h,M,v)}})}catch(k){console.warn("scheduler error",k),n?.(k)}},o)}getPhase(){return this.phase}start(){if(!this.pattern)throw new Error("Scheduler: no pattern set! call .setPattern first.");this.clock.start(),this.started=!0}pause(){this.clock.stop(),delete this.origin,this.started=!1}stop(){delete this.origin,this.clock.stop(),this.started=!1}setPattern(o){this.pattern=o}setCps(o=1){this.cps=o}log(o,r,n){const a=n.filter(l=>l.hasOnset());console.log(`${o.toFixed(4)} - ${r.toFixed(4)} ${Array(a.length).fill("I").join("")}`)}}function Ee({defaultOutput:e,interval:o,getTime:r,code:n,evalOnMount:a=!1}){const[l,c]=t.useState(),[s,u]=t.useState(),[i,g]=t.useState(n),[b,f]=t.useState(),k=n!==i,h=t.useMemo(()=>new ve({interval:o,onTrigger:e,onError:c,getTime:r}),[e,o]),M=t.useCallback(async()=>{if(!n){console.log("no code..");return}try{const{pattern:m}=await B.evaluate(n);g(n),h?.setPattern(m),f(m),u()}catch(m){u(m),console.warn("eval error",m)}},[n,h]),v=t.useRef();return t.useEffect(()=>{!v.current&&a&&(v.current=!0,M())},[M,a]),{schedulerError:l,scheduler:h,evalError:s,evaluate:M,activeCode:i,isDirty:k,pattern:b}}const ke=e=>t.useLayoutEffect(()=>(window.addEventListener("keydown",e,!0),()=>window.removeEventListener("keydown",e,!0)),[e]);function Me(e){const{ready:o,connected:r,disconnected:n}=e,[a,l]=t.useState(!0),[c,s]=t.useState(T.WebMidi?.outputs||[]);return t.useEffect(()=>{T.enableWebMidi().then(()=>{T.WebMidi.addListener("connected",i=>{s([...T.WebMidi.outputs]),r?.(T.WebMidi,i)}),T.WebMidi.addListener("disconnected",i=>{s([...T.WebMidi.outputs]),n?.(T.WebMidi,i)}),o?.(T.WebMidi),l(!1)}).catch(i=>{if(i){console.error(i),console.warn("Web Midi could not be enabled..");return}})},[o,r,n,c]),{loading:a,outputs:c,outputByName:i=>T.WebMidi.getOutputByName(i)}}exports.CodeMirror=K;exports.MiniRepl=ye;exports.cx=H;exports.flash=$;exports.useCycle=U;exports.useHighlighting=G;exports.useKeydown=ke;exports.usePostMessage=Q;exports.useRepl=J;exports.useStrudel=Ee;exports.useWebMidi=Me; +"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const t=require("react"),G=require("@uiw/react-codemirror"),b=require("@codemirror/view"),R=require("@codemirror/state"),Q=require("@codemirror/lang-javascript"),o=require("@lezer/highlight"),W=require("@uiw/codemirror-themes"),X=require("react-hook-inview"),V=require("@strudel.cycles/webaudio"),Y=require("@strudel.cycles/core"),Z=require("@strudel.cycles/transpiler"),j=e=>e&&typeof e=="object"&&"default"in e?e:{default:e},n=j(t),ee=j(G),te=W.createTheme({theme:"dark",settings:{background:"#222",foreground:"#75baff",caret:"#ffcc00",selection:"rgba(128, 203, 196, 0.5)",selectionMatch:"#036dd626",lineHighlight:"#00000050",gutterBackground:"transparent",gutterForeground:"#8a919966"},styles:[{tag:o.tags.keyword,color:"#c792ea"},{tag:o.tags.operator,color:"#89ddff"},{tag:o.tags.special(o.tags.variableName),color:"#eeffff"},{tag:o.tags.typeName,color:"#c3e88d"},{tag:o.tags.atom,color:"#f78c6c"},{tag:o.tags.number,color:"#c3e88d"},{tag:o.tags.definition(o.tags.variableName),color:"#82aaff"},{tag:o.tags.string,color:"#c3e88d"},{tag:o.tags.special(o.tags.string),color:"#c3e88d"},{tag:o.tags.comment,color:"#7d8799"},{tag:o.tags.variableName,color:"#c792ea"},{tag:o.tags.tagName,color:"#c3e88d"},{tag:o.tags.bracket,color:"#525154"},{tag:o.tags.meta,color:"#ffcb6b"},{tag:o.tags.attributeName,color:"#c792ea"},{tag:o.tags.propertyName,color:"#c792ea"},{tag:o.tags.className,color:"#decb6b"},{tag:o.tags.invalid,color:"#ffffff"}]});const P=R.StateEffect.define(),re=R.StateField.define({create(){return b.Decoration.none},update(e,r){try{for(let a of r.effects)if(a.is(P))if(a.value){const s=b.Decoration.mark({attributes:{style:"background-color: #FFCA2880"}});e=b.Decoration.set([s.range(0,r.newDoc.length)])}else e=b.Decoration.set([]);return e}catch(a){return console.warn("flash error",a),e}},provide:e=>b.EditorView.decorations.from(e)}),B=e=>{e.dispatch({effects:P.of(!0)}),setTimeout(()=>{e.dispatch({effects:P.of(!1)})},200)},N=R.StateEffect.define(),oe=R.StateField.define({create(){return b.Decoration.none},update(e,r){try{for(let a of r.effects)if(a.is(N)){const s=a.value.map(c=>(c.context.locations||[]).map(({start:m,end:u})=>{const d=c.context.color||"#FFCA28";let i=r.newDoc.line(m.line).from+m.column,l=r.newDoc.line(u.line).from+u.column;const h=r.newDoc.length;return i>h||l>h?void 0:b.Decoration.mark({attributes:{style:`outline: 1.5px solid ${d};`}}).range(i,l)})).flat().filter(Boolean)||[];e=b.Decoration.set(s,!0)}return e}catch{return b.Decoration.set([])}},provide:e=>b.EditorView.decorations.from(e)}),ae=[Q.javascript(),te,oe,re];function I({value:e,onChange:r,onViewChanged:a,onSelectionChange:s,options:c,editorDidMount:m}){const u=t.useCallback(l=>{r?.(l)},[r]),d=t.useCallback(l=>{a?.(l)},[a]),i=t.useCallback(l=>{l.selectionSet&&s&&s?.(l.state.selection)},[s]);return n.default.createElement(n.default.Fragment,null,n.default.createElement(ee.default,{value:e,onChange:u,onCreateEditor:d,onUpdate:i,extensions:ae}))}function H(...e){return e.filter(Boolean).join(" ")}function K({view:e,pattern:r,active:a,getTime:s}){const c=t.useRef([]),m=t.useRef();t.useEffect(()=>{if(e)if(r&&a){let d=function(){try{const i=s(),h=[Math.max(m.current||i,i-1/10,0),i+1/60];m.current=h[1],c.current=c.current.filter(p=>p.whole.end>i);const w=r.queryArc(...h).filter(p=>p.hasOnset());c.current=c.current.concat(w),e.dispatch({effects:N.of(c.current)})}catch{e.dispatch({effects:N.of([])})}u=requestAnimationFrame(d)},u=requestAnimationFrame(d);return()=>{cancelAnimationFrame(u)}}else c.current=[],e.dispatch({effects:N.of([])})},[r,a,e])}const ne="_container_3i85k_1",se="_header_3i85k_5",ce="_buttons_3i85k_9",ie="_button_3i85k_9",le="_buttonDisabled_3i85k_17",ue="_error_3i85k_21",de="_body_3i85k_25",E={container:ne,header:se,buttons:ce,button:ie,buttonDisabled:le,error:ue,body:de};function z({type:e}){return n.default.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",className:"sc-h-5 sc-w-5",viewBox:"0 0 20 20",fill:"currentColor"},{refresh:n.default.createElement("path",{fillRule:"evenodd",d:"M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z",clipRule:"evenodd"}),play:n.default.createElement("path",{fillRule:"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z",clipRule:"evenodd"}),pause:n.default.createElement("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z",clipRule:"evenodd"})}[e])}function O({defaultOutput:e,interval:r,getTime:a,evalOnMount:s=!1,initialCode:c="",autolink:m=!1,beforeEval:u,afterEval:d,onEvalError:i,onToggle:l}){const[h,w]=t.useState(),[p,D]=t.useState(),[v,k]=t.useState(c),[y,q]=t.useState(v),[F,_]=t.useState(),[C,A]=t.useState(!1),M=v!==y,{scheduler:f,evaluate:x,start:U,stop:J,pause:$}=t.useMemo(()=>Y.repl({interval:r,defaultOutput:e,onSchedulerError:w,onEvalError:g=>{D(g),i?.(g)},getTime:a,transpiler:Z.transpiler,beforeEval:({code:g})=>{k(g),u?.()},afterEval:({pattern:g,code:T})=>{q(T),_(g),D(),w(),m&&(window.location.hash="#"+encodeURIComponent(btoa(T))),d?.()},onToggle:g=>{A(g),l?.(g)}}),[e,r,a]),S=t.useCallback(async(g=!0)=>x(v,g),[x,v]),L=t.useRef();return t.useEffect(()=>{!L.current&&s&&v&&(L.current=!0,S())},[S,s,v]),t.useEffect(()=>()=>{f.stop()},[f]),{code:v,setCode:k,error:h||p,schedulerError:h,scheduler:f,evalError:p,evaluate:x,activateCode:S,activeCode:y,isDirty:M,pattern:F,started:C,start:U,stop:J,pause:$,togglePlay:async()=>{C?f.pause():await S()}}}const fe=()=>V.getAudioContext().currentTime;function ge({tune:e,hideOutsideView:r=!1,init:a,enableKeyboard:s}){const{code:c,setCode:m,evaluate:u,activateCode:d,error:i,isDirty:l,activeCode:h,pattern:w,started:p,scheduler:D,togglePlay:v,stop:k}=O({initialCode:e,defaultOutput:V.webaudioOutput,getTime:fe}),[y,q]=t.useState(),[F,_]=X.useInView({threshold:.01}),C=t.useRef(),A=t.useMemo(()=>((_||!r)&&(C.current=!0),_||C.current),[_,r]);return K({view:y,pattern:w,active:p&&!h?.includes("strudel disable-highlighting"),getTime:()=>D.getPhase()}),t.useLayoutEffect(()=>{if(s){const M=async f=>{(f.ctrlKey||f.altKey)&&(f.code==="Enter"?(f.preventDefault(),B(y),await d()):f.code==="Period"&&(k(),f.preventDefault()))};return window.addEventListener("keydown",M,!0),()=>window.removeEventListener("keydown",M,!0)}},[s,w,c,u,k,y]),n.default.createElement("div",{className:E.container,ref:F},n.default.createElement("div",{className:E.header},n.default.createElement("div",{className:E.buttons},n.default.createElement("button",{className:H(E.button,p?"sc-animate-pulse":""),onClick:()=>v()},n.default.createElement(z,{type:p?"pause":"play"})),n.default.createElement("button",{className:H(l?E.button:E.buttonDisabled),onClick:()=>d()},n.default.createElement(z,{type:"refresh"}))),i&&n.default.createElement("div",{className:E.error},i.message)),n.default.createElement("div",{className:E.body},A&&n.default.createElement(I,{value:c,onChange:m,onViewChanged:q})))}function me(e){return t.useEffect(()=>(window.addEventListener("message",e),()=>window.removeEventListener("message",e)),[e]),t.useCallback(r=>window.postMessage(r,"*"),[])}const he=e=>t.useLayoutEffect(()=>(window.addEventListener("keydown",e,!0),()=>window.removeEventListener("keydown",e,!0)),[e]);exports.CodeMirror=I;exports.MiniRepl=ge;exports.cx=H;exports.flash=B;exports.useHighlighting=K;exports.useKeydown=he;exports.usePostMessage=me;exports.useStrudel=O; diff --git a/packages/react/dist/index.es.js b/packages/react/dist/index.es.js index deebffc2..9ebf09b3 100644 --- a/packages/react/dist/index.es.js +++ b/packages/react/dist/index.es.js @@ -1,17 +1,15 @@ -import y, { useCallback as T, useState as w, useEffect as q, useMemo as R, useRef as I, useLayoutEffect as j } from "react"; -import Y from "@uiw/react-codemirror"; -import { Decoration as A, EditorView as K } from "@codemirror/view"; -import { StateEffect as U, StateField as Q } from "@codemirror/state"; -import { javascript as Z } from "@codemirror/lang-javascript"; -import { tags as m } from "@lezer/highlight"; -import { createTheme as ee } from "@uiw/codemirror-themes"; -import { useInView as te } from "react-hook-inview"; -import { evaluate as G } from "@strudel.cycles/eval"; -import { Tone as M } from "@strudel.cycles/tone"; -import { TimeSpan as oe, State as re } from "@strudel.cycles/core"; -import { webaudioOutputTrigger as ne } from "@strudel.cycles/webaudio"; -import { WebMidi as N, enableWebMidi as se } from "@strudel.cycles/midi"; -const ae = ee({ +import n, { useCallback as N, useRef as x, useEffect as R, useState as w, useMemo as I, useLayoutEffect as K } from "react"; +import Q from "@uiw/react-codemirror"; +import { Decoration as E, EditorView as O } from "@codemirror/view"; +import { StateEffect as j, StateField as U } from "@codemirror/state"; +import { javascript as W } from "@codemirror/lang-javascript"; +import { tags as r } from "@lezer/highlight"; +import { createTheme as X } from "@uiw/codemirror-themes"; +import { useInView as Y } from "react-hook-inview"; +import { webaudioOutput as Z, getAudioContext as ee } from "@strudel.cycles/webaudio"; +import { repl as te } from "@strudel.cycles/core"; +import { transpiler as re } from "@strudel.cycles/transpiler"; +const oe = X({ theme: "dark", settings: { background: "#222", @@ -19,450 +17,294 @@ const ae = ee({ caret: "#ffcc00", selection: "rgba(128, 203, 196, 0.5)", selectionMatch: "#036dd626", - lineHighlight: "#8a91991a", + lineHighlight: "#00000050", gutterBackground: "transparent", - gutterForeground: "#676e95" + gutterForeground: "#8a919966" }, styles: [ - { tag: m.keyword, color: "#c792ea" }, - { tag: m.operator, color: "#89ddff" }, - { tag: m.special(m.variableName), color: "#eeffff" }, - { tag: m.typeName, color: "#f07178" }, - { tag: m.atom, color: "#f78c6c" }, - { tag: m.number, color: "#ff5370" }, - { tag: m.definition(m.variableName), color: "#82aaff" }, - { tag: m.string, color: "#c3e88d" }, - { tag: m.special(m.string), color: "#f07178" }, - { tag: m.comment, color: "#7d8799" }, - { tag: m.variableName, color: "#f07178" }, - { tag: m.tagName, color: "#ff5370" }, - { tag: m.bracket, color: "#a2a1a4" }, - { tag: m.meta, color: "#ffcb6b" }, - { tag: m.attributeName, color: "#c792ea" }, - { tag: m.propertyName, color: "#c792ea" }, - { tag: m.className, color: "#decb6b" }, - { tag: m.invalid, color: "#ffffff" } + { tag: r.keyword, color: "#c792ea" }, + { tag: r.operator, color: "#89ddff" }, + { tag: r.special(r.variableName), color: "#eeffff" }, + { tag: r.typeName, color: "#c3e88d" }, + { tag: r.atom, color: "#f78c6c" }, + { tag: r.number, color: "#c3e88d" }, + { tag: r.definition(r.variableName), color: "#82aaff" }, + { tag: r.string, color: "#c3e88d" }, + { tag: r.special(r.string), color: "#c3e88d" }, + { tag: r.comment, color: "#7d8799" }, + { tag: r.variableName, color: "#c792ea" }, + { tag: r.tagName, color: "#c3e88d" }, + { tag: r.bracket, color: "#525154" }, + { tag: r.meta, color: "#ffcb6b" }, + { tag: r.attributeName, color: "#c792ea" }, + { tag: r.propertyName, color: "#c792ea" }, + { tag: r.className, color: "#decb6b" }, + { tag: r.invalid, color: "#ffffff" } ] }); -const z = U.define(), ce = Q.define({ +const T = j.define(), ne = U.define({ create() { - return A.none; + return E.none; }, - update(e, o) { + update(e, t) { try { - for (let r of o.effects) - if (r.is(z)) - if (r.value) { - const n = A.mark({ attributes: { style: "background-color: #FFCA2880" } }); - e = A.set([n.range(0, o.newDoc.length)]); + for (let o of t.effects) + if (o.is(T)) + if (o.value) { + const a = E.mark({ attributes: { style: "background-color: #FFCA2880" } }); + e = E.set([a.range(0, t.newDoc.length)]); } else - e = A.set([]); + e = E.set([]); return e; - } catch (r) { - return console.warn("flash error", r), e; + } catch (o) { + return console.warn("flash error", o), e; } }, - provide: (e) => K.decorations.from(e) -}), ie = (e) => { - e.dispatch({ effects: z.of(!0) }), setTimeout(() => { - e.dispatch({ effects: z.of(!1) }); + provide: (e) => O.decorations.from(e) +}), ae = (e) => { + e.dispatch({ effects: T.of(!0) }), setTimeout(() => { + e.dispatch({ effects: T.of(!1) }); }, 200); -}, O = U.define(), le = Q.define({ +}, A = j.define(), se = U.define({ create() { - return A.none; + return E.none; }, - update(e, o) { + update(e, t) { try { - for (let r of o.effects) - if (r.is(O)) { - const n = r.value.map( - (s) => (s.context.locations || []).map(({ start: i, end: a }) => { - const t = s.context.color || "#FFCA28"; - let l = o.newDoc.line(i.line).from + i.column, c = o.newDoc.line(a.line).from + a.column; - const f = o.newDoc.length; - return l > f || c > f ? void 0 : A.mark({ attributes: { style: `outline: 1.5px solid ${t};` } }).range(l, c); + for (let o of t.effects) + if (o.is(A)) { + const a = o.value.map( + (s) => (s.context.locations || []).map(({ start: m, end: l }) => { + const d = s.context.color || "#FFCA28"; + let c = t.newDoc.line(m.line).from + m.column, i = t.newDoc.line(l.line).from + l.column; + const g = t.newDoc.length; + return c > g || i > g ? void 0 : E.mark({ attributes: { style: `outline: 1.5px solid ${d};` } }).range(c, i); }) ).flat().filter(Boolean) || []; - e = A.set(n, !0); + e = E.set(a, !0); } return e; } catch { - return A.set([]); + return E.set([]); } }, - provide: (e) => K.decorations.from(e) -}), ue = [Z(), ae, le, ce]; -function de({ value: e, onChange: o, onViewChanged: r, onSelectionChange: n, options: s, editorDidMount: i }) { - const a = T( - (c) => { - o?.(c); + provide: (e) => O.decorations.from(e) +}), ce = [W(), oe, se, ne]; +function ie({ value: e, onChange: t, onViewChanged: o, onSelectionChange: a, options: s, editorDidMount: m }) { + const l = N( + (i) => { + t?.(i); + }, + [t] + ), d = N( + (i) => { + o?.(i); }, [o] - ), t = T( - (c) => { - r?.(c); + ), c = N( + (i) => { + i.selectionSet && a && a?.(i.state.selection); }, - [r] - ), l = T( - (c) => { - c.selectionSet && n && n?.(c.state.selection); - }, - [n] + [a] ); - return /* @__PURE__ */ y.createElement(y.Fragment, null, /* @__PURE__ */ y.createElement(Y, { + return /* @__PURE__ */ n.createElement(n.Fragment, null, /* @__PURE__ */ n.createElement(Q, { value: e, - onChange: a, - onCreateEditor: t, - onUpdate: l, - extensions: ue + onChange: l, + onCreateEditor: d, + onUpdate: c, + extensions: ce })); } -function fe(e) { - const { onEvent: o, onQuery: r, onSchedule: n, ready: s = !0, onDraw: i } = e, [a, t] = w(!1), l = 1, c = () => Math.floor(M.getTransport().seconds / l), f = (g = c()) => { - const C = new oe(g, g + 1), v = r?.(new re(C)) || []; - n?.(v, g); - const h = C.begin.valueOf(); - M.getTransport().cancel(h); - const D = (g + 1) * l - 0.5, _ = Math.max(M.getTransport().seconds, D) + 0.1; - M.getTransport().schedule(() => { - f(g + 1); - }, _), v?.filter((E) => E.part.begin.equals(E.whole?.begin)).forEach((E) => { - M.getTransport().schedule((L) => { - o(L, E, M.getContext().currentTime), M.Draw.schedule(() => { - i?.(L, E); - }, L); - }, E.part.begin.valueOf()); - }); - }; - q(() => { - s && f(); - }, [o, n, r, i, s]); - const p = async () => { - t(!0), await M.start(), M.getTransport().start("+0.1"); - }, d = () => { - M.getTransport().pause(), t(!1); - }; - return { - start: p, - stop: d, - onEvent: o, - started: a, - setStarted: t, - toggle: () => a ? d() : p(), - query: f, - activeCycle: c - }; -} -function ge(e) { - return q(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), T((o) => window.postMessage(o, "*"), []); -} -let he = () => Math.floor((1 + Math.random()) * 65536).toString(16).substring(1); -const me = (e) => encodeURIComponent(btoa(e)); -function pe({ tune: e, autolink: o = !0, onEvent: r, onDraw: n }) { - const s = R(() => he(), []), [i, a] = w(e), [t, l] = w(), [c, f] = w(""), [p, d] = w(), [k, g] = w(!1), [C, v] = w(""), [h, D] = w(), _ = R(() => i !== t || p, [i, t, p]), E = T((b) => f((u) => u + `${u ? ` - -` : ""}${b}`), []), L = R(() => { - if (t && !t.includes("strudel disable-highlighting")) - return (b, u) => n?.(b, u, t); - }, [t, n]), H = R(() => t && t.includes("strudel hide-header"), [t]), x = R(() => t && t.includes("strudel hide-console"), [t]), P = fe({ - onDraw: L, - onEvent: T( - (b, u, X) => { - try { - r?.(u), u.context.logs?.length && u.context.logs.forEach(E); - const { onTrigger: S = ne } = u.context; - S(b, u, X, 1); - } catch (S) { - console.warn(S), S.message = "unplayable event: " + S?.message, E(S.message); - } - }, - [r, E] - ), - onQuery: T( - (b) => { - try { - return h?.query(b) || []; - } catch (u) { - return console.warn(u), u.message = "query error: " + u.message, d(u), []; - } - }, - [h] - ), - onSchedule: T((b, u) => J(b), []), - ready: !!h && !!t - }), B = ge(({ data: { from: b, type: u } }) => { - u === "start" && b !== s && (P.setStarted(!1), l(void 0)); - }), V = T( - async (b = i) => { - if (t && !_) { - d(void 0), P.start(); - return; - } - try { - g(!0); - const u = await G(b); - P.start(), B({ type: "start", from: s }), D(() => u.pattern), o && (window.location.hash = "#" + encodeURIComponent(btoa(i))), v(me(i)), d(void 0), l(b), g(!1); - } catch (u) { - u.message = "evaluation error: " + u.message, console.warn(u), d(u); - } - }, - [t, _, i, P, o, s, B] - ), J = (b, u) => { - b.length; - }; - return { - hideHeader: H, - hideConsole: x, - pending: k, - code: i, - setCode: a, - pattern: h, - error: p, - cycle: P, - setPattern: D, - dirty: _, - log: c, - togglePlay: () => { - P.started ? P.stop() : V(); - }, - setActiveCode: l, - activateCode: V, - activeCode: t, - pushLog: E, - hash: C - }; -} -function $(...e) { +function B(...e) { return e.filter(Boolean).join(" "); } -function ye({ view: e, pattern: o, active: r, getTime: n }) { - const s = I([]), i = I(); - q(() => { +function le({ view: e, pattern: t, active: o, getTime: a }) { + const s = x([]), m = x(); + R(() => { if (e) - if (o && r) { - let t = function() { + if (t && o) { + let d = function() { try { - const l = n(), f = [Math.max(i.current || l, l - 1 / 10), l + 1 / 60]; - i.current = l + 1 / 60, s.current = s.current.filter((d) => d.whole.end > l); - const p = o.queryArc(...f).filter((d) => d.hasOnset()); - s.current = s.current.concat(p), e.dispatch({ effects: O.of(s.current) }); + const c = a(), g = [Math.max(m.current || c, c - 1 / 10, 0), c + 1 / 60]; + m.current = g[1], s.current = s.current.filter((p) => p.whole.end > c); + const v = t.queryArc(...g).filter((p) => p.hasOnset()); + s.current = s.current.concat(v), e.dispatch({ effects: A.of(s.current) }); } catch { - e.dispatch({ effects: O.of([]) }); + e.dispatch({ effects: A.of([]) }); } - a = requestAnimationFrame(t); - }, a = requestAnimationFrame(t); + l = requestAnimationFrame(d); + }, l = requestAnimationFrame(d); return () => { - cancelAnimationFrame(a); + cancelAnimationFrame(l); }; } else - s.current = [], e.dispatch({ effects: O.of([]) }); - }, [o, r, e]); + s.current = [], e.dispatch({ effects: A.of([]) }); + }, [t, o, e]); } -const be = "_container_3i85k_1", we = "_header_3i85k_5", ve = "_buttons_3i85k_9", Ee = "_button_3i85k_9", ke = "_buttonDisabled_3i85k_17", Ce = "_error_3i85k_21", Me = "_body_3i85k_25", F = { - container: be, - header: we, - buttons: ve, - button: Ee, - buttonDisabled: ke, - error: Ce, - body: Me +const de = "_container_3i85k_1", ue = "_header_3i85k_5", fe = "_buttons_3i85k_9", me = "_button_3i85k_9", ge = "_buttonDisabled_3i85k_17", pe = "_error_3i85k_21", he = "_body_3i85k_25", b = { + container: de, + header: ue, + buttons: fe, + button: me, + buttonDisabled: ge, + error: pe, + body: he }; -function W({ type: e }) { - return /* @__PURE__ */ y.createElement("svg", { +function q({ type: e }) { + return /* @__PURE__ */ n.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", className: "sc-h-5 sc-w-5", viewBox: "0 0 20 20", fill: "currentColor" }, { - refresh: /* @__PURE__ */ y.createElement("path", { + refresh: /* @__PURE__ */ n.createElement("path", { fillRule: "evenodd", d: "M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z", clipRule: "evenodd" }), - play: /* @__PURE__ */ y.createElement("path", { + play: /* @__PURE__ */ n.createElement("path", { fillRule: "evenodd", d: "M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z", clipRule: "evenodd" }), - pause: /* @__PURE__ */ y.createElement("path", { + pause: /* @__PURE__ */ n.createElement("path", { fillRule: "evenodd", d: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z", clipRule: "evenodd" }) }[e]); } -function Be({ tune: e, hideOutsideView: o = !1, init: r, onEvent: n, enableKeyboard: s }) { - const { code: i, setCode: a, pattern: t, activeCode: l, activateCode: c, evaluateOnly: f, error: p, cycle: d, dirty: k, togglePlay: g, stop: C } = pe({ - tune: e, - autolink: !1, - onEvent: n - }); - q(() => { - r && f(); - }, [e, r]); - const [v, h] = w(), [D, _] = te({ - threshold: 0.01 - }), E = I(), L = R(() => ((_ || !o) && (E.current = !0), _ || E.current), [_, o]); - return ye({ - view: v, - pattern: t, - active: d.started && !l?.includes("strudel disable-highlighting"), - getTime: () => M.getTransport().seconds - }), j(() => { - if (s) { - const H = async (x) => { - (x.ctrlKey || x.altKey) && (x.code === "Enter" ? (x.preventDefault(), ie(v), await c()) : x.code === "Period" && (d.stop(), x.preventDefault())); - }; - return window.addEventListener("keydown", H, !0), () => window.removeEventListener("keydown", H, !0); +function ve({ + defaultOutput: e, + interval: t, + getTime: o, + evalOnMount: a = !1, + initialCode: s = "", + autolink: m = !1, + beforeEval: l, + afterEval: d, + onEvalError: c, + onToggle: i +}) { + const [g, v] = w(), [p, D] = w(), [h, k] = w(s), [y, P] = w(h), [z, _] = w(), [C, H] = w(!1), F = h !== y, { scheduler: u, evaluate: L, start: $, stop: G, pause: J } = I( + () => te({ + interval: t, + defaultOutput: e, + onSchedulerError: v, + onEvalError: (f) => { + D(f), c?.(f); + }, + getTime: o, + transpiler: re, + beforeEval: ({ code: f }) => { + k(f), l?.(); + }, + afterEval: ({ pattern: f, code: S }) => { + P(S), _(f), D(), v(), m && (window.location.hash = "#" + encodeURIComponent(btoa(S))), d?.(); + }, + onToggle: (f) => { + H(f), i?.(f); + } + }), + [e, t, o] + ), M = N(async (f = !0) => L(h, f), [L, h]), V = x(); + return R(() => { + !V.current && a && h && (V.current = !0, M()); + }, [M, a, h]), R(() => () => { + u.stop(); + }, [u]), { + code: h, + setCode: k, + error: g || p, + schedulerError: g, + scheduler: u, + evalError: p, + evaluate: L, + activateCode: M, + activeCode: y, + isDirty: F, + pattern: z, + started: C, + start: $, + stop: G, + pause: J, + togglePlay: async () => { + C ? u.pause() : await M(); } - }, [s, t, i, c, d, v]), /* @__PURE__ */ y.createElement("div", { - className: F.container, - ref: D - }, /* @__PURE__ */ y.createElement("div", { - className: F.header - }, /* @__PURE__ */ y.createElement("div", { - className: F.buttons - }, /* @__PURE__ */ y.createElement("button", { - className: $(F.button, d.started ? "sc-animate-pulse" : ""), - onClick: () => g() - }, /* @__PURE__ */ y.createElement(W, { - type: d.started ? "pause" : "play" - })), /* @__PURE__ */ y.createElement("button", { - className: $(k ? F.button : F.buttonDisabled), - onClick: () => c() - }, /* @__PURE__ */ y.createElement(W, { + }; +} +const be = () => ee().currentTime; +function Pe({ tune: e, hideOutsideView: t = !1, init: o, enableKeyboard: a }) { + const { + code: s, + setCode: m, + evaluate: l, + activateCode: d, + error: c, + isDirty: i, + activeCode: g, + pattern: v, + started: p, + scheduler: D, + togglePlay: h, + stop: k + } = ve({ + initialCode: e, + defaultOutput: Z, + getTime: be + }), [y, P] = w(), [z, _] = Y({ + threshold: 0.01 + }), C = x(), H = I(() => ((_ || !t) && (C.current = !0), _ || C.current), [_, t]); + return le({ + view: y, + pattern: v, + active: p && !g?.includes("strudel disable-highlighting"), + getTime: () => D.getPhase() + }), K(() => { + if (a) { + const F = async (u) => { + (u.ctrlKey || u.altKey) && (u.code === "Enter" ? (u.preventDefault(), ae(y), await d()) : u.code === "Period" && (k(), u.preventDefault())); + }; + return window.addEventListener("keydown", F, !0), () => window.removeEventListener("keydown", F, !0); + } + }, [a, v, s, l, k, y]), /* @__PURE__ */ n.createElement("div", { + className: b.container, + ref: z + }, /* @__PURE__ */ n.createElement("div", { + className: b.header + }, /* @__PURE__ */ n.createElement("div", { + className: b.buttons + }, /* @__PURE__ */ n.createElement("button", { + className: B(b.button, p ? "sc-animate-pulse" : ""), + onClick: () => h() + }, /* @__PURE__ */ n.createElement(q, { + type: p ? "pause" : "play" + })), /* @__PURE__ */ n.createElement("button", { + className: B(i ? b.button : b.buttonDisabled), + onClick: () => d() + }, /* @__PURE__ */ n.createElement(q, { type: "refresh" - }))), p && /* @__PURE__ */ y.createElement("div", { - className: F.error - }, p.message)), /* @__PURE__ */ y.createElement("div", { - className: F.body - }, L && /* @__PURE__ */ y.createElement(de, { - value: i, - onChange: a, - onViewChanged: h + }))), c && /* @__PURE__ */ n.createElement("div", { + className: b.error + }, c.message)), /* @__PURE__ */ n.createElement("div", { + className: b.body + }, H && /* @__PURE__ */ n.createElement(ie, { + value: s, + onChange: m, + onViewChanged: P }))); } -function Te(e, o, r = 0.05, n = 0.1, s = 0.1) { - let i = 0, a = 0, t = 10 ** 4, l = 0.01; - const c = (h) => r = h(r); - s = s || n / 2; - const f = () => { - const h = e(), D = h + n + s; - for (a === 0 && (a = h + l); a < D; ) - a = Math.round(a * t) / t, a >= h && o(a, r, i), a < h && console.log("TOO LATE", a), a += r, i++; - }; - let p; - const d = () => { - f(), p = setInterval(f, n * 1e3); - }, k = () => clearInterval(p); - return { setDuration: c, start: d, stop: () => { - i = 0, a = 0, k(); - }, pause: () => k(), duration: r, getPhase: () => a }; -} -class _e { - worker; - pattern; - started = !1; - cps = 1; - getTime; - phase = 0; - constructor({ interval: o, onTrigger: r, onError: n, getTime: s, latency: i = 0.1 }) { - this.getTime = s; - const a = (t) => Math.round(t * 1e3) / 1e3; - this.clock = Te( - s, - (t, l, c) => { - c === 0 && (this.origin = t); - const f = a(t - this.origin); - this.phase = f - i; - const p = a(f + l), d = s(); - try { - this.pattern.queryArc(f, p).forEach((g) => { - if (g.part.begin.equals(g.whole.begin)) { - const C = g.whole.begin + this.origin - d + i, v = g.duration * 1; - r?.(g, C, v); - } - }); - } catch (k) { - console.warn("scheduler error", k), n?.(k); - } - }, - o - ); - } - getPhase() { - return this.phase; - } - start() { - if (!this.pattern) - throw new Error("Scheduler: no pattern set! call .setPattern first."); - this.clock.start(), this.started = !0; - } - pause() { - this.clock.stop(), delete this.origin, this.started = !1; - } - stop() { - delete this.origin, this.clock.stop(), this.started = !1; - } - setPattern(o) { - this.pattern = o; - } - setCps(o = 1) { - this.cps = o; - } - log(o, r, n) { - const s = n.filter((i) => i.hasOnset()); - console.log(`${o.toFixed(4)} - ${r.toFixed(4)} ${Array(s.length).fill("I").join("")}`); - } -} -function Ve({ defaultOutput: e, interval: o, getTime: r, code: n, evalOnMount: s = !1 }) { - const [i, a] = w(), [t, l] = w(), [c, f] = w(n), [p, d] = w(), k = n !== c, g = R( - () => new _e({ interval: o, onTrigger: e, onError: a, getTime: r }), - [e, o] - ), C = T(async () => { - if (!n) { - console.log("no code.."); - return; - } - try { - const { pattern: h } = await G(n); - f(n), g?.setPattern(h), d(h), l(); - } catch (h) { - l(h), console.warn("eval error", h); - } - }, [n, g]), v = I(); - return q(() => { - !v.current && s && (v.current = !0, C()); - }, [C, s]), { schedulerError: i, scheduler: g, evalError: t, evaluate: C, activeCode: c, isDirty: k, pattern: p }; -} -const $e = (e) => j(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]); -function We(e) { - const { ready: o, connected: r, disconnected: n } = e, [s, i] = w(!0), [a, t] = w(N?.outputs || []); - return q(() => { - se().then(() => { - N.addListener("connected", (c) => { - t([...N.outputs]), r?.(N, c); - }), N.addListener("disconnected", (c) => { - t([...N.outputs]), n?.(N, c); - }), o?.(N), i(!1); - }).catch((c) => { - if (c) { - console.error(c), console.warn("Web Midi could not be enabled.."); - return; - } - }); - }, [o, r, n, a]), { loading: s, outputs: a, outputByName: (c) => N.getOutputByName(c) }; +function ze(e) { + return R(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), N((t) => window.postMessage(t, "*"), []); } +const He = (e) => K(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]); export { - de as CodeMirror, - Be as MiniRepl, - $ as cx, - ie as flash, - fe as useCycle, - ye as useHighlighting, - $e as useKeydown, - ge as usePostMessage, - pe as useRepl, - Ve as useStrudel, - We as useWebMidi + ie as CodeMirror, + Pe as MiniRepl, + B as cx, + ae as flash, + le as useHighlighting, + He as useKeydown, + ze as usePostMessage, + ve as useStrudel }; diff --git a/packages/react/dist/style.css b/packages/react/dist/style.css index ba86df52..b28aca62 100644 --- a/packages/react/dist/style.css +++ b/packages/react/dist/style.css @@ -1 +1 @@ -.cm-editor{background-color:transparent!important;height:100%;z-index:11;font-size:16px}.cm-theme-light{width:100%}.cm-line>*{background:#00000095}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sc-h-5{height:1.25rem}.sc-w-5{width:1.25rem}@keyframes sc-pulse{50%{opacity:.5}}.sc-animate-pulse{animation:sc-pulse 2s cubic-bezier(.4,0,.6,1) infinite}._container_3i85k_1{overflow:hidden;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(34 34 34 / var(--tw-bg-opacity))}._header_3i85k_5{display:flex;justify-content:space-between;border-top-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity))}._buttons_3i85k_9{display:flex}._button_3i85k_9{display:flex;width:4rem;cursor:pointer;align-items:center;justify-content:center;border-right-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}._button_3i85k_9:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity))}._buttonDisabled_3i85k_17{display:flex;width:4rem;cursor:pointer;cursor:not-allowed;align-items:center;justify-content:center;--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}._error_3i85k_21{padding:.25rem;text-align:right;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity))}._body_3i85k_25{position:relative;overflow:auto} +.cm-editor{background-color:transparent!important;height:100%;z-index:11;font-size:18px}.cm-theme-light{width:100%}.cm-line>*{background:#00000095}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sc-h-5{height:1.25rem}.sc-w-5{width:1.25rem}@keyframes sc-pulse{50%{opacity:.5}}.sc-animate-pulse{animation:sc-pulse 2s cubic-bezier(.4,0,.6,1) infinite}._container_3i85k_1{overflow:hidden;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(34 34 34 / var(--tw-bg-opacity))}._header_3i85k_5{display:flex;justify-content:space-between;border-top-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity))}._buttons_3i85k_9{display:flex}._button_3i85k_9{display:flex;width:4rem;cursor:pointer;align-items:center;justify-content:center;border-right-width:1px;--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}._button_3i85k_9:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity))}._buttonDisabled_3i85k_17{display:flex;width:4rem;cursor:pointer;cursor:not-allowed;align-items:center;justify-content:center;--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity));padding:.25rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}._error_3i85k_21{padding:.25rem;text-align:right;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity))}._body_3i85k_25{position:relative;overflow:auto} diff --git a/packages/react/examples/nano-repl/src/App.jsx b/packages/react/examples/nano-repl/src/App.jsx index f82618af..07c46f3e 100644 --- a/packages/react/examples/nano-repl/src/App.jsx +++ b/packages/react/examples/nano-repl/src/App.jsx @@ -82,7 +82,7 @@ function App() { view, pattern, active: !activeCode?.includes('strudel disable-highlighting'), - getTime: () => scheduler.phase, + getTime: () => scheduler.getPhase(), }); const error = evalError || schedulerError; diff --git a/packages/react/package.json b/packages/react/package.json index 8c6568a0..fe99a7c2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -17,6 +17,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "watch": "vite build --watch", "preview": "vite preview" }, "repository": { @@ -39,7 +40,8 @@ "dependencies": { "@codemirror/lang-javascript": "^6.1.1", "@strudel.cycles/core": "^0.3.2", - "@strudel.cycles/eval": "^0.3.2", + "@strudel.cycles/transpiler": "^0.3.2", + "@strudel.cycles/webaudio": "^0.3.3", "@strudel.cycles/tone": "^0.3.3", "@uiw/codemirror-themes": "^4.12.4", "@uiw/react-codemirror": "^4.12.4", diff --git a/packages/react/src/App.jsx b/packages/react/src/App.jsx index b22516da..911fca0b 100644 --- a/packages/react/src/App.jsx +++ b/packages/react/src/App.jsx @@ -6,7 +6,7 @@ import { controls, evalScope } from '@strudel.cycles/core'; evalScope( controls, import('@strudel.cycles/core'), - import('@strudel.cycles/tone'), + // import('@strudel.cycles/tone'), import('@strudel.cycles/tonal'), import('@strudel.cycles/mini'), import('@strudel.cycles/midi'), diff --git a/packages/react/src/components/MiniRepl.jsx b/packages/react/src/components/MiniRepl.jsx index 0fd6d8c0..ab035b6a 100644 --- a/packages/react/src/components/MiniRepl.jsx +++ b/packages/react/src/components/MiniRepl.jsx @@ -1,6 +1,5 @@ import React, { useState, useMemo, useRef, useEffect, useLayoutEffect } from 'react'; import { useInView } from 'react-hook-inview'; -import useRepl from '../hooks/useRepl.mjs'; import cx from '../cx'; import useHighlighting from '../hooks/useHighlighting.mjs'; import CodeMirror6, { flash } from './CodeMirror6'; @@ -8,18 +7,33 @@ import 'tailwindcss/tailwind.css'; import './style.css'; import styles from './MiniRepl.module.css'; import { Icon } from './Icon'; -import { Tone } from '@strudel.cycles/tone'; +import { getAudioContext, webaudioOutput } from '@strudel.cycles/webaudio'; +import useStrudel from '../hooks/useStrudel.mjs'; -export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableKeyboard }) { - const { code, setCode, pattern, activeCode, activateCode, evaluateOnly, error, cycle, dirty, togglePlay, stop } = - useRepl({ - tune, - autolink: false, - onEvent, - }); - useEffect(() => { - init && evaluateOnly(); - }, [tune, init]); +const getTime = () => getAudioContext().currentTime; + +export function MiniRepl({ tune, hideOutsideView = false, init, enableKeyboard }) { + const { + code, + setCode, + evaluate, + activateCode, + error, + isDirty, + activeCode, + pattern, + started, + scheduler, + togglePlay, + stop, + } = useStrudel({ + initialCode: tune, + defaultOutput: webaudioOutput, + getTime, + }); + /* useEffect(() => { + init && activateCode(); + }, [init, activateCode]); */ const [view, setView] = useState(); const [ref, isVisible] = useInView({ threshold: 0.01, @@ -34,8 +48,8 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK useHighlighting({ view, pattern, - active: cycle.started && !activeCode?.includes('strudel disable-highlighting'), - getTime: () => Tone.getTransport().seconds, + active: started && !activeCode?.includes('strudel disable-highlighting'), + getTime: () => scheduler.getPhase(), }); // set active pattern on ctrl+enter @@ -48,7 +62,7 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK flash(view); await activateCode(); } else if (e.code === 'Period') { - cycle.stop(); + stop(); e.preventDefault(); } } @@ -56,16 +70,16 @@ export function MiniRepl({ tune, hideOutsideView = false, init, onEvent, enableK window.addEventListener('keydown', handleKeyPress, true); return () => window.removeEventListener('keydown', handleKeyPress, true); } - }, [enableKeyboard, pattern, code, activateCode, cycle, view]); + }, [enableKeyboard, pattern, code, evaluate, stop, view]); return (
- -
diff --git a/packages/react/src/components/style.css b/packages/react/src/components/style.css index 767aa71d..d3aed9d7 100644 --- a/packages/react/src/components/style.css +++ b/packages/react/src/components/style.css @@ -2,7 +2,7 @@ background-color: transparent !important; height: 100%; z-index: 11; - font-size: 16px; + font-size: 18px; } .cm-theme-light { diff --git a/packages/react/src/hooks/useCycle.mjs b/packages/react/src/hooks/useCycle.mjs deleted file mode 100644 index 5a68c9da..00000000 --- a/packages/react/src/hooks/useCycle.mjs +++ /dev/null @@ -1,86 +0,0 @@ -/* -useCycle.mjs - -Copyright (C) 2022 Strudel contributors - see -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 . -*/ - -import { useEffect, useState } from 'react'; -import { Tone } from '@strudel.cycles/tone'; -import { State, TimeSpan } from '@strudel.cycles/core'; - -/* export declare interface UseCycleProps { - onEvent: ToneEventCallback; - onQuery?: (state: State) => Hap[]; - onSchedule?: (events: Hap[], cycle: number) => void; - onDraw?: ToneEventCallback; - ready?: boolean; // if false, query will not be called on change props -} */ - -// function useCycle(props: UseCycleProps) { -function useCycle(props) { - // onX must use useCallback! - const { onEvent, onQuery, onSchedule, ready = true, onDraw } = props; - const [started, setStarted] = useState(false); - const cycleDuration = 1; - const activeCycle = () => Math.floor(Tone.getTransport().seconds / cycleDuration); - - // pull events with onQuery + count up to next cycle - const query = (cycle = activeCycle()) => { - const timespan = new TimeSpan(cycle, cycle + 1); - const events = onQuery?.(new State(timespan)) || []; - onSchedule?.(events, cycle); - // cancel events after current query. makes sure no old events are player for rescheduled cycles - // console.log('schedule', cycle); - // query next cycle in the middle of the current - const cancelFrom = timespan.begin.valueOf(); - Tone.getTransport().cancel(cancelFrom); - // const queryNextTime = (cycle + 1) * cycleDuration - 0.1; - const queryNextTime = (cycle + 1) * cycleDuration - 0.5; - - // if queryNextTime would be before current time, execute directly (+0.1 for safety that it won't miss) - const t = Math.max(Tone.getTransport().seconds, queryNextTime) + 0.1; - Tone.getTransport().schedule(() => { - query(cycle + 1); - }, t); - - // schedule events for next cycle - events - ?.filter((event) => event.part.begin.equals(event.whole?.begin)) - .forEach((event) => { - Tone.getTransport().schedule((time) => { - onEvent(time, event, Tone.getContext().currentTime); - Tone.Draw.schedule(() => { - // do drawing or DOM manipulation here - onDraw?.(time, event); - }, time); - }, event.part.begin.valueOf()); - }); - }; - - useEffect(() => { - ready && query(); - }, [onEvent, onSchedule, onQuery, onDraw, ready]); - - const start = async () => { - setStarted(true); - await Tone.start(); - Tone.getTransport().start('+0.1'); - }; - const stop = () => { - Tone.getTransport().pause(); - setStarted(false); - }; - const toggle = () => (started ? stop() : start()); - return { - start, - stop, - onEvent, - started, - setStarted, - toggle, - query, - activeCycle, - }; -} - -export default useCycle; diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index ee9dd473..c9539044 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -14,15 +14,14 @@ function useHighlighting({ view, pattern, active, getTime }) { const audioTime = getTime(); // force min framerate of 10 fps => fixes crash on tab refocus, where lastEnd could be far away // see https://github.com/tidalcycles/strudel/issues/108 - const begin = Math.max(lastEnd.current || audioTime, audioTime - 1 / 10); + const begin = Math.max(lastEnd.current || audioTime, audioTime - 1 / 10, 0); // negative time seems buggy const span = [begin, audioTime + 1 / 60]; - lastEnd.current = audioTime + 1 / 60; + lastEnd.current = span[1]; highlights.current = highlights.current.filter((hap) => hap.whole.end > audioTime); // keep only highlights that are still active const haps = pattern.queryArc(...span).filter((hap) => hap.hasOnset()); highlights.current = highlights.current.concat(haps); // add potential new onsets view.dispatch({ effects: setHighlights.of(highlights.current) }); // highlight all still active + new active haps } catch (err) { - // console.log('error in updateHighlights', err); view.dispatch({ effects: setHighlights.of([]) }); } frame = requestAnimationFrame(updateHighlights); diff --git a/packages/react/src/hooks/useRepl.mjs b/packages/react/src/hooks/useRepl.mjs deleted file mode 100644 index 6da1b887..00000000 --- a/packages/react/src/hooks/useRepl.mjs +++ /dev/null @@ -1,150 +0,0 @@ -/* -useRepl.mjs - -Copyright (C) 2022 Strudel contributors - see -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 . -*/ - -import { useCallback, useState, useMemo } from 'react'; -import { evaluate } from '@strudel.cycles/eval'; -import useCycle from './useCycle.mjs'; -import usePostMessage from './usePostMessage.mjs'; -import { webaudioOutputTrigger } from '@strudel.cycles/webaudio'; - -let s4 = () => { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -}; -const generateHash = (code) => encodeURIComponent(btoa(code)); - -function useRepl({ tune, autolink = true, onEvent, onDraw: onDrawProp }) { - const id = useMemo(() => s4(), []); - const [code, setCode] = useState(tune); - const [activeCode, setActiveCode] = useState(); - const [log, setLog] = useState(''); - const [error, setError] = useState(); - const [pending, setPending] = useState(false); - const [hash, setHash] = useState(''); - const [pattern, setPattern] = useState(); - const dirty = useMemo(() => code !== activeCode || error, [code, activeCode, error]); - const pushLog = useCallback((message) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`), []); - - // below block allows disabling the highlighting by including "strudel disable-highlighting" in the code (as comment) - const onDraw = useMemo(() => { - if (activeCode && !activeCode.includes('strudel disable-highlighting')) { - return (time, event) => onDrawProp?.(time, event, activeCode); - } - }, [activeCode, onDrawProp]); - - const hideHeader = useMemo(() => activeCode && activeCode.includes('strudel hide-header'), [activeCode]); - const hideConsole = useMemo(() => activeCode && activeCode.includes('strudel hide-console'), [activeCode]); - // cycle hook to control scheduling - const cycle = useCycle({ - onDraw, - onEvent: useCallback( - (time, event, currentTime) => { - try { - onEvent?.(event); - if (event.context.logs?.length) { - event.context.logs.forEach(pushLog); - } - const { onTrigger = webaudioOutputTrigger } = event.context; - onTrigger(time, event, currentTime, 1 /* cps */); - } catch (err) { - console.warn(err); - err.message = 'unplayable event: ' + err?.message; - pushLog(err.message); // not with setError, because then we would have to setError(undefined) on next playable event - } - }, - [onEvent, pushLog], - ), - onQuery: useCallback( - (state) => { - try { - return pattern?.query(state) || []; - } catch (err) { - console.warn(err); - err.message = 'query error: ' + err.message; - setError(err); - return []; - } - }, - [pattern], - ), - onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), []), - ready: !!pattern && !!activeCode, - }); - - const broadcast = usePostMessage(({ data: { from, type } }) => { - if (type === 'start' && from !== id) { - // console.log('message', from, type); - cycle.setStarted(false); - setActiveCode(undefined); - } - }); - - const activateCode = useCallback( - async (_code = code) => { - if (activeCode && !dirty) { - setError(undefined); - cycle.start(); - return; - } - try { - setPending(true); - const parsed = await evaluate(_code); - cycle.start(); - broadcast({ type: 'start', from: id }); - setPattern(() => parsed.pattern); - if (autolink) { - window.location.hash = '#' + encodeURIComponent(btoa(code)); - } - setHash(generateHash(code)); - setError(undefined); - setActiveCode(_code); - setPending(false); - } catch (err) { - err.message = 'evaluation error: ' + err.message; - console.warn(err); - setError(err); - } - }, - [activeCode, dirty, code, cycle, autolink, id, broadcast], - ); - // logs events of cycle - const logCycle = (_events, cycle) => { - if (_events.length) { - // pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n')); - } - }; - - const togglePlay = () => { - if (!cycle.started) { - activateCode(); - } else { - cycle.stop(); - } - }; - - return { - hideHeader, - hideConsole, - pending, - code, - setCode, - pattern, - error, - cycle, - setPattern, - dirty, - log, - togglePlay, - setActiveCode, - activateCode, - activeCode, - pushLog, - hash, - }; -} - -export default useRepl; diff --git a/packages/react/src/hooks/useStrudel.mjs b/packages/react/src/hooks/useStrudel.mjs index 81569f65..64668105 100644 --- a/packages/react/src/hooks/useStrudel.mjs +++ b/packages/react/src/hooks/useStrudel.mjs @@ -1,43 +1,105 @@ import { useRef, useCallback, useEffect, useMemo, useState } from 'react'; -import { repl } from '@strudel.cycles/core/repl.mjs'; +import { repl } from '@strudel.cycles/core'; import { transpiler } from '@strudel.cycles/transpiler'; -function useStrudel({ defaultOutput, interval, getTime, code, evalOnMount = false }) { +function useStrudel({ + defaultOutput, + interval, + getTime, + evalOnMount = false, + initialCode = '', + autolink = false, + beforeEval, + afterEval, + onEvalError, + onToggle, +}) { // scheduler const [schedulerError, setSchedulerError] = useState(); const [evalError, setEvalError] = useState(); + const [code, setCode] = useState(initialCode); const [activeCode, setActiveCode] = useState(code); const [pattern, setPattern] = useState(); + const [started, setStarted] = useState(false); const isDirty = code !== activeCode; - const { scheduler, evaluate: _evaluate } = useMemo( + + // TODO: make sure this hook reruns when scheduler.started changes + const { scheduler, evaluate, start, stop, pause } = useMemo( () => repl({ interval, defaultOutput, onSchedulerError: setSchedulerError, - onEvalError: setEvalError, + onEvalError: (err) => { + setEvalError(err); + onEvalError?.(err); + }, getTime, transpiler, - onEval: ({ pattern: _pattern, code }) => { + beforeEval: ({ code }) => { + setCode(code); + beforeEval?.(); + }, + afterEval: ({ pattern: _pattern, code }) => { setActiveCode(code); setPattern(_pattern); setEvalError(); + setSchedulerError(); + if (autolink) { + window.location.hash = '#' + encodeURIComponent(btoa(code)); + } + afterEval?.(); + }, + onToggle: (v) => { + setStarted(v); + onToggle?.(v); }, - onEvalError: setEvalError, }), [defaultOutput, interval, getTime], ); - const evaluate = useCallback(() => _evaluate(code), [_evaluate, code]); + const activateCode = useCallback(async (autostart = true) => evaluate(code, autostart), [evaluate, code]); const inited = useRef(); useEffect(() => { if (!inited.current && evalOnMount && code) { inited.current = true; - evaluate(); + activateCode(); } - }, [evaluate, evalOnMount, code]); + }, [activateCode, evalOnMount, code]); - return { schedulerError, scheduler, evalError, evaluate, activeCode, isDirty, pattern }; + // this will stop the scheduler when hot reloading in development + useEffect(() => { + return () => { + scheduler.stop(); + }; + }, [scheduler]); + + const togglePlay = async () => { + if (started) { + scheduler.pause(); + } else { + await activateCode(); + } + }; + const error = schedulerError || evalError; + return { + code, + setCode, + error, + schedulerError, + scheduler, + evalError, + evaluate, + activateCode, + activeCode, + isDirty, + pattern, + started, + start, + stop, + pause, + togglePlay, + }; } export default useStrudel; diff --git a/packages/react/src/hooks/useWebMidi.mjs b/packages/react/src/hooks/useWebMidi.mjs deleted file mode 100644 index ee3f0db3..00000000 --- a/packages/react/src/hooks/useWebMidi.mjs +++ /dev/null @@ -1,41 +0,0 @@ -/* -useWebMidi.js - -Copyright (C) 2022 Strudel contributors - see -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 . -*/ - -import { useEffect, useState } from 'react'; -import { enableWebMidi, WebMidi } from '@strudel.cycles/midi' - -export function useWebMidi(props) { - const { ready, connected, disconnected } = props; - const [loading, setLoading] = useState(true); - const [outputs, setOutputs] = useState(WebMidi?.outputs || []); - useEffect(() => { - enableWebMidi() - .then(() => { - // Reacting when a new device becomes available - WebMidi.addListener('connected', (e) => { - setOutputs([...WebMidi.outputs]); - connected?.(WebMidi, e); - }); - // Reacting when a device becomes unavailable - WebMidi.addListener('disconnected', (e) => { - setOutputs([...WebMidi.outputs]); - disconnected?.(WebMidi, e); - }); - ready?.(WebMidi); - setLoading(false); - }) - .catch((err) => { - if (err) { - console.error(err); - //throw new Error("Web Midi could not be enabled..."); - console.warn('Web Midi could not be enabled..'); - return; - } - }); - }, [ready, connected, disconnected, outputs]); - const outputByName = (name) => WebMidi.getOutputByName(name); - return { loading, outputs, outputByName }; -} diff --git a/packages/react/src/index.js b/packages/react/src/index.js index e1b0fe0a..e8a4c447 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -2,11 +2,8 @@ export { default as CodeMirror, flash } from './components/CodeMirror6'; export * from './components/MiniRepl'; -export { default as useCycle } from './hooks/useCycle'; export { default as useHighlighting } from './hooks/useHighlighting'; export { default as usePostMessage } from './hooks/usePostMessage'; -export { default as useRepl } from './hooks/useRepl'; export { default as useStrudel } from './hooks/useStrudel'; export { default as useKeydown } from './hooks/useKeydown'; export { default as cx } from './cx'; -export { useWebMidi } from './hooks/useWebMidi'; diff --git a/packages/react/src/themes/strudel-theme.js b/packages/react/src/themes/strudel-theme.js index 514883a5..36987c33 100644 --- a/packages/react/src/themes/strudel-theme.js +++ b/packages/react/src/themes/strudel-theme.js @@ -8,28 +8,36 @@ export default createTheme({ caret: '#ffcc00', selection: 'rgba(128, 203, 196, 0.5)', selectionMatch: '#036dd626', - lineHighlight: '#8a91991a', + // lineHighlight: '#8a91991a', // original + lineHighlight: '#00000050', gutterBackground: 'transparent', // gutterForeground: '#8a919966', - gutterForeground: '#676e95', + gutterForeground: '#8a919966', }, styles: [ { tag: t.keyword, color: '#c792ea' }, { tag: t.operator, color: '#89ddff' }, { tag: t.special(t.variableName), color: '#eeffff' }, - { tag: t.typeName, color: '#f07178' }, + // { tag: t.typeName, color: '#f07178' }, // original + { tag: t.typeName, color: '#c3e88d' }, { tag: t.atom, color: '#f78c6c' }, - { tag: t.number, color: '#ff5370' }, + // { tag: t.number, color: '#ff5370' }, // original + { tag: t.number, color: '#c3e88d' }, { tag: t.definition(t.variableName), color: '#82aaff' }, { tag: t.string, color: '#c3e88d' }, - { tag: t.special(t.string), color: '#f07178' }, + // { tag: t.special(t.string), color: '#f07178' }, // original + { tag: t.special(t.string), color: '#c3e88d' }, { tag: t.comment, color: '#7d8799' }, - { tag: t.variableName, color: '#f07178' }, - { tag: t.tagName, color: '#ff5370' }, - { tag: t.bracket, color: '#a2a1a4' }, + // { tag: t.variableName, color: '#f07178' }, // original + { tag: t.variableName, color: '#c792ea' }, + // { tag: t.tagName, color: '#ff5370' }, // original + { tag: t.tagName, color: '#c3e88d' }, + { tag: t.bracket, color: '#525154' }, + // { tag: t.bracket, color: '#a2a1a4' }, // original { tag: t.meta, color: '#ffcb6b' }, { tag: t.attributeName, color: '#c792ea' }, { tag: t.propertyName, color: '#c792ea' }, + { tag: t.className, color: '#decb6b' }, { tag: t.invalid, color: '#ffffff' }, ], diff --git a/packages/react/vite.config.js b/packages/react/vite.config.js index 6786d022..a7499a39 100644 --- a/packages/react/vite.config.js +++ b/packages/react/vite.config.js @@ -24,8 +24,9 @@ export default defineConfig({ // TODO: find out which of below names are obsolete now '@strudel.cycles/tone', '@strudel.cycles/eval', + '@strudel.cycles/transpiler', + 'acorn', '@strudel.cycles/core', - '@strudel.cycles/core/util.mjs', '@strudel.cycles/mini', '@strudel.cycles/tonal', '@strudel.cycles/midi', diff --git a/packages/serial/serial.mjs b/packages/serial/serial.mjs index c2202b14..4452195d 100644 --- a/packages/serial/serial.mjs +++ b/packages/serial/serial.mjs @@ -9,7 +9,7 @@ import { Pattern, isPattern } from '@strudel.cycles/core'; var serialWriter; var choosing = false; -export async function getWriter(br=38400) { +export async function getWriter(br = 38400) { if (choosing) { return; } @@ -24,11 +24,10 @@ export async function getWriter(br=38400) { const writableStreamClosed = textEncoder.readable.pipeTo(port.writable); const writer = textEncoder.writable.getWriter(); serialWriter = function (message) { - writer.write(message) - } - } - else { - throw('Webserial is not available in this browser.') + writer.write(message); + }; + } else { + throw 'Webserial is not available in this browser.'; } } @@ -40,7 +39,7 @@ Pattern.prototype.serial = function (...args) { getWriter(...args); } const onTrigger = (time, hap, currentTime) => { - var message = ""; + var message = ''; if (typeof hap.value === 'object') { if ('action' in hap.value) { message += hap.value['action'] + '('; @@ -51,26 +50,23 @@ Pattern.prototype.serial = function (...args) { } if (first) { first = false; + } else { + message += ','; } - else { - message +=','; - } - message += `${key}:${val}` + message += `${key}:${val}`; } message += ')'; - } - else { + } else { for (const [key, val] of Object.entries(hap.value)) { - message += `${key}:${val};` + message += `${key}:${val};`; } } - } - else { + } else { message = hap.value; } const offset = (time - currentTime + latency) * 1000; window.setTimeout(serialWriter, offset, message); }; - return hap.setContext({ ...hap.context, onTrigger }); + return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true }); }); }; diff --git a/packages/tone/index.mjs b/packages/tone/index.mjs index 537156fd..021d7718 100644 --- a/packages/tone/index.mjs +++ b/packages/tone/index.mjs @@ -1,5 +1 @@ -import './pianoroll.mjs'; - export * from './tone.mjs'; -export * from './draw.mjs'; -export * from './ui.mjs'; diff --git a/packages/tone/tone.mjs b/packages/tone/tone.mjs index 47ab0f7e..b5ea4b9e 100644 --- a/packages/tone/tone.mjs +++ b/packages/tone/tone.mjs @@ -50,32 +50,29 @@ export const getDefaultSynth = () => { // with this function, you can play the pattern with any tone synth Pattern.prototype.tone = function (instrument) { - return this._withHap((hap) => { - const onTrigger = (time, hap) => { - let note; - let velocity = hap.context?.velocity ?? 0.75; - if (instrument instanceof PluckSynth) { - note = getPlayableNoteValue(hap); - instrument.triggerAttack(note, time); - } else if (instrument instanceof NoiseSynth) { - instrument.triggerAttackRelease(hap.duration.valueOf(), time); // noise has no value - } else if (instrument instanceof Sampler) { - note = getPlayableNoteValue(hap); - instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity); - } else if (instrument instanceof Players) { - if (!instrument.has(hap.value)) { - throw new Error(`name "${hap.value}" not defined for players`); - } - const player = instrument.player(hap.value); - // velocity ? - player.start(time); - player.stop(time + hap.duration.valueOf()); - } else { - note = getPlayableNoteValue(hap); - instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity); + return this.onTrigger((time, hap) => { + let note; + let velocity = hap.context?.velocity ?? 0.75; + if (instrument instanceof PluckSynth) { + note = getPlayableNoteValue(hap); + instrument.triggerAttack(note, time); + } else if (instrument instanceof NoiseSynth) { + instrument.triggerAttackRelease(hap.duration.valueOf(), time); // noise has no value + } else if (instrument instanceof Sampler) { + note = getPlayableNoteValue(hap); + instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity); + } else if (instrument instanceof Players) { + if (!instrument.has(hap.value)) { + throw new Error(`name "${hap.value}" not defined for players`); } - }; - return hap.setContext({ ...hap.context, instrument, onTrigger }); + const player = instrument.player(hap.value); + // velocity ? + player.start(time); + player.stop(time + hap.duration.valueOf()); + } else { + note = getPlayableNoteValue(hap); + instrument.triggerAttackRelease(note, hap.duration.valueOf(), time, velocity); + } }); }; diff --git a/packages/webaudio/sampler.mjs b/packages/webaudio/sampler.mjs index 1be618c5..59dffb7c 100644 --- a/packages/webaudio/sampler.mjs +++ b/packages/webaudio/sampler.mjs @@ -1,13 +1,87 @@ +import { logger } from '@strudel.cycles/core'; + const bufferCache = {}; // string: Promise const loadCache = {}; // string: Promise export const getCachedBuffer = (url) => bufferCache[url]; -export const loadBuffer = (url, ac) => { +function humanFileSize(bytes, si) { + var thresh = si ? 1000 : 1024; + if (bytes < thresh) return bytes + ' B'; + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + var u = -1; + do { + bytes /= thresh; + ++u; + } while (bytes >= thresh); + return bytes.toFixed(1) + ' ' + units[u]; +} + +export const getSampleBufferSource = async (s, n, note, speed) => { + let transpose = 0; + let midi = typeof note === 'string' ? toMidi(note) : note || 36; + transpose = midi - 36; // C3 is middle C + + const ac = getAudioContext(); + // is sample from loaded samples(..) + const samples = getLoadedSamples(); + if (!samples) { + throw new Error('no samples loaded'); + } + const bank = samples?.[s]; + if (!bank) { + throw new Error( + `sample not found: "${s}"`, + // , try one of ${Object.keys(samples) + // .map((s) => `"${s}"`) + // .join(', ')}. + ); + } + if (typeof bank !== 'object') { + throw new Error('wrong format for sample bank:', s); + } + let sampleUrl; + if (Array.isArray(bank)) { + sampleUrl = bank[n % bank.length]; + } else { + const midiDiff = (noteA) => toMidi(noteA) - midi; + // object format will expect keys as notes + const closest = Object.keys(bank) + .filter((k) => !k.startsWith('_')) + .reduce( + (closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest), + null, + ); + transpose = -midiDiff(closest); // semitones to repitch + sampleUrl = bank[closest][n % bank[closest].length]; + } + let buffer = await loadBuffer(sampleUrl, ac, s, n); + if (speed < 0) { + // should this be cached? + buffer = reverseBuffer(buffer); + } + const bufferSource = ac.createBufferSource(); + bufferSource.buffer = buffer; + const playbackRate = 1.0 * Math.pow(2, transpose / 12); + // bufferSource.playbackRate.value = Math.pow(2, transpose / 12); + bufferSource.playbackRate.value = playbackRate; + return bufferSource; +}; + +export const loadBuffer = (url, ac, s, n = 0) => { + const label = s ? `sound "${s}:${n}"` : 'sample'; if (!loadCache[url]) { + logger(`[sampler] load ${label}..`, 'load-sample', { url }); + const timestamp = Date.now(); loadCache[url] = fetch(url) .then((res) => res.arrayBuffer()) .then(async (res) => { + const took = Date.now() - timestamp; + const size = humanFileSize(res.byteLength); + // const downSpeed = humanFileSize(res.byteLength / took); + logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); const decoded = await ac.decodeAudioData(res); bufferCache[url] = decoded; return decoded; @@ -29,66 +103,7 @@ export const getLoadedBuffer = (url) => { return bufferCache[url]; }; -/* export const playBuffer = (buffer, time = ac.currentTime, destination = ac.destination) => { - const src = ac.createBufferSource(); - src.buffer = buffer; - src.connect(destination); - src.start(time); -}; - -export const playSample = async (url) => playBuffer(await loadBuffer(url)); */ - -// https://estuary.mcmaster.ca/samples/resources.json -// Array<{ "url":string, "bank": string, "n": number}> -// ritchse/tidal-drum-machines/tree/main/machines/AkaiLinn -const githubCache = {}; let sampleCache = { current: undefined }; -export const loadGithubSamples = async (path, nameFn) => { - const storageKey = 'loadGithubSamples ' + path; - const stored = localStorage.getItem(storageKey); - if (stored) { - console.log('[sampler]: loaded sample list from localstorage', path); - githubCache[path] = JSON.parse(stored); - } - if (githubCache[path]) { - sampleCache.current = githubCache[path]; - return githubCache[path]; - } - console.log('[sampler]: fetching sample list from github', path); - try { - const [user, repo, ...folders] = path.split('/'); - const baseUrl = `https://api.github.com/repos/${user}/${repo}/contents`; - const banks = await fetch(`${baseUrl}/${folders.join('/')}`).then((res) => res.json()); - // fetch each subfolder - githubCache[path] = ( - await Promise.all( - banks.map(async ({ name, path }) => ({ - name, - content: await fetch(`${baseUrl}/${path}`) - .then((res) => res.json()) - .catch((err) => { - console.error('could not load path', err); - }), - })), - ) - ) - .filter(({ content }) => !!content) - .reduce( - (acc, { name, content }) => ({ - ...acc, - [nameFn?.(name) || name]: content.map(({ download_url }) => download_url), - }), - {}, - ); - } catch (err) { - console.error('[sampler]: failed to fetch sample list from github', err); - return; - } - sampleCache.current = githubCache[path]; - localStorage.setItem(storageKey, JSON.stringify(sampleCache.current)); - console.log('[sampler]: loaded samples:', sampleCache.current); - return githubCache[path]; -}; /** * Loads a collection of samples to use with `s` diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 58d6871c..de93cdbe 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -6,10 +6,10 @@ This program is free software: you can redistribute it and/or modify it under th // import { Pattern, getFrequency, patternify2 } from '@strudel.cycles/core'; import * as strudel from '@strudel.cycles/core'; -import { fromMidi, isNote, toMidi } from '@strudel.cycles/core'; +import { fromMidi, logger, toMidi } from '@strudel.cycles/core'; import './feedbackdelay.mjs'; import './reverb.mjs'; -import { loadBuffer, reverseBuffer } from './sampler.mjs'; +import { getSampleBufferSource } from './sampler.mjs'; const { Pattern } = strudel; import './vowel.mjs'; import workletsUrl from './worklets.mjs?url'; @@ -98,56 +98,6 @@ const getSoundfontKey = (s) => { return; }; -const getSampleBufferSource = async (s, n, note, speed) => { - let transpose = 0; - let midi = typeof note === 'string' ? toMidi(note) : note || 36; - transpose = midi - 36; // C3 is middle C - - const ac = getAudioContext(); - // is sample from loaded samples(..) - const samples = getLoadedSamples(); - if (!samples) { - throw new Error('no samples loaded'); - } - const bank = samples?.[s]; - if (!bank) { - throw new Error( - `sample not found: "${s}", try one of ${Object.keys(samples) - .map((s) => `"${s}"`) - .join(', ')}.`, - ); - } - if (typeof bank !== 'object') { - throw new Error('wrong format for sample bank:', s); - } - let sampleUrl; - if (Array.isArray(bank)) { - sampleUrl = bank[n % bank.length]; - } else { - const midiDiff = (noteA) => toMidi(noteA) - midi; - // object format will expect keys as notes - const closest = Object.keys(bank) - .filter((k) => !k.startsWith('_')) - .reduce( - (closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest), - null, - ); - transpose = -midiDiff(closest); // semitones to repitch - sampleUrl = bank[closest][n % bank[closest].length]; - } - let buffer = await loadBuffer(sampleUrl, ac); - if (speed < 0) { - // should this be cached? - buffer = reverseBuffer(buffer); - } - const bufferSource = ac.createBufferSource(); - bufferSource.buffer = buffer; - const playbackRate = 1.0 * Math.pow(2, transpose / 12); - // bufferSource.playbackRate.value = Math.pow(2, transpose / 12); - bufferSource.playbackRate.value = playbackRate; - return bufferSource; -}; - const splitSN = (s, n) => { if (!s.includes(':')) { return [s, n]; @@ -176,14 +126,25 @@ function getWorklet(ac, processor, params) { return node; } -if (typeof window !== 'undefined') { - try { - loadWorklets(); - } catch (err) { - console.warn('could not load AudioWorklet effects coarse, crush and shape', err); +// this function should be called on first user interaction (to avoid console warning) +export function initAudio() { + if (typeof window !== 'undefined') { + try { + getAudioContext().resume(); + loadWorklets(); + } catch (err) { + console.warn('could not load AudioWorklet effects coarse, crush and shape', err); + } } } +export function initAudioOnFirstClick() { + document.addEventListener('click', function listener() { + initAudio(); + document.removeEventListener('click', listener); + }); +} + function gainNode(value) { const node = getAudioContext().createGain(); node.gain.value = value; @@ -229,202 +190,194 @@ function effectSend(input, effect, wet) { // export const webaudioOutput = async (t, hap, ct, cps) => { export const webaudioOutput = async (hap, deadline, hapDuration) => { - try { - const ac = getAudioContext(); - /* if (isNote(hap.value)) { + const ac = getAudioContext(); + /* if (isNote(hap.value)) { // supports primitive hap values that look like notes hap.value = { note: hap.value }; } */ - if (typeof hap.value !== 'object') { - throw new Error( - `hap.value ${hap.value} is not supported by webaudio output. Hint: append .note() or .s() to the end`, - ); - } - // calculate correct time (tone.js workaround) - let t = ac.currentTime + deadline; - // destructure value - let { - freq, - s, - bank, - sf, - clip = 0, // if 1, samples will be cut off when the hap ends - n = 0, - note, - gain = 0.8, - cutoff, - resonance = 1, - hcutoff, - hresonance = 1, - bandf, - bandq = 1, - coarse, - crush, - shape, - pan, - speed = 1, // sample playback speed - begin = 0, - end = 1, - vowel, - delay = 0, - delayfeedback = 0.5, - delaytime = 0.25, - unit, - nudge = 0, // TODO: is this in seconds? - cut, - loop, - orbit = 1, - room, - size = 2, - roomsize = size, - } = hap.value; - const { velocity = 1 } = hap.context; - gain *= velocity; // legacy fix for velocity - // the chain will hold all audio nodes that connect to each other - const chain = []; - if (bank && s) { - s = `${bank}_${s}`; - } - if (typeof s === 'string') { - [s, n] = splitSN(s, n); - } - if (typeof note === 'string') { - [note, n] = splitSN(note, n); - } - if (!s || ['sine', 'square', 'triangle', 'sawtooth'].includes(s)) { - // destructure adsr here, because the default should be different for synths and samples - const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = hap.value; - // with synths, n and note are the same thing - n = note || n || 36; - if (typeof n === 'string') { - n = toMidi(n); // e.g. c3 => 48 - } - // get frequency - if (!freq && typeof n === 'number') { - freq = fromMidi(n); // + 48); - } - // make oscillator - const o = getOscillator({ t, s, freq, duration: hapDuration, release }); - chain.push(o); - // level down oscillators as they are really loud compared to samples i've tested - chain.push(gainNode(0.3)); - // TODO: make adsr work with samples without pops - // envelope - const adsr = getADSR(attack, decay, sustain, release, 1, t, t + hapDuration); - chain.push(adsr); - } else { - // destructure adsr here, because the default should be different for synths and samples - const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = hap.value; - // load sample - if (speed === 0) { - // no playback - return; - } - if (!s) { - console.warn('no sample specified'); - return; - } - const soundfont = getSoundfontKey(s); - let bufferSource; - - try { - if (soundfont) { - // is soundfont - bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac); - } else { - // is sample from loaded samples(..) - bufferSource = await getSampleBufferSource(s, n, note, speed); - } - } catch (err) { - console.warn(err); - return; - } - // asny stuff above took too long? - if (ac.currentTime > t) { - console.warn('sample still loading:', s, n); - return; - } - if (!bufferSource) { - console.warn('no buffer source'); - return; - } - bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; - if (unit === 'c') { - // are there other units? - bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration; - } - let duration = soundfont || clip ? hapDuration : bufferSource.buffer.duration / bufferSource.playbackRate.value; - // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, - // rather than the current playback rate, so even if the sound is playing at twice its normal speed, - // the midway point through a 10-second audio buffer is still 5." - const offset = begin * duration * bufferSource.playbackRate.value; - duration = (end - begin) * duration; - if (loop) { - bufferSource.loop = true; - bufferSource.loopStart = offset; - bufferSource.loopEnd = offset + duration; - duration = loop * duration; - } - t += nudge; - - bufferSource.start(t, offset); - if (cut !== undefined) { - cutGroups[cut]?.stop(t); // fade out? - cutGroups[cut] = bufferSource; - } - chain.push(bufferSource); - bufferSource.stop(t + duration + release); - const adsr = getADSR(attack, decay, sustain, release, 1, t, t + duration); - chain.push(adsr); - } - - // gain stage - chain.push(gainNode(gain)); - - // filters - cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance)); - hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance)); - bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq)); - vowel !== undefined && chain.push(ac.createVowelFilter(vowel)); - - // effects - coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); - crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); - shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape })); - - // panning - if (pan !== undefined) { - const panner = ac.createStereoPanner(); - panner.pan.value = 2 * pan - 1; - chain.push(panner); - } - - // last gain - const post = gainNode(1); - chain.push(post); - post.connect(getDestination()); - - // delay - let delaySend; - if (delay > 0 && delaytime > 0 && delayfeedback > 0) { - const delyNode = getDelay(orbit, delaytime, delayfeedback, t); - delaySend = effectSend(post, delyNode, delay); - } - // reverb - let reverbSend; - if (room > 0 && roomsize > 0) { - const reverbNode = getReverb(orbit, roomsize); - reverbSend = effectSend(post, reverbNode, room); - } - - // connect chain elements together - chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); - - // disconnect all nodes when source node has ended: - chain[0].onended = () => chain.concat([delaySend, reverbSend]).forEach((n) => n?.disconnect()); - } catch (e) { - console.warn('.out error:', e); + if (typeof hap.value !== 'object') { + throw new Error( + `hap.value ${hap.value} is not supported by webaudio output. Hint: append .note() or .s() to the end`, + ); } + // calculate correct time (tone.js workaround) + let t = ac.currentTime + deadline; + // destructure value + let { + freq, + s, + bank, + sf, + clip = 0, // if 1, samples will be cut off when the hap ends + n = 0, + note, + gain = 0.8, + cutoff, + resonance = 1, + hcutoff, + hresonance = 1, + bandf, + bandq = 1, + coarse, + crush, + shape, + pan, + speed = 1, // sample playback speed + begin = 0, + end = 1, + vowel, + delay = 0, + delayfeedback = 0.5, + delaytime = 0.25, + unit, + nudge = 0, // TODO: is this in seconds? + cut, + loop, + orbit = 1, + room, + size = 2, + roomsize = size, + } = hap.value; + const { velocity = 1 } = hap.context; + gain *= velocity; // legacy fix for velocity + // the chain will hold all audio nodes that connect to each other + const chain = []; + if (bank && s) { + s = `${bank}_${s}`; + } + if (typeof s === 'string') { + [s, n] = splitSN(s, n); + } + if (typeof note === 'string') { + [note, n] = splitSN(note, n); + } + if (!s || ['sine', 'square', 'triangle', 'sawtooth'].includes(s)) { + // destructure adsr here, because the default should be different for synths and samples + const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = hap.value; + // with synths, n and note are the same thing + n = note || n || 36; + if (typeof n === 'string') { + n = toMidi(n); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof n === 'number') { + freq = fromMidi(n); // + 48); + } + // make oscillator + const o = getOscillator({ t, s, freq, duration: hapDuration, release }); + chain.push(o); + // level down oscillators as they are really loud compared to samples i've tested + chain.push(gainNode(0.3)); + // TODO: make adsr work with samples without pops + // envelope + const adsr = getADSR(attack, decay, sustain, release, 1, t, t + hapDuration); + chain.push(adsr); + } else { + // destructure adsr here, because the default should be different for synths and samples + const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = hap.value; + // load sample + if (speed === 0) { + // no playback + return; + } + if (!s) { + console.warn('no sample specified'); + return; + } + const soundfont = getSoundfontKey(s); + let bufferSource; + + if (soundfont) { + // is soundfont + bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac); + } else { + // is sample from loaded samples(..) + bufferSource = await getSampleBufferSource(s, n, note, speed); + } + // asny stuff above took too long? + if (ac.currentTime > t) { + logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); + // console.warn('sample still loading:', s, n); + return; + } + if (!bufferSource) { + console.warn('no buffer source'); + return; + } + bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; + if (unit === 'c') { + // are there other units? + bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration; + } + let duration = soundfont || clip ? hapDuration : bufferSource.buffer.duration / bufferSource.playbackRate.value; + // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, + // rather than the current playback rate, so even if the sound is playing at twice its normal speed, + // the midway point through a 10-second audio buffer is still 5." + const offset = begin * duration * bufferSource.playbackRate.value; + duration = (end - begin) * duration; + if (loop) { + bufferSource.loop = true; + bufferSource.loopStart = offset; + bufferSource.loopEnd = offset + duration; + duration = loop * duration; + } + t += nudge; + + bufferSource.start(t, offset); + if (cut !== undefined) { + cutGroups[cut]?.stop(t); // fade out? + cutGroups[cut] = bufferSource; + } + chain.push(bufferSource); + bufferSource.stop(t + duration + release); + const adsr = getADSR(attack, decay, sustain, release, 1, t, t + duration); + chain.push(adsr); + } + + // gain stage + chain.push(gainNode(gain)); + + // filters + cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance)); + hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance)); + bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq)); + vowel !== undefined && chain.push(ac.createVowelFilter(vowel)); + + // effects + coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); + crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); + shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape })); + + // panning + if (pan !== undefined) { + const panner = ac.createStereoPanner(); + panner.pan.value = 2 * pan - 1; + chain.push(panner); + } + + // last gain + const post = gainNode(1); + chain.push(post); + post.connect(getDestination()); + + // delay + let delaySend; + if (delay > 0 && delaytime > 0 && delayfeedback > 0) { + const delyNode = getDelay(orbit, delaytime, delayfeedback, t); + delaySend = effectSend(post, delyNode, delay); + } + // reverb + let reverbSend; + if (room > 0 && roomsize > 0) { + const reverbNode = getReverb(orbit, roomsize); + reverbSend = effectSend(post, reverbNode, room); + } + + // connect chain elements together + chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); + + // disconnect all nodes when source node has ended: + chain[0].onended = () => chain.concat([delaySend, reverbSend]).forEach((n) => n?.disconnect()); }; export const webaudioOutputTrigger = (t, hap, ct, cps) => webaudioOutput(hap, t - ct, hap.duration / cps); diff --git a/packages/webdirt/webdirt.mjs b/packages/webdirt/webdirt.mjs index 467ce416..500d7373 100644 --- a/packages/webdirt/webdirt.mjs +++ b/packages/webdirt/webdirt.mjs @@ -62,37 +62,34 @@ export function loadWebDirt(config) { */ Pattern.prototype.webdirt = function () { // create a WebDirt object and initialize Web Audio context - return this._withHap((hap) => { - const onTrigger = async (time, e, currentTime) => { - if (!webDirt) { - throw new Error('WebDirt not initialized!'); - } - const deadline = time - currentTime; - const { s, n = 0, ...rest } = e.value || {}; - if (!s) { - console.warn('Pattern.webdirt: no "s" was set!'); - } - const samples = getLoadedSamples(); - if (!samples?.[s]) { - // try default samples - webDirt.playSample({ s, n, ...rest }, deadline); - return; - } - if (!samples?.[s]) { - console.warn(`Pattern.webdirt: sample "${s}" not found in loaded samples`, samples); + return this.onTrigger(async (time, e, currentTime) => { + if (!webDirt) { + throw new Error('WebDirt not initialized!'); + } + const deadline = time - currentTime; + const { s, n = 0, ...rest } = e.value || {}; + if (!s) { + console.warn('Pattern.webdirt: no "s" was set!'); + } + const samples = getLoadedSamples(); + if (!samples?.[s]) { + // try default samples + webDirt.playSample({ s, n, ...rest }, deadline); + return; + } + if (!samples?.[s]) { + console.warn(`Pattern.webdirt: sample "${s}" not found in loaded samples`, samples); + } else { + const bank = samples[s]; + const sampleUrl = bank[n % bank.length]; + const buffer = getLoadedBuffer(sampleUrl); + if (!buffer) { + console.log(`Pattern.webdirt: load ${s}:${n} from ${sampleUrl}`); + loadBuffer(sampleUrl, webDirt.ac); } else { - const bank = samples[s]; - const sampleUrl = bank[n % bank.length]; - const buffer = getLoadedBuffer(sampleUrl); - if (!buffer) { - console.log(`Pattern.webdirt: load ${s}:${n} from ${sampleUrl}`); - loadBuffer(sampleUrl, webDirt.ac); - } else { - const msg = { buffer: { buffer }, ...rest }; - webDirt.playSample(msg, deadline); - } + const msg = { buffer: { buffer }, ...rest }; + webDirt.playSample(msg, deadline); } - }; - return hap.setContext({ ...hap.context, onTrigger }); + } }); }; diff --git a/repl/package-lock.json b/repl/package-lock.json index 261892f0..3759e11f 100644 --- a/repl/package-lock.json +++ b/repl/package-lock.json @@ -8,6 +8,7 @@ "name": "@strudel.cycles/repl", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.0.13", "@supabase/supabase-js": "^1.35.3", "nanoid": "^4.0.0", "react": "^17.0.2", @@ -449,6 +450,14 @@ "node": ">=12" } }, + "node_modules/@heroicons/react": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.13.tgz", + "integrity": "sha512-iSN5XwmagrnirWlYEWNPdCDj9aRYVD/lnK3JlsC9/+fqGF80k8C7rl+1HCvBX0dBoagKqOFBs6fMhJJ1hOg1EQ==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", @@ -2972,6 +2981,12 @@ "dev": true, "optional": true }, + "@heroicons/react": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.13.tgz", + "integrity": "sha512-iSN5XwmagrnirWlYEWNPdCDj9aRYVD/lnK3JlsC9/+fqGF80k8C7rl+1HCvBX0dBoagKqOFBs6fMhJJ1hOg1EQ==", + "requires": {} + }, "@jridgewell/gen-mapping": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", diff --git a/repl/package.json b/repl/package.json index 6f3c06a9..b8ed1757 100644 --- a/repl/package.json +++ b/repl/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.0", "scripts": { + "predev": "cd ${PWD}/../tutorial/ && npm run render", "dev": "vite --host", "start": "vite", "build": "vite build", @@ -16,6 +17,7 @@ "dbdump": "node src/test/dbdump.js > src/test/dbdump.json" }, "dependencies": { + "@heroicons/react": "^2.0.13", "@supabase/supabase-js": "^1.35.3", "nanoid": "^4.0.0", "react": "^17.0.2", diff --git a/repl/src/App.css b/repl/src/App.css index 9dc525e8..357f3743 100644 --- a/repl/src/App.css +++ b/repl/src/App.css @@ -18,3 +18,18 @@ body { background: black; opacity: 0.5; } + +.cm-gutters { + display: none !important; +} + +.cm-content { + padding-top: 12px !important; + padding-left: 8px !important; +} + +@layer base { + * { + /* font-family: 'monospace'; */ + } +} diff --git a/repl/src/App.jsx b/repl/src/App.jsx index b91f43dc..ef2346c2 100644 --- a/repl/src/App.jsx +++ b/repl/src/App.jsx @@ -4,19 +4,26 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { evaluate } from '@strudel.cycles/eval'; -import { CodeMirror, cx, flash, useHighlighting, useRepl, useWebMidi } from '@strudel.cycles/react'; -import { cleanupDraw, cleanupUi, Tone } from '@strudel.cycles/tone'; -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import './App.css'; -import logo from './logo.svg'; -import * as tunes from './tunes.mjs'; -import { prebake } from './prebake.mjs'; -import * as WebDirt from 'WebDirt'; -import { resetLoadedSamples, getAudioContext } from '@strudel.cycles/webaudio'; -import { controls, evalScope } from '@strudel.cycles/core'; +import { cleanupDraw, cleanupUi, controls, evalScope, logger } from '@strudel.cycles/core'; +import { CodeMirror, cx, flash, useHighlighting, useStrudel } from '@strudel.cycles/react'; +import { + getAudioContext, + getLoadedSamples, + initAudioOnFirstClick, + resetLoadedSamples, + webaudioOutput, +} from '@strudel.cycles/webaudio'; import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; +import React, { createContext, useCallback, useEffect, useState } from 'react'; +import * as WebDirt from 'WebDirt'; +import './App.css'; +import { Footer } from './Footer'; +import { Header } from './Header'; +import { prebake } from './prebake.mjs'; +import * as tunes from './tunes.mjs'; + +initAudioOnFirstClick(); // Create a single supabase client for interacting with your database const supabase = createClient( @@ -24,12 +31,9 @@ const supabase = createClient( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM', ); -evalScope( - Tone, - controls, // sadly, this cannot be exported from core direclty - { WebDirt }, +const modules = [ import('@strudel.cycles/core'), - import('@strudel.cycles/tone'), + // import('@strudel.cycles/tone'), import('@strudel.cycles/tonal'), import('@strudel.cycles/mini'), import('@strudel.cycles/midi'), @@ -38,9 +42,24 @@ evalScope( import('@strudel.cycles/osc'), import('@strudel.cycles/serial'), import('@strudel.cycles/soundfonts'), +]; + +evalScope( + // Tone, + controls, // sadly, this cannot be exported from core direclty + { WebDirt }, + ...modules, ); -prebake(); +export let loadedSamples = []; +const presets = prebake(); + +Promise.all([...modules, presets]).then((data) => { + // console.log('modules and sample registry loade', data); + loadedSamples = Object.entries(getLoadedSamples() || {}); +}); + +const getTime = () => getAudioContext().currentTime; async function initCode() { // load code from url hash (either short hash from database or decode long hash) @@ -50,7 +69,6 @@ async function initCode() { const codeParam = window.location.href.split('#')[1]; // looking like https://strudel.tidalcycles.org/?J01s5i1J0200 (fixed hash length) if (codeParam) { - console.log('decode hash from url'); // looking like https://strudel.tidalcycles.org/#ImMzIGUzIg%3D%3D (hash length depends on code length) return atob(decodeURIComponent(codeParam || '')); } else if (hash) { @@ -74,269 +92,202 @@ async function initCode() { } function getRandomTune() { - const allTunes = Object.values(tunes); + const allTunes = Object.entries(tunes); const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)]; - return randomItem(allTunes); + const [name, code] = randomItem(allTunes); + return { name, code }; } -const randomTune = getRandomTune(); -const isEmbedded = window.location !== window.parent.location; +const { code: randomTune, name } = getRandomTune(); + +export const AppContext = createContext(); + function App() { - // const [editor, setEditor] = useState(); - const [view, setView] = useState(); + const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); - const { - setCode, - setPattern, - error, - code, - cycle, - dirty, - log, - togglePlay, - activeCode, - setActiveCode, - activateCode, - pattern, - pushLog, - pending, - hideHeader, - hideConsole, - } = useRepl({ - tune: '// LOADING...', - }); + const [activeFooter, setActiveFooter] = useState(''); + const [isZen, setIsZen] = useState(false); + const [pending, setPending] = useState(false); + + const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } = + useStrudel({ + initialCode: '// LOADING', + defaultOutput: webaudioOutput, + getTime, + autolink: true, + beforeEval: () => { + cleanupUi(); + cleanupDraw(); + setPending(true); + }, + afterEval: () => { + setPending(false); + }, + onToggle: (play) => !play && cleanupDraw(false), + }); + + // init code useEffect(() => { - initCode().then((decoded) => setCode(decoded || randomTune)); - }, []); - const logBox = useRef(); - // scroll log box to bottom when log changes - useLayoutEffect(() => { - if (logBox.current) { - logBox.current.scrollTop = logBox.current?.scrollHeight; - } - }, [log]); - - // set active pattern on ctrl+enter - useLayoutEffect(() => { - // TODO: make sure this is only fired when editor has focus - const handleKeyPress = async (e) => { - if (e.ctrlKey || e.altKey) { - if (e.code === 'Enter') { - e.preventDefault(); - flash(view); - await activateCode(); - } else if (e.code === 'Period') { - cycle.stop(); - e.preventDefault(); - } + initCode().then((decoded) => { + if (!decoded) { + setActiveFooter('intro'); // TODO: get rid } - }; - window.addEventListener('keydown', handleKeyPress, true); - return () => window.removeEventListener('keydown', handleKeyPress, true); - }, [pattern, code, activateCode, cycle, view]); + logger( + `Welcome to Strudel! ${ + decoded ? `I have loaded the code from the URL.` : `A random code snippet named "${name}" has been loaded!` + } Press play or hit ctrl+enter to run it!`, + 'highlight', + ); + setCode(decoded || randomTune); + }); + }, []); + // keyboard shortcuts + useKeydown( + useCallback( + async (e) => { + if (e.ctrlKey || e.altKey) { + if (e.code === 'Enter') { + e.preventDefault(); + flash(view); + await activateCode(); + } else if (e.code === 'Period') { + stop(); + e.preventDefault(); + } + } + }, + [activateCode, stop, view], + ), + ); + + // highlighting useHighlighting({ view, pattern, - active: cycle.started && !activeCode?.includes('strudel disable-highlighting'), - getTime: () => Tone.getTransport().seconds, + active: started && !activeCode?.includes('strudel disable-highlighting'), + getTime: () => scheduler.getPhase(), }); - useWebMidi({ - ready: useCallback( - ({ outputs }) => { - pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `'${o.name}'`).join(' | ')}) to the pattern. `); - }, - [pushLog], - ), - connected: useCallback( - ({ outputs }) => { - pushLog(`Midi device connected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`); - }, - [pushLog], - ), - disconnected: useCallback( - ({ outputs }) => { - pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `'${o.name}'`).join(', ')}`); - }, - [pushLog], - ), - }); + // + // UI Actions + // + + const handleChangeCode = useCallback( + (c) => { + setCode(c); + started && logger('[edit] code changed. hit ctrl+enter to update'); + }, + [started], + ); + const handleSelectionChange = useCallback((selection) => { + // TODO: scroll to selected function in reference + // console.log('selectino change', selection.ranges[0].from); + }, []); + const handleTogglePlay = async () => { + await getAudioContext().resume(); // fixes no sound in ios webkit + if (!started) { + logger('[repl] started. tip: you can also start by pressing ctrl+enter', 'highlight'); + activateCode(); + } else { + logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight'); + stop(); + } + }; + const handleUpdate = () => { + isDirty && activateCode(); + logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight'); + }; + + const handleShuffle = async () => { + const { code, name } = getRandomTune(); + logger(`[repl] ✨ loading random tune "${name}"`); + resetLoadedSamples(); + await prebake(); // declare default samples + await evaluate(code, false); + }; + + const handleShare = async () => { + const codeToShare = activeCode || code; + if (lastShared === codeToShare) { + logger(`Link already generated!`, 'error'); + return; + } + // generate uuid in the browser + const hash = nanoid(12); + const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]); + if (!error) { + setLastShared(activeCode || code); + const shareUrl = window.location.origin + '?' + hash; + // copy shareUrl to clipboard + navigator.clipboard.writeText(shareUrl); + const message = `Link copied to clipboard: ${shareUrl}`; + alert(message); + // alert(message); + logger(message, 'highlight'); + } else { + console.log('error', error); + const message = `Error: ${error.message}`; + // alert(message); + logger(message); + } + }; return ( -
- {!hideHeader && ( - - )} -
-
- {/* onCursor={markParens} */} - - - {!cycle.started ? `press ctrl+enter to play\n` : dirty ? `ctrl+enter to update\n` : 'no changes\n'} - - {error && ( -
- {error?.message || 'unknown error'} -
- )} -
- {!isEmbedded && !hideConsole && ( -