diff --git a/.forgejo/workflows/deploy-warm-beta.yml b/.forgejo/workflows/deploy-warm-beta.yml new file mode 100644 index 000000000..2de6670c8 --- /dev/null +++ b/.forgejo/workflows/deploy-warm-beta.yml @@ -0,0 +1,37 @@ +name: Build and Deploy to beta (warm.strudel.cc) + +on: [workflow_dispatch] + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: docker + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9.12.2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + # cache: "pnpm" + - name: Install Dependencies + run: pnpm install + + - name: Build + run: pnpm build + + - name: Deploy + run: | + eval $(ssh-agent -s) + echo "$SSH_PRIVATE_KEY" | ssh-add - + apt update && apt install -y rsync + mkdir ~/.ssh + ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts + rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc \ No newline at end of file diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 561fee658..0643f9be1 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build and Deploy +name: Build and Deploy to live (strudel.cc) on: [workflow_dispatch] @@ -34,4 +34,4 @@ jobs: apt update && apt install -y rsync mkdir ~/.ssh ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts - rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy \ No newline at end of file + rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2be3ee698..7532adaf0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,20 @@ tidal-drum-machines webaudiofontdata src-tauri/target + + +### BEGIN Visual Studio Code ### +# Blanket, recursive exclude for .vscode directory and files +.vscode/**/* +# Unexclude specific files and directories within .vscode +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +### END Visual Studio Code ### + + + # BEGIN JetBrains -> END JetBrains # for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 69aa3bc59..b8a569bea 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -1,6 +1,9 @@ import jsdoc from '../../doc.json'; import { autocompletion } from '@codemirror/autocomplete'; import { h } from './html'; +import { Scale } from '@tonaljs/tonal'; +import { soundMap } from 'superdough'; +import { complex } from '@strudel/tonal'; const escapeHtml = (str) => { const div = document.createElement('div'); @@ -75,6 +78,51 @@ const isValidDoc = (doc) => { const hasExcludedTags = (doc) => ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); +export function bankCompletions() { + const soundDict = soundMap.get(); + const banks = new Set(); + for (const key of Object.keys(soundDict)) { + const [bank, suffix] = key.split('_'); + if (suffix && bank) banks.add(bank); + } + return Array.from(banks) + .sort() + .map((name) => ({ label: name, type: 'bank' })); +} + +// Attempt to get all scale names from Tonal +let scaleCompletions = []; +try { + scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' })); +} catch (e) { + console.warn('[autocomplete] Could not load scale names from Tonal:', e); +} + +// Valid mode values for voicing +const modeCompletions = [ + { label: 'below', type: 'mode' }, + { label: 'above', type: 'mode' }, + { label: 'duck', type: 'mode' }, + { label: 'root', type: 'mode' }, +]; + +// Valid chord symbols from ireal dictionary plus empty string for major triads +const chordSymbols = ['', ...Object.keys(complex)].sort(); +const chordSymbolCompletions = chordSymbols.map((symbol) => { + if (symbol === '') { + return { + label: 'major', + apply: '', + type: 'chord-symbol', + }; + } + return { + label: symbol, + apply: symbol, + type: 'chord-symbol', + }; +}); + export const getSynonymDoc = (doc, synonym) => { const synonyms = doc.synonyms || []; const docLabel = getDocLabel(doc); @@ -113,19 +161,299 @@ const jsdocCompletions = (() => { return completions; })(); -export const strudelAutocomplete = (context) => { - const word = context.matchBefore(/\w*/); - if (word.from === word.to && !context.explicit) return null; +// --- Handler functions for each context --- +const pitchNames = [ + 'C', + 'C#', + 'Db', + 'D', + 'D#', + 'Eb', + 'E', + 'E#', + 'Fb', + 'F', + 'F#', + 'Gb', + 'G', + 'G#', + 'Ab', + 'A', + 'A#', + 'Bb', + 'B', + 'B#', + 'Cb', +]; +// Cached regex patterns for scaleHandler +const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/; +const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/; +const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/; +const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/; +const SCALE_SPACES_TO_COLON_REGEX = /\s+/g; + +function scaleHandler(context) { + // First check for scale context without quotes - block with empty completions + let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX); + if (scaleNoQuotesContext) { + return { + from: scaleNoQuotesContext.to, + options: [], + }; + } + + // Check for after-colon context first (more specific) + let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX); + if (scaleAfterColonContext) { + const text = scaleAfterColonContext.text; + const colonIdx = text.lastIndexOf(':'); + if (colonIdx !== -1) { + const fragment = text.slice(colonIdx + 1); + const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment)); + const options = filteredScales.map((s) => ({ + ...s, + apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'), + })); + const from = scaleAfterColonContext.from + colonIdx + 1; + return { + from, + options, + }; + } + } + + // Then check for pre-colon context + let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX); + if (scalePreColonContext) { + if (!scalePreColonContext.text.includes(':')) { + if (context.explicit) { + const text = scalePreColonContext.text; + const match = text.match(SCALE_PITCH_MATCH_REGEX); + const fragment = match ? match[0] : ''; + const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase())); + const from = scalePreColonContext.to - fragment.length; + const options = filtered.map((p) => ({ label: p, type: 'pitch' })); + return { from, options }; + } else { + return { from: scalePreColonContext.to, options: [] }; + } + } + } + return null; +} + +// Cached regex patterns for soundHandler +const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/; +const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/; +const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/; + +function soundHandler(context) { + // First check for sound context without quotes - block with empty completions + let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX); + if (soundNoQuotesContext) { + return { + from: soundNoQuotesContext.to, + options: [], + }; + } + + // Then check for sound context with quotes - provide completions + let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX); + if (!soundContext) return null; + + const text = soundContext.text; + const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'")); + if (quoteIdx === -1) return null; + const inside = text.slice(quoteIdx + 1); + const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX); + const fragment = fragMatch ? fragMatch[1] : inside; + const soundNames = Object.keys(soundMap.get()).sort(); + const filteredSounds = soundNames.filter((name) => name.includes(fragment)); + let options = filteredSounds.map((name) => ({ label: name, type: 'sound' })); + const from = soundContext.to - fragment.length; return { - from: word.from, - options: jsdocCompletions, - /* options: [ - { label: 'match', type: 'keyword' }, - { label: 'hello', type: 'variable', info: '(World)' }, - { label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' }, - ], */ + from, + options, }; +} + +// Cached regex patterns for bankHandler +const BANK_NO_QUOTES_REGEX = /bank\(\s*$/; +const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/; + +function bankHandler(context) { + // First check for bank context without quotes - block with empty completions + let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX); + if (bankNoQuotesContext) { + return { + from: bankNoQuotesContext.to, + options: [], + }; + } + + // Then check for bank context with quotes - provide completions + let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX); + if (!bankMatch) return null; + + const text = bankMatch.text; + const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'")); + if (quoteIdx === -1) return null; + const inside = text.slice(quoteIdx + 1); + const fragment = inside; + let banks = bankCompletions(); + const filteredBanks = banks.filter((b) => b.label.startsWith(fragment)); + const from = bankMatch.to - fragment.length; + return { + from, + options: filteredBanks, + }; +} + +// Cached regex patterns for modeHandler +const MODE_NO_QUOTES_REGEX = /mode\(\s*$/; +const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/; +const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/; +const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/; + +function modeHandler(context) { + // First check for mode context without quotes - block with empty completions + let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX); + if (modeNoQuotesContext) { + return { + from: modeNoQuotesContext.to, + options: [], + }; + } + + // Check for after-colon context first (more specific) + let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX); + if (modeAfterColonContext) { + const text = modeAfterColonContext.text; + const colonIdx = text.lastIndexOf(':'); + if (colonIdx !== -1) { + const fragment = text.slice(colonIdx + 1); + // For anchor after colon, we can suggest pitch names + const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase())); + const options = filtered.map((p) => ({ label: p, type: 'pitch' })); + const from = modeAfterColonContext.from + colonIdx + 1; + return { + from, + options, + }; + } + } + + // Then check for pre-colon context + let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX); + if (!modeContext) return null; + + const text = modeContext.text; + const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'")); + if (quoteIdx === -1) return null; + const inside = text.slice(quoteIdx + 1); + const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX); + const fragment = fragMatch ? fragMatch[1] : inside; + const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment)); + const from = modeContext.to - fragment.length; + return { + from, + options: filteredModes, + }; +} + +// Cached regex patterns for chordHandler +const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/; +const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/; +const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/; + +function chordHandler(context) { + // First check for chord context without quotes - block with empty completions + let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX); + if (chordNoQuotesContext) { + return { + from: chordNoQuotesContext.to, + options: [], + }; + } + + // Then check for chord context with quotes - provide completions + let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX); + if (!chordContext) return null; + + const text = chordContext.text; + const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'")); + if (quoteIdx === -1) return null; + const inside = text.slice(quoteIdx + 1); + + // Use same fragment matching as sound/mode for expressions like "" + const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX); + const fragment = fragMatch ? fragMatch[1] : inside; + + // Check if fragment contains any pitch name at start (for root + symbol) + let rootMatch = null; + let symbolFragment = fragment; + for (const pitch of pitchNames) { + if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) { + rootMatch = pitch; + symbolFragment = fragment.slice(pitch.length); + break; + } + } + + if (rootMatch) { + // We have a root, now complete chord symbols + const filteredSymbols = chordSymbolCompletions.filter((s) => + s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()), + ); + + // Create completions that replace the entire chord, not just the symbol part + const options = filteredSymbols; + + const from = chordContext.to - symbolFragment.length; + return { from, options }; + } else { + // No root yet, complete with pitch names + const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase())); + const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' })); + const from = chordContext.to - fragment.length; + return { from, options }; + } +} + +// Cached regex patterns for fallbackHandler +const FALLBACK_WORD_REGEX = /\w*/; + +function fallbackHandler(context) { + const word = context.matchBefore(FALLBACK_WORD_REGEX); + if (word && word.from === word.to && !context.explicit) return null; + if (word) { + return { + from: word.from, + options: jsdocCompletions, + }; + } + return null; +} + +const handlers = [ + soundHandler, + bankHandler, + chordHandler, + scaleHandler, + modeHandler, + // this handler *must* be last + fallbackHandler, +]; + +export const strudelAutocomplete = (context) => { + for (const handler of handlers) { + const result = handler(context); + if (result) { + return result; + } + } + return null; }; export const isAutoCompletionEnabled = (enabled) => diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 4dc23996f..69fce4b50 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,30 +1,31 @@ import { closeBrackets } from '@codemirror/autocomplete'; -export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; -// import { search, highlightSelectionMatches } from '@codemirror/search'; -import { indentWithTab } from '@codemirror/commands'; +import { indentWithTab, toggleLineComment } from '@codemirror/commands'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; -import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language'; +import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState, Prec } from '@codemirror/state'; import { + drawSelection, EditorView, - highlightActiveLineGutter, highlightActiveLine, + highlightActiveLineGutter, keymap, lineNumbers, - drawSelection, } from '@codemirror/view'; -import { repl, registerControl } from '@strudel/core'; -import { Drawer, cleanupDraw } from '@strudel/draw'; +import { persistentAtom } from '@nanostores/persistent'; +import { logger, registerControl, repl } from '@strudel/core'; +import { cleanupDraw, Drawer } from '@strudel/draw'; + import { isAutoCompletionEnabled } from './autocomplete.mjs'; -import { isTooltipEnabled } from './tooltip.mjs'; +import { basicSetup } from './basicSetup.mjs'; import { flash, isFlashEnabled } from './flash.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { keybindings } from './keybindings.mjs'; -import { initTheme, activateTheme, theme } from './themes.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; -import { widgetPlugin, updateWidgets } from './widget.mjs'; -import { persistentAtom } from '@nanostores/persistent'; -import { basicSetup } from './basicSetup.mjs'; +import { activateTheme, initTheme, theme } from './themes.mjs'; +import { isTooltipEnabled } from './tooltip.mjs'; +import { updateWidgets, widgetPlugin } from './widget.mjs'; + +export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), @@ -94,8 +95,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo }), sliderPlugin, widgetPlugin, - // indentOnInput(), // works without. already brought with javascript extension? - // bracketMatching(), // does not do anything + // indentOnInput(), // works without. already brought with javascript + // extension? bracketMatching(), // does not do anything syntaxHighlighting(defaultHighlightStyle), EditorView.updateListener.of((v) => onChange(v)), drawSelection({ cursorBlinkRate: 0 }), @@ -119,13 +120,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo run: () => onStop?.(), }, /* { - key: 'Ctrl-Shift-.', - run: () => (onPanic ? onPanic() : onStop?.()), - }, - { - key: 'Ctrl-Shift-Enter', - run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), - }, */ + key: 'Ctrl-Shift-.', + run: () => (onPanic ? onPanic() : onStop?.()), + }, + { + key: 'Ctrl-Shift-Enter', + run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()), + }, */ ]), ), ], @@ -206,7 +207,8 @@ export class StrudelMirror { updateWidgets(this.editor, widgets); updateMiniLocations(this.editor, this.miniLocations); replOptions?.afterEval?.(options); - // if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting) + // if no painters are set (.onPaint was not called), then we only need + // the present moment (for highlighting) const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0]; this.drawer.setDrawTime(drawTime); // invalidate drawer after we've set the appropriate drawTime @@ -245,6 +247,33 @@ export class StrudelMirror { } }; document.addEventListener('start-repl', this.onStartRepl); + + // Handle global evaluation requests (e.g., from Vim :w) + this.onEvaluateRequest = (e) => { + try { + // Evaluate current editor on repl-evaluate + logger('[repl] evaluate via event'); + this.evaluate(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-evaluate event', err); + } + }; + document.addEventListener('repl-evaluate', this.onEvaluateRequest); + document.addEventListener('repl-stop', this.onStopRequest); + + // Toggle comments requested from Vim (gc) + this.onToggleComment = (e) => { + try { + // Honor selections; toggleLineComment handles both selections and + // single line + toggleLineComment(this.editor); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-toggle-comment event', err); + } + }; + document.addEventListener('repl-toggle-comment', this.onToggleComment); } draw(haps, time, painters) { painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); @@ -271,6 +300,16 @@ export class StrudelMirror { async stop() { this.repl.scheduler.stop(); } + + // Listen for global stop requests (e.g., from Vim :q) + onStopRequest = (e) => { + try { + this.stop(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-stop event', err); + } + }; async toggle() { if (this.repl.scheduler.started) { this.repl.stop(); @@ -346,11 +385,18 @@ export class StrudelMirror { } } setCode(code) { - const changes = { from: 0, to: this.editor.state.doc.length, insert: code }; + const changes = { + from: 0, + to: this.editor.state.doc.length, + insert: code, + }; this.editor.dispatch({ changes }); } clear() { this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); + this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); + this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest); + this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment); } getCursorLocation() { return this.editor.state.selection.main.head; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index ca5f34f4c..81dc87a52 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -1,11 +1,12 @@ +import { defaultKeymap } from '@codemirror/commands'; import { Prec } from '@codemirror/state'; import { keymap, ViewPlugin } from '@codemirror/view'; // import { searchKeymap } from '@codemirror/search'; import { emacs } from '@replit/codemirror-emacs'; -import { vim } from '@replit/codemirror-vim'; +import { vim, Vim } from '@replit/codemirror-vim'; // import { vim } from './vim_test.mjs'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; -import { defaultKeymap } from '@codemirror/commands'; +import { logger } from '@strudel/core'; const vscodePlugin = ViewPlugin.fromClass( class { @@ -19,6 +20,110 @@ const vscodePlugin = ViewPlugin.fromClass( ); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); +// Map Vim :w to trigger the same action as evaluation. We dispatch a custom +// event 'repl-evaluate' that the editor listens for, and also simulate +// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it +// appears in the Console panel. +try { + if (Vim && typeof Vim.defineEx === 'function') { + // Map gc to toggle line comments by dispatching a custom event that our + // CodeMirror integration listens to. This avoids depending on Vim's + // internal actions and works with current selections/visual mode. + try { + Vim.defineAction('strudelToggleComment', (cm) => { + const view = cm?.view || cm; + try { + const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); + document.dispatchEvent(ev); + } catch (e) { + console.error('strudelToggleComment dispatch failed', e); + } + }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' }); + } catch (e) { + console.error('Vim gc mapping failed', e); + } + + // :q to pause/stop + Vim.defineEx('quit', 'q', (cm) => { + try { + const view = cm?.view || cm; + // First try dispatching our custom stop event, then fallback to Alt+. + let handled = false; + try { + const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true }); + handled = document.dispatchEvent(ev) === false; + } catch (e) { + console.error('Error dispatching repl-stop event', e); + } + if (!handled) { + const altDot = new KeyboardEvent('keydown', { + key: '.', + code: 'Period', + altKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(altDot); + } + } catch (e) { + console.error('Error dispatching :q stop event', e); + } + }); + + // :w to evaluate + Vim.defineEx('write', 'w', (cm) => { + const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself + try { + view?.focus?.(); + // Let the app know this came from Vim :w + try { + logger('[vim] :w — evaluating code'); + } catch (e) { + console.error('Error logging Vim :w evaluation', e); + } + // Dispatch a dedicated evaluate event first + let handled = false; + try { + const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true }); + handled = document.dispatchEvent(ev) === false; // false means preventDefault was called + } catch (e) { + console.error('Error dispatching repl-evaluate event', e); + } + if (handled) { + return; + } + // Try Ctrl+Enter first if not handled by custom event + const ctrlEnter = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(ctrlEnter); + // If not handled (no handler called preventDefault), try Alt+Enter as + // fallback + if (!ctrlEnter.defaultPrevented) { + const altEnter = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + altKey: true, + bubbles: true, + cancelable: true, + }); + view?.dom?.dispatchEvent?.(altEnter); + } + } catch (e) { + console.error('Error dispatching :w evaluation event', e); + } + }); + } +} catch (e) { + console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e); +} + const keymaps = { vim, emacs, diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 803b33f3d..e54c4a428 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/codemirror", - "version": "1.2.5", + "version": "1.2.6", "description": "Codemirror Extensions for Strudel", "main": "index.mjs", "publishConfig": { @@ -46,8 +46,11 @@ "@replit/codemirror-vscode-keymap": "^6.0.2", "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", + "@strudel/tonal": "workspace:*", "@strudel/transpiler": "workspace:*", - "nanostores": "^0.11.3" + "@tonaljs/tonal": "^4.10.0", + "nanostores": "^0.11.3", + "superdough": "workspace:*" }, "devDependencies": { "vite": "^6.0.11" diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 3475fc2f4..a3ca638aa 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2320,6 +2320,24 @@ export const { miditouch } = registerControl('miditouch'); // TODO: what is this? export const { polyTouch } = registerControl('polyTouch'); +/** + * The host to send open sound control messages to. Requires running the OSC bridge. + * @name oschost + * @param {string | Pattern} oschost e.g. 'localhost' + * @example + * note("c4").oschost('127.0.0.1').oscport(57120).osc(); + */ +export const { oschost } = registerControl('oschost'); + +/** + * The port to send open sound control messages to. Requires running the OSC bridge. + * @name oscport + * @param {number | Pattern} oscport e.g. 57120 + * @example + * note("c4").oschost('127.0.0.1').oscport(57120).osc(); + */ +export const { oscport } = registerControl('oscport'); + export const getControlName = (alias) => { if (controlAlias.has(alias)) { return controlAlias.get(alias); diff --git a/packages/core/euclid.mjs b/packages/core/euclid.mjs index 44ab07f11..98c21a116 100644 --- a/packages/core/euclid.mjs +++ b/packages/core/euclid.mjs @@ -35,18 +35,18 @@ const right = function (n, x) { return result; }; -const _bjork = function (n, x) { +const _bjorklund = function (n, x) { const [ons, offs] = n; - return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x))); + return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x))); }; -export const bjork = function (ons, steps) { +export const bjorklund = function (ons, steps) { const inverted = ons < 0; const absOns = Math.abs(ons); const offs = steps - absOns; const ones = Array(absOns).fill([1]); const zeros = Array(offs).fill([0]); - const result = _bjork([absOns, offs], [ones, zeros]); + const result = _bjorklund([absOns, offs], [ones, zeros]); const pattern = flatten(result[1][0]).concat(flatten(result[1][1])); return inverted ? pattern.map((x) => 1 - x) : pattern; }; @@ -128,7 +128,7 @@ export const bjork = function (ons, steps) { */ const _euclidRot = function (pulses, steps, rotation) { - const b = bjork(pulses, steps); + const b = bjorklund(pulses, steps); if (rotation) { return rotate(b, -rotation); } @@ -139,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) { return pat.struct(_euclidRot(pulses, steps, 0)); }); -export const e = register('e', function (euc, pat) { +export const bjork = register('bjork', function (euc, pat) { if (!Array.isArray(euc)) { euc = [euc]; } @@ -216,6 +216,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s * .pan(sine.slow(8)) */ export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) { - const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc); + const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc); return pat.struct(morphed).setSteps(steps); }); diff --git a/packages/core/package.json b/packages/core/package.json index 7cf20cea7..c33432f14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/core", - "version": "1.2.4", + "version": "1.2.5", "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 51d525f74..e29769900 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2998,6 +2998,24 @@ export const extend = stepRegister('extend', function (factor, pat) { return pat.fast(factor).expand(factor); }); +/** + * *Experimental* + * + * `replicate` is similar to `fast` in that it increases its density, but it also increases the step count + * accordingly. So `stepcat("a b".replicate(2), "c d")` would be the same as `"a b a b c d"`, whereas + * `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`. + * + * TODO: find out how this function differs from extend + * @example + * stepcat( + * sound("bd bd - cp").replicate(2), + * sound("bd - sd -") + * ).pace(8) + */ +export const replicate = stepRegister('replicate', function (factor, pat) { + return pat.repeatCycles(factor).fast(factor).expand(factor); +}); + /** * *Experimental* * diff --git a/packages/core/test/euclid.test.js b/packages/core/test/euclid.test.js index a33ec9514..6169abd71 100644 --- a/packages/core/test/euclid.test.js +++ b/packages/core/test/euclid.test.js @@ -1,14 +1,14 @@ -import { bjork } from '../euclid.mjs'; +import { bjorklund } from '../euclid.mjs'; import { describe, expect, it } from 'vitest'; import { fastcat } from '../pattern.mjs'; -describe('bjork', () => { - it('should apply bjorklund to ons and steps', () => { - expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]); - expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]); - expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]); - expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]); - expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]); +describe('bjorklund', () => { + it('should apply bjorklundlund to ons and steps', () => { + expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]); + expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]); + expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]); + expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]); + expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]); }); }); diff --git a/packages/csound/package.json b/packages/csound/package.json index 90130a101..058b8d774 100644 --- a/packages/csound/package.json +++ b/packages/csound/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/csound", - "version": "1.2.5", + "version": "1.2.6", "description": "csound bindings for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/draw/draw.mjs b/packages/draw/draw.mjs index a429395ff..95f3aed50 100644 --- a/packages/draw/draw.mjs +++ b/packages/draw/draw.mjs @@ -84,9 +84,18 @@ Pattern.prototype.onPaint = function (painter) { state.controls.painters = []; } state.controls.painters.push(painter); + return state; }); }; +// TODO - Why isn't this pure deep copy not working? +// Pattern.prototype.onPaint = function (painter) { +// return this.withState((state) => { +// const painters = state.controls.painters ? [...state.controls.painters, painter] : [painter]; +// return new State(state.span, { ...state.controls, painters }); +// }); +// }; + Pattern.prototype.getPainters = function () { let painters = []; this.queryArc(0, 0, { painters }); diff --git a/packages/draw/package.json b/packages/draw/package.json index 6a4c57540..ac2123985 100644 --- a/packages/draw/package.json +++ b/packages/draw/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/draw", - "version": "1.2.4", + "version": "1.2.5", "description": "Helpers for drawing with Strudel", "main": "index.mjs", "type": "module", diff --git a/packages/gamepad/package.json b/packages/gamepad/package.json index 555eac03f..8605e35cd 100644 --- a/packages/gamepad/package.json +++ b/packages/gamepad/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/gamepad", - "version": "1.2.4", + "version": "1.2.5", "description": "Gamepad Inputs for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/hydra/package.json b/packages/hydra/package.json index 7ba79d5d0..eccdde52f 100644 --- a/packages/hydra/package.json +++ b/packages/hydra/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/hydra", - "version": "1.2.4", + "version": "1.2.5", "description": "Hydra integration for strudel", "main": "hydra.mjs", "type": "module", diff --git a/packages/midi/package.json b/packages/midi/package.json index 2342cf7e9..ebb07fc3a 100644 --- a/packages/midi/package.json +++ b/packages/midi/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/midi", - "version": "1.2.5", + "version": "1.2.6", "description": "Midi API for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mini/package.json b/packages/mini/package.json index 6eeaab0da..05bbd019b 100644 --- a/packages/mini/package.json +++ b/packages/mini/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mini", - "version": "1.2.4", + "version": "1.2.5", "description": "Mini notation for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mondo/mondo.mjs b/packages/mondo/mondo.mjs index b879f24bf..b87a8b588 100644 --- a/packages/mondo/mondo.mjs +++ b/packages/mondo/mondo.mjs @@ -21,13 +21,14 @@ export class MondoParser { close_curly: /^\}/, number: /^-?[0-9]*\.?[0-9]+/, // before pipe! // TODO: better error handling when "-" is used as rest, e.g "s [- bd]" - op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? .. + op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? .. // dollar: /^\$/, pipe: /^#/, stack: /^[,$]/, or: /^[|]/, plain: /^[a-zA-Z0-9-~_^#]+/, }; + op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']]; // matches next token next_token(code, offset = 0) { for (let type in this.token_types) { @@ -150,9 +151,9 @@ export class MondoParser { } return children; } - desugar_ops(children) { + desugar_ops(children, types) { while (true) { - let opIndex = children.findIndex((child) => child.type === 'op'); + let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value)); if (opIndex === -1) break; const op = { type: 'plain', value: children[opIndex].value }; if (opIndex === children.length - 1) { @@ -263,8 +264,10 @@ export class MondoParser { // the type we've removed before splitting needs to be added back children = [{ type: 'plain', value: type }, ...children]; } - children = this.desugar_ops(children); - // children = this.desugar_pipes(children, (children) => this.desugar_dollars(children)); + // for each precendence group, call desugar_ops once + this.op_precedence.forEach((ops) => { + children = this.desugar_ops(children, ops); + }); children = this.desugar_pipes(children); return children; }), diff --git a/packages/mondo/test/mondo.test.mjs b/packages/mondo/test/mondo.test.mjs index 6393b10f9..a02c6fec1 100644 --- a/packages/mondo/test/mondo.test.mjs +++ b/packages/mondo/test/mondo.test.mjs @@ -117,6 +117,7 @@ describe('mondo sugar', () => { it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)')); it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))')); it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))')); + it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)')); it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)')); /* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)')); it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))')); diff --git a/packages/mondough/mondough.mjs b/packages/mondough/mondough.mjs index b7ee83787..a230c3cb9 100644 --- a/packages/mondough/mondough.mjs +++ b/packages/mondough/mondough.mjs @@ -5,12 +5,13 @@ import { slow, seq, stepcat, - extend, + replicate, expand, pace, chooseIn, degradeBy, silence, + bjork, } from '@strudel/core'; import { registerLanguage } from '@strudel/transpiler'; import { MondoRunner } from 'mondolang'; @@ -36,10 +37,11 @@ lib.square = (...args) => stepcat(...args).setSteps(1); lib.angle = (...args) => stepcat(...args).pace(1); lib['*'] = fast; lib['/'] = slow; -lib['!'] = extend; +lib['!'] = replicate; lib['@'] = expand; lib['%'] = pace; lib['?'] = degradeBy; // todo: default 0.5 not working.. +lib['&'] = bjork; lib[':'] = tail; lib['..'] = range; lib['def'] = () => silence; diff --git a/packages/mondough/package.json b/packages/mondough/package.json index c81d76cb6..593d2264a 100644 --- a/packages/mondough/package.json +++ b/packages/mondough/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mondo", - "version": "1.1.4", + "version": "1.1.5", "description": "mondo notation for strudel", "main": "mondough.mjs", "type": "module", diff --git a/packages/motion/package.json b/packages/motion/package.json index 57cac9cc8..b0a4d5565 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/motion", - "version": "1.2.4", + "version": "1.2.5", "description": "DeviceMotion API for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mqtt/package.json b/packages/mqtt/package.json index 2e32825fe..1b34a4eae 100644 --- a/packages/mqtt/package.json +++ b/packages/mqtt/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mqtt", - "version": "1.2.4", + "version": "1.2.5", "description": "MQTT API for strudel", "main": "mqtt.mjs", "type": "module", diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index fe0691522..68caab3a7 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -4,8 +4,6 @@ Copyright (C) 2022 Strudel contributors - see . */ -import OSC from 'osc-js'; - import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core'; let connection; // Promise @@ -13,19 +11,18 @@ 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); + const ws = new WebSocket('ws://localhost:8080'); + ws.addEventListener('open', (event) => { + logger(`[osc] websocket connected`); + resolve(ws); }); - osc.on('close', () => { + ws.addEventListener('close', (event) => { + logger(`[osc] websocket closed`); connection = undefined; // allows new connection afterwards console.log('[osc] disconnected'); reject('OSC connection closed'); }); - osc.on('error', (err) => reject(err)); + ws.addEventListener('error', (err) => reject(err)); }).catch((err) => { connection = undefined; throw new Error('Could not connect to OSC server. Is it running?'); @@ -61,15 +58,19 @@ export function parseControlsFromHap(hap, cps) { const collator = new ClockCollator({}); export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { - const osc = await connect(); + const ws = await connect(); const controls = parseControlsFromHap(hap, cps); const keyvals = Object.entries(controls).flat(); + const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000; + const msg = { address: '/dirt/play', args: keyvals, timestamp: ts }; - const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000); - const message = new OSC.Message('/dirt/play', ...keyvals); - const bundle = new OSC.Bundle([message], ts); - bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60 - osc.send(bundle); + if ('oschost' in hap.value) { + msg['host'] = hap.value['oschost']; + } + if ('oscport' in hap.value) { + msg['port'] = hap.value['oscport']; + } + ws.send(JSON.stringify(msg)); } /** diff --git a/packages/osc/package.json b/packages/osc/package.json index bc828d798..173b76744 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.10", + "version": "1.3.0", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.js", @@ -38,7 +38,8 @@ "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { "@strudel/core": "workspace:*", - "osc-js": "^2.4.1" + "osc": "^2.4.5", + "ws": "^8.18.3" }, "devDependencies": { "pkg": "^5.8.1", diff --git a/packages/osc/server.js b/packages/osc/server.js old mode 100644 new mode 100755 index d7ec21c4b..ede35e975 --- a/packages/osc/server.js +++ b/packages/osc/server.js @@ -6,70 +6,59 @@ Copyright (C) 2022 Strudel contributors - see . */ -import OSC from 'osc-js'; +// import OSC from 'osc-js'; -const args = process.argv.slice(2); -function getArgValue(flag) { - const i = args.indexOf(flag); - if (i !== -1) { - const nextIsFlag = args[i + 1]?.startsWith('--') ?? true; - if (nextIsFlag) return true; - return args[i + 1]; - } -} +import { WebSocketServer } from 'ws'; +import osc from 'osc'; -let udpClientPort = Number(getArgValue('--port')) || 57120; -let debug = Number(getArgValue('--debug')) || 0; +const WS_PORT = 8080; // WebSocket server port +const OSC_REMOTE_IP = '127.0.0.1'; +const OSC_REMOTE_PORT = 57120; -const config = { - receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client - udpServer: { - host: 'localhost', // @param {string} Hostname of udp server to bind to - port: 57121, // @param {number} Port of udp client for messaging - // enabling the following line will receive tidal messages: - // port: 57120, // @param {number} Port of udp client for messaging - exclusive: false, // @param {boolean} Exclusive flag - }, - udpClient: { - host: 'localhost', // @param {string} Hostname of udp client for messaging - port: udpClientPort, // @param {number} Port of udp client for messaging - }, - wsServer: { - host: 'localhost', // @param {string} Hostname of WebSocket server - port: 8080, // @param {number} Port of WebSocket server - }, -}; - -const osc = new OSC({ plugin: new OSC.BridgePlugin(config) }); - -if (debug) { - osc.on('*', (message) => { - const { address, args } = message; - let str = ''; - for (let i = 0; i < args.length; i += 2) { - str += `${args[i]}: ${args[i + 1]} `; - } - console.log(`${address} ${str}`); - }); -} - -osc.on('error', (message) => { - if (message.toString().includes('EADDRINUSE')) { - console.log(`------ ERROR ------- -a server is already running on port 57121! to stop it: -1. run "lsof -ti :57121 | xargs kill -9" (macos / linux) -2. re-run the osc server -`); - } else { - console.log(message); - } +const udpPort = new osc.UDPPort({ + localAddress: '0.0.0.0', + localPort: 0, + remoteAddress: OSC_REMOTE_IP, + remotePort: OSC_REMOTE_PORT, }); -osc.open(); +udpPort.open(); +console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`); -console.log('osc client running on port', config.udpClient.port); -console.log('osc server running on port', config.udpServer.port); -console.log('websocket server running on port', config.wsServer.port); -if (debug) { - console.log('debug logs enabled. incoming messages will appear below'); -} +udpPort.on('error', (e) => { + console.log('Error: ', e); +}); + +const wss = new WebSocketServer({ port: WS_PORT }); +console.log(`[Listening WS] ws://localhost:${WS_PORT}`); + +wss.on('connection', (ws) => { + console.log('New WebSocket connection'); + + ws.on('message', (message) => { + let osc_host = '127.0.0.1'; + let osc_port = 57120; + + try { + const data = JSON.parse(message); + if ('host' in data) { + osc_host = data['host']; + } + if ('port' in data) { + osc_port = data['port']; + } + let msg = { address: data['address'], args: data['args'] }; + if ('timestamp' in data) { + msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] }; + } + + udpPort.send(msg, osc_host, osc_port); + } catch (err) { + console.error('Error parsing message:', err); + } + }); + + ws.on('close', () => { + console.log('WebSocket connection closed'); + }); +}); diff --git a/packages/repl/package.json b/packages/repl/package.json index 41a165c34..1ad173c61 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/repl", - "version": "1.2.6", + "version": "1.2.7", "description": "Strudel REPL as a Web Component", "module": "index.mjs", "publishConfig": { diff --git a/packages/serial/package.json b/packages/serial/package.json index 9ed89cf2a..d73b05a1c 100644 --- a/packages/serial/package.json +++ b/packages/serial/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/serial", - "version": "1.2.4", + "version": "1.2.5", "description": "Webserial API for strudel", "main": "serial.mjs", "type": "module", diff --git a/packages/soundfonts/package.json b/packages/soundfonts/package.json index 07e35674b..8c41a5d50 100644 --- a/packages/soundfonts/package.json +++ b/packages/soundfonts/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/soundfonts", - "version": "1.2.5", + "version": "1.2.6", "description": "Soundsfont support for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/superdough/README.md b/packages/superdough/README.md index 0c6e4f14c..f5947d83f 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -153,6 +153,7 @@ samples('github:tidalcycles/dirt-samples') The format is `github://`. +If `` and `` are not specified, they will default to `samples` and `main` respectively. It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo. The format is also expected to be the same as explained above. diff --git a/packages/superdough/package.json b/packages/superdough/package.json index a82117774..a95fc5ed0 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "1.2.5", + "version": "1.2.6", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index f229cd338..84bac3582 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -121,13 +121,18 @@ function githubPath(base, subpath = '') { if (!base.startsWith('github:')) { throw new Error('expected "github:" at the start of pseudoUrl'); } - let [_, path] = base.split('github:'); + let path = base.slice('github:'.length); path = path.endsWith('/') ? path.slice(0, -1) : path; - if (path.split('/').length === 2) { - // assume main as default branch if none set - path += '/main'; - } - return `https://raw.githubusercontent.com/${path}/${subpath}`; + + let components = path.split('/'); + let user = components[0]; + let repo = components.length >= 2 ? components[1] : 'samples'; + let branch = components.length >= 3 ? components[2] : 'main'; + let other = components.slice(3); + other.push(subpath ? subpath : ''); + other = other.join('/'); + + return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`; } export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index a37504b1d..36bd68969 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -630,6 +630,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) // 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, postgain: shapevol })); distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype)); if (tremolosync != null) { diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index da6abcf76..01d4eb838 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -231,12 +231,12 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { begin: t, end: envEnd, frequency, - detune: value.detune, + freqspread: value.detune, position: value.wt, warp: value.warp, warpMode: warpmode, voices: Math.max(value.unison ?? 1, 1), - spread: value.spread, + panspread: value.spread, phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, }, { outputChannelCount: [2] }, diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index a40e7a1b7..7d7ef8e2a 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1050,14 +1050,15 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { return [ { name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, { name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, - { name: 'frequency', defaultValue: 220, minValue: 0.01, maxValue: 20000 }, - { name: 'detune', defaultValue: 0.18 }, - { name: 'position', defaultValue: 0, minValue: 0, maxValue: 1 }, - { name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 }, + { name: 'frequency', defaultValue: 440, min: Number.EPSILON }, + { name: 'detune', defaultValue: 0 }, + { name: 'freqspread', defaultValue: 0.18, min: 0 }, + { name: 'position', defaultValue: 0, min: 0, max: 1 }, + { name: 'warp', defaultValue: 0, min: 0, max: 1 }, { name: 'warpMode', defaultValue: 0 }, - { name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 }, - { name: 'spread', defaultValue: 0.7, minValue: 0, maxValue: 1 }, - { name: 'phaserand', defaultValue: 0, minValue: 0, maxValue: 1 }, + { name: 'voices', defaultValue: 1, min: 1 }, + { name: 'panspread', defaultValue: 0.7, min: 0, max: 1 }, + { name: 'phaserand', defaultValue: 0, min: 0, max: 1 }, ]; } @@ -1235,10 +1236,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { _sampleFrame(frame, phase) { const len = frame.length; const pos = phase * len; - const i = pos | 0; + let i = pos | 0; + if (i >= len) i = 0; // fast wrap const frac = pos - i; const a = frame[i]; - const i1 = i + 1 < len ? i + 1 : 0; // fast wrap + let i1 = i + 1; + if (i1 >= len) i1 = 0; const b = frame[i1]; return a + (b - a) * frac; } @@ -1268,6 +1271,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } for (let i = 0; i < outL.length; i++) { const detune = pv(parameters.detune, i); + const freqspread = pv(parameters.freqspread, i); const tablePos = clamp(pv(parameters.position, i), 0, 1); const idx = tablePos * (this.numFrames - 1); const fIdx = idx | 0; @@ -1276,9 +1280,9 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { const warpMode = pv(parameters.warpMode, i); const voices = pv(parameters.voices, i); const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1); - const spread = voices > 1 ? clamp(pv(parameters.spread, i), 0, 1) : 0; - const gain1 = Math.sqrt(0.5 - 0.5 * spread); - const gain2 = Math.sqrt(0.5 + 0.5 * spread); + const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0; + const gain1 = Math.sqrt(0.5 - 0.5 * panspread); + const gain2 = Math.sqrt(0.5 + 0.5 * panspread); let f = pv(parameters.frequency, i); f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune const normalizer = 1 / Math.sqrt(voices); @@ -1291,7 +1295,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { gainL = gain2; gainR = gain1; } - const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune + const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune const dPhase = fVoice * this.invSR; const level = this._chooseMip(dPhase); const table = this.tables[level]; diff --git a/packages/supradough/package.json b/packages/supradough/package.json index 7e465c0a9..503043718 100644 --- a/packages/supradough/package.json +++ b/packages/supradough/package.json @@ -1,6 +1,6 @@ { "name": "supradough", - "version": "1.2.3", + "version": "1.2.4", "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.", "main": "index.mjs", "type": "module", @@ -32,6 +32,5 @@ "vite": "^6.0.11", "vite-plugin-bundle-audioworklet": "workspace:*", "wav-encoder": "^1.3.0" - }, - "dependencies": {} + } } diff --git a/packages/tonal/index.mjs b/packages/tonal/index.mjs index a3c0cba39..ebbcf0851 100644 --- a/packages/tonal/index.mjs +++ b/packages/tonal/index.mjs @@ -1,9 +1,9 @@ import './tonal.mjs'; import './voicings.mjs'; +import './ireal.mjs'; export * from './tonal.mjs'; export * from './voicings.mjs'; - -import './ireal.mjs'; +export * from './ireal.mjs'; export const packageName = '@strudel/tonal'; diff --git a/packages/tonal/package.json b/packages/tonal/package.json index 1461bdc8e..611df6766 100644 --- a/packages/tonal/package.json +++ b/packages/tonal/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/tonal", - "version": "1.2.4", + "version": "1.2.5", "description": "Tonal functions for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json index 18722bdc2..bd28a19f4 100644 --- a/packages/transpiler/package.json +++ b/packages/transpiler/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/transpiler", - "version": "1.2.4", + "version": "1.2.5", "description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.", "main": "index.mjs", "type": "module", diff --git a/packages/web/package.json b/packages/web/package.json index df21f4055..aef044c79 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/web", - "version": "1.2.5", + "version": "1.2.6", "description": "Easy to setup, opiniated bundle of Strudel for the browser.", "module": "web.mjs", "publishConfig": { diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index 49da00f23..2d5cd420c 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/webaudio", - "version": "1.2.5", + "version": "1.2.6", "description": "Web Audio helpers for Strudel", "main": "index.mjs", "type": "module", diff --git a/packages/xen/package.json b/packages/xen/package.json index 0a6736d9c..4015c437d 100644 --- a/packages/xen/package.json +++ b/packages/xen/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/xen", - "version": "1.2.4", + "version": "1.2.5", "description": "Xenharmonic API for strudel", "main": "index.mjs", "type": "module", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c225057f3..23903fcac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: version: 2.2.7 '@vitest/coverage-v8': specifier: 3.0.4 - version: 3.0.4(vitest@3.0.4) + version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)) '@vitest/ui': specifier: ^3.0.4 version: 3.0.4(vitest@3.0.4) @@ -212,12 +212,21 @@ importers: '@strudel/draw': specifier: workspace:* version: link:../draw + '@strudel/tonal': + specifier: workspace:* + version: link:../tonal '@strudel/transpiler': specifier: workspace:* version: link:../transpiler + '@tonaljs/tonal': + specifier: ^4.10.0 + version: 4.10.0 nanostores: specifier: ^0.11.3 version: 0.11.3 + superdough: + specifier: workspace:* + version: link:../superdough devDependencies: vite: specifier: ^6.0.11 @@ -406,9 +415,12 @@ importers: '@strudel/core': specifier: workspace:* version: link:../core - osc-js: - specifier: ^2.4.1 - version: 2.4.1 + osc: + specifier: ^2.4.5 + version: 2.4.5 + ws: + specifier: ^8.18.3 + version: 8.18.3 devDependencies: pkg: specifier: ^5.8.1 @@ -2420,6 +2432,70 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@serialport/binding-mock@10.2.2': + resolution: {integrity: sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==} + engines: {node: '>=12.0.0'} + + '@serialport/bindings-cpp@12.0.1': + resolution: {integrity: sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==} + engines: {node: '>=16.0.0'} + + '@serialport/bindings-interface@1.2.2': + resolution: {integrity: sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==} + engines: {node: ^12.22 || ^14.13 || >=16} + + '@serialport/parser-byte-length@12.0.0': + resolution: {integrity: sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-cctalk@12.0.0': + resolution: {integrity: sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@11.0.0': + resolution: {integrity: sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@12.0.0': + resolution: {integrity: sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-inter-byte-timeout@12.0.0': + resolution: {integrity: sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-packet-length@12.0.0': + resolution: {integrity: sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==} + engines: {node: '>=8.6.0'} + + '@serialport/parser-readline@11.0.0': + resolution: {integrity: sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-readline@12.0.0': + resolution: {integrity: sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-ready@12.0.0': + resolution: {integrity: sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-regex@12.0.0': + resolution: {integrity: sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-slip-encoder@12.0.0': + resolution: {integrity: sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-spacepacket@12.0.0': + resolution: {integrity: sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==} + engines: {node: '>=12.0.0'} + + '@serialport/stream@12.0.0': + resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==} + engines: {node: '>=12.0.0'} + '@shikijs/core@1.29.1': resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==} @@ -3672,6 +3748,15 @@ packages: supports-color: optional: true + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -5281,6 +5366,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5664,6 +5752,9 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5722,6 +5813,9 @@ packages: resolution: {integrity: sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==} engines: {node: '>=10'} + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + node-addon-api@8.3.0: resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} engines: {node: ^18 || ^20 || >= 21} @@ -5760,6 +5854,10 @@ packages: resolution: {integrity: sha512-yqkmYrMbK1wPrfz7mgeYvA4tBperLg9FQ4S3Sau3nSAkpOA0x0zC8nQ1siBwozy1f4SE8vq2n1WKv99r+PCa1Q==} engines: {node: '>= 0.6.0'} + node-gyp-build@4.6.0: + resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -5918,8 +6016,8 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} - osc-js@2.4.1: - resolution: {integrity: sha512-QlSeRKJclL47FNvO1MUCAAp9frmCF9zcYbnf6R9HpcklAst8ZyX3ISsk1v/Vghr/5GmXn0bhVjFXF9h+hfnl4Q==} + osc@2.4.5: + resolution: {integrity: sha512-Nc4/qcl+vA/CMxiKS1xrYgzjfnyB3W94gZnrkn3eTzihlndbEml6+wj1YQNKBI4r+qrw3obCcEoU7SVH/OxpxA==} own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} @@ -6680,6 +6778,10 @@ packages: serialize-javascript@4.0.0: resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} + serialport@12.0.0: + resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==} + engines: {node: '>=16.0.0'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -6767,6 +6869,9 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slip@1.0.2: + resolution: {integrity: sha512-XrcHe3NAcyD3wO+O4I13RcS4/3AF+S9RvGNj9JhJeS02HyImwD2E3QWLrmn9hBfL+fB6yapagwxRkeyYzhk98g==} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -7642,6 +7747,9 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} + wolfy87-eventemitter@5.2.9: + resolution: {integrity: sha512-P+6vtWyuDw+MB01X7UeF8TaHBvbCovf4HPEMF/SV7BdDc1SMTiBy13SRD71lQh4ExFTG1d/WNzDGDCyOKSMblw==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7764,6 +7872,18 @@ packages: utf-8-validate: optional: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlcreate@2.0.4: resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} @@ -9760,6 +9880,76 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@serialport/binding-mock@10.2.2': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + optional: true + + '@serialport/bindings-cpp@12.0.1': + dependencies: + '@serialport/bindings-interface': 1.2.2 + '@serialport/parser-readline': 11.0.0 + debug: 4.3.4 + node-addon-api: 7.0.0 + node-gyp-build: 4.6.0 + transitivePeerDependencies: + - supports-color + optional: true + + '@serialport/bindings-interface@1.2.2': + optional: true + + '@serialport/parser-byte-length@12.0.0': + optional: true + + '@serialport/parser-cctalk@12.0.0': + optional: true + + '@serialport/parser-delimiter@11.0.0': + optional: true + + '@serialport/parser-delimiter@12.0.0': + optional: true + + '@serialport/parser-inter-byte-timeout@12.0.0': + optional: true + + '@serialport/parser-packet-length@12.0.0': + optional: true + + '@serialport/parser-readline@11.0.0': + dependencies: + '@serialport/parser-delimiter': 11.0.0 + optional: true + + '@serialport/parser-readline@12.0.0': + dependencies: + '@serialport/parser-delimiter': 12.0.0 + optional: true + + '@serialport/parser-ready@12.0.0': + optional: true + + '@serialport/parser-regex@12.0.0': + optional: true + + '@serialport/parser-slip-encoder@12.0.0': + optional: true + + '@serialport/parser-spacepacket@12.0.0': + optional: true + + '@serialport/stream@12.0.0': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + optional: true + '@shikijs/core@1.29.1': dependencies: '@shikijs/engine-javascript': 1.29.1 @@ -10364,7 +10554,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.0.4(vitest@3.0.4)': + '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -11272,6 +11462,11 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + optional: true + debug@4.4.0: dependencies: ms: 2.1.3 @@ -13141,6 +13336,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@4.0.0: {} + longest-streak@3.1.0: {} loupe@3.1.2: {} @@ -13818,6 +14015,9 @@ snapshots: ms@2.0.0: {} + ms@2.1.2: + optional: true + ms@2.1.3: {} multimatch@5.0.0: @@ -13867,6 +14067,9 @@ snapshots: dependencies: semver: 7.6.3 + node-addon-api@7.0.0: + optional: true + node-addon-api@8.3.0: {} node-domexception@1.0.0: {} @@ -13893,6 +14096,9 @@ snapshots: node-getopt@0.3.2: {} + node-gyp-build@4.6.0: + optional: true + node-gyp-build@4.8.4: {} node-gyp@10.3.1: @@ -14144,11 +14350,17 @@ snapshots: os-tmpdir@1.0.2: {} - osc-js@2.4.1: + osc@2.4.5: dependencies: + long: 4.0.0 + slip: 1.0.2 + wolfy87-eventemitter: 5.2.9 ws: 8.18.0 + optionalDependencies: + serialport: 12.0.0 transitivePeerDependencies: - bufferutil + - supports-color - utf-8-validate own-keys@1.0.1: @@ -15043,6 +15255,26 @@ snapshots: dependencies: randombytes: 2.1.0 + serialport@12.0.0: + dependencies: + '@serialport/binding-mock': 10.2.2 + '@serialport/bindings-cpp': 12.0.1 + '@serialport/parser-byte-length': 12.0.0 + '@serialport/parser-cctalk': 12.0.0 + '@serialport/parser-delimiter': 12.0.0 + '@serialport/parser-inter-byte-timeout': 12.0.0 + '@serialport/parser-packet-length': 12.0.0 + '@serialport/parser-readline': 12.0.0 + '@serialport/parser-ready': 12.0.0 + '@serialport/parser-regex': 12.0.0 + '@serialport/parser-slip-encoder': 12.0.0 + '@serialport/parser-spacepacket': 12.0.0 + '@serialport/stream': 12.0.0 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + optional: true + set-blocking@2.0.0: {} set-function-length@1.2.2: @@ -15185,6 +15417,8 @@ snapshots: slash@3.0.0: {} + slip@1.0.2: {} + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -16094,6 +16328,8 @@ snapshots: dependencies: string-width: 7.2.0 + wolfy87-eventemitter@5.2.9: {} + word-wrap@1.2.5: {} wordwrap@0.0.3: {} @@ -16293,6 +16529,8 @@ snapshots: ws@8.18.0: {} + ws@8.18.3: {} + xmlcreate@2.0.4: {} xtend@4.0.2: {} diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index fd0a1bb5d..a27c5a6bc 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -6958,6 +6958,24 @@ exports[`runs examples > example "orbit" example index 0 1`] = ` ] `; +exports[`runs examples > example "oschost" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 1/1 → 2/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 2/1 → 3/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 3/1 → 4/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", +] +`; + +exports[`runs examples > example "oscport" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 1/1 → 2/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 2/1 → 3/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", + "[ 3/1 → 4/1 | note:c4 oschost:127.0.0.1 oscport:57120 ]", +] +`; + exports[`runs examples > example "outside" example index 0 1`] = ` [ "[ 0/1 → 1/1 | note:A3 ]", @@ -8307,6 +8325,33 @@ exports[`runs examples > example "repeatCycles" example index 0 1`] = ` ] `; +exports[`runs examples > example "replicate" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:bd ]", + "[ 1/8 → 1/4 | s:bd ]", + "[ 3/8 → 1/2 | s:cp ]", + "[ 1/2 → 5/8 | s:bd ]", + "[ 5/8 → 3/4 | s:bd ]", + "[ 7/8 → 1/1 | s:cp ]", + "[ 1/1 → 9/8 | s:bd ]", + "[ 5/4 → 11/8 | s:sd ]", + "[ 3/2 → 13/8 | s:bd ]", + "[ 13/8 → 7/4 | s:bd ]", + "[ 15/8 → 2/1 | s:cp ]", + "[ 2/1 → 17/8 | s:bd ]", + "[ 17/8 → 9/4 | s:bd ]", + "[ 19/8 → 5/2 | s:cp ]", + "[ 5/2 → 21/8 | s:bd ]", + "[ 11/4 → 23/8 | s:sd ]", + "[ 3/1 → 25/8 | s:bd ]", + "[ 25/8 → 13/4 | s:bd ]", + "[ 27/8 → 7/2 | s:cp ]", + "[ 7/2 → 29/8 | s:bd ]", + "[ 29/8 → 15/4 | s:bd ]", + "[ 31/8 → 4/1 | s:cp ]", +] +`; + exports[`runs examples > example "reset" example index 0 1`] = ` [ "[ 0/1 → 1/8 | s:hh ]", diff --git a/website/src/pages/learn/code.mdx b/website/src/pages/learn/code.mdx index f9c6b3a25..c454dcceb 100644 --- a/website/src/pages/learn/code.mdx +++ b/website/src/pages/learn/code.mdx @@ -60,6 +60,23 @@ Strudel makes heavy use of chained functions. Here is a more sophisticated examp .room(0.5)`} /> +## Write your own chained function + +You can write your own chained function using `register`. Here's the above chain but registered as a reusable, chained function. + + pat + .s("sawtooth") + .cutoff(500) + //.delay(0.5) + .room(0.5) + ) +note("a3 c#4 e4 a4").effectChain()`} +/> + +Try adding `.rev()` after `effectChain()` to hear further effects added. + # Comments The `//` in the example above is a line comment, resulting in the `delay` function being ignored. diff --git a/website/src/pages/learn/synths.mdx b/website/src/pages/learn/synths.mdx index 21a9b917e..0fcc41363 100644 --- a/website/src/pages/learn/synths.mdx +++ b/website/src/pages/learn/synths.mdx @@ -157,7 +157,7 @@ It has 20 parameters in total, here is a snippet that uses all: .pitchJump(0) // +/- pitch change after pitchJumpTime .pitchJumpTime(0) // >0 time after pitchJump is applied .lfo(0) // >0 resets slide + pitchJump + sets tremolo speed - .tremolo(0) // 0-1 lfo volume modulation amount + .tremolo(0.5) // 0-1 lfo volume modulation amount //.duration(.2) // overwrite strudel event duration //.gain(1) // change volume ._scope() // vizualise waveform (not zzfx related) diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index 8c4287af5..f74a4fb8b 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]` which translates to `seq(c3, seq(e3, g3))` +## Vim Keybindings + +See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim) + ## Scheduling Events After an instance of `Pattern` is obtained from the user code, diff --git a/website/src/pages/technical-manual/vim.mdx b/website/src/pages/technical-manual/vim.mdx new file mode 100644 index 000000000..fa53aa550 --- /dev/null +++ b/website/src/pages/technical-manual/vim.mdx @@ -0,0 +1,37 @@ +--- +title: Vim Shortcuts +layout: ../../layouts/MainLayout.astro +--- + +# Vim Shortcuts in the REPL + +When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available: + +- :w — Evaluate the current code + + - Triggers the same evaluation as Ctrl+Enter / Alt+Enter + - You'll see messages in the Console panel such as: + - [vim] :w — evaluating code + - [repl] evaluate via event + - [eval] code updated + +- :q — Stop/pause playback + + - Triggers the same stop action as Alt+. + - Useful to quickly stop scheduling without leaving Vim mode + +- gc — Toggle line comments for the current selection(s) + + - Works in normal and visual mode + - If there's a selection, all selected lines are toggled + +Notes + +- Behavior respects the current language mode in the editor for comment syntax. +- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor. +- If you don't see the Console panel, open the right panel in the REPL UI. + +Troubleshooting + +- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback. +- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again. diff --git a/website/src/repl/prebake.mjs b/website/src/repl/prebake.mjs index 1fbc84021..8a6ccc7e0 100644 --- a/website/src/repl/prebake.mjs +++ b/website/src/repl/prebake.mjs @@ -6,6 +6,7 @@ import './files.mjs'; const { BASE_URL } = import.meta.env; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; +const baseCDN = 'https://strudel.b-cdn.net'; export async function prebake() { // https://archive.org/details/SalamanderGrandPianoV3 @@ -19,23 +20,23 @@ export async function prebake() { // => getting "window is not defined", as soon as "@strudel/soundfonts" is imported statically // seems to be a problem with soundfont2 import('@strudel/soundfonts').then(({ registerSoundfonts }) => registerSoundfonts()), - samples(`${baseNoTrailing}/piano.json`, undefined, { prebake: true }), + samples(`${baseCDN}/piano.json`, `${baseCDN}/piano/`, { prebake: true }), // https://github.com/sgossner/VCSL/ // https://api.github.com/repositories/126427031/contents/ // LICENSE: CC0 general-purpose - samples(`${baseNoTrailing}/vcsl.json`, 'github:sgossner/VCSL/master/', { prebake: true }), - samples(`${baseNoTrailing}/tidal-drum-machines.json`, 'github:ritchse/tidal-drum-machines/main/machines/', { + samples(`${baseCDN}/vcsl.json`, `${baseCDN}/VCSL/`, { prebake: true }), + samples(`${baseCDN}/tidal-drum-machines.json`, `${baseCDN}/tidal-drum-machines/machines/`, { prebake: true, tag: 'drum-machines', }), - samples(`${baseNoTrailing}/uzu-drumkit.json`, undefined, { + samples(`${baseCDN}/uzu-drumkit.json`, `${baseCDN}/uzu-drumkit/`, { prebake: true, tag: 'drum-machines', }), - samples(`${baseNoTrailing}/uzu-wavetables.json`, undefined, { + samples(`${baseCDN}/uzu-wavetables.json`, `${baseCDN}/uzu-wavetables/`, { prebake: true, }), - samples(`${baseNoTrailing}/mridangam.json`, undefined, { prebake: true, tag: 'drum-machines' }), + samples(`${baseCDN}/mridangam.json`, `${baseCDN}/mrid/`, { prebake: true, tag: 'drum-machines' }), samples( { casio: ['casio/high.wav', 'casio/low.wav', 'casio/noise.wav'], @@ -145,14 +146,14 @@ export async function prebake() { 'num/20.wav', ], }, - 'github:tidalcycles/dirt-samples', + `${baseCDN}/Dirt-Samples/`, { prebake: true, }, ), ]); - aliasBank(`${baseNoTrailing}/tidal-drum-machines-alias.json`); + aliasBank(`${baseCDN}/tidal-drum-machines-alias.json`); } const maxPan = noteToMidi('C8');