Merge branch 'main' into docs/xen/added-tune
This commit is contained in:
commit
e57f240e70
106 changed files with 3988 additions and 466 deletions
|
|
@ -1,68 +1,93 @@
|
|||
import jsdoc from '../../doc.json';
|
||||
// import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { h } from './html';
|
||||
|
||||
function plaintext(str) {
|
||||
const escapeHtml = (str) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerText = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||
const getInnerText = (html) => {
|
||||
var div = document.createElement('div');
|
||||
const stripHtml = (html) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || '';
|
||||
};
|
||||
|
||||
export function Autocomplete({ doc, label }) {
|
||||
return h`<div class="prose dark:prose-invert max-h-[400px] overflow-auto p-2">
|
||||
<h1 class="pt-0 mt-0">${label || getDocLabel(doc)}</h1>
|
||||
${doc.description}
|
||||
<ul>
|
||||
${doc.params?.map(
|
||||
({ name, type, description }) =>
|
||||
`<li>${name} : ${type.names?.join(' | ')} ${description ? ` - ${getInnerText(description)}` : ''}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
<div>
|
||||
${doc.examples?.map((example) => `<div><pre>${plaintext(example)}</pre></div>`)}
|
||||
</div>
|
||||
</div>`[0];
|
||||
/*
|
||||
<pre
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
console.log('ola!');
|
||||
navigator.clipboard.writeText(example);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{example}
|
||||
</pre>
|
||||
*/
|
||||
}
|
||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||
|
||||
const buildParamsList = (params) =>
|
||||
params?.length
|
||||
? `
|
||||
<div class="autocomplete-info-params-section">
|
||||
<h4 class="autocomplete-info-section-title">Parameters</h4>
|
||||
<ul class="autocomplete-info-params-list">
|
||||
${params
|
||||
.map(
|
||||
({ name, type, description }) => `
|
||||
<li class="autocomplete-info-param-item">
|
||||
<span class="autocomplete-info-param-name">${name}</span>
|
||||
<span class="autocomplete-info-param-type">${type.names?.join(' | ')}</span>
|
||||
${description ? `<div class="autocomplete-info-param-desc">${stripHtml(description)}</div>` : ''}
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
const buildExamples = (examples) =>
|
||||
examples?.length
|
||||
? `
|
||||
<div class="autocomplete-info-examples-section">
|
||||
<h4 class="autocomplete-info-section-title">Examples</h4>
|
||||
${examples
|
||||
.map(
|
||||
(example) => `
|
||||
<pre class="autocomplete-info-example-code">${escapeHtml(example)}</pre>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
export const Autocomplete = ({ doc, label }) =>
|
||||
h`
|
||||
<div class="autocomplete-info-container">
|
||||
<div class="autocomplete-info-tooltip">
|
||||
<h3 class="autocomplete-info-function-name">${label || getDocLabel(doc)}</h3>
|
||||
${doc.description ? `<div class="autocomplete-info-function-description">${doc.description}</div>` : ''}
|
||||
${buildParamsList(doc.params)}
|
||||
${buildExamples(doc.examples)}
|
||||
</div>
|
||||
</div>
|
||||
`[0];
|
||||
|
||||
const isValidDoc = (doc) => {
|
||||
const label = getDocLabel(doc);
|
||||
return label && !label.startsWith('_') && !['package'].includes(doc.kind);
|
||||
};
|
||||
|
||||
const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
const jsdocCompletions = jsdoc.docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
getDocLabel(doc) &&
|
||||
!getDocLabel(doc).startsWith('_') &&
|
||||
!['package'].includes(doc.kind) &&
|
||||
!['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)),
|
||||
)
|
||||
.filter((doc) => isValidDoc(doc) && !hasExcludedTags(doc))
|
||||
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
||||
.map((doc) /*: Completion */ => ({
|
||||
.map((doc) => ({
|
||||
label: getDocLabel(doc),
|
||||
// detail: 'xxx', // An optional short piece of information to show (with a different style) after the label.
|
||||
info: () => Autocomplete({ doc }),
|
||||
type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
|
||||
}));
|
||||
|
||||
export const strudelAutocomplete = (context /* : CompletionContext */) => {
|
||||
let word = context.matchBefore(/\w*/);
|
||||
if (word.from == word.to && !context.explicit) return null;
|
||||
export const strudelAutocomplete = (context) => {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
|
|
@ -74,11 +99,5 @@ export const strudelAutocomplete = (context /* : CompletionContext */) => {
|
|||
};
|
||||
};
|
||||
|
||||
export function isAutoCompletionEnabled(on) {
|
||||
return on
|
||||
? [
|
||||
autocompletion({ override: [strudelAutocomplete] }),
|
||||
//javascriptLanguage.data.of({ autocomplete: strudelAutocomplete }),
|
||||
]
|
||||
: []; // autocompletion({ override: [] })
|
||||
}
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
enabled ? [autocompletion({ override: [strudelAutocomplete], closeOnBlur: false })] : [];
|
||||
|
|
|
|||
63
packages/codemirror/basicSetup.mjs
Normal file
63
packages/codemirror/basicSetup.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
} from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
|
||||
// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
|
||||
export const basicSetup = (() => [
|
||||
// lineNumbers(),
|
||||
// highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
// foldGutter(),
|
||||
// drawSelection(),
|
||||
dropCursor(),
|
||||
// EditorState.allowMultipleSelections.of(true),
|
||||
// indentOnInput(),
|
||||
// syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
// autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
// highlightActiveLine(),
|
||||
// highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
// ...searchKeymap,
|
||||
...historyKeymap,
|
||||
// ...foldKeymap,
|
||||
// ...completionKeymap,
|
||||
]),
|
||||
])();
|
||||
|
||||
/// A minimal set of extensions to create a functional editor. Only
|
||||
/// includes [the default keymap](#commands.defaultKeymap), [undo
|
||||
/// history](#commands.history), [special character
|
||||
/// highlighting](#view.highlightSpecialChars), [custom selection
|
||||
/// drawing](#view.drawSelection), and [default highlight
|
||||
/// style](#language.defaultHighlightStyle).
|
||||
export const minimalSetup = (() => [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
])();
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
|
||||
// import { search, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { history } from '@codemirror/commands';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
import {
|
||||
|
|
@ -24,6 +24,7 @@ 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';
|
||||
|
||||
const extensions = {
|
||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||
|
|
@ -37,6 +38,14 @@ const extensions = {
|
|||
isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
|
||||
isFlashEnabled,
|
||||
keybindings,
|
||||
isTabIndentationEnabled: (on) => (on ? keymap.of([indentWithTab]) : []),
|
||||
isMultiCursorEnabled: (on) =>
|
||||
on
|
||||
? [
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
|
||||
]
|
||||
: [],
|
||||
};
|
||||
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||
|
||||
|
|
@ -51,6 +60,8 @@ export const defaultSettings = {
|
|||
isFlashEnabled: true,
|
||||
isTooltipEnabled: false,
|
||||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
|
|
@ -75,13 +86,17 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
|||
/* search(),
|
||||
highlightSelectionMatches(), */
|
||||
...initialSettings,
|
||||
basicSetup,
|
||||
mondo ? [] : javascript(),
|
||||
javascriptLanguage.data.of({
|
||||
closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
||||
bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
||||
}),
|
||||
sliderPlugin,
|
||||
widgetPlugin,
|
||||
// indentOnInput(), // works without. already brought with javascript extension?
|
||||
// bracketMatching(), // does not do anything
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
history(),
|
||||
EditorView.updateListener.of((v) => onChange(v)),
|
||||
drawSelection({ cursorBlinkRate: 0 }),
|
||||
Prec.highest(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ 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 } from './vim_test.mjs';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import { defaultKeymap, historyKeymap } from '@codemirror/commands';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
|
|
@ -21,11 +22,11 @@ const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
|||
const keymaps = {
|
||||
vim,
|
||||
emacs,
|
||||
codemirror: () => keymap.of(defaultKeymap),
|
||||
vscode: vscodeExtension,
|
||||
};
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [keymap.of(defaultKeymap), keymap.of(historyKeymap), active ? active() : []];
|
||||
// keymap.of(searchKeymap),
|
||||
return [active ? Prec.high(active()) : []];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/codemirror",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.5",
|
||||
"description": "Codemirror Extensions for Strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"@lezer/highlight": "^1.2.1",
|
||||
"@nanostores/persistent": "^0.10.2",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.2.1",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateTactus, sequence, stack } from '../index.mjs';
|
||||
import { calculateSteps, sequence, stack } from '../index.mjs';
|
||||
|
||||
const pat64 = sequence(...Array(64).keys());
|
||||
|
||||
describe('steps', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
|
|
@ -14,7 +14,7 @@ describe('steps', () => {
|
|||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
|
|
@ -25,7 +25,7 @@ describe('steps', () => {
|
|||
});
|
||||
|
||||
describe('stack', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
|
|
@ -34,7 +34,7 @@ describe('stack', () => {
|
|||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
|
|
@ -43,4 +43,4 @@ describe('stack', () => {
|
|||
{ time: 1000 },
|
||||
);
|
||||
});
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
|
|
|
|||
|
|
@ -252,6 +252,20 @@ export const { fmenv } = registerControl('fmenv');
|
|||
*
|
||||
*/
|
||||
export const { fmattack } = registerControl('fmattack');
|
||||
|
||||
/**
|
||||
* waveform of the fm modulator
|
||||
*
|
||||
* @name fmwave
|
||||
* @param {number | Pattern} wave waveform
|
||||
* @example
|
||||
* n("0 1 2 3".fast(4)).scale("d:minor").s("sine").fmwave("<sine square sawtooth crackle>").fm(4).fmh(2.01)
|
||||
* @example
|
||||
* n("0 1 2 3".fast(4)).chord("<Dm Am F G>").voicing().s("sawtooth").fmwave("brown").fm(.6)
|
||||
*
|
||||
*/
|
||||
export const { fmwave } = registerControl('fmwave');
|
||||
|
||||
/**
|
||||
* Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase.
|
||||
*
|
||||
|
|
@ -295,6 +309,17 @@ export const { fmvelocity } = registerControl('fmvelocity');
|
|||
*/
|
||||
export const { bank } = registerControl('bank');
|
||||
|
||||
/**
|
||||
* mix control for the chorus effect
|
||||
*
|
||||
* @name chorus
|
||||
* @param {string | Pattern} chorus mix amount between 0 and 1
|
||||
* @example
|
||||
* note("d d a# a").s("sawtooth").chorus(.5)
|
||||
*
|
||||
*/
|
||||
export const { chorus } = registerControl('chorus');
|
||||
|
||||
// analyser node send amount 0 - 1 (used by scope)
|
||||
export const { analyze } = registerControl('analyze');
|
||||
// fftSize of analyser
|
||||
|
|
@ -306,6 +331,7 @@ export const { fft } = registerControl('fft');
|
|||
*
|
||||
* @name decay
|
||||
* @param {number | Pattern} time decay time in seconds
|
||||
* @synonyms dec
|
||||
* @example
|
||||
* note("c3 e3 f3 g3").decay("<.1 .2 .3 .4>").sustain(0)
|
||||
*
|
||||
|
|
@ -444,6 +470,78 @@ export const { crush } = registerControl('crush');
|
|||
*/
|
||||
export const { coarse } = registerControl('coarse');
|
||||
|
||||
/**
|
||||
* modulate the amplitude of a sound with a continuous waveform
|
||||
*
|
||||
* @name tremolo
|
||||
* @synonyms trem
|
||||
* @param {number | Pattern} speed modulation speed in HZ
|
||||
* @example
|
||||
* note("d d d# d".fast(4)).s("supersaw").tremolo("<3 2 100> ").tremoloskew("<.5>")
|
||||
*
|
||||
*/
|
||||
export const { tremolo } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
|
||||
|
||||
/**
|
||||
* modulate the amplitude of a sound with a continuous waveform
|
||||
*
|
||||
* @name tremolosync
|
||||
* @synonyms tremsync
|
||||
* @param {number | Pattern} cycles modulation speed in cycles
|
||||
* @example
|
||||
* note("d d d# d".fast(4)).s("supersaw").tremolosync("4").tremoloskew("<1 .5 0>")
|
||||
*
|
||||
*/
|
||||
export const { tremolosync } = registerControl(
|
||||
['tremolosync', 'tremolodepth', 'tremoloskew', 'tremolophase'],
|
||||
'tremsync',
|
||||
);
|
||||
|
||||
/**
|
||||
* depth of amplitude modulation
|
||||
*
|
||||
* @name tremolodepth
|
||||
* @synonyms tremdepth
|
||||
* @param {number | Pattern} depth
|
||||
* @example
|
||||
* note("a1 a1 a#1 a1".fast(4)).s("pulse").tremsync(4).tremolodepth("<1 2 .7>")
|
||||
*
|
||||
*/
|
||||
export const { tremolodepth } = registerControl('tremolodepth', 'tremdepth');
|
||||
/**
|
||||
* alter the shape of the modulation waveform
|
||||
*
|
||||
* @name tremoloskew
|
||||
* @synonyms tremskew
|
||||
* @param {number | Pattern} amount between 0 & 1, the shape of the waveform
|
||||
* @example
|
||||
* note("{f a c e}%16").s("sawtooth").tremsync(4).tremoloskew("<.5 0 1>")
|
||||
*
|
||||
*/
|
||||
export const { tremoloskew } = registerControl('tremoloskew', 'tremskew');
|
||||
|
||||
/**
|
||||
* alter the phase of the modulation waveform
|
||||
*
|
||||
* @name tremolophase
|
||||
* @synonyms tremphase
|
||||
* @param {number | Pattern} offset the offset in cycles of the modulation
|
||||
* @example
|
||||
* note("{f a c e}%16").s("sawtooth").tremsync(4).tremolophase("<0 .25 .66>")
|
||||
*
|
||||
*/
|
||||
export const { tremolophase } = registerControl('tremolophase', 'tremphase');
|
||||
|
||||
/**
|
||||
* shape of amplitude modulation
|
||||
*
|
||||
* @name tremoloshape
|
||||
* @param {number | Pattern} shape tri | square | sine | saw | ramp
|
||||
* @example
|
||||
* note("{f g c d}%16").tremsync(4).tremoloshape("<sine tri square>").s("sawtooth")
|
||||
*
|
||||
*/
|
||||
export const { tremoloshape } = registerControl('tremoloshape', 'tremshape');
|
||||
/**
|
||||
* filter overdrive for supported filter types
|
||||
*
|
||||
|
|
@ -453,6 +551,42 @@ export const { coarse } = registerControl('coarse');
|
|||
* note("{f g g c d a a#}%16".sub(17)).s("supersaw").lpenv(8).lpf(150).lpq(.8).ftype('ladder').drive("<.5 4>")
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* modulate the amplitude of an orbit to create a "sidechain" like effect
|
||||
*
|
||||
* @name duckorbit
|
||||
* @param {number | Pattern} orbit target orbit
|
||||
* @example
|
||||
* $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2)
|
||||
* $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack(0.2).duckdepth(1)
|
||||
*
|
||||
*/
|
||||
export const { duck } = registerControl('duckorbit', 'duck');
|
||||
|
||||
/**
|
||||
* the amount of ducking applied to target orbit
|
||||
*
|
||||
* @name duckdepth
|
||||
* @param {number | Pattern} depth depth of modulation from 0 to 1
|
||||
* @example
|
||||
* stack( n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2), s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack(0.2).duckdepth("<1 .9 .6 0>"))
|
||||
*
|
||||
*/
|
||||
|
||||
export const { duckdepth } = registerControl('duckdepth');
|
||||
|
||||
/**
|
||||
* the attack time of the duck effect
|
||||
*
|
||||
* @name duckattack
|
||||
* @param {number | Pattern} time
|
||||
* @example
|
||||
* stack( n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2), s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack("<0.2 0 0.4>").duckdepth(1))
|
||||
*
|
||||
*/
|
||||
export const { duckattack } = registerControl('duckattack', 'duckatt');
|
||||
|
||||
export const { drive } = registerControl('drive');
|
||||
|
||||
/**
|
||||
|
|
@ -974,14 +1108,27 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback']
|
|||
*
|
||||
*/
|
||||
export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb');
|
||||
|
||||
/**
|
||||
* Sets the level of the signal that is fed back into the delay.
|
||||
* Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it
|
||||
*
|
||||
* @name delayfeedback
|
||||
* @param {number | Pattern} feedback between 0 and 1
|
||||
* @synonyms delayfb, dfb
|
||||
* @example
|
||||
* s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>")
|
||||
*
|
||||
*/
|
||||
export const { delayspeed } = registerControl('delayspeed');
|
||||
/**
|
||||
* Sets the time of the delay effect.
|
||||
*
|
||||
* @name delaytime
|
||||
* @param {number | Pattern} seconds between 0 and Infinity
|
||||
* @name delayspeed
|
||||
* @param {number | Pattern} delayspeed controls the pitch of the delay feedback
|
||||
* @synonyms delayt, dt
|
||||
* @example
|
||||
* s("bd bd").delay(.25).delaytime("<.125 .25 .5 1>")
|
||||
* note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>")
|
||||
*
|
||||
*/
|
||||
export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt');
|
||||
|
|
@ -1393,6 +1540,29 @@ export const { roomfade, rfade } = registerControl('roomfade', 'rfade');
|
|||
*
|
||||
*/
|
||||
export const { ir, iresponse } = registerControl(['ir', 'i'], 'iresponse');
|
||||
|
||||
/**
|
||||
* Sets speed of the sample for the impulse response.
|
||||
* @name irspeed
|
||||
* @param {string | Pattern} speed
|
||||
* @example
|
||||
* samples('github:switchangel/pad')
|
||||
* $: s("brk/2").fit().scrub(irand(16).div(16).seg(8)).ir("swpad:4").room(.2).irspeed("<2 1 .5>/2").irbegin(.5).roomsize(.5)
|
||||
*
|
||||
*/
|
||||
export const { irspeed } = registerControl('irspeed');
|
||||
|
||||
/**
|
||||
* Sets the beginning of the IR response sample
|
||||
* @name irbegin
|
||||
* @param {string | Pattern} begin between 0 and 1
|
||||
* @synonyms ir
|
||||
* @example
|
||||
* samples('github:switchangel/pad')
|
||||
* $: s("brk/2").fit().scrub(irand(16).div(16).seg(8)).ir("swpad:4").room(.65).irspeed("-2").irbegin("<0 .5 .75>/2").roomsize(.6)
|
||||
*
|
||||
*/
|
||||
export const { irbegin } = registerControl('irbegin');
|
||||
/**
|
||||
* Sets the room size of the reverb, see `room`.
|
||||
* When this property is changed, the reverb will be recaculated, so only change this sparsely..
|
||||
|
|
@ -1561,18 +1731,6 @@ export const { density } = registerControl('density');
|
|||
// ['modwheel'],
|
||||
export const { expression } = registerControl('expression');
|
||||
export const { sustainpedal } = registerControl('sustainpedal');
|
||||
/* // TODO: doesn't seem to do anything
|
||||
*
|
||||
* Tremolo Audio DSP effect
|
||||
*
|
||||
* @name tremolodepth
|
||||
* @param {number | Pattern} depth between 0 and 1
|
||||
* @example
|
||||
* n("0,4,7").tremolodepth("<0 .3 .6 .9>").osc()
|
||||
*
|
||||
*/
|
||||
export const { tremolodepth, tremdp } = registerControl('tremolodepth', 'tremdp');
|
||||
export const { tremolorate, tremr } = registerControl('tremolorate', 'tremr');
|
||||
|
||||
export const { fshift } = registerControl('fshift');
|
||||
export const { fshiftnote } = registerControl('fshiftnote');
|
||||
|
|
@ -1649,7 +1807,6 @@ export const { zmod } = registerControl('zmod');
|
|||
// like crush but scaled differently
|
||||
export const { zcrush } = registerControl('zcrush');
|
||||
export const { zdelay } = registerControl('zdelay');
|
||||
export const { tremolo } = registerControl('tremolo');
|
||||
export const { zzfx } = registerControl('zzfx');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
*/
|
||||
|
||||
import createClock from './zyklus.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
|
||||
export class Cyclist {
|
||||
constructor({
|
||||
|
|
@ -67,6 +67,7 @@ export class Cyclist {
|
|||
// the following line is dumb and only here for backwards compatibility
|
||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||
const deadline = targetTime - phase;
|
||||
// this onTrigger has another signature
|
||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||
this.cps = hap.value.cps;
|
||||
|
|
@ -75,7 +76,7 @@ export class Cyclist {
|
|||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger(`[cyclist] error: ${e.message}`);
|
||||
errorLogger(e);
|
||||
onError?.(e);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ https://rohandrape.net/?t=hmt
|
|||
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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { timeCat, register, silence } from './pattern.mjs';
|
||||
import { timeCat, register, silence, stack, pure, _morph } from './pattern.mjs';
|
||||
import { rotate, flatten, splitAt, zipWith } from './util.mjs';
|
||||
import Fraction, { lcm } from './fraction.mjs';
|
||||
|
||||
|
|
@ -196,3 +196,26 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
|
|||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||
return _euclidLegato(pulses, steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* A 'euclid' variant with an additional parameter that morphs the resulting
|
||||
* rhythm from 0 (no morphing) to 1 (completely 'even'). For example
|
||||
* `sound("bd").euclidish(3,8,0)` would be the same as
|
||||
* `sound("bd").euclid(3,8)`, and `sound("bd").euclidish(3,8,1)` would be the
|
||||
* same as `sound("bd bd bd")`. `sound("bd").euclidish(3,8,0.5)` would have a
|
||||
* groove somewhere between.
|
||||
* Inspired by the work of Malcom Braff.
|
||||
* @name euclidish
|
||||
* @synonyms eish
|
||||
* @memberof Pattern
|
||||
* @param {number} pulses the number of onsets
|
||||
* @param {number} steps the number of steps to fill
|
||||
* @param {number} groove exists between the extremes of 0 (straight euclidian) and 1 (straight pulse)
|
||||
* @example
|
||||
* sound("hh").euclidish(7,12,sine.slow(8))
|
||||
* .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);
|
||||
return pat.struct(morphed).setSteps(steps);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
|||
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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import Fraction from './fraction.mjs';
|
||||
import { stringifyValues } from './util.mjs';
|
||||
|
||||
export class Hap {
|
||||
/*
|
||||
|
|
@ -148,13 +149,7 @@ export class Hap {
|
|||
}
|
||||
|
||||
showWhole(compact = false) {
|
||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${
|
||||
typeof this.value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(this.value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(this.value)
|
||||
: this.value
|
||||
}`;
|
||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${stringifyValues(this.value, compact)}`;
|
||||
}
|
||||
|
||||
combineContext(b) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ let debounce = 1000,
|
|||
lastMessage,
|
||||
lastTime;
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
//TODO: add some kind of debug flag that enables this while in dev mode
|
||||
// console.error(e);
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
export function logger(message, type, data = {}) {
|
||||
let t = performance.now();
|
||||
if (lastMessage === message && t - lastTime < debounce) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export class NeoCyclist {
|
|||
constructor({ onTrigger, onToggle, getTime }) {
|
||||
this.started = false;
|
||||
this.cps = 0.5;
|
||||
this.lastTick = 0; // absolute time when last tick (clock callback) happened
|
||||
this.getTime = getTime; // get absolute time
|
||||
this.time_at_last_tick_message = 0;
|
||||
// the clock of the worker and the audio context clock can drift apart over time
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/core",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Port of Tidal Cycles to JavaScript",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
numeralArgs,
|
||||
parseNumeral,
|
||||
pairs,
|
||||
zipWith,
|
||||
stringifyValues,
|
||||
} from './util.mjs';
|
||||
import drawLine from './drawLine.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
|
@ -852,14 +854,29 @@ export class Pattern {
|
|||
);
|
||||
}
|
||||
|
||||
log(func = (_, hap) => `[hap] ${hap.showWhole(true)}`, getData = (_, hap) => ({ hap })) {
|
||||
/**
|
||||
* Writes the content of the current event to the console (visible in the side menu).
|
||||
* @name log
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd sd").log()
|
||||
*/
|
||||
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
|
||||
return this.onTrigger((...args) => {
|
||||
logger(func(...args), undefined, getData(...args));
|
||||
}, false);
|
||||
}
|
||||
|
||||
logValues(func = id) {
|
||||
return this.log((_, hap) => func(hap.value));
|
||||
/**
|
||||
* A simplified version of `log` which writes all "values" (various configurable parameters)
|
||||
* within the event to the console (visible in the side menu).
|
||||
* @name logValues
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd sd").gain("0.25 0.5 1").n("2 1 0").logValues()
|
||||
*/
|
||||
logValues(func = (value) => `[hap] ${stringifyValues(value, true)}`) {
|
||||
return this.log((hap) => func(hap.value));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -1288,7 +1305,7 @@ export function sequenceP(pats) {
|
|||
* @synonyms polyrhythm, pr
|
||||
* @example
|
||||
* stack("g3", "b3", ["e4", "d4"]).note()
|
||||
* // "g3,b3,[e4,d4]".note()
|
||||
* // "g3,b3,[e4 d4]".note()
|
||||
*
|
||||
* @example
|
||||
* // As a chained function:
|
||||
|
|
@ -1569,7 +1586,7 @@ export const func = curry((a, b) => reify(b).func(a));
|
|||
/**
|
||||
* Registers a new pattern method. The method is added to the Pattern class + the standalone function is returned from register.
|
||||
*
|
||||
* @param {string} name name of the function
|
||||
* @param {string | string[]} name name of the function, or an array of names to be used as synonyms
|
||||
* @param {function} func function with 1 or more params, where last is the current pattern
|
||||
* @noAutocomplete
|
||||
*
|
||||
|
|
@ -2522,7 +2539,7 @@ export const { fastchunk, fastChunk } = register(
|
|||
/**
|
||||
* Like `chunk`, but the function is applied to a looped subcycle of the source pattern.
|
||||
* @name chunkInto
|
||||
* @synonym chunkinto
|
||||
* @synonyms chunkinto
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||
|
|
@ -2535,7 +2552,7 @@ export const { chunkinto, chunkInto } = register(['chunkinto', 'chunkInto'], fun
|
|||
/**
|
||||
* Like `chunkInto`, but moves backwards through the chunks.
|
||||
* @name chunkBackInto
|
||||
* @synonym chunkbackinto
|
||||
* @synonyms chunkbackinto
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||
|
|
@ -2565,7 +2582,7 @@ export const bypass = register(
|
|||
* Loops the pattern inside an `offset` for `cycles`.
|
||||
* If you think of the entire span of time in cycles as a ribbon, you can cut a single piece and loop it.
|
||||
* @name ribbon
|
||||
* @synonym rib
|
||||
* @synonyms rib
|
||||
* @param {number} offset start point of loop in cycles
|
||||
* @param {number} cycles loop length in cycles
|
||||
* @example
|
||||
|
|
@ -3260,10 +3277,10 @@ export const slice = register(
|
|||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* s("bd!8").onTriggerTime((hap) => {console.info(hap)})
|
||||
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
|
||||
*/
|
||||
Pattern.prototype.onTriggerTime = function (func) {
|
||||
return this.onTrigger((t_deprecate, hap, currentTime, cps = 1, targetTime) => {
|
||||
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
const diff = targetTime - currentTime;
|
||||
window.setTimeout(() => {
|
||||
func(hap);
|
||||
|
|
@ -3400,3 +3417,69 @@ export const { beat } = register(
|
|||
['beat'],
|
||||
__beat((x) => x.innerJoin()),
|
||||
);
|
||||
|
||||
export const _morph = (from, to, by) => {
|
||||
by = Fraction(by);
|
||||
const dur = Fraction(1).div(from.length);
|
||||
const positions = (list) => {
|
||||
const result = [];
|
||||
for (const [pos, value] of list.entries()) {
|
||||
if (value) {
|
||||
result.push([Fraction(pos).div(list.length), value]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const arcs = zipWith(
|
||||
([posa, valuea], [posb, valueb]) => {
|
||||
const b = by.mul(posb - posa).add(posa);
|
||||
const e = b.add(dur);
|
||||
return new TimeSpan(b, e);
|
||||
},
|
||||
positions(from),
|
||||
positions(to),
|
||||
);
|
||||
function query(state) {
|
||||
const cycle = state.span.begin.sam();
|
||||
const cycleArc = state.span.cycleArc();
|
||||
const result = [];
|
||||
for (const whole of arcs) {
|
||||
const part = whole.intersection(cycleArc);
|
||||
if (part !== undefined) {
|
||||
result.push(
|
||||
new Hap(
|
||||
whole.withTime((x) => x.add(cycle)),
|
||||
part.withTime((x) => x.add(cycle)),
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return new Pattern(query).splitQueries();
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes two binary rhythms represented as lists of 1s and 0s, and a number
|
||||
* between 0 and 1 that morphs between them. The two lists should contain the same
|
||||
* number of true values.
|
||||
* @example
|
||||
* sound("hh").struct(morph([1,0,1,0,1,0,1,0], // straight rhythm
|
||||
* [1,1,0,1,0,1,0], // wonky rhythm
|
||||
* 0.25 // creates a slightly wonky rhythm
|
||||
* )
|
||||
* )
|
||||
* @example
|
||||
* sound("hh").struct(morph("1:0:1:0:1:0:1:0", // straight rhythm
|
||||
* "1:1:0:1:0:1:0", // wonky rhythm
|
||||
* sine.slow(8) // slowly morph between the rhythms
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
export const morph = (frompat, topat, bypat) => {
|
||||
frompat = reify(frompat);
|
||||
topat = reify(topat);
|
||||
bypat = reify(bypat);
|
||||
return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by))));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NeoCyclist } from './neocyclist.mjs';
|
||||
import { Cyclist } from './cyclist.mjs';
|
||||
import { evaluate as _evaluate } from './evaluate.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { setTime } from './time.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
|
|
@ -245,6 +245,7 @@ export function repl({
|
|||
export const getTrigger =
|
||||
({ getTime, defaultOutput }) =>
|
||||
async (hap, deadline, duration, cps, t) => {
|
||||
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
|
||||
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||
try {
|
||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||
|
|
@ -252,9 +253,9 @@ export const getTrigger =
|
|||
}
|
||||
if (hap.context.onTrigger) {
|
||||
// call signature of output / onTrigger is different...
|
||||
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t);
|
||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
||||
}
|
||||
} catch (err) {
|
||||
logger(`[cyclist] error: ${err.message}`, 'error');
|
||||
errorLogger(err, 'getTrigger');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
|
|||
}
|
||||
|
||||
export const speak = register('speak', function (lang, voice, pat) {
|
||||
return pat.onTrigger((_, hap) => {
|
||||
return pat.onTrigger((hap) => {
|
||||
triggerSpeech(hap.value, lang, voice);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
|
||||
import Fraction from 'fraction.js';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
TimeSpan,
|
||||
|
|
@ -55,6 +55,8 @@ import {
|
|||
expand,
|
||||
} from '../index.mjs';
|
||||
|
||||
import { log, logValues } from '../pattern.mjs';
|
||||
|
||||
import { steady } from '../signal.mjs';
|
||||
|
||||
import { n, s } from '../controls.mjs';
|
||||
|
|
@ -1306,4 +1308,40 @@ describe('Pattern', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
describe('log', () => {
|
||||
it('logs to console', () => {
|
||||
const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const pattern = pure('a').log();
|
||||
const haps = pattern.queryArc(0, 1);
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
'%c[hap] 0/1 → 1/1: a',
|
||||
'background-color: black;color:white;border-radius:15px',
|
||||
);
|
||||
mockConsoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
describe('logValues', () => {
|
||||
it('logs values to console', () => {
|
||||
const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const pattern = pure('a').note('c#').logValues();
|
||||
const haps = pattern.queryArc(0, 1);
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
'%c[hap] value:a note:c#',
|
||||
'background-color: black;color:white;border-radius:15px',
|
||||
);
|
||||
mockConsoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export class TimeSpan {
|
|||
}
|
||||
|
||||
intersection(other) {
|
||||
// Intersection of two timespans, returns None if they don't intersect.
|
||||
// Intersection of two timespans, returns undefined if they don't intersect.
|
||||
const intersect_begin = this.begin.max(other.begin);
|
||||
const intersect_end = this.end.min(other.end);
|
||||
|
||||
|
|
|
|||
|
|
@ -487,3 +487,13 @@ export function getCurrentKeyboardState() {
|
|||
// }
|
||||
// return lcm((x * y) / gcd(x, y), ...z);
|
||||
// };
|
||||
|
||||
// Takes values -- typically derived from events, i.e. `hap`s -- and renders them
|
||||
// into a readable format
|
||||
export function stringifyValues(value, compact = false) {
|
||||
return typeof value === 'object'
|
||||
? compact
|
||||
? JSON.stringify(value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
|
||||
: JSON.stringify(value)
|
||||
: value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
|
|||
instrument = instrument || 'triangle';
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
|
||||
return pat.onTrigger((time_deprecate, hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
|
|
@ -142,7 +142,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
|||
p1 = `"${instrument}"`;
|
||||
}
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
return pat.onTrigger((tidal_time, hap) => {
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
|
|
@ -151,7 +151,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
|||
throw new Error('csound only support objects as hap values');
|
||||
}
|
||||
// Time in seconds counting from now.
|
||||
const p2 = tidal_time - getAudioContext().currentTime;
|
||||
const p2 = targetTime - currentTime;
|
||||
const p3 = hap.duration.valueOf() + 0;
|
||||
const frequency = getFrequency(hap);
|
||||
let { gain = 1, velocity = 0.9 } = hap.value;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/csound",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "csound bindings for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const OFF_MESSAGE = 0x80;
|
|||
const CC_MESSAGE = 0xb0;
|
||||
|
||||
Pattern.prototype.midi = function (output) {
|
||||
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = 34;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Invoke } from './utils.mjs';
|
|||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const params = [];
|
||||
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/draw",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Helpers for drawing with Strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/embed",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "Embeddable Web Component to load a Strudel REPL into an iframe",
|
||||
"main": "embed.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/gamepad",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Gamepad Inputs for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/hydra",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Hydra integration for strudel",
|
||||
"main": "hydra.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
if (!WebMidi.enabled) {
|
||||
logger('Midi not enabled');
|
||||
return;
|
||||
|
|
@ -493,6 +493,9 @@ export async function midin(input) {
|
|||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// ensure refs for this input are initialized
|
||||
if (!refs[input]) {
|
||||
refs[input] = {};
|
||||
}
|
||||
const cc = (cc) => ref(() => refs[input][cc] || 0);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/midi",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "Midi API for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, bench } from 'vitest';
|
||||
|
||||
import { calculateTactus } from '../../core/index.mjs';
|
||||
import { calculateSteps } from '../../core/index.mjs';
|
||||
import { mini } from '../index.mjs';
|
||||
|
||||
describe('mini', () => {
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
bench(
|
||||
'+tactus',
|
||||
() => {
|
||||
|
|
@ -13,7 +13,7 @@ describe('mini', () => {
|
|||
{ time: 1000 },
|
||||
);
|
||||
|
||||
calculateTactus(false);
|
||||
calculateSteps(false);
|
||||
bench(
|
||||
'-tactus',
|
||||
() => {
|
||||
|
|
@ -21,5 +21,5 @@ describe('mini', () => {
|
|||
},
|
||||
{ time: 1000 },
|
||||
);
|
||||
calculateTactus(true);
|
||||
calculateSteps(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/mini",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Mini notation for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "mondolang",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "a language for functional composition that translates to js",
|
||||
"main": "mondo.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ lib['%'] = pace;
|
|||
lib['?'] = degradeBy; // todo: default 0.5 not working..
|
||||
lib[':'] = tail;
|
||||
lib['..'] = range;
|
||||
lib['def'] = () => silence;
|
||||
lib['or'] = (...children) => chooseIn(...children); // always has structure but is cyclewise.. e.g. "s oh*8.dec[.04 | .5]"
|
||||
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
|
||||
|
||||
|
|
@ -107,7 +108,7 @@ export function mondo(code, offset = 0) {
|
|||
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
|
||||
}
|
||||
|
||||
let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||
export let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||
|
||||
export const mondi = (str, offset) => {
|
||||
const code = `[${str}]`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/mondo",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.4",
|
||||
"description": "mondo notation for strudel",
|
||||
"main": "mondough.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineConfig } from 'vite';
|
||||
//import { dependencies } from './package.json';
|
||||
import { dependencies } from './package.json';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
|||
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
|
||||
},
|
||||
rollupOptions: {
|
||||
// external: [...Object.keys(dependencies)],
|
||||
external: [...Object.keys(dependencies)],
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/motion",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "DeviceMotion API for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/mqtt",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "MQTT API for strudel",
|
||||
"main": "mqtt.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function parseControlsFromHap(hap, cps) {
|
|||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
const osc = await connect();
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const keyvals = Object.entries(controls).flat();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/osc",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "OSC messaging for strudel",
|
||||
"main": "osc.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs';
|
||||
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs'; */
|
||||
import { oscTrigger } from './osc.mjs';
|
||||
|
||||
const trigger = isTauri() ? oscTriggerTauri : oscTrigger;
|
||||
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
|
||||
|
||||
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
||||
const currentTime = performance.now() / 1000;
|
||||
return trigger(null, hap, currentTime, cps, targetTime);
|
||||
return trigger(hap, currentTime, cps, targetTime);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/reference",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "Headless reference of all strudel functions",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/repl",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.6",
|
||||
"description": "Strudel REPL as a Web Component",
|
||||
"module": "index.mjs",
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export async function prebake() {
|
|||
samples(`${ds}/tidal-drum-machines.json`),
|
||||
samples(`${ds}/piano.json`),
|
||||
samples(`${ds}/Dirt-Samples.json`),
|
||||
samples(`${ds}/EmuSP12.json`),
|
||||
samples(`${ds}/uzu-drumkit.json`),
|
||||
samples(`${ds}/vcsl.json`),
|
||||
samples(`${ds}/mridangam.json`),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -20,3 +20,13 @@ samples('http://localhost:5432')
|
|||
LOG=1 npx @strudel/sampler # adds logging
|
||||
PORT=5555 npx @strudel/sampler # changes port
|
||||
```
|
||||
|
||||
## static json
|
||||
|
||||
when running with `--json`, you will simply get the json logged back:
|
||||
|
||||
```sh
|
||||
npx --yes @strudel/sampler --json > strudel.json
|
||||
```
|
||||
|
||||
this is useful if you want to create a sample pack from the current folder.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/sampler",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import cowsay from 'cowsay';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { createReadStream, existsSync, writeFileSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
import http from 'http';
|
||||
import { join, sep } from 'path';
|
||||
import { join, resolve, sep } from 'path';
|
||||
import readline from 'readline';
|
||||
import os from 'os';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const LOG = !!process.env.LOG || false;
|
||||
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
|
||||
|
||||
console.log(
|
||||
cowsay.say({
|
||||
text: 'welcome to @strudel/sampler',
|
||||
e: 'oO',
|
||||
T: 'U ',
|
||||
}),
|
||||
);
|
||||
const isAudioFile = (f) => {
|
||||
const ext = f.split('.').slice(-1)[0].toLowerCase();
|
||||
return VALID_AUDIO_EXTENSIONS.includes(ext);
|
||||
};
|
||||
|
||||
async function getFilesInDirectory(directory) {
|
||||
let files = [];
|
||||
|
|
@ -29,42 +27,90 @@ async function getFilesInDirectory(directory) {
|
|||
continue;
|
||||
}
|
||||
try {
|
||||
const subFiles = (await getFilesInDirectory(fullPath)).filter((f) =>
|
||||
['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()),
|
||||
);
|
||||
const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile);
|
||||
files = files.concat(subFiles);
|
||||
LOG && console.log(`${dirent.name} (${subFiles.length})`);
|
||||
} catch (err) {
|
||||
LOG && console.warn(`skipped due to error: ${fullPath}`);
|
||||
}
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
isAudioFile(fullPath) && files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function getBanks(directory) {
|
||||
async function getBanks(directory, flat = false) {
|
||||
let files = await getFilesInDirectory(directory);
|
||||
let banks = {};
|
||||
directory = directory.split(sep).join('/');
|
||||
files = files.map((path) => {
|
||||
path = path.split(sep).join('/');
|
||||
const [bank] = path.split('/').slice(-2);
|
||||
const subDir = path.replace(directory, '');
|
||||
const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore
|
||||
const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension
|
||||
let bank = flat ? subDirFlatStem : path.split('/').slice(-2)[0];
|
||||
banks[bank] = banks[bank] || [];
|
||||
const relativeUrl = path.replace(directory, '');
|
||||
banks[bank].push(relativeUrl);
|
||||
return relativeUrl;
|
||||
banks[bank].push(subDir);
|
||||
return subDir;
|
||||
});
|
||||
banks._base = `http://localhost:5432`;
|
||||
return { banks, files };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const directory = process.cwd();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
function getInput(query) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) =>
|
||||
rl.question(query, (response) => {
|
||||
rl.close();
|
||||
resolve(response);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let directory = getArgValue('--dir') || process.cwd();
|
||||
directory = resolve(directory);
|
||||
if (args.includes('--json')) {
|
||||
const { banks } = await getBanks(directory, getArgValue('--flat'));
|
||||
const json = JSON.stringify(banks);
|
||||
const outFile = resolve(directory, 'strudel.json');
|
||||
if (existsSync(outFile)) {
|
||||
const answer = await getInput(`Warning: File already exists at ${outFile}. Overwrite? (y/N): `);
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
writeFileSync(outFile, json, 'utf8');
|
||||
console.log(`Wrote json to ${outFile}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
cowsay.say({
|
||||
text: 'welcome to @strudel/sampler',
|
||||
e: 'oO',
|
||||
T: 'U ',
|
||||
}),
|
||||
);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const { banks, files } = await getBanks(directory);
|
||||
const { banks, files } = await getBanks(directory, getArgValue('--flat'));
|
||||
if (req.url === '/') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.end(JSON.stringify(banks));
|
||||
|
|
@ -72,7 +118,7 @@ const server = http.createServer(async (req, res) => {
|
|||
let subpath = decodeURIComponent(req.url);
|
||||
const filePath = join(directory, subpath.split('/').join(sep));
|
||||
|
||||
//console.log('GET:', filePath);
|
||||
// console.log('GET:', filePath);
|
||||
const isFound = existsSync(filePath);
|
||||
if (!isFound) {
|
||||
res.statusCode = 404;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/serial",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Webserial API for strudel",
|
||||
"main": "serial.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -537,7 +537,7 @@ export default {
|
|||
],
|
||||
gm_synth_bass_1: [
|
||||
// Synth Bass 1: Bass
|
||||
'0380_Aspirin_sf2_file',
|
||||
// '0380_Aspirin_sf2_file', // broken in safari https://codeberg.org/uzu/strudel/issues/1384
|
||||
'0380_Chaos_sf2_file',
|
||||
'0380_FluidR3_GM_sf2_file',
|
||||
// 0380_GeneralUserGS_sf2_file // laut
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/soundfonts",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "Soundsfont support for strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
|
|||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||
|
||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||
return this.onTrigger((time_deprecate, h, ct, cps, targetTime) => {
|
||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
||||
const ctx = getAudioContext();
|
||||
const note = getPlayableNoteValue(h);
|
||||
const preset = sf.presets[n % sf.presets.length];
|
||||
|
|
|
|||
|
|
@ -74,6 +74,6 @@ export const dough = async (code) => {
|
|||
worklet.node.connect(ac.destination);
|
||||
};
|
||||
|
||||
export function doughTrigger(time_deprecate, hap, currentTime, cps, targetTime) {
|
||||
export function doughTrigger(hap, currentTime, cps, targetTime) {
|
||||
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { getAudioContext } from './superdough.mjs';
|
||||
import { clamp, nanFallback } from './util.mjs';
|
||||
import { getNoiseBuffer } from './noise.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
export function gainNode(value) {
|
||||
const node = getAudioContext().createGain();
|
||||
|
|
@ -206,19 +209,46 @@ export function getVibratoOscillator(param, value, t) {
|
|||
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
||||
// a bit of a hack, but it works very well :)
|
||||
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||
const constantNode = audioContext.createConstantSource();
|
||||
constantNode.start(startTime);
|
||||
constantNode.stop(stopTime);
|
||||
const constantNode = new ConstantSourceNode(audioContext);
|
||||
|
||||
// Certain browsers requires audio nodes to be connected in order for their onended events
|
||||
// to fire, so we _mute it_ and then connect it to the destination
|
||||
const zeroGain = gainNode(0);
|
||||
zeroGain.connect(audioContext.destination);
|
||||
constantNode.connect(zeroGain);
|
||||
|
||||
// Schedule the `onComplete` callback to occur at `stopTime`
|
||||
constantNode.onended = () => {
|
||||
// Ensure garbage collection
|
||||
try {
|
||||
zeroGain.disconnect();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
try {
|
||||
constantNode.disconnect();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
onComplete();
|
||||
};
|
||||
constantNode.start(startTime);
|
||||
constantNode.stop(stopTime);
|
||||
return constantNode;
|
||||
}
|
||||
const mod = (freq, range = 1, type = 'sine') => {
|
||||
const ctx = getAudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
let osc;
|
||||
if (noises.includes(type)) {
|
||||
osc = ctx.createBufferSource();
|
||||
osc.buffer = getNoiseBuffer(type, 2);
|
||||
osc.loop = true;
|
||||
} else {
|
||||
osc = ctx.createOscillator();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
}
|
||||
|
||||
osc.start();
|
||||
const g = new GainNode(ctx, { gain: range });
|
||||
osc.connect(g); // -range, range
|
||||
|
|
@ -253,7 +283,7 @@ export function applyFM(param, value, begin) {
|
|||
|
||||
modulator = fmmod.node;
|
||||
stop = fmmod.stop;
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
|
||||
// no envelope by default
|
||||
modulator.connect(param);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
let log = (msg) => console.log(msg);
|
||||
|
||||
export function errorLogger(e, origin = 'cyclist') {
|
||||
//TODO: add some kind of debug flag that enables this while in dev mode
|
||||
// console.error(e);
|
||||
logger(`[${origin}] error: ${e.message}`);
|
||||
}
|
||||
|
||||
export const logger = (...args) => log(...args);
|
||||
|
||||
export const setLogger = (fn) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getAudioContext } from './superdough.mjs';
|
|||
let noiseCache = {};
|
||||
|
||||
// lazy generates noise buffers and keeps them forever
|
||||
function getNoiseBuffer(type, density) {
|
||||
export function getNoiseBuffer(type, density) {
|
||||
const ac = getAudioContext();
|
||||
if (noiseCache[type]) {
|
||||
return noiseCache[type];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "superdough",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import reverbGen from './reverbGen.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer) {
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||
const newLength = buffer.sampleRate * duration;
|
||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
|
|
@ -9,22 +11,30 @@ if (typeof AudioContext !== 'undefined') {
|
|||
let newData = newBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
newData[i] = oldData[i] || 0;
|
||||
// loop the buffer around to prevent
|
||||
let position = (sampleOffset + i * Math.abs(speed)) % oldData.length;
|
||||
if (speed < 1) {
|
||||
position = position * -1;
|
||||
}
|
||||
|
||||
newData[i] = oldData.at(position) || 0;
|
||||
}
|
||||
}
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||
convolver.duration = d;
|
||||
convolver.fade = fade;
|
||||
convolver.lp = lp;
|
||||
convolver.dim = dim;
|
||||
convolver.ir = ir;
|
||||
convolver.irspeed = irspeed;
|
||||
convolver.irbegin = irbegin;
|
||||
if (ir) {
|
||||
convolver.buffer = this.adjustLength(d, ir);
|
||||
convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin);
|
||||
} else {
|
||||
reverbGen.generateReverb(
|
||||
{
|
||||
|
|
@ -41,7 +51,7 @@ if (typeof AudioContext !== 'undefined') {
|
|||
);
|
||||
}
|
||||
};
|
||||
convolver.generate(duration, fade, lp, dim, ir);
|
||||
convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
|
||||
return convolver;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,52 @@ function getSamplesPrefixHandler(url) {
|
|||
return;
|
||||
}
|
||||
|
||||
export async function fetchSampleMap(url) {
|
||||
// check if custom prefix handler
|
||||
const handler = getSamplesPrefixHandler(url);
|
||||
if (handler) {
|
||||
return handler(url);
|
||||
}
|
||||
url = resolveSpecialPaths(url);
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
if (url.startsWith('local:')) {
|
||||
url = `http://localhost:5432`;
|
||||
}
|
||||
if (url.startsWith('shabda:')) {
|
||||
let [_, path] = url.split('shabda:');
|
||||
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (url.startsWith('shabda/speech')) {
|
||||
let [_, path] = url.split('shabda/speech');
|
||||
path = path.startsWith('/') ? path.substring(1) : path;
|
||||
let [params, words] = path.split(':');
|
||||
let gender = 'f';
|
||||
let language = 'en-GB';
|
||||
if (params) {
|
||||
[language, gender] = params.split('/');
|
||||
}
|
||||
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = url.split('/').slice(0, -1).join('/');
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
const json = await fetch(url)
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${url}"`);
|
||||
});
|
||||
return [json, json._base || base];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a collection of samples to use with `s`
|
||||
* @example
|
||||
|
|
@ -217,49 +263,8 @@ function getSamplesPrefixHandler(url) {
|
|||
|
||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||
if (typeof sampleMap === 'string') {
|
||||
// check if custom prefix handler
|
||||
const handler = getSamplesPrefixHandler(sampleMap);
|
||||
if (handler) {
|
||||
return handler(sampleMap);
|
||||
}
|
||||
sampleMap = resolveSpecialPaths(sampleMap);
|
||||
if (sampleMap.startsWith('github:')) {
|
||||
sampleMap = githubPath(sampleMap, 'strudel.json');
|
||||
}
|
||||
if (sampleMap.startsWith('local:')) {
|
||||
sampleMap = `http://localhost:5432`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda:')) {
|
||||
let [_, path] = sampleMap.split('shabda:');
|
||||
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda/speech')) {
|
||||
let [_, path] = sampleMap.split('shabda/speech');
|
||||
path = path.startsWith('/') ? path.substring(1) : path;
|
||||
let [params, words] = path.split(':');
|
||||
let gender = 'f';
|
||||
let language = 'en-GB';
|
||||
if (params) {
|
||||
[language, gender] = params.split('/');
|
||||
}
|
||||
sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = sampleMap.split('/').slice(0, -1).join('/');
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
return fetch(sampleMap)
|
||||
.then((res) => res.json())
|
||||
.then((json) => samples(json, baseUrl || json._base || base, options))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${sampleMap}"`);
|
||||
});
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
return samples(json, baseUrl || base, options);
|
||||
}
|
||||
const { prebake, tag } = options;
|
||||
processSampleMap(
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
import './feedbackdelay.mjs';
|
||||
import './reverb.mjs';
|
||||
import './vowel.mjs';
|
||||
import { clamp, nanFallback, _mod, cycleToSeconds } from './util.mjs';
|
||||
import { clamp, nanFallback, _mod, cycleToSeconds, secondsToCycle } from './util.mjs';
|
||||
import workletsUrl from './worklets.mjs?audioworklet';
|
||||
import { createFilter, gainNode, getCompressor, getWorklet } from './helpers.mjs';
|
||||
import { createFilter, gainNode, getCompressor, getWorklet, webAudioTimeout } from './helpers.mjs';
|
||||
import { map } from 'nanostores';
|
||||
import { logger } from './logger.mjs';
|
||||
import { logger, errorLogger } from './logger.mjs';
|
||||
import { loadBuffer } from './sampler.mjs';
|
||||
|
||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||
|
|
@ -28,6 +28,13 @@ export function setMultiChannelOrbits(bool) {
|
|||
multiChannelOrbits = bool == true;
|
||||
}
|
||||
|
||||
function getModulationShapeInput(val) {
|
||||
if (typeof val === 'number') {
|
||||
return val % 5;
|
||||
}
|
||||
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
|
||||
}
|
||||
|
||||
export const soundMap = map();
|
||||
|
||||
export function registerSound(key, onTrigger, data = {}) {
|
||||
|
|
@ -189,7 +196,7 @@ export const resetLoadedSounds = () => soundMap.set({});
|
|||
let audioContext;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
audioContext = new AudioContext();
|
||||
audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
|
|
@ -205,11 +212,17 @@ export function getAudioContextCurrentTime() {
|
|||
return getAudioContext().currentTime;
|
||||
}
|
||||
|
||||
let externalWorklets = [];
|
||||
export function registerWorklet(url) {
|
||||
externalWorklets.push(url);
|
||||
}
|
||||
|
||||
let workletsLoading;
|
||||
function loadWorklets() {
|
||||
if (!workletsLoading) {
|
||||
const audioCtx = getAudioContext();
|
||||
workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
|
||||
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
|
||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
|
||||
}
|
||||
|
||||
return workletsLoading;
|
||||
|
|
@ -275,7 +288,6 @@ export async function initAudioOnFirstClick(options) {
|
|||
return audioReady;
|
||||
}
|
||||
|
||||
let delays = {};
|
||||
const maxfeedback = 0.98;
|
||||
|
||||
let channelMerger, destinationGain;
|
||||
|
|
@ -319,35 +331,45 @@ export const panic = () => {
|
|||
channelMerger == null;
|
||||
};
|
||||
|
||||
function getDelay(orbit, delaytime, delayfeedback, t, channels) {
|
||||
function getDelay(orbit, delaytime, delayfeedback, t) {
|
||||
if (delayfeedback > maxfeedback) {
|
||||
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
|
||||
}
|
||||
delayfeedback = clamp(delayfeedback, 0, 0.98);
|
||||
if (!delays[orbit]) {
|
||||
if (!orbits[orbit].delayNode) {
|
||||
const ac = getAudioContext();
|
||||
const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback);
|
||||
dly.start?.(t); // for some reason, this throws when audion extension is installed..
|
||||
connectToDestination(dly, channels);
|
||||
delays[orbit] = dly;
|
||||
connectToOrbit(dly, orbit);
|
||||
orbits[orbit].delayNode = dly;
|
||||
}
|
||||
delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t);
|
||||
delays[orbit].feedback.value !== delayfeedback && delays[orbit].feedback.setValueAtTime(delayfeedback, t);
|
||||
return delays[orbit];
|
||||
orbits[orbit].delayNode.delayTime.value !== delaytime &&
|
||||
orbits[orbit].delayNode.delayTime.setValueAtTime(delaytime, t);
|
||||
orbits[orbit].delayNode.feedback.value !== delayfeedback &&
|
||||
orbits[orbit].delayNode.feedback.setValueAtTime(delayfeedback, t);
|
||||
return orbits[orbit].delayNode;
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, time, end, properties = {}) {
|
||||
return getWorklet(audioContext, 'lfo-processor', {
|
||||
export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
const { shape = 0, ...props } = properties;
|
||||
const { dcoffset = -0.5, depth = 1 } = properties;
|
||||
const lfoprops = {
|
||||
frequency: 1,
|
||||
depth: 1,
|
||||
skew: 0,
|
||||
depth,
|
||||
skew: 0.5,
|
||||
phaseoffset: 0,
|
||||
time,
|
||||
time: begin,
|
||||
begin,
|
||||
end,
|
||||
shape: 1,
|
||||
dcoffset: -0.5,
|
||||
...properties,
|
||||
});
|
||||
shape: getModulationShapeInput(shape),
|
||||
dcoffset,
|
||||
min: dcoffset * depth,
|
||||
max: dcoffset * depth + depth,
|
||||
curve: 1,
|
||||
...props,
|
||||
};
|
||||
|
||||
return getWorklet(audioContext, 'lfo-processor', lfoprops);
|
||||
}
|
||||
|
||||
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
|
||||
|
|
@ -381,31 +403,76 @@ function getFilterType(ftype) {
|
|||
return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
|
||||
}
|
||||
|
||||
let reverbs = {};
|
||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
function getReverb(orbit, duration, fade, lp, dim, ir, channels) {
|
||||
// If no reverb has been created for a given orbit, create one
|
||||
if (!reverbs[orbit]) {
|
||||
const ac = getAudioContext();
|
||||
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
|
||||
connectToDestination(reverb, channels);
|
||||
reverbs[orbit] = reverb;
|
||||
//type orbit {
|
||||
// gain: number,
|
||||
// reverbNode: reverbNode
|
||||
// delayNode: delayNode
|
||||
//}
|
||||
let orbits = {};
|
||||
function connectToOrbit(node, orbit) {
|
||||
if (orbits[orbit] == null) {
|
||||
errorLogger(new Error('target orbit does not exist'), 'superdough');
|
||||
}
|
||||
node.connect(orbits[orbit].gain);
|
||||
}
|
||||
|
||||
function setOrbit(audioContext, orbit, channels) {
|
||||
if (orbits[orbit] == null) {
|
||||
orbits[orbit] = {
|
||||
gain: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }),
|
||||
};
|
||||
connectToDestination(orbits[orbit].gain, channels);
|
||||
}
|
||||
}
|
||||
function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1) {
|
||||
const targetArr = [targetOrbit].flat();
|
||||
|
||||
targetArr.forEach((target) => {
|
||||
if (orbits[target] == null) {
|
||||
errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough');
|
||||
return;
|
||||
}
|
||||
webAudioTimeout(
|
||||
audioContext,
|
||||
() => {
|
||||
orbits[target].gain.gain.cancelScheduledValues(t);
|
||||
const currVal = orbits[target].gain.gain.value;
|
||||
orbits[target].gain.gain.linearRampToValueAtTime(clamp(1 - Math.pow(duckdepth, 0.5), 0.01, currVal), t);
|
||||
orbits[target].gain.gain.exponentialRampToValueAtTime(1, t + Math.max(0.002, attacktime));
|
||||
},
|
||||
0,
|
||||
t - 0.01,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||
// If no reverb has been created for a given orbit, create one
|
||||
if (!orbits[orbit].reverbNode) {
|
||||
const ac = getAudioContext();
|
||||
const reverb = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
|
||||
connectToOrbit(reverb, orbit);
|
||||
orbits[orbit].reverbNode = reverb;
|
||||
}
|
||||
|
||||
if (
|
||||
hasChanged(duration, reverbs[orbit].duration) ||
|
||||
hasChanged(fade, reverbs[orbit].fade) ||
|
||||
hasChanged(lp, reverbs[orbit].lp) ||
|
||||
hasChanged(dim, reverbs[orbit].dim) ||
|
||||
reverbs[orbit].ir !== ir
|
||||
hasChanged(duration, orbits[orbit].reverbNode.duration) ||
|
||||
hasChanged(fade, orbits[orbit].reverbNode.fade) ||
|
||||
hasChanged(lp, orbits[orbit].reverbNode.lp) ||
|
||||
hasChanged(dim, orbits[orbit].reverbNode.dim) ||
|
||||
hasChanged(irspeed, orbits[orbit].reverbNode.irspeed) ||
|
||||
hasChanged(irbegin, orbits[orbit].reverbNode.irbegin) ||
|
||||
orbits[orbit].reverbNode.ir !== ir
|
||||
) {
|
||||
// only regenerate when something has changed
|
||||
// avoids endless regeneration on things like
|
||||
// stack(s("a"), s("b").rsize(8)).room(.5)
|
||||
// this only works when args may stay undefined until here
|
||||
// setting default values breaks this
|
||||
reverbs[orbit].generate(duration, fade, lp, dim, ir);
|
||||
orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
|
||||
}
|
||||
return reverbs[orbit];
|
||||
return orbits[orbit].reverbNode;
|
||||
}
|
||||
|
||||
export let analysers = {},
|
||||
|
|
@ -448,8 +515,7 @@ function effectSend(input, effect, wet) {
|
|||
}
|
||||
|
||||
export function resetGlobalEffects() {
|
||||
delays = {};
|
||||
reverbs = {};
|
||||
orbits = {};
|
||||
analysers = {};
|
||||
analysersData = {};
|
||||
}
|
||||
|
|
@ -461,9 +527,10 @@ function mapChannelNumbers(channels) {
|
|||
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
||||
}
|
||||
|
||||
export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
||||
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
|
||||
// new: t is always expected to be the absolute target onset time
|
||||
const ac = getAudioContext();
|
||||
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
|
||||
|
||||
let { stretch } = value;
|
||||
if (stretch != null) {
|
||||
//account for phase vocoder latency
|
||||
|
|
@ -489,15 +556,25 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
}
|
||||
// destructure
|
||||
let {
|
||||
tremolo,
|
||||
tremolosync,
|
||||
tremolodepth = 1,
|
||||
tremoloskew,
|
||||
tremolophase = 0,
|
||||
tremoloshape,
|
||||
s = getDefaultValue('s'),
|
||||
bank,
|
||||
source,
|
||||
gain = getDefaultValue('gain'),
|
||||
postgain = getDefaultValue('postgain'),
|
||||
density = getDefaultValue('density'),
|
||||
duckorbit,
|
||||
duckattack,
|
||||
duckdepth,
|
||||
// filters
|
||||
fanchor = getDefaultValue('fanchor'),
|
||||
drive = 0.69,
|
||||
release = 0,
|
||||
// low pass
|
||||
cutoff,
|
||||
lpenv,
|
||||
|
|
@ -530,7 +607,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
phasercenter,
|
||||
//
|
||||
coarse,
|
||||
|
||||
crush,
|
||||
dry,
|
||||
shape,
|
||||
shapevol = getDefaultValue('shapevol'),
|
||||
distort,
|
||||
|
|
@ -548,6 +627,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
roomdim,
|
||||
roomsize,
|
||||
ir,
|
||||
irspeed,
|
||||
irbegin,
|
||||
i = getDefaultValue('i'),
|
||||
velocity = getDefaultValue('velocity'),
|
||||
analyze, // analyser wet
|
||||
|
|
@ -564,7 +645,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
const orbitChannels = mapChannelNumbers(
|
||||
multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
|
||||
);
|
||||
|
||||
const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
|
||||
setOrbit(ac, orbit, channels, t, cycle, cps);
|
||||
|
||||
if (duckorbit != null) {
|
||||
duckOrbit(ac, duckorbit, t, duckattack, duckdepth);
|
||||
}
|
||||
|
||||
gain = applyGainCurve(nanFallback(gain, 1));
|
||||
postgain = applyGainCurve(postgain);
|
||||
|
|
@ -572,8 +659,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
distortvol = applyGainCurve(distortvol);
|
||||
delay = applyGainCurve(delay);
|
||||
velocity = applyGainCurve(velocity);
|
||||
tremolodepth = applyGainCurve(tremolodepth);
|
||||
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
|
||||
|
||||
const end = t + hapDuration;
|
||||
const endWithRelease = end + release;
|
||||
const chainID = Math.round(Math.random() * 1000000);
|
||||
|
||||
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
|
||||
|
|
@ -648,7 +738,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
lprelease,
|
||||
lpenv,
|
||||
t,
|
||||
t + hapDuration,
|
||||
end,
|
||||
fanchor,
|
||||
ftype,
|
||||
drive,
|
||||
|
|
@ -672,7 +762,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
hprelease,
|
||||
hpenv,
|
||||
t,
|
||||
t + hapDuration,
|
||||
end,
|
||||
fanchor,
|
||||
);
|
||||
chain.push(hp());
|
||||
|
|
@ -683,20 +773,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
|
||||
if (bandf !== undefined) {
|
||||
let bp = () =>
|
||||
createFilter(
|
||||
ac,
|
||||
'bandpass',
|
||||
bandf,
|
||||
bandq,
|
||||
bpattack,
|
||||
bpdecay,
|
||||
bpsustain,
|
||||
bprelease,
|
||||
bpenv,
|
||||
t,
|
||||
t + hapDuration,
|
||||
fanchor,
|
||||
);
|
||||
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
|
||||
chain.push(bp());
|
||||
if (ftype === '24db') {
|
||||
chain.push(bp());
|
||||
|
|
@ -714,6 +791,33 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
|
||||
distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol }));
|
||||
|
||||
if (tremolosync != null) {
|
||||
tremolo = cps * tremolosync;
|
||||
}
|
||||
|
||||
if (tremolo !== undefined) {
|
||||
// Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
|
||||
// EX: a triangle waveform will clip like this /-\ when the depth is above 1
|
||||
const gain = Math.max(1 - tremolodepth, 0);
|
||||
const amGain = new GainNode(ac, { gain });
|
||||
|
||||
const time = cycle / cps;
|
||||
const lfo = getLfo(ac, t, endWithRelease, {
|
||||
skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
|
||||
frequency: tremolo,
|
||||
depth: tremolodepth,
|
||||
time,
|
||||
dcoffset: 0,
|
||||
shape: tremoloshape,
|
||||
phaseoffset: tremolophase,
|
||||
min: 0,
|
||||
max: 1,
|
||||
curve: 1.5,
|
||||
});
|
||||
lfo.connect(amGain.gain);
|
||||
chain.push(amGain);
|
||||
}
|
||||
|
||||
compressorThreshold !== undefined &&
|
||||
chain.push(
|
||||
getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
|
||||
|
|
@ -727,19 +831,18 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
}
|
||||
// phaser
|
||||
if (phaser !== undefined && phaserdepth > 0) {
|
||||
const phaserFX = getPhaser(t, t + hapDuration, phaser, phaserdepth, phasercenter, phasersweep);
|
||||
const phaserFX = getPhaser(t, endWithRelease, phaser, phaserdepth, phasercenter, phasersweep);
|
||||
chain.push(phaserFX);
|
||||
}
|
||||
|
||||
// last gain
|
||||
const post = new GainNode(ac, { gain: postgain });
|
||||
chain.push(post);
|
||||
connectToDestination(post, channels);
|
||||
|
||||
// delay
|
||||
let delaySend;
|
||||
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
||||
const delayNode = getDelay(orbit, delaytime, delayfeedback, t, orbitChannels);
|
||||
const delayNode = getDelay(orbit, delaytime, delayfeedback, t);
|
||||
delaySend = effectSend(post, delayNode, delay);
|
||||
audioNodes.push(delaySend);
|
||||
}
|
||||
|
|
@ -757,7 +860,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
}
|
||||
roomIR = await loadBuffer(url, ac, ir, 0);
|
||||
}
|
||||
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, orbitChannels);
|
||||
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
|
||||
reverbSend = effectSend(post, reverbNode, room);
|
||||
audioNodes.push(reverbSend);
|
||||
}
|
||||
|
|
@ -769,6 +872,14 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
|||
analyserSend = effectSend(post, analyserNode, 1);
|
||||
audioNodes.push(analyserSend);
|
||||
}
|
||||
if (dry != null) {
|
||||
dry = applyGainCurve(dry);
|
||||
const dryGain = new GainNode(ac, { gain: dry });
|
||||
chain.push(dryGain);
|
||||
connectToOrbit(dryGain, orbit);
|
||||
} else {
|
||||
connectToOrbit(post, orbit);
|
||||
}
|
||||
|
||||
// connect chain elements together
|
||||
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import {
|
|||
getVibratoOscillator,
|
||||
webAudioTimeout,
|
||||
getWorklet,
|
||||
noises,
|
||||
} from './helpers.mjs';
|
||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||
|
||||
const getFrequencyFromValue = (value) => {
|
||||
const getFrequencyFromValue = (value, defaultNote = 36) => {
|
||||
let { note, freq } = value;
|
||||
note = note || 36;
|
||||
note = note || defaultNote;
|
||||
if (typeof note === 'string') {
|
||||
note = noteToMidi(note); // e.g. c3 => 48
|
||||
}
|
||||
|
|
@ -40,7 +41,17 @@ const waveformAliases = [
|
|||
['saw', 'sawtooth'],
|
||||
['sin', 'sine'],
|
||||
];
|
||||
const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
function makeSaturationCurve(amount, n_samples) {
|
||||
const k = typeof amount === 'number' ? amount : 50;
|
||||
const curve = new Float32Array(n_samples);
|
||||
|
||||
for (let i = 0; i < n_samples; i++) {
|
||||
const x = (i * 2) / n_samples - 1;
|
||||
curve[i] = Math.tanh(x * k);
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
export function registerSynthSounds() {
|
||||
[...waveforms].forEach((s) => {
|
||||
|
|
@ -84,6 +95,75 @@ export function registerSynthSounds() {
|
|||
{ type: 'synth', prebake: true },
|
||||
);
|
||||
});
|
||||
|
||||
registerSound(
|
||||
'sbd',
|
||||
(t, value, onended) => {
|
||||
const { duration, decay = 0.5, pdecay = 0.5, penv = 36, clip } = value;
|
||||
const ctx = getAudioContext();
|
||||
const attackhold = 0.02;
|
||||
const noiselvl = 1.2;
|
||||
const noisedecay = 0.025;
|
||||
const mixGain = 1;
|
||||
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'triangle';
|
||||
o.frequency.value = getFrequencyFromValue(value, 29);
|
||||
o.detune.setValueAtTime(penv * 100, 0);
|
||||
o.detune.setValueAtTime(penv * 100, t);
|
||||
o.detune.exponentialRampToValueAtTime(0.001, t + pdecay);
|
||||
const g = gainNode(1);
|
||||
g.gain.setValueAtTime(1, t + attackhold);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + attackhold + decay);
|
||||
o.start(t);
|
||||
|
||||
const noise = getNoiseOscillator('brown', t, 2);
|
||||
const noiseGain = gainNode(1);
|
||||
noiseGain.gain.setValueAtTime(noiselvl, t);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, t + noisedecay);
|
||||
|
||||
const sat = new WaveShaperNode(ctx);
|
||||
// tri to sine diode shaper emulation
|
||||
sat.curve = makeSaturationCurve(2, ctx.sampleRate);
|
||||
|
||||
const mix = gainNode(mixGain);
|
||||
|
||||
o.onended = () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
sat.disconnect();
|
||||
noise.node.disconnect();
|
||||
noiseGain.disconnect();
|
||||
mix.disconnect();
|
||||
onended();
|
||||
};
|
||||
|
||||
const node = o.connect(sat).connect(g).connect(mix);
|
||||
noise.node.connect(noiseGain).connect(mix);
|
||||
|
||||
const holdEnd = t + decay;
|
||||
let end = holdEnd + 0.01;
|
||||
if (clip != null) {
|
||||
end = Math.min(t + clip * duration, end);
|
||||
}
|
||||
|
||||
// prevent clicking
|
||||
mix.gain.setValueAtTime(mixGain, end - 0.01);
|
||||
mix.gain.linearRampToValueAtTime(0, end);
|
||||
|
||||
o.stop(end);
|
||||
noise.stop(end);
|
||||
|
||||
return {
|
||||
node,
|
||||
stop: (endTime) => {
|
||||
o.stop(endTime);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ type: 'synth', prebake: true },
|
||||
);
|
||||
|
||||
registerSound(
|
||||
'supersaw',
|
||||
(begin, value, onended) => {
|
||||
|
|
@ -121,10 +201,7 @@ export function registerSynthSounds() {
|
|||
const gainAdjustment = 1 / Math.sqrt(voices);
|
||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
// const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
// https://codeberg.org/uzu/strudel/issues/1428
|
||||
// if you think about re-enabling this, please test with fm > 1 first
|
||||
// it's like 10x gain, so it's really dangerous
|
||||
const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
let envGain = gainNode(1);
|
||||
envGain = o.connect(envGain);
|
||||
|
||||
|
|
@ -136,7 +213,7 @@ export function registerSynthSounds() {
|
|||
destroyAudioWorkletNode(o);
|
||||
envGain.disconnect();
|
||||
onended();
|
||||
// fm?.stop();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
},
|
||||
begin,
|
||||
|
|
|
|||
|
|
@ -72,3 +72,7 @@ export const getSoundIndex = (n, numSounds) => {
|
|||
export function cycleToSeconds(cycle, cps) {
|
||||
return cycle / cps;
|
||||
}
|
||||
|
||||
export function secondsToCycle(t, cps) {
|
||||
return t * cps;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,28 @@ import FFT from './fft.js';
|
|||
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||
const _mod = (n, m) => ((n % m) + m) % m;
|
||||
|
||||
// Restrict phase to the range [0, maxPhase) via wrapping
|
||||
function wrapPhase(phase, maxPhase = 1) {
|
||||
if (phase >= maxPhase) {
|
||||
phase -= maxPhase;
|
||||
} else if (phase < 0) {
|
||||
phase += maxPhase;
|
||||
}
|
||||
return phase;
|
||||
}
|
||||
const blockSize = 128;
|
||||
// adjust waveshape to remove frequencies above nyquist to prevent aliasing
|
||||
// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing
|
||||
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
|
||||
function polyBlep(phase, dt) {
|
||||
// 0 <= phase < 1
|
||||
dt = Math.min(dt, 1 - dt);
|
||||
// Start of cycle
|
||||
if (phase < dt) {
|
||||
phase /= dt;
|
||||
// 2 * (phase - phase^2/2 - 0.5)
|
||||
return phase + phase - phase * phase - 1;
|
||||
}
|
||||
|
||||
// -1 < phase < 0
|
||||
// End of cycle
|
||||
else if (phase > 1 - dt) {
|
||||
phase = (phase - 1) / dt;
|
||||
// 2 * (phase^2/2 + phase + 0.5)
|
||||
|
|
@ -31,7 +41,7 @@ function polyBlep(phase, dt) {
|
|||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The order is important for dough integration
|
||||
const waveshapes = {
|
||||
tri(phase, skew = 0.5) {
|
||||
const x = 1 - skew;
|
||||
|
|
@ -81,10 +91,12 @@ function getParamValue(block, param) {
|
|||
}
|
||||
return param[0];
|
||||
}
|
||||
|
||||
const waveShapeNames = Object.keys(waveshapes);
|
||||
class LFOProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{ name: 'begin', defaultValue: 0 },
|
||||
{ name: 'time', defaultValue: 0 },
|
||||
{ name: 'end', defaultValue: 0 },
|
||||
{ name: 'frequency', defaultValue: 0.5 },
|
||||
|
|
@ -92,7 +104,10 @@ class LFOProcessor extends AudioWorkletProcessor {
|
|||
{ name: 'depth', defaultValue: 1 },
|
||||
{ name: 'phaseoffset', defaultValue: 0 },
|
||||
{ name: 'shape', defaultValue: 0 },
|
||||
{ name: 'curve', defaultValue: 1 },
|
||||
{ name: 'dcoffset', defaultValue: 0 },
|
||||
{ name: 'min', defaultValue: 0 },
|
||||
{ name: 'max', defaultValue: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -109,10 +124,13 @@ class LFOProcessor extends AudioWorkletProcessor {
|
|||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const begin = parameters['begin'][0];
|
||||
if (currentTime >= parameters.end[0]) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= begin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const output = outputs[0];
|
||||
const frequency = parameters['frequency'][0];
|
||||
|
|
@ -122,7 +140,11 @@ class LFOProcessor extends AudioWorkletProcessor {
|
|||
const skew = parameters['skew'][0];
|
||||
const phaseoffset = parameters['phaseoffset'][0];
|
||||
|
||||
const curve = parameters['curve'][0];
|
||||
|
||||
const dcoffset = parameters['dcoffset'][0];
|
||||
const min = parameters['min'][0];
|
||||
const max = parameters['max'][0];
|
||||
const shape = waveShapeNames[parameters['shape'][0]];
|
||||
|
||||
const blockSize = output[0].length ?? 0;
|
||||
|
|
@ -130,12 +152,12 @@ class LFOProcessor extends AudioWorkletProcessor {
|
|||
if (this.phase == null) {
|
||||
this.phase = _mod(time * frequency + phaseoffset, 1);
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
const dt = frequency / sampleRate;
|
||||
for (let n = 0; n < blockSize; n++) {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
|
||||
output[i][n] = modval;
|
||||
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
|
||||
modval = Math.pow(modval, curve);
|
||||
output[i][n] = clamp(modval, min, max);
|
||||
}
|
||||
this.incrementPhase(dt);
|
||||
}
|
||||
|
|
@ -291,7 +313,6 @@ class LadderProcessor extends AudioWorkletProcessor {
|
|||
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
|
||||
|
||||
let cutoff = parameters.frequency[0];
|
||||
// eslint-disable-next-line no-undef
|
||||
cutoff = (cutoff * 2 * _PI) / sampleRate;
|
||||
cutoff = cutoff > 1 ? 1 : cutoff;
|
||||
|
||||
|
|
@ -424,18 +445,13 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
|||
];
|
||||
}
|
||||
process(input, outputs, params) {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (currentTime <= params.begin[0]) {
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
if (currentTime >= params.end[0]) {
|
||||
// this.port.postMessage({ type: 'onended' });
|
||||
return false;
|
||||
}
|
||||
let frequency = params.frequency[0];
|
||||
//apply detune in cents
|
||||
frequency = frequency * Math.pow(2, params.detune[0] / 1200);
|
||||
|
||||
const output = outputs[0];
|
||||
const voices = params.voices[0];
|
||||
|
|
@ -446,9 +462,6 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
|||
|
||||
for (let n = 0; n < voices; n++) {
|
||||
const isOdd = (n & 1) == 1;
|
||||
|
||||
//applies unison "spread" detune in semitones
|
||||
const freq = applySemitoneDetuneToFrequency(frequency, getUnisonDetune(voices, freqspread, n));
|
||||
let gainL = gain1;
|
||||
let gainR = gain2;
|
||||
// invert right and left gain
|
||||
|
|
@ -456,21 +469,21 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
|||
gainL = gain2;
|
||||
gainR = gain1;
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
const dt = freq / sampleRate;
|
||||
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
// Main detuning
|
||||
let freq = applySemitoneDetuneToFrequency(params.frequency[i] ?? params.frequency[0], params.detune[0] / 100);
|
||||
// Individual voice detuning
|
||||
freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
|
||||
// We must wrap this here because it is passed into sawblep below which
|
||||
// has domain [0, 1]
|
||||
const dt = _mod(freq / sampleRate, 1);
|
||||
this.phase[n] = this.phase[n] ?? Math.random();
|
||||
const v = waveshapes.sawblep(this.phase[n], dt);
|
||||
|
||||
output[0][i] = output[0][i] + v * gainL;
|
||||
output[1][i] = output[1][i] + v * gainR;
|
||||
|
||||
this.phase[n] += dt;
|
||||
|
||||
if (this.phase[n] > 1.0) {
|
||||
this.phase[n] = this.phase[n] - 1;
|
||||
}
|
||||
this.phase[n] = wrapPhase(this.phase[n] + dt);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
|
@ -479,7 +492,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
|||
|
||||
registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
|
||||
|
||||
// Phase Vocoder sourced from // sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
|
||||
// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
|
||||
const BUFFERED_BLOCK_SIZE = 2048;
|
||||
|
||||
function genHannWindow(length) {
|
||||
|
|
|
|||
1
packages/supradough/.gitignore
vendored
Normal file
1
packages/supradough/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pattern.wav
|
||||
3
packages/supradough/README.md
Normal file
3
packages/supradough/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# supradough
|
||||
|
||||
platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.
|
||||
123
packages/supradough/dough-export.mjs
Normal file
123
packages/supradough/dough-export.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// this is a poc of how a pattern can be rendered as a wav file using node
|
||||
// run via: node dough-export.mjs
|
||||
import fs from 'node:fs';
|
||||
import WavEncoder from 'wav-encoder';
|
||||
import { evalScope } from '@strudel/core';
|
||||
import { miniAllStrings } from '@strudel/mini';
|
||||
import { Dough } from './dough.mjs';
|
||||
|
||||
await evalScope(
|
||||
import('@strudel/core'),
|
||||
import('@strudel/mini'),
|
||||
import('@strudel/tonal'),
|
||||
// import('@strudel/tonal'),
|
||||
);
|
||||
|
||||
miniAllStrings(); // allows using single quotes for mini notation / skip transpilation
|
||||
|
||||
let sampleRate = 48000,
|
||||
cps = 0.4;
|
||||
|
||||
/* await doughsamples('github:eddyflux/crate');
|
||||
await doughsamples('github:eddyflux/wax'); */
|
||||
|
||||
let pat = note('c,eb,g,<bb c4 d4 eb4>')
|
||||
.s('sine')
|
||||
.press()
|
||||
.add(note(24))
|
||||
.fmi(3)
|
||||
.fmh(5.01)
|
||||
.dec(0.4)
|
||||
.delay('.6:<.12 .22>:.8')
|
||||
.jux(press)
|
||||
.rarely(add(note('12')))
|
||||
.lpf(400)
|
||||
.lpq(0.2)
|
||||
.lpd(0.4)
|
||||
.lpenv(3)
|
||||
.fmdecay(0.4)
|
||||
.fmenv(1)
|
||||
.postgain(0.6)
|
||||
.stack(s('<pink white>*8').dec(0.07).rarely(ply('2')).delay(0.5).hpf(sine.range(200, 2000).slow(4)).hpq(0.2))
|
||||
.stack(
|
||||
s('[- white@3]*2')
|
||||
.dec(0.4)
|
||||
.hpf('<2000!3 <4000 8000>>*4')
|
||||
.hpq(0.6)
|
||||
.ply('<1 2>*4')
|
||||
.postgain(0.5)
|
||||
.delay(0.5)
|
||||
.jux(rev)
|
||||
.lpf(5000),
|
||||
)
|
||||
.stack(
|
||||
note('<c2 - [- f1] ->*2')
|
||||
.s('square')
|
||||
.lpf(sine.range(100, 300).slow(4))
|
||||
.lpe(1)
|
||||
.segment(8)
|
||||
.lpd(0.3)
|
||||
.lpq(0.2)
|
||||
.dec(0.2)
|
||||
.speed('<1 2>')
|
||||
.ply('<1 2>')
|
||||
.postgain(1),
|
||||
)
|
||||
.stack(
|
||||
chord('<Cm Cm7 Cm9 Cm11 Fm Fm7 Fm9 Fm11>')
|
||||
.voicing()
|
||||
.s('<sine>')
|
||||
.clip(1)
|
||||
.rel(0.4)
|
||||
.vib('4:.2')
|
||||
.gain(0.7)
|
||||
.hpf(1200)
|
||||
.fm(0.5)
|
||||
.att(1)
|
||||
.lpa(0.5)
|
||||
.lpf(200)
|
||||
.lpenv(4)
|
||||
.chorus(0.8),
|
||||
)
|
||||
.slow(1 / cps);
|
||||
|
||||
let cycles = 30;
|
||||
let seconds = cycles + 1; // 1s release tail
|
||||
const haps = pat.queryArc(0, cycles);
|
||||
|
||||
const dough = new Dough(sampleRate);
|
||||
|
||||
console.log('spawn voices...');
|
||||
haps.forEach((hap) => {
|
||||
hap.value._begin = Number(hap.whole.begin);
|
||||
hap.value._duration = hap.duration /* / cps */;
|
||||
dough.scheduleSpawn(hap.value);
|
||||
});
|
||||
console.log(`render ${seconds}s long buffer, each dot is 1 second:`);
|
||||
const buffers = [new Float32Array(seconds * sampleRate), new Float32Array(seconds * sampleRate)];
|
||||
let t = performance.now();
|
||||
while (dough.t <= buffers[0].length) {
|
||||
dough.update();
|
||||
buffers[0][dough.t] = dough.out[0];
|
||||
buffers[1][dough.t] = dough.out[1];
|
||||
if (dough.t % sampleRate === 0) {
|
||||
process.stdout.write('.');
|
||||
}
|
||||
}
|
||||
const took = (performance.now() - t) / 1000;
|
||||
const load = (took / seconds) * 100;
|
||||
const speed = (seconds / took).toFixed(2);
|
||||
console.log('');
|
||||
console.log(`done!
|
||||
rendered ${seconds}s in ${took.toFixed(2)}s
|
||||
speed: ${speed}x
|
||||
load: ${load.toFixed(2)}%`);
|
||||
|
||||
const patternAudio = {
|
||||
sampleRate,
|
||||
channelData: buffers,
|
||||
};
|
||||
|
||||
WavEncoder.encode(patternAudio).then((buffer) => {
|
||||
fs.writeFileSync('pattern.wav', new Float32Array(buffer));
|
||||
});
|
||||
39
packages/supradough/dough-worklet.mjs
Normal file
39
packages/supradough/dough-worklet.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Dough } from './dough.mjs';
|
||||
|
||||
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||
|
||||
class DoughProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.dough = new Dough(sampleRate, currentTime);
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.spawn) {
|
||||
this.dough.scheduleSpawn(event.data.spawn);
|
||||
} else if (event.data.sample) {
|
||||
this.dough.loadSample(event.data.sample, event.data.channels, event.data.sampleRate);
|
||||
} else if (event.data.samples) {
|
||||
event.data.samples.forEach(([name, channels, sampleRate]) => {
|
||||
this.dough.loadSample(name, channels, sampleRate);
|
||||
});
|
||||
} else {
|
||||
console.log('unrecognized event type', event.data);
|
||||
}
|
||||
};
|
||||
}
|
||||
process(inputs, outputs, params) {
|
||||
if (this.disconnected) {
|
||||
return false;
|
||||
}
|
||||
const output = outputs[0];
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
this.dough.update();
|
||||
for (let c = 0; c < output.length; c++) {
|
||||
//prevent speaker blowout via clipping if threshold exceeds
|
||||
output[c][i] = clamp(this.dough.out[c], -1, 1);
|
||||
}
|
||||
}
|
||||
return true; // keep the audio processing going
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('dough-processor', DoughProcessor);
|
||||
976
packages/supradough/dough.mjs
Normal file
976
packages/supradough/dough.mjs
Normal file
|
|
@ -0,0 +1,976 @@
|
|||
// this is dough, the superdough without dependencies
|
||||
const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000;
|
||||
const PI_DIV_SR = Math.PI / SAMPLE_RATE;
|
||||
const ISR = 1 / SAMPLE_RATE;
|
||||
|
||||
let gainCurveFunc = (val) => Math.pow(val, 2);
|
||||
|
||||
function applyGainCurve(val) {
|
||||
return gainCurveFunc(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Equal Power Crossfade function.
|
||||
* Smoothly transitions between signals A and B, maintaining consistent perceived loudness.
|
||||
*
|
||||
* @param {number} a - Signal A (can be a single value or an array value in buffer processing).
|
||||
* @param {number} b - Signal B (can be a single value or an array value in buffer processing).
|
||||
* @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix).
|
||||
* @returns {number} Crossfaded output value.
|
||||
*/
|
||||
function crossfade(a, b, m) {
|
||||
const aGain = Math.sin((1 - m) * 0.5 * Math.PI);
|
||||
const bGain = Math.sin(m * 0.5 * Math.PI);
|
||||
return a * aGain + b * bGain;
|
||||
}
|
||||
|
||||
// function setGainCurve(newGainCurveFunc) {
|
||||
// gainCurveFunc = newGainCurveFunc;
|
||||
// }
|
||||
// https://garten.salat.dev/audio-DSP/oscillators.html
|
||||
export class SineOsc {
|
||||
phase = 0;
|
||||
update(freq) {
|
||||
const value = Math.sin(this.phase * 2 * Math.PI);
|
||||
this.phase = (this.phase + freq / SAMPLE_RATE) % 1;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export class ZawOsc {
|
||||
phase = 0;
|
||||
update(freq) {
|
||||
this.phase += ISR * freq;
|
||||
return (this.phase % 1) * 2 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function polyBlep(t, dt) {
|
||||
// 0 <= t < 1
|
||||
if (t < dt) {
|
||||
t /= dt;
|
||||
// 2 * (t - t^2/2 - 0.5)
|
||||
return t + t - t * t - 1;
|
||||
}
|
||||
// -1 < t < 0
|
||||
if (t > 1 - dt) {
|
||||
t = (t - 1) / dt;
|
||||
// 2 * (t^2/2 + t + 0.5)
|
||||
return t * t + t + t + 1;
|
||||
}
|
||||
// 0 otherwise
|
||||
return 0;
|
||||
}
|
||||
|
||||
export class SawOsc {
|
||||
constructor(props = {}) {
|
||||
this.phase = props.phase ?? 0;
|
||||
}
|
||||
update(freq) {
|
||||
const dt = freq / SAMPLE_RATE;
|
||||
let p = polyBlep(this.phase, dt);
|
||||
let s = 2 * this.phase - 1 - p;
|
||||
this.phase += dt;
|
||||
if (this.phase > 1) {
|
||||
this.phase -= 1;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function getUnisonDetune(unison, detune, voiceIndex) {
|
||||
if (unison < 2) {
|
||||
return 0;
|
||||
}
|
||||
const lerp = (a, b, n) => {
|
||||
return n * (b - a) + a;
|
||||
};
|
||||
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
|
||||
}
|
||||
function applySemitoneDetuneToFrequency(frequency, detune) {
|
||||
return frequency * Math.pow(2, detune / 12);
|
||||
}
|
||||
export class SupersawOsc {
|
||||
constructor(props = {}) {
|
||||
//TODO: figure out a good way to pass in these params
|
||||
this.voices = props.voices ?? 5;
|
||||
this.freqspread = props.freqspread ?? 0.2;
|
||||
this.panspread = props.panspread ?? 0.4;
|
||||
this.phase = new Float32Array(this.voices).map(() => Math.random());
|
||||
}
|
||||
update(freq) {
|
||||
const gain1 = Math.sqrt(1 - this.panspread);
|
||||
const gain2 = Math.sqrt(this.panspread);
|
||||
let sl = 0;
|
||||
let sr = 0;
|
||||
for (let n = 0; n < this.voices; n++) {
|
||||
const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n));
|
||||
const dt = freqAdjusted / SAMPLE_RATE;
|
||||
const isOdd = (n & 1) == 1;
|
||||
let gainL = gain1;
|
||||
let gainR = gain2;
|
||||
// invert right and left gain
|
||||
if (isOdd) {
|
||||
gainL = gain2;
|
||||
gainR = gain1;
|
||||
}
|
||||
let p = polyBlep(this.phase[n], dt);
|
||||
let s = 2 * this.phase[n] - 1 - p;
|
||||
sl = sl + s * gainL;
|
||||
sr = sr + s * gainL;
|
||||
|
||||
this.phase[n] += dt;
|
||||
if (this.phase[n] > 1) {
|
||||
this.phase[n] -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sl + sr;
|
||||
//TODO: make stereo
|
||||
// return [sl, sr];
|
||||
}
|
||||
}
|
||||
|
||||
export class TriOsc {
|
||||
phase = 0;
|
||||
update(freq) {
|
||||
this.phase += ISR * freq;
|
||||
let phase = this.phase % 1;
|
||||
let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5);
|
||||
return value * 2 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class TwoPoleFilter {
|
||||
s0 = 0;
|
||||
s1 = 0;
|
||||
update(s, cutoff, resonance = 0) {
|
||||
// Out of bound values can produce NaNs
|
||||
resonance = Math.max(resonance, 0);
|
||||
|
||||
cutoff = Math.min(cutoff, 20000);
|
||||
const c = 2 * Math.sin(cutoff * PI_DIV_SR);
|
||||
|
||||
const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
|
||||
const mrc = 1 - r * c;
|
||||
|
||||
this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
|
||||
this.s1 = mrc * this.s1 + c * this.s0; // lpf
|
||||
return this.s1; // return lpf by default
|
||||
}
|
||||
}
|
||||
|
||||
class PulseOsc {
|
||||
constructor(phase = 0) {
|
||||
this.phase = phase;
|
||||
}
|
||||
saw(offset, dt) {
|
||||
let phase = (this.phase + offset) % 1;
|
||||
let p = polyBlep(phase, dt);
|
||||
return 2 * phase - 1 - p;
|
||||
}
|
||||
update(freq, pw = 0.5) {
|
||||
const dt = freq / SAMPLE_RATE;
|
||||
let pulse = this.saw(0, dt) - this.saw(pw, dt);
|
||||
this.phase = (this.phase + dt) % 1;
|
||||
return pulse + pw * 2 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// non bandlimited (has aliasing)
|
||||
export class PulzeOsc {
|
||||
phase = 0;
|
||||
update(freq, duty = 0.5) {
|
||||
this.phase += ISR * freq;
|
||||
let cyclePos = this.phase % 1;
|
||||
return cyclePos < duty ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
export class Dust {
|
||||
update = (density) => (Math.random() < density * ISR ? Math.random() : 0);
|
||||
}
|
||||
|
||||
export class WhiteNoise {
|
||||
update() {
|
||||
return Math.random() * 2 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrownNoise {
|
||||
constructor() {
|
||||
this.out = 0;
|
||||
}
|
||||
update() {
|
||||
let white = Math.random() * 2 - 1;
|
||||
this.out = (this.out + 0.02 * white) / 1.02;
|
||||
return this.out;
|
||||
}
|
||||
}
|
||||
|
||||
export class PinkNoise {
|
||||
constructor() {
|
||||
this.b0 = 0;
|
||||
this.b1 = 0;
|
||||
this.b2 = 0;
|
||||
this.b3 = 0;
|
||||
this.b4 = 0;
|
||||
this.b5 = 0;
|
||||
this.b6 = 0;
|
||||
}
|
||||
|
||||
update() {
|
||||
const white = Math.random() * 2 - 1;
|
||||
|
||||
this.b0 = 0.99886 * this.b0 + white * 0.0555179;
|
||||
this.b1 = 0.99332 * this.b1 + white * 0.0750759;
|
||||
this.b2 = 0.969 * this.b2 + white * 0.153852;
|
||||
this.b3 = 0.8665 * this.b3 + white * 0.3104856;
|
||||
this.b4 = 0.55 * this.b4 + white * 0.5329522;
|
||||
this.b5 = -0.7616 * this.b5 - white * 0.016898;
|
||||
|
||||
const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
|
||||
this.b6 = white * 0.115926;
|
||||
|
||||
return pink * 0.11;
|
||||
}
|
||||
}
|
||||
|
||||
export class Impulse {
|
||||
phase = 1;
|
||||
update(freq) {
|
||||
this.phase += ISR * freq;
|
||||
let v = this.phase >= 1 ? 1 : 0;
|
||||
this.phase = this.phase % 1;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClockDiv {
|
||||
inSgn = true;
|
||||
outSgn = true;
|
||||
clockCnt = 0;
|
||||
update(clock, factor) {
|
||||
let curSgn = clock > 0;
|
||||
if (this.inSgn != curSgn) {
|
||||
this.clockCnt++;
|
||||
if (this.clockCnt >= factor) {
|
||||
this.clockCnt = 0;
|
||||
this.outSgn = !this.outSgn;
|
||||
}
|
||||
}
|
||||
|
||||
this.inSgn = curSgn;
|
||||
return this.outSgn ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
export class Hold {
|
||||
value = 0;
|
||||
trigSgn = false;
|
||||
update(input, trig) {
|
||||
if (!this.trigSgn && trig > 0) this.value = input;
|
||||
this.trigSgn = trig > 0;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(x, y0, y1, exponent = 1) {
|
||||
if (x <= 0) return y0;
|
||||
if (x >= 1) return y1;
|
||||
|
||||
let curvedX;
|
||||
|
||||
if (exponent === 0) {
|
||||
curvedX = x; // linear
|
||||
} else if (exponent > 0) {
|
||||
curvedX = Math.pow(x, exponent); // ease-in
|
||||
} else {
|
||||
curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out
|
||||
}
|
||||
|
||||
return y0 + (y1 - y0) * curvedX;
|
||||
}
|
||||
|
||||
export class ADSR {
|
||||
constructor(props = {}) {
|
||||
this.state = 'off';
|
||||
this.startTime = 0;
|
||||
this.startVal = 0;
|
||||
this.decayCurve = props.decayCurve ?? 1;
|
||||
}
|
||||
|
||||
update(curTime, gate, attack, decay, susVal, release) {
|
||||
switch (this.state) {
|
||||
case 'off': {
|
||||
if (gate > 0) {
|
||||
this.state = 'attack';
|
||||
this.startTime = curTime;
|
||||
this.startVal = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'attack': {
|
||||
let time = curTime - this.startTime;
|
||||
if (time > attack) {
|
||||
this.state = 'decay';
|
||||
this.startTime = curTime;
|
||||
return 1;
|
||||
}
|
||||
return lerp(time / attack, this.startVal, 1, 1);
|
||||
}
|
||||
case 'decay': {
|
||||
let time = curTime - this.startTime;
|
||||
let curVal = lerp(time / decay, 1, susVal, -this.decayCurve);
|
||||
if (gate <= 0) {
|
||||
this.state = 'release';
|
||||
this.startTime = curTime;
|
||||
this.startVal = curVal;
|
||||
return curVal;
|
||||
}
|
||||
if (time > decay) {
|
||||
this.state = 'sustain';
|
||||
this.startTime = curTime;
|
||||
return susVal;
|
||||
}
|
||||
return curVal;
|
||||
}
|
||||
case 'sustain': {
|
||||
if (gate <= 0) {
|
||||
this.state = 'release';
|
||||
this.startTime = curTime;
|
||||
this.startVal = susVal;
|
||||
}
|
||||
return susVal;
|
||||
}
|
||||
case 'release': {
|
||||
let time = curTime - this.startTime;
|
||||
|
||||
if (time > release) {
|
||||
this.state = 'off';
|
||||
return 0;
|
||||
}
|
||||
let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve);
|
||||
if (gate > 0) {
|
||||
this.state = 'attack';
|
||||
this.startTime = curTime;
|
||||
this.startVal = curVal;
|
||||
}
|
||||
return curVal;
|
||||
}
|
||||
}
|
||||
throw 'invalid envelope state';
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
impulse(1).ad(.1).mul(sine(200))
|
||||
.add(x=>x.delay(.1).mul(.8))
|
||||
.out()*/
|
||||
const MAX_DELAY_TIME = 10;
|
||||
export class PitchDelay {
|
||||
lpf = new TwoPoleFilter();
|
||||
constructor(_props = {}) {
|
||||
this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE);
|
||||
this.writeIdx = 0;
|
||||
this.readIdx = 0;
|
||||
this.numSamples = 0;
|
||||
}
|
||||
write(s, delayTime) {
|
||||
// Calculate how far in the past to read
|
||||
this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
|
||||
this.writeIdx = (this.writeIdx + 1) % this.numSamples;
|
||||
this.buffer[this.writeIdx] = s;
|
||||
this.readIdx = this.writeIdx - this.numSamples + 1;
|
||||
|
||||
// If past the start of the buffer, wrap around (Q: is this possible?)
|
||||
if (this.readIdx < 0) this.readIdx += this.numSamples;
|
||||
}
|
||||
update(input, delayTime, speed = 1) {
|
||||
this.write(input, delayTime);
|
||||
let index = this.readIdx;
|
||||
if (speed < 0) {
|
||||
index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples);
|
||||
} else {
|
||||
index = Math.floor(this.readIdx * speed) % this.numSamples;
|
||||
}
|
||||
const s = this.lpf.update(this.buffer[index], 0.9, 0);
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
export class Delay {
|
||||
writeIdx = 0;
|
||||
readIdx = 0;
|
||||
buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0)
|
||||
write(s, delayTime) {
|
||||
this.writeIdx = (this.writeIdx + 1) % this.buffer.length;
|
||||
this.buffer[this.writeIdx] = s;
|
||||
// Calculate how far in the past to read
|
||||
let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
|
||||
this.readIdx = this.writeIdx - numSamples;
|
||||
// If past the start of the buffer, wrap around
|
||||
if (this.readIdx < 0) this.readIdx += this.buffer.length;
|
||||
}
|
||||
update(input, delayTime) {
|
||||
this.write(input, delayTime);
|
||||
return this.buffer[this.readIdx];
|
||||
}
|
||||
}
|
||||
//TODO: Figure out why clicking at the start off the buffer
|
||||
export class Chorus {
|
||||
delay = new Delay();
|
||||
modulator = new TriOsc();
|
||||
update(input, mix, delayTime, modulationFreq, modulationDepth) {
|
||||
const m = this.modulator.update(modulationFreq) * modulationDepth;
|
||||
const c = this.delay.update(input, delayTime * (1 + m));
|
||||
return crossfade(input, c, mix);
|
||||
}
|
||||
}
|
||||
|
||||
export class Fold {
|
||||
update(input = 0, rate = 0) {
|
||||
if (rate < 0) rate = 0;
|
||||
rate = rate + 1;
|
||||
input = input * rate;
|
||||
return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
export class Lag {
|
||||
lagUnit = 4410;
|
||||
s = 0;
|
||||
update(input, rate) {
|
||||
// Remap so the useful range is around [0, 1]
|
||||
rate = rate * this.lagUnit;
|
||||
if (rate < 1) rate = 1;
|
||||
this.s += (1 / rate) * (input - this.s);
|
||||
return this.s;
|
||||
}
|
||||
}
|
||||
|
||||
export class Slew {
|
||||
last = 0;
|
||||
update(input, up, dn) {
|
||||
const upStep = up * ISR;
|
||||
const downStep = dn * ISR;
|
||||
let delta = input - this.last;
|
||||
if (delta > upStep) {
|
||||
delta = upStep;
|
||||
} else if (delta < -downStep) {
|
||||
delta = -downStep;
|
||||
}
|
||||
this.last += delta;
|
||||
return this.last;
|
||||
}
|
||||
}
|
||||
|
||||
// overdrive style distortion (adapted from noisecraft) currently unused
|
||||
export function applyDistortion(x, amount) {
|
||||
amount = Math.min(Math.max(amount, 0), 1);
|
||||
amount -= 0.01;
|
||||
var k = (2 * amount) / (1 - amount);
|
||||
var y = ((1 + k) * x) / (1 + k * Math.abs(x));
|
||||
return y;
|
||||
}
|
||||
|
||||
export class Sequence {
|
||||
clockSgn = true;
|
||||
step = 0;
|
||||
first = true;
|
||||
update(clock, ...ins) {
|
||||
if (!this.clockSgn && clock > 0) {
|
||||
this.step = (this.step + 1) % ins.length;
|
||||
this.clockSgn = clock > 0;
|
||||
return 0; // set first sample to zero to retrigger gates on step change...
|
||||
}
|
||||
this.clockSgn = clock > 0;
|
||||
return ins[this.step];
|
||||
}
|
||||
}
|
||||
|
||||
// sample rate bit crusher
|
||||
export class Coarse {
|
||||
hold = 0;
|
||||
t = 0;
|
||||
update(input, coarse) {
|
||||
if (this.t++ % coarse === 0) {
|
||||
this.t = 0;
|
||||
this.hold = input;
|
||||
}
|
||||
return this.hold;
|
||||
}
|
||||
}
|
||||
|
||||
// amplitude bit crusher
|
||||
export class Crush {
|
||||
update(input, crush) {
|
||||
crush = Math.max(1, crush);
|
||||
const x = Math.pow(2, crush - 1);
|
||||
return Math.round(input * x) / x;
|
||||
}
|
||||
}
|
||||
|
||||
// this is the distort from superdough
|
||||
export class Distort {
|
||||
update(input, distort = 0, postgain = 1) {
|
||||
postgain = Math.max(0.001, Math.min(1, postgain));
|
||||
const shape = Math.expm1(distort);
|
||||
return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain;
|
||||
}
|
||||
}
|
||||
// distortion could be expressed as a function, because it's stateless
|
||||
|
||||
export class BufferPlayer {
|
||||
static samples = new Map(); // string -> { channels, sampleRate }
|
||||
buffer; // Float32Array
|
||||
sampleRate;
|
||||
pos = 0;
|
||||
sampleFreq = note2freq();
|
||||
constructor(buffer, sampleRate, normalize) {
|
||||
this.buffer = buffer;
|
||||
this.sampleRate = sampleRate;
|
||||
this.duration = this.buffer.length / this.sampleRate;
|
||||
this.speed = SAMPLE_RATE / this.sampleRate;
|
||||
if (normalize) {
|
||||
// this will make the buffer last 1s if freq = sampleFreq
|
||||
// it's useful to loop samples (e.g. fit function)
|
||||
this.speed *= this.duration;
|
||||
}
|
||||
}
|
||||
update(freq) {
|
||||
if (this.pos >= this.buffer.length) {
|
||||
return 0;
|
||||
}
|
||||
const speed = (freq / this.sampleFreq) * this.speed;
|
||||
let s = this.buffer[Math.floor(this.pos)];
|
||||
this.pos = this.pos + speed;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
export function _rangex(sig, min, max) {
|
||||
let logmin = Math.log(min);
|
||||
let range = Math.log(max) - logmin;
|
||||
const unipolar = (sig + 1) / 2;
|
||||
return Math.exp(unipolar * range + logmin);
|
||||
}
|
||||
|
||||
// duplicate
|
||||
export const getADSR = (params, curve = 'linear', defaultValues) => {
|
||||
const envmin = curve === 'exponential' ? 0.001 : 0.001;
|
||||
const releaseMin = 0.01;
|
||||
const envmax = 1;
|
||||
const [a, d, s, r] = params;
|
||||
if (a == null && d == null && s == null && r == null) {
|
||||
return defaultValues ?? [envmin, envmin, envmax, releaseMin];
|
||||
}
|
||||
const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
|
||||
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
||||
};
|
||||
|
||||
let shapes = {
|
||||
sine: SineOsc,
|
||||
saw: SawOsc,
|
||||
zaw: ZawOsc,
|
||||
sawtooth: SawOsc,
|
||||
zawtooth: ZawOsc,
|
||||
supersaw: SupersawOsc,
|
||||
tri: TriOsc,
|
||||
triangle: TriOsc,
|
||||
pulse: PulseOsc,
|
||||
square: PulseOsc,
|
||||
pulze: PulzeOsc,
|
||||
dust: Dust,
|
||||
crackle: Dust,
|
||||
impulse: Impulse,
|
||||
white: WhiteNoise,
|
||||
brown: BrownNoise,
|
||||
pink: PinkNoise,
|
||||
};
|
||||
|
||||
const defaultDefaultValues = {
|
||||
chorus: 0,
|
||||
note: 48,
|
||||
s: 'triangle',
|
||||
bank: '',
|
||||
gain: 1,
|
||||
postgain: 1,
|
||||
velocity: 1,
|
||||
density: '.03',
|
||||
ftype: '12db',
|
||||
fanchor: 0,
|
||||
//resonance: 1, // superdough resonance is scaled differently
|
||||
resonance: 0,
|
||||
//hresonance: 1, // superdough resonance is scaled differently
|
||||
hresonance: 0,
|
||||
// bandq: 1, // superdough resonance is scaled differently
|
||||
bandq: 0,
|
||||
channels: [1, 2],
|
||||
phaserdepth: 0.75,
|
||||
shapevol: 1,
|
||||
distortvol: 1,
|
||||
delay: 0,
|
||||
byteBeatExpression: '0',
|
||||
delayfeedback: 0.5,
|
||||
delayspeed: 1,
|
||||
delaytime: 0.25,
|
||||
orbit: 1,
|
||||
i: 1,
|
||||
fft: 8,
|
||||
z: 'triangle',
|
||||
pan: 0.5,
|
||||
fmh: 1,
|
||||
fmenv: 0, // differs from superdough
|
||||
speed: 1,
|
||||
pw: 0.5,
|
||||
};
|
||||
|
||||
let getDefaultValue = (key) => defaultDefaultValues[key];
|
||||
|
||||
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
||||
const accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||
const note2midi = (note, defaultOctave = 3) => {
|
||||
let [pc, acc = '', oct = ''] =
|
||||
String(note)
|
||||
.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)
|
||||
?.slice(1) || [];
|
||||
if (!pc) {
|
||||
throw new Error('not a note: "' + note + '"');
|
||||
}
|
||||
const chroma = chromas[pc.toLowerCase()];
|
||||
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||
oct = Number(oct || defaultOctave);
|
||||
return (oct + 1) * 12 + chroma + offset;
|
||||
};
|
||||
const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440;
|
||||
const note2freq = (note) => {
|
||||
note = note || getDefaultValue('note');
|
||||
if (typeof note === 'string') {
|
||||
note = note2midi(note, 3); // e.g. c3 => 48
|
||||
}
|
||||
return midi2freq(note);
|
||||
};
|
||||
|
||||
export class DoughVoice {
|
||||
out = [0, 0];
|
||||
constructor(value) {
|
||||
value.freq ??= note2freq(value.note);
|
||||
let $ = this;
|
||||
Object.assign($, value);
|
||||
$.s = $.s ?? getDefaultValue('s');
|
||||
$.gain = applyGainCurve($.gain ?? getDefaultValue('gain'));
|
||||
$.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity'));
|
||||
$.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain'));
|
||||
$.density = $.density ?? getDefaultValue('density');
|
||||
$.fanchor = $.fanchor ?? getDefaultValue('fanchor');
|
||||
$.drive = $.drive ?? 0.69;
|
||||
$.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth');
|
||||
$.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol'));
|
||||
$.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol'));
|
||||
$.i = $.i ?? getDefaultValue('i');
|
||||
$.chorus = $.chorus ?? getDefaultValue('chorus');
|
||||
$.fft = $.fft ?? getDefaultValue('fft');
|
||||
$.pan = $.pan ?? getDefaultValue('pan');
|
||||
$.orbit = $.orbit ?? getDefaultValue('orbit');
|
||||
$.fmenv = $.fmenv ?? getDefaultValue('fmenv');
|
||||
$.resonance = $.resonance ?? getDefaultValue('resonance');
|
||||
$.hresonance = $.hresonance ?? getDefaultValue('hresonance');
|
||||
$.bandq = $.bandq ?? getDefaultValue('bandq');
|
||||
$.speed = $.speed ?? getDefaultValue('speed');
|
||||
$.pw = $.pw ?? getDefaultValue('pw');
|
||||
|
||||
[$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]);
|
||||
|
||||
$._holdEnd = $._begin + $._duration; // needed for gate
|
||||
$._end = $._holdEnd + $.release + 0.01; // needed for despawn
|
||||
|
||||
if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) {
|
||||
$.s = 'zaw'; // polyblepped saw when fm is applied
|
||||
}
|
||||
|
||||
if (shapes[$.s]) {
|
||||
const SourceClass = shapes[$.s];
|
||||
$._sound = new SourceClass();
|
||||
$._channels = 1;
|
||||
} else if (BufferPlayer.samples.has($.s)) {
|
||||
const sample = BufferPlayer.samples.get($.s);
|
||||
$._buffers = [];
|
||||
$._channels = sample.channels.length;
|
||||
for (let i = 0; i < $._channels; i++) {
|
||||
$._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c'
|
||||
}
|
||||
} else {
|
||||
console.warn('sound not loaded', $.s);
|
||||
}
|
||||
|
||||
if ($.penv) {
|
||||
$._penv = new ADSR({ decayCurve: 4 });
|
||||
[$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]);
|
||||
}
|
||||
|
||||
if ($.vib) {
|
||||
$._vib = new SineOsc();
|
||||
$.vibmod = $.vibmod ?? getDefaultValue('vibmod');
|
||||
}
|
||||
|
||||
if ($.fmi) {
|
||||
$._fm = new SineOsc();
|
||||
$.fmh = $.fmh ?? getDefaultValue('fmh');
|
||||
if ($.fmenv) {
|
||||
$._fmenv = new ADSR({ decayCurve: 2 });
|
||||
[$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]);
|
||||
}
|
||||
}
|
||||
|
||||
// gain envelope
|
||||
$._adsr = new ADSR({ decayCurve: 2 });
|
||||
// delay
|
||||
$.delay = applyGainCurve($.delay ?? getDefaultValue('delay'));
|
||||
$.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback');
|
||||
$.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed');
|
||||
$.delaytime = $.delaytime ?? getDefaultValue('delaytime');
|
||||
|
||||
// filter setup
|
||||
if ($.lpenv) {
|
||||
$._lpenv = new ADSR({ decayCurve: 4 });
|
||||
[$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]);
|
||||
}
|
||||
if ($.hpenv) {
|
||||
$._hpenv = new ADSR({ decayCurve: 4 });
|
||||
[$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]);
|
||||
}
|
||||
if ($.bpenv) {
|
||||
$._bpenv = new ADSR({ decayCurve: 4 });
|
||||
[$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]);
|
||||
}
|
||||
|
||||
// channelwise effects setup
|
||||
$._chorus = $.chorus ? [] : null;
|
||||
$._lpf = $.cutoff ? [] : null;
|
||||
$._hpf = $.hcutoff ? [] : null;
|
||||
$._bpf = $.bandf ? [] : null;
|
||||
$._coarse = $.coarse ? [] : null;
|
||||
$._crush = $.crush ? [] : null;
|
||||
$._distort = $.distort ? [] : null;
|
||||
for (let i = 0; i < this._channels; i++) {
|
||||
$._lpf?.push(new TwoPoleFilter());
|
||||
$._hpf?.push(new TwoPoleFilter());
|
||||
$._bpf?.push(new TwoPoleFilter());
|
||||
$._chorus?.push(new Chorus());
|
||||
$._coarse?.push(new Coarse());
|
||||
$._crush?.push(new Crush());
|
||||
$._distort?.push(new Distort());
|
||||
}
|
||||
}
|
||||
update(t) {
|
||||
if (!this._sound && !this._buffers) {
|
||||
return 0;
|
||||
}
|
||||
let gate = Number(t >= this._begin && t <= this._holdEnd);
|
||||
|
||||
let freq = this.freq * this.speed;
|
||||
|
||||
// frequency modulation
|
||||
if (this._fm) {
|
||||
let fmi = this.fmi;
|
||||
if (this._fmenv) {
|
||||
const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease);
|
||||
fmi = this.fmenv * env * fmi;
|
||||
}
|
||||
const modfreq = freq * this.fmh;
|
||||
const modgain = modfreq * fmi;
|
||||
freq = freq + this._fm.update(modfreq) * modgain;
|
||||
}
|
||||
|
||||
// vibrato
|
||||
if (this._vib) {
|
||||
freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12);
|
||||
}
|
||||
|
||||
// pitch envelope
|
||||
if (this._penv) {
|
||||
const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease);
|
||||
freq = freq + env * this.penv;
|
||||
}
|
||||
|
||||
// filters
|
||||
let lpf = this.cutoff;
|
||||
if (this._lpf) {
|
||||
if (this._lpenv) {
|
||||
const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease);
|
||||
lpf = this.lpenv * env * lpf + lpf;
|
||||
}
|
||||
}
|
||||
let hpf = this.hcutoff;
|
||||
if (this._hpf) {
|
||||
if (this._hpenv) {
|
||||
const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease);
|
||||
hpf = 2 ** this.hpenv * env * hpf + hpf;
|
||||
}
|
||||
}
|
||||
let bpf = this.bandf;
|
||||
if (this._bpf) {
|
||||
if (this._bpenv) {
|
||||
const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease);
|
||||
bpf = 2 ** this.bpenv * env * bpf + bpf;
|
||||
}
|
||||
}
|
||||
// gain envelope
|
||||
const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release);
|
||||
|
||||
// channelwise dsp
|
||||
for (let i = 0; i < this._channels; i++) {
|
||||
// sound source
|
||||
if (this._sound && this.s === 'pulse') {
|
||||
this.out[i] = this._sound.update(freq, this.pw);
|
||||
} else if (this._sound) {
|
||||
this.out[i] = this._sound.update(freq);
|
||||
} else if (this._buffers) {
|
||||
this.out[i] = this._buffers[i].update(freq);
|
||||
}
|
||||
this.out[i] = this.out[i] * this.gain * this.velocity;
|
||||
if (this._chorus) {
|
||||
const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11);
|
||||
this.out[i] = c + this.out[i];
|
||||
}
|
||||
|
||||
if (this._lpf) {
|
||||
this._lpf[i].update(this.out[i], lpf, this.resonance);
|
||||
this.out[i] = this._lpf[i].s1;
|
||||
}
|
||||
if (this._hpf) {
|
||||
this._hpf[i].update(this.out[i], hpf, this.hresonance);
|
||||
this.out[i] = this.out[i] - this._hpf[i].s1;
|
||||
}
|
||||
if (this._bpf) {
|
||||
this._bpf[i].update(this.out[i], bpf, this.bandq);
|
||||
this.out[i] = this._bpf[i].s0;
|
||||
}
|
||||
if (this._coarse) {
|
||||
this.out[i] = this._coarse[i].update(this.out[i], this.coarse);
|
||||
}
|
||||
if (this._crush) {
|
||||
this.out[i] = this._crush[i].update(this.out[i], this.crush);
|
||||
}
|
||||
if (this._distort) {
|
||||
this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol);
|
||||
}
|
||||
this.out[i] = this.out[i] * env;
|
||||
this.out[i] = this.out[i] * this.postgain;
|
||||
if (!this._buffers) {
|
||||
this.out[i] = this.out[i] * 0.2; // turn down waveform
|
||||
}
|
||||
}
|
||||
if (this._channels === 1) {
|
||||
this.out[1] = this.out[0];
|
||||
}
|
||||
if (this.pan !== 0.5) {
|
||||
const panpos = (this.pan * Math.PI) / 2;
|
||||
this.out[0] = this.out[0] * Math.cos(panpos);
|
||||
this.out[1] = this.out[1] * Math.sin(panpos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this class is the interface to the "outer world"
|
||||
// it handles spawning and despawning of DoughVoice's
|
||||
export class Dough {
|
||||
voices = []; // DoughVoice[]
|
||||
vid = 0;
|
||||
q = [];
|
||||
out = [0, 0];
|
||||
delaysend = [0, 0];
|
||||
delaytime = getDefaultValue('delaytime');
|
||||
delayfeedback = getDefaultValue('delayfeedback');
|
||||
delayspeed = getDefaultValue('delayspeed');
|
||||
t = 0;
|
||||
// sampleRate: number, currentTime: number (seconds)
|
||||
constructor(sampleRate = 48000, currentTime = 0) {
|
||||
this.sampleRate = sampleRate;
|
||||
this.t = Math.floor(currentTime * sampleRate); // samples
|
||||
// console.log('init dough', this.sampleRate, this.t);
|
||||
this._delayL = new PitchDelay();
|
||||
this._delayR = new PitchDelay();
|
||||
}
|
||||
loadSample(name, channels, sampleRate) {
|
||||
BufferPlayer.samples.set(name, { channels, sampleRate });
|
||||
}
|
||||
scheduleSpawn(value) {
|
||||
if (value._begin === undefined) {
|
||||
throw new Error('[dough]: scheduleSpawn expected _begin to be set');
|
||||
}
|
||||
if (value._duration === undefined) {
|
||||
throw new Error('[dough]: scheduleSpawn expected _duration to be set');
|
||||
}
|
||||
value.sampleRate = this.sampleRate;
|
||||
// convert seconds to samples
|
||||
const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs
|
||||
this.schedule({ time, type: 'spawn', arg: value });
|
||||
}
|
||||
spawn(value) {
|
||||
value.id = this.vid++;
|
||||
const voice = new DoughVoice(value);
|
||||
this.voices.push(voice);
|
||||
// console.log('spawn', voice.id, 'voices:', this.voices.length);
|
||||
// schedule removal
|
||||
const endTime = Math.ceil(voice._end * this.sampleRate);
|
||||
this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id });
|
||||
}
|
||||
despawn(vid) {
|
||||
this.voices = this.voices.filter((v) => v.id !== vid);
|
||||
// console.log('despawn', vid, 'voices:', this.voices.length);
|
||||
}
|
||||
// schedules a function call with a single argument
|
||||
// msg = {time:number,type:string, arg: any}
|
||||
// the Dough method "type" will be called with "arg" at "time"
|
||||
schedule(msg) {
|
||||
if (!this.q.length) {
|
||||
// if empty, just push
|
||||
this.q.push(msg);
|
||||
return;
|
||||
}
|
||||
// not empty
|
||||
// find index where msg.time fits in
|
||||
let i = 0;
|
||||
while (i < this.q.length && this.q[i].time < msg.time) {
|
||||
i++;
|
||||
}
|
||||
// this ensures q stays sorted by time, so we only need to check q[0]
|
||||
this.q.splice(i, 0, msg);
|
||||
}
|
||||
// maybe update should be called once per block instead for perf reasons?
|
||||
update() {
|
||||
// go over q
|
||||
while (this.q.length > 0 && this.q[0].time <= this.t) {
|
||||
// console.log('schedule', this.q[0]);
|
||||
// trigger due messages. q is sorted, so we only need to check q[0]
|
||||
this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method
|
||||
this.q.shift();
|
||||
}
|
||||
// add active voices
|
||||
this.out[0] = 0;
|
||||
this.out[1] = 0;
|
||||
for (let v = 0; v < this.voices.length; v++) {
|
||||
this.voices[v].update(this.t / this.sampleRate);
|
||||
this.out[0] += this.voices[v].out[0];
|
||||
this.out[1] += this.voices[v].out[1];
|
||||
if (this.voices[v].delay) {
|
||||
this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay;
|
||||
this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay;
|
||||
this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice
|
||||
this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice
|
||||
this.delayfeedback = this.voices[v].delayfeedback;
|
||||
}
|
||||
}
|
||||
// todo: how to change delaytime / delayfeedback from a voice?
|
||||
const delayL = this._delayL.update(this.delaysend[0], this.delaytime, this.delayspeed);
|
||||
const delayR = this._delayR.update(this.delaysend[1], this.delaytime, this.delayspeed);
|
||||
this.delaysend[0] = delayL * this.delayfeedback;
|
||||
this.delaysend[1] = delayR * this.delayfeedback;
|
||||
this.out[0] += delayL;
|
||||
this.out[1] += delayR;
|
||||
this.t++;
|
||||
}
|
||||
}
|
||||
5
packages/supradough/index.mjs
Normal file
5
packages/supradough/index.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// import _workletUrl from './dough-worklet.mjs?url'; // only for dev (breaks for production build)
|
||||
import _workletUrl from './dough-worklet.mjs?audioworklet'; // only for prod (breaks in development?!)
|
||||
|
||||
export * from './dough.mjs';
|
||||
export const workletUrl = _workletUrl;
|
||||
37
packages/supradough/package.json
Normal file
37
packages/supradough/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "supradough",
|
||||
"version": "1.2.3",
|
||||
"description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tidalcycles/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Felix Roos <flix91@gmail.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tidalcycles/strudel/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tidalcycles/strudel#readme",
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-bundle-audioworklet": "workspace:*",
|
||||
"wav-encoder": "^1.3.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/tonal",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Tonal functions for strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -88,13 +88,14 @@ function scaleOffset(scale, offset, note) {
|
|||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @name transpose
|
||||
* @synonyms trans
|
||||
* @example
|
||||
* "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note()
|
||||
* @example
|
||||
* "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note()
|
||||
*/
|
||||
|
||||
export const transpose = register('transpose', function (intervalOrSemitones, pat) {
|
||||
export const { transpose, trans } = register(['transpose', 'trans'], function transposeFn(intervalOrSemitones, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const note = hap.value.note ?? hap.value;
|
||||
if (typeof note === 'number') {
|
||||
|
|
@ -142,6 +143,7 @@ export const transpose = register('transpose', function (intervalOrSemitones, pa
|
|||
* @name scaleTranspose
|
||||
* @param {offset} offset number of steps inside the scale
|
||||
* @returns Pattern
|
||||
* @synonyms scaleTrans, strans
|
||||
* @example
|
||||
* "-8 [2,4,6]"
|
||||
* .scale('C4 bebop major')
|
||||
|
|
@ -149,22 +151,25 @@ export const transpose = register('transpose', function (intervalOrSemitones, pa
|
|||
* .note()
|
||||
*/
|
||||
|
||||
export const scaleTranspose = register('scaleTranspose', function (offset /* : number | string */, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
if (!hap.context.scale) {
|
||||
throw new Error('can only use scaleTranspose after .scale');
|
||||
}
|
||||
if (typeof hap.value === 'object')
|
||||
return hap.withValue(() => ({
|
||||
...hap.value,
|
||||
note: scaleOffset(hap.context.scale, Number(offset), hap.value.note),
|
||||
}));
|
||||
if (typeof hap.value !== 'string') {
|
||||
throw new Error('can only use scaleTranspose with notes');
|
||||
}
|
||||
return hap.withValue(() => scaleOffset(hap.context.scale, Number(offset), hap.value));
|
||||
});
|
||||
});
|
||||
export const { scaleTranspose, scaleTrans, strans } = register(
|
||||
['scaleTranspose', 'scaleTrans', 'strans'],
|
||||
function (offset /* : number | string */, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
if (!hap.context.scale) {
|
||||
throw new Error('can only use scaleTranspose after .scale');
|
||||
}
|
||||
if (typeof hap.value === 'object')
|
||||
return hap.withValue(() => ({
|
||||
...hap.value,
|
||||
note: scaleOffset(hap.context.scale, Number(offset), hap.value.note),
|
||||
}));
|
||||
if (typeof hap.value !== 'string') {
|
||||
throw new Error('can only use scaleTranspose with notes');
|
||||
}
|
||||
return hap.withValue(() => scaleOffset(hap.context.scale, Number(offset), hap.value));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Turns numbers into notes in the scale (zero indexed). Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/transpiler",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/web",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "Easy to setup, opiniated bundle of Strudel for the browser.",
|
||||
"module": "web.mjs",
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
export * from './webaudio.mjs';
|
||||
export * from './scope.mjs';
|
||||
export * from './spectrum.mjs';
|
||||
export * from './supradough.mjs';
|
||||
export * from 'superdough';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/webaudio",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.5",
|
||||
"description": "Web Audio helpers for Strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
@ -35,7 +35,8 @@
|
|||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"superdough": "workspace:*"
|
||||
"superdough": "workspace:*",
|
||||
"supradough": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
|
|
|
|||
130
packages/webaudio/supradough.mjs
Normal file
130
packages/webaudio/supradough.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Pattern } from '@strudel/core';
|
||||
import { connectToDestination, getAudioContext, getWorklet } from 'superdough';
|
||||
|
||||
let doughWorklet;
|
||||
|
||||
function initDoughWorklet() {
|
||||
const ac = getAudioContext();
|
||||
doughWorklet = getWorklet(
|
||||
ac,
|
||||
'dough-processor',
|
||||
{},
|
||||
{
|
||||
outputChannelCount: [2],
|
||||
},
|
||||
);
|
||||
connectToDestination(doughWorklet); // channels?
|
||||
}
|
||||
|
||||
const soundMap = new Map();
|
||||
const loadedSounds = new Map();
|
||||
|
||||
Pattern.prototype.supradough = function () {
|
||||
return this.onTrigger((hap, __, cps, begin) => {
|
||||
hap.value._begin = begin;
|
||||
hap.value._duration = hap.duration / cps;
|
||||
!doughWorklet && initDoughWorklet();
|
||||
const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s;
|
||||
const n = hap.value.n ?? 0;
|
||||
const soundKey = `${s}:${n}`;
|
||||
if (soundMap.has(s)) {
|
||||
hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers)
|
||||
}
|
||||
if (soundMap.has(s) && !loadedSounds.has(soundKey)) {
|
||||
const urls = soundMap.get(s);
|
||||
const url = urls[n % urls.length];
|
||||
console.log(`load ${soundKey} from ${url}`);
|
||||
const loadSample = fetchSample(url);
|
||||
loadedSounds.set(soundKey, loadSample);
|
||||
loadSample.then(({ channels, sampleRate }) =>
|
||||
doughWorklet.port.postMessage({
|
||||
sample: soundKey,
|
||||
channels,
|
||||
sampleRate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
doughWorklet.port.postMessage({ spawn: hap.value });
|
||||
}, 1);
|
||||
};
|
||||
|
||||
function githubPath(base, subpath = '') {
|
||||
if (!base.startsWith('github:')) {
|
||||
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||
}
|
||||
let [_, path] = base.split('github:');
|
||||
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}`;
|
||||
}
|
||||
export async function fetchSampleMap(url) {
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
if (url.startsWith('local:')) {
|
||||
url = `http://localhost:5432`;
|
||||
}
|
||||
if (url.startsWith('shabda:')) {
|
||||
let [_, path] = url.split('shabda:');
|
||||
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (url.startsWith('shabda/speech')) {
|
||||
let [_, path] = url.split('shabda/speech');
|
||||
path = path.startsWith('/') ? path.substring(1) : path;
|
||||
let [params, words] = path.split(':');
|
||||
let gender = 'f';
|
||||
let language = 'en-GB';
|
||||
if (params) {
|
||||
[language, gender] = params.split('/');
|
||||
}
|
||||
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = url.split('/').slice(0, -1).join('/');
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
}
|
||||
const json = await fetch(url)
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${url}"`);
|
||||
});
|
||||
return [json, json._base || base];
|
||||
}
|
||||
|
||||
// for some reason, only piano and flute work.. is it because mp3??
|
||||
|
||||
async function fetchSample(url) {
|
||||
const buffer = await fetch(url)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buf) => getAudioContext().decodeAudioData(buf));
|
||||
let channels = [];
|
||||
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||
channels.push(buffer.getChannelData(i));
|
||||
}
|
||||
return { channels, sampleRate: buffer.sampleRate };
|
||||
}
|
||||
|
||||
export async function doughsamples(sampleMap, baseUrl) {
|
||||
if (typeof sampleMap === 'string') {
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
// console.log('json', json, 'base', base);
|
||||
return doughsamples(json, base);
|
||||
}
|
||||
Object.entries(sampleMap).map(async ([key, urls]) => {
|
||||
if (key !== '_base') {
|
||||
urls = urls.map((url) => baseUrl + url);
|
||||
// console.log('set', key, urls);
|
||||
soundMap.set(key, urls);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
|
||||
import './supradough.mjs';
|
||||
import { workletUrl } from 'supradough';
|
||||
|
||||
registerWorklet(workletUrl);
|
||||
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
|
||||
setLogger(logger);
|
||||
|
|
@ -15,14 +20,10 @@ const hap2value = (hap) => {
|
|||
return hap.value;
|
||||
};
|
||||
|
||||
export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(hap), t - ct, hap.duration / cps, cps);
|
||||
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
|
||||
export const webaudioOutput = (hap, deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration, cps);
|
||||
};
|
||||
|
||||
Pattern.prototype.webaudio = function () {
|
||||
return this.onTrigger(webaudioOutputTrigger);
|
||||
// TODO: refactor output callbacks to eliminate deadline
|
||||
export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@strudel/xen",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.4",
|
||||
"description": "Xenharmonic API for strudel",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue