mirror of
https://codeberg.org/uzu/strudel
synced 2026-10-15 12:33:14 -04:00
Compare commits
423 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3422c5e6 | |||
| 382d5df933 | |||
| 2e47716d23 | |||
| 0ffeb14c58 | |||
| 73b3982b69 | |||
| a18c2b6b80 | |||
| c9b32ec38b | |||
| d3325a2f82 | |||
| a2af0cd5cc | |||
| 56b279d723 | |||
| 32cccda215 | |||
| d7b4c6e498 | |||
| dd8d04dc5c | |||
| 1a52eb4515 | |||
| 196115a9da | |||
| 3d6f4cf32b | |||
| 2ebf9f7103 | |||
| a3302cf167 | |||
| cd6d364067 | |||
| ff80bae275 | |||
| 8247179a4d | |||
| c398620439 | |||
| 2cc9bf2568 | |||
| 39b6466a91 | |||
| d5dc506dc4 | |||
| 1f2202e6a1 | |||
| 63e2810375 | |||
| 262a6d85db | |||
| e66723230b | |||
| cb06042d3b | |||
| 88fdb424c4 | |||
| 0d4816804e | |||
| ce4ef629bf | |||
| 6448bd0956 | |||
| 884409310f | |||
| 126e45af9a | |||
| a6943f1eeb | |||
| 4a0d9de664 | |||
| 7f486556c6 | |||
| 257787d38a | |||
| 9eebf0ad21 | |||
| d97e91e928 | |||
| e78cc85ad7 | |||
| ee9138b514 | |||
| 0b786700d0 | |||
| 076bb8a152 | |||
| 357af69052 | |||
| fd2626d11d | |||
| 2b5f31f605 | |||
| c57c5a7c4d | |||
| c7d0f15826 | |||
| 60ac02905d | |||
| 5148066b01 | |||
| 29a4e30042 | |||
| 0a6d61e14a | |||
| 74de75644f | |||
| 6e50217a53 | |||
| c57320a2de | |||
| a1be663b15 | |||
| 9c05f8ebc6 | |||
| b2c2dfb0b4 | |||
| 38956e4d16 | |||
| c5fd80902b | |||
| 58dfe9a55c | |||
| ecc7bc078c | |||
| 078aa60779 | |||
| d7384dfc3d | |||
| 4671d343c4 | |||
| a3a38cfe33 | |||
| 98e99e8166 | |||
| 8f81463b9c | |||
| 8120fc72ab | |||
| d7fb9fe992 | |||
| 37d524e639 | |||
| d65b24db8f | |||
| f1dd209749 | |||
| d6f0b863f0 | |||
| 5dbbc7b9e1 | |||
| beb16f4040 | |||
| f2be5e4f5e | |||
| c7db8eaad6 | |||
| 723cd5da04 | |||
| 98d19a6643 | |||
| ef106b356f | |||
| ee460dc980 | |||
| d5d6bc6f34 | |||
| d9336aba3a | |||
| 263c9c0253 | |||
| 6ecdc739b4 | |||
| f67e81a03f | |||
| 5cf7571b77 | |||
| b2aeaa56c1 | |||
| 9e3792f6a6 | |||
| f8ed9c3a7c | |||
| c5b42b5555 | |||
| 75f58bc875 | |||
| 3b037deb10 | |||
| 362b2df390 | |||
| 20d925c10c | |||
| 91f37accdd | |||
| 07f01c237f | |||
| 76fa8264cd | |||
| 08dc5e2c5c | |||
| bd54c8003a | |||
| 9e946f1d14 | |||
| f1a541caec | |||
| 47cdf4561d | |||
| 6c8b707d2e | |||
| adcbf9240e | |||
| f4b3fdaacd | |||
| 9bf3249e82 | |||
| 0a0afcf73f | |||
| 6ebf506070 | |||
| eafa2f17ec | |||
| 429682735d | |||
| 04232370c2 | |||
| 4484ee4e80 | |||
| 57598f2aa1 | |||
| ebc25467c0 | |||
| a866cb2189 | |||
| 890d69b23e | |||
| 8e187abf24 | |||
| 2d014b47c8 | |||
| daafee7527 | |||
| 692170e117 | |||
| c9522a4e49 | |||
| 14ac26fa45 | |||
| 704018f774 | |||
| 25d1179985 | |||
| c150752372 | |||
| e248bf85f3 | |||
| 6c51c0261a | |||
| deefde7b50 | |||
| d4f63e8de3 | |||
| 19cb3dedc2 | |||
| a7c3407da7 | |||
| 6870b04fb2 | |||
| 2b2646a768 | |||
| 97ef5bc335 | |||
| 85e6d436ef | |||
| d3e2b7c7b4 | |||
| def1738259 | |||
| 0feeb1e701 | |||
| 813cc6c190 | |||
| 62d4f84e69 | |||
| 49a1e11cd8 | |||
| 941c97da0d | |||
| 95a9d301a1 | |||
| e38811d325 | |||
| 475f17ddfd | |||
| bd68c6a0a7 | |||
| 2635716c87 | |||
| 58f956b57e | |||
| 0ae2c120b3 | |||
| 05a43ef687 | |||
| f21aeb55bd | |||
| 422b03699a | |||
| cb046a3548 | |||
| d83139980b | |||
| 0c61cd7670 | |||
| 13b11efa15 | |||
| fe0be6bc53 | |||
| 4747fab4ed | |||
| f73b395648 | |||
| 9c47efd3cf | |||
| f4066089f4 | |||
| 0e81987c4e | |||
| 23dbe53013 | |||
| 0e4d0484a3 | |||
| 37bf2e4dc7 | |||
| e69761397e | |||
| 10c33f24f5 | |||
| ea0ec7edb9 | |||
| f900642359 | |||
| 4cc63b8b01 | |||
| d5171e2ab7 | |||
| 81257b5f06 | |||
| 26832c0fe7 | |||
| 01abc8ae79 | |||
| 84df683794 | |||
| ca1aca0c00 | |||
| 896ec4c643 | |||
| 1723125099 | |||
| e8668a1552 | |||
| 86d65c72f0 | |||
| 0d73c02314 | |||
| 3dff15eab0 | |||
| 8f1a0840c0 | |||
| 9f19e4616a | |||
| e15ce7d402 | |||
| ef5ddc90c2 | |||
| ae32743f12 | |||
| 7294cd5443 | |||
| 948f78da11 | |||
| 08eaa8a578 | |||
| aeeaa50071 | |||
| 91681983e2 | |||
| 6df34625c3 | |||
| 8ad767363f | |||
| 581bbc05fc | |||
| a58711a311 | |||
| 4596926ac2 | |||
| f2acdc580f | |||
| beee2bc921 | |||
| b586d8c4c4 | |||
| c901ffb37f | |||
| c0a43a20dc | |||
| fa4d060ed2 | |||
| 25bf6856ae | |||
| f769b9c7a5 | |||
| 7c7da3e783 | |||
| 3e5c945e12 | |||
| a1ccb9c0bb | |||
| 7510cb715f | |||
| 3554a544d6 | |||
| a161d5a5d3 | |||
| 02e0c43f77 | |||
| 814510f637 | |||
| cdae661b24 | |||
| e502a3c3e5 | |||
| 28af99914f | |||
| 63f42faca8 | |||
| 1ef39a2ea7 | |||
| 2624b97500 | |||
| 633b7287c0 | |||
| ee4b8fa315 | |||
| 36a456eeb4 | |||
| 3805ba8f76 | |||
| 37d052a655 | |||
| 44ec79182e | |||
| e5a08091f1 | |||
| 70213a26ae | |||
| aedd7de2df | |||
| 82e88d4a28 | |||
| 5cd45d5ea1 | |||
| d930057000 | |||
| daf27ed971 | |||
| 4d0a4902ab | |||
| 58e6b0543f | |||
| dfb2335b1c | |||
| 53cce1d97b | |||
| a76a23a43d | |||
| 787adfb20f | |||
| d4ec7e9a55 | |||
| 10b0e30057 | |||
| c2572f4fad | |||
| 4f7a0f76d3 | |||
| 88d7868e12 | |||
| 8520044f3e | |||
| 4a801ab668 | |||
| 8f61a0aa90 | |||
| 1e2705c07f | |||
| 3cefbf80bf | |||
| 81342fb469 | |||
| 761e2e347e | |||
| 3911142443 | |||
| bc8517ade7 | |||
| a926346d89 | |||
| 0f4e6af49d | |||
| c20a6673af | |||
| 6b00837a4d | |||
| 807cce521c | |||
| aa49d9dcc2 | |||
| 60da901952 | |||
| 30cc92e863 | |||
| 91203233c9 | |||
| c52e8f3b38 | |||
| d5dbc5c428 | |||
| 161d2ce056 | |||
| 7cb8a151a4 | |||
| 6f108f7f28 | |||
| 7c607c8d06 | |||
| be0b3c0d8b | |||
| d3e9b9c020 | |||
| c16b300144 | |||
| 29a3543918 | |||
| 1e502c7e8c | |||
| 27a22d7609 | |||
| c515ba89a0 | |||
| 7e3063038e | |||
| 9bc560b17f | |||
| bdeafbaf75 | |||
| 8984055ea6 | |||
| 04dcbda9a3 | |||
| d7067f3562 | |||
| a39c9e6096 | |||
| 9690f78c30 | |||
| cf845ddbab | |||
| 3f72b9b97a | |||
| a347bd607f | |||
| 3966659f18 | |||
| 6d9b899ae4 | |||
| d802907dac | |||
| 2bea102dc1 | |||
| d9e5c5b0c8 | |||
| 7c1304690f | |||
| 3c366b37ae | |||
| 52a206ceca | |||
| 23f6ff9bc4 | |||
| 3e1d02cb3d | |||
| 8a72f21257 | |||
| 2755a7eebf | |||
| deb69ced9f | |||
| d5a9019220 | |||
| d0d2459e14 | |||
| a60a54147c | |||
| 6707c7893b | |||
| 1d0e2bd0c7 | |||
| b5ef12a2b1 | |||
| e3dd4ab126 | |||
| 751f924a4d | |||
| 7b58bbf12b | |||
| b2cfd3dc3d | |||
| 9f2237e11d | |||
| 6d99a7f399 | |||
| 5921bec5f4 | |||
| b4e171f5b4 | |||
| 90ad12300a | |||
| ffc6d12d87 | |||
| 1334276ec0 | |||
| cbf472fe0a | |||
| 4439599117 | |||
| 32754ef97a | |||
| 67bce82a62 | |||
| 5da3ae8df4 | |||
| eb3818eebb | |||
| e6b824ebff | |||
| c74dd95bd0 | |||
| 897a750f70 | |||
| 81d02cf5ae | |||
| a6dc6bb831 | |||
| 00a3c1acc0 | |||
| 8ff51d4468 | |||
| 2e041e8647 | |||
| 55095ba4e8 | |||
| 530f45a7bb | |||
| 99295275ec | |||
| c2e45eb36c | |||
| 35e32a32e8 | |||
| 8def3f6af6 | |||
| aa3698c4f4 | |||
| 6b525b4819 | |||
| 77ced19144 | |||
| 14cc5d989b | |||
| 47ad4bb171 | |||
| e3ad6b1f0a | |||
| 1e771d6150 | |||
| 3ac4649536 | |||
| 14be1ddf7f | |||
| 207ce9cbec | |||
| 3abcc59874 | |||
| 7fc8f1c5c9 | |||
| 016315afbc | |||
| b3c0d9179f | |||
| 993dc53e92 | |||
| 60345db1bd | |||
| a09349e457 | |||
| 71f538b115 | |||
| 70be560a7a | |||
| 269758ebdb | |||
| 98d613fbdf | |||
| f5e21a220b | |||
| f6e6df78a8 | |||
| 05ae80b0ca | |||
| 3bbaf77b20 | |||
| a9975a2b41 | |||
| 2b3f719484 | |||
| 4051fd5241 | |||
| 55ffb59b9c | |||
| 93cf9fcb6a | |||
| a5b867f1b1 | |||
| d8645e9a71 | |||
| b824ad16ae | |||
| 03b221a2a2 | |||
| c96b1c2faf | |||
| 87e723649e | |||
| e35a2000de | |||
| 4b8d80016b | |||
| d2f2c6df02 | |||
| 9bcaaa3ac4 | |||
| 2e7ec9ea27 | |||
| bc9e97b9ac | |||
| 00f0ef56bc | |||
| 44ad05fb20 | |||
| 18cc1e4466 | |||
| 055f009357 | |||
| 2fe1712dae | |||
| 7c22b29493 | |||
| 0ef30d5e8d | |||
| 160ef84b47 | |||
| 1a7f464998 | |||
| ee326c709e | |||
| a0291e8a42 | |||
| ecada58edc | |||
| bb7f587025 | |||
| dab1a018e4 | |||
| 0c01a997ac | |||
| b8dc50267e | |||
| 1aaa04ab50 | |||
| 1860d31624 | |||
| 47557cf06f | |||
| 213edfb211 | |||
| 84e59645fd | |||
| 4df19f9ff8 | |||
| ba7721f53c | |||
| fd1e3d248a | |||
| bad81e3850 | |||
| a409149dae | |||
| a6b3b9ef5e | |||
| ec73c48e2e | |||
| c77675c492 | |||
| 5197af1aa4 | |||
| 65dd79e374 | |||
| 85f6472894 | |||
| 54fd877be1 | |||
| 7d948fd1a7 | |||
| 3373fa39a6 | |||
| 1de8123437 | |||
| 781c477078 | |||
| e2cdce5ac9 | |||
| ad331c8c2e | |||
| 43293dc80f | |||
| 73965a3a39 |
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
**/.angular
|
||||
**/dist
|
||||
.astro/
|
||||
.forgejo/
|
||||
.idea/
|
||||
.vscode/
|
||||
test/
|
||||
.gitignore
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Build and Deploy hot PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: "warm-pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.label.name == 'serve-hot' }}
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
eval $(ssh-agent -s)
|
||||
echo "$SSH_PRIVATE_KEY" | ssh-add -
|
||||
apt update && apt install -y rsync
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress \
|
||||
./website/dist/ \
|
||||
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy to beta (warm.strudel.cc)
|
||||
name: Build and Deploy to warm (strudel.cc)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,17 +13,13 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
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
|
||||
|
||||
@@ -37,4 +33,4 @@ jobs:
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy to live (strudel.cc)
|
||||
name: Build and Deploy to cold (cold.strudel.cc)
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
@@ -10,17 +10,13 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
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
|
||||
|
||||
@@ -34,4 +30,4 @@ jobs:
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
|
||||
|
||||
@@ -5,21 +5,11 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
container: node:22-bookworm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: apt install ztd
|
||||
run: apt update && apt install -y zstd
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- run: pnpm run format-check
|
||||
- run: pnpm run lint
|
||||
|
||||
-14
@@ -45,20 +45,6 @@ tidal-drum-machines
|
||||
webaudiofontdata
|
||||
src-tauri/target
|
||||
|
||||
|
||||
|
||||
### BEGIN Visual Studio Code ###
|
||||
# Blanket, recursive exclude for .vscode directory and files
|
||||
.vscode/**/*
|
||||
# Unexclude specific files and directories within .vscode
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
### END Visual Studio Code ###
|
||||
|
||||
|
||||
|
||||
# BEGIN JetBrains -> END JetBrains
|
||||
|
||||
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
|
||||
+11
-1
@@ -52,7 +52,17 @@ Strudel is a project handmade by humans, with thought and nuance.
|
||||
|
||||
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
|
||||
|
||||
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
|
||||
There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels.
|
||||
|
||||
## Creating and sharing a new project using strudel
|
||||
|
||||
Strudel is free/open source software, and we are also happy to see people making use of it within the following terms.
|
||||
|
||||
Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).)
|
||||
|
||||
Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
|
||||
|
||||
You are also encouraged to connect with the community and understand our aims and values.
|
||||
|
||||
## Report a Bug
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const drawContext = canvas.getContext('2d');
|
||||
const drawTime = [-2, 2]; // time window of drawn haps
|
||||
|
||||
const editor = new StrudelMirror({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
transpiler,
|
||||
root: document.getElementById('editor'),
|
||||
@@ -29,6 +29,7 @@ const editor = new StrudelMirror({
|
||||
import('@strudel/core'),
|
||||
import('@strudel/draw'),
|
||||
import('@strudel/mini'),
|
||||
import('@strudel/edo'),
|
||||
import('@strudel/tonal'),
|
||||
import('@strudel/webaudio'),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@strudel/codemirror": "workspace:*",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/edo": "workspace:*",
|
||||
"@strudel/mini": "workspace:*",
|
||||
"@strudel/soundfonts": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
|
||||
@@ -12,7 +12,7 @@ registerSynthSounds();
|
||||
evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal'));
|
||||
|
||||
const { evaluate } = repl({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => ctx.currentTime,
|
||||
transpiler,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './packages/core/index.mjs';
|
||||
export * from './packages/csound/index.mjs';
|
||||
export * from './packages/desktopbridge/index.mjs';
|
||||
export * from './packages/draw/index.mjs';
|
||||
export * from './packages/edo/index.mjs';
|
||||
export * from './packages/embed/index.mjs';
|
||||
export * from './packages/hydra/index.mjs';
|
||||
export * from './packages/midi/index.mjs';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Port of tidalcycles to javascript",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"scripts": {
|
||||
"setup": "pnpm i",
|
||||
"pretest": "npm run jsdoc-json",
|
||||
@@ -48,6 +49,7 @@
|
||||
"homepage": "https://strudel.cc",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/edo": "workspace:*",
|
||||
"@strudel/mini": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
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) => {
|
||||
const div = document.createElement('div');
|
||||
@@ -80,53 +75,6 @@ const isValidDoc = (doc) => {
|
||||
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
|
||||
let scaleCompletions = [];
|
||||
// try {
|
||||
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
||||
// } catch (e) {
|
||||
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
||||
// }
|
||||
|
||||
// Valid mode values for voicing
|
||||
const modeCompletions = [
|
||||
{ label: 'below', type: 'mode' },
|
||||
{ label: 'above', type: 'mode' },
|
||||
{ label: 'duck', type: 'mode' },
|
||||
{ label: 'root', type: 'mode' },
|
||||
];
|
||||
|
||||
// Valid chord symbols from ireal dictionary plus empty string for major triads
|
||||
const chordSymbols = ['', ...Object.keys(complex)].sort();
|
||||
const chordSymbolCompletions = chordSymbols.map((symbol) => {
|
||||
if (symbol === '') {
|
||||
return {
|
||||
label: 'major',
|
||||
apply: '',
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: symbol,
|
||||
apply: symbol,
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
});
|
||||
|
||||
export const getSynonymDoc = (doc, synonym) => {
|
||||
const synonyms = doc.synonyms || [];
|
||||
const docLabel = getDocLabel(doc);
|
||||
@@ -165,299 +113,19 @@ const jsdocCompletions = (() => {
|
||||
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;
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
/* options: [
|
||||
{ label: 'match', type: 'keyword' },
|
||||
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||
], */
|
||||
};
|
||||
};
|
||||
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
|
||||
@@ -2,23 +2,13 @@ import {
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
} from '@codemirror/language';
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
|
||||
// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
@@ -24,12 +24,7 @@ import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import {
|
||||
deleteAllInlineBeforeCharacter,
|
||||
InsertCharBeforeChar,
|
||||
jumpToCharacter,
|
||||
jumpToNextCharacter,
|
||||
} from './labelJump.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
@@ -79,10 +74,6 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||
decode: JSON.parse,
|
||||
});
|
||||
|
||||
const ANON_LABEL = '$';
|
||||
const SOLO_LABEL = 'S';
|
||||
const MUTE_LABEL = '_';
|
||||
|
||||
// https://codemirror.net/docs/guide/
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
||||
const settings = codemirrorSettings.get();
|
||||
@@ -131,75 +122,12 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
},
|
||||
{
|
||||
key: 'Alt-w',
|
||||
run: (view) => jumpToNextCharacter(view, ANON_LABEL, 1),
|
||||
run: (view) => jumpToCharacter(view, '$', 1),
|
||||
},
|
||||
{
|
||||
key: 'Alt-q',
|
||||
run: (view) => {
|
||||
return jumpToNextCharacter(view, ANON_LABEL, -1);
|
||||
},
|
||||
run: (view) => jumpToCharacter(view, '$', -1),
|
||||
},
|
||||
// clear all muted
|
||||
{
|
||||
key: `Alt-Ctrl-0`,
|
||||
run: (view) => {
|
||||
return deleteAllInlineBeforeCharacter(view, MUTE_LABEL + ANON_LABEL);
|
||||
},
|
||||
},
|
||||
// clear all solod
|
||||
{
|
||||
key: `Alt-Shift-0`,
|
||||
run: (view) => {
|
||||
return deleteAllInlineBeforeCharacter(view, SOLO_LABEL + ANON_LABEL);
|
||||
},
|
||||
},
|
||||
// clear all solo and mute
|
||||
{
|
||||
key: `Ctrl-Shift-0`,
|
||||
run: (view) => {
|
||||
return deleteAllInlineBeforeCharacter(view, ANON_LABEL);
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }).map((_, i) => {
|
||||
let num = i + 1;
|
||||
return {
|
||||
key: `Alt-${num}`,
|
||||
run: (view) => {
|
||||
return jumpToCharacter(view, ANON_LABEL, i);
|
||||
},
|
||||
};
|
||||
}),
|
||||
// handle solo toggles 1-9
|
||||
...Array.from({ length: 9 }).map((_, i) => {
|
||||
let num = i + 1;
|
||||
return {
|
||||
key: `Alt-Shift-${num}`,
|
||||
run: (view) => {
|
||||
return InsertCharBeforeChar(view, ANON_LABEL, SOLO_LABEL, i);
|
||||
},
|
||||
};
|
||||
}),
|
||||
// handle mute toggles 1-9
|
||||
...Array.from({ length: 9 }).map((_, i) => {
|
||||
let num = i + 1;
|
||||
return {
|
||||
key: `Alt-Ctrl-${num}`,
|
||||
run: (view) => {
|
||||
return InsertCharBeforeChar(view, ANON_LABEL, MUTE_LABEL, i);
|
||||
},
|
||||
};
|
||||
}),
|
||||
// Handle clearing mutes and solos 1-9
|
||||
...Array.from({ length: 9 }).map((_, i) => {
|
||||
let num = i + 1;
|
||||
return {
|
||||
key: `Ctrl-Shift-${num}`,
|
||||
run: (view) => {
|
||||
return InsertCharBeforeChar(view, ANON_LABEL,'', i);
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/* {
|
||||
key: 'Ctrl-Shift-.',
|
||||
run: () => (onPanic ? onPanic() : onStop?.()),
|
||||
@@ -280,8 +208,9 @@ export class StrudelMirror {
|
||||
},
|
||||
afterEval: (options) => {
|
||||
// remember for when highlighting is toggled on
|
||||
this.miniLocations = options.meta?.miniLocations;
|
||||
this.widgets = options.meta?.widgets;
|
||||
this.miniLocations = options.meta?.miniLocations || [];
|
||||
this.widgets = options.meta?.widgets || [];
|
||||
|
||||
const sliders = this.widgets.filter((w) => w.type === 'slider');
|
||||
updateSliderWidgets(this.editor, sliders);
|
||||
const widgets = this.widgets.filter((w) => w.type !== 'slider');
|
||||
@@ -328,33 +257,6 @@ export class StrudelMirror {
|
||||
}
|
||||
};
|
||||
document.addEventListener('start-repl', this.onStartRepl);
|
||||
|
||||
// Handle global evaluation requests (e.g., from Vim :w)
|
||||
this.onEvaluateRequest = (e) => {
|
||||
try {
|
||||
// Evaluate current editor on repl-evaluate
|
||||
logger('[repl] evaluate via event');
|
||||
this.evaluate();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-evaluate event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
document.addEventListener('repl-stop', this.onStopRequest);
|
||||
|
||||
// Toggle comments requested from Vim (gc)
|
||||
this.onToggleComment = (e) => {
|
||||
try {
|
||||
// Honor selections; toggleLineComment handles both selections and
|
||||
// single line
|
||||
toggleLineComment(this.editor);
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-toggle-comment event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
draw(haps, time, painters) {
|
||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||
@@ -382,15 +284,6 @@ export class StrudelMirror {
|
||||
this.repl.scheduler.stop();
|
||||
}
|
||||
|
||||
// Listen for global stop requests (e.g., from Vim :q)
|
||||
onStopRequest = (e) => {
|
||||
try {
|
||||
this.stop();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-stop event', err);
|
||||
}
|
||||
};
|
||||
async toggle() {
|
||||
if (this.repl.scheduler.started) {
|
||||
this.repl.stop();
|
||||
@@ -465,19 +358,18 @@ export class StrudelMirror {
|
||||
this.setFontSize(value);
|
||||
}
|
||||
}
|
||||
setCode(code) {
|
||||
const changes = {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length,
|
||||
insert: code,
|
||||
};
|
||||
replaceCode(code, from, to) {
|
||||
const changes = { from, to, insert: code };
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
insertCode(code, position) {
|
||||
this.replaceCode(code, position, position);
|
||||
}
|
||||
setCode(code) {
|
||||
this.replaceCode(code, 0, this.editor.state.doc.length);
|
||||
}
|
||||
clear() {
|
||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
|
||||
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './slider.mjs';
|
||||
export * from './themes.mjs';
|
||||
export * from './widget.mjs';
|
||||
export { Vim } from './keybindings.mjs';
|
||||
export * from './basicSetup.mjs';
|
||||
|
||||
@@ -3,10 +3,9 @@ import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
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 { logger } from '@strudel/core';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
@@ -20,110 +19,6 @@ const vscodePlugin = ViewPlugin.fromClass(
|
||||
);
|
||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||
|
||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
try {
|
||||
if (Vim && typeof Vim.defineEx === 'function') {
|
||||
// Map gc to toggle line comments by dispatching a custom event that our
|
||||
// CodeMirror integration listens to. This avoids depending on Vim's
|
||||
// internal actions and works with current selections/visual mode.
|
||||
try {
|
||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
||||
const view = cm?.view || cm;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
||||
document.dispatchEvent(ev);
|
||||
} catch (e) {
|
||||
console.error('strudelToggleComment dispatch failed', e);
|
||||
}
|
||||
});
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
|
||||
} catch (e) {
|
||||
console.error('Vim gc mapping failed', e);
|
||||
}
|
||||
|
||||
// :q to pause/stop
|
||||
Vim.defineEx('quit', 'q', (cm) => {
|
||||
try {
|
||||
const view = cm?.view || cm;
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :q stop event', e);
|
||||
}
|
||||
});
|
||||
|
||||
// :w to evaluate
|
||||
Vim.defineEx('write', 'w', (cm) => {
|
||||
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
|
||||
try {
|
||||
view?.focus?.();
|
||||
// Let the app know this came from Vim :w
|
||||
try {
|
||||
logger('[vim] :w — evaluating code');
|
||||
} catch (e) {
|
||||
console.error('Error logging Vim :w evaluation', e);
|
||||
}
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :w evaluation event', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
|
||||
}
|
||||
|
||||
const keymaps = {
|
||||
vim,
|
||||
emacs,
|
||||
|
||||
@@ -1,54 +1,18 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { SearchCursor } from '@codemirror/search';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* gets all of the positions of a character in a document, excluding commented out lines
|
||||
* @param { EditorState} state
|
||||
* @param {String} character
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function getCharacterPositions(state, character) {
|
||||
const cursor = new SearchCursor(state.doc, character);
|
||||
|
||||
const characterPositions = [];
|
||||
while (!cursor.next().done) {
|
||||
|
||||
const linestartpos = state.doc.lineAt(cursor.value.to).from
|
||||
if (!isLineCommentedOut(state, linestartpos)) {
|
||||
|
||||
characterPositions.push(cursor.value.to);
|
||||
}
|
||||
}
|
||||
return characterPositions;
|
||||
}
|
||||
|
||||
function isLineCommentedOut(state, pos) {
|
||||
|
||||
const line = state.doc.lineAt(pos);
|
||||
// remove white space
|
||||
pos = line.from + line.text.search(/\S/)
|
||||
|
||||
const tree = syntaxTree(state);
|
||||
const node = tree.resolveInner(pos, 1)
|
||||
return node.name.includes("Comment")
|
||||
}
|
||||
|
||||
/**
|
||||
* jump to the next character in a document
|
||||
* @param {EditorView} view
|
||||
* @param {String} character
|
||||
* @param {number} direction 0 or 1
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function jumpToNextCharacter(view, character, direction = 1) {
|
||||
export function jumpToCharacter(view, character, direction = 1) {
|
||||
const { state, dispatch } = view;
|
||||
const pos = state.selection.main.head;
|
||||
const cursor = new SearchCursor(state.doc, character);
|
||||
|
||||
let characterPositions = [];
|
||||
let jumpPos;
|
||||
const characterPositions = getCharacterPositions(state, character);
|
||||
while (!cursor.next().done) {
|
||||
characterPositions.push(cursor.value.to);
|
||||
}
|
||||
if (!characterPositions.length) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
if (direction > 0) {
|
||||
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
|
||||
@@ -57,97 +21,11 @@ export function jumpToNextCharacter(view, character, direction = 1) {
|
||||
}
|
||||
|
||||
if (jumpPos == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
const selection = EditorSelection.cursor(jumpPos - 1);
|
||||
dispatch({
|
||||
selection,
|
||||
effects: EditorView.scrollIntoView(
|
||||
selection.head,
|
||||
{ y: "start" }
|
||||
)
|
||||
selection: EditorSelection.cursor(jumpPos - 1),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {EditorView} view
|
||||
* @param {String} character
|
||||
* @param {number} index the instance of the character
|
||||
* @returns
|
||||
*/
|
||||
export function jumpToCharacter(view, character, index) {
|
||||
const { state, dispatch } = view;
|
||||
const characterPositions = getCharacterPositions(state, character);
|
||||
const pos = characterPositions.at(index) ?? characterPositions.at(-1);
|
||||
if (pos == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const selection = EditorSelection.cursor(pos - 1);
|
||||
dispatch({
|
||||
selection,
|
||||
effects: EditorView.scrollIntoView(
|
||||
selection.head,
|
||||
{ y: "start" }
|
||||
)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {EditorView} view
|
||||
* @param {String} character
|
||||
* @returns {true}
|
||||
*/
|
||||
export function deleteAllInlineBeforeCharacter(view, character) {
|
||||
const { state, dispatch } = view;
|
||||
const characterPositions = getCharacterPositions(state, character);
|
||||
|
||||
const changes = [];
|
||||
characterPositions.forEach((pos) => {
|
||||
const line = state.doc.lineAt(pos);
|
||||
if (state.doc.sliceString(line.from, line.from + 2) === COMMENT_STRING) {
|
||||
return;
|
||||
}
|
||||
changes.push({
|
||||
from: line.from,
|
||||
to: pos - 1,
|
||||
insert: '',
|
||||
});
|
||||
});
|
||||
dispatch({ changes });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EditorView} view
|
||||
* @param {String} character
|
||||
* @param {String} character2
|
||||
* @param {number} index
|
||||
* @returns
|
||||
*/
|
||||
export function InsertCharBeforeChar(view, character, character2, index) {
|
||||
const { state, dispatch } = view;
|
||||
|
||||
const changes = [];
|
||||
const characterPositions = getCharacterPositions(state, character);
|
||||
const labelpos = characterPositions.at(index) ?? characterPositions.at(-1);
|
||||
const line = state.doc.lineAt(labelpos);
|
||||
|
||||
//delete preceeding characters
|
||||
changes.push({
|
||||
from: line.from,
|
||||
to: labelpos - 1,
|
||||
insert: '',
|
||||
});
|
||||
|
||||
changes.push({
|
||||
insert: character2,
|
||||
from: line.from,
|
||||
});
|
||||
|
||||
dispatch({ changes });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.4",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@codemirror/search": "^6.5.8",
|
||||
"@codemirror/state": "^6.5.1",
|
||||
"@codemirror/view": "^6.36.2",
|
||||
"@codemirror/autocomplete": "catalog:",
|
||||
"@codemirror/commands": "catalog:",
|
||||
"@codemirror/lang-javascript": "catalog:",
|
||||
"@codemirror/language": "catalog:",
|
||||
"@codemirror/search": "catalog:",
|
||||
"@codemirror/state": "catalog:",
|
||||
"@codemirror/view": "catalog:",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@nanostores/persistent": "^0.10.2",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
@@ -46,11 +46,8 @@
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@tonaljs/tonal": "^4.10.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"superdough": "workspace:*"
|
||||
"nanostores": "^0.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
|
||||
+282
-248
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { errorLogger, logger } from './logger.mjs';
|
||||
export class Cyclist {
|
||||
constructor({
|
||||
interval,
|
||||
onSchedule,
|
||||
onTrigger,
|
||||
onToggle,
|
||||
onError,
|
||||
@@ -19,6 +20,11 @@ export class Cyclist {
|
||||
clearInterval,
|
||||
beforeStart,
|
||||
}) {
|
||||
if (onTrigger) {
|
||||
throw new Error(
|
||||
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
|
||||
);
|
||||
}
|
||||
this.started = false;
|
||||
this.beforeStart = beforeStart;
|
||||
this.cps = 0.5;
|
||||
@@ -63,12 +69,7 @@ export class Cyclist {
|
||||
if (hap.hasOnset()) {
|
||||
const targetTime =
|
||||
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
|
||||
const duration = hap.duration / this.cps;
|
||||
// the following line is dumb and only here for backwards compatibility
|
||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||
const deadline = targetTime - phase;
|
||||
// this onTrigger has another signature
|
||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||
onSchedule?.(hap, targetTime, this.cps);
|
||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||
this.cps = hap.value.cps;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
|
||||
+45
-19
@@ -59,6 +59,11 @@ export const bjorklund = function (ons, steps) {
|
||||
* really useful for computer/algorithmic music because they can
|
||||
* describe a large number of rhythms with a couple of numbers.
|
||||
*
|
||||
* A negative number of pulses inverts the pattern
|
||||
* so pulses happen in the gaps of the normal euclidean rhythm.
|
||||
*
|
||||
* A negative number of steps changes it into `euclidLegato`.
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name euclid
|
||||
* @tags temporal
|
||||
@@ -137,8 +142,38 @@ const _euclidRot = function (pulses, steps, rotation) {
|
||||
return b;
|
||||
};
|
||||
|
||||
const _euclidLegato = function (pulses, steps, rotation, pat) {
|
||||
var bin_pat = _euclidRot(pulses, steps, 0);
|
||||
// Because the `gapless` algorithm logic assumes the first entry in bin_pat is a 1,
|
||||
// we rotate the pattern so the first onset is at the start.
|
||||
const firstOnset = bin_pat.indexOf(1);
|
||||
bin_pat = rotate(bin_pat, firstOnset);
|
||||
steps = Math.abs(steps);
|
||||
const gapless = bin_pat
|
||||
.join('')
|
||||
.split('1')
|
||||
.slice(1)
|
||||
.map((s) => [s.length + 1, true]);
|
||||
return pat.struct(timeCat(...gapless).late(Fraction(rotation + firstOnset).div(steps)));
|
||||
};
|
||||
|
||||
const _euclid = function (pulses, steps, rotation, pat) {
|
||||
if (steps === 0 || pulses === 0) {
|
||||
return silence;
|
||||
}
|
||||
const cycles = Math.ceil(Math.abs(pulses) / Math.abs(steps));
|
||||
const totalSteps = cycles * steps;
|
||||
if (totalSteps < 0) {
|
||||
return _euclidLegato(pulses, -totalSteps, rotation, pat.fast(cycles)).slow(cycles);
|
||||
}
|
||||
return pat
|
||||
.fast(cycles)
|
||||
.struct(_euclidRot(pulses, totalSteps, rotation))
|
||||
.slow(cycles);
|
||||
};
|
||||
|
||||
export const euclid = register('euclid', function (pulses, steps, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||
return _euclid(pulses, steps, 0, pat);
|
||||
});
|
||||
|
||||
export const bjork = register('bjork', function (euc, pat) {
|
||||
@@ -146,16 +181,18 @@ export const bjork = register('bjork', function (euc, pat) {
|
||||
euc = [euc];
|
||||
}
|
||||
const [pulses, steps = pulses, rot = 0] = euc;
|
||||
return pat.struct(_euclidRot(pulses, steps, rot));
|
||||
return _euclid(pulses, steps, rot, pat);
|
||||
});
|
||||
|
||||
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, rotation));
|
||||
return _euclid(pulses, steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps.
|
||||
*
|
||||
* A negative number of steps changes it into `euclid`.
|
||||
* @name euclidLegato
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
@@ -167,27 +204,16 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
|
||||
* note("c3").euclidLegato(3,8)
|
||||
*/
|
||||
|
||||
const _euclidLegato = function (pulses, steps, rotation, pat) {
|
||||
if (pulses < 1) {
|
||||
return silence;
|
||||
}
|
||||
const bin_pat = _euclidRot(pulses, steps, 0);
|
||||
const gapless = bin_pat
|
||||
.join('')
|
||||
.split('1')
|
||||
.slice(1)
|
||||
.map((s) => [s.length + 1, true]);
|
||||
return pat.struct(timeCat(...gapless)).late(Fraction(rotation).div(steps));
|
||||
};
|
||||
|
||||
export const euclidLegato = register(['euclidLegato'], function (pulses, steps, pat) {
|
||||
return _euclidLegato(pulses, steps, 0, pat);
|
||||
return _euclid(pulses, -steps, 0, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps, and has an additional parameter for 'rotating'
|
||||
* the resulting sequence
|
||||
* the resulting sequence.
|
||||
*
|
||||
* A negative number of steps changes it into `euclidRot`.
|
||||
* @name euclidLegatoRot
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
@@ -198,7 +224,7 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
|
||||
* note("c3").euclidLegatoRot(3,5,2)
|
||||
*/
|
||||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||
return _euclidLegato(pulses, steps, rotation, pat);
|
||||
return _euclid(pulses, -steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,12 @@ import { logger } from './logger.mjs';
|
||||
import { ClockCollator, cycleToSeconds } from './util.mjs';
|
||||
|
||||
export class NeoCyclist {
|
||||
constructor({ onTrigger, onToggle, getTime }) {
|
||||
constructor({ onSchedule, onToggle, getTime, onTrigger }) {
|
||||
if (onTrigger) {
|
||||
throw new Error(
|
||||
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
|
||||
);
|
||||
}
|
||||
this.started = false;
|
||||
this.cps = 0.5;
|
||||
this.getTime = getTime; // get absolute time
|
||||
@@ -43,8 +48,7 @@ export class NeoCyclist {
|
||||
if (hap.hasOnset()) {
|
||||
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
||||
const targetTime = timeUntilTrigger + currentTime + this.latency;
|
||||
const duration = cycleToSeconds(hap.duration, this.cps);
|
||||
onTrigger?.(hap, 0, duration, this.cps, targetTime);
|
||||
onSchedule?.(hap, targetTime, this.cps);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+608
-105
File diff suppressed because it is too large
Load Diff
+30
-23
@@ -71,29 +71,32 @@ export const pickmod = register('pickmod', function (lookup, pat) {
|
||||
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
|
||||
* @tags combiners, functional
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @param {Pattern} lookup a pattern of indices or names
|
||||
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
|
||||
* @example
|
||||
* note("<c2 d2>(3,8)").s("square")
|
||||
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
|
||||
* .pickF("<0 2> 1", [jux(rev), fast(2), x=>x.lpf(800)])
|
||||
* @example
|
||||
* note("<c2 d2>(3,8)").s("square")
|
||||
* .pickF("<jr l> f", { jr:jux(rev), f:fast(2), l:x=>x.lpf(800) })
|
||||
*/
|
||||
export const pickF = register('pickF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pick(lookup, funcs));
|
||||
export const pickF = register('pickF', function (pickPattern, lookup, pat) {
|
||||
return pat.apply(pick(lookup, pickPattern));
|
||||
});
|
||||
|
||||
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @tags combiners
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @param {Pattern} lookup a pattern of indices or names
|
||||
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pickmod(lookup, funcs));
|
||||
export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) {
|
||||
return pat.apply(pickmod(lookup, pickPattern));
|
||||
});
|
||||
|
||||
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
|
||||
@@ -101,6 +104,8 @@ export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("<0*4 1 2*2 3*3>".pickOut(["g a", "e f", "f g f g" , "g c d"]))
|
||||
*/
|
||||
export const pickOut = register('pickOut', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).outerJoin();
|
||||
@@ -167,20 +172,22 @@ export const pickmodReset = register('pickmodReset', function (lookup, pat) {
|
||||
});
|
||||
|
||||
/** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
|
||||
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
|
||||
* @name inhabit
|
||||
* @tags combiners
|
||||
* @synonyms pickSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
|
||||
b: s("cp sd")
|
||||
})
|
||||
* @example
|
||||
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
|
||||
*/
|
||||
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
|
||||
* @name inhabit
|
||||
* @tags combiners
|
||||
* @synonyms pickSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* let a = s("bd(3,8)")
|
||||
* let b = s("cp sd")
|
||||
* "<a b [a,b]>".inhabit({ a, b })
|
||||
* @example
|
||||
* s("a@2 [a b] a"
|
||||
* .inhabit({a: "bd(3,8)", b: "sd sd"}))
|
||||
* .slow(4)
|
||||
*/
|
||||
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).squeezeJoin();
|
||||
});
|
||||
|
||||
+32
-15
@@ -7,13 +7,15 @@ import {
|
||||
setIsStarted,
|
||||
setPattern as exposeSchedulerPattern,
|
||||
setTime,
|
||||
setTriggerFunc,
|
||||
setScheduleFunc,
|
||||
setClockTime,
|
||||
} from './schedulerState.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
import { reset_state } from './impure.mjs';
|
||||
|
||||
export function repl({
|
||||
onSchedule,
|
||||
defaultOutput,
|
||||
onEvalError,
|
||||
beforeEval,
|
||||
@@ -30,6 +32,11 @@ export function repl({
|
||||
id,
|
||||
mondo = false,
|
||||
}) {
|
||||
if (defaultOutput) {
|
||||
throw new Error(
|
||||
'defaultOutput has been removed. use onSchedule(hap, time, cps) instead. see https://codeberg.org/uzu/strudel/pulls/2138/',
|
||||
);
|
||||
}
|
||||
const state = {
|
||||
schedulerError: undefined,
|
||||
evalError: undefined,
|
||||
@@ -54,7 +61,7 @@ export function repl({
|
||||
};
|
||||
|
||||
const schedulerOptions = {
|
||||
onTrigger: getTrigger({ defaultOutput, getTime }),
|
||||
onSchedule: getSchedule({ onSchedule }),
|
||||
getTime,
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
@@ -72,7 +79,7 @@ export function repl({
|
||||
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
|
||||
const scheduler =
|
||||
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
|
||||
setTriggerFunc(schedulerOptions.onTrigger);
|
||||
setScheduleFunc(schedulerOptions.onSchedule);
|
||||
setCpsFunc(() => scheduler.cps);
|
||||
let pPatterns = {};
|
||||
let anonymousIndex = 0;
|
||||
@@ -102,6 +109,7 @@ export function repl({
|
||||
return pattern;
|
||||
};
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
|
||||
const stop = () => scheduler.stop();
|
||||
const start = () => scheduler.start();
|
||||
@@ -132,8 +140,12 @@ export function repl({
|
||||
|
||||
let allTransforms = [];
|
||||
/**
|
||||
* Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
|
||||
* Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
|
||||
* a version that applies the function to each pattern separately.
|
||||
*
|
||||
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled
|
||||
* pattern such as `note("c4")` is not registered and will produce no audio when `all` is present.
|
||||
* Use `$: note("c4")` instead.
|
||||
* ```
|
||||
* $: sound("bd - cp sd")
|
||||
* $: sound("hh*8")
|
||||
@@ -153,6 +165,9 @@ export function repl({
|
||||
};
|
||||
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
|
||||
*
|
||||
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled
|
||||
* pattern such as `note("c4")` is not registered and will produce no audio when `each` is present.
|
||||
* Use `$: note("c4")` instead.
|
||||
* ```
|
||||
* $: sound("bd - cp sd")
|
||||
* $: sound("hh*8")
|
||||
@@ -225,6 +240,7 @@ export function repl({
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
await beforeEval?.({ code });
|
||||
allTransforms = []; // reset all transforms
|
||||
shouldHush && hush();
|
||||
@@ -266,17 +282,20 @@ export function repl({
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
updateState({
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeCode: code,
|
||||
pattern,
|
||||
evalError: undefined,
|
||||
schedulerError: undefined,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
afterEval?.({ code, pattern, meta });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
@@ -286,24 +305,22 @@ export function repl({
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
|
||||
const setCode = (code) => updateState({ code });
|
||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
}
|
||||
|
||||
export const getTrigger =
|
||||
({ getTime, defaultOutput }) =>
|
||||
async (hap, deadline, duration, cps, t) => {
|
||||
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
|
||||
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||
export const getSchedule =
|
||||
({ onSchedule }) =>
|
||||
async (hap, t, cps) => {
|
||||
try {
|
||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||
await defaultOutput(hap, deadline, duration, cps, t);
|
||||
if (!hap.context.onSchedule || !hap.context.dominantSchedule) {
|
||||
await onSchedule(hap, t, cps);
|
||||
}
|
||||
if (hap.context.onTrigger) {
|
||||
// call signature of output / onTrigger is different...
|
||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
||||
if (hap.context.onSchedule) {
|
||||
await hap.context.onSchedule(hap, t, cps);
|
||||
}
|
||||
} catch (err) {
|
||||
errorLogger(err, 'getTrigger');
|
||||
errorLogger(err, 'getSchedule');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,8 +7,14 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
let time;
|
||||
let cpsFunc;
|
||||
let pattern;
|
||||
let triggerFunc;
|
||||
let scheduleFunc;
|
||||
let isStarted;
|
||||
|
||||
/**
|
||||
* @name getTime
|
||||
* @tags internals
|
||||
* @returns {Number} the current cycle time as a number
|
||||
*/
|
||||
export function getTime() {
|
||||
if (!time) {
|
||||
throw new Error('no time set! use setTime to define a time source');
|
||||
@@ -20,10 +26,32 @@ export function setTime(func) {
|
||||
time = func;
|
||||
}
|
||||
|
||||
let clockTime;
|
||||
/**
|
||||
* @name getClockTime
|
||||
* @tags internals
|
||||
* @returns {Number} the current clock time in seconds (onSchedule time)
|
||||
*/
|
||||
export function getClockTime() {
|
||||
if (!clockTime) {
|
||||
throw new Error('no clockTime set! use setClockTime to define a time source');
|
||||
}
|
||||
return clockTime();
|
||||
}
|
||||
|
||||
export function setClockTime(func) {
|
||||
clockTime = func;
|
||||
}
|
||||
|
||||
export function setCpsFunc(func) {
|
||||
cpsFunc = func;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getCps
|
||||
* @tags internals
|
||||
* @returns {Number} the current cycles per second
|
||||
*/
|
||||
export function getCps() {
|
||||
return cpsFunc?.();
|
||||
}
|
||||
@@ -36,12 +64,12 @@ export function getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
export function setTriggerFunc(func) {
|
||||
triggerFunc = func;
|
||||
export function setScheduleFunc(func) {
|
||||
scheduleFunc = func;
|
||||
}
|
||||
|
||||
export function getTriggerFunc() {
|
||||
return triggerFunc;
|
||||
export function getScheduleFunc() {
|
||||
return scheduleFunc;
|
||||
}
|
||||
|
||||
export function setIsStarted(val) {
|
||||
|
||||
+104
-16
@@ -5,7 +5,8 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import { Hap } from './hap.mjs';
|
||||
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
|
||||
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP, isPattern } from './pattern.mjs';
|
||||
import { _mod } from './util.mjs';
|
||||
import Fraction from './fraction.mjs';
|
||||
|
||||
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
|
||||
@@ -15,6 +16,24 @@ export function steady(value) {
|
||||
return new Pattern((state) => [new Hap(undefined, state.span, value)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal given by the input function
|
||||
*
|
||||
* @name signal
|
||||
* @param {*} func
|
||||
* @returns {Pattern} signal
|
||||
* @tags generators
|
||||
* @example
|
||||
* // sawtooth signal definition
|
||||
* signal(t=>t%1).segment(16).range(30,40).note()
|
||||
* @example
|
||||
* signal(t=>Math.pow(t,2)%1).range(40, 60).add(5).segment(16)
|
||||
* .off(0, x=>x.add(7))
|
||||
* .note()
|
||||
* .s("gm_electric_guitar_jazz,gm_epiano")
|
||||
* .gain(1.7)
|
||||
* .pianoroll()
|
||||
*/
|
||||
export const signal = (func) => {
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
|
||||
return new Pattern(query);
|
||||
@@ -33,7 +52,7 @@ export const signal = (func) => {
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const saw = signal((t) => t % 1);
|
||||
export const saw = signal((t) => _mod(t, 1));
|
||||
|
||||
/**
|
||||
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
|
||||
@@ -56,7 +75,7 @@ export const saw2 = saw.toBipolar();
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const isaw = signal((t) => 1 - (t % 1));
|
||||
export const isaw = signal((t) => 1 - _mod(t, 1));
|
||||
|
||||
/**
|
||||
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
|
||||
@@ -113,7 +132,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
|
||||
* n(square.segment(4).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const square = signal((t) => Math.floor((t * 2) % 2));
|
||||
export const square = signal((t) => Math.floor(_mod(t * 2, 2)));
|
||||
|
||||
/**
|
||||
* A square signal between -1 and 1 (like `square`, but bipolar).
|
||||
@@ -123,6 +142,22 @@ export const square = signal((t) => Math.floor((t * 2) % 2));
|
||||
*/
|
||||
export const square2 = square.toBipolar();
|
||||
|
||||
/**
|
||||
* A square signal between 1 and 0 (like `square` but flipped).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @tags generators
|
||||
*/
|
||||
export const isquare = signal((t) => 1 - Math.floor(_mod(t * 2, 2)));
|
||||
|
||||
/**
|
||||
* A square signal between 1 and -1 (like `isquare`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @tags generators
|
||||
*/
|
||||
export const isquare2 = isquare.toBipolar();
|
||||
|
||||
/**
|
||||
* A triangle signal between 0 and 1.
|
||||
*
|
||||
@@ -172,6 +207,7 @@ export const time = signal(id);
|
||||
/**
|
||||
* The mouse's x position value ranges from 0 to 1.
|
||||
* @name mousex
|
||||
* @synonyms mouseX
|
||||
* @return {Pattern}
|
||||
* @tags external_io
|
||||
* @example
|
||||
@@ -182,6 +218,7 @@ export const time = signal(id);
|
||||
/**
|
||||
* The mouse's y position value ranges from 0 to 1.
|
||||
* @name mousey
|
||||
* @synonyms mouseY
|
||||
* @return {Pattern}
|
||||
* @tags external_io
|
||||
* @example
|
||||
@@ -316,7 +353,7 @@ export const run = (n) => saw.range(0, n).round().segment(n);
|
||||
* // "hh".s().struct("1 0 1")
|
||||
*/
|
||||
export const binary = (n) => {
|
||||
const nBits = reify(n).log2(0).floor().add(1);
|
||||
const nBits = reify(n).log2().floor().add(1);
|
||||
return binaryN(n, nBits);
|
||||
};
|
||||
|
||||
@@ -348,7 +385,7 @@ export const binaryN = (n, nBits = 16) => {
|
||||
* .partials(binaryL(irand(4096).add(1)))
|
||||
*/
|
||||
export const binaryL = (n) => {
|
||||
const nBits = reify(n).log2(0).floor().add(1);
|
||||
const nBits = reify(n).log2().floor().add(1);
|
||||
return binaryNL(n, nBits);
|
||||
};
|
||||
|
||||
@@ -389,12 +426,14 @@ export const randL = (n) => {
|
||||
export const randrun = (n) => {
|
||||
return signal((t, controls) => {
|
||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
||||
let rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
||||
// Support n = 1
|
||||
if (!Array.isArray(rands)) rands = [rands];
|
||||
const nums = rands
|
||||
.map((n, i) => [n, i])
|
||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||
.map((x) => x[1]);
|
||||
const i = t.cyclePos().mul(n).floor() % n;
|
||||
const i = _mod(t.cyclePos().mul(n).floor(), n);
|
||||
return nums[i];
|
||||
})._segment(n);
|
||||
};
|
||||
@@ -540,6 +579,9 @@ export const __chooseWith = (pat, xs) => {
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("c2 g2!2 d2 f1").s(chooseWith(sine.fast(2), ["sawtooth", "triangle", "bd:6"]))
|
||||
* @example
|
||||
* // Compare with chooseInWith
|
||||
* note(chooseWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
|
||||
*/
|
||||
export const chooseWith = (pat, xs) => {
|
||||
return __chooseWith(pat, xs).outerJoin();
|
||||
@@ -551,6 +593,13 @@ export const chooseWith = (pat, xs) => {
|
||||
* @tags temporal
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @example
|
||||
* // Because the structure comes from the chosen values (the notes, here)
|
||||
* // The choosing pattern will only advance once per cycle (not 3 times per cycle)
|
||||
* note(chooseInWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
|
||||
* @example
|
||||
* // Here, the chosen values have a structure such that the choosing pattern will advance 3 times per cycle
|
||||
* note(chooseInWith("<0 0.5 1>*3", ["c3*3", "b3*3", "d3*3"]))
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const chooseInWith = (pat, xs) => {
|
||||
@@ -567,9 +616,7 @@ export const chooseInWith = (pat, xs) => {
|
||||
*/
|
||||
export const choose = (...xs) => chooseWith(rand, xs);
|
||||
|
||||
// todo: doc
|
||||
export const chooseIn = (...xs) => chooseInWith(rand, xs);
|
||||
export const chooseOut = choose;
|
||||
|
||||
/**
|
||||
* Chooses from the given list of values (or patterns of values), according
|
||||
@@ -715,12 +762,30 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
|
||||
*/
|
||||
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
|
||||
|
||||
export const degradeByWith = register(
|
||||
'degradeByWith',
|
||||
(withPat, x, pat) => pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x)),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
/**
|
||||
* `degradeByWith` is like `degradeBy`, but with control over the random or other signal used to filter out the events.
|
||||
*
|
||||
* `degradeBy(x)` is essentially `degradeByWith(rand, x)`
|
||||
*
|
||||
* This allows you to do things other than with `rand`, see examples below
|
||||
*
|
||||
* @name degradeByWith
|
||||
* @param {Pattern|number} test
|
||||
* @param {Pattern|number} testAgainst
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(sine, .1)
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(perlin, .5)
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(sine, cosine)
|
||||
*/
|
||||
export const degradeByWith = register('degradeByWith', (withPat, x, pat) => {
|
||||
if (!isPattern(withPat)) withPat = pure(withPat);
|
||||
return pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x));
|
||||
});
|
||||
|
||||
/**
|
||||
* Randomly removes events from the pattern by a given amount.
|
||||
@@ -1078,3 +1143,26 @@ export const perx = new Pattern(function (state) {
|
||||
const n = Fraction(1).div(state.span.duration);
|
||||
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
|
||||
});
|
||||
|
||||
/**
|
||||
* A signal that goes from `0` to `1` over one cycle, and then stays at `1` forever.
|
||||
* Mainly useful in combination with other functions that reset the cycle. See also
|
||||
* `close`.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(open.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const open = signal((t) => t.min(1));
|
||||
|
||||
/**
|
||||
* The same as `open`, but goes from `1` to `0` over one cycle, and then
|
||||
* stays on `0` forever.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(close.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const close = signal((t) => Fraction(1).sub(t).max(0));
|
||||
|
||||
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
|
||||
}
|
||||
|
||||
export const speak = register('speak', function (lang, voice, pat) {
|
||||
return pat.onTrigger((hap) => {
|
||||
return pat.onSchedule((hap) => {
|
||||
triggerSpeech(hap.value, lang, voice);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,44 @@ describe('euclid', () => {
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
|
||||
});
|
||||
it('Can create euclidLegato using negative steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(3, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclidLegato(3, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(5, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
});
|
||||
it('stretches over multiple cycles if pulses is greater than steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(5, 4)
|
||||
.fast(2)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('euclidRot', () => {
|
||||
@@ -67,6 +105,45 @@ describe('euclidLegato', () => {
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
|
||||
});
|
||||
it('Can create euclid using negative steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(3, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclid(3, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclid(5, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
});
|
||||
it('stretches over multiple cycles if pulses is greater than steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, 4)
|
||||
.fast(2)
|
||||
.firstCycle()
|
||||
.filter((hap) => hap.hasOnset())
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('euclidLegatoRot', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fastcat,
|
||||
firstOf,
|
||||
slowcat,
|
||||
slowcatPrime,
|
||||
cat,
|
||||
sequence,
|
||||
palindrome,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
rev,
|
||||
time,
|
||||
run,
|
||||
randrun,
|
||||
binaryN,
|
||||
pick,
|
||||
stackLeft,
|
||||
@@ -53,9 +55,10 @@ import {
|
||||
stepcat,
|
||||
sometimes,
|
||||
expand,
|
||||
arp,
|
||||
} from '../index.mjs';
|
||||
|
||||
import { log, logValues } from '../pattern.mjs';
|
||||
import { log, logValues, stackCenter } from '../pattern.mjs';
|
||||
|
||||
import { steady } from '../signal.mjs';
|
||||
|
||||
@@ -72,6 +75,10 @@ const sameFirst = (a, b) => {
|
||||
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
|
||||
};
|
||||
|
||||
const sameFirstX = (x, a, b) => {
|
||||
return expect(a.fast(x).sortHapsByPart().firstCycle()).toStrictEqual(b.fast(x).sortHapsByPart().firstCycle());
|
||||
};
|
||||
|
||||
describe('TimeSpan', () => {
|
||||
describe('equals()', () => {
|
||||
it('Should be equal to the same value', () => {
|
||||
@@ -544,6 +551,9 @@ describe('Pattern', () => {
|
||||
});
|
||||
});
|
||||
describe('slowcat()', () => {
|
||||
it('Can be empty', () => {
|
||||
expect(slowcat().firstCycle()).toStrictEqual([]);
|
||||
});
|
||||
it('Can concatenate things slowly', () => {
|
||||
expect(
|
||||
slowcat('a', 'b')
|
||||
@@ -576,6 +586,38 @@ describe('Pattern', () => {
|
||||
sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2));
|
||||
});
|
||||
});
|
||||
describe('slowcatPrime()', () => {
|
||||
it('Can be empty', () => {
|
||||
expect(slowcatPrime().firstCycle()).toStrictEqual([]);
|
||||
});
|
||||
it('Can slowcat patterns swapping back and forth skipping the expected notes', () => {
|
||||
expect(
|
||||
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
|
||||
.fast(4)
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(
|
||||
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
|
||||
.fast(4)
|
||||
.late(8)
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
|
||||
});
|
||||
});
|
||||
describe('arp()', () => {
|
||||
it('It wraps around with both positive and negative indices', () => {
|
||||
expect(
|
||||
stack('a', 'b', 'c')
|
||||
.arp(fastcat(-3, -2, -1, 0, 1, 2, 3, 4))
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
describe('rev()', () => {
|
||||
it('Can reverse things', () => {
|
||||
expect(
|
||||
@@ -945,6 +987,17 @@ describe('Pattern', () => {
|
||||
it('Can run', () => {
|
||||
expect(run(4).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(run(4).late(1).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
|
||||
});
|
||||
});
|
||||
describe('randrun', () => {
|
||||
it('Can randrun', () => {
|
||||
expect(randrun(4).firstCycle()).toStrictEqual(sequence(2, 1, 3, 0).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(randrun(4).late(1).firstCycle()).toStrictEqual(sequence(1, 2, 0, 3).firstCycle());
|
||||
});
|
||||
});
|
||||
describe('binaryN', () => {
|
||||
it('Can make a binary pattern from a decimal', () => {
|
||||
@@ -1166,6 +1219,7 @@ describe('Pattern', () => {
|
||||
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
// maybe this should double when they are either all even or all odd
|
||||
expect(stackCentre(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
expect(stackCenter(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
expect(fastcat(0, 1).ply(3)._steps).toStrictEqual(Fraction(6));
|
||||
expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined);
|
||||
expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2));
|
||||
@@ -1271,6 +1325,21 @@ describe('Pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('isobite', () => {
|
||||
it('can play a melody with a structure', () => {
|
||||
sameFirst(
|
||||
slowcat(0, 1, 2, 3, 4, 5).isobite(fastcat(true, silence, [true, true]).fast(2)),
|
||||
fastcat(0, silence, [1, 2], 3, silence, [4, 5]),
|
||||
);
|
||||
});
|
||||
it('syncopates over multiple cycles if the number of haps in the patterns differ', () => {
|
||||
sameFirstX(
|
||||
4,
|
||||
slowcat(0, 1, 2, 3).isobite(fastcat(true, [true, true])),
|
||||
slowcat(0, [1, 2], 3, [0, 1], 2, [3, 0], 1, [2, 3]).fast(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('unjoin', () => {
|
||||
it('destructures a pattern into subcycles', () => {
|
||||
sameFirst(
|
||||
@@ -1314,7 +1383,7 @@ describe('Pattern', () => {
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
hap.context?.onSchedule?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
@@ -1332,7 +1401,7 @@ describe('Pattern', () => {
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
hap.context?.onSchedule?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
|
||||
@@ -8,8 +8,24 @@ import Fraction from 'fraction.js';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
|
||||
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
|
||||
import {
|
||||
saw,
|
||||
saw2,
|
||||
isaw,
|
||||
isaw2,
|
||||
tri,
|
||||
tri2,
|
||||
itri,
|
||||
itri2,
|
||||
square,
|
||||
square2,
|
||||
isquare,
|
||||
isquare2,
|
||||
per,
|
||||
perx,
|
||||
cyclesPer,
|
||||
} from '../signal.mjs';
|
||||
import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs';
|
||||
|
||||
const st = (begin, end) => new State(ts(begin, end));
|
||||
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
|
||||
@@ -24,17 +40,61 @@ const sameFirst = (a, b) => {
|
||||
|
||||
describe('signal()', () => {
|
||||
it('Can make saw/saw2', () => {
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
|
||||
);
|
||||
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle());
|
||||
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
|
||||
});
|
||||
it('Can make isaw/isaw2', () => {
|
||||
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
|
||||
|
||||
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
|
||||
});
|
||||
it('Can make tri/tri2', () => {
|
||||
expect(tri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.5, 1, 0.5).firstCycle());
|
||||
expect(tri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
|
||||
});
|
||||
it('Can make itri/itri2', () => {
|
||||
expect(itri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, 0.5).firstCycle());
|
||||
expect(itri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
|
||||
});
|
||||
it('Can make square/square2', () => {
|
||||
expect(square.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
|
||||
expect(square2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -1, 1, 1).firstCycle());
|
||||
});
|
||||
it('Can make isquare/isquare2', () => {
|
||||
expect(isquare.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, 0, 0).firstCycle());
|
||||
expect(isquare2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, -1, -1).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(saw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 0.25, 0.5, 0.75).firstCycle(),
|
||||
);
|
||||
expect(saw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(-1, -0.5, 0, 0.5).firstCycle(),
|
||||
);
|
||||
expect(isaw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.75, 0.5, 0.25).firstCycle(),
|
||||
);
|
||||
expect(isaw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.5, 0, -0.5).firstCycle(),
|
||||
);
|
||||
expect(tri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 0.5, 1, 0.5).firstCycle(),
|
||||
);
|
||||
expect(tri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
|
||||
expect(itri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.5, 0, 0.5).firstCycle(),
|
||||
);
|
||||
expect(itri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
|
||||
expect(square.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
|
||||
expect(square2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(-1, -1, 1, 1).firstCycle(),
|
||||
);
|
||||
expect(isquare.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 1, 0, 0).firstCycle(),
|
||||
);
|
||||
expect(isquare2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 1, -1, -1).firstCycle(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesPer', () => {
|
||||
@@ -59,3 +119,9 @@ describe('perx', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('returns original pattern if input is 1', () => {
|
||||
expect(note('c d e f').sound('piano').shuffle(1).firstCycle()).toEqual(note('c d e f').sound('piano').firstCycle());
|
||||
});
|
||||
});
|
||||
|
||||
+36
-17
@@ -70,17 +70,6 @@ export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
|
||||
return (targetTimeSeconds - currentTimeSeconds) * 1000;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated does not appear to be referenced or invoked anywhere in the codebase
|
||||
* @noAutocomplete
|
||||
*/
|
||||
export const getFreq = (noteOrMidi) => {
|
||||
if (typeof noteOrMidi === 'number') {
|
||||
return midiToFreq(noteOrMidi);
|
||||
}
|
||||
return midiToFreq(noteToMidi(noteOrMidi));
|
||||
};
|
||||
|
||||
const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
/**
|
||||
* @deprecated only used in workshop (first-notes)
|
||||
@@ -130,25 +119,50 @@ export const getPlayableNoteValue = (hap) => {
|
||||
return note;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a hap, returns the frequency of the note or freq, if defined
|
||||
*
|
||||
* Given a note, returns the frequency of that note
|
||||
* `getFreq('c3')`
|
||||
*
|
||||
* Given a midi note number, returns the frequency of that midi note
|
||||
* `getFreq('c3')`
|
||||
*
|
||||
* @name getFrequency
|
||||
* @tags pitch
|
||||
* @synonyms getFreq
|
||||
* @param {Hap | note | number} input
|
||||
* @returns {Number}
|
||||
* @example
|
||||
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
|
||||
* .mul(getFreq('c3'))
|
||||
* .freq().clip(.5).room(1)
|
||||
*/
|
||||
export const getFrequency = (hap) => {
|
||||
let { value, context } = hap;
|
||||
let { value, context } =
|
||||
typeof hap == 'object'
|
||||
? hap
|
||||
: // default: interpret value as midi note.
|
||||
{ value: hap, context: { type: 'note' } };
|
||||
// if value is number => interpret as midi number as long as its not marked as frequency
|
||||
if (typeof value === 'object') {
|
||||
if (value.freq) {
|
||||
return value.freq;
|
||||
}
|
||||
return getFreq(value.note || value.n || value.value);
|
||||
return getFreq(value.note ?? value.n ?? value.value);
|
||||
}
|
||||
if (typeof value === 'number' && context.type !== 'frequency') {
|
||||
value = midiToFreq(hap.value);
|
||||
value = midiToFreq(value);
|
||||
} else if (typeof value === 'string' && isNote(value)) {
|
||||
value = midiToFreq(noteToMidi(hap.value));
|
||||
value = midiToFreq(noteToMidi(value));
|
||||
} else if (typeof value !== 'number') {
|
||||
throw new Error('not a note or frequency: ' + value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getFreq = (hap) => getFrequency(hap);
|
||||
|
||||
// rotate array by n steps (to the left)
|
||||
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
|
||||
|
||||
@@ -339,8 +353,13 @@ export function uniqsortr(a) {
|
||||
|
||||
export function unicodeToBase64(text) {
|
||||
const utf8Bytes = new TextEncoder().encode(text);
|
||||
const base64String = btoa(String.fromCharCode(...utf8Bytes));
|
||||
return base64String;
|
||||
let binaryString = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
|
||||
const chunk = utf8Bytes.subarray(i, i + chunkSize);
|
||||
binaryString += String.fromCharCode.apply(null, chunk);
|
||||
}
|
||||
return btoa(binaryString);
|
||||
}
|
||||
|
||||
export function base64ToUnicode(base64String) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
|
||||
instrument = instrument || 'triangle';
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onSchedule((hap, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -36,9 +36,10 @@ export const csound = register('csound', (instrument, pat) => {
|
||||
const controls = Object.entries({ ...hap.value, freq })
|
||||
.flat()
|
||||
.join('/');
|
||||
|
||||
// TODO: find out how to send a precise ctx based time
|
||||
// http://www.csounds.com/manual/html/i.html
|
||||
const timeOffset = targetTime - currentTime; // latency ?
|
||||
const timeOffset = targetTime - getAudioContext().currentTime; // latency ?
|
||||
//const timeOffset = time_deprecate - getAudioContext().currentTime
|
||||
const params = [
|
||||
`"${instrument}"`, // p1: instrument name
|
||||
@@ -144,7 +145,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
p1 = `"${instrument}"`;
|
||||
}
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onSchedule((hap, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -152,6 +153,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
if (typeof hap.value !== 'object') {
|
||||
throw new Error('csound only support objects as hap values');
|
||||
}
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
// Time in seconds counting from now.
|
||||
const p2 = targetTime - currentTime;
|
||||
const p3 = hap.duration.valueOf() + 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAudioContext } from '@strudel/webaudio/index.mjs';
|
||||
import { Invoke } from './utils.mjs';
|
||||
import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
|
||||
|
||||
@@ -6,7 +7,8 @@ const OFF_MESSAGE = 0x80;
|
||||
const CC_MESSAGE = 0xb0;
|
||||
|
||||
Pattern.prototype.midi = function (output) {
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
return this.onSchedule((hap, targetTime, cps) => {
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = 34;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Pattern, ClockCollator } from '@strudel/core';
|
||||
import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
|
||||
import { parseControlsFromHap } from '@strudel/osc/osc.mjs';
|
||||
import { Invoke } from './utils.mjs';
|
||||
import { getAudioContext } from '@strudel/webaudio/index.mjs';
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
export async function tauriOscOutput(hap, targetTime, cps = 1) {
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const params = [];
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
||||
|
||||
Object.keys(controls).forEach((key) => {
|
||||
@@ -32,5 +34,5 @@ export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
});
|
||||
}
|
||||
Pattern.prototype.osc = function () {
|
||||
return this.onTrigger(oscTriggerTauri);
|
||||
return this.onSchedule(tauriOscOutput);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/osc": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"@tauri-apps/api": "^2.2.0"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
dough.mjs
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/osc.mjs>
|
||||
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 { register } from '@strudel/core';
|
||||
import { Dough, doughsamples } from 'dough-synth';
|
||||
import { getAudioContext, ensureMinimalOutput } from '@strudel/webaudio';
|
||||
import wasm from 'dough-synth/dough.wasm?url';
|
||||
import workletCode from 'dough-synth/dough.js?raw';
|
||||
import { hap2dough } from './lib.mjs';
|
||||
export * from './lib.mjs';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
let D;
|
||||
/**
|
||||
*
|
||||
* initializes dough ahead of time. you don't need to use this, but if you do, you can await it, so dough is ready before the pattern starts.
|
||||
*
|
||||
* @name initDough
|
||||
* @tags external_io
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* await initDough()
|
||||
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
|
||||
* .phaser(3).fm(1.4).fmh(1.01)
|
||||
* .dough()
|
||||
*/
|
||||
export function initDough() {
|
||||
if (!D || D.audioContext !== getAudioContext()) {
|
||||
D = new Dough({
|
||||
wasm,
|
||||
workletCode,
|
||||
audioContext: getAudioContext(),
|
||||
});
|
||||
}
|
||||
return D.ready;
|
||||
}
|
||||
|
||||
export async function doughOutput(hap, targetTime, cps = 1) {
|
||||
const offset = D.context_offset?.[0];
|
||||
if (!offset) {
|
||||
return; // not ready
|
||||
}
|
||||
const t = targetTime - offset;
|
||||
const event = hap2dough(hap, t, cps);
|
||||
D.evaluate(event);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Uses dough as the audio engine. more info at https://dough.strudel.cc
|
||||
*
|
||||
* @name dough
|
||||
* @tags external_io
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
|
||||
* .phaser(3).fm(1.4).fmh(1.01)
|
||||
* .dough()
|
||||
*/
|
||||
export const dough = register('dough', (pat) => {
|
||||
initDough();
|
||||
ensureMinimalOutput();
|
||||
return pat.onSchedule(doughOutput);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { register, registerControl, stack, Pattern, noteToMidi } from '@strudel/core';
|
||||
|
||||
export const { group } = registerControl('group');
|
||||
export const { voices } = registerControl('voices');
|
||||
export const { glide } = registerControl('glide');
|
||||
|
||||
export const groupmod = register('groupmod', function (modulator, pat) {
|
||||
const modpat = modulator
|
||||
.withValue((vm) => pat.withValue(({ group }) => ({ ...vm, group })))
|
||||
.outerJoin()
|
||||
.set({ dough: 'update_group' });
|
||||
return stack(pat.set(modulator), modpat);
|
||||
});
|
||||
Pattern.prototype.gmod = Pattern.prototype.groupmod;
|
||||
|
||||
export function hap2dough(hap, time, cps) {
|
||||
hap.ensureObjectValue();
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration: hap.duration / cps,
|
||||
};
|
||||
if (typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@strudel/dough",
|
||||
"version": "1.3.2",
|
||||
"description": "dough synth integration for strudel",
|
||||
"main": "dough.mjs",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://codeberg.org/uzu/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Felix Roos <flix91@gmail.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"dough-synth": "0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,42 @@ export function pitchwheel({
|
||||
}
|
||||
|
||||
if (edo) {
|
||||
edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo;
|
||||
root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root;
|
||||
const degreeIndexes =
|
||||
haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null;
|
||||
const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null;
|
||||
ctx.font = '20px sans-serif';
|
||||
const label = `${edo} EDO`;
|
||||
// Draw EDO label:
|
||||
ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius);
|
||||
Array.from({ length: edo }, (_, i) => {
|
||||
const angle = freq2angle(root * Math.pow(2, i / edo), root);
|
||||
|
||||
const [x, y] = circlePos(centerX, centerY, radius, angle);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||
// Draw interval label for degree i when it exists:
|
||||
if (degreeIndexes === null || degreeIndexes.includes(i)) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||
if (intLabels !== null) {
|
||||
const degree = degreeIndexes.indexOf(i);
|
||||
if (intLabels[degree]) {
|
||||
if (angle < 0.32 && angle > 0.125) {
|
||||
ctx.fillText(intLabels[degree], x - 34, y);
|
||||
} else {
|
||||
if (angle < 0.1 && angle > -1.125) {
|
||||
ctx.fillText(intLabels[degree], x - 7, y - 12);
|
||||
} else {
|
||||
ctx.fillText(intLabels[degree], x + 9, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||
}
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# @strudel/edo
|
||||
|
||||
This package adds EDO scale functions to strudel Patterns.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm i @strudel/edo --save
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```js
|
||||
import { n } from '@strudel/core';
|
||||
import '@strudel/edo';
|
||||
|
||||
// E.g. edoScale for Gorgo-6 scale, 16 EDO, LLsLLLs
|
||||
// base note C3, large step size 3, small step size 1:
|
||||
// C3:LLsLLL:3:1
|
||||
const [baseNote, sequence, largeStep, smallStep] = ['C3', 'LLsLLL', 3, 1]
|
||||
const pattern = n("0 2 4 6 4 2").edoScale([baseNote, sequence, largeStep, smallStep]);
|
||||
|
||||
const events = pattern.firstCycle().map((e) => e.show());
|
||||
console.log(events);
|
||||
```
|
||||
|
||||
yields:
|
||||
```
|
||||
[
|
||||
"[ 0/1 → 1/1 |
|
||||
{
|
||||
\"degree\":1,
|
||||
\"degreeIndexes\":[0,3,6,7,10,13],
|
||||
\"intLabels\":[null,\"S2\",\"d4\",\"N4\",\"s6\",\"s7\",\"P8\"],
|
||||
\"root\":\"130.8128\",
|
||||
\"freq\":130.813,
|
||||
\"edo\":16
|
||||
}
|
||||
]"
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
edo.mjs - Equal division of the octave (EDO) scale functions for strudel
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edo.mjs>
|
||||
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 { register, pure, noteToMidi, isNote, tokenizeNote } from '@strudel/core';
|
||||
import { EdoScale } from './edoscale.mjs';
|
||||
import { Intervals } from './intervals.mjs';
|
||||
import { Pitches } from './pitches.mjs';
|
||||
|
||||
const pitchesCache = new Map();
|
||||
|
||||
/**
|
||||
* Turns numbers into notes in the given EDO scale (zero indexed).
|
||||
*
|
||||
* An EDO scale definition looks like this:
|
||||
*
|
||||
* e.g. C:LLsLLLs:2:1 <- this is the C major scale, 12 EDO
|
||||
*
|
||||
* e.g. C:LLsLLL:3:1 <- this is the Gorgo 6 note scale, 16 EDO
|
||||
*
|
||||
* An EDO scale, e.g. C:LLsLLLs:2:1, consists of a root note (e.g. C)
|
||||
* followed by semicolon (':')
|
||||
* and then a [Large/small step notation sequence](https://en.xen.wiki/w/MOS_scale)
|
||||
* (e.g. LLsLLLs)
|
||||
* followed by semicolon, then the large step size (e.g. 2)
|
||||
* followed by semicolon, then the small step size (e.g. 1).
|
||||
*
|
||||
* The number of divisions of the octave is calculated as the sum
|
||||
* of the steps in the EDO scale definition.
|
||||
*
|
||||
* e.g. C:LLsLLLs:2:1 is 2+2+1+2+2+2+1 = 12 EDO, 7 note scale
|
||||
*
|
||||
* e.g. C:LLsLLL:3:1 is 3+3+1+3+3+3 = 16 EDO, 6 note scale
|
||||
*
|
||||
* The root note defaults to octave 3, if no octave number is given.
|
||||
*
|
||||
* @name edoScale
|
||||
* @param {string} scale Definition of EDO scale.
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* n("0 2 4 6 4 2").edoScale("C:LLsLLLs:2:1")
|
||||
* @example
|
||||
* n("[0,7] 4 [2,7] 4")
|
||||
* .edoScale("G2:<LLsLLL LLLLsL>:3:1")
|
||||
* .s("piano")._pitchwheel()
|
||||
* @example
|
||||
* n(rand.range(0,5).segment(6))
|
||||
* .edoScale("<G2 C3>:LLsLL:3:1")
|
||||
* .s("piano")._pitchwheel()
|
||||
*/
|
||||
export const edoScale = register(
|
||||
'edoScale',
|
||||
function (scaleDefinition, pat) {
|
||||
// console.log(scaleDefinition);
|
||||
|
||||
// if (Array.isArray(scale)) {
|
||||
const key = scaleDefinition.flat().join(':');
|
||||
// }
|
||||
// console.log(scaleDefinition);
|
||||
let pitches;
|
||||
if (pitchesCache.has(key)) {
|
||||
pitches = pitchesCache.get(key);
|
||||
} else {
|
||||
// console.log({ key });
|
||||
const [base_note, sequence, large, small] = scaleDefinition;
|
||||
const root_octave = tokenizeNote(base_note)[2] || 3;
|
||||
// console.log({ root_octave });
|
||||
const scale = new EdoScale(large, small, sequence);
|
||||
const intervals = new Intervals(scale);
|
||||
// console.log({ intervals });
|
||||
pitches = new Pitches(scale, intervals, 440, noteToMidi(base_note), root_octave);
|
||||
pitchesCache.set(key, pitches);
|
||||
// console.log({ base_note: noteToMidi(base_note) });
|
||||
// console.log({ sequence });
|
||||
// console.log({ edivisions: scale.edivisions });
|
||||
// console.log({ intervals });
|
||||
// console.log({ pat });
|
||||
// console.log({ pitches: pitches });
|
||||
}
|
||||
return pat
|
||||
.fmap((value) => {
|
||||
const isObject = typeof value === 'object';
|
||||
const n = isObject ? value.n : value;
|
||||
if (isObject) {
|
||||
delete value.n; // remove n so it won't cause trouble
|
||||
}
|
||||
if (isNote(n)) {
|
||||
// legacy..
|
||||
return pure(n);
|
||||
}
|
||||
const deg = (typeof n === 'string' ? parseInt(n, 10) : Number.isInteger(n) ? n : Math.round(n)) + 1;
|
||||
|
||||
const [oct, degree] = pitches.octdeg(deg);
|
||||
const freq = pitches.octdegfreq(oct, degree);
|
||||
const note = pitches.octdegmidi(oct, degree);
|
||||
const edo = pitches.scale.edivisions;
|
||||
const root = pitches.base_freq;
|
||||
const degreeIndexes = pitches.scale.divisions;
|
||||
const intLabels = pitches.intervals.intLabels;
|
||||
// const color = 'red';
|
||||
value = pure(isObject ? { ...value, degree, degreeIndexes, intLabels, root, freq, edo } : note);
|
||||
// value = pure(isObject ? { ...value, key } : note);
|
||||
// value = pure(isObject ? { ...value, edo } : note);
|
||||
// console.log({ value });
|
||||
return value;
|
||||
})
|
||||
.outerJoin()
|
||||
.withHap((hap) => hap.setContext({ ...hap.context, scaleDefinition }));
|
||||
},
|
||||
true,
|
||||
true, // preserve tactus
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
edoscale.mjs - EdoScale defines equal division of the octave (EDO) scale in Ls notation
|
||||
- Port of pitfalls/lib/Scale.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Scale.lua>
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edoscale.mjs>
|
||||
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/>.
|
||||
*/
|
||||
const M = 2;
|
||||
const L = 1;
|
||||
const S = 0;
|
||||
const LABELS = ['s', 'L', 'M'];
|
||||
|
||||
export class EdoScale {
|
||||
constructor(large, small, sequence, medium) {
|
||||
this.stepbackup = [L, L, S, L, L, L, S, L, L, L, S, L, L, L, S, L];
|
||||
this.large = large;
|
||||
this.medium = medium || large;
|
||||
this.small = small;
|
||||
this.divisions = [];
|
||||
this.edivisions = null;
|
||||
this.sequence = null;
|
||||
this.tonic = 1;
|
||||
this.mode = 1;
|
||||
this.max_steps = 12;
|
||||
this.min_steps = 3;
|
||||
this.setSequence(sequence);
|
||||
}
|
||||
|
||||
hasMedium() {
|
||||
return this.step.some((_, i) => this.step[this.offset(i)] === M);
|
||||
}
|
||||
|
||||
stepSize(i) {
|
||||
return LABELS[this.step[this.offset(i)]];
|
||||
}
|
||||
|
||||
sequence() {
|
||||
return this.step.map((_, i) => this.stepSize(i)).join('');
|
||||
}
|
||||
|
||||
stepValue(i) {
|
||||
const step = this.step[this.offset(i)];
|
||||
return step === L ? this.large : step === M ? this.medium : this.small;
|
||||
}
|
||||
|
||||
offset(i) {
|
||||
if (this.mode === 1) {
|
||||
return i;
|
||||
} else {
|
||||
const offset = (this.mode - 1 + i) % this.length;
|
||||
return offset === 0 ? this.length : offset;
|
||||
}
|
||||
}
|
||||
|
||||
static setMaxSteps(max) {
|
||||
this.max_steps = max;
|
||||
}
|
||||
|
||||
static setMinSteps(min) {
|
||||
this.min_steps = min;
|
||||
}
|
||||
|
||||
setSequence(sequence) {
|
||||
if (this.sequence !== sequence) {
|
||||
this.sequence = sequence;
|
||||
this.length = sequence.length;
|
||||
this.step = [];
|
||||
for (let i = 0; i < sequence.length; i++) {
|
||||
const char = sequence[i];
|
||||
this.step[i] = char === 'L' ? L : char === 'M' ? M : S;
|
||||
}
|
||||
this.updateEdo();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setLarge(l) {
|
||||
if (this.large !== l) {
|
||||
this.large = l;
|
||||
this.updateEdo();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setMedium(m) {
|
||||
if (this.medium !== m) {
|
||||
this.medium = m;
|
||||
this.updateEdo();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setSmall(s) {
|
||||
if (this.small !== s) {
|
||||
this.small = s;
|
||||
this.updateEdo();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
setTonic(tonic) {
|
||||
this.tonic = tonic;
|
||||
}
|
||||
|
||||
changeMode(d) {
|
||||
const orig = this.mode;
|
||||
this.mode = Math.max(1, Math.min(this.mode + d, this.length));
|
||||
return orig !== this.mode;
|
||||
}
|
||||
|
||||
changeTonic(d) {
|
||||
const orig = this.tonic;
|
||||
this.tonic = Math.max(1, Math.min(this.tonic + d, this.edivisions));
|
||||
return orig !== this.tonic;
|
||||
}
|
||||
|
||||
updateEdo() {
|
||||
const orig = this.edivisions;
|
||||
this.edivisions = this.step.reduce((sum, _, i) => {
|
||||
this.divisions[i] = sum;
|
||||
return sum + this.stepValue(i);
|
||||
}, 0);
|
||||
// console.log(this.divisions);
|
||||
const changed = orig !== this.edivisions;
|
||||
if (changed) {
|
||||
this.tonic = Math.max(1, Math.min(this.tonic, this.edivisions));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
changeStep(d, i) {
|
||||
const index = this.offset(i);
|
||||
const orig = this.step[index];
|
||||
this.step[index] = Math.max(S, Math.min(this.step[index] + d, M));
|
||||
this.stepbackup[index] = this.step[index];
|
||||
const changed = orig !== this.step[index];
|
||||
if (changed) {
|
||||
this.updateEdo();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
changeLarge(d) {
|
||||
const orig = this.large;
|
||||
this.setLarge(Math.max(this.small + 1, Math.min(this.large + d, this.large + 1)));
|
||||
const changed = this.large !== orig;
|
||||
if (changed) {
|
||||
if (this.large <= this.medium) {
|
||||
this.setMedium(Math.max(this.small + 1, Math.min(this.large - 1, this.large)));
|
||||
}
|
||||
this.updateEdo();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
changeMedium(d) {
|
||||
const orig = this.medium;
|
||||
this.setMedium(Math.max(this.small + 1, Math.min(this.medium + d, this.large - 1)));
|
||||
const changed = this.medium !== orig;
|
||||
if (changed) {
|
||||
this.updateEdo();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
changeSmall(d) {
|
||||
const orig = this.small;
|
||||
const value = this.small + d;
|
||||
|
||||
if (this.hasMedium()) {
|
||||
this.setSmall(Math.max(1, Math.min(value, this.medium - 1)));
|
||||
} else {
|
||||
this.setSmall(Math.max(1, Math.min(value, this.large - 1)));
|
||||
}
|
||||
|
||||
const changed = this.small !== orig;
|
||||
if (changed) {
|
||||
if (this.small >= this.medium) {
|
||||
this.setMedium(Math.max(this.small + 1, Math.min(this.large)));
|
||||
}
|
||||
this.updateEdo();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
changeLength(d) {
|
||||
const orig = this.length;
|
||||
if (d === 1) {
|
||||
this.length = Math.min(this.length + 1, this.max_steps);
|
||||
} else if (d === -1) {
|
||||
this.length = Math.max(this.length - 1, this.min_steps);
|
||||
}
|
||||
|
||||
const changed = this.length !== orig;
|
||||
if (changed) {
|
||||
this.mode = 1;
|
||||
if (d === 1) {
|
||||
this.step[this.length] = this.stepbackup[this.length] || L;
|
||||
} else if (d === -1 && this.length >= this.min_steps) {
|
||||
this.step.pop();
|
||||
}
|
||||
this.updateEdo();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import './edo.mjs';
|
||||
|
||||
export * from './edo.mjs';
|
||||
|
||||
export const packageName = '@strudel/edo';
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
intervals.mjs - defines Intervals for equal division of the octave (EDO) scale
|
||||
- Port of pitfalls/lib/Intervals.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Intervals.lua>
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/intervals.mjs>
|
||||
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 ratiointervals from './ratios.mjs';
|
||||
|
||||
function ratio(division, edivisions) {
|
||||
return division === 0 ? 1 : Math.pow(2, division / edivisions);
|
||||
}
|
||||
|
||||
export class Intervals {
|
||||
constructor(scale) {
|
||||
this.scale = scale;
|
||||
this.intLabels = [];
|
||||
this.intNoms = [];
|
||||
this.intRatios = [];
|
||||
this.uniqLabels = [];
|
||||
this.intErrors = [];
|
||||
this.ratios = [];
|
||||
|
||||
const BLANK = '';
|
||||
let division = 0;
|
||||
const labToErr = {};
|
||||
const labToInd = {};
|
||||
|
||||
this.ratios[0] = 1;
|
||||
|
||||
for (let i = 0; i < scale.length; i++) {
|
||||
division += scale.stepValue(i);
|
||||
this.ratios[i + 1] = ratio(division, scale.edivisions);
|
||||
|
||||
if (i < scale.length) {
|
||||
const nearest = ratiointervals.nearestInterval(this.ratios[i + 1]);
|
||||
const closeness = nearest[0];
|
||||
const ratio = nearest[1];
|
||||
const intLabel = ratiointervals.key(ratio);
|
||||
this.intLabels[i + 1] = intLabel;
|
||||
this.intErrors[i + 1] = closeness;
|
||||
this.intNoms[i + 1] = ratio ? ratiointervals.nom(ratio) : 0;
|
||||
this.intRatios[i + 1] = ratio ? `${ratiointervals.nom(ratio)}/${ratiointervals.denom(ratio)}` : '';
|
||||
this.uniqLabels[i + 1] = BLANK;
|
||||
|
||||
if (intLabel && intLabel !== 'P1' && intLabel !== 'P8') {
|
||||
if (!labToErr[intLabel]) {
|
||||
this.uniqLabels[i + 1] = intLabel;
|
||||
labToInd[intLabel] = i + 1;
|
||||
labToErr[intLabel] = closeness;
|
||||
} else if (closeness < labToErr[intLabel]) {
|
||||
this.uniqLabels[labToInd[intLabel]] = BLANK;
|
||||
this.uniqLabels[i + 1] = intLabel;
|
||||
labToInd[intLabel] = i + 1;
|
||||
labToErr[intLabel] = closeness;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ratio(i) {
|
||||
return this.ratios[i];
|
||||
}
|
||||
|
||||
intervalLabel(i) {
|
||||
return this.intLabels[i];
|
||||
}
|
||||
|
||||
intervalNominator(i) {
|
||||
return this.intNoms[i];
|
||||
}
|
||||
|
||||
intervalRatio(i) {
|
||||
return this.intRatios[i];
|
||||
}
|
||||
|
||||
uniqIntervalLabel(i) {
|
||||
return this.uniqLabels[i];
|
||||
}
|
||||
|
||||
intervalError(i) {
|
||||
return this.intErrors[i];
|
||||
}
|
||||
|
||||
nearestDegreeTo(r, threshold) {
|
||||
let min = 1;
|
||||
let degree = null;
|
||||
|
||||
for (const [i, v] of Object.entries(this.ratios)) {
|
||||
const diff = Math.abs((r - v) / r);
|
||||
if (diff < min) {
|
||||
min = diff;
|
||||
degree = parseInt(i, 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (threshold == null) {
|
||||
return degree;
|
||||
} else {
|
||||
return min < threshold ? degree : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@strudel/edo",
|
||||
"version": "0.1.0",
|
||||
"description": "Equal division of the octave (EDO) scale functions for strudel",
|
||||
"main": "index.mjs",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://codeberg.org/uzu/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Rob McKinnon <rob@movingflow>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@tonaljs/tonal": "^4.10.0",
|
||||
"chord-voicings": "^0.0.1",
|
||||
"webmidi": "^3.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
pitches.mjs - defines Pitches for equal division of the octave (EDO) scale
|
||||
- Port of pitfalls/lib/Pitches.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Pitches.lua>
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/pitches.mjs>
|
||||
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/>.
|
||||
*/
|
||||
|
||||
function ratio(division, edivisions) {
|
||||
return division === 0 ? 1 : Math.pow(2, division / edivisions);
|
||||
}
|
||||
|
||||
// This function gets frequency based on index, edo, octave, and base frequency
|
||||
function get_freq(base_freq, edo, index, oct, base_octave) {
|
||||
let f = base_freq * ratio(index - 1, edo);
|
||||
if (oct < base_octave) {
|
||||
f /= Math.pow(2, base_octave - oct);
|
||||
} else if (oct > base_octave) {
|
||||
f *= Math.pow(2, oct - base_octave);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// Convert MIDI number to Hz using a given tuning multiplier
|
||||
function midi_to_hz(n, tuning) {
|
||||
return tuning * Math.pow(2, (n - 69) / 12.0);
|
||||
}
|
||||
|
||||
const denom = Math.log(2);
|
||||
function hz_to_midi(freq, tuning) {
|
||||
return 12.0 * (Math.log(freq / tuning) / denom) + 69;
|
||||
}
|
||||
|
||||
export class Pitches {
|
||||
constructor(scale, intervals, tuning, midi_start, root_octave) {
|
||||
this.scale = scale;
|
||||
this.intervals = intervals;
|
||||
this.base_freq = midi_to_hz(midi_start, tuning);
|
||||
this.root_octave = root_octave;
|
||||
this.freqs = {};
|
||||
this.midis = {};
|
||||
this.degrees = {};
|
||||
this.octdegfreqs = {};
|
||||
this.octdegmidis = {};
|
||||
let index = 0;
|
||||
let f = null;
|
||||
|
||||
for (let oct = 0; oct <= 8; oct++) {
|
||||
this.octdegfreqs[oct] = {};
|
||||
this.octdegmidis[oct] = {};
|
||||
f = get_freq(this.base_freq, scale.edivisions, scale.tonic, oct, this.root_octave);
|
||||
for (let deg = 0; deg < scale.length; deg++) {
|
||||
index = index + 1;
|
||||
this.freqs[index] = parseFloat((f * intervals.ratio(deg)).toFixed(3));
|
||||
this.midis[index] = parseFloat(hz_to_midi(f * intervals.ratio(deg), tuning).toFixed(4));
|
||||
this.degrees[index] = deg;
|
||||
this.octdegfreqs[oct][deg + 1] = this.freqs[index];
|
||||
this.octdegmidis[oct][deg + 1] = this.midis[index];
|
||||
}
|
||||
}
|
||||
this.base_freq = parseFloat(this.base_freq).toFixed(4);
|
||||
}
|
||||
|
||||
base_freq() {
|
||||
return this.base_freq;
|
||||
}
|
||||
|
||||
degree(index) {
|
||||
return this.degrees[index];
|
||||
}
|
||||
|
||||
freq(index) {
|
||||
return this.freqs[index];
|
||||
}
|
||||
|
||||
octdeg(deg) {
|
||||
const higherOcatve = deg > this.scale.length;
|
||||
const octave = this.root_octave + (higherOcatve ? Math.floor((deg - 1) / this.scale.length) : 0);
|
||||
const degree = higherOcatve ? (deg % this.scale.length === 0 ? this.scale.length : deg % this.scale.length) : deg;
|
||||
// console.log([octave, degree]);
|
||||
return [octave, degree];
|
||||
}
|
||||
|
||||
octdegfreq(oct, deg) {
|
||||
if (this.octdegfreqs[oct]) {
|
||||
return this.octdegfreqs[oct][deg];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
octdegmidi(oct, deg) {
|
||||
if (this.octdegmidis[oct]) {
|
||||
return this.octdegmidis[oct][deg];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
ratios.mjs - lists whole number ratios for pitch intervals
|
||||
- Port of pitfalls/lib/ratios.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/ratios.lua>
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/ratios.mjs>
|
||||
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/>.
|
||||
*/
|
||||
const ratiointervals = {};
|
||||
|
||||
// FJS Calculators - https://misotanni.github.io/fjs/en/calc.html
|
||||
// List of pitch intervals - https://en.wikipedia.org/wiki/List_of_pitch_intervals
|
||||
// Gallery of just intervals - https://en.xen.wiki/w/Gallery_of_just_intervals
|
||||
// Two letter codes changed to make different interval labels more unique.
|
||||
ratiointervals.list = new Map([
|
||||
[1, ['P1', 'P1', 'P1', 1, 1]], // unison P1
|
||||
[16 / 15, ['m2', 'm2', 'm2_5', 16, 15]], // minor second m2
|
||||
[15 / 14, ['A1', 'A1', 'A1^5_7', 15, 14]], // augmented unison
|
||||
[13 / 12, ['t2', 'm2', 'm2^13', 13, 12]], // tridecimal neutral second
|
||||
[12 / 11, ['N2', 'M2', 'M2_11', 12, 11]], // undecimal neutral second
|
||||
[11 / 10, ['n2', 'm2', 'm2^11_5', 11, 10]], // undecimal submajor second
|
||||
[10 / 9, ['T2', 'M2', 'M2^5', 10, 9]], // classic (whole) tone
|
||||
[9 / 8, ['M2', 'M2', 'M2', 9, 8]], // major second M2
|
||||
[8 / 7, ['S2', 'M2', 'M2_7', 8, 7]], // septimal major second
|
||||
[7 / 6, ['s3', 'm3', 'm3^7', 7, 6]], // septimal minor third
|
||||
[19 / 16, ['o3', 'm3', 'm3^19', 19, 16]], // otonal minor third
|
||||
[6 / 5, ['m3', 'm3', 'm3_5', 6, 5]], // minor third m3
|
||||
[17 / 14, ['t3', 'm3', 'm3^17_7', 17, 14]], // septendecimal supraminor third
|
||||
[11 / 9, ['n3', 'm3', 'm3^11', 11, 9]], // undecimal neutral third
|
||||
[5 / 4, ['M3', 'M3', 'M3^5', 5, 4]], // major third M3
|
||||
[9 / 7, ['S3', 'M3', 'M3_7', 9, 7]], // septimal major third SM3
|
||||
[13 / 10, ['d4', 'd4', 'd4^13_5', 13, 10]], // Barbados third
|
||||
[4 / 3, ['P4', 'P4', 'P4', 4, 3]], // perfect fourth P4
|
||||
[19 / 14, ['N4', 'P4', 'P4^19_7', 19, 14]], // undevicesimal wide fourth
|
||||
[11 / 8, ['n4', 'P4', 'P4^11', 11, 8]], // super-fourth
|
||||
[25 / 18, ['a4', 'A4', 'A4^5,5', 25, 18]], // classic augmented fourth
|
||||
[7 / 5, ['sT', 'd5', 'd5^7_5', 7, 5]], // lesser septimal tritone
|
||||
[45 / 32, ['A4', 'A4', 'A4^5', 45, 32]], // just augmented fourth
|
||||
[17 / 12, ['d5', 'd5', 'd5^17', 17, 12]], // larger septendecimal tritone
|
||||
[10 / 7, ['ST', 'A4', 'A4^5_7', 10, 7]], // greater septimal tritone
|
||||
[13 / 9, ['t5', 'd5', 'd5^13', 13, 9]], // tridecimal diminished fifth
|
||||
[3 / 2, ['P5', 'P5', 'P5', 3, 2]], // perfect fifth P5
|
||||
[14 / 9, ['s6', 'M6', 'm6^7', 14, 9]], // subminor sixth or septimal sixth
|
||||
[25 / 16, ['a5', 'A5', 'A5^5,5', 25, 16]], // classic augmented fifth
|
||||
[11 / 7, ['A5', 'P5', 'P5^11_7', 11, 7]], // undecimal minor sixth
|
||||
[8 / 5, ['m6', 'm6', 'm6_5', 8, 5]], // minor sixth m6
|
||||
[13 / 8, ['N6', 'm6', 'm6^13', 13, 8]], // tridecimal neutral sixth
|
||||
[18 / 11, ['n6', 'M6', 'M6_11', 18, 11]], // undecimal neutral sixth
|
||||
[5 / 3, ['M6', 'M6', 'M6^5', 5, 3]], // just major sixth M6
|
||||
[128 / 75, ['d7', 'd7', 'd7_5,5', 128, 75]], // diminished seventh
|
||||
[17 / 10, ['T6', 'd7', 'd7^17_5', 17, 10]], // septendecimal diminished seventh
|
||||
[12 / 7, ['S6', 'M6', 'M6_7', 12, 7]], // septimal major sixth
|
||||
[7 / 4, ['s7', 'm7', 'm7^7', 7, 4]], // septimal minor seventh
|
||||
[16 / 9, ['m7', 'm7', 'm7', 16, 9]], // lesser minor seventh
|
||||
[9 / 5, ['g7', 'm7', 'm7_5', 9, 5]], // greater just minor seventh
|
||||
[11 / 6, ['n7', 'm7', 'm7^11', 11, 6]], // undecimal neutral seventh
|
||||
[13 / 7, ['N7', 'm7', 'm7^13_7', 13, 7]], // tridecimal neutral seventh
|
||||
[15 / 8, ['M7', 'M7', 'M7^5', 15, 8]], // major seventh
|
||||
[17 / 9, ['T7', 'd8', 'd8^17', 17, 9]], // large septendecimal major seventh
|
||||
[19 / 10, ['d8', 'd8', 'd8^19_5', 19, 10]], // large undevicesimal major seventh
|
||||
[2, ['P8', 'P8', 'P8', 2, 1]], // octave P8
|
||||
]);
|
||||
|
||||
ratiointervals.key = function (ratio) {
|
||||
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[0] || '';
|
||||
};
|
||||
|
||||
ratiointervals.label = function (ratio) {
|
||||
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[1] || '';
|
||||
};
|
||||
|
||||
ratiointervals.fjs = function (ratio) {
|
||||
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[2] || '';
|
||||
};
|
||||
|
||||
ratiointervals.nom = function (ratio) {
|
||||
return ratio == null ? null : ratiointervals.list.get(ratio)?.[3] || null;
|
||||
};
|
||||
|
||||
ratiointervals.denom = function (ratio) {
|
||||
return ratio == null ? null : ratiointervals.list.get(ratio)?.[4] || null;
|
||||
};
|
||||
|
||||
ratiointervals.nearestInterval = function (v) {
|
||||
let min = 1;
|
||||
let match = null;
|
||||
for (const [ratio, _labels] of ratiointervals.list) {
|
||||
const diff = Math.abs((ratio - v) / ratio);
|
||||
if (diff < min) {
|
||||
min = diff;
|
||||
match = ratio;
|
||||
}
|
||||
}
|
||||
return min < 0.01 ? [min, match] : [null, null];
|
||||
};
|
||||
|
||||
export default ratiointervals;
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
edo.test.mjs - tests of edo.mjs
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonal.test.mjs>
|
||||
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 '../edo.mjs';
|
||||
import { n } from '@strudel/core';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('edoScale', () => {
|
||||
it('Should run tonal functions ', () => {
|
||||
// base note A3 = 220Hz
|
||||
let baseNote = 'A3';
|
||||
let root = 220;
|
||||
let freq = 220;
|
||||
let pattern = n('0').edoScale([baseNote, 'LLsLLLs', 2, 1]);
|
||||
let cycle = pattern.firstCycleValues[0];
|
||||
expect(cycle.freq).toEqual(freq);
|
||||
expect(cycle.edo).toEqual(12);
|
||||
expect(cycle.degree).toEqual(1);
|
||||
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
|
||||
|
||||
// base note A4 = 440Hz
|
||||
baseNote = 'A4';
|
||||
root = 440;
|
||||
freq = 880;
|
||||
pattern = n('7').edoScale([baseNote, 'LLsLLLs', 2, 1]);
|
||||
cycle = pattern.firstCycleValues[0];
|
||||
expect(cycle.freq).toEqual(freq);
|
||||
expect(cycle.edo).toEqual(12);
|
||||
expect(cycle.degree).toEqual(1);
|
||||
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
edoscale.test.mjs - tests of EdoScale from edoscale.mjs
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/edoscale.test.mjs>
|
||||
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 '../edoscale.mjs'; // need to import this to add prototypes
|
||||
import { EdoScale } from '../edoscale.mjs';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('EdoScale', () => {
|
||||
it('updates edivisions', () => {
|
||||
let scale = new EdoScale(2, 1, ['L', 'L', 's', 'L', 'L', 'L', 's']);
|
||||
expect(scale.stepSize(0)).toEqual('L');
|
||||
expect(scale.stepValue(0)).toEqual(2);
|
||||
|
||||
expect(scale.stepSize(2)).toEqual('s');
|
||||
expect(scale.stepValue(2)).toEqual(1);
|
||||
|
||||
expect(scale.edivisions).toEqual(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
ratios.test.mjs - tests of ratios.mjs
|
||||
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/ratios.test.mjs>
|
||||
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 ratiointervals from '../ratios.mjs';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('ratiointervals', () => {
|
||||
it('updates edivisions', () => {
|
||||
let ratio = 9 / 7;
|
||||
expect(ratiointervals.key(ratio)).toEqual('S3');
|
||||
expect(ratiointervals.label(ratio)).toEqual('M3');
|
||||
expect(ratiointervals.fjs(ratio)).toEqual('M3_7');
|
||||
expect(ratiointervals.nom(ratio)).toEqual(9);
|
||||
expect(ratiointervals.denom(ratio)).toEqual(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { dependencies } from './package.json';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'index.mjs'),
|
||||
formats: ['es'],
|
||||
fileName: (ext) => ({ es: 'index.mjs' })[ext],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...Object.keys(dependencies)],
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
input.mjs - MIDI input wrapper
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||
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 { WebMidi } from 'webmidi';
|
||||
import { logger, ref } from '@strudel/core';
|
||||
import { getDevice } from './util.mjs';
|
||||
|
||||
/**
|
||||
* MIDI input device wrapper that manages connection and reconnection, tracks
|
||||
* persisted CC states, etc. These instances are long-lived and are maintained as singletons
|
||||
* (keyed globally by input string/number).
|
||||
*/
|
||||
export class MidiInput {
|
||||
/**
|
||||
*
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
*/
|
||||
constructor(input) {
|
||||
this.input = input;
|
||||
this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs
|
||||
|
||||
this._refs = {};
|
||||
this._refsByChan = {};
|
||||
|
||||
this._loadAllStates();
|
||||
|
||||
this.initialDevice = this._startDeviceListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation for the cc() factory function tied to this specific input.
|
||||
* @param {number} cc MIDI CC number
|
||||
* @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels
|
||||
*/
|
||||
createCC(cc, chan) {
|
||||
const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan];
|
||||
if (!(cc in lookupMap)) {
|
||||
const initialState = this._loadState(chan);
|
||||
lookupMap[cc] = initialState[cc] || 0;
|
||||
}
|
||||
|
||||
return ref(() => lookupMap[cc]);
|
||||
}
|
||||
|
||||
_startDeviceListener() {
|
||||
const initialDevice = getDevice(this.input, WebMidi.inputs);
|
||||
|
||||
// Background connection loop
|
||||
(async () => {
|
||||
const midiListener = this._onMidiMessage.bind(this);
|
||||
let device = initialDevice;
|
||||
|
||||
while (true) {
|
||||
if (!device) {
|
||||
device = await this._waitForDevice();
|
||||
}
|
||||
|
||||
// Wait a bit for device to be ready to receive last state
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
// Still continue if sending did not work
|
||||
this._sendAllStates(device);
|
||||
} catch (err) {
|
||||
console.error('midiin: failed to send last state on connect:', device.name, err);
|
||||
}
|
||||
|
||||
// Listen for incoming MIDI messages and for disconnection
|
||||
device.addListener('midimessage', midiListener);
|
||||
|
||||
await this._waitForDeviceDisconnect(device);
|
||||
|
||||
device.removeListener('midimessage', midiListener);
|
||||
device = null; // Clear var to trigger wait for connection
|
||||
}
|
||||
})();
|
||||
|
||||
return initialDevice;
|
||||
}
|
||||
|
||||
// Returns a promise that resolves when the specified device is connected
|
||||
_waitForDevice() {
|
||||
return new Promise((resolve) => {
|
||||
const connListener = () => {
|
||||
const device = getDevice(this.input, WebMidi.inputs);
|
||||
if (device) {
|
||||
logger(`[midi] device reconnected: ${device.name}`);
|
||||
|
||||
WebMidi.removeListener('connected', connListener);
|
||||
resolve(device);
|
||||
}
|
||||
};
|
||||
|
||||
WebMidi.addListener('connected', connListener);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a promise that resolves when the specified device is disconnected
|
||||
_waitForDeviceDisconnect(device) {
|
||||
return new Promise((resolve) => {
|
||||
const disconnListener = (e) => {
|
||||
if (e.port.name === device.name) {
|
||||
logger(`[midi] device disconnected: ${device.name}`);
|
||||
|
||||
WebMidi.removeListener('disconnected', disconnListener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
WebMidi.addListener('disconnected', disconnListener);
|
||||
});
|
||||
}
|
||||
|
||||
_onMidiMessage(e) {
|
||||
const ccNum = e.dataBytes[0];
|
||||
const v = e.dataBytes[1];
|
||||
const chan = e.message.channel;
|
||||
const scaled = v / 127;
|
||||
|
||||
this._refs[ccNum] = scaled;
|
||||
this._refsByChan[chan] ??= {};
|
||||
this._refsByChan[chan][ccNum] = scaled;
|
||||
|
||||
this._saveState(undefined, ccNum, scaled);
|
||||
this._saveState(chan, ccNum, scaled);
|
||||
}
|
||||
|
||||
_loadAllStates() {
|
||||
Object.assign(this._refs, this._loadState(undefined));
|
||||
|
||||
for (let chan = 1; chan <= 16; chan++) {
|
||||
this._refsByChan[chan] ??= {};
|
||||
Object.assign(this._refsByChan[chan], this._loadState(chan));
|
||||
}
|
||||
}
|
||||
|
||||
_loadState(chan) {
|
||||
if (!this.stateKey) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const initialDataRaw = localStorage.getItem(
|
||||
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||
);
|
||||
if (!initialDataRaw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(initialDataRaw);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`,
|
||||
initialDataRaw,
|
||||
err,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
_saveState(chan, cc, value) {
|
||||
if (!this.stateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this._loadState(chan);
|
||||
state[cc] = value;
|
||||
localStorage.setItem(
|
||||
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||
JSON.stringify(state),
|
||||
);
|
||||
}
|
||||
|
||||
// Send CC values back to device to restore encoders and motorized sliders
|
||||
_sendAllStates(device) {
|
||||
const output = WebMidi.outputs.find((o) => o.name === device.name);
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [chan, refs] of Object.entries(this._refsByChan)) {
|
||||
const channel = Number(chan);
|
||||
for (const [cc, value] of Object.entries(refs)) {
|
||||
const ccn = Number(cc);
|
||||
const scaled = Math.round(value * 127);
|
||||
output.sendControlChange(ccn, scaled, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-138
@@ -13,7 +13,7 @@ import {
|
||||
getIsStarted,
|
||||
getPattern,
|
||||
getTime,
|
||||
getTriggerFunc,
|
||||
getScheduleFunc,
|
||||
isPattern,
|
||||
logger,
|
||||
ref,
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
} from '@strudel/core';
|
||||
import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
||||
import { getAudioContext, getClockBridge } from '@strudel/webaudio';
|
||||
import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
|
||||
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
|
||||
import { MidiInput } from './input.mjs';
|
||||
|
||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||
export const { WebMidi } = _WebMidi;
|
||||
@@ -31,10 +33,6 @@ function supportsMidi() {
|
||||
return typeof navigator.requestMIDIAccess === 'function';
|
||||
}
|
||||
|
||||
function getMidiDeviceNamesString(devices) {
|
||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||
}
|
||||
|
||||
export function enableWebMidi(options = {}) {
|
||||
const { onReady, onConnected, onDisconnected, onEnabled } = options;
|
||||
if (WebMidi.enabled) {
|
||||
@@ -72,29 +70,6 @@ export function enableWebMidi(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function getDevice(indexOrName, devices) {
|
||||
if (!devices.length) {
|
||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||
}
|
||||
if (typeof indexOrName === 'number') {
|
||||
return devices[indexOrName];
|
||||
}
|
||||
const byName = (name) => devices.find((output) => output.name.includes(name));
|
||||
if (typeof indexOrName === 'string') {
|
||||
return byName(indexOrName);
|
||||
}
|
||||
// attempt to default to first IAC device if none is specified
|
||||
const IACOutput = byName('IAC');
|
||||
const device = IACOutput ?? devices[0];
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return IACOutput ?? devices[0];
|
||||
}
|
||||
|
||||
// send start/stop messages to outputs when repl starts/stops
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
@@ -138,7 +113,7 @@ function githubPath(base, subpath = '') {
|
||||
|
||||
/**
|
||||
* configures the default midimap, which is used when no "midimap" port is set
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @example
|
||||
* defaultmidimap({ lpf: 74 })
|
||||
* $: note("c a f e").midi();
|
||||
@@ -152,7 +127,7 @@ let loadCache = {};
|
||||
|
||||
/**
|
||||
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @example
|
||||
* midimaps({ mymap: { lpf: 74 } })
|
||||
* $: note("c a f e")
|
||||
@@ -196,6 +171,23 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
|
||||
return Math.pow(normalized, exp);
|
||||
}
|
||||
|
||||
const isFirefox = navigator?.userAgent?.includes('Firefox');
|
||||
// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
|
||||
// the scheduleAtTime approach is still jittery, but the best we can be on firefox
|
||||
// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
|
||||
function timedSend(timeMs, fn) {
|
||||
if (isFirefox) {
|
||||
const audioTime = getClockBridge().getAudioContextTime(timeMs);
|
||||
if (!audioTime) {
|
||||
logger('[midi]: skip event, not ready');
|
||||
return;
|
||||
}
|
||||
scheduleAtTime(() => fn(undefined), audioTime);
|
||||
} else {
|
||||
fn(timeMs);
|
||||
}
|
||||
}
|
||||
|
||||
function mapCC(mapping, value) {
|
||||
return Object.keys(value)
|
||||
.filter((key) => !!mapping[getControlName(key)])
|
||||
@@ -207,7 +199,7 @@ function mapCC(mapping, value) {
|
||||
}
|
||||
|
||||
// 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, timeMs) {
|
||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||
throw new Error('expected ccv to be a number between 0 and 1');
|
||||
}
|
||||
@@ -215,23 +207,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||
throw new Error('expected ccn to be a number or a string');
|
||||
}
|
||||
const scaled = Math.round(ccv * 127);
|
||||
scheduleAtTime(() => {
|
||||
device.sendControlChange(ccn, scaled, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a program change message to the given device on the given channel
|
||||
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||
function sendProgramChange(progNum, device, midichan, timeMs) {
|
||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendProgramChange(progNum, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a sysex message to the given device on the given channel
|
||||
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
function sendSysex(sysexid, sysexdata, device, timeMs) {
|
||||
if (Array.isArray(sysexid)) {
|
||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||
@@ -246,13 +234,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendSysex(sysexid, sysexdata);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
|
||||
}
|
||||
|
||||
// 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, timeMs) {
|
||||
if (Array.isArray(nrpnn)) {
|
||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||
@@ -260,34 +246,29 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||
} 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');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||
}, targetTime);
|
||||
|
||||
timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a pitch bend message to the given device on the given channel
|
||||
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||
function sendPitchBend(midibend, device, midichan, timeMs) {
|
||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||
throw new Error('expected midibend to be a number between -1 and 1');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendPitchBend(midibend, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a channel aftertouch message to the given device on the given channel
|
||||
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||
function sendAftertouch(miditouch, device, midichan, timeMs) {
|
||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||
}
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.sendChannelAftertouch(miditouch, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// 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, timeMs) {
|
||||
if (note == null || note === '') {
|
||||
throw new Error('note cannot be null or empty');
|
||||
}
|
||||
@@ -298,11 +279,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
throw new Error('duration must be a positive number');
|
||||
}
|
||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||
const midiNote = new Note(midiNumber, { attack: velocity });
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.playNote(midiNote, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
|
||||
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,8 +292,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
* @param {object} options Additional MIDI configuration options
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1')
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
|
||||
*/
|
||||
|
||||
Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
@@ -339,7 +317,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
let midiConfig = {
|
||||
// Default configuration values
|
||||
isController: false, // Disable sending notes for midi controllers
|
||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||
noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack
|
||||
midichannel: 1, // Default MIDI channel
|
||||
velocity: 0.9, // Default velocity
|
||||
gain: 1, // Default gain
|
||||
@@ -362,11 +340,19 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
|
||||
ensureMinimalOutput();
|
||||
|
||||
return this.sortHapsByPart().onSchedule((hap, targetTime, cps) => {
|
||||
if (!WebMidi.enabled) {
|
||||
logger('Midi not enabled');
|
||||
return;
|
||||
}
|
||||
const timeMs = getClockBridge().getPerformanceTime(targetTime);
|
||||
if (!timeMs) {
|
||||
logger('[midi] clockbridge not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
hap.ensureObjectValue();
|
||||
|
||||
// midi event values from hap with configurable defaults
|
||||
@@ -404,7 +390,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if midimap is set, send a cc messages from defined controls
|
||||
if (midicontrolMap.has(midimap)) {
|
||||
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, timeMs));
|
||||
} else if (midimap !== 'default') {
|
||||
// Add warning when a non-existent midimap is specified
|
||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||
@@ -412,16 +398,20 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
|
||||
// Handle note
|
||||
if (note !== undefined && !midiConfig.isController) {
|
||||
// note off messages will often a few ms arrive late,
|
||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||
// note off time is calculated early, together with note on time
|
||||
// when the note off is due, the clock might have drifted, and the next note on message might happen before the note off
|
||||
// this would lead to the next note being cut off
|
||||
// this is why we make notes shorter by noteOffsetMs, so note offs happen earlier than the note ons after
|
||||
const hapDuration = (hap.duration.valueOf() / cps) * 1000;
|
||||
const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
|
||||
const duration = hapDuration - offset;
|
||||
|
||||
sendNote(note, velocity, duration, device, midichan, targetTime);
|
||||
sendNote(note, velocity, duration, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle program change
|
||||
if (progNum !== undefined) {
|
||||
sendProgramChange(progNum, device, midichan, targetTime);
|
||||
sendProgramChange(progNum, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle sysex
|
||||
@@ -431,63 +421,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if sysexid is an array the first byte is 0x00
|
||||
|
||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||
sendSysex(sysexid, sysexdata, device, targetTime);
|
||||
sendSysex(sysexid, sysexdata, device, timeMs);
|
||||
}
|
||||
|
||||
// Handle control change
|
||||
if (ccv !== undefined && ccn !== undefined) {
|
||||
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||
sendCC(ccn, ccv, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle NRPN non-registered parameter number
|
||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle midibend
|
||||
if (midibend !== undefined) {
|
||||
sendPitchBend(midibend, device, midichan, targetTime);
|
||||
sendPitchBend(midibend, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle miditouch
|
||||
if (miditouch !== undefined) {
|
||||
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||
sendAftertouch(miditouch, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle midicmd
|
||||
if (hap.whole.begin + 0 === 0) {
|
||||
// we need to start here because we have the timing info
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
|
||||
}
|
||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendClock();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
|
||||
} else if (['start'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
|
||||
} else if (['stop'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStop();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
|
||||
} else if (['continue'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendContinue();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
|
||||
} else if (Array.isArray(midicmd)) {
|
||||
if (midicmd[0] === 'progNum') {
|
||||
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||
sendProgramChange(midicmd[1], device, midichan, timeMs);
|
||||
} else if (midicmd[0] === 'cc') {
|
||||
if (midicmd.length === 2) {
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
|
||||
}
|
||||
} else if (midicmd[0] === 'sysex') {
|
||||
if (midicmd.length === 3) {
|
||||
const [_, id, data] = midicmd;
|
||||
sendSysex(id, data, device, targetTime);
|
||||
sendSysex(id, data, device, timeMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,9 +475,9 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize a midi device
|
||||
* Initialize a midi input device
|
||||
*/
|
||||
async function _initialize(input) {
|
||||
async function _initializeInput(input) {
|
||||
if (isPattern(input)) {
|
||||
throw new Error(
|
||||
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
|
||||
@@ -505,31 +485,38 @@ async function _initialize(input) {
|
||||
}')`,
|
||||
);
|
||||
}
|
||||
|
||||
const initial = await enableWebMidi(); // only returns on first init
|
||||
const device = getDevice(input, WebMidi.inputs);
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const instance = midiInputs[input] || new MidiInput(input);
|
||||
midiInputs[input] = instance;
|
||||
|
||||
if (initial) {
|
||||
const device = instance.initialDevice;
|
||||
|
||||
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
||||
logger(
|
||||
`[midi] Midi enabled! Using "${device.name}". ${
|
||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`,
|
||||
device
|
||||
? `[midi] Midi enabled! Using "${device.name}". ${
|
||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`
|
||||
: `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
return device;
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// MIDI input wrappers, by specified input string/index
|
||||
const midiInputs = {};
|
||||
|
||||
/**
|
||||
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
|
||||
*
|
||||
* The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel
|
||||
*
|
||||
* @name midin
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern.
|
||||
* When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
|
||||
@@ -543,31 +530,10 @@ async function _initialize(input) {
|
||||
* note("c a f e").s("saw")
|
||||
* .when(cc(0).gt(0), x => x.postgain(0))
|
||||
*/
|
||||
let listeners = {};
|
||||
const refs = {};
|
||||
const refsByChan = {};
|
||||
export async function midin(input) {
|
||||
const device = await _initialize(input);
|
||||
refs[input] ??= {};
|
||||
refsByChan[input] ??= {};
|
||||
const cc = (cc, chan) => {
|
||||
if (chan !== undefined) {
|
||||
return ref(() => refsByChan[input][cc]?.[chan] || 0);
|
||||
}
|
||||
return ref(() => refs[input][cc] || 0);
|
||||
};
|
||||
const instance = await _initializeInput(input);
|
||||
|
||||
listeners[input] && device.removeListener('midimessage', listeners[input]);
|
||||
listeners[input] = (e) => {
|
||||
const [ccNum, v] = e.dataBytes;
|
||||
const chan = e.message.channel;
|
||||
const scaled = v / 127;
|
||||
refsByChan[input][ccNum] ??= {};
|
||||
refsByChan[input][ccNum][chan] = scaled;
|
||||
refs[input][ccNum] = scaled;
|
||||
};
|
||||
device.addListener('midimessage', listeners[input]);
|
||||
return cc;
|
||||
return instance.createCC.bind(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,8 +542,11 @@ export async function midin(input) {
|
||||
* The note length is fixed as Superdough is not currently set up for undetermined
|
||||
* note durations
|
||||
*
|
||||
* The 'midichan' control value contains the number of the channel the note is coming from
|
||||
* so it could be filtered or manipulated further in the chain.
|
||||
*
|
||||
* @name midikeys
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
|
||||
* When queried, the pattern will produces the most recently played midi notes and velocities,
|
||||
@@ -591,14 +560,18 @@ export async function midin(input) {
|
||||
* .s("saw")
|
||||
* .add(note(rand.mul(0.3)))
|
||||
* .lpf(1000).lpe(2).room(0.5)
|
||||
* @example
|
||||
* // discard all notes not coming out from midi channel 2
|
||||
* const kb = await midikeys('Arturia KeyStep 32')
|
||||
* kb().filterValues(v=>v.midichan==2).s("tri")
|
||||
*/
|
||||
const kHaps = {};
|
||||
const kListeners = {};
|
||||
|
||||
function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
const pattern = getPattern();
|
||||
const trigger = getTriggerFunc();
|
||||
if (!pattern || !trigger) {
|
||||
const schedule = getScheduleFunc();
|
||||
if (!pattern || !schedule) {
|
||||
return false;
|
||||
}
|
||||
const t = now + latencyCycles;
|
||||
@@ -615,14 +588,22 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
return;
|
||||
}
|
||||
const t = ctxNow + (hap.whole.begin - now) / cps;
|
||||
const duration = hap.duration / cps;
|
||||
trigger(hap, t - ctxNow, duration, cps, t);
|
||||
schedule(hap, t, cps);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
export async function midikeys(input) {
|
||||
const device = await _initialize(input);
|
||||
const instance = await _initializeInput(input);
|
||||
|
||||
// TODO: support unpluggable device usage
|
||||
const device = instance.initialDevice;
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!kHaps[input]) {
|
||||
kHaps[input] = [];
|
||||
}
|
||||
@@ -642,7 +623,7 @@ export async function midikeys(input) {
|
||||
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
|
||||
const key = `${input}_${note}`;
|
||||
const cps = getCps() ?? 0.5;
|
||||
const triggerAvailable = !!(getPattern() && getTriggerFunc());
|
||||
const triggerAvailable = !!(getPattern() && getScheduleFunc());
|
||||
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
|
||||
const now = getTime();
|
||||
const t = now + latencySeconds * cps;
|
||||
@@ -663,7 +644,7 @@ export async function midikeys(input) {
|
||||
*/
|
||||
return;
|
||||
} else {
|
||||
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
|
||||
value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel };
|
||||
}
|
||||
kHaps[input].push(new Hap(span, span, value, {}));
|
||||
if (!noteoff && triggerAvailable) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
util.mjs - MIDI utility functions
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||
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 { Input, Output } from 'webmidi';
|
||||
|
||||
/**
|
||||
* Get a string listing device names for error messages.
|
||||
* @param {Input[] | Output[]} devices
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getMidiDeviceNamesString(devices) {
|
||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a device by index or name. Otherwise return a default device, or fail if none are connected.
|
||||
*
|
||||
* @param {string | number} indexOrName
|
||||
* @param {Input[] | Output[]} devices
|
||||
* @returns {Input | Output | undefined}
|
||||
*/
|
||||
export function getDevice(indexOrName, devices) {
|
||||
if (typeof indexOrName === 'number') {
|
||||
return devices[indexOrName];
|
||||
}
|
||||
const byName = (name) => devices.find((output) => output.name.includes(name));
|
||||
if (typeof indexOrName === 'string') {
|
||||
return byName(indexOrName);
|
||||
}
|
||||
// attempt to default to first IAC device if none is specified
|
||||
const IACOutput = byName('IAC');
|
||||
const device = IACOutput ?? devices[0];
|
||||
if (!device) {
|
||||
if (!devices.length) {
|
||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||
}
|
||||
throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
@@ -84,6 +84,22 @@ describe('mini', () => {
|
||||
it('supports patterning euclidean rhythms', () => {
|
||||
expect(minS('[a(<3 5>, <8 16>)]*2')).toEqual(minS('a(3,8) a(5,16)'));
|
||||
});
|
||||
it('supports inverted euclidean rhythms', () => {
|
||||
expect(minS('a(-3, 8)')).toEqual(['a: 1/8 - 1/4', 'a: 1/4 - 3/8', 'a: 1/2 - 5/8', 'a: 5/8 - 3/4', 'a: 7/8 - 1']);
|
||||
});
|
||||
it('supports euclidean legato rhythms', () => {
|
||||
expect(minS('a(3, -8)')).toEqual(['a: 0 - 3/8', 'a: 3/8 - 3/4', 'a: 3/4 - 1']);
|
||||
});
|
||||
it('supports inverted euclidean legato rhythms', () => {
|
||||
expect(minS('a(-3, -8)')).toEqual([
|
||||
'a: -1/8 - 1/8',
|
||||
'a: 1/8 - 1/4',
|
||||
'a: 1/4 - 1/2',
|
||||
'a: 1/2 - 5/8',
|
||||
'a: 5/8 - 7/8',
|
||||
'a: 7/8 - 9/8',
|
||||
]);
|
||||
});
|
||||
it("reproduces Toussaint's example euclidean algorithms", () => {
|
||||
const checkEuclid = function (spec, target) {
|
||||
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
|
||||
@@ -118,6 +134,40 @@ describe('mini', () => {
|
||||
checkEuclid([11, 24], 'x ~ ~ x ~ x ~ x ~ x ~ x ~ ~ x ~ x ~ x ~ x ~ x ~');
|
||||
checkEuclid([13, 24], 'x ~ x x ~ x ~ x ~ x ~ x ~ x x ~ x ~ x ~ x ~ x ~');
|
||||
});
|
||||
it("reproduces Toussaint's example euclidean legato algorithms", () => {
|
||||
const checkEuclid = function (spec, target) {
|
||||
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
|
||||
};
|
||||
checkEuclid([1, -2], 'x _');
|
||||
checkEuclid([1, -3], 'x _ _');
|
||||
checkEuclid([1, -4], 'x _ _ _');
|
||||
checkEuclid([4, -12], 'x _ _ x _ _ x _ _ x _ _');
|
||||
checkEuclid([2, -5], 'x _ x _ _');
|
||||
// checkEuclid([3, 4], "x _ x x"); // Toussaint is wrong..
|
||||
checkEuclid([3, -4], 'x x x _'); // correction
|
||||
checkEuclid([3, -5], 'x _ x _ x');
|
||||
checkEuclid([3, -7], 'x _ x _ x _ _');
|
||||
checkEuclid([3, -8], 'x _ _ x _ _ x _');
|
||||
checkEuclid([4, -7], 'x _ x _ x _ x');
|
||||
checkEuclid([4, -9], 'x _ x _ x _ x _ _');
|
||||
checkEuclid([4, -11], 'x _ _ x _ _ x _ _ x _');
|
||||
// checkEuclid([5, 6], "x _ x x x x"); // Toussaint is wrong..
|
||||
checkEuclid([5, -6], 'x x x x x _'); // correction
|
||||
checkEuclid([5, -7], 'x _ x x _ x x');
|
||||
checkEuclid([5, -8], 'x _ x x _ x x _');
|
||||
checkEuclid([5, -9], 'x _ x _ x _ x _ x');
|
||||
checkEuclid([5, -11], 'x _ x _ x _ x _ x _ _');
|
||||
checkEuclid([5, -12], 'x _ _ x _ x _ _ x _ x _');
|
||||
// checkEuclid([5, 16], "x _ _ x _ _ x _ _ x _ _ x _ _ _ _"); // Toussaint is wrong..
|
||||
checkEuclid([5, -16], 'x _ _ x _ _ x _ _ x _ _ x _ _ _'); // correction
|
||||
// checkEuclid([7, 8], "x _ x x x x x x"); // Toussaint is wrong..
|
||||
checkEuclid([7, -8], 'x x x x x x x _'); // Correction
|
||||
checkEuclid([7, -12], 'x _ x x _ x _ x x _ x _');
|
||||
checkEuclid([7, -16], 'x _ _ x _ x _ x _ _ x _ x _ x _');
|
||||
checkEuclid([9, -16], 'x _ x x _ x _ x _ x x _ x _ x _');
|
||||
checkEuclid([11, -24], 'x _ _ x _ x _ x _ x _ x _ _ x _ x _ x _ x _ x _');
|
||||
checkEuclid([13, -24], 'x _ x x _ x _ x _ x _ x _ x x _ x _ x _ x _ x _');
|
||||
});
|
||||
it('supports the - alias for ~', () => {
|
||||
expect(minS('a - b [- c]')).toEqual(minS('a ~ b [~ c]'));
|
||||
});
|
||||
|
||||
@@ -46,7 +46,6 @@ lib[':'] = tail;
|
||||
lib['..'] = range;
|
||||
lib['def'] = () => silence;
|
||||
lib['or'] = (...children) => chooseIn(...children); // always has structure but is cyclewise.. e.g. "s oh*8.dec[.04 | .5]"
|
||||
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
|
||||
|
||||
function evaluator(node, scope) {
|
||||
const { type } = node;
|
||||
|
||||
+38
-33
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, isPattern } from '@strudel/core';
|
||||
import { Pattern, getClockTime, createParams } from '@strudel/core';
|
||||
import Paho from 'paho-mqtt';
|
||||
|
||||
const connections = {};
|
||||
@@ -61,7 +61,6 @@ Pattern.prototype.mqtt = function (
|
||||
const props = {
|
||||
onSuccess: onConnect,
|
||||
onFailure: onFailure,
|
||||
useSSL: true,
|
||||
};
|
||||
|
||||
if (username) {
|
||||
@@ -81,41 +80,47 @@ Pattern.prototype.mqtt = function (
|
||||
}
|
||||
cx.connect(props);
|
||||
}
|
||||
return this.withHap((hap) => {
|
||||
const onTrigger = (hap, currentTime, cps, targetTime) => {
|
||||
let msg_topic = topic;
|
||||
if (!cx || !cx.isConnected()) {
|
||||
return;
|
||||
}
|
||||
let message = '';
|
||||
if (typeof hap.value === 'object') {
|
||||
let value = hap.value;
|
||||
return this.onSchedule((hap, targetTime, cps) => {
|
||||
let msg_topic = topic;
|
||||
if (!cx || !cx.isConnected()) {
|
||||
return;
|
||||
}
|
||||
let message = '';
|
||||
if (typeof hap.value === 'object') {
|
||||
let value = hap.value;
|
||||
|
||||
// Try to take topic from pattern if it's not set
|
||||
if (typeof msg_topic === 'undefined' && 'topic' in value) {
|
||||
msg_topic = value.topic;
|
||||
if (Array.isArray(msg_topic)) {
|
||||
msg_topic = msg_topic.join('/');
|
||||
}
|
||||
msg_topic = '/' + msg_topic;
|
||||
// Try to take topic from pattern if it's not set
|
||||
if (typeof msg_topic === 'undefined' && 'topic' in value) {
|
||||
msg_topic = value.topic;
|
||||
if (Array.isArray(msg_topic)) {
|
||||
msg_topic = msg_topic.join('/');
|
||||
}
|
||||
if (add_meta) {
|
||||
const duration = hap.duration.div(cps);
|
||||
value = { ...value, duration: duration.valueOf(), cps: cps };
|
||||
}
|
||||
message = JSON.stringify(value);
|
||||
} else {
|
||||
message = hap.value;
|
||||
msg_topic = '/' + msg_topic;
|
||||
}
|
||||
message = new Paho.Message(message);
|
||||
message.destinationName = msg_topic;
|
||||
if (add_meta) {
|
||||
const duration = hap.duration.div(cps);
|
||||
value = { ...value, duration: duration.valueOf(), cps: cps };
|
||||
}
|
||||
message = JSON.stringify(value);
|
||||
} else {
|
||||
message = hap.value;
|
||||
}
|
||||
message = new Paho.Message(message);
|
||||
message.destinationName = msg_topic;
|
||||
|
||||
const offset = (targetTime - currentTime + latency) * 1000;
|
||||
const offset = (targetTime - getClockTime() + latency) * 1000;
|
||||
|
||||
window.setTimeout(function () {
|
||||
cx.send(message);
|
||||
}, offset);
|
||||
};
|
||||
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
||||
window.setTimeout(function () {
|
||||
cx.send(message);
|
||||
}, offset);
|
||||
});
|
||||
};
|
||||
|
||||
// This adds the 'move' and 'motor' commands to strudel
|
||||
export const { move, motor } = createParams('move', 'motor');
|
||||
window.move = move;
|
||||
window.motor = motor;
|
||||
// This adds the 'robot' command
|
||||
Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') {
|
||||
return this.mqtt(undefined, undefined, '/move/' + robot_id, address);
|
||||
};
|
||||
|
||||
+25
-8
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
||||
import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core';
|
||||
import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio';
|
||||
|
||||
let connection; // Promise<OSC>
|
||||
function connect() {
|
||||
@@ -42,10 +43,9 @@ export function parseControlsFromHap(hap, cps) {
|
||||
if (isNote(controls.note)) {
|
||||
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
|
||||
} else {
|
||||
controls.note = parseNumeral(controls.note);
|
||||
controls.midinote = parseNumeral(controls.note);
|
||||
}
|
||||
}
|
||||
controls.bank && (controls.s = controls.bank + controls.s);
|
||||
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
|
||||
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel,
|
||||
// so we need to undo the adjustment before sending the message to superdirt.
|
||||
@@ -55,13 +55,27 @@ export function parseControlsFromHap(hap, cps) {
|
||||
return controls;
|
||||
}
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
let timeOrigin = performance.timeOrigin;
|
||||
export async function oscOutput(hap, targetTime, cps = 1) {
|
||||
const ws = await connect();
|
||||
|
||||
const timeMs = getClockBridge().getPerformanceTime(targetTime);
|
||||
if (!timeMs) {
|
||||
logger('[osc] clockbridge not ready');
|
||||
return;
|
||||
}
|
||||
// when the system sleeps while the browser is open, performance.now() doesnt tick
|
||||
// .. which means performance.timeOrigin + performance.now() doesnt land in the present
|
||||
const deprivation = timeOrigin + performance.now() - Date.now();
|
||||
if (Math.abs(deprivation) > 20) {
|
||||
console.log(`[osc] snap with ${(deprivation / 1000).toFixed(2)}s deprivation`);
|
||||
// shift time after microsleep
|
||||
timeOrigin -= deprivation;
|
||||
}
|
||||
const ts = timeOrigin + timeMs;
|
||||
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const keyvals = Object.entries(controls).flat();
|
||||
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
|
||||
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
|
||||
|
||||
if ('oschost' in hap.value) {
|
||||
@@ -83,4 +97,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
|
||||
export const osc = register('osc', (pat) => {
|
||||
ensureMinimalOutput();
|
||||
return pat.onSchedule(oscOutput);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"osc": "^2.4.5",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
/* import { tauriOscOutput } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs'; */
|
||||
import { oscTrigger } from './osc.mjs';
|
||||
import { oscOutput } from './osc.mjs';
|
||||
|
||||
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
|
||||
const output = /* isTauri() ? tauriOscOutput : */ oscOutput;
|
||||
|
||||
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
||||
const currentTime = performance.now() / 1000;
|
||||
return trigger(hap, currentTime, cps, targetTime);
|
||||
};
|
||||
export const superdirtOutput = (hap, targetTime, cps) => output(hap, targetTime, cps);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@strudel/codemirror": "workspace:*",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/edo": "workspace:*",
|
||||
"@strudel/hydra": "workspace:*",
|
||||
"@strudel/midi": "workspace:*",
|
||||
"@strudel/mini": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ export async function prebake() {
|
||||
core,
|
||||
import('@strudel/draw'),
|
||||
import('@strudel/mini'),
|
||||
import('@strudel/edo'),
|
||||
import('@strudel/tonal'),
|
||||
import('@strudel/webaudio'),
|
||||
import('@strudel/codemirror'),
|
||||
|
||||
@@ -37,7 +37,7 @@ if (typeof HTMLElement !== 'undefined') {
|
||||
const drawContext = getDrawContext();
|
||||
const drawTime = [-2, 2];
|
||||
this.editor = new StrudelMirror({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
transpiler,
|
||||
root: container,
|
||||
|
||||
@@ -98,6 +98,7 @@ if (args.includes('--json')) {
|
||||
}
|
||||
writeFileSync(outFile, json, 'utf8');
|
||||
console.log(`Wrote json to ${outFile}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
+40
-43
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, isPattern } from '@strudel/core';
|
||||
import { Pattern, getClockTime } from '@strudel/core';
|
||||
|
||||
var writeMessagers = {};
|
||||
var choosing = false;
|
||||
@@ -64,53 +64,50 @@ function crc16(data) {
|
||||
}
|
||||
|
||||
Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') {
|
||||
return this.withHap((hap) => {
|
||||
if (!(name in writeMessagers)) {
|
||||
getWriter(name, br);
|
||||
}
|
||||
const onTrigger = (hap, currentTime, _cps, targetTime) => {
|
||||
var message = '';
|
||||
var chk = 0;
|
||||
if (typeof hap.value === 'object') {
|
||||
if ('action' in hap.value) {
|
||||
var action = hap.value['action'];
|
||||
if (!(name in writeMessagers)) {
|
||||
getWriter(name, br);
|
||||
}
|
||||
return this.onSchedule((hap, targetTime) => {
|
||||
var message = '';
|
||||
var chk = 0;
|
||||
if (typeof hap.value === 'object') {
|
||||
if ('action' in hap.value) {
|
||||
var action = hap.value['action'];
|
||||
if (singlecharids) {
|
||||
action = action.charAt(0);
|
||||
}
|
||||
message += action + '(';
|
||||
var first = true;
|
||||
for (var [key, val] of Object.entries(hap.value)) {
|
||||
if (key === 'action') {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
message += ',';
|
||||
}
|
||||
if (singlecharids) {
|
||||
action = action.charAt(0);
|
||||
}
|
||||
message += action + '(';
|
||||
var first = true;
|
||||
for (var [key, val] of Object.entries(hap.value)) {
|
||||
if (key === 'action') {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
message += ',';
|
||||
}
|
||||
if (singlecharids) {
|
||||
key = key.charAt(0);
|
||||
}
|
||||
message += key + ':' + val;
|
||||
}
|
||||
message += ')';
|
||||
if (sendcrc) {
|
||||
chk = crc16(message);
|
||||
}
|
||||
} else {
|
||||
for (const [key, val] of Object.entries(hap.value)) {
|
||||
message += `${key}:${val}`;
|
||||
key = key.charAt(0);
|
||||
}
|
||||
message += key + ':' + val;
|
||||
}
|
||||
message += ')';
|
||||
if (sendcrc) {
|
||||
chk = crc16(message);
|
||||
}
|
||||
} else {
|
||||
message = hap.value;
|
||||
for (const [key, val] of Object.entries(hap.value)) {
|
||||
message += `${key}:${val}`;
|
||||
}
|
||||
}
|
||||
const offset = (targetTime - currentTime + latency) * 1000;
|
||||
} else {
|
||||
message = hap.value;
|
||||
}
|
||||
const offset = (targetTime - getClockTime() + latency) * 1000;
|
||||
|
||||
window.setTimeout(function () {
|
||||
writeMessagers[name](message, chk);
|
||||
}, offset);
|
||||
};
|
||||
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
||||
window.setTimeout(function () {
|
||||
writeMessagers[name](message, chk);
|
||||
}, offset);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ async function loadFont(name) {
|
||||
const url = `${soundfontUrl}/${name}.js`;
|
||||
const preset = await fetch(url).then((res) => res.text());
|
||||
let [_, data] = preset.split('={');
|
||||
return eval('{' + data);
|
||||
return new Function('return {' + data)().zones;
|
||||
};
|
||||
loadCache[name] = load();
|
||||
return loadCache[name];
|
||||
|
||||
@@ -479,7 +479,7 @@ export default {
|
||||
gm_electric_bass_finger: [
|
||||
// Electric Bass (finger): Bass
|
||||
'0330_JCLive_sf2_file',
|
||||
'0330_FluidR3_GM_sf2_fible',
|
||||
'0330_FluidR3_GM_sf2_file',
|
||||
'0330_Aspirin_sf2_file',
|
||||
//0330_Chaos_sf2_file // same as last
|
||||
'0330_GeneralUserGS_sf2_file',
|
||||
@@ -527,7 +527,7 @@ export default {
|
||||
'0370_Aspirin_sf2_file',
|
||||
// 0370_Chaos_sf2_file // same as last
|
||||
'0370_FluidR3_GM_sf2_file',
|
||||
'0370_GeneralUserGS_sf2_fil e',
|
||||
'0370_GeneralUserGS_sf2_file',
|
||||
'0370_JCLive_sf2_file',
|
||||
//0370_SBLive_sf2
|
||||
//0370_SoundBlasterOld_sf2
|
||||
@@ -1782,6 +1782,5 @@ export default {
|
||||
'1272_GeneralUserGS_sf2_file',
|
||||
'1273_GeneralUserGS_sf2_file',
|
||||
'1274_GeneralUserGS_sf2_file',
|
||||
'',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
|
||||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||
|
||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
||||
return this.onSchedule((h, targetTime) => {
|
||||
const ctx = getAudioContext();
|
||||
const note = getPlayableNoteValue(h);
|
||||
const preset = sf.presets[n % sf.presets.length];
|
||||
|
||||
@@ -7,15 +7,24 @@ Copyright (C) 2025 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/>.
|
||||
*/
|
||||
|
||||
let audioContext;
|
||||
import { clearNodePool } from './nodePools.mjs';
|
||||
import { ClockBridge } from './clockbridge.mjs';
|
||||
|
||||
let audioContext, clockBridge;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
audioContext = new AudioContext();
|
||||
return audioContext;
|
||||
return setAudioContext(new AudioContext());
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
// Existing nodes in the node pool contain references to the previous AudioContext,
|
||||
// so all the nodes in the pool must be cleared when we set a new AudioContext.
|
||||
clearNodePool();
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close();
|
||||
}
|
||||
audioContext = context;
|
||||
clockBridge = new ClockBridge(audioContext);
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
@@ -27,6 +36,13 @@ export const getAudioContext = () => {
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export function getClockBridge() {
|
||||
if (!clockBridge) {
|
||||
getAudioContext(); // creates clockBridge
|
||||
}
|
||||
return clockBridge;
|
||||
}
|
||||
|
||||
export function getAudioContextCurrentTime() {
|
||||
return getAudioContext().currentTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
clockbridge.mjs
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/index.mjs>
|
||||
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/>.
|
||||
*/
|
||||
|
||||
// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
|
||||
export function expDecay(a, b, decay, dt) {
|
||||
return b + (a - b) * Math.exp(-decay * dt);
|
||||
}
|
||||
|
||||
// translates between audio and performance clock
|
||||
export class ClockBridge {
|
||||
p; // smoothed clock offset
|
||||
lastTime;
|
||||
audioContext;
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
}
|
||||
// delta between audio and performance time in ms
|
||||
getOffset() {
|
||||
const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp();
|
||||
if (!contextTime || !performanceTime) {
|
||||
return;
|
||||
}
|
||||
const offset = performanceTime - contextTime * 1000; // clock offset in ms
|
||||
const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap
|
||||
const deprivation = offset - this.p;
|
||||
// when the system sleeps, the audio and system clock get jumbled
|
||||
// so we dont smooth if the jump is too high, to snap the offset
|
||||
if (Math.abs(deprivation) > 20) {
|
||||
console.log(`[ClockBridge] snap with ${(deprivation / 1000).toFixed(2)}s deprivation`);
|
||||
this.p = offset;
|
||||
} else {
|
||||
const decay = 1 / 10000; // how fast offset changes have an effect
|
||||
this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset
|
||||
}
|
||||
this.lastTime = performanceTime;
|
||||
return this.p;
|
||||
}
|
||||
getPerformanceTime(audioContextTime) {
|
||||
const offset = this.getOffset();
|
||||
return audioContextTime * 1000 + offset; // this is now correct in performance time (ms)
|
||||
}
|
||||
getAudioContextTime(performanceTime) {
|
||||
const offset = this.getOffset();
|
||||
return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds)
|
||||
}
|
||||
}
|
||||
@@ -67,13 +67,14 @@ if (typeof window !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
export const dough = async (code) => {
|
||||
export const rawdsp = async (code) => {
|
||||
const ac = getAudioContext();
|
||||
stop();
|
||||
worklet = await dspWorklet(ac, code);
|
||||
worklet.node.connect(ac.destination);
|
||||
};
|
||||
|
||||
export function doughTrigger(hap, currentTime, cps, targetTime) {
|
||||
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
export function rawdspOutput(hap, time, cps) {
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
window.postMessage({ time, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
}
|
||||
|
||||
@@ -6,12 +6,27 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
export function gainNode(value) {
|
||||
const node = getAudioContext().createGain();
|
||||
export function gainNode(value, audioContext = getAudioContext()) {
|
||||
const node = audioContext.createGain();
|
||||
node.gain.value = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
// this helper makes sure the audio context is "used", meaning it outputs something
|
||||
// this prevents the browser from throttling timing accuracy
|
||||
// it happened when only midi was running, the clock got more drifty without this
|
||||
let constantNode, constantNodeAudioContext;
|
||||
export function ensureMinimalOutput() {
|
||||
if (constantNode && constantNodeAudioContext === getAudioContext()) {
|
||||
return;
|
||||
}
|
||||
constantNodeAudioContext = getAudioContext();
|
||||
constantNode = new ConstantSourceNode(constantNodeAudioContext);
|
||||
constantNode.offset.value = 1e-7;
|
||||
constantNode.connect(constantNodeAudioContext.destination);
|
||||
constantNode.start();
|
||||
}
|
||||
|
||||
export function effectSend(input, effect, wet) {
|
||||
const send = gainNode(wet);
|
||||
input.connect(send);
|
||||
@@ -374,7 +389,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||
|
||||
// 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);
|
||||
const zeroGain = gainNode(0, audioContext);
|
||||
zeroGain.connect(audioContext.destination);
|
||||
constantNode.connect(zeroGain);
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './modulators.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
export * from './audioContext.mjs';
|
||||
export * from './wavetable.mjs';
|
||||
export * from './clockbridge.mjs';
|
||||
|
||||
@@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
const nodePools = new Map();
|
||||
const POOL_KEY = Symbol('nodePoolKey');
|
||||
|
||||
export const isPoolable = (node) => !!node[POOL_KEY];
|
||||
|
||||
export const clearNodePool = () => {
|
||||
for (const pool of nodePools) {
|
||||
for (const node of pool) {
|
||||
if (node instanceof AudioNode) {
|
||||
releaseAudioNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
nodePools.clear();
|
||||
};
|
||||
|
||||
const getNodeTime = (node) => {
|
||||
return node.context?.currentTime ?? 0;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||
import { registerSound, registerWaveTable } from './index.mjs';
|
||||
import { registerSound, registerWaveTable, soundMap } from './index.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import {
|
||||
getADSRValues,
|
||||
@@ -30,6 +30,30 @@ function humanFileSize(bytes, si) {
|
||||
return bytes.toFixed(1) + ' ' + units[u];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the duration, in seconds, of the given sample.
|
||||
* Has optional param `n` (for instance, the `2` in `s("casio:2")`)
|
||||
*
|
||||
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
|
||||
*
|
||||
* @name getDuration
|
||||
* @synonym getDur
|
||||
* @tags samples
|
||||
* @param {string} sampleName
|
||||
* @param {number} (optional) n
|
||||
*
|
||||
* @example
|
||||
* // Set a patterns cycle length to exactly the length of the sample
|
||||
* samples('github:tidalcycles/dirt-samples')
|
||||
* let k = await getDuration('sax')
|
||||
* s("sax").cps(1/k)
|
||||
*/
|
||||
export const getDuration = (s, n = 0) => {
|
||||
return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
|
||||
};
|
||||
|
||||
export const getDur = getDuration;
|
||||
|
||||
export function getSampleInfo(hapValue, bank) {
|
||||
const { speed = 1.0 } = hapValue;
|
||||
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { map } from 'nanostores';
|
||||
import { logger } from './logger.mjs';
|
||||
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
|
||||
import { loadBuffer } from './sampler.mjs';
|
||||
import { getSampleBufferSource, loadBuffer } from './sampler.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||
import { resetSeenKeys } from './wavetable.mjs';
|
||||
@@ -40,7 +40,7 @@ export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
* start to die out in first-in-first-out order once the max polyphony has been hit
|
||||
*
|
||||
* @name setMaxPolyphony
|
||||
* @tags fx, superdough
|
||||
* @tags superdough
|
||||
* @param {number} Max polyphony. Defaults to 128
|
||||
* @example
|
||||
* setMaxPolyphony(4)
|
||||
@@ -74,7 +74,7 @@ export function applyGainCurve(val) {
|
||||
* quadratic, exponential, etc. rather than linear
|
||||
*
|
||||
* @name setGainCurve
|
||||
* @tags fx, superdough
|
||||
* @tags amplitude, superdough
|
||||
* @param {Function} function to apply to all gain values
|
||||
* @example
|
||||
* setGainCurve((x) => x * x) // quadratic gain
|
||||
@@ -1048,7 +1048,3 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const superdoughTrigger = (t, hap, ct, cps) => {
|
||||
superdough(hap, t - ct, hap.duration / cps, cps);
|
||||
};
|
||||
|
||||
@@ -485,8 +485,10 @@ export function waveformN(partials, phases, type) {
|
||||
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;
|
||||
const R0 = R;
|
||||
const I0 = I;
|
||||
R = c * R0 - s * I0;
|
||||
I = s * R0 + c * I0;
|
||||
}
|
||||
real[n + 1] = R;
|
||||
imag[n + 1] = I;
|
||||
|
||||
@@ -186,7 +186,7 @@ export function registerWaveTable(key, tables, params) {
|
||||
* Loads a collection of wavetables to use with `s`
|
||||
*
|
||||
* @name tables
|
||||
* @tags fx
|
||||
* @tags wavetable
|
||||
*/
|
||||
export const tables = async (url, frameLen, json, options = {}) => {
|
||||
if (json !== undefined) return _processTables(json, url, frameLen);
|
||||
|
||||
@@ -214,7 +214,7 @@ class CoarseProcessor extends AudioWorkletProcessor {
|
||||
coarse = Math.max(1, coarse);
|
||||
for (let n = 0; n < blockSize; n++) {
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1];
|
||||
output[i][n] = n % coarse < 1 ? input[i][n] : output[i][n - 1];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './tonal.mjs';
|
||||
import './voicings.mjs';
|
||||
import './ireal.mjs';
|
||||
|
||||
export * from './tonal.mjs';
|
||||
export * from './voicings.mjs';
|
||||
export * from './ireal.mjs';
|
||||
|
||||
import './ireal.mjs';
|
||||
|
||||
export const packageName = '@strudel/tonal';
|
||||
|
||||
@@ -100,7 +100,7 @@ function scaleOffset(scale, offset, note) {
|
||||
* - 5P = perfect fifth
|
||||
* - 5d = diminished fifth
|
||||
*
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {string | number} amount Either number of semitones or interval string.
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
@@ -137,7 +137,14 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
|
||||
const interval = !isNaN(Number(intervalOrSemitones))
|
||||
? Interval.fromSemitones(intervalOrSemitones)
|
||||
: String(intervalOrSemitones);
|
||||
const targetNote = Note.transpose(note, interval);
|
||||
|
||||
let n = Note.get(note);
|
||||
if (n.oct == undefined) {
|
||||
n.oct = 3;
|
||||
let n1 = Note.get(Note.transpose(n, interval));
|
||||
n.oct = n1.oct == 3 ? undefined : 3;
|
||||
}
|
||||
const targetNote = Note.transpose(n, interval);
|
||||
if (typeof hap.value === 'object') {
|
||||
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
|
||||
}
|
||||
@@ -155,7 +162,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name scaleTranspose
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {offset} offset number of steps inside the scale
|
||||
* @returns Pattern
|
||||
* @synonyms scaleTrans, strans
|
||||
@@ -246,7 +253,7 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
|
||||
* The root note defaults to octave 3, if no octave number is given.
|
||||
*
|
||||
* @name scale
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {string} scale Name of scale
|
||||
* @returns Pattern
|
||||
* @example
|
||||
|
||||
@@ -134,6 +134,16 @@ let modeTarget = {
|
||||
duck: (v) => v.slice(-1)[0],
|
||||
above: (v) => v[0],
|
||||
root: (v) => v[0],
|
||||
oldabove: (v) => v[0],
|
||||
oldroot: (v) => v[0],
|
||||
};
|
||||
let modeMult = {
|
||||
below: 1,
|
||||
duck: 1,
|
||||
above: -1,
|
||||
root: -1,
|
||||
oldabove: 1,
|
||||
oldroot: 1,
|
||||
};
|
||||
|
||||
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
|
||||
@@ -144,19 +154,20 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
|
||||
const voicings = dictionary[symbol].map((voicing) =>
|
||||
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
|
||||
);
|
||||
const mult = modeMult[mode];
|
||||
|
||||
let minDistance, bestIndex;
|
||||
// calculate distances up from voicing top notes
|
||||
let chromaDiffs = voicings.map((v, i) => {
|
||||
const targetStep = modeTarget[mode](v);
|
||||
const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
|
||||
const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12);
|
||||
if (minDistance === undefined || diff < minDistance) {
|
||||
minDistance = diff;
|
||||
bestIndex = i;
|
||||
}
|
||||
return diff;
|
||||
return diff * mult;
|
||||
});
|
||||
if (mode === 'root') {
|
||||
if (mode === 'root' || mode === 'oldroot') {
|
||||
bestIndex = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistr
|
||||
* Adds a new custom voicing dictionary.
|
||||
*
|
||||
* @name addVoicings
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {string} name identifier for the voicing dictionary
|
||||
* @param {Object} dictionary maps chord symbol to possible voicings
|
||||
@@ -134,7 +134,7 @@ const getVoicing = (chord, dictionaryName, lastVoicing) => {
|
||||
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
|
||||
*
|
||||
* @name voicings
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {string} dictionary which voicing dictionary to use.
|
||||
* @returns Pattern
|
||||
@@ -159,7 +159,7 @@ export const voicings = register('voicings', function (dictionary, pat) {
|
||||
* Maps the chords of the incoming pattern to root notes in the given octave.
|
||||
*
|
||||
* @name rootNotes
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {octave} octave octave to use
|
||||
* @returns Pattern
|
||||
@@ -185,6 +185,9 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
|
||||
* - `below`: top note <= anchor
|
||||
* - `duck`: top note <= anchor, anchor excluded
|
||||
* - `above`: bottom note >= anchor
|
||||
* - `root`: bottom note is the lowest root of the chord >= anchor
|
||||
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
|
||||
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
|
||||
* - `offset`: whole number that shifts the voicing up or down to the next voicing
|
||||
* - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
|
||||
*
|
||||
@@ -192,7 +195,7 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
|
||||
* If you pass a pattern of strings to voicing, they will be interpreted as chords.
|
||||
*
|
||||
* @name voicing
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* n("0 1 2 3").chord("<C Am F G>").voicing()
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
# @strudel/transpiler
|
||||
|
||||
This package contains a JS code transpiler with the following features:
|
||||
This package contains a JS code transpiler with a plugin system that can be used to transform the code.
|
||||
|
||||
A plugin can be registered via a `registerTranspilerPlugin` call :
|
||||
|
||||
```js
|
||||
registerTranspilerPlugin({
|
||||
walk: (context) => ({
|
||||
enter: function(node, parent, prop, index) {},
|
||||
leave: function(node, parent, prop, index) {}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
where the enter/leave functions (both optional) follow the `estree-walker` walk API.
|
||||
|
||||
4 plugins are currently hosted inside `@strudel/transpiler`
|
||||
|
||||
- mini: add locations of mini notation strings (double quoted or backticked) for highlighting
|
||||
- widgets: add handling of sliders & draw widgets
|
||||
- sample: make it possible to call a sample without await
|
||||
- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation
|
||||
|
||||
it also
|
||||
|
||||
- add locations of mini notation strings (double quoted or backticked) for highlighting
|
||||
- converts pseudo note variables to note strings
|
||||
- adds return statement to the last expression
|
||||
- handles label capturing for block-based eval
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
/*
|
||||
index.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { evaluate as _evaluate } from '@strudel/core';
|
||||
import { transpiler } from './transpiler.mjs';
|
||||
|
||||
export * from './transpiler.mjs';
|
||||
|
||||
export const evaluate = (code) => _evaluate(code, transpiler);
|
||||
import './plugin-kabelsalat.mjs';
|
||||
import './plugin-mini.mjs';
|
||||
import './plugin-sample.mjs';
|
||||
import './plugin-widgets.mjs';
|
||||
|
||||
export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs';
|
||||
export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions);
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
plugin-kabelsalat.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { walk } from 'estree-walker';
|
||||
import escodegen from 'escodegen';
|
||||
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
|
||||
|
||||
export function genExprSource(expr) {
|
||||
return escodegen.generate(expr, { format: { semicolons: false } });
|
||||
}
|
||||
|
||||
function isStrudelPatternWrap(node) {
|
||||
if (node.type !== 'CallExpression') {
|
||||
return false;
|
||||
}
|
||||
const callee = node.callee;
|
||||
if (callee.type === 'Identifier') {
|
||||
return callee.name === 'S';
|
||||
}
|
||||
if (callee.type === 'MemberExpression' && !callee.computed) {
|
||||
return callee.property?.name === 'S';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Used to identify transpiled `m(...)` calls for proper conversion
|
||||
// to, say, kabelsalat placeholders
|
||||
function isMiniCall(node) {
|
||||
if (node.type !== 'CallExpression') {
|
||||
return false;
|
||||
}
|
||||
const callee = node.callee;
|
||||
if (callee.type !== 'Identifier') {
|
||||
return false;
|
||||
}
|
||||
if (callee.name !== getMinilangName()) {
|
||||
return false;
|
||||
}
|
||||
const firstArg = node.arguments?.[0];
|
||||
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
|
||||
}
|
||||
|
||||
function getMinilangName() {
|
||||
const minilang = getLanguages().get('minilang');
|
||||
return minilang?.name || 'm';
|
||||
}
|
||||
|
||||
function replaceNode(node, replacement, parents, currentRoot) {
|
||||
const info = parents.get(node);
|
||||
if (!info || !info.parent) {
|
||||
return replacement;
|
||||
}
|
||||
|
||||
const { parent, prop, index } = info;
|
||||
if (Array.isArray(parent[prop])) {
|
||||
parent[prop][index] = replacement;
|
||||
} else {
|
||||
parent[prop] = replacement;
|
||||
}
|
||||
parents.set(replacement, { parent, prop, index });
|
||||
return currentRoot;
|
||||
}
|
||||
|
||||
// If `start` is available, we use it. If it's already been transpiled
|
||||
// to `m(...)`, use the provided offset
|
||||
function getPatternNodeOrder(node) {
|
||||
if (typeof node.start === 'number') {
|
||||
return node.start;
|
||||
}
|
||||
if (isMiniCall(node)) {
|
||||
const offsetArg = node.arguments?.[1];
|
||||
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
|
||||
return offsetArg.value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function placeholderAst(index) {
|
||||
return {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'pat' },
|
||||
property: { type: 'Literal', value: index },
|
||||
computed: true,
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getStrudelPatternExpr(node) {
|
||||
if (isStrudelPatternWrap(node)) {
|
||||
const arg = node.arguments?.[0];
|
||||
if (!arg) {
|
||||
throw new Error('S(...) requires an argument');
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
if (isMiniCall(node)) {
|
||||
return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cloneNode(node) {
|
||||
return JSON.parse(JSON.stringify(node));
|
||||
}
|
||||
|
||||
export function extractPatternPlaceholders(expr) {
|
||||
const templateExpr = cloneNode(expr);
|
||||
const parents = new Map();
|
||||
const targets = [];
|
||||
|
||||
walk(templateExpr, {
|
||||
enter(node, parent, prop, index) {
|
||||
parents.set(node, { parent, prop, index });
|
||||
const patternExpr = getStrudelPatternExpr(node);
|
||||
if (patternExpr) {
|
||||
targets.push({ node, patternExpr });
|
||||
this.skip();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!targets.length) {
|
||||
return { template: genExprSource(templateExpr), patternExprs: [] };
|
||||
}
|
||||
|
||||
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
|
||||
|
||||
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
|
||||
|
||||
let currentExpr = templateExpr;
|
||||
targets.forEach(({ node }, index) => {
|
||||
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
|
||||
});
|
||||
|
||||
const template = genExprSource(currentExpr);
|
||||
return { template, patternExprs };
|
||||
}
|
||||
|
||||
const transpilerPlugin = {
|
||||
walk: (context) => ({
|
||||
leave: function (node, parent, prop, index) {
|
||||
if (!isKabelCall(node)) return;
|
||||
let [expr, ...rest] = node.arguments;
|
||||
if (!expr) throw new Error('K(...) requires an expression');
|
||||
if (shouldCallKabelExpression(expr)) {
|
||||
expr = {
|
||||
type: 'CallExpression',
|
||||
callee: expr,
|
||||
arguments: [],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
const language = 'kabelsalat';
|
||||
const { template, patternExprs } = extractPatternPlaceholders(expr);
|
||||
if (patternExprs.length) {
|
||||
const workletArgs = [
|
||||
/*{ type: 'Literal', value: language },*/
|
||||
{ type: 'Literal', value: template },
|
||||
...patternExprs,
|
||||
...rest,
|
||||
];
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') {
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: workletMemberAst(callee.object),
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: { type: 'Identifier', name: 'worklet' },
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
|
||||
const kabelSrc = genExprSource(expr);
|
||||
const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest];
|
||||
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') {
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: workletMemberAst(callee.object),
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: { type: 'Identifier', name: 'worklet' },
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
registerTranspilerPlugin(transpilerPlugin);
|
||||
|
||||
function isKabelCall(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
|
||||
return callee.type === 'Identifier' && callee.name === 'K';
|
||||
}
|
||||
|
||||
function shouldCallKabelExpression(expr) {
|
||||
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
|
||||
return false;
|
||||
}
|
||||
if (expr.params.length) {
|
||||
return false;
|
||||
}
|
||||
return expr.body?.type === 'BlockStatement';
|
||||
}
|
||||
|
||||
function workletMemberAst(objectExpr) {
|
||||
return {
|
||||
type: 'MemberExpression',
|
||||
object: objectExpr,
|
||||
property: { type: 'Identifier', name: 'worklet' },
|
||||
computed: false,
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
plugin-mini.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { getLeafLocations } from '@strudel/mini';
|
||||
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
|
||||
|
||||
const languageLiteral = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isLanguageLiteral(node)) return;
|
||||
context.miniLocations ??= [];
|
||||
const { options, miniLocations } = context;
|
||||
const { emitMiniLocations } = options;
|
||||
const { name } = node.tag;
|
||||
const language = getLanguages().get(name);
|
||||
const code = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const locs = language.getLocations(code, offset);
|
||||
miniLocations.push(...locs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(languageWithLocation(name, code, offset));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const tidal = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isTemplateLiteral(node, 'tidal')) return;
|
||||
context.miniLocations ??= [];
|
||||
const { options, miniLocations } = context;
|
||||
const { emitMiniLocations } = options;
|
||||
const raw = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const stringLocs = collectHaskellMiniLocations(raw, offset);
|
||||
miniLocations.push(...stringLocs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(tidalWithLocation(raw, offset));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const backtick = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isBackTickString(node, parent)) return;
|
||||
context.miniLocations ??= [];
|
||||
const { options, input, miniDisableRanges, miniLocations } = context;
|
||||
const { emitMiniLocations } = options;
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { quasis } = node;
|
||||
const { raw } = quasis[0].value;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input);
|
||||
return this.replace(miniWithLocation(raw, node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const doublequotes = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isStringWithDoubleQuotes(node)) return;
|
||||
context.miniLocations ??= [];
|
||||
const { options, input, miniDisableRanges, miniLocations } = context;
|
||||
const { emitMiniLocations } = options;
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { value } = node;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(value, node, miniLocations, input);
|
||||
return this.replace(miniWithLocation(value, node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function isLanguageLiteral(node) {
|
||||
return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name);
|
||||
}
|
||||
|
||||
function languageWithLocation(name, value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function collectHaskellMiniLocations(haskellCode, offset) {
|
||||
return haskellCode
|
||||
.split('')
|
||||
.reduce((acc, char, i) => {
|
||||
if (char !== '"') {
|
||||
return acc;
|
||||
}
|
||||
if (!acc.length || acc[acc.length - 1].length > 1) {
|
||||
acc.push([i + 1]);
|
||||
} else {
|
||||
acc[acc.length - 1].push(i);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.map(([start, end]) => {
|
||||
const miniString = haskellCode.slice(start, end);
|
||||
return getLeafLocations(`"${miniString}"`, offset + start - 1);
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function isTemplateLiteral(node, value) {
|
||||
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
|
||||
}
|
||||
|
||||
function tidalWithLocation(value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'tidal',
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isBackTickString(node, parent) {
|
||||
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
|
||||
}
|
||||
|
||||
function isMiniDisabled(offset, miniDisableRanges) {
|
||||
for (const [start, end] of miniDisableRanges) {
|
||||
if (offset >= start && offset < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function miniWithLocation(value, node) {
|
||||
const { start: fromOffset } = node;
|
||||
|
||||
const minilang = getLanguages().get('minilang');
|
||||
let name = 'm';
|
||||
if (minilang && minilang.name) {
|
||||
name = minilang.name; // name is expected to be exported from the package of the minilang
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name,
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: fromOffset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
const collectMiniLocations = (value, node, miniLocations, input) => {
|
||||
const minilang = getLanguages().get('minilang');
|
||||
if (minilang) {
|
||||
const code = `[${value}]`;
|
||||
const locs = minilang.getLocations(code, node.start);
|
||||
miniLocations.push(...locs);
|
||||
} else {
|
||||
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
|
||||
miniLocations.push(...leafLocs);
|
||||
}
|
||||
};
|
||||
|
||||
function isStringWithDoubleQuotes(node, locations, code) {
|
||||
if (node.type !== 'Literal') {
|
||||
return false;
|
||||
}
|
||||
return node.raw[0] === '"';
|
||||
}
|
||||
|
||||
registerTranspilerPlugin([languageLiteral, tidal, backtick, doublequotes]);
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
plugin-sample.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { registerTranspilerPlugin } from './transpiler.mjs';
|
||||
|
||||
export function withAwait(node) {
|
||||
return {
|
||||
type: 'AwaitExpression',
|
||||
argument: node,
|
||||
};
|
||||
}
|
||||
|
||||
const bareSample = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isBareSamplesCall(node, parent)) return;
|
||||
this.replace(withAwait(node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function isBareSamplesCall(node, parent) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
||||
}
|
||||
|
||||
registerTranspilerPlugin(bareSample);
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
plugin-widgets.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { registerTranspilerPlugin } from './transpiler.mjs';
|
||||
|
||||
let widgetMethods = [];
|
||||
export function registerWidgetType(type) {
|
||||
widgetMethods.push(type);
|
||||
}
|
||||
|
||||
const widgetTranspilerPlugin = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isWidgetMethod(node)) return;
|
||||
context.widgets ??= [];
|
||||
const { widgets, options } = context;
|
||||
const { emitWidgets } = options;
|
||||
const type = node.callee.property.name;
|
||||
const idx = widgets.filter((w) => w.type === type).length;
|
||||
const widgetConfig = {
|
||||
from: node.start,
|
||||
to: node.end,
|
||||
index: idx,
|
||||
type,
|
||||
id: options.id,
|
||||
};
|
||||
emitWidgets && widgets.push(widgetConfig);
|
||||
return this.replace(widgetWithLocation(node, widgetConfig));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const sliderTranspilerPlugin = {
|
||||
walk: (context) => ({
|
||||
enter: function (node, parent, prop, index) {
|
||||
if (!isSliderFunction(node)) return;
|
||||
context.widgets ??= [];
|
||||
context.sliders ??= [];
|
||||
const { options, widgets, sliders } = context;
|
||||
const { emitWidgets } = options;
|
||||
const sliderConfig = {
|
||||
from: node.arguments[0].start,
|
||||
to: node.arguments[0].end,
|
||||
value: node.arguments[0].raw, // don't use value!
|
||||
min: node.arguments[1]?.value ?? 0,
|
||||
max: node.arguments[2]?.value ?? 1,
|
||||
step: node.arguments[3]?.value,
|
||||
type: 'slider',
|
||||
};
|
||||
emitWidgets && widgets.push(sliderConfig);
|
||||
sliders.push(sliderConfig);
|
||||
return this.replace(sliderWithLocation(node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const widgetTranspilerPlugins = [sliderTranspilerPlugin, widgetTranspilerPlugin];
|
||||
|
||||
// these functions are connected to @strudel/codemirror -> slider.mjs
|
||||
// maybe someday there will be pluggable transpiler functions, then move this there
|
||||
function isSliderFunction(node) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'slider';
|
||||
}
|
||||
|
||||
function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
function sliderWithLocation(node) {
|
||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
type: 'Literal',
|
||||
value: id,
|
||||
raw: id,
|
||||
});
|
||||
node.callee.name = 'sliderWithID';
|
||||
return node;
|
||||
}
|
||||
|
||||
export function getWidgetID(widgetConfig) {
|
||||
// the widget id is used as id for the dom element + as key for eventual resources
|
||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||
// that means, if we use the index index of line position as id, less garbage is generated
|
||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
const id = getWidgetID(widgetConfig);
|
||||
// Store the unique ID back into the config so it's available for widget management
|
||||
// This is critical for block-based evaluation to match existing widgets with new ones
|
||||
widgetConfig.id = id;
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
type: 'Literal',
|
||||
value: id,
|
||||
raw: id,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
registerTranspilerPlugin(widgetTranspilerPlugins);
|
||||
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transpiler } from '../transpiler.mjs';
|
||||
import { transpiler } from '../index.mjs';
|
||||
|
||||
const simple = { wrapAsync: false, addReturn: false, simpleLocs: true };
|
||||
|
||||
|
||||
+169
-461
@@ -1,13 +1,12 @@
|
||||
import { getLeafLocations } from '@strudel/mini';
|
||||
/*
|
||||
transpiler.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
|
||||
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 { parse } from 'acorn';
|
||||
import escodegen from 'escodegen';
|
||||
import { walk } from 'estree-walker';
|
||||
|
||||
let widgetMethods = [];
|
||||
export function registerWidgetType(type) {
|
||||
widgetMethods.push(type);
|
||||
}
|
||||
|
||||
let languages = new Map();
|
||||
// config = { getLocations: (code: string, offset?: number) => number[][] }
|
||||
// see mondough.mjs for example use
|
||||
@@ -17,9 +16,29 @@ let languages = new Map();
|
||||
export function registerLanguage(type, config) {
|
||||
languages.set(type, config);
|
||||
}
|
||||
export function getLanguages() {
|
||||
return languages;
|
||||
}
|
||||
|
||||
const plugins = [];
|
||||
|
||||
export function registerTranspilerPlugin(plugin) {
|
||||
plugins.push(plugin);
|
||||
}
|
||||
export function getPlugins() {
|
||||
return plugins.flat(Infinity);
|
||||
}
|
||||
|
||||
export function transpiler(input, options = {}) {
|
||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||
options = {
|
||||
wrapAsync: false,
|
||||
addReturn: true,
|
||||
emitMiniLocations: true,
|
||||
emitWidgets: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets } = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
@@ -29,169 +48,49 @@ export function transpiler(input, options = {}) {
|
||||
onComment: comments,
|
||||
});
|
||||
|
||||
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
let miniLocations = [];
|
||||
const collectMiniLocations = (value, node) => {
|
||||
const minilang = languages.get('minilang');
|
||||
if (minilang) {
|
||||
const code = `[${value}]`;
|
||||
const locs = minilang.getLocations(code, node.start);
|
||||
miniLocations = miniLocations.concat(locs);
|
||||
} else {
|
||||
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
|
||||
miniLocations = miniLocations.concat(leafLocs);
|
||||
}
|
||||
};
|
||||
let widgets = [];
|
||||
let miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
|
||||
let labels = [];
|
||||
|
||||
const context = { options, input, miniDisableRanges, labels };
|
||||
const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
|
||||
|
||||
walk(ast, {
|
||||
enter(node, parent /* , prop, index */) {
|
||||
if (isLanguageLiteral(node)) {
|
||||
const { name } = node.tag;
|
||||
const language = languages.get(name);
|
||||
const code = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const locs = language.getLocations(code, offset);
|
||||
miniLocations = miniLocations.concat(locs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(languageWithLocation(name, code, offset));
|
||||
}
|
||||
if (isTemplateLiteral(node, 'tidal')) {
|
||||
const raw = node.quasi.quasis[0].value.raw;
|
||||
const offset = node.quasi.start + 1;
|
||||
if (emitMiniLocations) {
|
||||
const stringLocs = collectHaskellMiniLocations(raw, offset);
|
||||
miniLocations = miniLocations.concat(stringLocs);
|
||||
}
|
||||
this.skip();
|
||||
return this.replace(tidalWithLocation(raw, offset));
|
||||
}
|
||||
if (isBackTickString(node, parent)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { quasis } = node;
|
||||
const { raw } = quasis[0].value;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(raw, node);
|
||||
return this.replace(miniWithLocation(raw, node));
|
||||
}
|
||||
if (isStringWithDoubleQuotes(node)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { value } = node;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(value, node);
|
||||
return this.replace(miniWithLocation(value, node));
|
||||
}
|
||||
if (isSliderFunction(node)) {
|
||||
emitWidgets &&
|
||||
widgets.push({
|
||||
from: node.arguments[0].start,
|
||||
to: node.arguments[0].end,
|
||||
value: node.arguments[0].raw, // don't use value!
|
||||
min: node.arguments[1]?.value ?? 0,
|
||||
max: node.arguments[2]?.value ?? 1,
|
||||
step: node.arguments[3]?.value,
|
||||
type: 'slider',
|
||||
});
|
||||
return this.replace(sliderWithLocation(node));
|
||||
}
|
||||
if (isWidgetMethod(node)) {
|
||||
const type = node.callee.property.name;
|
||||
const index = widgets.filter((w) => w.type === type).length;
|
||||
const widgetConfig = {
|
||||
to: node.end,
|
||||
index,
|
||||
type,
|
||||
id: options.id,
|
||||
};
|
||||
emitWidgets && widgets.push(widgetConfig);
|
||||
return this.replace(widgetWithLocation(node, widgetConfig));
|
||||
}
|
||||
if (isBareSamplesCall(node, parent)) {
|
||||
return this.replace(withAwait(node));
|
||||
enter(node, parent, prop, index) {
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin?.enter?.call(this, node, parent, prop, index)) continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLabelStatement(node)) {
|
||||
return this.replace(labelToP(node));
|
||||
}
|
||||
},
|
||||
|
||||
leave(node, parent, prop, index) {
|
||||
if (!isKabelCall(node)) return;
|
||||
|
||||
let [expr, ...rest] = node.arguments;
|
||||
if (!expr) throw new Error('K(...) requires an expression');
|
||||
|
||||
if (shouldCallKabelExpression(expr)) {
|
||||
expr = {
|
||||
type: 'CallExpression',
|
||||
callee: expr,
|
||||
arguments: [],
|
||||
optional: false,
|
||||
};
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin?.leave?.call(this, node, parent, prop, index)) continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const { template, patternExprs } = extractPatternPlaceholders(expr);
|
||||
if (patternExprs.length) {
|
||||
const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest];
|
||||
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') {
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: workletMemberAst(callee.object),
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: { type: 'Identifier', name: 'worklet' },
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
|
||||
const kabelSrc = genExprSource(expr);
|
||||
const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest];
|
||||
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') {
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: workletMemberAst(callee.object),
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
}
|
||||
return this.replace({
|
||||
type: 'CallExpression',
|
||||
callee: { type: 'Identifier', name: 'worklet' },
|
||||
arguments: workletArgs,
|
||||
optional: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let { body } = ast;
|
||||
|
||||
const silenceExpression = {
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'Identifier',
|
||||
name: 'silence',
|
||||
},
|
||||
};
|
||||
|
||||
if (!body.length) {
|
||||
console.warn('empty body -> fallback to silence');
|
||||
body.push({
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'Identifier',
|
||||
name: 'silence',
|
||||
},
|
||||
});
|
||||
body.push(silenceExpression);
|
||||
} else if (!body?.[body.length - 1]?.expression) {
|
||||
throw new Error('unexpected ast format without body expression');
|
||||
// Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
|
||||
body.push(silenceExpression);
|
||||
}
|
||||
|
||||
// add return to last statement
|
||||
@@ -209,256 +108,15 @@ export function transpiler(input, options = {}) {
|
||||
if (!emitMiniLocations) {
|
||||
return { output };
|
||||
}
|
||||
return { output, miniLocations, widgets };
|
||||
|
||||
let pluginContext;
|
||||
({ options, input, miniDisableRanges, ...pluginContext } = context);
|
||||
|
||||
return { output, ...pluginContext };
|
||||
}
|
||||
|
||||
function isKabelCall(node) {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
let callee = node.callee;
|
||||
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
|
||||
return callee.type === 'Identifier' && callee.name === 'K';
|
||||
}
|
||||
|
||||
function shouldCallKabelExpression(expr) {
|
||||
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
|
||||
return false;
|
||||
}
|
||||
if (expr.params.length) {
|
||||
return false;
|
||||
}
|
||||
return expr.body?.type === 'BlockStatement';
|
||||
}
|
||||
|
||||
function genExprSource(expr) {
|
||||
return escodegen.generate(expr, { format: { semicolons: false } });
|
||||
}
|
||||
|
||||
function extractPatternPlaceholders(expr) {
|
||||
const templateExpr = cloneNode(expr);
|
||||
const parents = new Map();
|
||||
const targets = [];
|
||||
|
||||
walk(templateExpr, {
|
||||
enter(node, parent, prop, index) {
|
||||
parents.set(node, { parent, prop, index });
|
||||
const patternExpr = getStrudelPatternExpr(node);
|
||||
if (patternExpr) {
|
||||
targets.push({ node, patternExpr });
|
||||
this.skip();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!targets.length) {
|
||||
return { template: genExprSource(templateExpr), patternExprs: [] };
|
||||
}
|
||||
|
||||
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
|
||||
|
||||
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
|
||||
|
||||
let currentExpr = templateExpr;
|
||||
targets.forEach(({ node }, index) => {
|
||||
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
|
||||
});
|
||||
|
||||
const template = genExprSource(currentExpr);
|
||||
return { template, patternExprs };
|
||||
}
|
||||
|
||||
function getStrudelPatternExpr(node) {
|
||||
if (isStrudelPatternWrap(node)) {
|
||||
const arg = node.arguments?.[0];
|
||||
if (!arg) {
|
||||
throw new Error('S(...) requires an argument');
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
if (isMiniCall(node)) {
|
||||
return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStrudelPatternWrap(node) {
|
||||
if (node.type !== 'CallExpression') {
|
||||
return false;
|
||||
}
|
||||
const callee = node.callee;
|
||||
if (callee.type === 'Identifier') {
|
||||
return callee.name === 'S';
|
||||
}
|
||||
if (callee.type === 'MemberExpression' && !callee.computed) {
|
||||
return callee.property?.name === 'S';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMinilangName() {
|
||||
const minilang = languages.get('minilang');
|
||||
return minilang?.name || 'm';
|
||||
}
|
||||
|
||||
// Used to identify transpiled `m(...)` calls for proper conversion
|
||||
// to, say, kabelsalat placeholders
|
||||
function isMiniCall(node) {
|
||||
if (node.type !== 'CallExpression') {
|
||||
return false;
|
||||
}
|
||||
const callee = node.callee;
|
||||
if (callee.type !== 'Identifier') {
|
||||
return false;
|
||||
}
|
||||
if (callee.name !== getMinilangName()) {
|
||||
return false;
|
||||
}
|
||||
const firstArg = node.arguments?.[0];
|
||||
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
|
||||
}
|
||||
|
||||
// If `start` is available, we use it. If it's already been transpiled
|
||||
// to `m(...)`, use the provided offset
|
||||
function getPatternNodeOrder(node) {
|
||||
if (typeof node.start === 'number') {
|
||||
return node.start;
|
||||
}
|
||||
if (isMiniCall(node)) {
|
||||
const offsetArg = node.arguments?.[1];
|
||||
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
|
||||
return offsetArg.value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function placeholderAst(index) {
|
||||
return {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'pat' },
|
||||
property: { type: 'Literal', value: index },
|
||||
computed: true,
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceNode(node, replacement, parents, currentRoot) {
|
||||
const info = parents.get(node);
|
||||
if (!info || !info.parent) {
|
||||
return replacement;
|
||||
}
|
||||
|
||||
const { parent, prop, index } = info;
|
||||
if (Array.isArray(parent[prop])) {
|
||||
parent[prop][index] = replacement;
|
||||
} else {
|
||||
parent[prop] = replacement;
|
||||
}
|
||||
parents.set(replacement, { parent, prop, index });
|
||||
return currentRoot;
|
||||
}
|
||||
|
||||
function cloneNode(node) {
|
||||
return JSON.parse(JSON.stringify(node));
|
||||
}
|
||||
|
||||
function workletMemberAst(objectExpr) {
|
||||
return {
|
||||
type: 'MemberExpression',
|
||||
object: objectExpr,
|
||||
property: { type: 'Identifier', name: 'worklet' },
|
||||
computed: false,
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isStringWithDoubleQuotes(node, locations, code) {
|
||||
if (node.type !== 'Literal') {
|
||||
return false;
|
||||
}
|
||||
return node.raw[0] === '"';
|
||||
}
|
||||
|
||||
function isBackTickString(node, parent) {
|
||||
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
|
||||
}
|
||||
|
||||
function miniWithLocation(value, node) {
|
||||
const { start: fromOffset } = node;
|
||||
|
||||
const minilang = languages.get('minilang');
|
||||
let name = 'm';
|
||||
if (minilang && minilang.name) {
|
||||
name = minilang.name; // name is expected to be exported from the package of the minilang
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name,
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: fromOffset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
// these functions are connected to @strudel/codemirror -> slider.mjs
|
||||
// maybe someday there will be pluggable transpiler functions, then move this there
|
||||
function isSliderFunction(node) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'slider';
|
||||
}
|
||||
|
||||
function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
function sliderWithLocation(node) {
|
||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
type: 'Literal',
|
||||
value: id,
|
||||
raw: id,
|
||||
});
|
||||
node.callee.name = 'sliderWithID';
|
||||
return node;
|
||||
}
|
||||
|
||||
export function getWidgetID(widgetConfig) {
|
||||
// the widget id is used as id for the dom element + as key for eventual resources
|
||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||
// that means, if we use the index index of line position as id, less garbage is generated
|
||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
const id = getWidgetID(widgetConfig);
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
type: 'Literal',
|
||||
value: id,
|
||||
raw: id,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function isBareSamplesCall(node, parent) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
||||
}
|
||||
|
||||
function withAwait(node) {
|
||||
return {
|
||||
type: 'AwaitExpression',
|
||||
argument: node,
|
||||
};
|
||||
function isAllCall(node) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'all';
|
||||
}
|
||||
|
||||
function isLabelStatement(node) {
|
||||
@@ -491,67 +149,126 @@ function labelToP(node) {
|
||||
};
|
||||
}
|
||||
|
||||
function isLanguageLiteral(node) {
|
||||
return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name);
|
||||
// List of non-inline widgets that need cleanup
|
||||
// These are Pattern.prototype methods that create persistent visualizations
|
||||
// (should be repalced by a function call producing an actual list of registered widgets)
|
||||
const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
|
||||
|
||||
function isVisualizerCall(node) {
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
nonInlineWidgets.includes(node.callee.property?.name)
|
||||
) {
|
||||
return node.callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// tidal highlighting
|
||||
// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs)
|
||||
// but maybe it's the only way
|
||||
function findVisualizerInSubtree(node) {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
function isTemplateLiteral(node, value) {
|
||||
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
|
||||
}
|
||||
// Check if this node is a visualizer call
|
||||
const viz = isVisualizerCall(node);
|
||||
if (viz) return viz;
|
||||
|
||||
function collectHaskellMiniLocations(haskellCode, offset) {
|
||||
return haskellCode
|
||||
.split('')
|
||||
.reduce((acc, char, i) => {
|
||||
if (char !== '"') {
|
||||
return acc;
|
||||
// Recursively search children
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'parent') continue; // Skip parent references to avoid cycles
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
const found = findVisualizerInSubtree(item);
|
||||
if (found) return found;
|
||||
}
|
||||
if (!acc.length || acc[acc.length - 1].length > 1) {
|
||||
acc.push([i + 1]);
|
||||
} else {
|
||||
acc[acc.length - 1].push(i);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.map(([start, end]) => {
|
||||
const miniString = haskellCode.slice(start, end);
|
||||
return getLeafLocations(`"${miniString}"`, offset + start - 1);
|
||||
})
|
||||
.flat();
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
const found = findVisualizerInSubtree(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tidalWithLocation(value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: 'tidal',
|
||||
// Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name;
|
||||
// Used in block-based evaluation to persist variables/functions across blocks
|
||||
// We add to both strudelScope (for internal lookups) and globalThis (for direct access)
|
||||
// We also track the key in userDefinedKeys so clearScope() can remove it later
|
||||
function createScopeAssignment(name) {
|
||||
return [
|
||||
// userDefinedKeys.add('name');
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'userDefinedKeys',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: 'add',
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
arguments: [
|
||||
{
|
||||
type: 'Literal',
|
||||
value: name,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function languageWithLocation(name, value, offset) {
|
||||
return {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
// strudelScope.name = name;
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'AssignmentExpression',
|
||||
operator: '=',
|
||||
left: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'strudelScope',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
right: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
arguments: [
|
||||
{ type: 'Literal', value },
|
||||
{ type: 'Literal', value: offset },
|
||||
],
|
||||
optional: false,
|
||||
};
|
||||
// globalThis.name = name;
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'AssignmentExpression',
|
||||
operator: '=',
|
||||
left: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'globalThis',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
right: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function findMiniDisableRanges(comments, codeEnd) {
|
||||
@@ -573,12 +290,3 @@ function findMiniDisableRanges(comments, codeEnd) {
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isMiniDisabled(offset, miniDisableRanges) {
|
||||
for (const [start, end] of miniDisableRanges) {
|
||||
if (offset >= start && offset < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/edo": "workspace:*",
|
||||
"@strudel/mini": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
|
||||
@@ -3,9 +3,10 @@ export * from '@strudel/webaudio';
|
||||
//export * from '@strudel/soundfonts';
|
||||
export * from '@strudel/transpiler';
|
||||
export * from '@strudel/mini';
|
||||
export * from '@strudel/edo';
|
||||
export * from '@strudel/tonal';
|
||||
export * from '@strudel/webaudio';
|
||||
import { Pattern, evalScope, setTime } from '@strudel/core';
|
||||
import { Pattern, evalScope, setTime, setClockTime } from '@strudel/core';
|
||||
import { initAudioOnFirstClick, registerSynthSounds, webaudioRepl } from '@strudel/webaudio';
|
||||
// import { registerSoundfonts } from '@strudel/soundfonts';
|
||||
import { evaluate as _evaluate, transpiler } from '@strudel/transpiler';
|
||||
@@ -17,6 +18,7 @@ export async function defaultPrebake() {
|
||||
evalScope,
|
||||
import('@strudel/core'),
|
||||
import('@strudel/mini'),
|
||||
import('@strudel/edo'),
|
||||
import('@strudel/tonal'),
|
||||
import('@strudel/webaudio'),
|
||||
{ hush, evaluate },
|
||||
@@ -39,6 +41,7 @@ export function initStrudel(options = {}) {
|
||||
return repl;
|
||||
})();
|
||||
setTime(() => repl.scheduler.now());
|
||||
setClockTime(() => repl.scheduler.getTime()); // TODO: refactor?
|
||||
return initDone;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ initAudioOnFirstClick();
|
||||
const ctx = getAudioContext();
|
||||
|
||||
const { scheduler } = repl({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => ctx.currentTime
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const soundMap = new Map();
|
||||
const loadedSounds = new Map();
|
||||
|
||||
Pattern.prototype.supradough = function () {
|
||||
return this.onTrigger((hap, __, cps, begin) => {
|
||||
return this.onSchedule((hap, begin, cps) => {
|
||||
hap.value._begin = begin;
|
||||
hap.value._duration = hap.duration / cps;
|
||||
!doughWorklet && initDoughWorklet();
|
||||
@@ -61,7 +61,7 @@ function githubPath(base, subpath = '') {
|
||||
}
|
||||
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||
}
|
||||
export async function fetchSampleMap(url) {
|
||||
export async function _fetchSampleMap(url) {
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
@@ -114,11 +114,11 @@ async function fetchSample(url) {
|
||||
return { channels, sampleRate: buffer.sampleRate };
|
||||
}
|
||||
|
||||
export async function doughsamples(sampleMap, baseUrl) {
|
||||
export async function supradoughsamples(sampleMap, baseUrl) {
|
||||
if (typeof sampleMap === 'string') {
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
const [json, base] = await _fetchSampleMap(sampleMap);
|
||||
// console.log('json', json, 'base', base);
|
||||
return doughsamples(json, base);
|
||||
return supradoughsamples(json, base);
|
||||
}
|
||||
Object.entries(sampleMap).map(async ([key, urls]) => {
|
||||
if (key !== '_base') {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
superdough,
|
||||
getAudioContext,
|
||||
setLogger,
|
||||
doughTrigger,
|
||||
rawdspOutput,
|
||||
registerWorklet,
|
||||
setAudioContext,
|
||||
initAudio,
|
||||
@@ -32,8 +32,8 @@ const hap2value = (hap) => {
|
||||
};
|
||||
|
||||
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
|
||||
// TODO: refactor output callbacks to eliminate deadline
|
||||
export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
export const webaudioOutput = (hap, t, cps) => {
|
||||
const hapDuration = hap.duration / cps;
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
@@ -56,32 +56,14 @@ export async function renderPatternAudio(
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
logger('[webaudio] preloading');
|
||||
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(begin, end, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext
|
||||
.startRendering()
|
||||
// Firefox currently doesn't support suspending an OfflineAudioContext,
|
||||
// so no chunked rendering. Bad performance, but at least it works.
|
||||
return (
|
||||
audioContext.suspend === undefined
|
||||
? renderPatternAudioWhole(audioContext, pattern, cps, begin, end)
|
||||
: renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1)
|
||||
)
|
||||
.then((renderedBuffer) => {
|
||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
@@ -102,19 +84,97 @@ export async function renderPatternAudio(
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) {
|
||||
logger(`[webaudio] preloading`);
|
||||
|
||||
await scheduleHapsChunk(pattern, cps, begin, begin, end);
|
||||
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext.startRendering();
|
||||
}
|
||||
|
||||
async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) {
|
||||
let currentCycle = begin;
|
||||
let renderPromise = null;
|
||||
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
while (currentCycle <= end) {
|
||||
const chunkStart = currentCycle;
|
||||
const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end);
|
||||
|
||||
logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`);
|
||||
|
||||
await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd);
|
||||
|
||||
logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`);
|
||||
|
||||
currentCycle += chunkSizeInCycles;
|
||||
|
||||
// According to the MDN docs, suspends should be scheduled while
|
||||
// the audioContext is not currently running for better precision.
|
||||
// So we schedule the suspend first, and await after resuming.
|
||||
var suspendPromise;
|
||||
if (currentCycle < end) {
|
||||
// Make sure to suspend one cycle before the next currentCycle
|
||||
// so the next haps can be scheduled on time.
|
||||
suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps);
|
||||
}
|
||||
|
||||
if (renderPromise === null) {
|
||||
renderPromise = audioContext.startRendering();
|
||||
} else {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
if (currentCycle < end) {
|
||||
await suspendPromise;
|
||||
}
|
||||
}
|
||||
|
||||
logger('[webaudio] finish rendering');
|
||||
|
||||
return renderPromise;
|
||||
}
|
||||
|
||||
async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) {
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(chunkStart, chunkEnd, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
const audioContext = options.audioContext ?? getAudioContext();
|
||||
setAudioContext(audioContext);
|
||||
options = {
|
||||
getTime: () => audioContext.currentTime,
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
...options,
|
||||
};
|
||||
return repl(options);
|
||||
}
|
||||
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
Pattern.prototype.rawdsp = function () {
|
||||
return this.onSchedule(rawdspOutput, 1);
|
||||
};
|
||||
|
||||
function audioBufferToWav(buffer, opt) {
|
||||
|
||||
+41
-1
@@ -7,14 +7,54 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
import Tune from './tunejs.js';
|
||||
import { register } from '@strudel/core';
|
||||
|
||||
/**
|
||||
* Deprecated.
|
||||
*
|
||||
* Assumes pattern contains numerical scale degrees on the `i` control (see examples below). Accepts a scale name or list of frequencies (see all available names at the link on the reference). Returns a new pattern with all values mapped to a frequency ratio. Similar to `xen`.
|
||||
* @name tune
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @param {(string | number[] )} scale
|
||||
* @deprecated
|
||||
* @example
|
||||
* i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()
|
||||
* @example
|
||||
* // You can set your root to be a
|
||||
* // particular note with getFreq:
|
||||
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
|
||||
* .mul(getFreq('c3'))
|
||||
* .freq().clip(.5).room(1)
|
||||
* @example
|
||||
* // You can also give tune a list of
|
||||
* // frequencies to use as the scale:
|
||||
* i("0 1 2 3 4").tune([
|
||||
* 261.6255653006,
|
||||
* 302.72962012827,
|
||||
* 350.29154279212,
|
||||
* 405.32593044476,
|
||||
* 469.00678383895,
|
||||
* 523.2511306012
|
||||
* ]).mul(220).freq();
|
||||
*
|
||||
* @tags tonal
|
||||
*/
|
||||
|
||||
// Tune.scale seems to be in ratio format
|
||||
export const tune = register('tune', (scale, pat) => {
|
||||
const tune = new Tune();
|
||||
if (!tune.isValidScale(scale)) {
|
||||
throw new Error('not a valid tune.js scale name: "' + scale + '". See http://abbernie.github.io/tune/scales.html');
|
||||
}
|
||||
tune.loadScale(scale);
|
||||
// if the tonic is a frequency, why are we putting in "1"
|
||||
tune.tonicize(1);
|
||||
return pat.withHap((hap) => {
|
||||
return hap.withValue(() => tune.note(hap.value));
|
||||
if (typeof hap.value !== 'object') {
|
||||
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
|
||||
}
|
||||
// const { i, ...otherValues } = hap.value;
|
||||
// hap.value = { ...otherValues, freq: tune.note(i)}
|
||||
// return hap
|
||||
return hap.withValue(() => tune.note(hap.value.i));
|
||||
});
|
||||
});
|
||||
|
||||
+14
-4
@@ -78,14 +78,21 @@ Tune.prototype.frequency = function(stepIn, octaveIn) {
|
||||
}
|
||||
|
||||
// which scale degree (0 - scale length) is our input
|
||||
// 60 % 12 = 0
|
||||
var scaleDegree = stepIn % this.scale.length
|
||||
|
||||
// what's this doing
|
||||
// 0 scaleDegree = 12
|
||||
// seems to simply be for a negative result from above, maybe another way to do it, but ok for now
|
||||
while (scaleDegree < 0) {
|
||||
scaleDegree += this.scale.length
|
||||
}
|
||||
|
||||
// tonic is currently always 1
|
||||
// so this is 1*scale[scaledegree]
|
||||
var freq = this.tonic*this.scale[scaleDegree]
|
||||
|
||||
// map it to octave
|
||||
freq = freq*(Math.pow(2,octave))
|
||||
|
||||
// truncate irrational numbers
|
||||
@@ -137,10 +144,13 @@ Tune.prototype.MIDI = function(stepIn,octaveIn) {
|
||||
}
|
||||
|
||||
/* Load a new scale */
|
||||
// sets .scale to ratios
|
||||
|
||||
Tune.prototype.loadScale = function(scale){
|
||||
|
||||
/* load the scale */
|
||||
let name
|
||||
if (typeof scale === 'string') name = scale;
|
||||
var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies
|
||||
this.scale = []
|
||||
for (var i=0;i<freqs.length-1;i++) {
|
||||
@@ -148,10 +158,10 @@ Tune.prototype.loadScale = function(scale){
|
||||
}
|
||||
|
||||
/* visualize in console */
|
||||
/* console.log(" ");
|
||||
console.log("LOADED "+name);
|
||||
console.log(TuningList[name].description);
|
||||
console.log(this.scale); */
|
||||
// console.log(" ");
|
||||
// console.log("LOADED "+name);
|
||||
// console.log(TuningList[name].description);
|
||||
// console.log(this.scale);
|
||||
var vis = [];
|
||||
for (var i=0;i<100;i++) {
|
||||
vis[i] = " ";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user