mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-23 05:33:13 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 912f755d03 |
@@ -1,37 +0,0 @@
|
|||||||
name: Build and Deploy to beta (warm.strudel.cc)
|
|
||||||
|
|
||||||
on: [workflow_dispatch]
|
|
||||||
|
|
||||||
# Allow one concurrent deployment
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: docker
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9.12.2
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
# cache: "pnpm"
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
run: |
|
|
||||||
eval $(ssh-agent -s)
|
|
||||||
echo "$SSH_PRIVATE_KEY" | ssh-add -
|
|
||||||
apt update && apt install -y rsync
|
|
||||||
mkdir ~/.ssh
|
|
||||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
|
||||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy to live (strudel.cc)
|
name: Build and Deploy
|
||||||
|
|
||||||
on: [workflow_dispatch]
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
@@ -34,4 +34,4 @@ jobs:
|
|||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
mkdir ~/.ssh
|
mkdir ~/.ssh
|
||||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Strudel tests
|
name: Strudel tests
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "pnpm"
|
cache: 'pnpm'
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
- run: pnpm run format-check
|
- run: pnpm run format-check
|
||||||
- run: pnpm run lint
|
- run: pnpm run lint
|
||||||
|
|||||||
-14
@@ -45,20 +45,6 @@ tidal-drum-machines
|
|||||||
webaudiofontdata
|
webaudiofontdata
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### BEGIN Visual Studio Code ###
|
|
||||||
# Blanket, recursive exclude for .vscode directory and files
|
|
||||||
.vscode/**/*
|
|
||||||
# Unexclude specific files and directories within .vscode
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
### END Visual Studio Code ###
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# BEGIN JetBrains -> END JetBrains
|
# BEGIN JetBrains -> END JetBrains
|
||||||
|
|
||||||
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ Important: Always publish with `pnpm`, as `npm` does not support overriding main
|
|||||||
|
|
||||||
|
|
||||||
## useful commands
|
## useful commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
#regenerate the test snapshots (ex: when updating or creating new pattern functions)
|
#regenerate the test snapshots (ex: when updating or creating new pattern functions)
|
||||||
pnpm snapshot
|
pnpm snapshot
|
||||||
@@ -161,81 +160,6 @@ pnpm run osc
|
|||||||
#build the standalone version
|
#build the standalone version
|
||||||
pnpm tauri build
|
pnpm tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
## version tag patching
|
|
||||||
|
|
||||||
here's a little guide on how to patch patterns in the database to prevent breaking old patterns due to breaking changes in newer versions.
|
|
||||||
|
|
||||||
the general tactic is to use `// @version x.y` to tag a pattern with a specific strudel version. when a pattern is evaluated, this metadata will de-activate any breaking changes that came after the specified version.
|
|
||||||
for example, in version 1.1, the default value for `fanchor` was changed from `0.5` to `0`.
|
|
||||||
if play a pattern that was made before that change, sounds that use filter evenlopes can sound very different, so by adding `// @version 1.0` will make it sound like it used to.
|
|
||||||
before releasing a new version with breaking changes, we can edit all patterns in the database, inserting the version tag they were created under:
|
|
||||||
|
|
||||||
as an example, to release version 1.2, do the following:
|
|
||||||
|
|
||||||
1. get date range
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# get date of last version:
|
|
||||||
git log -1 --format=%aI @strudel/core@1.1.0
|
|
||||||
# 2024-05-31T23:07:26+02:00
|
|
||||||
|
|
||||||
# get date of current version:
|
|
||||||
git log -1 --format=%aI @strudel/core@1.2.0
|
|
||||||
# 2025-05-01T12:39:24+02:00
|
|
||||||
# might also use todays timestamp if version is not yet released
|
|
||||||
```
|
|
||||||
|
|
||||||
now we know, all patterns between these 2 dates have to receive a version tag (unless they already have one).
|
|
||||||
|
|
||||||
2. get patterns in question
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT *
|
|
||||||
FROM code_v1
|
|
||||||
WHERE code NOT LIKE '%@version%'
|
|
||||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
|
||||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
|
||||||
ORDER BY created_at ASC;
|
|
||||||
```
|
|
||||||
|
|
||||||
this gives us all unversioned patterns that were saved between 1.1.0 and 1.2.0. in this case, it's 9373 patterns!
|
|
||||||
|
|
||||||
3. insert version tags
|
|
||||||
|
|
||||||
we are now ready to insert the version tag to these patterns.
|
|
||||||
before updating thousands of patterns, it's probably a good idea to test if a single one gets udpated:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE code_v1
|
|
||||||
SET code = code || E'\n// @version 1.1'
|
|
||||||
WHERE hash = 'Ns2sMB40yIw4';
|
|
||||||
```
|
|
||||||
|
|
||||||
after [verifying](https://strudel.cc/?Ns2sMB40yIw4) that the version tag has been added, let's insert it everywhere:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE code_v1
|
|
||||||
SET code = code || E'\n// @version 1.1'
|
|
||||||
WHERE code NOT LIKE '%@version%'
|
|
||||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
|
||||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
|
||||||
```
|
|
||||||
|
|
||||||
4. verify
|
|
||||||
|
|
||||||
we can verify that the edits worked by querying all patterns that contain the new version tag:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT *
|
|
||||||
FROM code_v1
|
|
||||||
WHERE code LIKE '%@version 1.1%'
|
|
||||||
AND created_at > '2024-05-31T23:07:26+02:00'
|
|
||||||
AND created_at < '2025-05-01T12:39:24+02:00'
|
|
||||||
ORDER BY created_at ASC;
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Have Fun
|
## Have Fun
|
||||||
|
|
||||||
Remember to have fun, and that this project is driven by the passion of volunteers!
|
Remember to have fun, and that this project is driven by the passion of volunteers!
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
FROM node:24
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN npm install pnpm --global
|
|
||||||
|
|
||||||
COPY pnpm-workspace.yaml ./
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
COPY examples/ ./examples/
|
|
||||||
RUN mkdir -p website/public
|
|
||||||
COPY website/package.json ./website/
|
|
||||||
|
|
||||||
RUN pnpm install
|
|
||||||
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 4321
|
|
||||||
|
|
||||||
CMD ["pnpm", "dev"]
|
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
Live coding patterns on the web
|
Live coding patterns on the web
|
||||||
https://strudel.cc/
|
https://strudel.cc/
|
||||||
|
|
||||||
|
Development is moving to https://codeberg.org/uzu/strudel
|
||||||
|
|
||||||
- Try it here: <https://strudel.cc>
|
- Try it here: <https://strudel.cc>
|
||||||
- Docs: <https://strudel.cc/learn>
|
- Docs: <https://strudel.cc/learn>
|
||||||
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
|
- Technical Blog Post: <https://loophole-letters.vercel.app/strudel>
|
||||||
@@ -13,7 +15,7 @@ https://strudel.cc/
|
|||||||
|
|
||||||
After cloning the project, you can run the REPL locally:
|
After cloning the project, you can run the REPL locally:
|
||||||
|
|
||||||
1. Install [Node.js](https://nodejs.org/) 18 or newer
|
1. Install [Node.js](https://nodejs.org/)
|
||||||
2. Install [pnpm](https://pnpm.io/installation)
|
2. Install [pnpm](https://pnpm.io/installation)
|
||||||
3. Install dependencies by running the following command:
|
3. Install dependencies by running the following command:
|
||||||
```bash
|
```bash
|
||||||
@@ -36,7 +38,13 @@ Licensing info for the default sound banks can be found over on the [dough-sampl
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
There are many ways to contribute to this project! See [contribution guide](./CONTRIBUTING.md). You can find the full list of contributors [here](https://codeberg.org/uzu/strudel/activity/contributors).
|
There are many ways to contribute to this project! See [contribution guide](./CONTRIBUTING.md).
|
||||||
|
|
||||||
|
<a href="https://codeberg.org/uzu/strudel/activity/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=tidalcycles/strudel" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
@@ -45,5 +53,3 @@ There is a #strudel channel on the TidalCycles discord: <https://discord.com/inv
|
|||||||
You can also ask questions and find related discussions on the tidal club forum: <https://club.tidalcycles.org/>
|
You can also ask questions and find related discussions on the tidal club forum: <https://club.tidalcycles.org/>
|
||||||
|
|
||||||
The discord and forum is shared with the haskell (tidal) and python (vortex) siblings of this project.
|
The discord and forum is shared with the haskell (tidal) and python (vortex) siblings of this project.
|
||||||
|
|
||||||
We also have a mastodon account: <a rel="me" href="https://social.toplap.org/@strudel">social.toplap.org/@strudel</a>
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export default [
|
|||||||
'**/hydra.mjs',
|
'**/hydra.mjs',
|
||||||
'**/jsdoc-synonyms.js',
|
'**/jsdoc-synonyms.js',
|
||||||
'packages/hs2js/src/hs2js.mjs',
|
'packages/hs2js/src/hs2js.mjs',
|
||||||
'packages/supradough/dough-export.mjs',
|
|
||||||
'**/samples',
|
'**/samples',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -84,14 +83,4 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Properties provided by AudioWorkletGlobalScope
|
|
||||||
files: ['packages/superdough/worklets.mjs'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
currentTime: 'readonly',
|
|
||||||
sampleRate: 'readonly',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,8 +20,5 @@
|
|||||||
"@strudel/tonal": "workspace:*",
|
"@strudel/tonal": "workspace:*",
|
||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
"@strudel/webaudio": "workspace:*"
|
"@strudel/webaudio": "workspace:*"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,5 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strudel/web": "workspace:*"
|
"@strudel/web": "workspace:*"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,5 @@
|
|||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
"@strudel/webaudio": "workspace:*",
|
"@strudel/webaudio": "workspace:*",
|
||||||
"@strudel/tonal": "workspace:*"
|
"@strudel/tonal": "workspace:*"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
jsdoc-synonyms.js - Add support for @synonyms tag
|
jsdoc-synonyms.js - Add support for @synonym tag
|
||||||
Copyright (C) 2023 Strudel contributors - see <https://codeberg.org/uzu/strudel/activity/contributors>
|
Copyright (C) 2023 Strudel contributors - see <https://codeberg.org/uzu/strudel/activity/contributors>
|
||||||
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/>.
|
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/>.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,8 +73,5 @@
|
|||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"vitest": "^3.0.4",
|
"vitest": "^3.0.4",
|
||||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,464 +1,84 @@
|
|||||||
import jsdoc from '../../doc.json';
|
import jsdoc from '../../doc.json';
|
||||||
|
// import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||||
import { autocompletion } from '@codemirror/autocomplete';
|
import { autocompletion } from '@codemirror/autocomplete';
|
||||||
import { h } from './html';
|
import { h } from './html';
|
||||||
//TODO: fix tonal scale import
|
|
||||||
// import { Scale } from '@tonaljs/tonal';
|
|
||||||
// import { soundMap } from '@strudel/webaudio';
|
|
||||||
let soundMap = undefined;
|
|
||||||
import { complex } from '@strudel/tonal';
|
|
||||||
|
|
||||||
const escapeHtml = (str) => {
|
function plaintext(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerText = str;
|
div.innerText = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
}
|
||||||
|
|
||||||
const stripHtml = (html) => {
|
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||||
const div = document.createElement('div');
|
const getInnerText = (html) => {
|
||||||
|
var div = document.createElement('div');
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
return div.textContent || div.innerText || '';
|
return div.textContent || div.innerText || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
export function Autocomplete({ doc, label }) {
|
||||||
|
return h`<div class="prose dark:prose-invert max-h-[400px] overflow-auto p-2">
|
||||||
const buildParamsList = (params) =>
|
<h1 class="pt-0 mt-0">${label || getDocLabel(doc)}</h1>
|
||||||
params?.length
|
${doc.description}
|
||||||
? `
|
<ul>
|
||||||
<div class="autocomplete-info-params-section">
|
${doc.params?.map(
|
||||||
<h4 class="autocomplete-info-section-title">Parameters</h4>
|
({ name, type, description }) =>
|
||||||
<ul class="autocomplete-info-params-list">
|
`<li>${name} : ${type.names?.join(' | ')} ${description ? ` - ${getInnerText(description)}` : ''}</li>`,
|
||||||
${params
|
)}
|
||||||
.map(
|
</ul>
|
||||||
({ name, type, description }) => `
|
<div>
|
||||||
<li class="autocomplete-info-param-item">
|
${doc.examples?.map((example) => `<div><pre>${plaintext(example)}</pre></div>`)}
|
||||||
<span class="autocomplete-info-param-name">${name}</span>
|
</div>
|
||||||
<span class="autocomplete-info-param-type">${type.names?.join(' | ')}</span>
|
</div>`[0];
|
||||||
${description ? `<div class="autocomplete-info-param-desc">${stripHtml(description)}</div>` : ''}
|
/*
|
||||||
</li>
|
<pre
|
||||||
`,
|
className="cursor-pointer"
|
||||||
)
|
onMouseDown={(e) => {
|
||||||
.join('')}
|
console.log('ola!');
|
||||||
</ul>
|
navigator.clipboard.writeText(example);
|
||||||
</div>
|
e.stopPropagation();
|
||||||
`
|
}}
|
||||||
: '';
|
>
|
||||||
|
{example}
|
||||||
const buildExamples = (examples) =>
|
</pre>
|
||||||
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) =>
|
|
||||||
h`
|
|
||||||
<div class="autocomplete-info-container">
|
|
||||||
<div class="autocomplete-info-tooltip">
|
|
||||||
<h3 class="autocomplete-info-function-name">${getDocLabel(doc)}</h3>
|
|
||||||
${doc.synonyms_text ? `<div class="autocomplete-info-function-synonyms">Synonyms: ${doc.synonyms_text}</div>` : ''}
|
|
||||||
${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));
|
|
||||||
|
|
||||||
export function bankCompletions() {
|
|
||||||
// TODO: FIX IMPORT
|
|
||||||
const soundDict = soundMap?.get() ?? {};
|
|
||||||
|
|
||||||
const banks = new Set();
|
|
||||||
for (const key of Object.keys(soundDict)) {
|
|
||||||
const [bank, suffix] = key.split('_');
|
|
||||||
if (suffix && bank) banks.add(bank);
|
|
||||||
}
|
|
||||||
return Array.from(banks)
|
|
||||||
.sort()
|
|
||||||
.map((name) => ({ label: name, type: 'bank' }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
const jsdocCompletions = jsdoc.docs
|
||||||
let scaleCompletions = [];
|
.filter(
|
||||||
// try {
|
(doc) =>
|
||||||
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
getDocLabel(doc) &&
|
||||||
// } catch (e) {
|
!getDocLabel(doc).startsWith('_') &&
|
||||||
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
!['package'].includes(doc.kind) &&
|
||||||
// }
|
!['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)),
|
||||||
|
)
|
||||||
|
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
||||||
|
.map((doc) /*: Completion */ => ({
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
|
||||||
// Valid mode values for voicing
|
export const strudelAutocomplete = (context /* : CompletionContext */) => {
|
||||||
const modeCompletions = [
|
let word = context.matchBefore(/\w*/);
|
||||||
{ label: 'below', type: 'mode' },
|
if (word.from == word.to && !context.explicit) return null;
|
||||||
{ label: 'above', type: 'mode' },
|
|
||||||
{ label: 'duck', type: 'mode' },
|
|
||||||
{ label: 'root', type: 'mode' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Valid chord symbols from ireal dictionary plus empty string for major triads
|
|
||||||
const chordSymbols = ['', ...Object.keys(complex)].sort();
|
|
||||||
const chordSymbolCompletions = chordSymbols.map((symbol) => {
|
|
||||||
if (symbol === '') {
|
|
||||||
return {
|
|
||||||
label: 'major',
|
|
||||||
apply: '',
|
|
||||||
type: 'chord-symbol',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
label: symbol,
|
from: word.from,
|
||||||
apply: symbol,
|
options: jsdocCompletions,
|
||||||
type: 'chord-symbol',
|
/* options: [
|
||||||
};
|
{ label: 'match', type: 'keyword' },
|
||||||
});
|
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||||
|
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||||
export const getSynonymDoc = (doc, synonym) => {
|
], */
|
||||||
const synonyms = doc.synonyms || [];
|
|
||||||
const docLabel = getDocLabel(doc);
|
|
||||||
// Swap `doc.name` in for `s` in the list of synonyms
|
|
||||||
const synonymsWithDoc = [docLabel, ...synonyms].filter((x) => x && x !== synonym);
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
name: synonym,
|
|
||||||
longname: synonym,
|
|
||||||
synonyms: synonymsWithDoc,
|
|
||||||
synonyms_text: synonymsWithDoc.join(', '),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsdocCompletions = (() => {
|
export function isAutoCompletionEnabled(on) {
|
||||||
const seen = new Set(); // avoid repetition
|
return on
|
||||||
const completions = [];
|
? [
|
||||||
for (const doc of jsdoc.docs) {
|
autocompletion({ override: [strudelAutocomplete] }),
|
||||||
if (!isValidDoc(doc) || hasExcludedTags(doc)) continue;
|
//javascriptLanguage.data.of({ autocomplete: strudelAutocomplete }),
|
||||||
const docLabel = getDocLabel(doc);
|
]
|
||||||
// Remove duplicates
|
: []; // autocompletion({ override: [] })
|
||||||
const synonyms = doc.synonyms || [];
|
|
||||||
let labels = [docLabel, ...synonyms];
|
|
||||||
for (const label of labels) {
|
|
||||||
// https://codemirror.net/docs/ref/#autocomplete.Completion
|
|
||||||
if (label && !seen.has(label)) {
|
|
||||||
seen.add(label);
|
|
||||||
completions.push({
|
|
||||||
label,
|
|
||||||
info: () => Autocomplete(getSynonymDoc(doc, label)),
|
|
||||||
type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return completions;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Handler functions for each context ---
|
|
||||||
const pitchNames = [
|
|
||||||
'C',
|
|
||||||
'C#',
|
|
||||||
'Db',
|
|
||||||
'D',
|
|
||||||
'D#',
|
|
||||||
'Eb',
|
|
||||||
'E',
|
|
||||||
'E#',
|
|
||||||
'Fb',
|
|
||||||
'F',
|
|
||||||
'F#',
|
|
||||||
'Gb',
|
|
||||||
'G',
|
|
||||||
'G#',
|
|
||||||
'Ab',
|
|
||||||
'A',
|
|
||||||
'A#',
|
|
||||||
'Bb',
|
|
||||||
'B',
|
|
||||||
'B#',
|
|
||||||
'Cb',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Cached regex patterns for scaleHandler
|
|
||||||
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
|
|
||||||
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
|
|
||||||
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
|
|
||||||
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
|
|
||||||
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
|
|
||||||
|
|
||||||
function scaleHandler(context) {
|
|
||||||
// First check for scale context without quotes - block with empty completions
|
|
||||||
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
|
|
||||||
if (scaleNoQuotesContext) {
|
|
||||||
return {
|
|
||||||
from: scaleNoQuotesContext.to,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for after-colon context first (more specific)
|
|
||||||
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
|
|
||||||
if (scaleAfterColonContext) {
|
|
||||||
const text = scaleAfterColonContext.text;
|
|
||||||
const colonIdx = text.lastIndexOf(':');
|
|
||||||
if (colonIdx !== -1) {
|
|
||||||
const fragment = text.slice(colonIdx + 1);
|
|
||||||
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
|
|
||||||
const options = filteredScales.map((s) => ({
|
|
||||||
...s,
|
|
||||||
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
|
|
||||||
}));
|
|
||||||
const from = scaleAfterColonContext.from + colonIdx + 1;
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for pre-colon context
|
|
||||||
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
|
|
||||||
if (scalePreColonContext) {
|
|
||||||
if (!scalePreColonContext.text.includes(':')) {
|
|
||||||
if (context.explicit) {
|
|
||||||
const text = scalePreColonContext.text;
|
|
||||||
const match = text.match(SCALE_PITCH_MATCH_REGEX);
|
|
||||||
const fragment = match ? match[0] : '';
|
|
||||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
|
||||||
const from = scalePreColonContext.to - fragment.length;
|
|
||||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
|
||||||
return { from, options };
|
|
||||||
} else {
|
|
||||||
return { from: scalePreColonContext.to, options: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached regex patterns for soundHandler
|
|
||||||
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
|
|
||||||
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
|
|
||||||
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
|
|
||||||
|
|
||||||
function soundHandler(context) {
|
|
||||||
// First check for sound context without quotes - block with empty completions
|
|
||||||
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
|
|
||||||
if (soundNoQuotesContext) {
|
|
||||||
return {
|
|
||||||
from: soundNoQuotesContext.to,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for sound context with quotes - provide completions
|
|
||||||
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
|
|
||||||
if (!soundContext) return null;
|
|
||||||
|
|
||||||
const text = soundContext.text;
|
|
||||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
|
||||||
if (quoteIdx === -1) return null;
|
|
||||||
const inside = text.slice(quoteIdx + 1);
|
|
||||||
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
|
||||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
|
||||||
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
|
|
||||||
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
|
||||||
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
|
||||||
const from = soundContext.to - fragment.length;
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached regex patterns for bankHandler
|
|
||||||
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
|
|
||||||
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
|
|
||||||
|
|
||||||
function bankHandler(context) {
|
|
||||||
// First check for bank context without quotes - block with empty completions
|
|
||||||
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
|
|
||||||
if (bankNoQuotesContext) {
|
|
||||||
return {
|
|
||||||
from: bankNoQuotesContext.to,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for bank context with quotes - provide completions
|
|
||||||
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
|
|
||||||
if (!bankMatch) return null;
|
|
||||||
|
|
||||||
const text = bankMatch.text;
|
|
||||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
|
||||||
if (quoteIdx === -1) return null;
|
|
||||||
const inside = text.slice(quoteIdx + 1);
|
|
||||||
const fragment = inside;
|
|
||||||
let banks = bankCompletions();
|
|
||||||
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
|
|
||||||
const from = bankMatch.to - fragment.length;
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
options: filteredBanks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached regex patterns for modeHandler
|
|
||||||
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
|
|
||||||
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
|
|
||||||
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
|
|
||||||
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
|
|
||||||
|
|
||||||
function modeHandler(context) {
|
|
||||||
// First check for mode context without quotes - block with empty completions
|
|
||||||
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
|
|
||||||
if (modeNoQuotesContext) {
|
|
||||||
return {
|
|
||||||
from: modeNoQuotesContext.to,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for after-colon context first (more specific)
|
|
||||||
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
|
|
||||||
if (modeAfterColonContext) {
|
|
||||||
const text = modeAfterColonContext.text;
|
|
||||||
const colonIdx = text.lastIndexOf(':');
|
|
||||||
if (colonIdx !== -1) {
|
|
||||||
const fragment = text.slice(colonIdx + 1);
|
|
||||||
// For anchor after colon, we can suggest pitch names
|
|
||||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
|
||||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
|
||||||
const from = modeAfterColonContext.from + colonIdx + 1;
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for pre-colon context
|
|
||||||
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
|
|
||||||
if (!modeContext) return null;
|
|
||||||
|
|
||||||
const text = modeContext.text;
|
|
||||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
|
||||||
if (quoteIdx === -1) return null;
|
|
||||||
const inside = text.slice(quoteIdx + 1);
|
|
||||||
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
|
|
||||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
|
||||||
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
|
|
||||||
const from = modeContext.to - fragment.length;
|
|
||||||
return {
|
|
||||||
from,
|
|
||||||
options: filteredModes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached regex patterns for chordHandler
|
|
||||||
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
|
|
||||||
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
|
|
||||||
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
|
|
||||||
|
|
||||||
function chordHandler(context) {
|
|
||||||
// First check for chord context without quotes - block with empty completions
|
|
||||||
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
|
|
||||||
if (chordNoQuotesContext) {
|
|
||||||
return {
|
|
||||||
from: chordNoQuotesContext.to,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for chord context with quotes - provide completions
|
|
||||||
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
|
|
||||||
if (!chordContext) return null;
|
|
||||||
|
|
||||||
const text = chordContext.text;
|
|
||||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
|
||||||
if (quoteIdx === -1) return null;
|
|
||||||
const inside = text.slice(quoteIdx + 1);
|
|
||||||
|
|
||||||
// Use same fragment matching as sound/mode for expressions like "<G Am>"
|
|
||||||
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
|
|
||||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
|
||||||
|
|
||||||
// Check if fragment contains any pitch name at start (for root + symbol)
|
|
||||||
let rootMatch = null;
|
|
||||||
let symbolFragment = fragment;
|
|
||||||
for (const pitch of pitchNames) {
|
|
||||||
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
|
|
||||||
rootMatch = pitch;
|
|
||||||
symbolFragment = fragment.slice(pitch.length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rootMatch) {
|
|
||||||
// We have a root, now complete chord symbols
|
|
||||||
const filteredSymbols = chordSymbolCompletions.filter((s) =>
|
|
||||||
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create completions that replace the entire chord, not just the symbol part
|
|
||||||
const options = filteredSymbols;
|
|
||||||
|
|
||||||
const from = chordContext.to - symbolFragment.length;
|
|
||||||
return { from, options };
|
|
||||||
} else {
|
|
||||||
// No root yet, complete with pitch names
|
|
||||||
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
|
||||||
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
|
|
||||||
const from = chordContext.to - fragment.length;
|
|
||||||
return { from, options };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached regex patterns for fallbackHandler
|
|
||||||
const FALLBACK_WORD_REGEX = /\w*/;
|
|
||||||
|
|
||||||
function fallbackHandler(context) {
|
|
||||||
const word = context.matchBefore(FALLBACK_WORD_REGEX);
|
|
||||||
if (word && word.from === word.to && !context.explicit) return null;
|
|
||||||
if (word) {
|
|
||||||
return {
|
|
||||||
from: word.from,
|
|
||||||
options: jsdocCompletions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = [
|
|
||||||
soundHandler,
|
|
||||||
bankHandler,
|
|
||||||
chordHandler,
|
|
||||||
scaleHandler,
|
|
||||||
modeHandler,
|
|
||||||
// this handler *must* be last
|
|
||||||
fallbackHandler,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const strudelAutocomplete = (context) => {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
const result = handler(context);
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isAutoCompletionEnabled = (enabled) =>
|
|
||||||
enabled ? [autocompletion({ override: [strudelAutocomplete], closeOnBlur: false })] : [];
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
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,31 +1,29 @@
|
|||||||
import { closeBrackets } from '@codemirror/autocomplete';
|
import { closeBrackets } from '@codemirror/autocomplete';
|
||||||
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
|
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
|
||||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
// import { search, highlightSelectionMatches } from '@codemirror/search';
|
||||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { history } from '@codemirror/commands';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
|
||||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
drawSelection,
|
|
||||||
EditorView,
|
EditorView,
|
||||||
highlightActiveLine,
|
|
||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
|
highlightActiveLine,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
|
drawSelection,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { persistentAtom } from '@nanostores/persistent';
|
import { repl, registerControl } from '@strudel/core';
|
||||||
import { logger, registerControl, repl } from '@strudel/core';
|
import { Drawer, cleanupDraw } from '@strudel/draw';
|
||||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
|
||||||
|
|
||||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||||
import { basicSetup } from './basicSetup.mjs';
|
import { isTooltipEnabled } from './tooltip.mjs';
|
||||||
import { flash, isFlashEnabled } from './flash.mjs';
|
import { flash, isFlashEnabled } from './flash.mjs';
|
||||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||||
import { keybindings } from './keybindings.mjs';
|
import { keybindings } from './keybindings.mjs';
|
||||||
|
import { initTheme, activateTheme, theme } from './themes.mjs';
|
||||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
import { widgetPlugin, updateWidgets } from './widget.mjs';
|
||||||
import { isTooltipEnabled } from './tooltip.mjs';
|
import { persistentAtom } from '@nanostores/persistent';
|
||||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
|
||||||
|
|
||||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
|
||||||
|
|
||||||
const extensions = {
|
const extensions = {
|
||||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||||
@@ -39,14 +37,6 @@ const extensions = {
|
|||||||
isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
|
isActiveLineHighlighted: (on) => (on ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
|
||||||
isFlashEnabled,
|
isFlashEnabled,
|
||||||
keybindings,
|
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()]));
|
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
|
||||||
|
|
||||||
@@ -61,8 +51,6 @@ export const defaultSettings = {
|
|||||||
isFlashEnabled: true,
|
isFlashEnabled: true,
|
||||||
isTooltipEnabled: false,
|
isTooltipEnabled: false,
|
||||||
isLineWrappingEnabled: false,
|
isLineWrappingEnabled: false,
|
||||||
isTabIndentationEnabled: false,
|
|
||||||
isMultiCursorEnabled: false,
|
|
||||||
theme: 'strudelTheme',
|
theme: 'strudelTheme',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -87,17 +75,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
|||||||
/* search(),
|
/* search(),
|
||||||
highlightSelectionMatches(), */
|
highlightSelectionMatches(), */
|
||||||
...initialSettings,
|
...initialSettings,
|
||||||
basicSetup,
|
|
||||||
mondo ? [] : javascript(),
|
mondo ? [] : javascript(),
|
||||||
javascriptLanguage.data.of({
|
|
||||||
closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
|
||||||
bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] },
|
|
||||||
}),
|
|
||||||
sliderPlugin,
|
sliderPlugin,
|
||||||
widgetPlugin,
|
widgetPlugin,
|
||||||
// indentOnInput(), // works without. already brought with javascript
|
// indentOnInput(), // works without. already brought with javascript extension?
|
||||||
// extension? bracketMatching(), // does not do anything
|
// bracketMatching(), // does not do anything
|
||||||
syntaxHighlighting(defaultHighlightStyle),
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
history(),
|
||||||
EditorView.updateListener.of((v) => onChange(v)),
|
EditorView.updateListener.of((v) => onChange(v)),
|
||||||
drawSelection({ cursorBlinkRate: 0 }),
|
drawSelection({ cursorBlinkRate: 0 }),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
@@ -120,13 +104,13 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
|||||||
run: () => onStop?.(),
|
run: () => onStop?.(),
|
||||||
},
|
},
|
||||||
/* {
|
/* {
|
||||||
key: 'Ctrl-Shift-.',
|
key: 'Ctrl-Shift-.',
|
||||||
run: () => (onPanic ? onPanic() : onStop?.()),
|
run: () => (onPanic ? onPanic() : onStop?.()),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Ctrl-Shift-Enter',
|
key: 'Ctrl-Shift-Enter',
|
||||||
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
|
run: () => (onReEvaluate ? onReEvaluate() : onEvaluate?.()),
|
||||||
}, */
|
}, */
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -207,8 +191,7 @@ export class StrudelMirror {
|
|||||||
updateWidgets(this.editor, widgets);
|
updateWidgets(this.editor, widgets);
|
||||||
updateMiniLocations(this.editor, this.miniLocations);
|
updateMiniLocations(this.editor, this.miniLocations);
|
||||||
replOptions?.afterEval?.(options);
|
replOptions?.afterEval?.(options);
|
||||||
// if no painters are set (.onPaint was not called), then we only need
|
// if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting)
|
||||||
// the present moment (for highlighting)
|
|
||||||
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
|
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
|
||||||
this.drawer.setDrawTime(drawTime);
|
this.drawer.setDrawTime(drawTime);
|
||||||
// invalidate drawer after we've set the appropriate drawTime
|
// invalidate drawer after we've set the appropriate drawTime
|
||||||
@@ -247,33 +230,6 @@ export class StrudelMirror {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('start-repl', this.onStartRepl);
|
document.addEventListener('start-repl', this.onStartRepl);
|
||||||
|
|
||||||
// Handle global evaluation requests (e.g., from Vim :w)
|
|
||||||
this.onEvaluateRequest = (e) => {
|
|
||||||
try {
|
|
||||||
// Evaluate current editor on repl-evaluate
|
|
||||||
logger('[repl] evaluate via event');
|
|
||||||
this.evaluate();
|
|
||||||
e?.cancelable && e.preventDefault?.();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error handling repl-evaluate event', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
|
||||||
document.addEventListener('repl-stop', this.onStopRequest);
|
|
||||||
|
|
||||||
// Toggle comments requested from Vim (gc)
|
|
||||||
this.onToggleComment = (e) => {
|
|
||||||
try {
|
|
||||||
// Honor selections; toggleLineComment handles both selections and
|
|
||||||
// single line
|
|
||||||
toggleLineComment(this.editor);
|
|
||||||
e?.cancelable && e.preventDefault?.();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error handling repl-toggle-comment event', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
|
||||||
}
|
}
|
||||||
draw(haps, time, painters) {
|
draw(haps, time, painters) {
|
||||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||||
@@ -300,16 +256,6 @@ export class StrudelMirror {
|
|||||||
async stop() {
|
async stop() {
|
||||||
this.repl.scheduler.stop();
|
this.repl.scheduler.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for global stop requests (e.g., from Vim :q)
|
|
||||||
onStopRequest = (e) => {
|
|
||||||
try {
|
|
||||||
this.stop();
|
|
||||||
e?.cancelable && e.preventDefault?.();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error handling repl-stop event', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
async toggle() {
|
async toggle() {
|
||||||
if (this.repl.scheduler.started) {
|
if (this.repl.scheduler.started) {
|
||||||
this.repl.stop();
|
this.repl.stop();
|
||||||
@@ -385,18 +331,11 @@ export class StrudelMirror {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCode(code) {
|
setCode(code) {
|
||||||
const changes = {
|
const changes = { from: 0, to: this.editor.state.doc.length, insert: code };
|
||||||
from: 0,
|
|
||||||
to: this.editor.state.doc.length,
|
|
||||||
insert: code,
|
|
||||||
};
|
|
||||||
this.editor.dispatch({ changes });
|
this.editor.dispatch({ changes });
|
||||||
}
|
}
|
||||||
clear() {
|
clear() {
|
||||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
|
||||||
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
|
|
||||||
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
|
|
||||||
}
|
}
|
||||||
getCursorLocation() {
|
getCursorLocation() {
|
||||||
return this.editor.state.selection.main.head;
|
return this.editor.state.selection.main.head;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
const parser = typeof DOMParser !== 'undefined' ? new DOMParser() : null;
|
||||||
export let html = (string) => {
|
export let html = (string) => {
|
||||||
const template = document.createElement('template');
|
return parser?.parseFromString(string, 'text/html').querySelectorAll('*');
|
||||||
template.innerHTML = string.trim();
|
|
||||||
return template.content.childNodes;
|
|
||||||
};
|
};
|
||||||
let parseChunk = (chunk) => {
|
let parseChunk = (chunk) => {
|
||||||
if (Array.isArray(chunk)) return chunk.flat().join('');
|
if (Array.isArray(chunk)) return chunk.flat().join('');
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { defaultKeymap } from '@codemirror/commands';
|
|
||||||
import { Prec } from '@codemirror/state';
|
import { Prec } from '@codemirror/state';
|
||||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||||
// import { searchKeymap } from '@codemirror/search';
|
// import { searchKeymap } from '@codemirror/search';
|
||||||
import { emacs } from '@replit/codemirror-emacs';
|
import { emacs } from '@replit/codemirror-emacs';
|
||||||
import { vim, Vim } from '@replit/codemirror-vim';
|
import { vim } from '@replit/codemirror-vim';
|
||||||
// import { vim } from './vim_test.mjs';
|
|
||||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||||
import { logger } from '@strudel/core';
|
import { defaultKeymap, historyKeymap } from '@codemirror/commands';
|
||||||
|
|
||||||
const vscodePlugin = ViewPlugin.fromClass(
|
const vscodePlugin = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
@@ -20,118 +18,14 @@ const vscodePlugin = ViewPlugin.fromClass(
|
|||||||
);
|
);
|
||||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||||
|
|
||||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
|
||||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
|
||||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
|
||||||
// appears in the Console panel.
|
|
||||||
try {
|
|
||||||
if (Vim && typeof Vim.defineEx === 'function') {
|
|
||||||
// Map gc to toggle line comments by dispatching a custom event that our
|
|
||||||
// CodeMirror integration listens to. This avoids depending on Vim's
|
|
||||||
// internal actions and works with current selections/visual mode.
|
|
||||||
try {
|
|
||||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
|
||||||
const view = cm?.view || cm;
|
|
||||||
try {
|
|
||||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
|
||||||
document.dispatchEvent(ev);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('strudelToggleComment dispatch failed', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
|
|
||||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Vim gc mapping failed', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// :q to pause/stop
|
|
||||||
Vim.defineEx('quit', 'q', (cm) => {
|
|
||||||
try {
|
|
||||||
const view = cm?.view || cm;
|
|
||||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
|
||||||
let handled = false;
|
|
||||||
try {
|
|
||||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
|
||||||
handled = document.dispatchEvent(ev) === false;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error dispatching repl-stop event', e);
|
|
||||||
}
|
|
||||||
if (!handled) {
|
|
||||||
const altDot = new KeyboardEvent('keydown', {
|
|
||||||
key: '.',
|
|
||||||
code: 'Period',
|
|
||||||
altKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
view?.dom?.dispatchEvent?.(altDot);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error dispatching :q stop event', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// :w to evaluate
|
|
||||||
Vim.defineEx('write', 'w', (cm) => {
|
|
||||||
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
|
|
||||||
try {
|
|
||||||
view?.focus?.();
|
|
||||||
// Let the app know this came from Vim :w
|
|
||||||
try {
|
|
||||||
logger('[vim] :w — evaluating code');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error logging Vim :w evaluation', e);
|
|
||||||
}
|
|
||||||
// Dispatch a dedicated evaluate event first
|
|
||||||
let handled = false;
|
|
||||||
try {
|
|
||||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
|
||||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error dispatching repl-evaluate event', e);
|
|
||||||
}
|
|
||||||
if (handled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Try Ctrl+Enter first if not handled by custom event
|
|
||||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
|
||||||
key: 'Enter',
|
|
||||||
code: 'Enter',
|
|
||||||
ctrlKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
|
||||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
|
||||||
// fallback
|
|
||||||
if (!ctrlEnter.defaultPrevented) {
|
|
||||||
const altEnter = new KeyboardEvent('keydown', {
|
|
||||||
key: 'Enter',
|
|
||||||
code: 'Enter',
|
|
||||||
altKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
view?.dom?.dispatchEvent?.(altEnter);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error dispatching :w evaluation event', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keymaps = {
|
const keymaps = {
|
||||||
vim,
|
vim,
|
||||||
emacs,
|
emacs,
|
||||||
codemirror: () => keymap.of(defaultKeymap),
|
|
||||||
vscode: vscodeExtension,
|
vscode: vscodeExtension,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function keybindings(name) {
|
export function keybindings(name) {
|
||||||
const active = keymaps[name];
|
const active = keymaps[name];
|
||||||
return [active ? Prec.high(active()) : []];
|
return [keymap.of(defaultKeymap), keymap.of(historyKeymap), active ? active() : []];
|
||||||
|
// keymap.of(searchKeymap),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/codemirror",
|
"name": "@strudel/codemirror",
|
||||||
"version": "1.2.6",
|
"version": "1.2.2",
|
||||||
"description": "Codemirror Extensions for Strudel",
|
"description": "Codemirror Extensions for Strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -42,20 +42,14 @@
|
|||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@nanostores/persistent": "^0.10.2",
|
"@nanostores/persistent": "^0.10.2",
|
||||||
"@replit/codemirror-emacs": "^6.1.0",
|
"@replit/codemirror-emacs": "^6.1.0",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
"@strudel/draw": "workspace:*",
|
"@strudel/draw": "workspace:*",
|
||||||
"@strudel/tonal": "workspace:*",
|
|
||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
"@tonaljs/tonal": "^4.10.0",
|
"nanostores": "^0.11.3"
|
||||||
"nanostores": "^0.11.3",
|
|
||||||
"superdough": "workspace:*"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { hoverTooltip } from '@codemirror/view';
|
import { hoverTooltip } from '@codemirror/view';
|
||||||
import jsdoc from '../../doc.json';
|
import jsdoc from '../../doc.json';
|
||||||
import { Autocomplete, getSynonymDoc } from './autocomplete.mjs';
|
import { Autocomplete } from './autocomplete.mjs';
|
||||||
|
|
||||||
const getDocLabel = (doc) => doc.name || doc.longname;
|
const getDocLabel = (doc) => doc.name || doc.longname;
|
||||||
|
|
||||||
@@ -52,11 +52,10 @@ export const strudelTooltip = hoverTooltip(
|
|||||||
let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0];
|
let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
// Try for synonyms
|
// Try for synonyms
|
||||||
const doc = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0];
|
entry = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0];
|
||||||
if (!doc) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
entry = getSynonymDoc(doc, word);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,7 +66,7 @@ export const strudelTooltip = hoverTooltip(
|
|||||||
create(view) {
|
create(view) {
|
||||||
let dom = document.createElement('div');
|
let dom = document.createElement('div');
|
||||||
dom.className = 'strudel-tooltip';
|
dom.className = 'strudel-tooltip';
|
||||||
const ac = Autocomplete(entry);
|
const ac = Autocomplete({ doc: entry, label: word });
|
||||||
dom.appendChild(ac);
|
dom.appendChild(ac);
|
||||||
return { dom };
|
return { dom };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, bench } from 'vitest';
|
import { describe, bench } from 'vitest';
|
||||||
|
|
||||||
import { calculateSteps, sequence, stack } from '../index.mjs';
|
import { calculateTactus, sequence, stack } from '../index.mjs';
|
||||||
|
|
||||||
const pat64 = sequence(...Array(64).keys());
|
const pat64 = sequence(...Array(64).keys());
|
||||||
|
|
||||||
describe('steps', () => {
|
describe('steps', () => {
|
||||||
calculateSteps(true);
|
calculateTactus(true);
|
||||||
bench(
|
bench(
|
||||||
'+tactus',
|
'+tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -14,7 +14,7 @@ describe('steps', () => {
|
|||||||
{ time: 1000 },
|
{ time: 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
calculateSteps(false);
|
calculateTactus(false);
|
||||||
bench(
|
bench(
|
||||||
'-tactus',
|
'-tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -25,7 +25,7 @@ describe('steps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('stack', () => {
|
describe('stack', () => {
|
||||||
calculateSteps(true);
|
calculateTactus(true);
|
||||||
bench(
|
bench(
|
||||||
'+tactus',
|
'+tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -34,7 +34,7 @@ describe('stack', () => {
|
|||||||
{ time: 1000 },
|
{ time: 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
calculateSteps(false);
|
calculateTactus(false);
|
||||||
bench(
|
bench(
|
||||||
'-tactus',
|
'-tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -43,4 +43,4 @@ describe('stack', () => {
|
|||||||
{ time: 1000 },
|
{ time: 1000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
calculateSteps(true);
|
calculateTactus(true);
|
||||||
|
|||||||
+43
-776
File diff suppressed because it is too large
Load Diff
@@ -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 createClock from './zyklus.mjs';
|
||||||
import { errorLogger, logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
export class Cyclist {
|
export class Cyclist {
|
||||||
constructor({
|
constructor({
|
||||||
@@ -57,7 +57,7 @@ export class Cyclist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// query the pattern for events
|
// query the pattern for events
|
||||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' });
|
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
|
||||||
|
|
||||||
haps.forEach((hap) => {
|
haps.forEach((hap) => {
|
||||||
if (hap.hasOnset()) {
|
if (hap.hasOnset()) {
|
||||||
@@ -67,7 +67,6 @@ export class Cyclist {
|
|||||||
// the following line is dumb and only here for backwards compatibility
|
// the following line is dumb and only here for backwards compatibility
|
||||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||||
const deadline = targetTime - phase;
|
const deadline = targetTime - phase;
|
||||||
// this onTrigger has another signature
|
|
||||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||||
this.cps = hap.value.cps;
|
this.cps = hap.value.cps;
|
||||||
@@ -76,7 +75,7 @@ export class Cyclist {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorLogger(e);
|
logger(`[cyclist] error: ${e.message}`);
|
||||||
onError?.(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/>.
|
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, stack, pure, _morph } from './pattern.mjs';
|
import { timeCat, register, silence } from './pattern.mjs';
|
||||||
import { rotate, flatten, splitAt, zipWith } from './util.mjs';
|
import { rotate, flatten, splitAt, zipWith } from './util.mjs';
|
||||||
import Fraction, { lcm } from './fraction.mjs';
|
import Fraction, { lcm } from './fraction.mjs';
|
||||||
|
|
||||||
@@ -35,18 +35,18 @@ const right = function (n, x) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _bjorklund = function (n, x) {
|
const _bjork = function (n, x) {
|
||||||
const [ons, offs] = n;
|
const [ons, offs] = n;
|
||||||
return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x)));
|
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bjorklund = function (ons, steps) {
|
export const bjork = function (ons, steps) {
|
||||||
const inverted = ons < 0;
|
const inverted = ons < 0;
|
||||||
const absOns = Math.abs(ons);
|
const absOns = Math.abs(ons);
|
||||||
const offs = steps - absOns;
|
const offs = steps - absOns;
|
||||||
const ones = Array(absOns).fill([1]);
|
const ones = Array(absOns).fill([1]);
|
||||||
const zeros = Array(offs).fill([0]);
|
const zeros = Array(offs).fill([0]);
|
||||||
const result = _bjorklund([absOns, offs], [ones, zeros]);
|
const result = _bjork([absOns, offs], [ones, zeros]);
|
||||||
const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
|
const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
|
||||||
return inverted ? pattern.map((x) => 1 - x) : pattern;
|
return inverted ? pattern.map((x) => 1 - x) : pattern;
|
||||||
};
|
};
|
||||||
@@ -128,7 +128,7 @@ export const bjorklund = function (ons, steps) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const _euclidRot = function (pulses, steps, rotation) {
|
const _euclidRot = function (pulses, steps, rotation) {
|
||||||
const b = bjorklund(pulses, steps);
|
const b = bjork(pulses, steps);
|
||||||
if (rotation) {
|
if (rotation) {
|
||||||
return rotate(b, -rotation);
|
return rotate(b, -rotation);
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
|
|||||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bjork = register('bjork', function (euc, pat) {
|
export const e = register('e', function (euc, pat) {
|
||||||
if (!Array.isArray(euc)) {
|
if (!Array.isArray(euc)) {
|
||||||
euc = [euc];
|
euc = [euc];
|
||||||
}
|
}
|
||||||
@@ -196,26 +196,3 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
|
|||||||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||||
return _euclidLegato(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(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
|
|
||||||
return pat.struct(morphed).setSteps(steps);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -126,8 +126,6 @@ export const lcm = (...fractions) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFraction = (x) => x instanceof Fraction;
|
|
||||||
|
|
||||||
fraction._original = Fraction;
|
fraction._original = Fraction;
|
||||||
|
|
||||||
export default fraction;
|
export default fraction;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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/>.
|
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 Fraction from './fraction.mjs';
|
||||||
import { stringifyValues } from './util.mjs';
|
|
||||||
|
|
||||||
export class Hap {
|
export class Hap {
|
||||||
/*
|
/*
|
||||||
@@ -149,7 +148,13 @@ export class Hap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showWhole(compact = false) {
|
showWhole(compact = false) {
|
||||||
return `${this.whole == undefined ? '~' : this.whole.show()}: ${stringifyValues(this.value, compact)}`;
|
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
|
||||||
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
combineContext(b) {
|
combineContext(b) {
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ let debounce = 1000,
|
|||||||
lastMessage,
|
lastMessage,
|
||||||
lastTime;
|
lastTime;
|
||||||
|
|
||||||
export function errorLogger(e, origin = 'cyclist') {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
logger(`[${origin}] error: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logger(message, type, data = {}) {
|
export function logger(message, type, data = {}) {
|
||||||
let t = performance.now();
|
let t = performance.now();
|
||||||
if (lastMessage === message && t - lastTime < debounce) {
|
if (lastMessage === message && t - lastTime < debounce) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class NeoCyclist {
|
|||||||
constructor({ onTrigger, onToggle, getTime }) {
|
constructor({ onTrigger, onToggle, getTime }) {
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.cps = 0.5;
|
this.cps = 0.5;
|
||||||
|
this.lastTick = 0; // absolute time when last tick (clock callback) happened
|
||||||
this.getTime = getTime; // get absolute time
|
this.getTime = getTime; // get absolute time
|
||||||
this.time_at_last_tick_message = 0;
|
this.time_at_last_tick_message = 0;
|
||||||
// the clock of the worker and the audio context clock can drift apart over time
|
// the clock of the worker and the audio context clock can drift apart over time
|
||||||
@@ -38,7 +39,8 @@ export class NeoCyclist {
|
|||||||
if (this.started === false) {
|
if (this.started === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'neocyclist' });
|
|
||||||
|
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
|
||||||
haps.forEach((hap) => {
|
haps.forEach((hap) => {
|
||||||
if (hap.hasOnset()) {
|
if (hap.hasOnset()) {
|
||||||
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/core",
|
"name": "@strudel/core",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Port of Tidal Cycles to JavaScript",
|
"description": "Port of Tidal Cycles to JavaScript",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -37,8 +37,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-312
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import TimeSpan from './timespan.mjs';
|
import TimeSpan from './timespan.mjs';
|
||||||
import Fraction, { isFraction, lcm } from './fraction.mjs';
|
import Fraction, { lcm } from './fraction.mjs';
|
||||||
import Hap from './hap.mjs';
|
import Hap from './hap.mjs';
|
||||||
import State from './state.mjs';
|
import State from './state.mjs';
|
||||||
import { unionWithObj } from './value.mjs';
|
import { unionWithObj } from './value.mjs';
|
||||||
@@ -21,8 +21,6 @@ import {
|
|||||||
numeralArgs,
|
numeralArgs,
|
||||||
parseNumeral,
|
parseNumeral,
|
||||||
pairs,
|
pairs,
|
||||||
zipWith,
|
|
||||||
stringifyValues,
|
|
||||||
} from './util.mjs';
|
} from './util.mjs';
|
||||||
import drawLine from './drawLine.mjs';
|
import drawLine from './drawLine.mjs';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
@@ -48,10 +46,11 @@ export class Pattern {
|
|||||||
* @param {function} query - The function that maps a `State` to an array of `Hap`.
|
* @param {function} query - The function that maps a `State` to an array of `Hap`.
|
||||||
* @noAutocomplete
|
* @noAutocomplete
|
||||||
*/
|
*/
|
||||||
constructor(query, steps = undefined) {
|
constructor(query, steps = undefined, alignment = undefined) {
|
||||||
this.query = query;
|
this.query = query;
|
||||||
this._Pattern = true; // this property is used to detectinstance of another Pattern
|
this._Pattern = true; // this property is used to detectinstance of another Pattern
|
||||||
this._steps = steps; // in terms of number of steps per cycle
|
this._steps = steps; // in terms of number of steps per cycle
|
||||||
|
this._alignment = alignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _steps() {
|
get _steps() {
|
||||||
@@ -78,6 +77,11 @@ export class Pattern {
|
|||||||
return this._steps !== undefined;
|
return this._steps !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAlignment(alignment) {
|
||||||
|
this._alignment = alignment;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
// Haskell-style functor, applicative and monadic operations
|
// Haskell-style functor, applicative and monadic operations
|
||||||
|
|
||||||
@@ -98,7 +102,10 @@ export class Pattern {
|
|||||||
|
|
||||||
// runs func on query state
|
// runs func on query state
|
||||||
withState(func) {
|
withState(func) {
|
||||||
return new Pattern((state) => this.query(func(state)));
|
return this.withHaps((haps, state) => {
|
||||||
|
func(state);
|
||||||
|
return haps;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -851,29 +858,14 @@ 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) => {
|
return this.onTrigger((...args) => {
|
||||||
logger(func(...args), undefined, getData(...args));
|
logger(func(...args), undefined, getData(...args));
|
||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
logValues(func = id) {
|
||||||
* A simplified version of `log` which writes all "values" (various configurable parameters)
|
return this.log((_, hap) => func(hap.value));
|
||||||
* 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
@@ -996,7 +988,7 @@ addToPrototype('weaveWith', function (t, ...funcs) {
|
|||||||
// compose matrix functions
|
// compose matrix functions
|
||||||
|
|
||||||
function _nonArrayObject(x) {
|
function _nonArrayObject(x) {
|
||||||
return !Array.isArray(x) && typeof x === 'object' && !isFraction(x);
|
return !Array.isArray(x) && typeof x === 'object';
|
||||||
}
|
}
|
||||||
function _composeOp(a, b, func) {
|
function _composeOp(a, b, func) {
|
||||||
if (_nonArrayObject(a) || _nonArrayObject(b)) {
|
if (_nonArrayObject(a) || _nonArrayObject(b)) {
|
||||||
@@ -1243,8 +1235,7 @@ export const silence = gap(1);
|
|||||||
/* Like silence, but with a 'steps' (relative duration) of 0 */
|
/* Like silence, but with a 'steps' (relative duration) of 0 */
|
||||||
export const nothing = gap(0);
|
export const nothing = gap(0);
|
||||||
|
|
||||||
/**
|
/** A discrete value that repeats once per cycle.
|
||||||
* A discrete value that repeats once per cycle.
|
|
||||||
*
|
*
|
||||||
* @returns {Pattern}
|
* @returns {Pattern}
|
||||||
* @example
|
* @example
|
||||||
@@ -1297,14 +1288,13 @@ export function sequenceP(pats) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The given items are played at the same time at the same length.
|
||||||
* The given items are played at the same time at the same length.
|
|
||||||
*
|
*
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @synonyms polyrhythm, pr
|
* @synonyms polyrhythm, pr
|
||||||
* @example
|
* @example
|
||||||
* stack("g3", "b3", ["e4", "d4"]).note()
|
* stack("g3", "b3", ["e4", "d4"]).note()
|
||||||
* // "g3,b3,[e4 d4]".note()
|
* // "g3,b3,[e4,d4]".note()
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // As a chained function:
|
* // As a chained function:
|
||||||
@@ -1381,11 +1371,11 @@ export function stackBy(by, ...pats) {
|
|||||||
.setSteps(steps);
|
.setSteps(steps);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Concatenation: combines a list of patterns, switching between them successively, one per cycle:
|
||||||
* Concatenation: combines a list of patterns, switching between them successively, one per cycle.
|
*
|
||||||
|
* synonyms: `cat`
|
||||||
*
|
*
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @synonyms cat
|
|
||||||
* @example
|
* @example
|
||||||
* slowcat("e5", "b4", ["d5", "c5"])
|
* slowcat("e5", "b4", ["d5", "c5"])
|
||||||
*
|
*
|
||||||
@@ -1585,7 +1575,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.
|
* Registers a new pattern method. The method is added to the Pattern class + the standalone function is returned from register.
|
||||||
*
|
*
|
||||||
* @param {string | string[]} name name of the function, or an array of names to be used as synonyms
|
* @param {string} name name of the function
|
||||||
* @param {function} func function with 1 or more params, where last is the current pattern
|
* @param {function} func function with 1 or more params, where last is the current pattern
|
||||||
* @noAutocomplete
|
* @noAutocomplete
|
||||||
*
|
*
|
||||||
@@ -1626,8 +1616,13 @@ export function register(name, func, patternify = true, preserveSteps = false, j
|
|||||||
let mapFn = (...args) => {
|
let mapFn = (...args) => {
|
||||||
return func(...args, pat);
|
return func(...args, pat);
|
||||||
};
|
};
|
||||||
mapFn = curry(mapFn, null, arity - 1);
|
// mapFn = curry(mapFn, null, arity - 1);
|
||||||
result = join(right.reduce((acc, p) => acc.appLeft(p), left.fmap(mapFn)));
|
// result = join(right.reduce((acc, p) => acc.appLeft(p), left.fmap(mapFn)));
|
||||||
|
// patternify2 f pata patb patc = pata >>= \a -> patb >>= \b -> f a b patc
|
||||||
|
function bindArgs(argv, pat, ...pats) {
|
||||||
|
return pat.innerBind((x) => (pats.length ? bindArgs([...argv, x], ...pats) : mapFn(...argv, x)));
|
||||||
|
}
|
||||||
|
result = bindArgs([], ...firstArgs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (preserveSteps) {
|
if (preserveSteps) {
|
||||||
@@ -2396,57 +2391,6 @@ export const stut = register('stut', function (times, feedback, time, pat) {
|
|||||||
return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i)));
|
return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i)));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const applyN = register('applyN', function (n, func, p) {
|
|
||||||
let result = p;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
result = func(result);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The plyWith function repeats each event the given number of times, applying the given function to each event.\n
|
|
||||||
* @name plyWith
|
|
||||||
* @synonyms plywith
|
|
||||||
* @param {number} factor how many times to repeat
|
|
||||||
* @param {function} func function to apply, given the pattern
|
|
||||||
* @example
|
|
||||||
* "<0 [2 4]>"
|
|
||||||
* .plyWith(4, (p) => p.add(2))
|
|
||||||
* .scale("C:minor").note()
|
|
||||||
*/
|
|
||||||
export const plyWith = register(['plyWith', 'plywith'], function (factor, func, pat) {
|
|
||||||
const result = pat
|
|
||||||
.fmap((x) => cat(...listRange(0, factor - 1).map((i) => applyN(i, func, x)))._fast(factor))
|
|
||||||
.squeezeJoin();
|
|
||||||
if (__steps) {
|
|
||||||
result._steps = Fraction(factor).mulmaybe(pat._steps);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The plyForEach function repeats each event the given number of times, applying the given function to each event.
|
|
||||||
* This version of ply uses the iteration index as an argument to the function, similar to echoWith.
|
|
||||||
* @name plyForEach
|
|
||||||
* @synonyms plyforeach
|
|
||||||
* @param {number} factor how many times to repeat
|
|
||||||
* @param {function} func function to apply, given the pattern and the iteration index
|
|
||||||
* @example
|
|
||||||
* "<0 [2 4]>"
|
|
||||||
* .plyForEach(4, (p,n) => p.add(n*2))
|
|
||||||
* .scale("C:minor").note()
|
|
||||||
*/
|
|
||||||
export const plyForEach = register(['plyForEach', 'plyforeach'], function (factor, func, pat) {
|
|
||||||
const result = pat
|
|
||||||
.fmap((x) => cat(cat(pure(x), ...listRange(1, factor - 1).map((i) => func(pure(x), i))))._fast(factor))
|
|
||||||
.squeezeJoin();
|
|
||||||
if (__steps) {
|
|
||||||
result._steps = Fraction(factor).mulmaybe(pat._steps);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Divides a pattern into a given number of subdivisions, plays the subdivisions in order, but increments the starting subdivision each cycle. The pattern wraps to the first subdivision after the last subdivision is played.
|
* Divides a pattern into a given number of subdivisions, plays the subdivisions in order, but increments the starting subdivision each cycle. The pattern wraps to the first subdivision after the last subdivision is played.
|
||||||
* @name iter
|
* @name iter
|
||||||
@@ -2574,8 +2518,8 @@ export const { chunkBack, chunkback } = register(
|
|||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* "<0 8> 1 2 3 4 5 6 7"
|
* "<0 8> 1 2 3 4 5 6 7"
|
||||||
* .scale("C2:major").note()
|
|
||||||
* .fastChunk(4, x => x.color('red')).slow(2)
|
* .fastChunk(4, x => x.color('red')).slow(2)
|
||||||
|
* .scale("C2:major").note()
|
||||||
*/
|
*/
|
||||||
export const { fastchunk, fastChunk } = register(
|
export const { fastchunk, fastChunk } = register(
|
||||||
['fastchunk', 'fastChunk'],
|
['fastchunk', 'fastChunk'],
|
||||||
@@ -2589,7 +2533,7 @@ export const { fastchunk, fastChunk } = register(
|
|||||||
/**
|
/**
|
||||||
* Like `chunk`, but the function is applied to a looped subcycle of the source pattern.
|
* Like `chunk`, but the function is applied to a looped subcycle of the source pattern.
|
||||||
* @name chunkInto
|
* @name chunkInto
|
||||||
* @synonyms chunkinto
|
* @synonym chunkinto
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @example
|
* @example
|
||||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||||
@@ -2602,7 +2546,7 @@ export const { chunkinto, chunkInto } = register(['chunkinto', 'chunkInto'], fun
|
|||||||
/**
|
/**
|
||||||
* Like `chunkInto`, but moves backwards through the chunks.
|
* Like `chunkInto`, but moves backwards through the chunks.
|
||||||
* @name chunkBackInto
|
* @name chunkBackInto
|
||||||
* @synonyms chunkbackinto
|
* @synonym chunkbackinto
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @example
|
* @example
|
||||||
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
* sound("bd sd ht lt bd - cp lt").chunkInto(4, hurry(2))
|
||||||
@@ -2632,7 +2576,7 @@ export const bypass = register(
|
|||||||
* Loops the pattern inside an `offset` for `cycles`.
|
* 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.
|
* 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
|
* @name ribbon
|
||||||
* @synonyms rib
|
* @synonym rib
|
||||||
* @param {number} offset start point of loop in cycles
|
* @param {number} offset start point of loop in cycles
|
||||||
* @param {number} cycles loop length in cycles
|
* @param {number} cycles loop length in cycles
|
||||||
* @example
|
* @example
|
||||||
@@ -2998,24 +2942,6 @@ export const extend = stepRegister('extend', function (factor, pat) {
|
|||||||
return pat.fast(factor).expand(factor);
|
return pat.fast(factor).expand(factor);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* *Experimental*
|
|
||||||
*
|
|
||||||
* `replicate` is similar to `fast` in that it increases its density, but it also increases the step count
|
|
||||||
* accordingly. So `stepcat("a b".replicate(2), "c d")` would be the same as `"a b a b c d"`, whereas
|
|
||||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
|
||||||
*
|
|
||||||
* TODO: find out how this function differs from extend
|
|
||||||
* @example
|
|
||||||
* stepcat(
|
|
||||||
* sound("bd bd - cp").replicate(2),
|
|
||||||
* sound("bd - sd -")
|
|
||||||
* ).pace(8)
|
|
||||||
*/
|
|
||||||
export const replicate = stepRegister('replicate', function (factor, pat) {
|
|
||||||
return pat.repeatCycles(factor).fast(factor).expand(factor);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
@@ -3345,10 +3271,10 @@ export const slice = register(
|
|||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
|
* s("bd!8").onTriggerTime((hap) => {console.info(hap)})
|
||||||
*/
|
*/
|
||||||
Pattern.prototype.onTriggerTime = function (func) {
|
Pattern.prototype.onTriggerTime = function (func) {
|
||||||
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
|
return this.onTrigger((t_deprecate, hap, currentTime, cps = 1, targetTime) => {
|
||||||
const diff = targetTime - currentTime;
|
const diff = targetTime - currentTime;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
func(hap);
|
func(hap);
|
||||||
@@ -3485,205 +3411,3 @@ export const { beat } = register(
|
|||||||
['beat'],
|
['beat'],
|
||||||
__beat((x) => x.innerJoin()),
|
__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))));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft-clipping distortion
|
|
||||||
*
|
|
||||||
* @name soft
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Hard-clipping distortion
|
|
||||||
*
|
|
||||||
* @name hard
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Cubic polynomial distortion
|
|
||||||
*
|
|
||||||
* @name cubic
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Diode-emulating distortion
|
|
||||||
*
|
|
||||||
* @name diode
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Asymmetrical diode distortion
|
|
||||||
*
|
|
||||||
* @name asym
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Wavefolding distortion
|
|
||||||
*
|
|
||||||
* @name fold
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Wavefolding distortion composed with sinusoid
|
|
||||||
*
|
|
||||||
* @name sinefold
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Distortion via Chebyshev polynomials
|
|
||||||
*
|
|
||||||
* @name chebyshev
|
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const distAlgoNames = ['scurve', 'soft', 'hard', 'cubic', 'diode', 'asym', 'fold', 'sinefold', 'chebyshev'];
|
|
||||||
for (const name of distAlgoNames) {
|
|
||||||
// Add aliases for distortion algorithms
|
|
||||||
Pattern.prototype[name] = function (args) {
|
|
||||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
|
||||||
return this.distort(argsPat);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns a list of patterns into a single pattern which outputs list-values
|
|
||||||
*
|
|
||||||
* @name parray
|
|
||||||
* @returns Pattern
|
|
||||||
*/
|
|
||||||
export const parray = (pats) => {
|
|
||||||
const pack = (...xs) => xs;
|
|
||||||
let acc = pure(curry(pack, null, pats.length));
|
|
||||||
for (const p of pats) acc = acc.appBoth(reify(p));
|
|
||||||
return acc;
|
|
||||||
};
|
|
||||||
|
|
||||||
const _ensureListPattern = (list) => {
|
|
||||||
if (Array.isArray(list)) {
|
|
||||||
return parray(list);
|
|
||||||
}
|
|
||||||
return reify(list);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scale the magnitude of the harmonics of one of the core synths ('sine', 'tri', 'saw', ..)
|
|
||||||
*
|
|
||||||
* Can also be used to create a new synth via `s('user').partials(...)`
|
|
||||||
*
|
|
||||||
* @name partials
|
|
||||||
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
|
|
||||||
* @example
|
|
||||||
* s("user").seg(16).n(irand(8)).scale("A:major")
|
|
||||||
* .partials([1, 0, 1, 0, 0, 1])
|
|
||||||
* @example
|
|
||||||
* s("saw").seg(8).n(irand(12)).scale("G#:minor")
|
|
||||||
* .partials(binaryL(irand(256).add("1")))
|
|
||||||
*/
|
|
||||||
Pattern.prototype.partials = function (list) {
|
|
||||||
return this.withValue((v) => (l) => ({ ...v, partials: l })).appLeft(_ensureListPattern(list));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Also create a top-level function
|
|
||||||
export const partials = (list) => {
|
|
||||||
return _ensureListPattern(list).as('partials');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
|
|
||||||
*
|
|
||||||
* @name phases
|
|
||||||
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
|
|
||||||
* @example
|
|
||||||
* // Phase cancellation
|
|
||||||
* s("saw").seg(8).n(irand(12)).scale("G#1:minor")
|
|
||||||
* .partials(partials([1, 1, 1]))
|
|
||||||
* .superimpose(x => x.phases([0.5, 0.5, 0.5]))
|
|
||||||
*/
|
|
||||||
Pattern.prototype.phases = function (list) {
|
|
||||||
return this.withValue((v) => (l) => ({ ...v, phases: l })).appLeft(_ensureListPattern(list));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Also create a top-level function
|
|
||||||
export const phases = (list) => {
|
|
||||||
return _ensureListPattern(list).as('phases');
|
|
||||||
};
|
|
||||||
|
|||||||
+10
-40
@@ -1,7 +1,7 @@
|
|||||||
import { NeoCyclist } from './neocyclist.mjs';
|
import { NeoCyclist } from './neocyclist.mjs';
|
||||||
import { Cyclist } from './cyclist.mjs';
|
import { Cyclist } from './cyclist.mjs';
|
||||||
import { evaluate as _evaluate } from './evaluate.mjs';
|
import { evaluate as _evaluate } from './evaluate.mjs';
|
||||||
import { errorLogger, logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { setTime } from './time.mjs';
|
import { setTime } from './time.mjs';
|
||||||
import { evalScope } from './evaluate.mjs';
|
import { evalScope } from './evaluate.mjs';
|
||||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||||
@@ -74,14 +74,6 @@ export function repl({
|
|||||||
return silence;
|
return silence;
|
||||||
};
|
};
|
||||||
|
|
||||||
// helper to get a patternified pure value out
|
|
||||||
function unpure(pat) {
|
|
||||||
if (pat._Pattern) {
|
|
||||||
return pat.__pure;
|
|
||||||
}
|
|
||||||
return pat;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPattern = async (pattern, autostart = true) => {
|
const setPattern = async (pattern, autostart = true) => {
|
||||||
pattern = editPattern?.(pattern) || pattern;
|
pattern = editPattern?.(pattern) || pattern;
|
||||||
await scheduler.setPattern(pattern, autostart);
|
await scheduler.setPattern(pattern, autostart);
|
||||||
@@ -93,10 +85,7 @@ export function repl({
|
|||||||
const start = () => scheduler.start();
|
const start = () => scheduler.start();
|
||||||
const pause = () => scheduler.pause();
|
const pause = () => scheduler.pause();
|
||||||
const toggle = () => scheduler.toggle();
|
const toggle = () => scheduler.toggle();
|
||||||
const setCps = (cps) => {
|
const setCps = (cps) => scheduler.setCps(cps);
|
||||||
scheduler.setCps(unpure(cps));
|
|
||||||
return silence;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the global tempo to the given cycles per minute
|
* Changes the global tempo to the given cycles per minute
|
||||||
@@ -108,10 +97,7 @@ export function repl({
|
|||||||
* setcpm(140/4) // =140 bpm in 4/4
|
* setcpm(140/4) // =140 bpm in 4/4
|
||||||
* $: s("bd*4,[- sd]*2").bank('tr707')
|
* $: s("bd*4,[- sd]*2").bank('tr707')
|
||||||
*/
|
*/
|
||||||
const setCpm = (cpm) => {
|
const setCpm = (cpm) => scheduler.setCps(cpm / 60);
|
||||||
scheduler.setCps(unpure(cpm) / 60);
|
|
||||||
return silence;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
|
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
|
||||||
|
|
||||||
@@ -152,9 +138,9 @@ export function repl({
|
|||||||
// allows muting a pattern x with x_ or _x
|
// allows muting a pattern x with x_ or _x
|
||||||
return silence;
|
return silence;
|
||||||
}
|
}
|
||||||
if (id.includes('$')) {
|
if (id === '$') {
|
||||||
// allows adding anonymous patterns with $:
|
// allows adding anonymous patterns with $:
|
||||||
id = `${id}${anonymousIndex}`;
|
id = `$${anonymousIndex}`;
|
||||||
anonymousIndex++;
|
anonymousIndex++;
|
||||||
}
|
}
|
||||||
pPatterns[id] = this;
|
pPatterns[id] = this;
|
||||||
@@ -214,21 +200,7 @@ export function repl({
|
|||||||
}
|
}
|
||||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||||
if (Object.keys(pPatterns).length) {
|
if (Object.keys(pPatterns).length) {
|
||||||
let patterns = [];
|
let patterns = Object.values(pPatterns);
|
||||||
let soloActive = false;
|
|
||||||
for (const [key, value] of Object.entries(pPatterns)) {
|
|
||||||
// handle soloed patterns ex: S$: s("bd!4")
|
|
||||||
const isSolod = key.length > 1 && key.startsWith('S');
|
|
||||||
if (isSolod && soloActive === false) {
|
|
||||||
// first time we see a soloed pattern, clear existing patterns
|
|
||||||
patterns = [];
|
|
||||||
soloActive = true;
|
|
||||||
}
|
|
||||||
if (!soloActive || (soloActive && isSolod)) {
|
|
||||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
|
||||||
patterns.push(valWithState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eachTransform) {
|
if (eachTransform) {
|
||||||
// Explicit lambda so only element (not index and array) are passed
|
// Explicit lambda so only element (not index and array) are passed
|
||||||
patterns = patterns.map((x) => eachTransform(x));
|
patterns = patterns.map((x) => eachTransform(x));
|
||||||
@@ -238,11 +210,10 @@ export function repl({
|
|||||||
pattern = eachTransform(pattern);
|
pattern = eachTransform(pattern);
|
||||||
}
|
}
|
||||||
if (allTransforms.length) {
|
if (allTransforms.length) {
|
||||||
for (const transform of allTransforms) {
|
for (let i in allTransforms) {
|
||||||
pattern = transform(pattern);
|
pattern = allTransforms[i](pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPattern(pattern)) {
|
if (!isPattern(pattern)) {
|
||||||
const message = `got "${typeof evaluated}" instead of pattern`;
|
const message = `got "${typeof evaluated}" instead of pattern`;
|
||||||
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
|
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
|
||||||
@@ -274,7 +245,6 @@ export function repl({
|
|||||||
export const getTrigger =
|
export const getTrigger =
|
||||||
({ getTime, defaultOutput }) =>
|
({ getTime, defaultOutput }) =>
|
||||||
async (hap, deadline, duration, cps, t) => {
|
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
|
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||||
try {
|
try {
|
||||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||||
@@ -282,9 +252,9 @@ export const getTrigger =
|
|||||||
}
|
}
|
||||||
if (hap.context.onTrigger) {
|
if (hap.context.onTrigger) {
|
||||||
// call signature of output / onTrigger is different...
|
// call signature of output / onTrigger is different...
|
||||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorLogger(err, 'getTrigger');
|
logger(`[cyclist] error: ${err.message}`, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
|
|||||||
export const run = (n) => saw.range(0, n).round().segment(n);
|
export const run = (n) => saw.range(0, n).round().segment(n);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a binary pattern from a number.
|
* Creates a pattern from a binary number.
|
||||||
*
|
*
|
||||||
* @name binary
|
* @name binary
|
||||||
* @param {number} n - input number to convert to binary
|
* @param {number} n - input number to convert to binary
|
||||||
@@ -242,7 +242,7 @@ export const binary = (n) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a binary pattern from a number, padded to n bits long.
|
* Creates a pattern from a binary number, padded to n bits long.
|
||||||
*
|
*
|
||||||
* @name binaryN
|
* @name binaryN
|
||||||
* @param {number} n - input number to convert to binary
|
* @param {number} n - input number to convert to binary
|
||||||
@@ -258,58 +258,13 @@ export const binaryN = (n, nBits = 16) => {
|
|||||||
return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
|
return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a binary list pattern from a number.
|
|
||||||
*
|
|
||||||
* @name binaryL
|
|
||||||
* @param {number} n - input number to convert to binary
|
|
||||||
* s("saw").seg(8)
|
|
||||||
* .partials(binaryL(irand(4096).add(1)))
|
|
||||||
*/
|
|
||||||
export const binaryL = (n) => {
|
|
||||||
const nBits = reify(n).log2(0).floor().add(1);
|
|
||||||
return binaryNL(n, nBits);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a binary list pattern from a number, padded to n bits long.
|
|
||||||
*
|
|
||||||
* @name binaryNL
|
|
||||||
* @param {number} n - input number to convert to binary
|
|
||||||
* @param {number} nBits - pattern length, defaults to 16
|
|
||||||
*/
|
|
||||||
export const binaryNL = (n, nBits = 16) => {
|
|
||||||
return reify(n)
|
|
||||||
.withValue((v) => (bits) => {
|
|
||||||
const bList = [];
|
|
||||||
for (let i = bits - 1; i >= 0; i--) {
|
|
||||||
bList.push((v >> i) & 1);
|
|
||||||
}
|
|
||||||
return bList;
|
|
||||||
})
|
|
||||||
.appLeft(reify(nBits));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a list of random numbers of the given length
|
|
||||||
*
|
|
||||||
* @name randL
|
|
||||||
* @param {number} n Number of random numbers to sample
|
|
||||||
* @example
|
|
||||||
* s("saw").seg(16).n(irand(12)).scale("F1:minor")
|
|
||||||
* .partials(randL(8))
|
|
||||||
*/
|
|
||||||
export const randL = (n) => {
|
|
||||||
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const randrun = (n) => {
|
export const randrun = (n) => {
|
||||||
return signal((t) => {
|
return signal((t) => {
|
||||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||||
const rands = timeToRands(t.floor().add(0.5), n);
|
const rands = timeToRands(t.floor().add(0.5), n);
|
||||||
const nums = rands
|
const nums = rands
|
||||||
.map((n, i) => [n, i])
|
.map((n, i) => [n, i])
|
||||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
.sort((a, b) => a[0] > b[0] - a[0] < b[0])
|
||||||
.map((x) => x[1]);
|
.map((x) => x[1]);
|
||||||
const i = t.cyclePos().mul(n).floor() % n;
|
const i = t.cyclePos().mul(n).floor() % n;
|
||||||
return nums[i];
|
return nums[i];
|
||||||
@@ -524,7 +479,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
|
|||||||
* @example
|
* @example
|
||||||
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
|
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
|
||||||
* @example
|
* @example
|
||||||
* wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note()
|
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
|
||||||
* @example
|
* @example
|
||||||
* // The probability can itself be a pattern
|
* // The probability can itself be a pattern
|
||||||
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
|
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const speak = register('speak', function (lang, voice, pat) {
|
export const speak = register('speak', function (lang, voice, pat) {
|
||||||
return pat.onTrigger((hap) => {
|
return pat.onTrigger((_, hap) => {
|
||||||
triggerSpeech(hap.value, lang, voice);
|
triggerSpeech(hap.value, lang, voice);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export class State {
|
|||||||
return this.setSpan(func(this.span));
|
return this.setSpan(func(this.span));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns new State with added controls.
|
// Returns new State with different controls
|
||||||
setControls(controls) {
|
setControls(controls) {
|
||||||
return new State(this.span, { ...this.controls, ...controls });
|
return new State(this.span, controls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { bjorklund } from '../euclid.mjs';
|
import { bjork } from '../euclid.mjs';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { fastcat } from '../pattern.mjs';
|
import { fastcat } from '../pattern.mjs';
|
||||||
|
|
||||||
describe('bjorklund', () => {
|
describe('bjork', () => {
|
||||||
it('should apply bjorklundlund to ons and steps', () => {
|
it('should apply bjorklund to ons and steps', () => {
|
||||||
expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
||||||
expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
||||||
expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||||
expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 Fraction from 'fraction.js';
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TimeSpan,
|
TimeSpan,
|
||||||
@@ -55,8 +55,6 @@ import {
|
|||||||
expand,
|
expand,
|
||||||
} from '../index.mjs';
|
} from '../index.mjs';
|
||||||
|
|
||||||
import { log, logValues } from '../pattern.mjs';
|
|
||||||
|
|
||||||
import { steady } from '../signal.mjs';
|
import { steady } from '../signal.mjs';
|
||||||
|
|
||||||
import { n, s } from '../controls.mjs';
|
import { n, s } from '../controls.mjs';
|
||||||
@@ -1308,40 +1306,4 @@ 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(other) {
|
||||||
// Intersection of two timespans, returns undefined if they don't intersect.
|
// Intersection of two timespans, returns None if they don't intersect.
|
||||||
const intersect_begin = this.begin.max(other.begin);
|
const intersect_begin = this.begin.max(other.begin);
|
||||||
const intersect_end = this.end.min(other.end);
|
const intersect_end = this.end.min(other.end);
|
||||||
|
|
||||||
|
|||||||
+4
-18
@@ -7,13 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
// returns true if the given string is a note
|
// returns true if the given string is a note
|
||||||
export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name);
|
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
|
||||||
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name);
|
export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name);
|
||||||
export const tokenizeNote = (note) => {
|
export const tokenizeNote = (note) => {
|
||||||
if (typeof note !== 'string') {
|
if (typeof note !== 'string') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
|
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
|
||||||
if (!pc) {
|
if (!pc) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,6 @@ export const tokenizeNote = (note) => {
|
|||||||
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
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 accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||||
|
|
||||||
export const getAccidentalsOffset = (accidentals) => {
|
|
||||||
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// turns the given note into its midi number representation
|
// turns the given note into its midi number representation
|
||||||
export const noteToMidi = (note, defaultOctave = 3) => {
|
export const noteToMidi = (note, defaultOctave = 3) => {
|
||||||
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
||||||
@@ -34,7 +30,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
|
|||||||
throw new Error('not a note: "' + note + '"');
|
throw new Error('not a note: "' + note + '"');
|
||||||
}
|
}
|
||||||
const chroma = chromas[pc.toLowerCase()];
|
const chroma = chromas[pc.toLowerCase()];
|
||||||
const offset = getAccidentalsOffset(acc);
|
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||||
};
|
};
|
||||||
export const midiToFreq = (n) => {
|
export const midiToFreq = (n) => {
|
||||||
@@ -491,13 +487,3 @@ export function getCurrentKeyboardState() {
|
|||||||
// }
|
// }
|
||||||
// return lcm((x * y) / gcd(x, y), ...z);
|
// 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';
|
instrument = instrument || 'triangle';
|
||||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
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)
|
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
|
||||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
return pat.onTrigger((time_deprecate, hap, currentTime, _cps, targetTime) => {
|
||||||
if (!_csound) {
|
if (!_csound) {
|
||||||
logger('[csound] not loaded yet', 'warning');
|
logger('[csound] not loaded yet', 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +142,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
|||||||
p1 = `"${instrument}"`;
|
p1 = `"${instrument}"`;
|
||||||
}
|
}
|
||||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
return pat.onTrigger((tidal_time, hap) => {
|
||||||
if (!_csound) {
|
if (!_csound) {
|
||||||
logger('[csound] not loaded yet', 'warning');
|
logger('[csound] not loaded yet', 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -151,7 +151,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
|||||||
throw new Error('csound only support objects as hap values');
|
throw new Error('csound only support objects as hap values');
|
||||||
}
|
}
|
||||||
// Time in seconds counting from now.
|
// Time in seconds counting from now.
|
||||||
const p2 = targetTime - currentTime;
|
const p2 = tidal_time - getAudioContext().currentTime;
|
||||||
const p3 = hap.duration.valueOf() + 0;
|
const p3 = hap.duration.valueOf() + 0;
|
||||||
const frequency = getFrequency(hap);
|
const frequency = getFrequency(hap);
|
||||||
let { gain = 1, velocity = 0.9 } = hap.value;
|
let { gain = 1, velocity = 0.9 } = hap.value;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/csound",
|
"name": "@strudel/csound",
|
||||||
"version": "1.2.6",
|
"version": "1.2.3",
|
||||||
"description": "csound bindings for strudel",
|
"description": "csound bindings for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -38,8 +38,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const OFF_MESSAGE = 0x80;
|
|||||||
const CC_MESSAGE = 0xb0;
|
const CC_MESSAGE = 0xb0;
|
||||||
|
|
||||||
Pattern.prototype.midi = function (output) {
|
Pattern.prototype.midi = function (output) {
|
||||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||||
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
|
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
|
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||||
const latencyMs = 34;
|
const latencyMs = 34;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Invoke } from './utils.mjs';
|
|||||||
|
|
||||||
const collator = new ClockCollator({});
|
const collator = new ClockCollator({});
|
||||||
|
|
||||||
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||||
const controls = parseControlsFromHap(hap, cps);
|
const controls = parseControlsFromHap(hap, cps);
|
||||||
const params = [];
|
const params = [];
|
||||||
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
||||||
|
|||||||
@@ -25,8 +25,5 @@
|
|||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
"@tauri-apps/api": "^2.2.0"
|
"@tauri-apps/api": "^2.2.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/strudel#readme"
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,18 +84,9 @@ Pattern.prototype.onPaint = function (painter) {
|
|||||||
state.controls.painters = [];
|
state.controls.painters = [];
|
||||||
}
|
}
|
||||||
state.controls.painters.push(painter);
|
state.controls.painters.push(painter);
|
||||||
return state;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO - Why isn't this pure deep copy not working?
|
|
||||||
// Pattern.prototype.onPaint = function (painter) {
|
|
||||||
// return this.withState((state) => {
|
|
||||||
// const painters = state.controls.painters ? [...state.controls.painters, painter] : [painter];
|
|
||||||
// return new State(state.span, { ...state.controls, painters });
|
|
||||||
// });
|
|
||||||
// };
|
|
||||||
|
|
||||||
Pattern.prototype.getPainters = function () {
|
Pattern.prototype.getPainters = function () {
|
||||||
let painters = [];
|
let painters = [];
|
||||||
this.queryArc(0, 0, { painters });
|
this.queryArc(0, 0, { painters });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/draw",
|
"name": "@strudel/draw",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Helpers for drawing with Strudel",
|
"description": "Helpers for drawing with Strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/embed",
|
"name": "@strudel/embed",
|
||||||
"version": "1.1.1",
|
"version": "1.1.0",
|
||||||
"description": "Embeddable Web Component to load a Strudel REPL into an iframe",
|
"description": "Embeddable Web Component to load a Strudel REPL into an iframe",
|
||||||
"main": "embed.js",
|
"main": "embed.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,8 +20,5 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://codeberg.org/uzu/strudel/issues"
|
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/strudel#readme"
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ const pattern = sequence([
|
|||||||
- D-Pad
|
- D-Pad
|
||||||
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
|
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
|
||||||
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
|
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
|
||||||
- Stick Buttons
|
|
||||||
- `l3`, `r3` (or `ls`, `rs`)
|
|
||||||
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
|
|
||||||
- System Buttons
|
|
||||||
- `start`, `back` (or uppercase `START`, `BACK`)
|
|
||||||
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
|
|
||||||
|
|
||||||
### Analog Sticks
|
### Analog Sticks
|
||||||
- Left Stick
|
- Left Stick
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ The gamepad module provides access to buttons and analog sticks as normalized si
|
|||||||
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
|
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
|
||||||
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
|
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
|
||||||
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
|
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
|
||||||
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
|
|
||||||
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
|
|
||||||
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
|
|
||||||
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
|
|
||||||
|
|
||||||
### Analog Sticks
|
### Analog Sticks
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ export const buttonMap = {
|
|||||||
rt: 7,
|
rt: 7,
|
||||||
back: 8,
|
back: 8,
|
||||||
start: 9,
|
start: 9,
|
||||||
l3: 10,
|
|
||||||
ls: 10,
|
|
||||||
r3: 11,
|
|
||||||
rs: 11,
|
|
||||||
u: 12,
|
u: 12,
|
||||||
up: 12,
|
up: 12,
|
||||||
d: 13,
|
d: 13,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/gamepad",
|
"name": "@strudel/gamepad",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Gamepad Inputs for strudel",
|
"description": "Gamepad Inputs for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tree-sitter-haskell": "^0.23.1",
|
"tree-sitter-haskell": "^0.23.1",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/hydra",
|
"name": "@strudel/hydra",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Hydra integration for strudel",
|
"description": "Hydra integration for strudel",
|
||||||
"main": "hydra.mjs",
|
"main": "hydra.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -40,8 +40,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-62
@@ -5,10 +5,9 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _WebMidi from 'webmidi';
|
import * as _WebMidi from 'webmidi';
|
||||||
import { Pattern, isPattern, logger, ref } from '@strudel/core';
|
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
|
||||||
import { noteToMidi, getControlName } from '@strudel/core';
|
import { noteToMidi, getControlName } from '@strudel/core';
|
||||||
import { Note } from 'webmidi';
|
import { Note } from 'webmidi';
|
||||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
|
||||||
|
|
||||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||||
export const { WebMidi } = _WebMidi;
|
export const { WebMidi } = _WebMidi;
|
||||||
@@ -191,7 +190,7 @@ function mapCC(mapping, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sends a cc message to the given device on the given channel
|
// sends a cc message to the given device on the given channel
|
||||||
function sendCC(ccn, ccv, device, midichan, targetTime) {
|
function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
||||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||||
throw new Error('expected ccv to be a number between 0 and 1');
|
throw new Error('expected ccv to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
@@ -199,23 +198,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
|
|||||||
throw new Error('expected ccn to be a number or a string');
|
throw new Error('expected ccn to be a number or a string');
|
||||||
}
|
}
|
||||||
const scaled = Math.round(ccv * 127);
|
const scaled = Math.round(ccv * 127);
|
||||||
scheduleAtTime(() => {
|
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
||||||
device.sendControlChange(ccn, scaled, midichan);
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a program change message to the given device on the given channel
|
// sends a program change message to the given device on the given channel
|
||||||
function sendProgramChange(progNum, device, midichan, targetTime) {
|
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
||||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||||
}
|
}
|
||||||
scheduleAtTime(() => {
|
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
||||||
device.sendProgramChange(progNum, midichan);
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a sysex message to the given device on the given channel
|
// sends a sysex message to the given device on the given channel
|
||||||
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
||||||
if (Array.isArray(sysexid)) {
|
if (Array.isArray(sysexid)) {
|
||||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||||
@@ -230,13 +225,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
|
|||||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||||
}
|
}
|
||||||
scheduleAtTime(() => {
|
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
||||||
device.sendSysex(sysexid, sysexdata);
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a NRPN message to the given device on the given channel
|
// sends a NRPN message to the given device on the given channel
|
||||||
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
||||||
if (Array.isArray(nrpnn)) {
|
if (Array.isArray(nrpnn)) {
|
||||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||||
@@ -244,34 +237,28 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
|||||||
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
||||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||||
}
|
}
|
||||||
scheduleAtTime(() => {
|
|
||||||
device.sendNRPN(nrpnn, nrpv, midichan);
|
device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a pitch bend message to the given device on the given channel
|
// sends a pitch bend message to the given device on the given channel
|
||||||
function sendPitchBend(midibend, device, midichan, targetTime) {
|
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
||||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||||
throw new Error('expected midibend to be a number between -1 and 1');
|
throw new Error('expected midibend to be a number between -1 and 1');
|
||||||
}
|
}
|
||||||
scheduleAtTime(() => {
|
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
||||||
device.sendPitchBend(midibend, midichan);
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a channel aftertouch message to the given device on the given channel
|
// sends a channel aftertouch message to the given device on the given channel
|
||||||
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
||||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
|
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
|
||||||
scheduleAtTime(() => {
|
|
||||||
device.sendChannelAftertouch(miditouch, midichan);
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a note message to the given device on the given channel
|
// sends a note message to the given device on the given channel
|
||||||
function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
|
||||||
if (note == null || note === '') {
|
if (note == null || note === '') {
|
||||||
throw new Error('note cannot be null or empty');
|
throw new Error('note cannot be null or empty');
|
||||||
}
|
}
|
||||||
@@ -281,12 +268,12 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
|||||||
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
if (duration != null && (typeof duration !== 'number' || duration < 0)) {
|
||||||
throw new Error('duration must be a positive number');
|
throw new Error('duration must be a positive number');
|
||||||
}
|
}
|
||||||
|
|
||||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||||
|
device.playNote(midiNote, midichan, {
|
||||||
scheduleAtTime(() => {
|
time: timeOffsetString,
|
||||||
device.playNote(midiNote, midichan);
|
});
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,6 +309,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
let midiConfig = {
|
let midiConfig = {
|
||||||
// Default configuration values
|
// Default configuration values
|
||||||
isController: false, // Disable sending notes for midi controllers
|
isController: false, // Disable sending notes for midi controllers
|
||||||
|
latencyMs: 34, // Default latency to get audio engine to line up in ms
|
||||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||||
midichannel: 1, // Default MIDI channel
|
midichannel: 1, // Default MIDI channel
|
||||||
velocity: 0.9, // Default velocity
|
velocity: 0.9, // Default velocity
|
||||||
@@ -345,13 +333,18 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
|
return this.onTrigger((time_deprecate, hap, currentTime, cps, targetTime) => {
|
||||||
if (!WebMidi.enabled) {
|
if (!WebMidi.enabled) {
|
||||||
logger('Midi not enabled');
|
logger('Midi not enabled');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hap.ensureObjectValue();
|
hap.ensureObjectValue();
|
||||||
|
|
||||||
|
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||||
|
const latencyMs = midiConfig.latencyMs;
|
||||||
|
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
|
||||||
|
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
|
||||||
|
|
||||||
// midi event values from hap with configurable defaults
|
// midi event values from hap with configurable defaults
|
||||||
let {
|
let {
|
||||||
note,
|
note,
|
||||||
@@ -387,7 +380,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
// if midimap is set, send a cc messages from defined controls
|
// if midimap is set, send a cc messages from defined controls
|
||||||
if (midicontrolMap.has(midimap)) {
|
if (midicontrolMap.has(midimap)) {
|
||||||
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
||||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
|
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
|
||||||
} else if (midimap !== 'default') {
|
} else if (midimap !== 'default') {
|
||||||
// Add warning when a non-existent midimap is specified
|
// Add warning when a non-existent midimap is specified
|
||||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||||
@@ -399,12 +392,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||||
|
|
||||||
sendNote(note, velocity, duration, device, midichan, targetTime);
|
sendNote(note, velocity, duration, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle program change
|
// Handle program change
|
||||||
if (progNum !== undefined) {
|
if (progNum !== undefined) {
|
||||||
sendProgramChange(progNum, device, midichan, targetTime);
|
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sysex
|
// Handle sysex
|
||||||
@@ -414,63 +407,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
// if sysexid is an array the first byte is 0x00
|
// if sysexid is an array the first byte is 0x00
|
||||||
|
|
||||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||||
sendSysex(sysexid, sysexdata, device, targetTime);
|
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle control change
|
// Handle control change
|
||||||
if (ccv !== undefined && ccn !== undefined) {
|
if (ccv !== undefined && ccn !== undefined) {
|
||||||
sendCC(ccn, ccv, device, midichan, targetTime);
|
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle NRPN non-registered parameter number
|
// Handle NRPN non-registered parameter number
|
||||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||||
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midibend
|
// Handle midibend
|
||||||
if (midibend !== undefined) {
|
if (midibend !== undefined) {
|
||||||
sendPitchBend(midibend, device, midichan, targetTime);
|
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle miditouch
|
// Handle miditouch
|
||||||
if (miditouch !== undefined) {
|
if (miditouch !== undefined) {
|
||||||
sendAftertouch(miditouch, device, midichan, targetTime);
|
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midicmd
|
// Handle midicmd
|
||||||
if (hap.whole.begin + 0 === 0) {
|
if (hap.whole.begin + 0 === 0) {
|
||||||
// we need to start here because we have the timing info
|
// we need to start here because we have the timing info
|
||||||
scheduleAtTime(() => {
|
device.sendStart({ time: timeOffsetString });
|
||||||
device.sendStart();
|
|
||||||
}, targetTime);
|
|
||||||
}
|
}
|
||||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||||
scheduleAtTime(() => {
|
device.sendClock({ time: timeOffsetString });
|
||||||
device.sendClock();
|
|
||||||
}, targetTime);
|
|
||||||
} else if (['start'].includes(midicmd)) {
|
} else if (['start'].includes(midicmd)) {
|
||||||
scheduleAtTime(() => {
|
device.sendStart({ time: timeOffsetString });
|
||||||
device.sendStart();
|
|
||||||
}, targetTime);
|
|
||||||
} else if (['stop'].includes(midicmd)) {
|
} else if (['stop'].includes(midicmd)) {
|
||||||
scheduleAtTime(() => {
|
device.sendStop({ time: timeOffsetString });
|
||||||
device.sendStop();
|
|
||||||
}, targetTime);
|
|
||||||
} else if (['continue'].includes(midicmd)) {
|
} else if (['continue'].includes(midicmd)) {
|
||||||
scheduleAtTime(() => {
|
device.sendContinue({ time: timeOffsetString });
|
||||||
device.sendContinue();
|
|
||||||
}, targetTime);
|
|
||||||
} else if (Array.isArray(midicmd)) {
|
} else if (Array.isArray(midicmd)) {
|
||||||
if (midicmd[0] === 'progNum') {
|
if (midicmd[0] === 'progNum') {
|
||||||
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
||||||
} else if (midicmd[0] === 'cc') {
|
} else if (midicmd[0] === 'cc') {
|
||||||
if (midicmd.length === 2) {
|
if (midicmd.length === 2) {
|
||||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
||||||
}
|
}
|
||||||
} else if (midicmd[0] === 'sysex') {
|
} else if (midicmd[0] === 'sysex') {
|
||||||
if (midicmd.length === 3) {
|
if (midicmd.length === 3) {
|
||||||
const [_, id, data] = midicmd;
|
const [_, id, data] = midicmd;
|
||||||
sendSysex(id, data, device, targetTime);
|
sendSysex(id, data, device, timeOffsetString);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,9 +493,6 @@ export async function midin(input) {
|
|||||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
// ensure refs for this input are initialized
|
|
||||||
if (!refs[input]) {
|
|
||||||
refs[input] = {};
|
refs[input] = {};
|
||||||
}
|
}
|
||||||
const cc = (cc) => ref(() => refs[input][cc] || 0);
|
const cc = (cc) => ref(() => refs[input][cc] || 0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/midi",
|
"name": "@strudel/midi",
|
||||||
"version": "1.2.6",
|
"version": "1.2.3",
|
||||||
"description": "Midi API for strudel",
|
"description": "Midi API for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -35,8 +35,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, bench } from 'vitest';
|
import { describe, bench } from 'vitest';
|
||||||
|
|
||||||
import { calculateSteps } from '../../core/index.mjs';
|
import { calculateTactus } from '../../core/index.mjs';
|
||||||
import { mini } from '../index.mjs';
|
import { mini } from '../index.mjs';
|
||||||
|
|
||||||
describe('mini', () => {
|
describe('mini', () => {
|
||||||
calculateSteps(true);
|
calculateTactus(true);
|
||||||
bench(
|
bench(
|
||||||
'+tactus',
|
'+tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -13,7 +13,7 @@ describe('mini', () => {
|
|||||||
{ time: 1000 },
|
{ time: 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
calculateSteps(false);
|
calculateTactus(false);
|
||||||
bench(
|
bench(
|
||||||
'-tactus',
|
'-tactus',
|
||||||
() => {
|
() => {
|
||||||
@@ -21,5 +21,5 @@ describe('mini', () => {
|
|||||||
},
|
},
|
||||||
{ time: 1000 },
|
{ time: 1000 },
|
||||||
);
|
);
|
||||||
calculateSteps(true);
|
calculateTactus(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/mini",
|
"name": "@strudel/mini",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Mini notation for strudel",
|
"description": "Mini notation for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -38,8 +38,5 @@
|
|||||||
"peggy": "^4.2.0",
|
"peggy": "^4.2.0",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ export class MondoParser {
|
|||||||
close_curly: /^\}/,
|
close_curly: /^\}/,
|
||||||
number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
|
number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
|
||||||
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
|
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
|
||||||
op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? ..
|
op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? ..
|
||||||
// dollar: /^\$/,
|
// dollar: /^\$/,
|
||||||
pipe: /^#/,
|
pipe: /^#/,
|
||||||
stack: /^[,$]/,
|
stack: /^[,$]/,
|
||||||
or: /^[|]/,
|
or: /^[|]/,
|
||||||
plain: /^[a-zA-Z0-9-~_^#]+/,
|
plain: /^[a-zA-Z0-9-~_^#]+/,
|
||||||
};
|
};
|
||||||
op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']];
|
|
||||||
// matches next token
|
// matches next token
|
||||||
next_token(code, offset = 0) {
|
next_token(code, offset = 0) {
|
||||||
for (let type in this.token_types) {
|
for (let type in this.token_types) {
|
||||||
@@ -151,9 +150,9 @@ export class MondoParser {
|
|||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
desugar_ops(children, types) {
|
desugar_ops(children) {
|
||||||
while (true) {
|
while (true) {
|
||||||
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
|
let opIndex = children.findIndex((child) => child.type === 'op');
|
||||||
if (opIndex === -1) break;
|
if (opIndex === -1) break;
|
||||||
const op = { type: 'plain', value: children[opIndex].value };
|
const op = { type: 'plain', value: children[opIndex].value };
|
||||||
if (opIndex === children.length - 1) {
|
if (opIndex === children.length - 1) {
|
||||||
@@ -264,10 +263,8 @@ export class MondoParser {
|
|||||||
// the type we've removed before splitting needs to be added back
|
// the type we've removed before splitting needs to be added back
|
||||||
children = [{ type: 'plain', value: type }, ...children];
|
children = [{ type: 'plain', value: type }, ...children];
|
||||||
}
|
}
|
||||||
// for each precendence group, call desugar_ops once
|
children = this.desugar_ops(children);
|
||||||
this.op_precedence.forEach((ops) => {
|
// children = this.desugar_pipes(children, (children) => this.desugar_dollars(children));
|
||||||
children = this.desugar_ops(children, ops);
|
|
||||||
});
|
|
||||||
children = this.desugar_pipes(children);
|
children = this.desugar_pipes(children);
|
||||||
return children;
|
return children;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mondolang",
|
"name": "mondolang",
|
||||||
"version": "1.1.1",
|
"version": "1.1.0",
|
||||||
"description": "a language for functional composition that translates to js",
|
"description": "a language for functional composition that translates to js",
|
||||||
"main": "mondo.mjs",
|
"main": "mondo.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ describe('mondo sugar', () => {
|
|||||||
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
|
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
|
||||||
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
|
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
|
||||||
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
|
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
|
||||||
it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)'));
|
|
||||||
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
|
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
|
||||||
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
|
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
|
||||||
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
|
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import {
|
|||||||
slow,
|
slow,
|
||||||
seq,
|
seq,
|
||||||
stepcat,
|
stepcat,
|
||||||
replicate,
|
extend,
|
||||||
expand,
|
expand,
|
||||||
pace,
|
pace,
|
||||||
chooseIn,
|
chooseIn,
|
||||||
degradeBy,
|
degradeBy,
|
||||||
silence,
|
silence,
|
||||||
bjork,
|
|
||||||
} from '@strudel/core';
|
} from '@strudel/core';
|
||||||
import { registerLanguage } from '@strudel/transpiler';
|
import { registerLanguage } from '@strudel/transpiler';
|
||||||
import { MondoRunner } from 'mondolang';
|
import { MondoRunner } from 'mondolang';
|
||||||
@@ -37,14 +36,12 @@ lib.square = (...args) => stepcat(...args).setSteps(1);
|
|||||||
lib.angle = (...args) => stepcat(...args).pace(1);
|
lib.angle = (...args) => stepcat(...args).pace(1);
|
||||||
lib['*'] = fast;
|
lib['*'] = fast;
|
||||||
lib['/'] = slow;
|
lib['/'] = slow;
|
||||||
lib['!'] = replicate;
|
lib['!'] = extend;
|
||||||
lib['@'] = expand;
|
lib['@'] = expand;
|
||||||
lib['%'] = pace;
|
lib['%'] = pace;
|
||||||
lib['?'] = degradeBy; // todo: default 0.5 not working..
|
lib['?'] = degradeBy; // todo: default 0.5 not working..
|
||||||
lib['&'] = bjork;
|
|
||||||
lib[':'] = tail;
|
lib[':'] = tail;
|
||||||
lib['..'] = range;
|
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) => 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
|
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
|
||||||
|
|
||||||
@@ -87,7 +84,7 @@ function evaluator(node, scope) {
|
|||||||
let pat;
|
let pat;
|
||||||
if (type === 'plain' && typeof variable !== 'undefined') {
|
if (type === 'plain' && typeof variable !== 'undefined') {
|
||||||
// some function names are not patternable, so we skip reification here
|
// some function names are not patternable, so we skip reification here
|
||||||
if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all', 'setcpm', 'setcps'].includes(value)) {
|
if (['!', 'extend', '@', 'expand', 'square', 'angle'].includes(value)) {
|
||||||
return variable;
|
return variable;
|
||||||
}
|
}
|
||||||
pat = reify(variable);
|
pat = reify(variable);
|
||||||
@@ -110,7 +107,7 @@ export function mondo(code, offset = 0) {
|
|||||||
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
|
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
|
||||||
}
|
}
|
||||||
|
|
||||||
export let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
|
||||||
|
|
||||||
export const mondi = (str, offset) => {
|
export const mondi = (str, offset) => {
|
||||||
const code = `[${str}]`;
|
const code = `[${str}]`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/mondo",
|
"name": "@strudel/mondo",
|
||||||
"version": "1.1.5",
|
"version": "1.1.0",
|
||||||
"description": "mondo notation for strudel",
|
"description": "mondo notation for strudel",
|
||||||
"main": "mondough.mjs",
|
"main": "mondough.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -40,8 +40,5 @@
|
|||||||
"mondo": "*",
|
"mondo": "*",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { dependencies } from './package.json';
|
//import { dependencies } from './package.json';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
|
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [...Object.keys(dependencies)],
|
// external: [...Object.keys(dependencies)],
|
||||||
},
|
},
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/motion",
|
"name": "@strudel/motion",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "DeviceMotion API for strudel",
|
"description": "DeviceMotion API for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Pattern.prototype.mqtt = function (
|
|||||||
cx.connect(props);
|
cx.connect(props);
|
||||||
}
|
}
|
||||||
return this.withHap((hap) => {
|
return this.withHap((hap) => {
|
||||||
const onTrigger = (hap, currentTime, cps, targetTime) => {
|
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
|
||||||
let msg_topic = topic;
|
let msg_topic = topic;
|
||||||
if (!cx || !cx.isConnected()) {
|
if (!cx || !cx.isConnected()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/mqtt",
|
"name": "@strudel/mqtt",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "MQTT API for strudel",
|
"description": "MQTT API for strudel",
|
||||||
"main": "mqtt.mjs",
|
"main": "mqtt.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -34,8 +34,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-26
@@ -4,13 +4,21 @@ OSC output for strudel patterns! Currently only tested with super collider / sup
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Assuming you have [node.js](https://nodejs.org/) installed, you can run the osc bridge server via:
|
OSC will only work if you run the REPL locally + the OSC server besides it:
|
||||||
|
|
||||||
```sh
|
From the project root:
|
||||||
npx @strudel/osc
|
|
||||||
|
```js
|
||||||
|
npm run repl
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see something like:
|
and in a seperate shell:
|
||||||
|
|
||||||
|
```js
|
||||||
|
npm run osc
|
||||||
|
```
|
||||||
|
|
||||||
|
This should give you
|
||||||
|
|
||||||
```log
|
```log
|
||||||
osc client running on port 57120
|
osc client running on port 57120
|
||||||
@@ -18,32 +26,14 @@ osc server running on port 57121
|
|||||||
websocket server running on port 8080
|
websocket server running on port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### --port
|
Now open Supercollider (with the super dirt startup file)
|
||||||
|
|
||||||
By default it will use port 57120 for the osc client, which is what [superdirt](https://github.com/musikinformatik/SuperDirt) uses. You can change it via the `--port` option:
|
Now open the REPL and type:
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @strudel/osc --port 7771 # classic dirt
|
|
||||||
```
|
|
||||||
|
|
||||||
### --debug
|
|
||||||
|
|
||||||
To log all incoming osc messages, add the `--debug` flag:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @strudel/osc --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage in Strudel
|
|
||||||
|
|
||||||
To test it in strudel, you have can use `all(osc)` to send all events through osc:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
$: s("bd*4")
|
s("<bd sd> hh").osc()
|
||||||
|
|
||||||
all(osc)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
|
or just [click here](https://strudel.cc/#cygiPGJkIHNkPiBoaCIpLm9zYygp)...
|
||||||
|
|
||||||
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
||||||
|
|||||||
+21
-20
@@ -4,25 +4,28 @@ 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/>.
|
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 { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
import OSC from 'osc-js';
|
||||||
|
|
||||||
|
import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
||||||
|
|
||||||
let connection; // Promise<OSC>
|
let connection; // Promise<OSC>
|
||||||
function connect() {
|
function connect() {
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
// make sure this runs only once
|
// make sure this runs only once
|
||||||
connection = new Promise((resolve, reject) => {
|
connection = new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket('ws://localhost:8080');
|
const osc = new OSC();
|
||||||
ws.addEventListener('open', (event) => {
|
osc.open();
|
||||||
logger(`[osc] websocket connected`);
|
osc.on('open', () => {
|
||||||
resolve(ws);
|
const url = osc.options?.plugin?.socket?.url;
|
||||||
|
logger(`[osc] connected${url ? ` to ${url}` : ''}`);
|
||||||
|
resolve(osc);
|
||||||
});
|
});
|
||||||
ws.addEventListener('close', (event) => {
|
osc.on('close', () => {
|
||||||
logger(`[osc] websocket closed`);
|
|
||||||
connection = undefined; // allows new connection afterwards
|
connection = undefined; // allows new connection afterwards
|
||||||
console.log('[osc] disconnected');
|
console.log('[osc] disconnected');
|
||||||
reject('OSC connection closed');
|
reject('OSC connection closed');
|
||||||
});
|
});
|
||||||
ws.addEventListener('error', (err) => reject(err));
|
osc.on('error', (err) => reject(err));
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
connection = undefined;
|
connection = undefined;
|
||||||
throw new Error('Could not connect to OSC server. Is it running?');
|
throw new Error('Could not connect to OSC server. Is it running?');
|
||||||
@@ -57,20 +60,16 @@ export function parseControlsFromHap(hap, cps) {
|
|||||||
|
|
||||||
const collator = new ClockCollator({});
|
const collator = new ClockCollator({});
|
||||||
|
|
||||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) {
|
||||||
const ws = await connect();
|
const osc = await connect();
|
||||||
const controls = parseControlsFromHap(hap, cps);
|
const controls = parseControlsFromHap(hap, cps);
|
||||||
const keyvals = Object.entries(controls).flat();
|
const keyvals = Object.entries(controls).flat();
|
||||||
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
|
|
||||||
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
|
|
||||||
|
|
||||||
if ('oschost' in hap.value) {
|
const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000);
|
||||||
msg['host'] = hap.value['oschost'];
|
const message = new OSC.Message('/dirt/play', ...keyvals);
|
||||||
}
|
const bundle = new OSC.Bundle([message], ts);
|
||||||
if ('oscport' in hap.value) {
|
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
|
||||||
msg['port'] = hap.value['oscport'];
|
osc.send(bundle);
|
||||||
}
|
|
||||||
ws.send(JSON.stringify(msg));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,4 +81,6 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
|||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
*/
|
*/
|
||||||
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
|
Pattern.prototype.osc = function () {
|
||||||
|
return this.onTrigger(oscTrigger);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/osc",
|
"name": "@strudel/osc",
|
||||||
"version": "1.3.0",
|
"version": "1.2.2",
|
||||||
"description": "OSC messaging for strudel",
|
"description": "OSC messaging for strudel",
|
||||||
"main": "osc.mjs",
|
"main": "osc.mjs",
|
||||||
"bin": "./server.js",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"main": "dist/index.mjs"
|
"main": "dist/index.mjs"
|
||||||
@@ -38,14 +37,10 @@
|
|||||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
"osc": "^2.4.5",
|
"osc-js": "^2.4.1"
|
||||||
"ws": "^8.18.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
+24
-54
@@ -1,64 +1,34 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
server.js - <short description TODO>
|
server.js - <short description TODO>
|
||||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/server.js>
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/server.js>
|
||||||
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/>.
|
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 OSC from 'osc-js';
|
import OSC from 'osc-js';
|
||||||
|
|
||||||
import { WebSocketServer } from 'ws';
|
const config = {
|
||||||
import osc from 'osc';
|
receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client
|
||||||
|
udpServer: {
|
||||||
|
host: 'localhost', // @param {string} Hostname of udp server to bind to
|
||||||
|
port: 57121, // @param {number} Port of udp client for messaging
|
||||||
|
// enabling the following line will receive tidal messages:
|
||||||
|
// port: 57120, // @param {number} Port of udp client for messaging
|
||||||
|
exclusive: false, // @param {boolean} Exclusive flag
|
||||||
|
},
|
||||||
|
udpClient: {
|
||||||
|
host: 'localhost', // @param {string} Hostname of udp client for messaging
|
||||||
|
port: 57120, // @param {number} Port of udp client for messaging
|
||||||
|
},
|
||||||
|
wsServer: {
|
||||||
|
host: 'localhost', // @param {string} Hostname of WebSocket server
|
||||||
|
port: 8080, // @param {number} Port of WebSocket server
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const WS_PORT = 8080; // WebSocket server port
|
const osc = new OSC({ plugin: new OSC.BridgePlugin(config) });
|
||||||
const OSC_REMOTE_IP = '127.0.0.1';
|
|
||||||
const OSC_REMOTE_PORT = 57120;
|
|
||||||
|
|
||||||
const udpPort = new osc.UDPPort({
|
osc.open(); // start a WebSocket server on port 8080
|
||||||
localAddress: '0.0.0.0',
|
|
||||||
localPort: 0,
|
|
||||||
remoteAddress: OSC_REMOTE_IP,
|
|
||||||
remotePort: OSC_REMOTE_PORT,
|
|
||||||
});
|
|
||||||
|
|
||||||
udpPort.open();
|
console.log('osc client running on port', config.udpClient.port);
|
||||||
console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`);
|
console.log('osc server running on port', config.udpServer.port);
|
||||||
|
console.log('websocket server running on port', config.wsServer.port);
|
||||||
udpPort.on('error', (e) => {
|
|
||||||
console.log('Error: ', e);
|
|
||||||
});
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: WS_PORT });
|
|
||||||
console.log(`[Listening WS] ws://localhost:${WS_PORT}`);
|
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
|
||||||
console.log('New WebSocket connection');
|
|
||||||
|
|
||||||
ws.on('message', (message) => {
|
|
||||||
let osc_host = '127.0.0.1';
|
|
||||||
let osc_port = 57120;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
if ('host' in data) {
|
|
||||||
osc_host = data['host'];
|
|
||||||
}
|
|
||||||
if ('port' in data) {
|
|
||||||
osc_port = data['port'];
|
|
||||||
}
|
|
||||||
let msg = { address: data['address'], args: data['args'] };
|
|
||||||
if ('timestamp' in data) {
|
|
||||||
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
|
|
||||||
}
|
|
||||||
|
|
||||||
udpPort.send(msg, osc_host, osc_port);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing message:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
console.log('WebSocket connection closed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||||
import { isTauri } from '../desktopbridge/utils.mjs'; */
|
import { isTauri } from '../desktopbridge/utils.mjs';
|
||||||
import { oscTrigger } from './osc.mjs';
|
import { oscTrigger } from './osc.mjs';
|
||||||
|
|
||||||
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
|
const trigger = isTauri() ? oscTriggerTauri : oscTrigger;
|
||||||
|
|
||||||
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
||||||
const currentTime = performance.now() / 1000;
|
const currentTime = performance.now() / 1000;
|
||||||
return trigger(hap, currentTime, cps, targetTime);
|
return trigger(null, hap, currentTime, cps, targetTime);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/reference",
|
"name": "@strudel/reference",
|
||||||
"version": "1.2.1",
|
"version": "1.2.0",
|
||||||
"description": "Headless reference of all strudel functions",
|
"description": "Headless reference of all strudel functions",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/repl",
|
"name": "@strudel/repl",
|
||||||
"version": "1.2.7",
|
"version": "1.2.3",
|
||||||
"description": "Strudel REPL as a Web Component",
|
"description": "Strudel REPL as a Web Component",
|
||||||
"module": "index.mjs",
|
"module": "index.mjs",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -48,8 +48,5 @@
|
|||||||
"@rollup/plugin-replace": "^6.0.2",
|
"@rollup/plugin-replace": "^6.0.2",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,10 @@ export async function prebake() {
|
|||||||
// import('@strudel/osc'),
|
// import('@strudel/osc'),
|
||||||
);
|
);
|
||||||
// load samples
|
// load samples
|
||||||
const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main';
|
const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/';
|
||||||
|
|
||||||
// TODO: move this onto the strudel repo
|
// TODO: move this onto the strudel repo
|
||||||
const ts = 'https://raw.githubusercontent.com/todepond/samples/main';
|
const ts = 'https://raw.githubusercontent.com/todepond/samples/main/';
|
||||||
|
|
||||||
const tc = 'https://raw.githubusercontent.com/tidalcycles/uzu-drumkit/main';
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
modulesLoading,
|
modulesLoading,
|
||||||
registerSynthSounds(),
|
registerSynthSounds(),
|
||||||
@@ -39,9 +36,9 @@ export async function prebake() {
|
|||||||
samples(`${ds}/tidal-drum-machines.json`),
|
samples(`${ds}/tidal-drum-machines.json`),
|
||||||
samples(`${ds}/piano.json`),
|
samples(`${ds}/piano.json`),
|
||||||
samples(`${ds}/Dirt-Samples.json`),
|
samples(`${ds}/Dirt-Samples.json`),
|
||||||
|
samples(`${ds}/EmuSP12.json`),
|
||||||
samples(`${ds}/vcsl.json`),
|
samples(`${ds}/vcsl.json`),
|
||||||
samples(`${ds}/mridangam.json`),
|
samples(`${ds}/mridangam.json`),
|
||||||
samples(`${tc}/strudel.json`),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
aliasBank(`${ts}/tidal-drum-machines-alias.json`);
|
aliasBank(`${ts}/tidal-drum-machines-alias.json`);
|
||||||
|
|||||||
@@ -20,13 +20,3 @@ samples('http://localhost:5432')
|
|||||||
LOG=1 npx @strudel/sampler # adds logging
|
LOG=1 npx @strudel/sampler # adds logging
|
||||||
PORT=5555 npx @strudel/sampler # changes port
|
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",
|
"name": "@strudel/sampler",
|
||||||
"version": "0.2.3",
|
"version": "0.2.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"tidalcycles",
|
"tidalcycles",
|
||||||
@@ -15,8 +15,5 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cowsay": "^1.6.0"
|
"cowsay": "^1.6.0"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import cowsay from 'cowsay';
|
import cowsay from 'cowsay';
|
||||||
import { createReadStream, existsSync, writeFileSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { readdir } from 'fs/promises';
|
import { readdir } from 'fs/promises';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { join, resolve, sep } from 'path';
|
import { join, sep } from 'path';
|
||||||
import readline from 'readline';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
const LOG = !!process.env.LOG || false;
|
const LOG = !!process.env.LOG || false;
|
||||||
const PORT = process.env.PORT || 5432;
|
|
||||||
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
|
|
||||||
|
|
||||||
const isAudioFile = (f) => {
|
console.log(
|
||||||
const ext = f.split('.').slice(-1)[0].toLowerCase();
|
cowsay.say({
|
||||||
return VALID_AUDIO_EXTENSIONS.includes(ext);
|
text: 'welcome to @strudel/sampler',
|
||||||
};
|
e: 'oO',
|
||||||
|
T: 'U ',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function getFilesInDirectory(directory) {
|
async function getFilesInDirectory(directory) {
|
||||||
let files = [];
|
let files = [];
|
||||||
@@ -28,89 +29,42 @@ async function getFilesInDirectory(directory) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile);
|
const subFiles = (await getFilesInDirectory(fullPath)).filter((f) =>
|
||||||
|
['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()),
|
||||||
|
);
|
||||||
files = files.concat(subFiles);
|
files = files.concat(subFiles);
|
||||||
LOG && console.log(`${dirent.name} (${subFiles.length})`);
|
LOG && console.log(`${dirent.name} (${subFiles.length})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
LOG && console.warn(`skipped due to error: ${fullPath}`);
|
LOG && console.warn(`skipped due to error: ${fullPath}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
isAudioFile(fullPath) && files.push(fullPath);
|
files.push(fullPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBanks(directory, flat = false) {
|
async function getBanks(directory) {
|
||||||
let files = await getFilesInDirectory(directory);
|
let files = await getFilesInDirectory(directory);
|
||||||
let banks = {};
|
let banks = {};
|
||||||
directory = directory.split(sep).join('/');
|
directory = directory.split(sep).join('/');
|
||||||
files = files.map((path) => {
|
files = files.map((path) => {
|
||||||
path = path.split(sep).join('/');
|
path = path.split(sep).join('/');
|
||||||
const subDir = path.replace(directory, '');
|
const [bank] = path.split('/').slice(-2);
|
||||||
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] || [];
|
banks[bank] = banks[bank] || [];
|
||||||
banks[bank].push(subDir);
|
const relativeUrl = path.replace(directory, '');
|
||||||
return subDir;
|
banks[bank].push(relativeUrl);
|
||||||
|
return relativeUrl;
|
||||||
});
|
});
|
||||||
|
banks._base = `http://localhost:5432`;
|
||||||
return { banks, files };
|
return { banks, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
// eslint-disable-next-line
|
||||||
|
const directory = process.cwd();
|
||||||
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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
const { banks, files } = await getBanks(directory, getArgValue('--flat'));
|
const { banks, files } = await getBanks(directory);
|
||||||
if (req.url === '/') {
|
if (req.url === '/') {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
return res.end(JSON.stringify(banks));
|
return res.end(JSON.stringify(banks));
|
||||||
@@ -118,7 +72,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
let subpath = decodeURIComponent(req.url);
|
let subpath = decodeURIComponent(req.url);
|
||||||
const filePath = join(directory, subpath.split('/').join(sep));
|
const filePath = join(directory, subpath.split('/').join(sep));
|
||||||
|
|
||||||
// console.log('GET:', filePath);
|
//console.log('GET:', filePath);
|
||||||
const isFound = existsSync(filePath);
|
const isFound = existsSync(filePath);
|
||||||
if (!isFound) {
|
if (!isFound) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
@@ -134,6 +88,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
readStream.pipe(res);
|
readStream.pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const PORT = process.env.PORT || 5432;
|
||||||
const IP_ADDRESS = '0.0.0.0';
|
const IP_ADDRESS = '0.0.0.0';
|
||||||
let IP;
|
let IP;
|
||||||
const networkInterfaces = os.networkInterfaces();
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/serial",
|
"name": "@strudel/serial",
|
||||||
"version": "1.2.5",
|
"version": "1.2.2",
|
||||||
"description": "Webserial API for strudel",
|
"description": "Webserial API for strudel",
|
||||||
"main": "serial.mjs",
|
"main": "serial.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ export default {
|
|||||||
],
|
],
|
||||||
gm_synth_bass_1: [
|
gm_synth_bass_1: [
|
||||||
// Synth Bass 1: Bass
|
// Synth Bass 1: Bass
|
||||||
// '0380_Aspirin_sf2_file', // broken in safari https://codeberg.org/uzu/strudel/issues/1384
|
'0380_Aspirin_sf2_file',
|
||||||
'0380_Chaos_sf2_file',
|
'0380_Chaos_sf2_file',
|
||||||
'0380_FluidR3_GM_sf2_file',
|
'0380_FluidR3_GM_sf2_file',
|
||||||
// 0380_GeneralUserGS_sf2_file // laut
|
// 0380_GeneralUserGS_sf2_file // laut
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strudel/soundfonts",
|
"name": "@strudel/soundfonts",
|
||||||
"version": "1.2.6",
|
"version": "1.2.3",
|
||||||
"description": "Soundsfont support for strudel",
|
"description": "Soundsfont support for strudel",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -37,8 +37,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
|
|||||||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||||
|
|
||||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
return this.onTrigger((time_deprecate, h, ct, cps, targetTime) => {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const note = getPlayableNoteValue(h);
|
const note = getPlayableNoteValue(h);
|
||||||
const preset = sf.presets[n % sf.presets.length];
|
const preset = sf.presets[n % sf.presets.length];
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
|
|||||||
- `decay`: seconds of decay phase
|
- `decay`: seconds of decay phase
|
||||||
- `sustain`: gain of sustain phase
|
- `sustain`: gain of sustain phase
|
||||||
- `release`: seconds of release phase
|
- `release`: seconds of release phase
|
||||||
- `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate)
|
- `deadline`: seconds until the sound should play (0 = immediate)
|
||||||
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
|
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
|
||||||
|
|
||||||
### registerSynthSounds()
|
### registerSynthSounds()
|
||||||
@@ -153,7 +153,6 @@ samples('github:tidalcycles/dirt-samples')
|
|||||||
|
|
||||||
The format is `github:<user>/<repo>/<branch>`.
|
The format is `github:<user>/<repo>/<branch>`.
|
||||||
|
|
||||||
If `<repo>` and `<branch>` are not specified, they will default to `samples` and `main` respectively.
|
|
||||||
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
|
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
|
||||||
|
|
||||||
The format is also expected to be the same as explained above.
|
The format is also expected to be the same as explained above.
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
let audioContext;
|
|
||||||
|
|
||||||
export const setDefaultAudioContext = () => {
|
|
||||||
audioContext = new AudioContext();
|
|
||||||
return audioContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAudioContext = () => {
|
|
||||||
if (!audioContext) {
|
|
||||||
return setDefaultAudioContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
return audioContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getAudioContextCurrentTime() {
|
|
||||||
return getAudioContext().currentTime;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './superdough.mjs';
|
||||||
|
|
||||||
let worklet;
|
let worklet;
|
||||||
export async function dspWorklet(ac, code) {
|
export async function dspWorklet(ac, code) {
|
||||||
@@ -74,6 +74,6 @@ export const dough = async (code) => {
|
|||||||
worklet.node.connect(ac.destination);
|
worklet.node.connect(ac.destination);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function doughTrigger(hap, currentTime, cps, targetTime) {
|
export function doughTrigger(time_deprecate, hap, currentTime, cps, targetTime) {
|
||||||
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-316
@@ -1,9 +1,5 @@
|
|||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './superdough.mjs';
|
||||||
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
import { clamp, nanFallback } from './util.mjs';
|
||||||
import { getNoiseBuffer } from './noise.mjs';
|
|
||||||
import { logger } from './logger.mjs';
|
|
||||||
|
|
||||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
|
||||||
|
|
||||||
export function gainNode(value) {
|
export function gainNode(value) {
|
||||||
const node = getAudioContext().createGain();
|
const node = getAudioContext().createGain();
|
||||||
@@ -11,13 +7,6 @@ export function gainNode(value) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function effectSend(input, effect, wet) {
|
|
||||||
const send = gainNode(wet);
|
|
||||||
input.connect(send);
|
|
||||||
send.connect(effect);
|
|
||||||
return send;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSlope = (y1, y2, x1, x2) => {
|
const getSlope = (y1, y2, x1, x2) => {
|
||||||
const denom = x2 - x1;
|
const denom = x2 - x1;
|
||||||
if (denom === 0) {
|
if (denom === 0) {
|
||||||
@@ -29,9 +18,7 @@ const getSlope = (y1, y2, x1, x2) => {
|
|||||||
export function getWorklet(ac, processor, params, config) {
|
export function getWorklet(ac, processor, params, config) {
|
||||||
const node = new AudioWorkletNode(ac, processor, config);
|
const node = new AudioWorkletNode(ac, processor, config);
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
node.parameters.get(key).value = value;
|
||||||
node.parameters.get(key).value = value;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@@ -98,35 +85,6 @@ export const getParamADSR = (
|
|||||||
param[ramp](min, end + release);
|
param[ramp](min, end + release);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 function getLfo(audioContext, begin, end, properties = {}) {
|
|
||||||
const { shape = 0, ...props } = properties;
|
|
||||||
const { dcoffset = -0.5, depth = 1 } = properties;
|
|
||||||
const lfoprops = {
|
|
||||||
frequency: 1,
|
|
||||||
depth,
|
|
||||||
skew: 0.5,
|
|
||||||
phaseoffset: 0,
|
|
||||||
time: begin,
|
|
||||||
begin,
|
|
||||||
end,
|
|
||||||
shape: getModulationShapeInput(shape),
|
|
||||||
dcoffset,
|
|
||||||
min: dcoffset * depth,
|
|
||||||
max: dcoffset * depth + depth,
|
|
||||||
curve: 1,
|
|
||||||
...props,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getWorklet(audioContext, 'lfo-processor', lfoprops);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||||
const options = {
|
const options = {
|
||||||
threshold: threshold ?? -3,
|
threshold: threshold ?? -3,
|
||||||
@@ -154,110 +112,36 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
|
|||||||
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
|
||||||
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
|
const curve = 'exponential';
|
||||||
if (depth == null) {
|
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
|
||||||
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
|
let filter;
|
||||||
depth = hasLFOParams ? defaultDepth : 0;
|
let frequencyParam;
|
||||||
}
|
|
||||||
let lfo;
|
|
||||||
if (depth) {
|
|
||||||
lfo = getLfo(audioContext, start, end, {
|
|
||||||
depth,
|
|
||||||
dcoffset,
|
|
||||||
...getLfoInputs,
|
|
||||||
});
|
|
||||||
lfo.connect(param);
|
|
||||||
}
|
|
||||||
return lfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper utility for applying standard modulators to a parameter
|
|
||||||
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
|
|
||||||
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
|
|
||||||
|
|
||||||
if (amount == null) {
|
|
||||||
const hasADSRParams = values.some((p) => p != null);
|
|
||||||
amount = hasADSRParams ? defaultAmount : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const min = offset ?? 0;
|
|
||||||
const max = amount + min;
|
|
||||||
const diff = Math.abs(max - min);
|
|
||||||
if (diff) {
|
|
||||||
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
|
|
||||||
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
|
|
||||||
}
|
|
||||||
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
|
||||||
return { lfo, disconnect: () => lfo?.disconnect() };
|
|
||||||
}
|
|
||||||
export function createFilter(context, start, end, params, cps, cycle) {
|
|
||||||
let {
|
|
||||||
frequency,
|
|
||||||
anchor,
|
|
||||||
env,
|
|
||||||
type,
|
|
||||||
model,
|
|
||||||
q = 1,
|
|
||||||
drive = 0.69,
|
|
||||||
depth,
|
|
||||||
depthfrequency,
|
|
||||||
dcoffset = -0.5,
|
|
||||||
skew,
|
|
||||||
shape,
|
|
||||||
rate,
|
|
||||||
sync,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
let frequencyParam, filter;
|
|
||||||
if (model === 'ladder') {
|
if (model === 'ladder') {
|
||||||
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
|
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
|
||||||
frequencyParam = filter.parameters.get('frequency');
|
frequencyParam = filter.parameters.get('frequency');
|
||||||
} else {
|
} else {
|
||||||
filter = context.createBiquadFilter();
|
filter = context.createBiquadFilter();
|
||||||
filter.type = type;
|
filter.type = type;
|
||||||
filter.Q.value = q;
|
filter.Q.value = Q;
|
||||||
filter.frequency.value = frequency;
|
filter.frequency.value = frequency;
|
||||||
frequencyParam = filter.frequency;
|
frequencyParam = filter.frequency;
|
||||||
}
|
}
|
||||||
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
|
|
||||||
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
|
|
||||||
// envelope is active when any of these values is set
|
// envelope is active when any of these values is set
|
||||||
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
|
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
|
||||||
// Apply ADSR to filter frequency
|
// Apply ADSR to filter frequency
|
||||||
if (hasEnvelope) {
|
if (hasEnvelope !== undefined) {
|
||||||
env = nanFallback(env, 1, true);
|
fenv = nanFallback(fenv, 1, true);
|
||||||
anchor = nanFallback(anchor, 0, true);
|
fanchor = nanFallback(fanchor, 0, true);
|
||||||
const envAbs = Math.abs(env);
|
const fenvAbs = Math.abs(fenv);
|
||||||
const offset = envAbs * anchor;
|
const offset = fenvAbs * fanchor;
|
||||||
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
||||||
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
|
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
|
||||||
if (env < 0) [min, max] = [max, min];
|
if (fenv < 0) [min, max] = [max, min];
|
||||||
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
|
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
|
||||||
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sync != null) {
|
|
||||||
rate = cps * sync;
|
|
||||||
}
|
|
||||||
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
|
|
||||||
if (hasLFO) {
|
|
||||||
depth = depth ?? 1;
|
|
||||||
const time = cycle / cps;
|
|
||||||
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
|
|
||||||
const lfoValues = {
|
|
||||||
depth: modDepth,
|
|
||||||
dcoffset,
|
|
||||||
skew,
|
|
||||||
shape,
|
|
||||||
frequency: rate ?? cps,
|
|
||||||
min: -frequency + 30,
|
|
||||||
max: 20000 - frequency,
|
|
||||||
time,
|
|
||||||
curve: 1,
|
|
||||||
};
|
|
||||||
getParamLfo(context, frequencyParam, start, end, lfoValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,22 +164,14 @@ export function drywet(dry, wet, wetAmount = 0) {
|
|||||||
let mix = ac.createGain();
|
let mix = ac.createGain();
|
||||||
dry_gain.connect(mix);
|
dry_gain.connect(mix);
|
||||||
wet_gain.connect(mix);
|
wet_gain.connect(mix);
|
||||||
return {
|
return mix;
|
||||||
node: mix,
|
|
||||||
onended: () => {
|
|
||||||
dry_gain.disconnect(mix);
|
|
||||||
wet_gain.disconnect(mix);
|
|
||||||
dry.disconnect(dry_gain);
|
|
||||||
wet.disconnect(wet_gain);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let curves = ['linear', 'exponential'];
|
let curves = ['linear', 'exponential'];
|
||||||
export function getPitchEnvelope(param, value, t, holdEnd) {
|
export function getPitchEnvelope(param, value, t, holdEnd) {
|
||||||
// envelope is active when any of these values is set
|
// envelope is active when any of these values is set
|
||||||
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
|
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
|
||||||
if (hasEnvelope === undefined) {
|
if (!hasEnvelope) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const penv = nanFallback(value.penv, 1, true);
|
const penv = nanFallback(value.penv, 1, true);
|
||||||
@@ -323,66 +199,30 @@ export function getVibratoOscillator(param, value, t) {
|
|||||||
gain.gain.value = vibmod * 100;
|
gain.gain.value = vibmod * 100;
|
||||||
vibratoOscillator.connect(gain);
|
vibratoOscillator.connect(gain);
|
||||||
gain.connect(param);
|
gain.connect(param);
|
||||||
vibratoOscillator.onended = () => {
|
|
||||||
gain.disconnect(param);
|
|
||||||
vibratoOscillator.disconnect(gain);
|
|
||||||
};
|
|
||||||
vibratoOscillator.start(t);
|
vibratoOscillator.start(t);
|
||||||
return vibratoOscillator;
|
return vibratoOscillator;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
|
|
||||||
const currentTime = audioContext.currentTime;
|
|
||||||
webAudioTimeout(audioContext, callback, currentTime, targetTime);
|
|
||||||
}
|
|
||||||
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
||||||
// a bit of a hack, but it works very well :)
|
// a bit of a hack, but it works very well :)
|
||||||
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||||
const constantNode = new ConstantSourceNode(audioContext);
|
const constantNode = audioContext.createConstantSource();
|
||||||
|
|
||||||
// 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.start(startTime);
|
||||||
constantNode.stop(stopTime);
|
constantNode.stop(stopTime);
|
||||||
|
constantNode.onended = () => {
|
||||||
|
onComplete();
|
||||||
|
};
|
||||||
return constantNode;
|
return constantNode;
|
||||||
}
|
}
|
||||||
const mod = (freq, range = 1, type = 'sine') => {
|
const mod = (freq, range = 1, type = 'sine') => {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
let osc;
|
const osc = ctx.createOscillator();
|
||||||
if (noises.includes(type)) {
|
osc.type = type;
|
||||||
osc = ctx.createBufferSource();
|
osc.frequency.value = freq;
|
||||||
osc.buffer = getNoiseBuffer(type, 2);
|
|
||||||
osc.loop = true;
|
|
||||||
} else {
|
|
||||||
osc = ctx.createOscillator();
|
|
||||||
osc.type = type;
|
|
||||||
osc.frequency.value = freq;
|
|
||||||
}
|
|
||||||
|
|
||||||
osc.start();
|
osc.start();
|
||||||
const g = gainNode(range);
|
const g = new GainNode(ctx, { gain: range });
|
||||||
osc.connect(g); // -range, range
|
osc.connect(g); // -range, range
|
||||||
return { node: g, stop: (t) => osc.stop(t), osc: osc };
|
return { node: g, stop: (t) => osc.stop(t) };
|
||||||
};
|
};
|
||||||
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
||||||
const carrfreq = frequencyparam.value;
|
const carrfreq = frequencyparam.value;
|
||||||
@@ -413,7 +253,7 @@ export function applyFM(param, value, begin) {
|
|||||||
|
|
||||||
modulator = fmmod.node;
|
modulator = fmmod.node;
|
||||||
stop = fmmod.stop;
|
stop = fmmod.stop;
|
||||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
|
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
|
||||||
// no envelope by default
|
// no envelope by default
|
||||||
modulator.connect(param);
|
modulator.connect(param);
|
||||||
} else {
|
} else {
|
||||||
@@ -434,129 +274,6 @@ export function applyFM(param, value, begin) {
|
|||||||
modulator.connect(envGain);
|
modulator.connect(envGain);
|
||||||
envGain.connect(param);
|
envGain.connect(param);
|
||||||
}
|
}
|
||||||
fmmod.osc.onended = () => {
|
|
||||||
envGain.disconnect();
|
|
||||||
modulator.disconnect();
|
|
||||||
fmmod.osc.disconnect();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { stop };
|
return { stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saturation curves
|
|
||||||
|
|
||||||
const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
|
|
||||||
const _mod = (n, m) => ((n % m) + m) % m;
|
|
||||||
|
|
||||||
const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x));
|
|
||||||
const _soft = (x, k) => Math.tanh(x * (1 + k));
|
|
||||||
const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
|
|
||||||
|
|
||||||
const _fold = (x, k) => {
|
|
||||||
// Closed form folding for audio rate
|
|
||||||
let y = (1 + 0.5 * k) * x;
|
|
||||||
const window = _mod(y + 1, 4);
|
|
||||||
return 1 - Math.abs(window - 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k));
|
|
||||||
|
|
||||||
const _cubic = (x, k) => {
|
|
||||||
const t = __squash(Math.log1p(k));
|
|
||||||
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1)
|
|
||||||
return _soft(cubic, k);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _diode = (x, k, asym = false) => {
|
|
||||||
const g = 1 + 2 * k; // gain
|
|
||||||
const t = __squash(Math.log1p(k));
|
|
||||||
const bias = 0.07 * t;
|
|
||||||
const pos = _soft(x + bias, 2 * k);
|
|
||||||
const neg = _soft(asym ? bias : -x + bias, 2 * k);
|
|
||||||
const y = pos - neg;
|
|
||||||
// We divide by the derivative at 0 so that the distortion is roughly
|
|
||||||
// the identity map near 0 => small values are preserved and undistorted
|
|
||||||
const sech = 1 / Math.cosh(g * bias);
|
|
||||||
const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2
|
|
||||||
const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x
|
|
||||||
return _soft(y / denom, k);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _asym = (x, k) => _diode(x, k, true);
|
|
||||||
|
|
||||||
const _chebyshev = (x, k) => {
|
|
||||||
const kl = 10 * Math.log1p(k);
|
|
||||||
let tnm1 = 1;
|
|
||||||
let tnm2 = x;
|
|
||||||
let tn;
|
|
||||||
let y = 0;
|
|
||||||
for (let i = 1; i < 64; i++) {
|
|
||||||
if (i < 2) {
|
|
||||||
// Already set inital conditions
|
|
||||||
y += i == 0 ? tnm1 : tnm2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition
|
|
||||||
tnm2 = tnm1;
|
|
||||||
tnm1 = tn;
|
|
||||||
if (i % 2 === 0) {
|
|
||||||
y += Math.min((1.3 * kl) / i, 2) * tn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Soft clip
|
|
||||||
return _soft(y, kl / 20);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const distortionAlgorithms = {
|
|
||||||
scurve: _scurve,
|
|
||||||
soft: _soft,
|
|
||||||
hard: _hard,
|
|
||||||
cubic: _cubic,
|
|
||||||
diode: _diode,
|
|
||||||
asym: _asym,
|
|
||||||
fold: _fold,
|
|
||||||
sinefold: _sineFold,
|
|
||||||
chebyshev: _chebyshev,
|
|
||||||
};
|
|
||||||
const _algoNames = Object.freeze(Object.keys(distortionAlgorithms));
|
|
||||||
|
|
||||||
export const getDistortionAlgorithm = (algo) => {
|
|
||||||
let index = algo;
|
|
||||||
if (typeof algo === 'string') {
|
|
||||||
index = _algoNames.indexOf(algo);
|
|
||||||
if (index === -1) {
|
|
||||||
logger(`[superdough] Could not find waveshaping algorithm ${algo}.
|
|
||||||
Available options are ${_algoNames.join(', ')}.
|
|
||||||
Defaulting to ${_algoNames[0]}.`);
|
|
||||||
index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number
|
|
||||||
return distortionAlgorithms[name];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDistortion = (distort, postgain, algorithm) => {
|
|
||||||
return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
|
||||||
let { note, freq, octave = 0 } = value;
|
|
||||||
note = note || defaultNote;
|
|
||||||
if (typeof note === 'string') {
|
|
||||||
note = noteToMidi(note); // e.g. c3 => 48
|
|
||||||
}
|
|
||||||
// get frequency
|
|
||||||
if (!freq && typeof note === 'number') {
|
|
||||||
freq = midiToFreq(note); // + 48);
|
|
||||||
}
|
|
||||||
freq *= Math.pow(2, octave);
|
|
||||||
return Number(freq);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const destroyAudioWorkletNode = (node) => {
|
|
||||||
if (node == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.disconnect();
|
|
||||||
node.parameters.get('end')?.setValueAtTime(0, 0);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -11,5 +11,3 @@ export * from './synth.mjs';
|
|||||||
export * from './zzfx.mjs';
|
export * from './zzfx.mjs';
|
||||||
export * from './logger.mjs';
|
export * from './logger.mjs';
|
||||||
export * from './dspworklet.mjs';
|
export * from './dspworklet.mjs';
|
||||||
export * from './audioContext.mjs';
|
|
||||||
export * from './wavetable.mjs';
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
let log = (msg) => console.log(msg);
|
let log = (msg) => console.log(msg);
|
||||||
|
|
||||||
export function errorLogger(e, origin = 'superdough') {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
logger(`[${origin}] error: ${e.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logger = (...args) => log(...args);
|
export const logger = (...args) => log(...args);
|
||||||
|
|
||||||
export const setLogger = (fn) => {
|
export const setLogger = (fn) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { drywet } from './helpers.mjs';
|
import { drywet } from './helpers.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './superdough.mjs';
|
||||||
|
|
||||||
let noiseCache = {};
|
let noiseCache = {};
|
||||||
|
|
||||||
// lazy generates noise buffers and keeps them forever
|
// lazy generates noise buffers and keeps them forever
|
||||||
export function getNoiseBuffer(type, density) {
|
function getNoiseBuffer(type, density) {
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
if (noiseCache[type]) {
|
if (noiseCache[type]) {
|
||||||
return noiseCache[type];
|
return noiseCache[type];
|
||||||
@@ -65,9 +65,8 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
|
|||||||
export function getNoiseMix(inputNode, wet, t) {
|
export function getNoiseMix(inputNode, wet, t) {
|
||||||
const noiseOscillator = getNoiseOscillator('pink', t);
|
const noiseOscillator = getNoiseOscillator('pink', t);
|
||||||
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
|
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
|
||||||
noiseOscillator.node.onended = () => noiseMix.onended();
|
|
||||||
return {
|
return {
|
||||||
node: noiseMix.node,
|
node: noiseMix,
|
||||||
stop: (time) => noiseOscillator?.stop(time),
|
stop: (time) => noiseOscillator?.stop(time),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "superdough",
|
"name": "superdough",
|
||||||
"version": "1.2.6",
|
"version": "1.2.3",
|
||||||
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -37,8 +37,5 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanostores": "^0.11.3"
|
"nanostores": "^0.11.3"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import reverbGen from './reverbGen.mjs';
|
import reverbGen from './reverbGen.mjs';
|
||||||
import { clamp } from './util.mjs';
|
|
||||||
|
|
||||||
if (typeof AudioContext !== 'undefined') {
|
if (typeof AudioContext !== 'undefined') {
|
||||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
AudioContext.prototype.adjustLength = function (duration, buffer) {
|
||||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
|
||||||
const newLength = buffer.sampleRate * duration;
|
const newLength = buffer.sampleRate * duration;
|
||||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||||
@@ -11,30 +9,22 @@ if (typeof AudioContext !== 'undefined') {
|
|||||||
let newData = newBuffer.getChannelData(channel);
|
let newData = newBuffer.getChannelData(channel);
|
||||||
|
|
||||||
for (let i = 0; i < newLength; i++) {
|
for (let i = 0; i < newLength; i++) {
|
||||||
// loop the buffer around to prevent
|
newData[i] = oldData[i] || 0;
|
||||||
let position = (sampleOffset + i * Math.abs(speed)) % oldData.length;
|
|
||||||
if (speed < 1) {
|
|
||||||
position = position * -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
newData[i] = oldData.at(position) || 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newBuffer;
|
return newBuffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
|
||||||
const convolver = this.createConvolver();
|
const convolver = this.createConvolver();
|
||||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
|
||||||
convolver.duration = d;
|
convolver.duration = d;
|
||||||
convolver.fade = fade;
|
convolver.fade = fade;
|
||||||
convolver.lp = lp;
|
convolver.lp = lp;
|
||||||
convolver.dim = dim;
|
convolver.dim = dim;
|
||||||
convolver.ir = ir;
|
convolver.ir = ir;
|
||||||
convolver.irspeed = irspeed;
|
|
||||||
convolver.irbegin = irbegin;
|
|
||||||
if (ir) {
|
if (ir) {
|
||||||
convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin);
|
convolver.buffer = this.adjustLength(d, ir);
|
||||||
} else {
|
} else {
|
||||||
reverbGen.generateReverb(
|
reverbGen.generateReverb(
|
||||||
{
|
{
|
||||||
@@ -51,7 +41,7 @@ if (typeof AudioContext !== 'undefined') {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
|
convolver.generate(duration, fade, lp, dim, ir);
|
||||||
return convolver;
|
return convolver;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
|
|||||||
player.start();
|
player.start();
|
||||||
context.oncomplete = function (event) {
|
context.oncomplete = function (event) {
|
||||||
callback(event.renderedBuffer);
|
callback(event.renderedBuffer);
|
||||||
filter.disconnect();
|
|
||||||
player.disconnect();
|
|
||||||
};
|
};
|
||||||
context.startRendering();
|
context.startRendering();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
import { noteToMidi, valueToMidi, getSoundIndex } from './util.mjs';
|
||||||
import { registerSound, registerWaveTable } from './index.mjs';
|
import { getAudioContext, registerSound } from './index.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
|
||||||
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
|
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
@@ -23,16 +22,39 @@ function humanFileSize(bytes, si) {
|
|||||||
return bytes.toFixed(1) + ' ' + units[u];
|
return bytes.toFixed(1) + ' ' + units[u];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deduces relevant info for sample loading from hap.value and sample definition
|
||||||
|
// it encapsulates the core sampler logic into a pure and synchronous function
|
||||||
|
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
|
||||||
export function getSampleInfo(hapValue, bank) {
|
export function getSampleInfo(hapValue, bank) {
|
||||||
const { speed = 1.0 } = hapValue;
|
const { s, n = 0, speed = 1.0 } = hapValue;
|
||||||
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
let midi = valueToMidi(hapValue, 36);
|
||||||
|
let transpose = midi - 36; // C3 is middle C;
|
||||||
|
let sampleUrl;
|
||||||
|
let index = 0;
|
||||||
|
if (Array.isArray(bank)) {
|
||||||
|
index = getSoundIndex(n, bank.length);
|
||||||
|
sampleUrl = bank[index];
|
||||||
|
} else {
|
||||||
|
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
||||||
|
// object format will expect keys as notes
|
||||||
|
const closest = Object.keys(bank)
|
||||||
|
.filter((k) => !k.startsWith('_'))
|
||||||
|
.reduce(
|
||||||
|
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
transpose = -midiDiff(closest); // semitones to repitch
|
||||||
|
index = getSoundIndex(n, bank[closest].length);
|
||||||
|
sampleUrl = bank[closest][index];
|
||||||
|
}
|
||||||
|
const label = `${s}:${index}`;
|
||||||
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
|
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
|
||||||
return { transpose, url, index, midi, label, playbackRate };
|
return { transpose, sampleUrl, index, midi, label, playbackRate };
|
||||||
}
|
}
|
||||||
|
|
||||||
// takes hapValue and returns buffer + playbackRate.
|
// takes hapValue and returns buffer + playbackRate.
|
||||||
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
|
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
|
||||||
let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
|
let { sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
|
||||||
if (resolveUrl) {
|
if (resolveUrl) {
|
||||||
sampleUrl = await resolveUrl(sampleUrl);
|
sampleUrl = await resolveUrl(sampleUrl);
|
||||||
}
|
}
|
||||||
@@ -57,14 +79,14 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
|
|||||||
bufferSource.buffer = buffer;
|
bufferSource.buffer = buffer;
|
||||||
bufferSource.playbackRate.value = playbackRate;
|
bufferSource.playbackRate.value = playbackRate;
|
||||||
|
|
||||||
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
|
const { s, loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
|
||||||
|
|
||||||
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
|
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
|
||||||
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
|
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
|
||||||
// the midway point through a 10-second audio buffer is still 5."
|
// the midway point through a 10-second audio buffer is still 5."
|
||||||
const offset = begin * bufferSource.buffer.duration;
|
const offset = begin * bufferSource.buffer.duration;
|
||||||
|
|
||||||
const loop = hapValue.loop;
|
const loop = s.startsWith('wt_') ? 1 : hapValue.loop;
|
||||||
if (loop) {
|
if (loop) {
|
||||||
bufferSource.loop = true;
|
bufferSource.loop = true;
|
||||||
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
|
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
|
||||||
@@ -121,18 +143,13 @@ function githubPath(base, subpath = '') {
|
|||||||
if (!base.startsWith('github:')) {
|
if (!base.startsWith('github:')) {
|
||||||
throw new Error('expected "github:" at the start of pseudoUrl');
|
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||||
}
|
}
|
||||||
let path = base.slice('github:'.length);
|
let [_, path] = base.split('github:');
|
||||||
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||||
|
if (path.split('/').length === 2) {
|
||||||
let components = path.split('/');
|
// assume main as default branch if none set
|
||||||
let user = components[0];
|
path += '/main';
|
||||||
let repo = components.length >= 2 ? components[1] : 'samples';
|
}
|
||||||
let branch = components.length >= 3 ? components[2] : 'main';
|
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||||
let other = components.slice(3);
|
|
||||||
other.push(subpath ? subpath : '');
|
|
||||||
other = other.join('/');
|
|
||||||
|
|
||||||
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
|
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
|
||||||
@@ -179,52 +196,6 @@ function getSamplesPrefixHandler(url) {
|
|||||||
return;
|
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 = getBaseURL(url);
|
|
||||||
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`
|
* Loads a collection of samples to use with `s`
|
||||||
* @example
|
* @example
|
||||||
@@ -246,16 +217,61 @@ export async function fetchSampleMap(url) {
|
|||||||
|
|
||||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||||
if (typeof sampleMap === 'string') {
|
if (typeof sampleMap === 'string') {
|
||||||
const [json, base] = await fetchSampleMap(sampleMap);
|
// check if custom prefix handler
|
||||||
return samples(json, baseUrl || base, options);
|
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 { prebake, tag } = options;
|
const { prebake, tag } = options;
|
||||||
|
|
||||||
processSampleMap(
|
processSampleMap(
|
||||||
sampleMap,
|
sampleMap,
|
||||||
(key, bank) => {
|
(key, bank) =>
|
||||||
registerSampleSource(key, bank, { baseUrl, prebake, tag });
|
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
||||||
},
|
type: 'sample',
|
||||||
|
samples: bank,
|
||||||
|
baseUrl,
|
||||||
|
prebake,
|
||||||
|
tag,
|
||||||
|
}),
|
||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -345,20 +361,3 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
|||||||
|
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerSample(key, bank, params) {
|
|
||||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
|
||||||
type: 'sample',
|
|
||||||
samples: bank,
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSampleSource(key, bank, params) {
|
|
||||||
const isWavetable = key.startsWith('wt_');
|
|
||||||
if (isWavetable) {
|
|
||||||
registerWaveTable(key, bank, params);
|
|
||||||
} else {
|
|
||||||
registerSample(key, bank, params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+228
-205
@@ -7,14 +7,12 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
import './feedbackdelay.mjs';
|
import './feedbackdelay.mjs';
|
||||||
import './reverb.mjs';
|
import './reverb.mjs';
|
||||||
import './vowel.mjs';
|
import './vowel.mjs';
|
||||||
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
import { clamp, nanFallback, _mod, cycleToSeconds } from './util.mjs';
|
||||||
import workletsUrl from './worklets.mjs?audioworklet';
|
import workletsUrl from './worklets.mjs?audioworklet';
|
||||||
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
|
import { createFilter, gainNode, getCompressor, getWorklet } from './helpers.mjs';
|
||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { loadBuffer } from './sampler.mjs';
|
import { loadBuffer } from './sampler.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
|
||||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
|
||||||
|
|
||||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||||
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
|
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
|
||||||
@@ -108,19 +106,6 @@ export async function aliasBank(...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an alias for a sound.
|
|
||||||
* @param {string} original - The original sound name
|
|
||||||
* @param {string} alias - The alias to use for the sound
|
|
||||||
*/
|
|
||||||
export function soundAlias(original, alias) {
|
|
||||||
if (getSound(original) == null) {
|
|
||||||
logger('soundAlias: original sound not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
soundMap.setKey(alias, getSound(original));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSound(s) {
|
export function getSound(s) {
|
||||||
if (typeof s !== 'string') {
|
if (typeof s !== 'string') {
|
||||||
console.warn(`getSound: expected string got "${s}". fall back to triangle`);
|
console.warn(`getSound: expected string got "${s}". fall back to triangle`);
|
||||||
@@ -141,16 +126,20 @@ export const getAudioDevices = async () => {
|
|||||||
return devicesMap;
|
return devicesMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
let defaultDefaultValues = {
|
const defaultDefaultValues = {
|
||||||
s: 'triangle',
|
s: 'triangle',
|
||||||
gain: 0.8,
|
gain: 0.8,
|
||||||
postgain: 1,
|
postgain: 1,
|
||||||
density: '.03',
|
density: '.03',
|
||||||
|
ftype: '12db',
|
||||||
|
fanchor: 0,
|
||||||
|
resonance: 1,
|
||||||
|
hresonance: 1,
|
||||||
|
bandq: 1,
|
||||||
channels: [1, 2],
|
channels: [1, 2],
|
||||||
phaserdepth: 0.75,
|
phaserdepth: 0.75,
|
||||||
shapevol: 1,
|
shapevol: 1,
|
||||||
distortvol: 1,
|
distortvol: 1,
|
||||||
distorttype: 0,
|
|
||||||
delay: 0,
|
delay: 0,
|
||||||
byteBeatExpression: '0',
|
byteBeatExpression: '0',
|
||||||
delayfeedback: 0.5,
|
delayfeedback: 0.5,
|
||||||
@@ -161,17 +150,6 @@ let defaultDefaultValues = {
|
|||||||
fft: 8,
|
fft: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
|
|
||||||
|
|
||||||
export function setDefault(control, value) {
|
|
||||||
// const main = getControlName(control); // we cant do this because superdough is independent of strudel/core
|
|
||||||
defaultDefaultValues[control] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetDefaults() {
|
|
||||||
defaultDefaultValues = { ...defaultDefaultDefaultValues };
|
|
||||||
}
|
|
||||||
|
|
||||||
let defaultControls = new Map(Object.entries(defaultDefaultValues));
|
let defaultControls = new Map(Object.entries(defaultDefaultValues));
|
||||||
|
|
||||||
export function setDefaultValue(key, value) {
|
export function setDefaultValue(key, value) {
|
||||||
@@ -197,17 +175,30 @@ export function setVersionDefaults(version) {
|
|||||||
|
|
||||||
export const resetLoadedSounds = () => soundMap.set({});
|
export const resetLoadedSounds = () => soundMap.set({});
|
||||||
|
|
||||||
let externalWorklets = [];
|
let audioContext;
|
||||||
export function registerWorklet(url) {
|
|
||||||
externalWorklets.push(url);
|
export const setDefaultAudioContext = () => {
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAudioContext = () => {
|
||||||
|
if (!audioContext) {
|
||||||
|
return setDefaultAudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAudioContextCurrentTime() {
|
||||||
|
return getAudioContext().currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
let workletsLoading;
|
let workletsLoading;
|
||||||
function loadWorklets() {
|
function loadWorklets() {
|
||||||
if (!workletsLoading) {
|
if (!workletsLoading) {
|
||||||
const audioCtx = getAudioContext();
|
const audioCtx = getAudioContext();
|
||||||
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
|
workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
|
||||||
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return workletsLoading;
|
return workletsLoading;
|
||||||
@@ -273,16 +264,79 @@ export async function initAudioOnFirstClick(options) {
|
|||||||
return audioReady;
|
return audioReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
let controller;
|
let delays = {};
|
||||||
function getSuperdoughAudioController() {
|
const maxfeedback = 0.98;
|
||||||
if (controller == null) {
|
|
||||||
controller = new SuperdoughAudioController(getAudioContext());
|
let channelMerger, destinationGain;
|
||||||
}
|
//update the output channel configuration to match user's audio device
|
||||||
return controller;
|
export function initializeAudioOutput() {
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const maxChannelCount = audioContext.destination.maxChannelCount;
|
||||||
|
audioContext.destination.channelCount = maxChannelCount;
|
||||||
|
channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount });
|
||||||
|
destinationGain = new GainNode(audioContext);
|
||||||
|
channelMerger.connect(destinationGain);
|
||||||
|
destinationGain.connect(audioContext.destination);
|
||||||
}
|
}
|
||||||
export function connectToDestination(input, channels) {
|
|
||||||
const controller = getSuperdoughAudioController();
|
// input: AudioNode, channels: ?Array<int>
|
||||||
controller.output.connectToDestination(input, channels);
|
export const connectToDestination = (input, channels = [0, 1]) => {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (channelMerger == null) {
|
||||||
|
initializeAudioOutput();
|
||||||
|
}
|
||||||
|
//This upmix can be removed if correct channel counts are set throughout the app,
|
||||||
|
// and then strudel could theoretically support surround sound audio files
|
||||||
|
const stereoMix = new StereoPannerNode(ctx);
|
||||||
|
input.connect(stereoMix);
|
||||||
|
|
||||||
|
const splitter = new ChannelSplitterNode(ctx, {
|
||||||
|
numberOfOutputs: stereoMix.channelCount,
|
||||||
|
});
|
||||||
|
stereoMix.connect(splitter);
|
||||||
|
channels.forEach((ch, i) => {
|
||||||
|
splitter.connect(channelMerger, i % stereoMix.channelCount, ch % ctx.destination.channelCount);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const panic = () => {
|
||||||
|
if (destinationGain == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01);
|
||||||
|
destinationGain = null;
|
||||||
|
channelMerger == null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDelay(orbit, delaytime, delayfeedback, t, channels) {
|
||||||
|
if (delayfeedback > maxfeedback) {
|
||||||
|
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
|
||||||
|
}
|
||||||
|
delayfeedback = clamp(delayfeedback, 0, 0.98);
|
||||||
|
if (!delays[orbit]) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLfo(audioContext, time, end, properties = {}) {
|
||||||
|
return getWorklet(audioContext, 'lfo-processor', {
|
||||||
|
frequency: 1,
|
||||||
|
depth: 1,
|
||||||
|
skew: 0,
|
||||||
|
phaseoffset: 0,
|
||||||
|
time,
|
||||||
|
end,
|
||||||
|
shape: 1,
|
||||||
|
dcoffset: -0.5,
|
||||||
|
...properties,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
|
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
|
||||||
@@ -316,6 +370,33 @@ function getFilterType(ftype) {
|
|||||||
return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : 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;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
return reverbs[orbit];
|
||||||
|
}
|
||||||
|
|
||||||
export let analysers = {},
|
export let analysers = {},
|
||||||
analysersData = {};
|
analysersData = {};
|
||||||
|
|
||||||
@@ -348,8 +429,16 @@ export function getAnalyzerData(type = 'time', id = 1) {
|
|||||||
return analysersData[id];
|
return analysersData[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function effectSend(input, effect, wet) {
|
||||||
|
const send = gainNode(wet);
|
||||||
|
input.connect(send);
|
||||||
|
send.connect(effect);
|
||||||
|
return send;
|
||||||
|
}
|
||||||
|
|
||||||
export function resetGlobalEffects() {
|
export function resetGlobalEffects() {
|
||||||
controller?.reset();
|
delays = {};
|
||||||
|
reverbs = {};
|
||||||
analysers = {};
|
analysers = {};
|
||||||
analysersData = {};
|
analysersData = {};
|
||||||
}
|
}
|
||||||
@@ -361,11 +450,9 @@ function mapChannelNumbers(channels) {
|
|||||||
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
|
export const superdough = async (value, t, hapDuration, cps = 0.5) => {
|
||||||
// new: t is always expected to be the absolute target onset time
|
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
const audioController = getSuperdoughAudioController();
|
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
|
||||||
|
|
||||||
let { stretch } = value;
|
let { stretch } = value;
|
||||||
if (stretch != null) {
|
if (stretch != null) {
|
||||||
//account for phase vocoder latency
|
//account for phase vocoder latency
|
||||||
@@ -391,26 +478,39 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
}
|
}
|
||||||
// destructure
|
// destructure
|
||||||
let {
|
let {
|
||||||
tremolo,
|
|
||||||
tremolosync,
|
|
||||||
tremolodepth = 1,
|
|
||||||
tremoloskew,
|
|
||||||
tremolophase = 0,
|
|
||||||
tremoloshape,
|
|
||||||
s = getDefaultValue('s'),
|
s = getDefaultValue('s'),
|
||||||
bank,
|
bank,
|
||||||
source,
|
source,
|
||||||
gain = getDefaultValue('gain'),
|
gain = getDefaultValue('gain'),
|
||||||
postgain = getDefaultValue('postgain'),
|
postgain = getDefaultValue('postgain'),
|
||||||
density = getDefaultValue('density'),
|
density = getDefaultValue('density'),
|
||||||
duckorbit,
|
|
||||||
duckonset,
|
|
||||||
duckattack,
|
|
||||||
duckdepth,
|
|
||||||
djf,
|
|
||||||
// filters
|
// filters
|
||||||
fanchor = getDefaultValue('fanchor'),
|
fanchor = getDefaultValue('fanchor'),
|
||||||
release = 0,
|
drive = 0.69,
|
||||||
|
// low pass
|
||||||
|
cutoff,
|
||||||
|
lpenv,
|
||||||
|
lpattack,
|
||||||
|
lpdecay,
|
||||||
|
lpsustain,
|
||||||
|
lprelease,
|
||||||
|
resonance = getDefaultValue('resonance'),
|
||||||
|
// high pass
|
||||||
|
hpenv,
|
||||||
|
hcutoff,
|
||||||
|
hpattack,
|
||||||
|
hpdecay,
|
||||||
|
hpsustain,
|
||||||
|
hprelease,
|
||||||
|
hresonance = getDefaultValue('hresonance'),
|
||||||
|
// band pass
|
||||||
|
bpenv,
|
||||||
|
bandf,
|
||||||
|
bpattack,
|
||||||
|
bpdecay,
|
||||||
|
bpsustain,
|
||||||
|
bprelease,
|
||||||
|
bandq = getDefaultValue('bandq'),
|
||||||
|
|
||||||
//phaser
|
//phaser
|
||||||
phaserrate: phaser,
|
phaserrate: phaser,
|
||||||
@@ -419,14 +519,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
phasercenter,
|
phasercenter,
|
||||||
//
|
//
|
||||||
coarse,
|
coarse,
|
||||||
|
|
||||||
crush,
|
crush,
|
||||||
dry,
|
|
||||||
shape,
|
shape,
|
||||||
shapevol = getDefaultValue('shapevol'),
|
shapevol = getDefaultValue('shapevol'),
|
||||||
distort,
|
distort,
|
||||||
distortvol = getDefaultValue('distortvol'),
|
distortvol = getDefaultValue('distortvol'),
|
||||||
distorttype = getDefaultValue('distorttype'),
|
|
||||||
pan,
|
pan,
|
||||||
vowel,
|
vowel,
|
||||||
delay = getDefaultValue('delay'),
|
delay = getDefaultValue('delay'),
|
||||||
@@ -440,8 +537,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
roomdim,
|
roomdim,
|
||||||
roomsize,
|
roomsize,
|
||||||
ir,
|
ir,
|
||||||
irspeed,
|
|
||||||
irbegin,
|
|
||||||
i = getDefaultValue('i'),
|
i = getDefaultValue('i'),
|
||||||
velocity = getDefaultValue('velocity'),
|
velocity = getDefaultValue('velocity'),
|
||||||
analyze, // analyser wet
|
analyze, // analyser wet
|
||||||
@@ -458,12 +553,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
const orbitChannels = mapChannelNumbers(
|
const orbitChannels = mapChannelNumbers(
|
||||||
multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
|
multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
|
const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
|
||||||
const orbitBus = audioController.getOrbit(orbit, channels);
|
|
||||||
if (duckorbit != null) {
|
|
||||||
audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
gain = applyGainCurve(nanFallback(gain, 1));
|
gain = applyGainCurve(nanFallback(gain, 1));
|
||||||
postgain = applyGainCurve(postgain);
|
postgain = applyGainCurve(postgain);
|
||||||
@@ -471,17 +561,14 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
distortvol = applyGainCurve(distortvol);
|
distortvol = applyGainCurve(distortvol);
|
||||||
delay = applyGainCurve(delay);
|
delay = applyGainCurve(delay);
|
||||||
velocity = applyGainCurve(velocity);
|
velocity = applyGainCurve(velocity);
|
||||||
tremolodepth = applyGainCurve(tremolodepth);
|
|
||||||
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
|
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);
|
const chainID = Math.round(Math.random() * 1000000);
|
||||||
|
|
||||||
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
|
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
|
||||||
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
|
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
|
||||||
const ch = activeSoundSources.entries().next();
|
const ch = activeSoundSources.entries().next();
|
||||||
const source = ch.value[1].deref();
|
const source = ch.value[1];
|
||||||
const chainID = ch.value[0];
|
const chainID = ch.value[0];
|
||||||
const endTime = t + 0.25;
|
const endTime = t + 0.25;
|
||||||
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
|
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
|
||||||
@@ -509,11 +596,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
audioNodes.forEach((n) => n?.disconnect());
|
audioNodes.forEach((n) => n?.disconnect());
|
||||||
activeSoundSources.delete(chainID);
|
activeSoundSources.delete(chainID);
|
||||||
};
|
};
|
||||||
const soundHandle = await onTrigger(t, value, onEnded, cps);
|
const soundHandle = await onTrigger(t, value, onEnded);
|
||||||
|
|
||||||
if (soundHandle) {
|
if (soundHandle) {
|
||||||
sourceNode = soundHandle.node;
|
sourceNode = soundHandle.node;
|
||||||
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
|
activeSoundSources.set(chainID, soundHandle);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`sound ${s} not found! Is it loaded?`);
|
throw new Error(`sound ${s} not found! Is it loaded?`);
|
||||||
@@ -535,90 +622,70 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
// gain stage
|
// gain stage
|
||||||
chain.push(gainNode(gain));
|
chain.push(gainNode(gain));
|
||||||
|
|
||||||
// filter
|
//filter
|
||||||
const ftype = getFilterType(value.ftype);
|
const ftype = getFilterType(value.ftype);
|
||||||
|
if (cutoff !== undefined) {
|
||||||
if (value.cutoff !== undefined) {
|
let lp = () =>
|
||||||
const lpMap = {
|
createFilter(
|
||||||
frequency: 'cutoff',
|
ac,
|
||||||
q: 'resonance',
|
'lowpass',
|
||||||
attack: 'lpattack',
|
cutoff,
|
||||||
decay: 'lpdecay',
|
resonance,
|
||||||
sustain: 'lpsustain',
|
lpattack,
|
||||||
release: 'lprelease',
|
lpdecay,
|
||||||
env: 'lpenv',
|
lpsustain,
|
||||||
anchor: 'fanchor',
|
lprelease,
|
||||||
model: 'ftype',
|
lpenv,
|
||||||
drive: 'drive',
|
t,
|
||||||
rate: 'lprate',
|
t + hapDuration,
|
||||||
sync: 'lpsync',
|
fanchor,
|
||||||
depth: 'lpdepth',
|
ftype,
|
||||||
depthfrequency: 'lpdepthfrequency',
|
drive,
|
||||||
shape: 'lpshape',
|
);
|
||||||
dcoffset: 'lpdc',
|
|
||||||
skew: 'lpskew',
|
|
||||||
};
|
|
||||||
const lpParams = pickAndRename(value, lpMap);
|
|
||||||
lpParams.type = 'lowpass';
|
|
||||||
let lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
|
|
||||||
chain.push(lp());
|
chain.push(lp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(lp());
|
chain.push(lp());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.hcutoff !== undefined) {
|
if (hcutoff !== undefined) {
|
||||||
const hpMap = {
|
let hp = () =>
|
||||||
frequency: 'hcutoff',
|
createFilter(
|
||||||
q: 'hresonance',
|
ac,
|
||||||
attack: 'hpattack',
|
'highpass',
|
||||||
decay: 'hpdecay',
|
hcutoff,
|
||||||
sustain: 'hpsustain',
|
hresonance,
|
||||||
release: 'hprelease',
|
hpattack,
|
||||||
env: 'hpenv',
|
hpdecay,
|
||||||
anchor: 'fanchor',
|
hpsustain,
|
||||||
model: 'ftype',
|
hprelease,
|
||||||
drive: 'drive',
|
hpenv,
|
||||||
rate: 'hprate',
|
t,
|
||||||
sync: 'hpsync',
|
t + hapDuration,
|
||||||
depth: 'hpdepth',
|
fanchor,
|
||||||
depthfrequency: 'hpdepthfrequency',
|
);
|
||||||
shape: 'hpshape',
|
|
||||||
dcoffset: 'hpdc',
|
|
||||||
skew: 'hpskew',
|
|
||||||
};
|
|
||||||
const hpParams = pickAndRename(value, hpMap);
|
|
||||||
hpParams.type = 'highpass';
|
|
||||||
let hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
|
|
||||||
chain.push(hp());
|
chain.push(hp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(hp());
|
chain.push(hp());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.bandf !== undefined) {
|
if (bandf !== undefined) {
|
||||||
const bpMap = {
|
let bp = () =>
|
||||||
frequency: 'bandf',
|
createFilter(
|
||||||
q: 'bandq',
|
ac,
|
||||||
attack: 'bpattack',
|
'bandpass',
|
||||||
decay: 'bpdecay',
|
bandf,
|
||||||
sustain: 'bpsustain',
|
bandq,
|
||||||
release: 'bprelease',
|
bpattack,
|
||||||
env: 'bpenv',
|
bpdecay,
|
||||||
anchor: 'fanchor',
|
bpsustain,
|
||||||
model: 'ftype',
|
bprelease,
|
||||||
drive: 'drive',
|
bpenv,
|
||||||
rate: 'bprate',
|
t,
|
||||||
sync: 'bpsync',
|
t + hapDuration,
|
||||||
depth: 'bpdepth',
|
fanchor,
|
||||||
depthfrequency: 'bpdepthfrequency',
|
);
|
||||||
shape: 'bpshape',
|
|
||||||
dcoffset: 'bpdc',
|
|
||||||
skew: 'bpskew',
|
|
||||||
};
|
|
||||||
const bpParams = pickAndRename(value, bpMap);
|
|
||||||
bpParams.type = 'bandpass';
|
|
||||||
let bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
|
|
||||||
chain.push(bp());
|
chain.push(bp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(bp());
|
chain.push(bp());
|
||||||
@@ -634,43 +701,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
|
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
|
||||||
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
|
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
|
||||||
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
|
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
|
||||||
distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype));
|
distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol }));
|
||||||
|
|
||||||
if (tremolosync != null) {
|
|
||||||
tremolo = cps * tremolosync;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.wtPosSynced != null) {
|
|
||||||
value.wtPosRate /= cps;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.wtWarpSynced != null) {
|
|
||||||
value.wtWarpRate /= cps;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
audioNodes.push(lfo);
|
|
||||||
chain.push(amGain);
|
|
||||||
}
|
|
||||||
|
|
||||||
compressorThreshold !== undefined &&
|
compressorThreshold !== undefined &&
|
||||||
chain.push(
|
chain.push(
|
||||||
@@ -685,21 +716,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
}
|
}
|
||||||
// phaser
|
// phaser
|
||||||
if (phaser !== undefined && phaserdepth > 0) {
|
if (phaser !== undefined && phaserdepth > 0) {
|
||||||
const phaserFX = getPhaser(t, endWithRelease, phaser, phaserdepth, phasercenter, phasersweep);
|
const phaserFX = getPhaser(t, t + hapDuration, phaser, phaserdepth, phasercenter, phasersweep);
|
||||||
chain.push(phaserFX);
|
chain.push(phaserFX);
|
||||||
}
|
}
|
||||||
|
|
||||||
// last gain
|
// last gain
|
||||||
const post = new GainNode(ac, { gain: postgain });
|
const post = new GainNode(ac, { gain: postgain });
|
||||||
chain.push(post);
|
chain.push(post);
|
||||||
|
connectToDestination(post, channels);
|
||||||
|
|
||||||
// delay
|
// delay
|
||||||
|
let delaySend;
|
||||||
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
|
||||||
orbitBus.getDelay(delaytime, delayfeedback, t);
|
const delayNode = getDelay(orbit, delaytime, delayfeedback, t, orbitChannels);
|
||||||
const send = orbitBus.sendDelay(post, delay);
|
delaySend = effectSend(post, delayNode, delay);
|
||||||
audioNodes.push(send);
|
audioNodes.push(delaySend);
|
||||||
}
|
}
|
||||||
// reverb
|
// reverb
|
||||||
|
let reverbSend;
|
||||||
if (room > 0) {
|
if (room > 0) {
|
||||||
let roomIR;
|
let roomIR;
|
||||||
if (ir !== undefined) {
|
if (ir !== undefined) {
|
||||||
@@ -712,29 +746,18 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
}
|
}
|
||||||
roomIR = await loadBuffer(url, ac, ir, 0);
|
roomIR = await loadBuffer(url, ac, ir, 0);
|
||||||
}
|
}
|
||||||
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
|
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, orbitChannels);
|
||||||
const send = orbitBus.sendReverb(post, room);
|
reverbSend = effectSend(post, reverbNode, room);
|
||||||
audioNodes.push(send);
|
audioNodes.push(reverbSend);
|
||||||
}
|
|
||||||
|
|
||||||
if (djf != null) {
|
|
||||||
orbitBus.getDjf(djf, t);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyser
|
// analyser
|
||||||
|
let analyserSend;
|
||||||
if (analyze) {
|
if (analyze) {
|
||||||
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
||||||
const analyserSend = effectSend(post, analyserNode, 1);
|
analyserSend = effectSend(post, analyserNode, 1);
|
||||||
audioNodes.push(analyserSend);
|
audioNodes.push(analyserSend);
|
||||||
}
|
}
|
||||||
if (dry != null) {
|
|
||||||
dry = applyGainCurve(dry);
|
|
||||||
const dryGain = new GainNode(ac, { gain: dry });
|
|
||||||
chain.push(dryGain);
|
|
||||||
orbitBus.connectToOutput(dryGain);
|
|
||||||
} else {
|
|
||||||
orbitBus.connectToOutput(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
// connect chain elements together
|
// connect chain elements together
|
||||||
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
|
|
||||||
import { errorLogger } from './logger.mjs';
|
|
||||||
import { clamp } from './util.mjs';
|
|
||||||
|
|
||||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
|
||||||
|
|
||||||
export class Orbit {
|
|
||||||
reverbNode;
|
|
||||||
delayNode;
|
|
||||||
output;
|
|
||||||
summingNode;
|
|
||||||
djfNode;
|
|
||||||
audioContext;
|
|
||||||
constructor(audioContext) {
|
|
||||||
this.audioContext = audioContext;
|
|
||||||
this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
|
||||||
this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
|
||||||
this.summingNode.connect(this.output);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.output.disconnect();
|
|
||||||
this.summingNode.disconnect();
|
|
||||||
this.delayNode?.disconnect();
|
|
||||||
this.reverbNode?.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDjf(value, t = 0) {
|
|
||||||
if (this.djfNode == null) {
|
|
||||||
this.djfNode = getWorklet(this.audioContext, 'djf-processor', { value });
|
|
||||||
this.summingNode.disconnect();
|
|
||||||
this.summingNode.connect(this.djfNode);
|
|
||||||
this.djfNode.connect(this.output);
|
|
||||||
}
|
|
||||||
const val = this.djfNode.parameters.get('value');
|
|
||||||
val.setValueAtTime(value, t);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDelay(delaytime = 0, feedback = 0.5, t) {
|
|
||||||
const maxfeedback = 0.98;
|
|
||||||
if (feedback > maxfeedback) {
|
|
||||||
//logger(`feedback was clamped to ${maxfeedback} to save your ears`);
|
|
||||||
}
|
|
||||||
feedback = clamp(feedback, 0, 0.98);
|
|
||||||
if (this.delayNode == null) {
|
|
||||||
this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback);
|
|
||||||
this.delayNode.connect(this.summingNode);
|
|
||||||
this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed..
|
|
||||||
}
|
|
||||||
this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t);
|
|
||||||
this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t);
|
|
||||||
return this.delayNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) {
|
|
||||||
// If no reverb has been created for a given orbit, create one
|
|
||||||
if (this.reverbNode == null) {
|
|
||||||
this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
|
|
||||||
this.reverbNode.connect(this.summingNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasChanged(duration, this.reverbNode.duration) ||
|
|
||||||
hasChanged(fade, this.reverbNode.fade) ||
|
|
||||||
hasChanged(lp, this.reverbNode.lp) ||
|
|
||||||
hasChanged(dim, this.reverbNode.dim) ||
|
|
||||||
hasChanged(irspeed, this.reverbNode.irspeed) ||
|
|
||||||
hasChanged(irbegin, this.reverbNode.irbegin) ||
|
|
||||||
this.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
|
|
||||||
this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
|
|
||||||
}
|
|
||||||
return this.reverbNode;
|
|
||||||
}
|
|
||||||
sendReverb(node, amount) {
|
|
||||||
return effectSend(node, this.reverbNode, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendDelay(node, amount) {
|
|
||||||
return effectSend(node, this.delayNode, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
duck(t, onsettime = 0, attacktime = 0.16, depth = 0.8) {
|
|
||||||
const onset = onsettime;
|
|
||||||
const attack = Math.max(attacktime, 0.002);
|
|
||||||
const gainParam = this.output.gain;
|
|
||||||
webAudioTimeout(
|
|
||||||
this.audioContext,
|
|
||||||
() => {
|
|
||||||
const now = this.audioContext.currentTime;
|
|
||||||
|
|
||||||
// cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime
|
|
||||||
// on browsers which lack that method
|
|
||||||
const currVal = gainParam.value;
|
|
||||||
gainParam.cancelScheduledValues(now);
|
|
||||||
gainParam.setValueAtTime(currVal, now);
|
|
||||||
|
|
||||||
const t0 = Math.max(t, now); // guard against now > t
|
|
||||||
const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal);
|
|
||||||
gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset);
|
|
||||||
gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack);
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
t - 0.01,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectToOutput(node) {
|
|
||||||
node.connect(this.summingNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SuperdoughOutput {
|
|
||||||
channelMerger;
|
|
||||||
destinationGain;
|
|
||||||
|
|
||||||
constructor(audioContext) {
|
|
||||||
this.audioContext = audioContext;
|
|
||||||
this.initializeAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeAudio() {
|
|
||||||
const audioContext = this.audioContext;
|
|
||||||
const maxChannelCount = audioContext.destination.maxChannelCount;
|
|
||||||
this.audioContext.destination.channelCount = maxChannelCount;
|
|
||||||
this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount });
|
|
||||||
this.destinationGain = new GainNode(audioContext);
|
|
||||||
this.channelMerger.connect(this.destinationGain);
|
|
||||||
this.destinationGain.connect(audioContext.destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.disconnect();
|
|
||||||
this.initializeAudio();
|
|
||||||
}
|
|
||||||
disconnect() {
|
|
||||||
this.channelMerger.disconnect();
|
|
||||||
this.destinationGain.disconnect();
|
|
||||||
this.destinationGain = null;
|
|
||||||
this.channelMerger = null;
|
|
||||||
}
|
|
||||||
connectToDestination = (input, channels = [0, 1]) => {
|
|
||||||
//This upmix can be removed if correct channel counts are set throughout the app,
|
|
||||||
// and then strudel could theoretically support surround sound audio files
|
|
||||||
const stereoMix = new StereoPannerNode(this.audioContext);
|
|
||||||
input.connect(stereoMix);
|
|
||||||
|
|
||||||
const splitter = new ChannelSplitterNode(this.audioContext, {
|
|
||||||
numberOfOutputs: stereoMix.channelCount,
|
|
||||||
});
|
|
||||||
stereoMix.connect(splitter);
|
|
||||||
channels.forEach((ch, i) => {
|
|
||||||
splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SuperdoughAudioController {
|
|
||||||
audioContext;
|
|
||||||
output;
|
|
||||||
nodes = {};
|
|
||||||
|
|
||||||
constructor(audioContext) {
|
|
||||||
this.audioContext = audioContext;
|
|
||||||
this.output = new SuperdoughOutput(audioContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
Array.from(this.nodes).forEach((node) => {
|
|
||||||
node.disconnect();
|
|
||||||
});
|
|
||||||
this.nodes = {};
|
|
||||||
this.output.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
duck(targetOrbits, t, onsettime = 0, attacktime = 0.16, depth = 0.8) {
|
|
||||||
const targetArr = [targetOrbits].flat();
|
|
||||||
const onsetArr = [onsettime].flat();
|
|
||||||
const attackArr = [attacktime].flat();
|
|
||||||
const depthArr = [depth].flat();
|
|
||||||
|
|
||||||
targetArr.forEach((target, idx) => {
|
|
||||||
const orbit = this.nodes[target];
|
|
||||||
|
|
||||||
if (orbit == null) {
|
|
||||||
errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const onset = onsetArr[idx] ?? onsetArr[0];
|
|
||||||
const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002);
|
|
||||||
const depth = depthArr[idx] ?? depthArr[0];
|
|
||||||
|
|
||||||
orbit.duck(t, onset, attack, depth);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrbit(orbitNum, channels) {
|
|
||||||
if (this.nodes[orbitNum] == null) {
|
|
||||||
this.nodes[orbitNum] = new Orbit(this.audioContext);
|
|
||||||
this.output.connectToDestination(this.nodes[orbitNum].output, channels);
|
|
||||||
}
|
|
||||||
return this.nodes[orbitNum];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+50
-139
@@ -1,41 +1,46 @@
|
|||||||
import { clamp } from './util.mjs';
|
import { clamp, midiToFreq, noteToMidi } from './util.mjs';
|
||||||
import { registerSound, soundMap } from './superdough.mjs';
|
import { registerSound, getAudioContext, soundMap, getLfo } from './superdough.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
|
||||||
import {
|
import {
|
||||||
applyFM,
|
applyFM,
|
||||||
destroyAudioWorkletNode,
|
|
||||||
gainNode,
|
gainNode,
|
||||||
getADSRValues,
|
getADSRValues,
|
||||||
getFrequencyFromValue,
|
|
||||||
getLfo,
|
|
||||||
getParamADSR,
|
getParamADSR,
|
||||||
getPitchEnvelope,
|
getPitchEnvelope,
|
||||||
getVibratoOscillator,
|
getVibratoOscillator,
|
||||||
getWorklet,
|
|
||||||
noises,
|
|
||||||
webAudioTimeout,
|
webAudioTimeout,
|
||||||
|
getWorklet,
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
import { logger } from './logger.mjs';
|
|
||||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||||
|
|
||||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
|
const getFrequencyFromValue = (value) => {
|
||||||
|
let { note, freq } = value;
|
||||||
|
note = note || 36;
|
||||||
|
if (typeof note === 'string') {
|
||||||
|
note = noteToMidi(note); // e.g. c3 => 48
|
||||||
|
}
|
||||||
|
// get frequency
|
||||||
|
if (!freq && typeof note === 'number') {
|
||||||
|
freq = midiToFreq(note); // + 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(freq);
|
||||||
|
};
|
||||||
|
function destroyAudioWorkletNode(node) {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.disconnect();
|
||||||
|
node.parameters.get('end')?.setValueAtTime(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
|
||||||
const waveformAliases = [
|
const waveformAliases = [
|
||||||
['tri', 'triangle'],
|
['tri', 'triangle'],
|
||||||
['sqr', 'square'],
|
['sqr', 'square'],
|
||||||
['saw', 'sawtooth'],
|
['saw', 'sawtooth'],
|
||||||
['sin', 'sine'],
|
['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() {
|
export function registerSynthSounds() {
|
||||||
[...waveforms].forEach((s) => {
|
[...waveforms].forEach((s) => {
|
||||||
@@ -48,17 +53,19 @@ export function registerSynthSounds() {
|
|||||||
[0.001, 0.05, 0.6, 0.01],
|
[0.001, 0.05, 0.6, 0.01],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let sound = getOscillator(s, t, value);
|
||||||
|
let { node: o, stop, triggerRelease } = sound;
|
||||||
|
|
||||||
// turn down
|
// turn down
|
||||||
const g = gainNode(0.3);
|
const g = gainNode(0.3);
|
||||||
|
|
||||||
let sound = getOscillator(s, t, value, () => {
|
const { duration } = value;
|
||||||
|
|
||||||
|
o.onended = () => {
|
||||||
|
o.disconnect();
|
||||||
g.disconnect();
|
g.disconnect();
|
||||||
onended();
|
onended();
|
||||||
});
|
};
|
||||||
|
|
||||||
let { node: o, stop, triggerRelease } = sound;
|
|
||||||
|
|
||||||
const { duration } = value;
|
|
||||||
|
|
||||||
const envGain = gainNode(1);
|
const envGain = gainNode(1);
|
||||||
let node = o.connect(g).connect(envGain);
|
let node = o.connect(g).connect(envGain);
|
||||||
@@ -77,75 +84,6 @@ export function registerSynthSounds() {
|
|||||||
{ type: 'synth', prebake: true },
|
{ 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(
|
registerSound(
|
||||||
'supersaw',
|
'supersaw',
|
||||||
(begin, value, onended) => {
|
(begin, value, onended) => {
|
||||||
@@ -413,13 +351,9 @@ export function registerSynthSounds() {
|
|||||||
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
|
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const PI2 = 2 * Math.PI;
|
export function waveformN(partials, type) {
|
||||||
export function waveformN(partials, phases, type) {
|
const real = new Float32Array(partials + 1);
|
||||||
const isList = typeof partials === 'object';
|
const imag = new Float32Array(partials + 1);
|
||||||
partials = isList ? partials : new Float32Array(partials).fill(1);
|
|
||||||
const len = partials.length;
|
|
||||||
const real = new Float32Array(len + 1);
|
|
||||||
const imag = new Float32Array(len + 1);
|
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
const osc = ac.createOscillator();
|
const osc = ac.createOscillator();
|
||||||
|
|
||||||
@@ -427,29 +361,20 @@ export function waveformN(partials, phases, type) {
|
|||||||
sawtooth: (n) => [0, -1 / n],
|
sawtooth: (n) => [0, -1 / n],
|
||||||
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
|
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
|
||||||
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
|
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
|
||||||
user: (_n) => [0, 1],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!terms[type]) {
|
if (!terms[type]) {
|
||||||
throw new Error(`unknown wave type ${type}`);
|
throw new Error(`unknown wave type ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let n = 0; n < len; n++) {
|
real[0] = 0; // dc offset
|
||||||
const mag = partials[n];
|
imag[0] = 0;
|
||||||
const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
|
let n = 1;
|
||||||
const phase = phases?.[n] ?? 0;
|
while (n <= partials) {
|
||||||
// Scale by `partials`
|
const [r, i] = terms[type](n);
|
||||||
let R = r * mag;
|
real[n] = r;
|
||||||
let I = i * mag;
|
imag[n] = i;
|
||||||
// Apply rotation by the phase
|
n++;
|
||||||
if (phase !== 0) {
|
|
||||||
const c = Math.cos(PI2 * phase);
|
|
||||||
const s = Math.sin(PI2 * phase);
|
|
||||||
R = c * R - s * I;
|
|
||||||
I = s * R + c * I;
|
|
||||||
}
|
|
||||||
real[n + 1] = R;
|
|
||||||
imag[n + 1] = I;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wave = ac.createPeriodicWave(real, imag);
|
const wave = ac.createPeriodicWave(real, imag);
|
||||||
@@ -458,28 +383,21 @@ export function waveformN(partials, phases, type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// expects one of waveforms as s
|
// expects one of waveforms as s
|
||||||
export function getOscillator(s, t, value, onended) {
|
export function getOscillator(s, t, value) {
|
||||||
const { duration, noise = 0 } = value;
|
let { n: partials, duration, noise = 0 } = value;
|
||||||
const partials = value.partials ?? value.n;
|
|
||||||
let o;
|
let o;
|
||||||
if (s === 'user' && !partials) {
|
|
||||||
logger(
|
|
||||||
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
|
|
||||||
);
|
|
||||||
s = 'triangle';
|
|
||||||
}
|
|
||||||
s = s === 'user' && !partials ? 'triangle' : s;
|
|
||||||
// If no partials are given, use stock waveforms
|
// If no partials are given, use stock waveforms
|
||||||
if (!partials || partials?.length === 0 || s === 'sine') {
|
if (!partials || s === 'sine') {
|
||||||
o = getAudioContext().createOscillator();
|
o = getAudioContext().createOscillator();
|
||||||
o.type = s || 'triangle';
|
o.type = s || 'triangle';
|
||||||
}
|
}
|
||||||
// generate custom waveform if partials are given
|
// generate custom waveform if partials are given
|
||||||
else {
|
else {
|
||||||
o = waveformN(partials, value.phases, s);
|
o = waveformN(partials, s);
|
||||||
}
|
}
|
||||||
// set frequency
|
// set frequency
|
||||||
o.frequency.value = getFrequencyFromValue(value);
|
o.frequency.value = getFrequencyFromValue(value);
|
||||||
|
o.start(t);
|
||||||
|
|
||||||
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
|
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
|
||||||
|
|
||||||
@@ -492,13 +410,6 @@ export function getOscillator(s, t, value, onended) {
|
|||||||
noiseMix = getNoiseMix(o, noise, t);
|
noiseMix = getNoiseMix(o, noise, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
o.onended = () => {
|
|
||||||
o.disconnect();
|
|
||||||
noiseMix?.node.disconnect();
|
|
||||||
onended();
|
|
||||||
};
|
|
||||||
o.start(t);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: noiseMix?.node || o,
|
node: noiseMix?.node || o,
|
||||||
stop: (time) => {
|
stop: (time) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const tokenizeNote = (note) => {
|
|||||||
if (typeof note !== 'string') {
|
if (typeof note !== 'string') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
|
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
|
||||||
if (!pc) {
|
if (!pc) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -16,17 +16,13 @@ export const tokenizeNote = (note) => {
|
|||||||
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
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 accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||||
|
|
||||||
export const getAccidentalsOffset = (accidentals) => {
|
|
||||||
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const noteToMidi = (note, defaultOctave = 3) => {
|
export const noteToMidi = (note, defaultOctave = 3) => {
|
||||||
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
||||||
if (!pc) {
|
if (!pc) {
|
||||||
throw new Error('not a note: "' + note + '"');
|
throw new Error('not a note: "' + note + '"');
|
||||||
}
|
}
|
||||||
const chroma = chromas[pc.toLowerCase()];
|
const chroma = chromas[pc.toLowerCase()];
|
||||||
const offset = getAccidentalsOffset(acc);
|
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||||
};
|
};
|
||||||
export const midiToFreq = (n) => {
|
export const midiToFreq = (n) => {
|
||||||
@@ -76,51 +72,3 @@ export const getSoundIndex = (n, numSounds) => {
|
|||||||
export function cycleToSeconds(cycle, cps) {
|
export function cycleToSeconds(cycle, cps) {
|
||||||
return cycle / cps;
|
return cycle / cps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function secondsToCycle(t, cps) {
|
|
||||||
return t * cps;
|
|
||||||
}
|
|
||||||
|
|
||||||
// deduces relevant info for sample loading from hap.value and sample definition
|
|
||||||
// it encapsulates the core sampler logic into a pure and synchronous function
|
|
||||||
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
|
|
||||||
export function getCommonSampleInfo(hapValue, bank) {
|
|
||||||
const { s, n = 0 } = hapValue;
|
|
||||||
let midi = valueToMidi(hapValue, 36);
|
|
||||||
let transpose = midi - 36; // C3 is middle C;
|
|
||||||
let url;
|
|
||||||
let index = 0;
|
|
||||||
if (Array.isArray(bank)) {
|
|
||||||
index = getSoundIndex(n, bank.length);
|
|
||||||
url = bank[index];
|
|
||||||
} else {
|
|
||||||
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
|
||||||
// object format will expect keys as notes
|
|
||||||
const closest = Object.keys(bank)
|
|
||||||
.filter((k) => !k.startsWith('_'))
|
|
||||||
.reduce(
|
|
||||||
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
transpose = -midiDiff(closest); // semitones to repitch
|
|
||||||
index = getSoundIndex(n, bank[closest].length);
|
|
||||||
url = bank[closest][index];
|
|
||||||
}
|
|
||||||
const label = `${s}:${index}`;
|
|
||||||
return { transpose, url, index, midi, label };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selects entries from `source` and renames them via `map` */
|
|
||||||
export const pickAndRename = (source, map) => {
|
|
||||||
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBaseURL = (url) => {
|
|
||||||
try {
|
|
||||||
// For real URLs
|
|
||||||
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
|
|
||||||
} catch {
|
|
||||||
// For pseudo URLS
|
|
||||||
return url.split('/').slice(0, -1).join('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
import { getAudioContext, registerSound } from './index.mjs';
|
|
||||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
|
||||||
import {
|
|
||||||
applyFM,
|
|
||||||
applyParameterModulators,
|
|
||||||
destroyAudioWorkletNode,
|
|
||||||
getADSRValues,
|
|
||||||
getFrequencyFromValue,
|
|
||||||
getParamADSR,
|
|
||||||
getPitchEnvelope,
|
|
||||||
getVibratoOscillator,
|
|
||||||
getWorklet,
|
|
||||||
webAudioTimeout,
|
|
||||||
} from './helpers.mjs';
|
|
||||||
import { logger } from './logger.mjs';
|
|
||||||
|
|
||||||
export const Warpmode = Object.freeze({
|
|
||||||
NONE: 0,
|
|
||||||
ASYM: 1,
|
|
||||||
MIRROR: 2,
|
|
||||||
BENDP: 3,
|
|
||||||
BENDM: 4,
|
|
||||||
BENDMP: 5,
|
|
||||||
SYNC: 6,
|
|
||||||
QUANT: 7,
|
|
||||||
FOLD: 8,
|
|
||||||
PWM: 9,
|
|
||||||
ORBIT: 10,
|
|
||||||
SPIN: 11,
|
|
||||||
CHAOS: 12,
|
|
||||||
PRIMES: 13,
|
|
||||||
BINARY: 14,
|
|
||||||
BROWNIAN: 15,
|
|
||||||
RECIPROCAL: 16,
|
|
||||||
WORMHOLE: 17,
|
|
||||||
LOGISTIC: 18,
|
|
||||||
SIGMOID: 19,
|
|
||||||
FRACTAL: 20,
|
|
||||||
FLIP: 21,
|
|
||||||
});
|
|
||||||
|
|
||||||
const seenKeys = new Set();
|
|
||||||
async function getPayload(url, label, frameLen = 2048) {
|
|
||||||
const key = `${url},${frameLen}`;
|
|
||||||
if (!seenKeys.has(key)) {
|
|
||||||
const buf = await loadBuffer(url, label);
|
|
||||||
const ch0 = buf.getChannelData(0);
|
|
||||||
const total = ch0.length;
|
|
||||||
const numFrames = Math.max(1, Math.floor(total / frameLen));
|
|
||||||
const frames = new Array(numFrames);
|
|
||||||
for (let i = 0; i < numFrames; i++) {
|
|
||||||
const start = i * frameLen;
|
|
||||||
frames[i] = ch0.subarray(start, start + frameLen);
|
|
||||||
}
|
|
||||||
seenKeys.add(key);
|
|
||||||
return { frames, frameLen, numFrames, key };
|
|
||||||
}
|
|
||||||
return { frameLen, key }; // worklet will use the cached version
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanFileSize(bytes, si) {
|
|
||||||
var thresh = si ? 1000 : 1024;
|
|
||||||
if (bytes < thresh) return bytes + ' B';
|
|
||||||
var units = si
|
|
||||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
||||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
|
||||||
var u = -1;
|
|
||||||
do {
|
|
||||||
bytes /= thresh;
|
|
||||||
++u;
|
|
||||||
} while (bytes >= thresh);
|
|
||||||
return bytes.toFixed(1) + ' ' + units[u];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the sample rate of a .wav file
|
|
||||||
function parseWavSampleRate(arrBuf) {
|
|
||||||
const dv = new DataView(arrBuf);
|
|
||||||
// Header is "RIFF<chunk size (4 bytes)>WAVE", so 12 bytes
|
|
||||||
let p = 12;
|
|
||||||
// Look through chunks for the format header
|
|
||||||
// (they will always have an 8 byte header (id and size) followed by a payload)
|
|
||||||
while (p + 8 <= dv.byteLength) {
|
|
||||||
// Parse id
|
|
||||||
const id = String.fromCharCode(dv.getUint8(p), dv.getUint8(p + 1), dv.getUint8(p + 2), dv.getUint8(p + 3));
|
|
||||||
// Parse chunk size
|
|
||||||
const size = dv.getUint32(p + 4, true);
|
|
||||||
if (id === 'fmt ') {
|
|
||||||
// The format chunk contains the sample rate after
|
|
||||||
// 8 bytes of header, 2 bytes of format tag, 2 bytes of num channels
|
|
||||||
// (for a total of 12)
|
|
||||||
return dv.getUint32(p + 12, true);
|
|
||||||
}
|
|
||||||
// Advance to next chunk
|
|
||||||
p += 8 + size + (size & 1);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decodeAtNativeRate(arr) {
|
|
||||||
const sr = parseWavSampleRate(arr) || 44100;
|
|
||||||
const tempAC = new OfflineAudioContext(1, 1, sr);
|
|
||||||
return await tempAC.decodeAudioData(arr);
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCache = {};
|
|
||||||
const loadBuffer = (url, label) => {
|
|
||||||
url = url.replace('#', '%23');
|
|
||||||
if (!loadCache[url]) {
|
|
||||||
logger(`[wavetable] load table ${label}..`, 'load-table', { url });
|
|
||||||
const timestamp = Date.now();
|
|
||||||
loadCache[url] = fetch(url)
|
|
||||||
.then((res) => res.arrayBuffer())
|
|
||||||
.then(async (res) => {
|
|
||||||
const took = Date.now() - timestamp;
|
|
||||||
const size = humanFileSize(res.byteLength);
|
|
||||||
logger(`[wavetable] load table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url });
|
|
||||||
const decoded = await decodeAtNativeRate(res);
|
|
||||||
return decoded;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return loadCache[url];
|
|
||||||
};
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _processTables = (json, baseUrl, frameLen, options = {}) => {
|
|
||||||
baseUrl = json._base || baseUrl;
|
|
||||||
return Object.entries(json).forEach(([key, tables]) => {
|
|
||||||
if (key === '_base') return false;
|
|
||||||
if (typeof tables === 'string') {
|
|
||||||
tables = [tables];
|
|
||||||
}
|
|
||||||
if (typeof tables !== 'object') {
|
|
||||||
throw new Error('wrong json format for ' + key);
|
|
||||||
}
|
|
||||||
let resolvedUrl = baseUrl;
|
|
||||||
if (resolvedUrl.startsWith('github:')) {
|
|
||||||
resolvedUrl = githubPath(resolvedUrl, '');
|
|
||||||
}
|
|
||||||
tables = tables
|
|
||||||
.map((t) => resolvedUrl + t)
|
|
||||||
.filter((t) => {
|
|
||||||
if (!t.toLowerCase().endsWith('.wav')) {
|
|
||||||
logger(`[wavetable] skipping ${t} -- wavetables must be ".wav" format`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (tables.length) {
|
|
||||||
registerWaveTable(key, tables, { baseUrl, frameLen });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function registerWaveTable(key, tables, params) {
|
|
||||||
registerSound(
|
|
||||||
key,
|
|
||||||
(t, hapValue, onended, cps) => {
|
|
||||||
return onTriggerSynth(t, hapValue, onended, tables, cps, params?.frameLen ?? 2048);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'wavetable',
|
|
||||||
tables,
|
|
||||||
...params,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a collection of wavetables to use with `s`
|
|
||||||
*
|
|
||||||
* @name tables
|
|
||||||
*/
|
|
||||||
export const tables = async (url, frameLen, json, options = {}) => {
|
|
||||||
if (json !== undefined) return _processTables(json, url, frameLen);
|
|
||||||
if (url.startsWith('github:')) {
|
|
||||||
url = githubPath(url, 'strudel.json');
|
|
||||||
}
|
|
||||||
if (url.startsWith('local:')) {
|
|
||||||
url = `http://localhost:5432`;
|
|
||||||
}
|
|
||||||
const base = getBaseURL(url);
|
|
||||||
if (typeof fetch !== 'function') {
|
|
||||||
// not a browser
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof fetch === 'undefined') {
|
|
||||||
// skip fetch when in node / testing
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return fetch(url)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((json) => _processTables(json, base, frameLen, options))
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
throw new Error(`error loading "${url}"`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
|
||||||
const { s, n = 0, duration, clip } = value;
|
|
||||||
const ac = getAudioContext();
|
|
||||||
const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
|
|
||||||
let { warpmode } = value;
|
|
||||||
if (typeof warpmode === 'string') {
|
|
||||||
warpmode = Warpmode[warpmode.toUpperCase()] ?? Warpmode.NONE;
|
|
||||||
}
|
|
||||||
const frequency = getFrequencyFromValue(value);
|
|
||||||
const { url, label } = getCommonSampleInfo(value, tables);
|
|
||||||
const payload = await getPayload(url, label, frameLen);
|
|
||||||
let holdEnd = t + duration;
|
|
||||||
if (clip !== undefined) {
|
|
||||||
holdEnd = Math.min(t + clip * duration, holdEnd);
|
|
||||||
}
|
|
||||||
const endWithRelease = holdEnd + release;
|
|
||||||
const envEnd = endWithRelease + 0.01;
|
|
||||||
const source = getWorklet(
|
|
||||||
ac,
|
|
||||||
'wavetable-oscillator-processor',
|
|
||||||
{
|
|
||||||
begin: t,
|
|
||||||
end: envEnd,
|
|
||||||
frequency,
|
|
||||||
freqspread: value.detune,
|
|
||||||
position: value.wt,
|
|
||||||
warp: value.warp,
|
|
||||||
warpMode: warpmode,
|
|
||||||
voices: Math.max(value.unison ?? 1, 1),
|
|
||||||
panspread: value.spread,
|
|
||||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
|
||||||
},
|
|
||||||
{ outputChannelCount: [2] },
|
|
||||||
);
|
|
||||||
source.port.postMessage({ type: 'table', payload });
|
|
||||||
if (ac.currentTime > t) {
|
|
||||||
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease];
|
|
||||||
const warpADSRParams = [value.warpattack, value.warpdecay, value.warpsustain, value.warprelease];
|
|
||||||
const wtParams = source.parameters;
|
|
||||||
const positionParam = wtParams.get('position');
|
|
||||||
const warpParam = wtParams.get('warp');
|
|
||||||
|
|
||||||
let wtrate = value.wtrate;
|
|
||||||
if (value.wtsync != null) {
|
|
||||||
wtrate = cps * value.wtsync;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wtPosModulators = applyParameterModulators(
|
|
||||||
ac,
|
|
||||||
positionParam,
|
|
||||||
t,
|
|
||||||
endWithRelease,
|
|
||||||
{
|
|
||||||
offset: value.wt,
|
|
||||||
amount: value.wtenv,
|
|
||||||
defaultAmount: 0.5,
|
|
||||||
shape: 'linear',
|
|
||||||
values: posADSRParams,
|
|
||||||
holdEnd,
|
|
||||||
defaultValues: [0, 0.5, 0, 0.1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frequency: wtrate,
|
|
||||||
depth: value.wtdepth,
|
|
||||||
defaultDepth: 0.5,
|
|
||||||
shape: value.wtshape,
|
|
||||||
skew: value.wtskew,
|
|
||||||
dcoffset: value.wtdc ?? 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let warprate = value.warprate;
|
|
||||||
if (value.warpsync != null) {
|
|
||||||
warprate = warprate = cps * value.warpsync;
|
|
||||||
}
|
|
||||||
const wtWarpModulators = applyParameterModulators(
|
|
||||||
ac,
|
|
||||||
warpParam,
|
|
||||||
t,
|
|
||||||
endWithRelease,
|
|
||||||
{
|
|
||||||
offset: value.warp,
|
|
||||||
amount: value.warpenv,
|
|
||||||
defaultAmount: 0.5,
|
|
||||||
shape: 'linear',
|
|
||||||
values: warpADSRParams,
|
|
||||||
holdEnd,
|
|
||||||
defaultValues: [0, 0.5, 0, 0.1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frequency: warprate,
|
|
||||||
depth: value.warpdepth,
|
|
||||||
defaultDepth: 0.5,
|
|
||||||
shape: value.warpshape,
|
|
||||||
skew: value.warpskew,
|
|
||||||
dcoffset: value.warpdc ?? 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
|
|
||||||
const fm = applyFM(source.parameters.get('frequency'), value, t);
|
|
||||||
const envGain = ac.createGain();
|
|
||||||
const node = source.connect(envGain);
|
|
||||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
|
|
||||||
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
|
|
||||||
const handle = { node, source };
|
|
||||||
const timeoutNode = webAudioTimeout(
|
|
||||||
ac,
|
|
||||||
() => {
|
|
||||||
destroyAudioWorkletNode(source);
|
|
||||||
vibratoOscillator?.stop();
|
|
||||||
fm?.stop();
|
|
||||||
node.disconnect();
|
|
||||||
wtPosModulators?.disconnect();
|
|
||||||
wtWarpModulators?.disconnect();
|
|
||||||
onended();
|
|
||||||
},
|
|
||||||
t,
|
|
||||||
envEnd,
|
|
||||||
);
|
|
||||||
handle.stop = (time) => {
|
|
||||||
timeoutNode.stop(time);
|
|
||||||
};
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
+185
-674
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user