diff --git a/packages/codemirror/README.md b/packages/codemirror/README.md new file mode 100644 index 00000000..300e30b3 --- /dev/null +++ b/packages/codemirror/README.md @@ -0,0 +1,3 @@ +# @strudel/codemirror + +This package contains helpers and extensions to use codemirror6. See [vite-vanilla-repl-cm6](../core/examples/vite-vanilla-repl-cm6/main.js) as an example of using it. diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs new file mode 100644 index 00000000..39429432 --- /dev/null +++ b/packages/codemirror/codemirror.mjs @@ -0,0 +1,199 @@ +import { EditorState } from '@codemirror/state'; +import { EditorView, keymap, Decoration, lineNumbers, highlightActiveLineGutter } from '@codemirror/view'; +import { defaultKeymap } from '@codemirror/commands'; +import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; +import { javascript } from '@codemirror/lang-javascript'; +import { StateField, StateEffect } from '@codemirror/state'; +import { oneDark } from './themes/one-dark'; +import { repl, Drawer } from '@strudel.cycles/core'; + +// https://codemirror.net/docs/guide/ +export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, theme = oneDark, root }) { + let state = EditorState.create({ + doc: initialCode, + extensions: [ + theme, + javascript(), + lineNumbers(), + highlightField, + highlightActiveLineGutter(), + syntaxHighlighting(defaultHighlightStyle), + keymap.of(defaultKeymap), + flashField, + EditorView.updateListener.of((v) => onChange(v)), + keymap.of([ + { + key: 'Ctrl-Enter', + run: () => onEvaluate(), + }, + { + key: 'Ctrl-.', + run: () => onStop(), + }, + ]), + ], + }); + + return new EditorView({ + state, + parent: root, + }); +} + +// +// highlighting +// + +export const setHighlights = StateEffect.define(); +export const highlightField = StateField.define({ + create() { + return Decoration.none; + }, + update(highlights, tr) { + try { + for (let e of tr.effects) { + if (e.is(setHighlights)) { + const { haps } = e.value; + const marks = + haps + .map((hap) => + (hap.context.locations || []).map(({ start, end }) => { + // const color = hap.context.color || e.value.color || '#FFCA28'; + let from = tr.newDoc.line(start.line).from + start.column; + let to = tr.newDoc.line(end.line).from + end.column; + const l = tr.newDoc.length; + if (from > l || to > l) { + return; // dont mark outside of range, as it will throw an error + } + const mark = Decoration.mark({ + attributes: { style: `outline: 2px solid #FFCA28;` }, + }); + return mark.range(from, to); + }), + ) + .flat() + .filter(Boolean) || []; + highlights = Decoration.set(marks, true); + } + } + return highlights; + } catch (err) { + // console.warn('highlighting error', err); + return Decoration.set([]); + } + }, + provide: (f) => EditorView.decorations.from(f), +}); + +// helper to simply trigger highlighting for given haps +export function highlightHaps(view, haps) { + view.dispatch({ effects: setHighlights.of({ haps }) }); +} + +// +// flash +// + +export const setFlash = StateEffect.define(); +const flashField = StateField.define({ + create() { + return Decoration.none; + }, + update(flash, tr) { + try { + for (let e of tr.effects) { + if (e.is(setFlash)) { + if (e.value) { + const mark = Decoration.mark({ attributes: { style: `background-color: #FFCA2880` } }); + flash = Decoration.set([mark.range(0, tr.newDoc.length)]); + } else { + flash = Decoration.set([]); + } + } + } + return flash; + } catch (err) { + console.warn('flash error', err); + return flash; + } + }, + provide: (f) => EditorView.decorations.from(f), +}); + +export const flash = (view, ms = 200) => { + view.dispatch({ effects: setFlash.of(true) }); + setTimeout(() => { + view.dispatch({ effects: setFlash.of(false) }); + }, ms); +}; + +export class StrudelMirror { + constructor(options) { + const { root, initialCode = '', onDraw, drawTime = [-2, 2], prebake, ...replOptions } = options; + this.code = initialCode; + + this.drawer = new Drawer((haps, time) => { + const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.whole.end); + this.highlight(currentFrame); + onDraw?.(haps, time, currentFrame); + }, drawTime); + + const prebaked = prebake(); + prebaked.then(async () => { + if (!onDraw) { + return; + } + const { scheduler, evaluate } = await this.repl; + // draw first frame instantly + prebaked.then(async () => { + await evaluate(this.code, false); + this.drawer.invalidate(scheduler); + onDraw?.(this.drawer.visibleHaps, 0, []); + }); + }); + + this.repl = repl({ + ...replOptions, + onToggle: async (started) => { + replOptions?.onToggle?.(started); + const { scheduler } = await this.repl; + if (started) { + this.drawer.start(scheduler); + } else { + this.drawer.stop(); + } + }, + beforeEval: async () => { + await prebaked; + }, + afterEval: (options) => { + replOptions?.afterEval?.(options); + this.drawer.invalidate(); + }, + }); + this.editor = initEditor({ + root, + initialCode, + onChange: (v) => { + this.code = v.state.doc.toString(); + }, + onEvaluate: () => this.evaluate(), + onStop: () => this.stop(), + }); + } + async evaluate() { + const { evaluate } = await this.repl; + this.flash(); + await evaluate(this.code); + } + async stop() { + const { scheduler } = await this.repl; + scheduler.stop(); + } + flash(ms) { + flash(this.editor, ms); + } + highlight(haps) { + highlightHaps(this.editor, haps); + } +} diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json new file mode 100644 index 00000000..c3b885db --- /dev/null +++ b/packages/codemirror/package.json @@ -0,0 +1,47 @@ +{ + "name": "@strudel/codemirror", + "version": "0.8.3", + "description": "Codemirror Extensions for Strudel", + "main": "codemirror.mjs", + "publishConfig": { + "main": "dist/index.js", + "module": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "contributors": [ + "Alex McLean " + ], + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "dependencies": { + "@codemirror/commands": "^6.2.4", + "@codemirror/lang-javascript": "^6.1.7", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.10.0", + "@lezer/highlight": "^1.1.4", + "@strudel.cycles/core": "workspace:*" + }, + "devDependencies": { + "vite": "^4.3.3" + } +} diff --git a/packages/codemirror/themes/one-dark.mjs b/packages/codemirror/themes/one-dark.mjs new file mode 100644 index 00000000..cce83699 --- /dev/null +++ b/packages/codemirror/themes/one-dark.mjs @@ -0,0 +1,139 @@ +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; + +// Using https://github.com/one-dark/vscode-one-dark-theme/ as reference for the colors + +const chalky = '#e5c07b', + coral = '#e06c75', + cyan = '#56b6c2', + invalid = '#ffffff', + ivory = '#abb2bf', + stone = '#7d8799', // Brightened compared to original to increase contrast + malibu = '#61afef', + sage = '#98c379', + whiskey = '#d19a66', + violet = '#c678dd', + darkBackground = '#21252b', + highlightBackground = '#2c313a', + background = '#282c34', + tooltipBackground = '#353a42', + selection = '#3E4451', + cursor = '#528bff'; + +/// The colors used in the theme, as CSS color strings. +export const color = { + chalky, + coral, + cyan, + invalid, + ivory, + stone, + malibu, + sage, + whiskey, + violet, + darkBackground, + highlightBackground, + background, + tooltipBackground, + selection, + cursor, +}; + +/// The editor theme styles for One Dark. +export const oneDarkTheme = EditorView.theme( + { + '&': { + color: ivory, + backgroundColor: background, + }, + + '.cm-content': { + caretColor: cursor, + }, + + '.cm-cursor, .cm-dropCursor': { borderLeftColor: cursor }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': + { backgroundColor: selection }, + + '.cm-panels': { backgroundColor: darkBackground, color: ivory }, + '.cm-panels.cm-panels-top': { borderBottom: '2px solid black' }, + '.cm-panels.cm-panels-bottom': { borderTop: '2px solid black' }, + + '.cm-searchMatch': { + backgroundColor: '#72a1ff59', + outline: '1px solid #457dff', + }, + '.cm-searchMatch.cm-searchMatch-selected': { + backgroundColor: '#6199ff2f', + }, + + '.cm-activeLine': { backgroundColor: '#6699ff0b' }, + '.cm-selectionMatch': { backgroundColor: '#aafe661a' }, + + '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': { + backgroundColor: '#bad0f847', + }, + + '.cm-gutters': { + backgroundColor: background, + color: stone, + border: 'none', + }, + + '.cm-activeLineGutter': { + backgroundColor: highlightBackground, + }, + + '.cm-foldPlaceholder': { + backgroundColor: 'transparent', + border: 'none', + color: '#ddd', + }, + + '.cm-tooltip': { + border: 'none', + backgroundColor: tooltipBackground, + }, + '.cm-tooltip .cm-tooltip-arrow:before': { + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + }, + '.cm-tooltip .cm-tooltip-arrow:after': { + borderTopColor: tooltipBackground, + borderBottomColor: tooltipBackground, + }, + '.cm-tooltip-autocomplete': { + '& > ul > li[aria-selected]': { + backgroundColor: highlightBackground, + color: ivory, + }, + }, + }, + { dark: true }, +); + +/// The highlighting style for code in the One Dark theme. +export const oneDarkHighlightStyle = HighlightStyle.define([ + { tag: t.keyword, color: violet }, + { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: coral }, + { tag: [t.function(t.variableName), t.labelName], color: malibu }, + { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, + { tag: [t.definition(t.name), t.separator], color: ivory }, + { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: chalky }, + { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: cyan }, + { tag: [t.meta, t.comment], color: stone }, + { tag: t.strong, fontWeight: 'bold' }, + { tag: t.emphasis, fontStyle: 'italic' }, + { tag: t.strikethrough, textDecoration: 'line-through' }, + { tag: t.link, color: stone, textDecoration: 'underline' }, + { tag: t.heading, fontWeight: 'bold', color: coral }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, + { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, + { tag: t.invalid, color: invalid }, +]); + +/// Extension to enable the One Dark theme (both the editor theme and +/// the highlight style). +export const oneDark = [oneDarkTheme, syntaxHighlighting(oneDarkHighlightStyle)]; diff --git a/packages/codemirror/vite.config.js b/packages/codemirror/vite.config.js new file mode 100644 index 00000000..8562915c --- /dev/null +++ b/packages/codemirror/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'codemirror.mjs'), + formats: ['es', 'cjs'], + fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index ef5bd33f..c4044d4a 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Pattern, sequence } from './pattern.mjs'; +import { Pattern, register, sequence } from './pattern.mjs'; import { zipWith } from './util.mjs'; const controls = {}; @@ -810,4 +810,15 @@ generic_params.forEach(([names, ...aliases]) => { controls.createParams = (...names) => names.reduce((acc, name) => Object.assign(acc, { [name]: controls.createParam(name) }), {}); +controls.adsr = register('adsr', (adsr, pat) => { + adsr = !Array.isArray(adsr) ? [adsr] : adsr; + const [attack, decay, sustain, release] = adsr; + return pat.set({ attack, decay, sustain, release }); +}); +controls.ds = register('ds', (ds, pat) => { + ds = !Array.isArray(ds) ? [ds] : ds; + const [decay, sustain] = ds; + return pat.set({ decay, sustain }); +}); + export default controls; diff --git a/packages/core/draw.mjs b/packages/core/draw.mjs index 4bfd3257..f42e3220 100644 --- a/packages/core/draw.mjs +++ b/packages/core/draw.mjs @@ -1,6 +1,6 @@ /* draw.mjs - -Copyright (C) 2022 Strudel contributors - see +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 . */ @@ -13,7 +13,7 @@ export const getDrawContext = (id = 'test-canvas') => { canvas.id = id; canvas.width = window.innerWidth; canvas.height = window.innerHeight; - canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0;z-index:5'; + canvas.style = 'pointer-events:none;width:100%;height:100%;position:fixed;top:0;left:0'; document.body.prepend(canvas); } return canvas.getContext('2d'); @@ -65,3 +65,97 @@ Pattern.prototype.onPaint = function (onPaint) { this.context = { onPaint }; return this; }; + +// const round = (x) => Math.round(x * 1000) / 1000; + +// encapsulates starting and stopping animation frames +export class Framer { + constructor(onFrame, onError) { + this.onFrame = onFrame; + this.onError = onError; + } + start() { + const self = this; + let frame = requestAnimationFrame(function updateHighlights(time) { + try { + self.onFrame(time); + } catch (err) { + self.onError(err); + } + frame = requestAnimationFrame(updateHighlights); + }); + self.cancel = () => { + cancelAnimationFrame(frame); + }; + } + stop() { + if (this.cancel) { + this.cancel(); + } + } +} + +// syncs animation frames to a cyclist scheduler +// see vite-vanilla-repl-cm6 for an example +export class Drawer { + constructor(onDraw, drawTime) { + let [lookbehind, lookahead] = drawTime; // e.g. [-2, 2] + lookbehind = Math.abs(lookbehind); + this.visibleHaps = []; + this.lastFrame = null; + this.drawTime = drawTime; + this.framer = new Framer( + () => { + if (!this.scheduler) { + console.warn('Drawer: no scheduler'); + return; + } + // calculate current frame time (think right side of screen for pianoroll) + const phase = this.scheduler.now() + lookahead; + // first frame just captures the phase + if (this.lastFrame === null) { + this.lastFrame = phase; + return; + } + // query haps from last frame till now. take last 100ms max + const haps = this.scheduler.pattern.queryArc(Math.max(this.lastFrame, phase - 1 / 10), phase); + this.lastFrame = phase; + this.visibleHaps = (this.visibleHaps || []) + // filter out haps that are too far in the past (think left edge of screen for pianoroll) + .filter((h) => h.whole.end >= phase - lookbehind - lookahead) + // add new haps with onset (think right edge bars scrolling in) + .concat(haps.filter((h) => h.hasOnset())); + const time = phase - lookahead; + onDraw(this.visibleHaps, time, this); + }, + (err) => { + console.warn('draw error', err); + }, + ); + } + invalidate(scheduler = this.scheduler) { + if (!scheduler) { + return; + } + this.scheduler = scheduler; + const t = scheduler.now(); + let [_, lookahead] = this.drawTime; + const [begin, end] = [Math.max(t, 0), t + lookahead + 0.1]; + // remove all future haps + this.visibleHaps = this.visibleHaps.filter((h) => h.whole.begin < t); + // query future haps + const futureHaps = scheduler.pattern.queryArc(begin, end); // +0.1 = workaround for weird holes in query.. + // append future haps + this.visibleHaps = this.visibleHaps.concat(futureHaps); + } + start(scheduler) { + this.scheduler = scheduler; + this.invalidate(); + this.framer.start(); + } + stop() { + if (this.framer) { + this.framer.stop(); + } + } +} diff --git a/packages/core/examples/vite-vanilla-repl-cm6/.gitignore b/packages/core/examples/vite-vanilla-repl-cm6/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/core/examples/vite-vanilla-repl-cm6/README.md b/packages/core/examples/vite-vanilla-repl-cm6/README.md new file mode 100644 index 00000000..70d18e09 --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/README.md @@ -0,0 +1,8 @@ +# vite-vanilla-repl-cm6 + +This folder demonstrates how to set up a strudel repl using vite and vanilla JS + codemirror. Run it using: + +```sh +npm i +npm run dev +``` diff --git a/packages/core/examples/vite-vanilla-repl-cm6/index.html b/packages/core/examples/vite-vanilla-repl-cm6/index.html new file mode 100644 index 00000000..1a214021 --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/index.html @@ -0,0 +1,22 @@ + + + + + + Vite Vanilla Strudel REPL + + +
+ +
+
+
+
+ +
+ + + diff --git a/packages/core/examples/vite-vanilla-repl-cm6/main.js b/packages/core/examples/vite-vanilla-repl-cm6/main.js new file mode 100644 index 00000000..425799be --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/main.js @@ -0,0 +1,39 @@ +import { StrudelMirror } from '@strudel/codemirror'; +import { funk42 } from './tunes'; +import { drawPianoroll, evalScope, controls } from '@strudel.cycles/core'; +import './style.css'; +import { initAudioOnFirstClick } from '@strudel.cycles/webaudio'; +import { transpiler } from '@strudel.cycles/transpiler'; +import { getAudioContext, webaudioOutput, registerSynthSounds } from '@strudel.cycles/webaudio'; +import { registerSoundfonts } from '@strudel.cycles/soundfonts'; + +// init canvas +const canvas = document.getElementById('roll'); +canvas.width = canvas.width * 2; +canvas.height = canvas.height * 2; +const drawContext = canvas.getContext('2d'); +const drawTime = [-2, 2]; // time window of drawn haps + +const editor = new StrudelMirror({ + defaultOutput: webaudioOutput, + getTime: () => getAudioContext().currentTime, + transpiler, + root: document.getElementById('editor'), + initialCode: funk42, + drawTime, + onDraw: (haps, time) => drawPianoroll({ haps, time, ctx: drawContext, drawTime, fold: 0 }), + prebake: async () => { + initAudioOnFirstClick(); // needed to make the browser happy (don't await this here..) + const loadModules = evalScope( + controls, + import('@strudel.cycles/core'), + import('@strudel.cycles/mini'), + import('@strudel.cycles/tonal'), + import('@strudel.cycles/webaudio'), + ); + await Promise.all([loadModules, registerSynthSounds(), registerSoundfonts()]); + }, +}); + +document.getElementById('play').addEventListener('click', () => editor.evaluate()); +document.getElementById('stop').addEventListener('click', () => editor.stop()); diff --git a/packages/core/examples/vite-vanilla-repl-cm6/package.json b/packages/core/examples/vite-vanilla-repl-cm6/package.json new file mode 100644 index 00000000..4e093d10 --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/package.json @@ -0,0 +1,23 @@ +{ + "name": "vite-vanilla-repl-cm6", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^4.3.2" + }, + "dependencies": { + "@strudel/codemirror": "workspace:*", + "@strudel.cycles/core": "workspace:*", + "@strudel.cycles/mini": "workspace:*", + "@strudel.cycles/soundfonts": "workspace:*", + "@strudel.cycles/tonal": "workspace:*", + "@strudel.cycles/transpiler": "workspace:*", + "@strudel.cycles/webaudio": "workspace:*" + } +} diff --git a/packages/core/examples/vite-vanilla-repl-cm6/style.css b/packages/core/examples/vite-vanilla-repl-cm6/style.css new file mode 100644 index 00000000..67ab3917 --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/style.css @@ -0,0 +1,31 @@ +body, +html { + margin: 0; + height: 100%; + background: #282c34; +} + +main { + height: 100%; + display: flex; + flex-direction: column; +} + +.container { + flex-grow: 1; + max-height: 100%; + position: relative; + overflow: auto; +} + +#editor { + overflow: auto; +} + +.cm-editor { + height: 100%; +} + +#roll { + height: 300px; +} diff --git a/packages/core/examples/vite-vanilla-repl-cm6/tunes.mjs b/packages/core/examples/vite-vanilla-repl-cm6/tunes.mjs new file mode 100644 index 00000000..242a0d4b --- /dev/null +++ b/packages/core/examples/vite-vanilla-repl-cm6/tunes.mjs @@ -0,0 +1,112 @@ +export const bumpStreet = `// froos - "22 bump street", licensed with CC BY-NC-SA 4.0 +await samples('github:felixroos/samples/main') +await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') + +"<[0,<6 7 9>,13,<17 20 22 26>]!2>/2" + // make it 22 edo + .fmap(v => Math.pow(2,v/22)) + // mess with the base frequency + .mul("<300 [300@3 200]>/8").freq() + .layer( + // chords + x=>x.div(freq(2)).s("flute").euclidLegato("<3 2>",8) + .shape(.4).lpf(sine.range(800,4000).slow(8)), + // adlibs + x=>x.arp("{0 3 2 [1 3]}%1.5") + .s('xylo').mul(freq(2)) + .delay(.5).delayfeedback(.4).juxBy(.5, rev) + .hpf(sine.range(200,3000).slow(8)), + // bass + x=>x.arp("[0 [2 1?]](5,8)").s('sawtooth').div(freq(4)) + .lpf(sine.range(400,2000).slow(8)).lpq(8).shape(.4) + .off(1/8, x=>x.mul(freq(2)).degradeBy(.5)).gain(.3) + ).clip(1).release(.2) +.stack( + // drums + s("bd sd:<2 1>, [~ hh]*2, [~ rim]").bank('RolandTR909') + .off(1/8, x=>x.speed(2).gain(.4)).sometimes(ply(2)).gain(.8) + .mask("<0@4 1@12>/4") + .reset("") + // wait for it... +).fast(2/3) + //.crush(6) // remove "//" if you dare`; + +export const trafficFlam = `// froos - "traffic flam", licensed with CC BY-NC-SA 4.0 + +await samples('github:felixroos/samples/main') +await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') + +addVoicings('hip', { + m11: ['2M 3m 4P 7m'], + '^7#11': ['3M 4A 5P 7M'], +}, ['C4', 'C6']) + +stack( + stack( + "/2".voicings('hip').note() + .s("gm_epiano1:2") + .arp("[<[0 1 2 3] [3 2 1 0]> ~@5]/2") + .release(2).late(.25).lpf(2000), + "/2".note().s('gm_acoustic_bass'), + n("<0 2 3>(3,8)".off(1/8, add(4))) + .scale("/2") + .s('gm_electric_guitar_jazz') + .decay(sine.range(.05, .2).slow(32)).sustain(0) + .delay(.5).lpf(sine.range(100,5000).slow(64)) + .gain(.7).room(.5).pan(sine.range(0,1).slow(11)) + ).add(perlin.range(0,.25).note()), + stack( + s("bd:1(3,8) rim").bank('RolandTR707').slow(2).room("<0 <.1 .6>>") + .when("<0@7 1>",x=>x.echoWith(3, .0625, (x,i) => x.speed(1+i*.24))), + s("rim*4").end(.05).bank('RolandTR808').speed(.8).room(.2) + ) +) + .late("[0 .05]*2").late(12) + +`; + +export const funk42 = `// froos - how to funk in 42 lines of code +// adapted from "how to funk in two minutes" by marc rebillet https://www.youtube.com/watch?v=3vBwRfQbXkg +// thanks to peach for the transcription: https://www.youtube.com/watch?v=8eiPXvIgda4 + +await samples('github:felixroos/samples/main') +await samples('https://strudel.tidalcycles.org/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/') + +setcps(.5) + +let drums = stack( + s("bd*2, ~ sd").bank('RolandTR707').room("0 .1"), + s("hh*4").begin(.2).release(.02).end(.25).release(.02) + .gain(.3).bank('RolandTR707').late(.02).room(.5), + //s("shaker_small").struct("[x x*2]*2").speed(".8,.9").release(.02) +).fast(2) + +let wurli = note(\`< +[[a2,g3,[b3 c4],e4] ~ [g3,c4,e4](3,8)@4 ~@2]!3 +[[e2,e3,a3,b3,e4]@3 [e2,e3,ab3,b3,e4]@5]>\`) + .s("gm_epiano1:5").decay(.2).sustain("<[1 0@7]!3 1>") + .gain("<[.8@2 .4@14]!3 .7>").room(.3) + +let organ = note("<[~@3 [a3,d4,f#4]@2 [[a3,c4,e4]@2 ~] ~@2]!3 ~>".add(12)) + .s("gm_percussive_organ:2").gain(.6).lpf(1800).pan(.2).room(.3); + +let clav = note(\`< +[~@3 a2 [g3,[b3 c4],e4]@2 ~ a2 [g3,b3,e4] ~@2 [g3,c4,e4] ~@4]!3 +[~@3 e3 [[a3 b3],c3,e3]@2 ~ e2 [e3,a3]@3 [b3,e3] ~@2 [b3,e3]@2]>\`) + .s("gm_clavinet:1").decay("<.25!3 [.25 .4]>").sustain(0) + .gain(.7).pan(.8).room(.2); + +let bass = note(\`< +[a1 [~ [g2 a2]] [g1 g#1] [a1 [g2 a2]]] +[a1 [~ [g2 a2]] [e3 d3] [c3 [g3 a3]]] +[a1 [~ [g2 a2]] [g1 g#1] [a1 [g2 a2]]] +[e2@6 e1@5 e1 [[d2 e3] g1]@4] +>\`).s("gm_electric_bass_pick:1").release(.1) + +stack( + drums + ,wurli + ,organ + ,clav + ,bass +)`; diff --git a/packages/core/examples/vite-vanilla-repl/README.md b/packages/core/examples/vite-vanilla-repl/README.md index ac2ed20f..d7f65720 100644 --- a/packages/core/examples/vite-vanilla-repl/README.md +++ b/packages/core/examples/vite-vanilla-repl/README.md @@ -6,5 +6,3 @@ This folder demonstrates how to set up a strudel repl using vite and vanilla JS. npm i npm run dev ``` - -or view it [live on githack](https://rawcdn.githack.com/tidalcycles/strudel/5fb36acb046ead7cd6ad3cd10f532e7f585f536a/packages/core/examples/vite-vanilla-repl/dist/index.html) diff --git a/packages/core/index.mjs b/packages/core/index.mjs index b6c74848..16ef3be4 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -18,6 +18,7 @@ export * from './util.mjs'; export * from './speak.mjs'; export * from './evaluate.mjs'; export * from './repl.mjs'; +export * from './cyclist.mjs'; export * from './logger.mjs'; export * from './time.mjs'; export * from './draw.mjs'; diff --git a/packages/core/package.json b/packages/core/package.json index 14237689..9f7aa390 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/core", - "version": "0.7.2", + "version": "0.8.1", "description": "Port of Tidal Cycles to JavaScript", "main": "index.mjs", "type": "module", diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 057fb5c7..e3316d40 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1677,6 +1677,9 @@ export const ply = register('ply', function (factor, pat) { * s(" hh").fast(2) // s("[ hh]*2") */ export const { fast, density } = register(['fast', 'density'], function (factor, pat) { + if (factor === 0) { + return silence; + } factor = Fraction(factor); const fastQuery = pat.withQueryTime((t) => t.mul(factor)); return fastQuery.withHapTime((t) => t.div(factor)); @@ -1703,6 +1706,9 @@ export const hurry = register('hurry', function (r, pat) { * s(" hh").slow(2) // s("[ hh]/2") */ export const { slow, sparsity } = register(['slow', 'sparsity'], function (factor, pat) { + if (factor === 0) { + return silence; + } return pat._fast(Fraction(1).div(factor)); }); diff --git a/packages/core/pianoroll.mjs b/packages/core/pianoroll.mjs index 635b7fac..66b56dc8 100644 --- a/packages/core/pianoroll.mjs +++ b/packages/core/pianoroll.mjs @@ -50,6 +50,7 @@ Pattern.prototype.pianoroll = function ({ timeframe: timeframeProp, fold = 0, vertical = 0, + labels = 0, } = {}) { const ctx = getDrawContext(); const w = ctx.canvas.width; @@ -87,7 +88,7 @@ Pattern.prototype.pianoroll = function ({ const isActive = event.whole.begin <= t && event.whole.end > t; ctx.fillStyle = event.context?.color || inactive; ctx.strokeStyle = event.context?.color || active; - ctx.globalAlpha = event.context.velocity ?? 1; + ctx.globalAlpha = event.context.velocity ?? event.value?.gain ?? 1; const timePx = scale((event.whole.begin - (flipTime ? to : from)) / timeExtent, ...timeRange); let durationPx = scale(event.duration / timeExtent, 0, timeAxis); const value = getValue(event); @@ -114,6 +115,14 @@ Pattern.prototype.pianoroll = function ({ ]; } isActive ? ctx.strokeRect(...coords) : ctx.fillRect(...coords); + if (labels) { + const label = event.value.note ?? event.value.s + (event.value.n ? `:${event.value.n}` : ''); + ctx.font = `${barThickness * 0.75}px monospace`; + ctx.strokeStyle = 'black'; + ctx.fillStyle = isActive ? 'white' : 'black'; + ctx.textBaseline = 'top'; + ctx.fillText(label, ...coords); + } }); ctx.globalAlpha = 1; // reset! const playheadPosition = scale(-from / timeExtent, ...timeRange); @@ -181,6 +190,7 @@ export function pianoroll({ timeframe: timeframeProp, fold = 0, vertical = 0, + labels = false, ctx, } = {}) { const w = ctx.canvas.width; @@ -240,7 +250,7 @@ export function pianoroll({ const color = event.value?.color || event.context?.color; ctx.fillStyle = color || inactive; ctx.strokeStyle = color || active; - ctx.globalAlpha = event.context.velocity ?? 1; + ctx.globalAlpha = event.context.velocity ?? event.value?.gain ?? 1; const timePx = scale((event.whole.begin - (flipTime ? to : from)) / timeExtent, ...timeRange); let durationPx = scale(event.duration / timeExtent, 0, timeAxis); const value = getValue(event); @@ -267,6 +277,14 @@ export function pianoroll({ ]; } isActive ? ctx.strokeRect(...coords) : ctx.fillRect(...coords); + if (labels) { + const label = event.value.note ?? event.value.s + (event.value.n ? `:${event.value.n}` : ''); + ctx.font = `${barThickness * 0.75}px monospace`; + ctx.strokeStyle = 'black'; + ctx.fillStyle = isActive ? 'white' : 'black'; + ctx.textBaseline = 'top'; + ctx.fillText(label, ...coords); + } }); ctx.globalAlpha = 1; // reset! const playheadPosition = scale(-from / timeExtent, ...timeRange); @@ -284,7 +302,7 @@ export function pianoroll({ return this; } -function getOptions(drawTime, options = {}) { +export function getDrawOptions(drawTime, options = {}) { let [lookbehind, lookahead] = drawTime; lookbehind = Math.abs(lookbehind); const cycles = lookahead + lookbehind; @@ -293,5 +311,18 @@ function getOptions(drawTime, options = {}) { } Pattern.prototype.punchcard = function (options) { - return this.onPaint((ctx, time, haps, drawTime) => pianoroll({ ctx, time, haps, ...getOptions(drawTime, options) })); + return this.onPaint((ctx, time, haps, drawTime) => + pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) }), + ); }; + +/* Pattern.prototype.pianoroll = function (options) { + return this.onPaint((ctx, time, haps, drawTime) => + pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 0, ...options }) }), + ); +}; */ + +export function drawPianoroll(options) { + const { drawTime, ...rest } = options; + pianoroll({ ...getDrawOptions(drawTime), ...rest }); +} diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 7c96bb66..c88145d1 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -18,23 +18,15 @@ export function repl({ }) { const scheduler = new Cyclist({ interval, - onTrigger: async (hap, deadline, duration, cps) => { - try { - if (!hap.context.onTrigger || !hap.context.dominantTrigger) { - await defaultOutput(hap, deadline, duration, cps); - } - if (hap.context.onTrigger) { - // call signature of output / onTrigger is different... - await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps); - } - } catch (err) { - logger(`[cyclist] error: ${err.message}`, 'error'); - } - }, + onTrigger: getTrigger({ defaultOutput, getTime }), onError: onSchedulerError, getTime, onToggle, }); + const setPattern = (pattern, autostart = true) => { + pattern = editPattern?.(pattern) || pattern; + scheduler.setPattern(pattern, autostart); + }; setTime(() => scheduler.now()); // TODO: refactor? const evaluate = async (code, autostart = true) => { if (!code) { @@ -45,8 +37,7 @@ export function repl({ let { pattern } = await _evaluate(code, transpiler); logger(`[eval] code updated`); - pattern = editPattern?.(pattern) || pattern; - scheduler.setPattern(pattern, autostart); + setPattern(pattern, autostart); afterEval?.({ code, pattern }); return pattern; } catch (err) { @@ -63,5 +54,21 @@ export function repl({ setCps, setcps: setCps, }); - return { scheduler, evaluate, start, stop, pause, setCps }; + return { scheduler, evaluate, start, stop, pause, setCps, setPattern }; } + +export const getTrigger = + ({ getTime, defaultOutput }) => + async (hap, deadline, duration, cps) => { + try { + if (!hap.context.onTrigger || !hap.context.dominantTrigger) { + await defaultOutput(hap, deadline, duration, cps); + } + if (hap.context.onTrigger) { + // call signature of output / onTrigger is different... + await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps); + } + } catch (err) { + logger(`[cyclist] error: ${err.message}`, 'error'); + } + }; diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 1e6d9a7b..f5af7151 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -6,12 +6,12 @@ This program is free software: you can redistribute it and/or modify it under th // returns true if the given string is a note export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); -export const isNote = (name) => /^[a-gA-G][#bs]*[0-9]?$/.test(name); +export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name); export const tokenizeNote = (note) => { if (typeof note !== 'string') { return []; } - const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bs]*)([0-9])?$/)?.slice(1) || []; + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9])?$/)?.slice(1) || []; if (!pc) { return []; } @@ -25,7 +25,7 @@ export const noteToMidi = (note) => { throw new Error('not a note: "' + note + '"'); } const chroma = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }[pc.toLowerCase()]; - const offset = acc?.split('').reduce((o, char) => o + { '#': 1, b: -1, s: 1 }[char], 0) || 0; + const offset = acc?.split('').reduce((o, char) => o + { '#': 1, b: -1, s: 1, f: -1 }[char], 0) || 0; return (Number(oct) + 1) * 12 + chroma + offset; }; export const midiToFreq = (n) => { @@ -67,14 +67,9 @@ export const getFreq = (noteOrMidi) => { return midiToFreq(noteToMidi(noteOrMidi)); }; -/** - * @deprecated does not appear to be referenced or invoked anywhere in the codebase - * @noAutocomplete - */ -/* added code from here to solve issue 302*/ -export const midi2note = (n, notation = 'letters') => { - const solfeggio = ['Do', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si']; /*solffegio notes*/ +/* added code from here to solve issue 302*/ +const solfeggio = ['Do', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si']; /*solffegio notes*/ const indian = [ 'Sa', 'Re', @@ -108,6 +103,11 @@ export const midi2note = (n, notation = 'letters') => { 'He', 'To', ]; /*traditional japanese musical notes, seems like they do not use falts or sharps*/ + +const english = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] + +export const sol2note = (n, notation = 'letters') => { + const pc = notation === 'solfeggio' ? solfeggio /*check if its is any of the following*/ @@ -119,11 +119,24 @@ export const midi2note = (n, notation = 'letters') => { ? byzantine : notation === 'japanese' ? japanese - : ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; /*if not use standard version*/ + : english; /*if not use standard version*/ const note = pc[n % 12]; /*calculating the midi value to the note*/ const oct = Math.floor(n / 12) - 1; return note + oct; }; + + +const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; +/** + * @deprecated only used in workshop atm + * @noAutocomplete + */ +export const midi2note = (n, notation = 'letters') => { + const oct = Math.floor(n / 12) - 1; + //return note + oct; + const pc = pcs[n % 12]; + return pc + oct; +}; /*testing if the function works by using the file solmization.test.mjs in the test folder*/ @@ -262,3 +275,5 @@ export const splitAt = function (index, value) { }; export const zipWith = (f, xs, ys) => xs.map((n, i) => f(n, ys[i])); + +export const clamp = (num, min, max) => Math.min(Math.max(num, min), max); diff --git a/packages/mini/mini.mjs b/packages/mini/mini.mjs index 648499e6..3f9228d9 100644 --- a/packages/mini/mini.mjs +++ b/packages/mini/mini.mjs @@ -190,3 +190,8 @@ export function minify(thing) { } return strudel.reify(thing); } + +// calling this function will cause patterns to parse strings as mini notation by default +export function miniAllStrings() { + strudel.setStringParser(mini); +} diff --git a/packages/mini/package.json b/packages/mini/package.json index 1f8ab829..11388dd8 100644 --- a/packages/mini/package.json +++ b/packages/mini/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/mini", - "version": "0.7.2", + "version": "0.8.1", "description": "Mini notation for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/react/examples/nano-repl/package.json b/packages/react/examples/nano-repl/package.json index 2c3045ef..c837e4b7 100644 --- a/packages/react/examples/nano-repl/package.json +++ b/packages/react/examples/nano-repl/package.json @@ -15,6 +15,7 @@ "@strudel.cycles/osc": "workspace:*", "@strudel.cycles/mini": "workspace:*", "@strudel.cycles/transpiler": "workspace:*", + "@strudel.cycles/soundfonts": "workspace:*", "@strudel.cycles/webaudio": "workspace:*", "@strudel.cycles/tonal": "workspace:*", "@strudel.cycles/react": "workspace:*" diff --git a/packages/react/examples/nano-repl/src/App.jsx b/packages/react/examples/nano-repl/src/App.jsx index 40640e7d..70bf2af1 100644 --- a/packages/react/examples/nano-repl/src/App.jsx +++ b/packages/react/examples/nano-repl/src/App.jsx @@ -1,22 +1,34 @@ import { controls, evalScope } from '@strudel.cycles/core'; import { CodeMirror, useHighlighting, useKeydown, useStrudel, flash } from '@strudel.cycles/react'; -import { getAudioContext, initAudioOnFirstClick, panic, webaudioOutput } from '@strudel.cycles/webaudio'; +import { + getAudioContext, + initAudioOnFirstClick, + panic, + webaudioOutput, + registerSynthSounds, +} from '@strudel.cycles/webaudio'; +import { registerSoundfonts } from '@strudel.cycles/soundfonts'; import { useCallback, useState } from 'react'; import './style.css'; // import { prebake } from '../../../../../repl/src/prebake.mjs'; initAudioOnFirstClick(); -// TODO: only import stuff when play is pressed? -evalScope( - controls, - import('@strudel.cycles/core'), - import('@strudel.cycles/tonal'), - import('@strudel.cycles/mini'), - import('@strudel.cycles/xen'), - import('@strudel.cycles/webaudio'), - import('@strudel.cycles/osc'), -); +async function init() { + // TODO: only import stuff when play is pressed? + const loadModules = evalScope( + controls, + import('@strudel.cycles/core'), + import('@strudel.cycles/tonal'), + import('@strudel.cycles/mini'), + import('@strudel.cycles/xen'), + import('@strudel.cycles/webaudio'), + import('@strudel.cycles/osc'), + ); + + await Promise.all([loadModules, registerSynthSounds(), registerSoundfonts()]); +} +init(); const defaultTune = `samples({ bd: ['bd/BT0AADA.wav','bd/BT0AAD0.wav','bd/BT0A0DA.wav','bd/BT0A0D3.wav','bd/BT0A0D0.wav','bd/BT0A0A7.wav'], @@ -31,7 +43,7 @@ stack( .off(1/8,x=>x.add(12).degradeBy(.5)) // random octave jumps .add(perlin.range(0,.5)) // random pitch variation .superimpose(add(.05)) // add second, slightly detuned voice - .n() // wrap in "n" + .note() // wrap in "note" .decay(.15).sustain(0) // make each note of equal length .s('sawtooth') // waveform .gain(.4) // turn down @@ -40,7 +52,7 @@ stack( ,">".voicings('lefthand') // chords .superimpose(x=>x.add(.04)) // add second, slightly detuned voice .add(perlin.range(0,.5)) // random pitch variation - .n() // wrap in "n" + .note() // wrap in "n" .s('square') // waveform .gain(.16) // turn down .cutoff(500) // fixed cutoff @@ -49,7 +61,7 @@ stack( ,"a4 c5 ".struct("x(5,8)") .superimpose(x=>x.add(.04)) // add second, slightly detuned voice .add(perlin.range(0,.5)) // random pitch variation - .n() // wrap in "n" + .note() // wrap in "note" .decay(.1).sustain(0) // make notes short .s('triangle') // waveform .degradeBy(perlin.range(0,.5)) // randomly controlled random removal :) @@ -103,7 +115,7 @@ function App() { } } }, - [scheduler, evaluate, view], + [scheduler, evaluate, view, code], ), ); return ( diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index aa90eb30..ebe3dd11 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -67,7 +67,7 @@ const highlightField = StateField.define({ } let mark; if (color) { - mark = Decoration.mark({ attributes: { style: `outline: 4px solid ${color};` } }); + mark = Decoration.mark({ attributes: { style: `outline: 2px solid ${color};` } }); } else { mark = Decoration.mark({ attributes: { class: `outline outline-2 outline-foreground` } }); } @@ -104,6 +104,7 @@ export default function CodeMirror({ onSelectionChange, theme, keybindings, + isLineNumbersDisplayed, fontSize = 18, fontFamily = 'monospace', options, @@ -148,6 +149,7 @@ export default function CodeMirror({ onCreateEditor={handleOnCreateEditor} onUpdate={handleOnUpdate} extensions={extensions} + basicSetup={{ lineNumbers: isLineNumbersDisplayed }} /> ); diff --git a/packages/react/src/components/MiniRepl.jsx b/packages/react/src/components/MiniRepl.jsx index a8de7978..1a6cce94 100644 --- a/packages/react/src/components/MiniRepl.jsx +++ b/packages/react/src/components/MiniRepl.jsx @@ -18,16 +18,24 @@ export function MiniRepl({ tune, hideOutsideView = false, enableKeyboard, + onTrigger, drawTime, punchcard, + punchcardLabels, + onPaint, canvasHeight = 200, + fontSize = 18, + fontFamily, + hideHeader = false, theme, + keybindings, + isLineNumbersDisplayed, }) { drawTime = drawTime || (punchcard ? [0, 4] : undefined); const evalOnMount = !!drawTime; const drawContext = useCallback( - !!drawTime ? (canvasId) => document.querySelector('#' + canvasId)?.getContext('2d') : null, - [drawTime], + punchcard ? (canvasId) => document.querySelector('#' + canvasId)?.getContext('2d') : null, + [punchcard], ); const { code, @@ -47,7 +55,18 @@ export function MiniRepl({ } = useStrudel({ initialCode: tune, defaultOutput: webaudioOutput, - editPattern: (pat) => (punchcard ? pat.punchcard() : pat), + editPattern: (pat, id) => { + //pat = pat.withContext((ctx) => ({ ...ctx, id })); + if (onTrigger) { + pat = pat.onTrigger(onTrigger, false); + } + if (onPaint) { + pat = pat.onPaint(onPaint); + } else if (punchcard) { + pat = pat.punchcard({ labels: punchcardLabels }); + } + return pat; + }, getTime, evalOnMount, drawContext, @@ -82,7 +101,7 @@ export function MiniRepl({ e.preventDefault(); flash(view); await activateCode(); - } else if (e.key === '.') { + } else if (e.key === '.' || e.code === 'Period') { stop(); e.preventDefault(); } @@ -101,7 +120,7 @@ export function MiniRepl({ // const logId = data?.pattern?.meta?.id; if (logId === replId) { setLog((l) => { - return l.concat([e.detail]).slice(-10); + return l.concat([e.detail]).slice(-8); }); } }, []), @@ -109,33 +128,46 @@ export function MiniRepl({ return (
-
-
- - + {!hideHeader && ( +
+
+ + +
- {error &&
{error.message}
} -
+ )}
- {show && } + {show && ( + + )} + {error &&
{error.message}
}
- {drawTime && ( + {punchcard && ( !!(pat?.context?.onPaint && drawContext), [drawContext]); + //const shouldPaint = useCallback((pat) => !!(pat?.context?.onPaint && drawContext), [drawContext]); + const shouldPaint = useCallback((pat) => !!pat?.context?.onPaint, []); // TODO: make sure this hook reruns when scheduler.started changes const { scheduler, evaluate, start, stop, pause, setCps } = useMemo( diff --git a/packages/soundfonts/package.json b/packages/soundfonts/package.json index ce1b4446..eeb354ab 100644 --- a/packages/soundfonts/package.json +++ b/packages/soundfonts/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/soundfonts", - "version": "0.7.1", + "version": "0.8.1", "description": "Soundsfont support for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/tonal/package.json b/packages/tonal/package.json index 2309a7d1..cc6d80c6 100644 --- a/packages/tonal/package.json +++ b/packages/tonal/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/tonal", - "version": "0.7.1", + "version": "0.8.1", "description": "Tonal functions for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json index 6b08191c..2878daa4 100644 --- a/packages/transpiler/package.json +++ b/packages/transpiler/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/transpiler", - "version": "0.7.1", + "version": "0.8.1", "description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.", "main": "index.mjs", "publishConfig": { diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 00000000..f1ed2bbc --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,82 @@ +# @strudel/web + +This package provides an easy to use bundle of multiple strudel packages for the web. + +## Usage + +Save this code as a `.html` file and double click it: + +```html + + + + +``` + +With the help of [skypack](https://www.skypack.dev/), you don't need a bundler nor a server. + +As soon as you call `initStrudel()`, all strudel functions are made available. +In this case, we are using the `note` function to create a pattern. +To actually play the pattern, you have to append `.play()` to the end. + +Note: Due to the [Autoplay policy](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy), you can only play audio in a browser after a click event. + +### Via npm + +If you're using a bundler, you can install the package via `npm i @strudel/web`, then just import it like: + +```js +import { initStrudel } from '@strudel/web'; +``` + +The rest of the code should be the same. Check out [vite](https://vitejs.dev/) for a good bundler / dev server. + +### Loading samples + +By default, no external samples are loaded, but you can add them like this: + +```js +initStrudel({ + prebake: () => samples('github:tidalcycles/Dirt-Samples/master'), +}); + +document.getElementById('play').addEventListener('click', + () => s("bd sd").play() +) +``` + +You can learn [more about the `samples` function here](https://strudel.tidalcycles.org/learn/samples#loading-custom-samples). + +### Evaluating Code + +Instead of creating patterns directly in JS, you might also want to take in user input and turn that into a pattern. +This is called evaluation: Taking a piece of code and executing it on the fly. + +To do that, you can use the `evaluate` function: + +```js +initStrudel(); +document.getElementById('play').addEventListener('click', + () => evaluate('note("c a f e").jux(rev)') +); +document.getElementById('play').addEventListener('stop', + () => hush() +); +``` + +### Double vs Single Quotes + +There is a tiny difference between the [Strudel REPL](https://strudel.tidalcycles.org/) and `@strudel/web`. +In the REPL you can use 'single quotes' for regular JS strings and "double quotes" for mini notation patterns. +In `@strudel/web`, it does not matter which types of quotes you're using. +There will probably be an escapte hatch for that in the future. + +## More Examples + +Check out the examples folder for more examples, both using plain html and vite! \ No newline at end of file diff --git a/packages/web/examples/evaluate.html b/packages/web/examples/evaluate.html new file mode 100644 index 00000000..77e6431f --- /dev/null +++ b/packages/web/examples/evaluate.html @@ -0,0 +1,10 @@ + + + + diff --git a/packages/web/examples/headless-serverless-buildless.html b/packages/web/examples/headless-serverless-buildless.html new file mode 100644 index 00000000..99197581 --- /dev/null +++ b/packages/web/examples/headless-serverless-buildless.html @@ -0,0 +1,16 @@ + + + + + + diff --git a/packages/web/examples/minimal.html b/packages/web/examples/minimal.html new file mode 100644 index 00000000..58487009 --- /dev/null +++ b/packages/web/examples/minimal.html @@ -0,0 +1,10 @@ + + + + diff --git a/packages/web/examples/repl-example/.gitignore b/packages/web/examples/repl-example/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/web/examples/repl-example/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/web/examples/repl-example/index.html b/packages/web/examples/repl-example/index.html new file mode 100644 index 00000000..12003e4b --- /dev/null +++ b/packages/web/examples/repl-example/index.html @@ -0,0 +1,28 @@ + + + + + + + @strudel/web REPL Example + + +
+ + + + + + + diff --git a/packages/web/examples/repl-example/package.json b/packages/web/examples/repl-example/package.json new file mode 100644 index 00000000..42fb8b83 --- /dev/null +++ b/packages/web/examples/repl-example/package.json @@ -0,0 +1,18 @@ +{ + "name": "repl-example", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "AGPL-3.0-or-later", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^4.3.2" + }, + "dependencies": { + "@strudel/web": "workspace:*" + } +} diff --git a/packages/web/examples/samples.html b/packages/web/examples/samples.html new file mode 100644 index 00000000..8b967ec2 --- /dev/null +++ b/packages/web/examples/samples.html @@ -0,0 +1,12 @@ + + + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 00000000..2e75b303 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,45 @@ +{ + "name": "@strudel/web", + "version": "0.8.2", + "description": "Easy to setup, opiniated bundle of Strudel for the browser.", + "main": "web.mjs", + "publishConfig": { + "main": "dist/index.js", + "module": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "contributors": [ + "Alex McLean " + ], + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "dependencies": { + "@strudel.cycles/core": "workspace:*", + "@strudel.cycles/webaudio": "workspace:*", + "@strudel.cycles/mini": "workspace:*", + "@strudel.cycles/tonal": "workspace:*", + "@strudel.cycles/transpiler": "workspace:*" + }, + "devDependencies": { + "vite": "^4.3.3" + } +} diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js new file mode 100644 index 00000000..ffa2ad27 --- /dev/null +++ b/packages/web/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'web.mjs'), + formats: ['es', 'cjs'], + fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/packages/web/web.mjs b/packages/web/web.mjs new file mode 100644 index 00000000..607d20ba --- /dev/null +++ b/packages/web/web.mjs @@ -0,0 +1,66 @@ +export * from '@strudel.cycles/core'; +export * from '@strudel.cycles/webaudio'; +//export * from '@strudel.cycles/soundfonts'; +export * from '@strudel.cycles/transpiler'; +export * from '@strudel.cycles/mini'; +export * from '@strudel.cycles/tonal'; +export * from '@strudel.cycles/webaudio'; +import { Pattern, evalScope, controls } from '@strudel.cycles/core'; +import { initAudioOnFirstClick, registerSynthSounds, webaudioScheduler } from '@strudel.cycles/webaudio'; +// import { registerSoundfonts } from '@strudel.cycles/soundfonts'; +import { evaluate as _evaluate } from '@strudel.cycles/transpiler'; +import { miniAllStrings } from '@strudel.cycles/mini'; + +// init logic +export async function defaultPrebake() { + const loadModules = evalScope( + evalScope, + controls, + import('@strudel.cycles/core'), + import('@strudel.cycles/mini'), + import('@strudel.cycles/tonal'), + import('@strudel.cycles/webaudio'), + { hush, evaluate }, + ); + await Promise.all([loadModules, registerSynthSounds() /* , registerSoundfonts() */]); +} + +// when this function finishes, everything is initialized +let initDone; + +let scheduler; +export function initStrudel(options = {}) { + initAudioOnFirstClick(); + miniAllStrings(); + const { prebake, ...schedulerOptions } = options; + + initDone = (async () => { + await defaultPrebake(); + await prebake?.(); + })(); + scheduler = webaudioScheduler(schedulerOptions); +} + +window.initStrudel = initStrudel; + +// this method will play the pattern on the default scheduler +Pattern.prototype.play = function () { + if (!scheduler) { + throw new Error('.play: no scheduler found. Have you called init?'); + } + initDone.then(() => { + scheduler.setPattern(this, true); + }); + return this; +}; + +// stop playback +export function hush() { + scheduler.stop(); +} + +// evaluate and play the given code using the transpiler +export async function evaluate(code, autoplay = true) { + const { pattern } = await _evaluate(code); + autoplay && pattern.play(); +} diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index bac45088..dfbcfd88 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -1,6 +1,6 @@ { "name": "@strudel.cycles/webaudio", - "version": "0.7.1", + "version": "0.8.1", "description": "Web Audio helpers for Strudel", "main": "index.mjs", "type": "module", diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index c75bde0b..c5922303 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -85,7 +85,12 @@ export async function initAudioOnFirstClick() { } let delays = {}; +const maxfeedback = 0.98; function getDelay(orbit, delaytime, delayfeedback, t) { + if (delayfeedback > maxfeedback) { + logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); + } + delayfeedback = strudel.clamp(delayfeedback, 0, 0.98); if (!delays[orbit]) { const ac = getAudioContext(); const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback); @@ -243,3 +248,16 @@ Pattern.prototype.webaudio = function () { // TODO: refactor (t, hap, ct, cps) to (hap, deadline, duration) ? return this.onTrigger(webaudioOutputTrigger); }; + +export function webaudioScheduler(options = {}) { + options = { + getTime: () => getAudioContext().currentTime, + defaultOutput: webaudioOutput, + ...options, + }; + const { defaultOutput, getTime } = options; + return new strudel.Cyclist({ + ...options, + onTrigger: strudel.getTrigger({ defaultOutput, getTime }), + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3731b000..8090c960 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,34 @@ importers: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) + packages/codemirror: + dependencies: + '@codemirror/commands': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/lang-javascript': + specifier: ^6.1.7 + version: 6.1.7 + '@codemirror/language': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/state': + specifier: ^6.2.0 + version: 6.2.0 + '@codemirror/view': + specifier: ^6.10.0 + version: 6.10.0 + '@lezer/highlight': + specifier: ^1.1.4 + version: 1.1.4 + '@strudel.cycles/core': + specifier: workspace:* + version: link:../core + devDependencies: + vite: + specifier: ^4.3.3 + version: 4.3.3(@types/node@18.11.18) + packages/core: dependencies: fraction.js: @@ -74,7 +102,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -99,7 +127,35 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) + + packages/core/examples/vite-vanilla-repl-cm6: + dependencies: + '@strudel.cycles/core': + specifier: workspace:* + version: link:../.. + '@strudel.cycles/mini': + specifier: workspace:* + version: link:../../../mini + '@strudel.cycles/soundfonts': + specifier: workspace:* + version: link:../../../soundfonts + '@strudel.cycles/tonal': + specifier: workspace:* + version: link:../../../tonal + '@strudel.cycles/transpiler': + specifier: workspace:* + version: link:../../../transpiler + '@strudel.cycles/webaudio': + specifier: workspace:* + version: link:../../../webaudio + '@strudel/codemirror': + specifier: workspace:* + version: link:../../../codemirror + devDependencies: + vite: + specifier: ^4.3.2 + version: 4.3.3(@types/node@18.11.18) packages/csound: dependencies: @@ -115,7 +171,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/embed: {} @@ -148,7 +204,7 @@ importers: version: link:../mini vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -167,7 +223,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/mini: dependencies: @@ -180,7 +236,7 @@ importers: version: 3.0.2 vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -199,7 +255,7 @@ importers: version: 5.8.1 vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/react: dependencies: @@ -220,10 +276,10 @@ importers: version: 1.1.4 '@replit/codemirror-emacs': specifier: ^6.0.1 - version: 6.0.1(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + version: 6.0.1(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) '@replit/codemirror-vim': specifier: ^6.0.14 - version: 6.0.14(@codemirror/commands@6.2.0)(@codemirror/language@6.6.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + version: 6.0.14(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) '@strudel.cycles/core': specifier: workspace:* version: link:../core @@ -269,7 +325,7 @@ importers: version: 3.3.2 vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/react/examples/nano-repl: dependencies: @@ -285,6 +341,9 @@ importers: '@strudel.cycles/react': specifier: workspace:* version: link:../.. + '@strudel.cycles/soundfonts': + specifier: workspace:* + version: link:../../../soundfonts '@strudel.cycles/tonal': specifier: workspace:* version: link:../../../tonal @@ -321,7 +380,7 @@ importers: version: 3.3.2 vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/serial: dependencies: @@ -331,7 +390,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/soundfonts: dependencies: @@ -353,7 +412,7 @@ importers: version: 3.3.1 vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/tonal: dependencies: @@ -372,7 +431,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -388,7 +447,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -410,11 +469,43 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) + packages/web: + dependencies: + '@strudel.cycles/core': + specifier: workspace:* + version: link:../core + '@strudel.cycles/mini': + specifier: workspace:* + version: link:../mini + '@strudel.cycles/tonal': + specifier: workspace:* + version: link:../tonal + '@strudel.cycles/transpiler': + specifier: workspace:* + version: link:../transpiler + '@strudel.cycles/webaudio': + specifier: workspace:* + version: link:../webaudio + devDependencies: + vite: + specifier: ^4.3.3 + version: 4.3.3(@types/node@18.11.18) + + packages/web/examples/repl-example: + dependencies: + '@strudel/web': + specifier: workspace:* + version: link:../.. + devDependencies: + vite: + specifier: ^4.3.2 + version: 4.3.3(@types/node@18.11.18) + packages/webaudio: dependencies: '@strudel.cycles/core': @@ -426,7 +517,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) packages/webdirt: dependencies: @@ -442,7 +533,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -455,7 +546,7 @@ importers: devDependencies: vite: specifier: ^4.3.3 - version: 4.3.3(@types/node@18.16.3) + version: 4.3.3(@types/node@18.11.18) vitest: specifier: ^0.28.0 version: 0.28.0(@vitest/ui@0.28.0) @@ -555,6 +646,9 @@ importers: canvas: specifier: ^2.11.2 version: 2.11.2 + claviature: + specifier: ^0.1.0 + version: 0.1.0 fraction.js: specifier: ^4.2.0 version: 4.2.0 @@ -2268,8 +2362,8 @@ packages: '@lezer/common': 1.0.2 dev: false - /@codemirror/commands@6.2.0: - resolution: {integrity: sha512-+00smmZBradoGFEkRjliN7BjqPh/Hx0KCHWOEibUmflUqZz2RwBTU0MrVovEEHozhx3AUSGcO/rl3/5f9e9Biw==} + /@codemirror/commands@6.2.4: + resolution: {integrity: sha512-42lmDqVH0ttfilLShReLXsDfASKLXzfyC36bzwcqzox9PlHulMcsUOfHXNo2X2aFMVNUoQ7j+d4q5bnfseYoOA==} dependencies: '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.0 @@ -3452,7 +3546,7 @@ packages: escalade: 3.1.1 dev: false - /@replit/codemirror-emacs@6.0.1(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): + /@replit/codemirror-emacs@6.0.1(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): resolution: {integrity: sha512-2WYkODZGH1QVAXWuOxTMCwktkoZyv/BjYdJi2A5w4fRrmOQFuIACzb6pO9dgU3J+Pm2naeiX2C8veZr/3/r6AA==} peerDependencies: '@codemirror/autocomplete': ^6.0.2 @@ -3462,13 +3556,13 @@ packages: '@codemirror/view': ^6.3.0 dependencies: '@codemirror/autocomplete': 6.6.0(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0)(@lezer/common@1.0.2) - '@codemirror/commands': 6.2.0 + '@codemirror/commands': 6.2.4 '@codemirror/search': 6.2.3 '@codemirror/state': 6.2.0 '@codemirror/view': 6.10.0 dev: false - /@replit/codemirror-vim@6.0.14(@codemirror/commands@6.2.0)(@codemirror/language@6.6.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): + /@replit/codemirror-vim@6.0.14(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): resolution: {integrity: sha512-wwhqhvL76FdRTdwfUWpKCbv0hkp2fvivfMosDVlL/popqOiNLtUhL02ThgHZH8mus/NkVr5Mj582lyFZqQrjOA==} peerDependencies: '@codemirror/commands': ^6.0.0 @@ -3477,7 +3571,7 @@ packages: '@codemirror/state': ^6.0.1 '@codemirror/view': ^6.0.3 dependencies: - '@codemirror/commands': 6.2.0 + '@codemirror/commands': 6.2.4 '@codemirror/language': 6.6.0 '@codemirror/search': 6.2.3 '@codemirror/state': 6.2.0 @@ -4073,7 +4167,7 @@ packages: eslint-visitor-keys: 3.3.0 dev: false - /@uiw/codemirror-extensions-basic-setup@4.19.16(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.0)(@codemirror/language@6.6.0)(@codemirror/lint@6.1.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): + /@uiw/codemirror-extensions-basic-setup@4.19.16(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/lint@6.1.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0): resolution: {integrity: sha512-Xm0RDpyYVZ/8hWqaBs3+wZwi4uLwZUBwp/uCt89X80FeR6mr3BFuC+a+gcDO4dBu3l+WQE3jJdhjKjB2TCY/PQ==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' @@ -4085,7 +4179,7 @@ packages: '@codemirror/view': '>=6.0.0' dependencies: '@codemirror/autocomplete': 6.6.0(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0)(@lezer/common@1.0.2) - '@codemirror/commands': 6.2.0 + '@codemirror/commands': 6.2.4 '@codemirror/language': 6.6.0 '@codemirror/lint': 6.1.0 '@codemirror/search': 6.2.3 @@ -4380,11 +4474,11 @@ packages: react-dom: '>=16.8.0' dependencies: '@babel/runtime': 7.20.13 - '@codemirror/commands': 6.2.0 + '@codemirror/commands': 6.2.4 '@codemirror/state': 6.2.0 '@codemirror/theme-one-dark': 6.1.0 '@codemirror/view': 6.10.0 - '@uiw/codemirror-extensions-basic-setup': 4.19.16(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.0)(@codemirror/language@6.6.0)(@codemirror/lint@6.1.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) + '@uiw/codemirror-extensions-basic-setup': 4.19.16(@codemirror/autocomplete@6.6.0)(@codemirror/commands@6.2.4)(@codemirror/language@6.6.0)(@codemirror/lint@6.1.0)(@codemirror/search@6.2.3)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) codemirror: 6.0.1(@lezer/common@1.0.2) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -4415,7 +4509,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.21.0(@babel/core@7.21.5) '@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.21.5) react-refresh: 0.14.0 - vite: 4.3.3(@types/node@18.16.3) + vite: 4.3.3(@types/node@18.11.18) transitivePeerDependencies: - supports-color dev: true @@ -5338,6 +5432,10 @@ packages: resolution: {integrity: sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==} engines: {node: '>=8'} + /claviature@0.1.0: + resolution: {integrity: sha512-Ai12axNwQ7x/F9QAj64RYKsgvi5Y33+X3GUSKAC/9s/adEws8TSSc0efeiqhKNGKBo6rT/c+CSCwSXzXxwxZzQ==} + dev: false + /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -5458,7 +5556,7 @@ packages: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} dependencies: '@codemirror/autocomplete': 6.6.0(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0)(@lezer/common@1.0.2) - '@codemirror/commands': 6.2.0 + '@codemirror/commands': 6.2.4 '@codemirror/language': 6.6.0 '@codemirror/lint': 6.1.0 '@codemirror/search': 6.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b068aa6..984f2447 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,6 @@ packages: - "packages/*" - "website/" - "packages/core/examples/vite-vanilla-repl" - - "packages/react/examples/nano-repl" \ No newline at end of file + - "packages/core/examples/vite-vanilla-repl-cm6" + - "packages/react/examples/nano-repl" + - "packages/web/examples/repl-example" diff --git a/test/metadata.test.mjs b/test/metadata.test.mjs new file mode 100644 index 00000000..cbd0f8a3 --- /dev/null +++ b/test/metadata.test.mjs @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; +import { getMetadata } from '../website/src/pages/metadata_parser'; + +describe.concurrent('Metadata parser', () => { + it('loads a tag from inline comment', async () => { + const tune = `// @title Awesome song`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + }); + }); + + it('loads many tags from inline comments', async () => { + const tune = `// @title Awesome song +// @by Sam`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads many tags from one inline comment', async () => { + const tune = `// @title Awesome song @by Sam`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads a tag from a block comment', async () => { + const tune = `/* @title Awesome song */`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + }); + }); + + it('loads many tags from a block comment', async () => { + const tune = `/* +@title Awesome song +@by Sam +*/`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads many tags from many block comments', async () => { + const tune = `/* @title Awesome song */ +/* @by Sam */`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads many tags from mixed comments', async () => { + const tune = `/* @title Awesome song */ +// @by Sam +`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads a title tag with quotes syntax', async () => { + const tune = `// "Awesome song"`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + }); + }); + + it('loads a title tag with quotes syntax among other tags', async () => { + const tune = `// "Awesome song" made @by Sam`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('loads a title tag with quotes syntax from block comment', async () => { + const tune = `/* "Awesome song" +@by Sam */`; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam'], + }); + }); + + it('does not load a title tag with quotes syntax after a prefix', async () => { + const tune = `// I don't care about those "metadata".`; + expect(getMetadata(tune)).toStrictEqual({}); + }); + + it('does not load a title tag with quotes syntax after an other comment', async () => { + const tune = `// I don't care about those +// "metadata"`; + expect(getMetadata(tune)).toStrictEqual({}); + }); + + it('does not load a title tag with quotes syntax after other tags', async () => { + const tune = `/* +@by Sam aka "Lady Strudel" + "Sandyyy" +*/`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam aka "Lady Strudel"', '"Sandyyy"'], + }); + }); + + it('loads a tag list with comma-separated values syntax', async () => { + const tune = `// @by Sam, Sandy`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + }); + }); + + it('loads a tag list with duplicate keys syntax', async () => { + const tune = `// @by Sam +// @by Sandy`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + }); + }); + + it('loads a tag list with duplicate keys syntax, with prefixes', async () => { + const tune = `// song @by Sam +// samples @by Sandy`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + }); + }); + + it('loads many tag lists with duplicate keys syntax, within code', async () => { + const tune = `note("a3 c#4 e4 a4") // @by Sam @license CC0 + s("bd hh sd hh") // @by Sandy @license CC BY-NC-SA`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + license: ['CC0', 'CC BY-NC-SA'], + }); + }); + + it('loads a tag list with duplicate keys syntax from block comment', async () => { + const tune = `/* @by Sam +@by Sandy */`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + }); + }); + + it('loads a tag list with newline syntax', async () => { + const tune = `/* +@by Sam + Sandy */`; + expect(getMetadata(tune)).toStrictEqual({ + by: ['Sam', 'Sandy'], + }); + }); + + it('loads a multiline tag from block comment', async () => { + const tune = `/* +@details I wrote this song in February 19th, 2023. + It was around midnight and I was lying on + the sofa in the living room. +*/`; + expect(getMetadata(tune)).toStrictEqual({ + details: + 'I wrote this song in February 19th, 2023. ' + + 'It was around midnight and I was lying on the sofa in the living room.', + }); + }); + + it('loads a multiline tag from block comment with duplicate keys', async () => { + const tune = `/* +@details I wrote this song in February 19th, 2023. +@details It was around midnight and I was lying on + the sofa in the living room. +*/`; + expect(getMetadata(tune)).toStrictEqual({ + details: + 'I wrote this song in February 19th, 2023. ' + + 'It was around midnight and I was lying on the sofa in the living room.', + }); + }); + + it('loads a multiline tag from inline comments', async () => { + const tune = `// @details I wrote this song in February 19th, 2023. +// @details It was around midnight and I was lying on +// @details the sofa in the living room. +*/`; + expect(getMetadata(tune)).toStrictEqual({ + details: + 'I wrote this song in February 19th, 2023. ' + + 'It was around midnight and I was lying on the sofa in the living room.', + }); + }); + + it('loads empty tags from inline comments', async () => { + const tune = `// @title +// @by`; + expect(getMetadata(tune)).toStrictEqual({ + title: '', + by: [], + }); + }); + + it('loads tags with whitespaces from inline comments', async () => { + const tune = ` // @title Awesome song + // @by Sam Tagada `; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam Tagada'], + }); + }); + + it('loads tags with whitespaces from block comment', async () => { + const tune = ` /* @title Awesome song + @by Sam Tagada */ `; + expect(getMetadata(tune)).toStrictEqual({ + title: 'Awesome song', + by: ['Sam Tagada'], + }); + }); + + it('loads empty tags from block comment', async () => { + const tune = `/* @title +@by */`; + expect(getMetadata(tune)).toStrictEqual({ + title: '', + by: [], + }); + }); + + it('does not load tags if there is not', async () => { + const tune = `note("a3 c#4 e4 a4")`; + expect(getMetadata(tune)).toStrictEqual({}); + }); + + it('does not load code that looks like a metadata tag', async () => { + const tune = `const str1 = '@title Awesome song'`; + // need a lexer to avoid this one, but it's a pretty rare use case: + // const tune = `const str1 = '// @title Awesome song'`; + + expect(getMetadata(tune)).toStrictEqual({}); + }); +}); diff --git a/website/package.json b/website/package.json index 3e15436f..c595f5a6 100644 --- a/website/package.json +++ b/website/package.json @@ -4,16 +4,16 @@ "version": "0.6.0", "private": true, "scripts": { - "dev": "astro dev", + "dev": "astro dev --host 0.0.0.0", "start": "astro dev", "check": "astro check && tsc", "build": "astro build", - "preview": "astro preview", + "preview": "astro preview --port 3009 --host 0.0.0.0", "astro": "astro" }, "dependencies": { "@algolia/client-search": "^4.17.0", - "@astrojs/mdx": "^0.19.0", + "@astrojs/mdx": "^0.19.0", "@astrojs/react": "^2.1.1", "@astrojs/tailwind": "^3.1.1", "@docsearch/css": "^3.3.4", @@ -43,6 +43,7 @@ "@uiw/codemirror-themes-all": "^4.19.16", "astro": "^2.3.2", "canvas": "^2.11.2", + "claviature": "^0.1.0", "fraction.js": "^4.2.0", "nanoid": "^4.0.2", "nanostores": "^0.8.1", diff --git a/website/public/icons/strudel_icon.png b/website/public/icons/strudel_icon.png new file mode 100644 index 00000000..ec9ad8e1 Binary files /dev/null and b/website/public/icons/strudel_icon.png differ diff --git a/website/src/components/Box.astro b/website/src/components/Box.astro new file mode 100644 index 00000000..d27671ea --- /dev/null +++ b/website/src/components/Box.astro @@ -0,0 +1,10 @@ +--- +import LightBulbIcon from '@heroicons/react/20/solid/LightBulbIcon'; +//import MusicalNoteIcon from '@heroicons/react/20/solid/MusicalNoteIcon'; +--- + +
+
+ + +
diff --git a/website/src/components/Claviature.jsx b/website/src/components/Claviature.jsx new file mode 100644 index 00000000..e97facbc --- /dev/null +++ b/website/src/components/Claviature.jsx @@ -0,0 +1,24 @@ +import { getClaviature } from 'claviature'; +import React from 'react'; + +export default function Claviature({ options, onClick, onMouseDown, onMouseUp, onMouseLeave }) { + const svg = getClaviature({ + options, + onClick, + onMouseDown, + onMouseUp, + onMouseLeave, + }); + return ( + + {svg.children.map((el, i) => { + const TagName = el.name; + return ( + + {el.value} + + ); + })} + + ); +} diff --git a/website/src/components/HeadCommon.astro b/website/src/components/HeadCommon.astro index 9e81b0b8..23b39842 100644 --- a/website/src/components/HeadCommon.astro +++ b/website/src/components/HeadCommon.astro @@ -33,7 +33,7 @@ const base = BASE_URL;