Merge branch 'tidalcycles:main' into file_import

This commit is contained in:
Jade (Rose) Rowland 2023-12-10 11:28:50 -05:00 committed by GitHub
commit 6238be7f2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1131 additions and 607 deletions

View file

@ -21,10 +21,10 @@ jobs:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 7 version: 8.11.0
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18

View file

@ -10,10 +10,10 @@ jobs:
node-version: [18] node-version: [18]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 7 version: 8.11.0
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}

85
.gitignore vendored
View file

@ -43,4 +43,87 @@ dev-dist
Dirt-Samples Dirt-Samples
tidal-drum-machines tidal-drum-machines
webaudiofontdata webaudiofontdata
src-tauri/target src-tauri/target
# BEGIN JetBrains -> END JetBrains
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# END JetBrains -> BEGIN JetBrains

View file

@ -66,7 +66,7 @@ To get the project up and running for development, make sure you have installed:
- [git](https://git-scm.com/) - [git](https://git-scm.com/)
- [node](https://nodejs.org/en/) >= 18 - [node](https://nodejs.org/en/) >= 18
- [pnpm](https://pnpm.io/) (`npm i pnpm -g`) - [pnpm](https://pnpm.io/) (`curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION=8.11.0 sh -`)
then, do the following: then, do the following:

View file

@ -381,6 +381,19 @@ const generic_params = [
*/ */
['coarse'], ['coarse'],
/**
* Allows you to set the output channels on the interface
*
* @name channels
* @synonyms ch
*
* @param {number | Pattern} channels pattern the output channels
* @example
* note("e a d b g").channels("3:4")
*
*/
['channels', 'ch'],
['phaserrate', 'phasr'], // superdirt only ['phaserrate', 'phasr'], // superdirt only
/** /**

View file

@ -1985,7 +1985,7 @@ Pattern.prototype.hush = function () {
* note("c d e g").palindrome() * note("c d e g").palindrome()
*/ */
export const palindrome = register('palindrome', function (pat) { export const palindrome = register('palindrome', function (pat) {
return pat.every(2, rev); return pat.lastOf(2, rev);
}); });
/** /**

View file

@ -20,6 +20,7 @@ import {
slowcat, slowcat,
cat, cat,
sequence, sequence,
palindrome,
polymeter, polymeter,
polymeterSteps, polymeterSteps,
polyrhythm, polyrhythm,
@ -571,6 +572,18 @@ describe('Pattern', () => {
expect(sequence(1, 2, 3).firstCycle()).toStrictEqual(fastcat(1, 2, 3).firstCycle()); expect(sequence(1, 2, 3).firstCycle()).toStrictEqual(fastcat(1, 2, 3).firstCycle());
}); });
}); });
describe('palindrome()', () => {
it('Can create palindrome', () => {
expect(
fastcat('a', 'b', 'c')
.palindrome()
.fast(2)
.firstCycle()
.sort((a, b) => a.part.begin.sub(b.part.begin))
.map((a) => a.value),
).toStrictEqual(['a', 'b', 'c', 'c', 'b', 'a']);
});
});
describe('polyrhythm()', () => { describe('polyrhythm()', () => {
it('Can layer up cycles', () => { it('Can layer up cycles', () => {
expect(polyrhythm(['a', 'b'], ['c']).firstCycle()).toStrictEqual( expect(polyrhythm(['a', 'b'], ['c']).firstCycle()).toStrictEqual(

View file

@ -74,8 +74,11 @@ function useStrudel({
} }
}); });
const activateCode = useCallback( const activateCode = useCallback(
async (autostart = true) => { async (newCode, autostart = true) => {
const res = await evaluate(code, autostart); if (newCode) {
setCode(code);
}
const res = await evaluate(newCode || code, autostart);
broadcast({ type: 'start', from: id }); broadcast({ type: 'start', from: id });
return res; return res;
}, },

View file

@ -1,4 +1,4 @@
import { noteToMidi, valueToMidi } from './util.mjs'; import { noteToMidi, valueToMidi, nanFallback } from './util.mjs';
import { getAudioContext, registerSound } from './index.mjs'; import { getAudioContext, registerSound } from './index.mjs';
import { getEnvelope } from './helpers.mjs'; import { getEnvelope } from './helpers.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
@ -33,6 +33,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol
const ac = getAudioContext(); const ac = getAudioContext();
let sampleUrl; let sampleUrl;
if (Array.isArray(bank)) { if (Array.isArray(bank)) {
n = nanFallback(n, 0);
sampleUrl = bank[n % bank.length]; sampleUrl = bank[n % bank.length];
} else { } else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi; const midiDiff = (noteA) => noteToMidi(noteA) - midi;

View file

@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs'; import './feedbackdelay.mjs';
import './reverb.mjs'; import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { clamp } from './util.mjs'; import { clamp, nanFallback } from './util.mjs';
import workletsUrl from './worklets.mjs?url'; import workletsUrl from './worklets.mjs?url';
import { createFilter, gainNode, getCompressor } from './helpers.mjs'; import { createFilter, gainNode, getCompressor } from './helpers.mjs';
import { map } from 'nanostores'; import { map } from 'nanostores';
@ -27,28 +27,16 @@ export function getSound(s) {
export const resetLoadedSounds = () => soundMap.set({}); export const resetLoadedSounds = () => soundMap.set({});
let audioContext; let audioContext;
export const getAudioContext = () => { export const getAudioContext = () => {
if (!audioContext) { if (!audioContext) {
audioContext = new AudioContext(); audioContext = new AudioContext();
const maxChannelCount = audioContext.destination.maxChannelCount;
audioContext.destination.channelCount = maxChannelCount;
} }
return audioContext; return audioContext;
}; };
let destination;
const getDestination = () => {
const ctx = getAudioContext();
if (!destination) {
destination = ctx.createGain();
destination.connect(ctx.destination);
}
return destination;
};
export const panic = () => {
getDestination().gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01);
destination = null;
};
let workletsLoading; let workletsLoading;
function loadWorklets() { function loadWorklets() {
@ -95,6 +83,39 @@ export async function initAudioOnFirstClick(options) {
let delays = {}; let delays = {};
const maxfeedback = 0.98; const maxfeedback = 0.98;
let channelMerger, destinationGain;
// input: AudioNode, channels: ?Array<int>
export const connectToDestination = (input, channels = [0, 1]) => {
const ctx = getAudioContext();
if (channelMerger == null) {
channelMerger = new ChannelMergerNode(ctx, { numberOfInputs: ctx.destination.channelCount });
destinationGain = new GainNode(ctx);
channelMerger.connect(destinationGain);
destinationGain.connect(ctx.destination);
}
//This upmix can be removed if correct channel counts are set throughout the app,
// and then strudel could theoretically support surround sound audio files
const stereoMix = new StereoPannerNode(ctx);
input.connect(stereoMix);
const splitter = new ChannelSplitterNode(ctx, {
numberOfOutputs: stereoMix.channelCount,
});
stereoMix.connect(splitter);
channels.forEach((ch, i) => {
splitter.connect(channelMerger, i % stereoMix.channelCount, clamp(ch, 0, ctx.destination.channelCount - 1));
});
};
export const panic = () => {
if (destinationGain == null) {
return;
}
destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01);
destinationGain = null;
};
function getDelay(orbit, delaytime, delayfeedback, t) { function getDelay(orbit, delaytime, delayfeedback, t) {
if (delayfeedback > maxfeedback) { if (delayfeedback > maxfeedback) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); //logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
@ -104,7 +125,7 @@ function getDelay(orbit, delaytime, delayfeedback, t) {
const ac = getAudioContext(); const ac = getAudioContext();
const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback); const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback);
dly.start?.(t); // for some reason, this throws when audion extension is installed.. dly.start?.(t); // for some reason, this throws when audion extension is installed..
dly.connect(getDestination()); connectToDestination(dly, [0, 1]);
delays[orbit] = dly; delays[orbit] = dly;
} }
delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t); delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t);
@ -163,7 +184,7 @@ function getReverb(orbit, duration, fade, lp, dim, ir) {
if (!reverbs[orbit]) { if (!reverbs[orbit]) {
const ac = getAudioContext(); const ac = getAudioContext();
const reverb = ac.createReverb(duration, fade, lp, dim, ir); const reverb = ac.createReverb(duration, fade, lp, dim, ir);
reverb.connect(getDestination()); connectToDestination(reverb, [0, 1]);
reverbs[orbit] = reverb; reverbs[orbit] = reverb;
} }
if ( if (
@ -269,7 +290,7 @@ export const superdough = async (value, deadline, hapDuration) => {
bpsustain = 1, bpsustain = 1,
bprelease = 0.01, bprelease = 0.01,
bandq = 1, bandq = 1,
channels = [1, 2],
//phaser //phaser
phaser, phaser,
phaserdepth = 0.75, phaserdepth = 0.75,
@ -301,6 +322,11 @@ export const superdough = async (value, deadline, hapDuration) => {
compressorAttack, compressorAttack,
compressorRelease, compressorRelease,
} = value; } = value;
gain = nanFallback(gain, 1);
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
gain *= velocity; // legacy fix for velocity gain *= velocity; // legacy fix for velocity
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended let toDisconnect = []; // audio nodes that will be disconnected when the source has ended
const onended = () => { const onended = () => {
@ -434,9 +460,9 @@ export const superdough = async (value, deadline, hapDuration) => {
} }
// last gain // last gain
const post = gainNode(postgain); const post = new GainNode(ac, { gain: postgain });
chain.push(post); chain.push(post);
post.connect(getDestination()); connectToDestination(post, channels);
// delay // delay
let delaySend; let delaySend;

View file

@ -1,3 +1,5 @@
import { logger } from './logger.mjs';
// currently duplicate with core util.mjs to skip dependency // currently duplicate with core util.mjs to skip dependency
// TODO: add separate util module? // TODO: add separate util module?
@ -51,3 +53,11 @@ export const valueToMidi = (value, fallbackValue) => {
} }
return fallbackValue; return fallbackValue;
}; };
export function nanFallback(value, fallback) {
if (isNaN(Number(value))) {
logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
return fallback;
}
return value;
}

View file

@ -1038,6 +1038,31 @@ exports[`runs examples > example "ceil" example index 0 1`] = `
] ]
`; `;
exports[`runs examples > example "channels" example index 0 1`] = `
[
"[ 0/1 → 1/5 | note:e channels:[3 4] ]",
"[ 1/5 → 2/5 | note:a channels:[3 4] ]",
"[ 2/5 → 3/5 | note:d channels:[3 4] ]",
"[ 3/5 → 4/5 | note:b channels:[3 4] ]",
"[ 4/5 → 1/1 | note:g channels:[3 4] ]",
"[ 1/1 → 6/5 | note:e channels:[3 4] ]",
"[ 6/5 → 7/5 | note:a channels:[3 4] ]",
"[ 7/5 → 8/5 | note:d channels:[3 4] ]",
"[ 8/5 → 9/5 | note:b channels:[3 4] ]",
"[ 9/5 → 2/1 | note:g channels:[3 4] ]",
"[ 2/1 → 11/5 | note:e channels:[3 4] ]",
"[ 11/5 → 12/5 | note:a channels:[3 4] ]",
"[ 12/5 → 13/5 | note:d channels:[3 4] ]",
"[ 13/5 → 14/5 | note:b channels:[3 4] ]",
"[ 14/5 → 3/1 | note:g channels:[3 4] ]",
"[ 3/1 → 16/5 | note:e channels:[3 4] ]",
"[ 16/5 → 17/5 | note:a channels:[3 4] ]",
"[ 17/5 → 18/5 | note:d channels:[3 4] ]",
"[ 18/5 → 19/5 | note:b channels:[3 4] ]",
"[ 19/5 → 4/1 | note:g channels:[3 4] ]",
]
`;
exports[`runs examples > example "chooseCycles" example index 0 1`] = ` exports[`runs examples > example "chooseCycles" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | s:bd ]", "[ 0/1 → 1/4 | s:bd ]",
@ -3267,22 +3292,22 @@ exports[`runs examples > example "outside" example index 0 1`] = `
exports[`runs examples > example "palindrome" example index 0 1`] = ` exports[`runs examples > example "palindrome" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | note:g ]", "[ 0/1 → 1/4 | note:c ]",
"[ 1/4 → 1/2 | note:e ]", "[ 1/4 → 1/2 | note:d ]",
"[ 1/2 → 3/4 | note:d ]", "[ 1/2 → 3/4 | note:e ]",
"[ 3/4 → 1/1 | note:c ]", "[ 3/4 → 1/1 | note:g ]",
"[ 1/1 → 5/4 | note:c ]", "[ 1/1 → 5/4 | note:g ]",
"[ 5/4 → 3/2 | note:d ]", "[ 5/4 → 3/2 | note:e ]",
"[ 3/2 → 7/4 | note:e ]", "[ 3/2 → 7/4 | note:d ]",
"[ 7/4 → 2/1 | note:g ]", "[ 7/4 → 2/1 | note:c ]",
"[ 2/1 → 9/4 | note:g ]", "[ 2/1 → 9/4 | note:c ]",
"[ 9/4 → 5/2 | note:e ]", "[ 9/4 → 5/2 | note:d ]",
"[ 5/2 → 11/4 | note:d ]", "[ 5/2 → 11/4 | note:e ]",
"[ 11/4 → 3/1 | note:c ]", "[ 11/4 → 3/1 | note:g ]",
"[ 3/1 → 13/4 | note:c ]", "[ 3/1 → 13/4 | note:g ]",
"[ 13/4 → 7/2 | note:d ]", "[ 13/4 → 7/2 | note:e ]",
"[ 7/2 → 15/4 | note:e ]", "[ 7/2 → 15/4 | note:d ]",
"[ 15/4 → 4/1 | note:g ]", "[ 15/4 → 4/1 | note:c ]",
] ]
`; `;

View file

@ -8372,16 +8372,16 @@ exports[`renders tunes > tune: hyperpop 1`] = `
exports[`renders tunes > tune: juxUndTollerei 1`] = ` exports[`renders tunes > tune: juxUndTollerei 1`] = `
[ [
"[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:0 cutoff:1188.2154262966046 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:c3 s:sawtooth pan:0 cutoff:1188.2154262966046 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 0/1 → 1/4 | note:c3 s:sawtooth pan:1 cutoff:1188.2154262966046 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:1 cutoff:1188.2154262966046 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/4 → 1/2 | note:g3 s:sawtooth pan:0 cutoff:1361.2562095290161 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:0 cutoff:1361.2562095290161 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:1 cutoff:1361.2562095290161 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:g3 s:sawtooth pan:1 cutoff:1361.2562095290161 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/2 → 3/4 | note:eb3 s:sawtooth pan:0 cutoff:1524.257063143398 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/2 → 3/4 | note:g3 s:sawtooth pan:0 cutoff:1524.257063143398 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 1/2 → 3/4 | note:g3 s:sawtooth pan:1 cutoff:1524.257063143398 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/2 → 3/4 | note:eb3 s:sawtooth pan:1 cutoff:1524.257063143398 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ (101/200 → 1/1) ⇝ 201/200 | note:65 s:triangle pan:0 cutoff:1601.4815730092653 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ (101/200 → 1/1) ⇝ 201/200 | note:55 s:triangle pan:0 cutoff:1601.4815730092653 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ (101/200 → 1/1) ⇝ 201/200 | note:55 s:triangle pan:1 cutoff:1601.4815730092653 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ (101/200 → 1/1) ⇝ 201/200 | note:65 s:triangle pan:1 cutoff:1601.4815730092653 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 3/4 → 1/1 | note:c3 s:sawtooth pan:0 cutoff:1670.953955747281 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 3/4 → 1/1 | note:bb3 s:sawtooth pan:0 cutoff:1670.953955747281 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
"[ 3/4 → 1/1 | note:bb3 s:sawtooth pan:1 cutoff:1670.953955747281 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 3/4 → 1/1 | note:c3 s:sawtooth pan:1 cutoff:1670.953955747281 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
] ]
`; `;

View file

@ -13,24 +13,39 @@ import AstroPWA from '@vite-pwa/astro';
const site = `https://strudel.cc/`; // root url without a path const site = `https://strudel.cc/`; // root url without a path
const base = '/'; // base path of the strudel site const base = '/'; // base path of the strudel site
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
// this rehype plugin converts relative anchor links to absolute ones // this rehype plugin fixes relative links
// it wokrs by prepending the absolute page path to anchor links // it works by prepending the base + page path to anchor links
// example: #gain -> /learn/effects/#gain // and by prepending the base path to other relative links starting with /
// this is necessary when using a base href like <base href={base} /> // this is necessary when using a base href like <base href={base} />
// in this setup, relative anchor links will always link to base, instead of the current page // examples with base as "mybase":
function absoluteAnchors() { // #gain -> /mybase/learn/effects/#gain
// /some/page -> /mybase/some/page
function relativeURLFix() {
return (tree, file) => { return (tree, file) => {
const chunks = file.history[0].split('/src/pages/'); // file.history[0] is the file path const chunks = file.history[0].split('/src/pages/'); // file.history[0] is the file path
const path = chunks[chunks.length - 1].slice(0, -4); // only path inside src/pages, without .mdx const path = chunks[chunks.length - 1].slice(0, -4); // only path inside src/pages, without .mdx
return rehypeUrls((url) => { return rehypeUrls((url) => {
if (!url.href.startsWith('#')) { let newHref = baseNoTrailing;
if (url.href.startsWith('#')) {
// special case: a relative anchor link to the current page
newHref += `/${path}/${url.href}`;
} else if (url.href.startsWith('/')) {
// any other relative url starting with /
newHref += url.pathname;
if (url.pathname.indexOf('.') == -1) {
// append trailing slash to resource only if there is no file extension
newHref += url.pathname.endsWith('/') ? '' : '/';
}
newHref += url.search || '';
newHref += url.hash || '';
} else {
// leave this URL alone
return; return;
} }
const baseWithSlash = base.endsWith('/') ? base : base + '/'; // console.log(url.href + ' -> ', newHref);
const absoluteUrl = baseWithSlash + path + url.href; return newHref;
// console.log(url.href + ' -> ', absoluteUrl);
return absoluteUrl;
})(tree); })(tree);
}; };
} }
@ -40,7 +55,7 @@ const options = {
remarkToc, remarkToc,
// E.g. `remark-frontmatter` // E.g. `remark-frontmatter`
], ],
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'append' }], absoluteAnchors], rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'append' }], relativeURLFix],
}; };
// https://astro.build/config // https://astro.build/config

View file

@ -3,7 +3,7 @@ import { pwaInfo } from 'virtual:pwa-info';
import '../styles/index.css'; import '../styles/index.css';
const { BASE_URL } = import.meta.env; const { BASE_URL } = import.meta.env;
const base = BASE_URL; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
--- ---
<!-- Global Metadata --> <!-- Global Metadata -->
@ -11,20 +11,20 @@ const base = BASE_URL;
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href="favicon.ico" /> <link rel="icon" type="image/svg+xml" href={`${baseNoTrailing}/favicon.ico`} />
<meta <meta
name="description" name="description"
content="Strudel is a music live coding environment for the browser, porting the TidalCycles pattern language to JavaScript." content="Strudel is a music live coding environment for the browser, porting the TidalCycles pattern language to JavaScript."
/> />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href={`${baseNoTrailing}/favicon.ico`} />
<link rel="apple-touch-icon" href="/icons/apple-icon-180.png" sizes="180x180" /> <link rel="apple-touch-icon" href={`${baseNoTrailing}/icons/apple-icon-180.png`} sizes="180x180" />
<meta name="theme-color" content="#222222" /> <meta name="theme-color" content="#222222" />
<base href={base} /> <base href={BASE_URL} />
<!-- Scrollable a11y code helper --> <!-- Scrollable a11y code helper -->
<script src="./make-scrollable-code-focusable.js" is:inline></script> <script src{`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
<script src="/src/pwa.ts"></script> <script src="/src/pwa.ts"></script>
<!-- this does not work for some reason: --> <!-- this does not work for some reason: -->

View file

@ -16,6 +16,9 @@ const { currentPage } = Astro.props as Props;
// const lang = getLanguageFromURL(currentPage); // const lang = getLanguageFromURL(currentPage);
const langCode = 'en'; // getLanguageFromURL(currentPage); const langCode = 'en'; // getLanguageFromURL(currentPage);
const sidebar = SIDEBAR[langCode]; const sidebar = SIDEBAR[langCode];
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
--- ---
<nav <nav
@ -23,7 +26,7 @@ const sidebar = SIDEBAR[langCode];
title="Top Navigation" title="Top Navigation"
> >
<div class="flex overflow-visible items-center grow" style="overflow:visible"> <div class="flex overflow-visible items-center grow" style="overflow:visible">
<a href="/" class="flex items-center text-2xl space-x-2"> <a href={`${baseNoTrailing}/`} class="flex items-center text-2xl space-x-2">
<h1 class="font-bold flex space-x-2 items-baseline text-xl"> <h1 class="font-bold flex space-x-2 items-baseline text-xl">
<span>🌀</span> <span>🌀</span>
<div class="flex space-x-1 items-baseline"> <div class="flex space-x-1 items-baseline">
@ -37,7 +40,7 @@ const sidebar = SIDEBAR[langCode];
<div class="search-item h-10"> <div class="search-item h-10">
<Search client:idle /> <Search client:idle />
</div> </div>
<a href="./" class="hidden md:flex cursor-pointer items-center space-x-1" <a href={`${baseNoTrailing}/`} class="hidden md:flex cursor-pointer items-center space-x-1"
><CommandLineIcon className="w-5 h-5" /><span>go to REPL</span> ><CommandLineIcon className="w-5 h-5" /><span>go to REPL</span>
</a> </a>
<div class="md:hidden"> <div class="md:hidden">

View file

@ -6,6 +6,8 @@ import './Search.css';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import * as docSearchReact from '@docsearch/react'; import * as docSearchReact from '@docsearch/react';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
/** FIXME: This is still kinda nasty, but DocSearch is not ESM ready. */ /** FIXME: This is still kinda nasty, but DocSearch is not ESM ready. */
const DocSearchModal = docSearchReact.DocSearchModal || (docSearchReact as any).default.DocSearchModal; const DocSearchModal = docSearchReact.DocSearchModal || (docSearchReact as any).default.DocSearchModal;
@ -89,9 +91,13 @@ export default function Search() {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = item.url; a.href = item.url;
const hash = a.hash === '#overview' ? '' : a.hash; const hash = a.hash === '#overview' ? '' : a.hash;
let pathname = a.pathname;
pathname = pathname.startsWith('/') ? pathname.slice(1) : pathname;
pathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const url = `${baseNoTrailing}/${pathname}/${hash}`;
return { return {
...item, ...item,
url: `${a.pathname}${hash}`, url,
}; };
}); });
}} }}

View file

@ -9,6 +9,7 @@ type Props = {
const { currentPage } = Astro.props as Props; const { currentPage } = Astro.props as Props;
const { BASE_URL } = import.meta.env; const { BASE_URL } = import.meta.env;
let currentPageMatch = currentPage.slice(BASE_URL.length, currentPage.endsWith('/') ? -1 : undefined); let currentPageMatch = currentPage.slice(BASE_URL.length, currentPage.endsWith('/') ? -1 : undefined);
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
const langCode = getLanguageFromURL(currentPage) || 'en'; const langCode = getLanguageFromURL(currentPage) || 'en';
const sidebar = SIDEBAR[langCode]; const sidebar = SIDEBAR[langCode];
@ -23,7 +24,7 @@ const sidebar = SIDEBAR[langCode];
<h2>{header}</h2> <h2>{header}</h2>
<ul> <ul>
{children.map((child) => { {children.map((child) => {
const url = Astro.site?.pathname + child.link; const url = `${baseNoTrailing}/${child.link}${child.link.endsWith('/') ? '' : '/'}`;
return ( return (
<li class=""> <li class="">
<a <a

View file

@ -73,7 +73,7 @@ const TableOfContents: FC<{ headings: MarkdownHeading[]; currentPage: string }>
.map((heading, i) => ( .map((heading, i) => (
<li className={`w-full`} key={i}> <li className={`w-full`} key={i}>
<a <a
href={`${currentPage}#${heading.slug}`} href={`${currentPage}/#${heading.slug}`}
onClick={onLinkClick} onClick={onLinkClick}
className={`py-0.5 block cursor-pointer w-full border-l-4 border-lineHighlight hover:bg-lineHighlight ${ className={`py-0.5 block cursor-pointer w-full border-l-4 border-lineHighlight hover:bg-lineHighlight ${
['pl-4', 'pl-9', 'pl-12'][heading.depth - minDepth] ['pl-4', 'pl-9', 'pl-12'][heading.depth - minDepth]

View file

@ -7,7 +7,7 @@ import { MiniRepl } from '../../../docs/MiniRepl';
# Willkommen # Willkommen
<img src="/icons/strudel_icon.png" className="w-32 animate-pulse md:float-right ml-8" /> <div className="w-32 animate-pulse md:float-right ml-8">![Strudel Icon](/icons/strudel_icon.png)</div>
Willkommen zum Strudel Workshop! Willkommen zum Strudel Workshop!
Du hast den richtigen Ort gefunden wenn du lernen möchtest wie man mit Code Musik macht. Du hast den richtigen Ort gefunden wenn du lernen möchtest wie man mit Code Musik macht.

View file

@ -3,6 +3,9 @@ import * as tunes from '../../../src/repl/tunes.mjs';
import HeadCommon from '../../components/HeadCommon.astro'; import HeadCommon from '../../components/HeadCommon.astro';
import { getMetadata } from '../metadata_parser'; import { getMetadata } from '../metadata_parser';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
--- ---
<head> <head>
@ -12,11 +15,11 @@ import { getMetadata } from '../metadata_parser';
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 p-2 select-none"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 p-2 select-none">
{ {
Object.entries(tunes).map(([name, tune]) => ( Object.entries(tunes).map(([name, tune]) => (
<a class="rounded-md bg-slate-900 hover:bg-slate-700 cursor-pointer relative" href={`./#${btoa(tune)}`}> <a class="rounded-md bg-slate-900 hover:bg-slate-700 cursor-pointer relative" href={`${baseNoTrailing}/#${btoa(tune)}`}>
<div class="absolute w-full h-full flex justify-center items-center"> <div class="absolute w-full h-full flex justify-center items-center">
<span class="bg-slate-800 p-2 rounded-md text-white">{getMetadata(tune)['title'] || name}</span> <span class="bg-slate-800 p-2 rounded-md text-white">{getMetadata(tune)['title'] || name}</span>
</div> </div>
<img src={`./img/example-${name}.png`} /> <img src={`${baseNoTrailing}/img/example-${name}.png`} />
</a> </a>
)) ))
} }

View file

@ -4,7 +4,7 @@ import { evaluate } from '@strudel.cycles/transpiler';
import '../../../../test/runtime.mjs'; import '../../../../test/runtime.mjs';
import * as tunes from '../../repl/tunes.mjs'; import * as tunes from '../../repl/tunes.mjs';
export async function get({ params, request }) { export async function GET({ params, request }) {
const { name } = params; const { name } = params;
const tune = tunes[name]; const tune = tunes[name];
const { pattern } = await evaluate(tune); const { pattern } = await evaluate(tune);
@ -13,10 +13,7 @@ export async function get({ params, request }) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
pianoroll({ time: 4, haps, ctx, playhead: 1, fold: 1, background: 'transparent', playheadColor: 'transparent' }); pianoroll({ time: 4, haps, ctx, playhead: 1, fold: 1, background: 'transparent', playheadColor: 'transparent' });
const buffer = canvas.toBuffer('image/png'); const buffer = canvas.toBuffer('image/png');
return { return new Response(buffer);
body: buffer,
encoding: 'binary',
};
} }
export function getStaticPaths() { export function getStaticPaths() {
return Object.keys(tunes).map((name) => ({ return Object.keys(tunes).map((name) => ({

View file

@ -145,7 +145,7 @@ In both cases, p4 is derived from the value of `freq` or `note`.
## Limitations / Future Plans ## Limitations / Future Plans
Apart from the above listed p values, no other parameter can be patterned so far. Apart from the above listed p values, no other parameter can be patterned so far.
This also means that [audio effects](./learn/effects) will not work. This also means that [audio effects](/learn/effects/) will not work.
In the future, the integration could be improved by passing all patterned control parameters to the csound instrument. In the future, the integration could be improved by passing all patterned control parameters to the csound instrument.
This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels) This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels)
for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd). for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd).

View file

@ -41,7 +41,7 @@ This interactive tutorial will guide you through the basics of Strudel.
To see and hear what Strudel can do, visit the [Strudel REPL](https://strudel.cc/) and click the Shuffle icon in the top menu bar. To see and hear what Strudel can do, visit the [Strudel REPL](https://strudel.cc/) and click the Shuffle icon in the top menu bar.
You can get a feel for Strudel by browsing and editing these examples and clicking the Refresh icon to update. You can get a feel for Strudel by browsing and editing these examples and clicking the Refresh icon to update.
You can also browse through the examples [here](./examples). You can also browse through the examples [here](/examples).
Alternatively, you can get a taste of what Strudel can do by clicking play on this track: Alternatively, you can get a taste of what Strudel can do by clicking play on this track:

View file

@ -49,7 +49,7 @@ A standalone app has its own desktop / homescreen icon and launches in a separat
without the browser ui. without the browser ui.
<figure> <figure>
<img src="./pwa/strudel-macos.png" alt="Strudel on MacOS" /> ![Strudel on MacOS](/pwa/strudel-macos.png)
<figcaption>Strudel on MacOS</figcaption> <figcaption>Strudel on MacOS</figcaption>
</figure> </figure>
@ -67,7 +67,7 @@ Without a chromium based browser, you can use [nativefier](https://github.com/na
2. run `npx nativefier strudel.cc` 2. run `npx nativefier strudel.cc`
<figure> <figure>
<img src="./pwa/strudel-linux.png" alt="Strudel on Linux" /> ![Strudel on Linux](/pwa/strudel-linux.png)
<figcaption>Strudel on Linux</figcaption> <figcaption>Strudel on Linux</figcaption>
</figure> </figure>

View file

@ -19,7 +19,7 @@ You can learn more about both of these approaches in the pages [Synths](/learn/s
# Combining notes and sounds # Combining notes and sounds
In both of the above cases, we are no longer directly controlling the `note`/`freq` of the sound heard via `s`, as we were in the [Notes](/strudel/notes) page. In both of the above cases, we are no longer directly controlling the `note`/`freq` of the sound heard via `s`, as we were in the [Notes](/workshop/first-notes/) page.
So how can we both control the sound and the pitch? We can _combine_ `note`/`freq` with `s` to change the sound of our pitches: So how can we both control the sound and the pitch? We can _combine_ `note`/`freq` with `s` to change the sound of our pitches:

View file

@ -96,13 +96,13 @@ If you find a tidal control that's not on the list, please tell!
## Sound ## Sound
Tidal is commonly paired with Superdirt / Supercollider for sound generation. Tidal is commonly paired with Superdirt / Supercollider for sound generation.
While Strudel also has a way of [communicating with Superdirt](./learn/input-output), While Strudel also has a way of [communicating with Superdirt](/learn/input-output/),
it aims to provide a standalone live coding environment that runs entirely in the browser. it aims to provide a standalone live coding environment that runs entirely in the browser.
### Audio Effects ### Audio Effects
Many of SuperDirt's effects have been reimplemented in Strudel, using the Web Audio API. Many of SuperDirt's effects have been reimplemented in Strudel, using the Web Audio API.
You can find a [list of available effects here](./learn/effects). You can find a [list of available effects here](/learn/effects/).
### Sampler ### Sampler

View file

@ -110,4 +110,4 @@ Some of these have equivalent operators in the Mini Notation:
<JsDoc client:idle name="ribbon" h={0} /> <JsDoc client:idle name="ribbon" h={0} />
Apart from modifying time, there are ways to [Control Parameters](functions/value-modifiers). Apart from modifying time, there are ways to [Control Parameters](/functions/value-modifiers/).

View file

@ -5,6 +5,9 @@ import { Content } from '../../../../my-patterns/README.md';
import HeadCommon from '../../components/HeadCommon.astro'; import HeadCommon from '../../components/HeadCommon.astro';
const myPatterns = await getMyPatterns(); const myPatterns = await getMyPatterns();
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
--- ---
<head> <head>
@ -23,12 +26,12 @@ const myPatterns = await getMyPatterns();
Object.entries(myPatterns).map(([name, tune]) => ( Object.entries(myPatterns).map(([name, tune]) => (
<a <a
class="rounded-md bg-slate-900 hover:bg-slate-700 cursor-pointer relative" class="rounded-md bg-slate-900 hover:bg-slate-700 cursor-pointer relative"
href={`./#${btoa(tune as string)}`} href={`${baseNoTrailing}/#${btoa(tune as string)}`}
> >
<div class="absolute w-full h-full flex justify-center items-center"> <div class="absolute w-full h-full flex justify-center items-center">
<span class="bg-slate-800 p-2 rounded-md text-white">{name}</span> <span class="bg-slate-800 p-2 rounded-md text-white">{name}</span>
</div> </div>
<img src={`./swatch/${name}.png`} /> <img src={`${baseNoTrailing}/swatch/${name}.png`} />
</a> </a>
)) ))
} }

View file

@ -7,7 +7,7 @@ import { MiniRepl } from '../../docs/MiniRepl';
# Welcome # Welcome
<img src="/icons/strudel_icon.png" className="w-32 animate-pulse md:float-right ml-8" /> <div className="w-32 animate-pulse md:float-right ml-8">![Strudel Icon](/icons/strudel_icon.png)</div>
Welcome to the Strudel documentation pages! Welcome to the Strudel documentation pages!
You've come to the right place if you want to learn how to make music with code. You've come to the right place if you want to learn how to make music with code.

View file

@ -1,493 +0,0 @@
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
import { logger } from '@strudel.cycles/core';
import { useEvent, cx } from '@strudel.cycles/react';
// import { cx } from '@strudel.cycles/react';
import { nanoid } from 'nanoid';
import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Reference } from './Reference';
import { themes } from './themes.mjs';
import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
import { useStore } from '@nanostores/react';
import { FilesTab } from './FilesTab';
const TAURI = window.__TAURI__;
export function Footer({ context }) {
const footerContent = useRef();
const [log, setLog] = useState([]);
const { activeFooter, isZen, panelPosition } = useSettings();
useLayoutEffect(() => {
if (footerContent.current && activeFooter === 'console') {
// scroll log box to bottom when log changes
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
}
}, [log, activeFooter]);
useLayoutEffect(() => {
if (!footerContent.current) {
} else if (activeFooter === 'console') {
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
} else {
footerContent.current.scrollTop = 0;
}
}, [activeFooter]);
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const FooterTab = ({ children, name, label }) => (
<>
<div
onClick={() => setActiveFooter(name)}
className={cx(
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
activeFooter === name ? 'border-foreground' : 'border-transparent',
)}
>
{label || name}
</div>
{activeFooter === name && <>{children}</>}
</>
);
if (isZen) {
return null;
}
const isActive = activeFooter !== '';
let positions = {
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
};
return (
<nav className={cx('bg-lineHighlight z-[10] flex flex-col', positions[panelPosition])}>
<div className="flex justify-between px-2">
<div className={cx('flex select-none max-w-full overflow-auto', activeFooter && 'pb-2')}>
<FooterTab name="intro" label="welcome" />
<FooterTab name="sounds" />
<FooterTab name="console" />
<FooterTab name="reference" />
<FooterTab name="settings" />
{TAURI && <FooterTab name="files" />}
</div>
{activeFooter !== '' && (
<button onClick={() => setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
{activeFooter !== '' && (
<div className="relative overflow-hidden">
<div className="text-white overflow-auto h-full max-w-full" ref={footerContent}>
{activeFooter === 'intro' && <WelcomeTab />}
{activeFooter === 'console' && <ConsoleTab log={log} />}
{activeFooter === 'sounds' && <SoundsTab />}
{activeFooter === 'reference' && <Reference />}
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
{activeFooter === 'files' && <FilesTab />}
</div>
</div>
)}
</nav>
);
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}
function WelcomeTab() {
return (
<div className="prose dark:prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
<h3>
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
</h3>
<p>
You have found <span className="underline">strudel</span>, a new live coding platform to write dynamic music
pieces in the browser! It is free and open-source and made for beginners and experts alike. To get started:
<br />
<br />
<span className="underline">1. hit play</span> - <span className="underline">2. change something</span> -{' '}
<span className="underline">3. hit update</span>
<br />
If you don't like what you hear, try <span className="underline">shuffle</span>!
</p>
<p>
To learn more about what this all means, check out the{' '}
<a href="./workshop/getting-started" target="_blank">
interactive tutorial
</a>
. Also feel free to join the{' '}
<a href="https://discord.com/invite/HGEdXmRkzT" target="_blank">
tidalcycles discord channel
</a>{' '}
to ask any questions, give feedback or just say hello.
</p>
<h3>about</h3>
<p>
strudel is a JavaScript version of{' '}
<a href="https://tidalcycles.org/" target="_blank">
tidalcycles
</a>
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
<a href="https://github.com/tidalcycles/strudel" target="_blank">
github
</a>
. Please consider to{' '}
<a href="https://opencollective.com/tidalcycles" target="_blank">
support this project
</a>{' '}
to ensure ongoing development 💖
</p>
</div>
);
}
function ConsoleTab({ log }) {
return (
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
`}</pre>
{log.map((l, i) => {
const message = linkify(l.message);
return (
<div key={l.id} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
);
}
const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const soundEntries = useMemo(() => {
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
if (!sounds) {
return [];
}
if (soundsFilter === 'user') {
return filtered.filter(([key, { data }]) => !data.prebake);
}
if (soundsFilter === 'drums') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
}
if (soundsFilter === 'samples') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
}
if (soundsFilter === 'synths') {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
return filtered;
}, [sounds, soundsFilter]);
// holds mutable ref to current triggered sound
const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
trigRef.current = undefined;
t?.then((ref) => {
ref?.stop(getAudioContext().currentTime + 0.01);
});
});
return (
<div id="sounds-tab" className="flex flex-col w-full h-full dark:text-white text-stone-900">
<div className="px-2 pb-2 flex-none">
<ButtonGroup
value={soundsFilter}
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
items={{
samples: 'samples',
drums: 'drum-machines',
synths: 'Synths',
user: 'User',
}}
></ButtonGroup>
</div>
<div className="p-2 min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal">
{soundEntries.map(([name, { data, onTrigger }]) => (
<span
key={name}
className="cursor-pointer hover:opacity-50"
onMouseDown={async () => {
const ctx = getAudioContext();
const params = {
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
s: name,
clip: 1,
release: 0.5,
};
const time = ctx.currentTime + 0.05;
const onended = () => trigRef.current?.node?.disconnect();
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
trigRef.current.then((ref) => {
ref?.node.connect(ctx.destination);
});
}}
>
{' '}
{name}
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
</span>
))}
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
</div>
</div>
);
}
function Checkbox({ label, value, onChange }) {
return (
<label>
<input type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
}
function ButtonGroup({ value, onChange, items }) {
return (
<div className="flex max-w-lg">
{Object.entries(items).map(([key, label], i, arr) => (
<button
key={key}
onClick={() => onChange(key)}
className={cx(
'px-2 border-b h-8',
// i === 0 && 'rounded-l-md',
// i === arr.length - 1 && 'rounded-r-md',
// value === key ? 'bg-background' : 'bg-lineHighlight',
value === key ? 'border-foreground' : 'border-transparent',
)}
>
{label.toLowerCase()}
</button>
))}
</div>
);
}
function SelectInput({ value, options, onChange }) {
return (
<select
className="p-2 bg-background rounded-md text-foreground"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{Object.entries(options).map(([k, label]) => (
<option key={k} className="bg-background" value={k}>
{label}
</option>
))}
</select>
);
}
function NumberSlider({ value, onChange, step = 1, ...rest }) {
return (
<div className="flex space-x-2 gap-1">
<input
className="p-2 grow"
type="range"
value={value}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
{...rest}
/>
<input
type="number"
value={value}
step={step}
className="w-16 bg-background rounded-md"
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
function FormItem({ label, children }) {
return (
<div className="grid gap-2">
<label>{label}</label>
{children}
</div>
);
}
const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
const fontFamilyOptions = {
monospace: 'monospace',
BigBlueTerminal: 'BigBlueTerminal',
x3270: 'x3270',
PressStart: 'PressStart2P',
galactico: 'galactico',
'we-come-in-peace': 'we-come-in-peace',
FiraCode: 'FiraCode',
'FiraCode-SemiBold': 'FiraCode SemiBold',
teletext: 'teletext',
mode7: 'mode7',
};
function SettingsTab({ scheduler }) {
const {
theme,
keybindings,
isLineNumbersDisplayed,
isActiveLineHighlighted,
isAutoCompletionEnabled,
isTooltipEnabled,
isLineWrappingEnabled,
fontSize,
fontFamily,
panelPosition,
} = useSettings();
return (
<div className="text-foreground p-4 space-y-4">
{/* <FormItem label="Tempo">
<div className="space-x-4">
<button
onClick={() => {
scheduler.setCps(scheduler.cps - 0.1);
}}
>
slower
</button>
<button
onClick={() => {
scheduler.setCps(scheduler.cps + 0.1);
}}
>
faster
</button>
</div>
</FormItem> */}
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="Font Family">
<SelectInput
options={fontFamilyOptions}
value={fontFamily}
onChange={(fontFamily) => settingsMap.setKey('fontFamily', fontFamily)}
/>
</FormItem>
<FormItem label="Font Size">
<NumberSlider
value={fontSize}
onChange={(fontSize) => settingsMap.setKey('fontSize', fontSize)}
min={10}
max={40}
step={2}
/>
</FormItem>
</div>
<FormItem label="Keybindings">
<ButtonGroup
value={keybindings}
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
></ButtonGroup>
</FormItem>
<FormItem label="Panel Position">
<ButtonGroup
value={panelPosition}
onChange={(value) => settingsMap.setKey('panelPosition', value)}
items={{ bottom: 'Bottom', right: 'Right' }}
></ButtonGroup>
</FormItem>
<FormItem label="Code Settings">
<Checkbox
label="Display line numbers"
onChange={(cbEvent) => settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
value={isLineNumbersDisplayed}
/>
<Checkbox
label="Highlight active line"
onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
value={isActiveLineHighlighted}
/>
<Checkbox
label="Enable auto-completion"
onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
value={isAutoCompletionEnabled}
/>
<Checkbox
label="Enable tooltips on Ctrl and hover"
onChange={(cbEvent) => settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
value={isTooltipEnabled}
/>
<Checkbox
label="Enable line wrapping"
onChange={(cbEvent) => settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
value={isLineWrappingEnabled}
/>
</FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings">
<button
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={() => {
if (confirm('Sure?')) {
settingsMap.set(defaultSettings);
}
}}
>
restore default settings
</button>
</FormItem>
</div>
);
}

View file

@ -9,6 +9,8 @@ import React, { useContext } from 'react';
import { useSettings, setIsZen } from '../settings.mjs'; import { useSettings, setIsZen } from '../settings.mjs';
// import { ReplContext } from './Repl'; // import { ReplContext } from './Repl';
import './Repl.css'; import './Repl.css';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function Header({ context }) { export function Header({ context }) {
const { const {
@ -85,7 +87,7 @@ export function Header({ context }) {
)} )}
</button> </button>
<button <button
onClick={handleUpdate} onClick={() => handleUpdate()}
title="update" title="update"
className={cx( className={cx(
'flex items-center space-x-1', 'flex items-center space-x-1',
@ -123,7 +125,7 @@ export function Header({ context }) {
{!isEmbedded && ( {!isEmbedded && (
<a <a
title="learn" title="learn"
href="./workshop/getting-started" href={`${baseNoTrailing}/workshop/getting-started/`}
className={cx('hover:opacity-50 flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')} className={cx('hover:opacity-50 flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')}
> >
<AcademicCapIcon className="w-6 h-6" /> <AcademicCapIcon className="w-6 h-6" />

View file

@ -11,13 +11,13 @@ import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react'; import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
import './Repl.css'; import './Repl.css';
import { Footer } from './Footer'; import { Panel } from './panel/Panel';
import { Header } from './Header'; import { Header } from './Header';
import { prebake } from './prebake.mjs'; import { prebake } from './prebake.mjs';
import * as tunes from './tunes.mjs'; import * as tunes from './tunes.mjs';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import { themes } from './themes.mjs'; import { themes } from './themes.mjs';
import { settingsMap, useSettings, setLatestCode } from '../settings.mjs'; import { settingsMap, useSettings, setLatestCode, updateUserCode, setActivePattern } from '../settings.mjs';
import Loader from './Loader'; import Loader from './Loader';
import { settingPatterns } from '../settings.mjs'; import { settingPatterns } from '../settings.mjs';
import { code2hash, hash2code } from './helpers.mjs'; import { code2hash, hash2code } from './helpers.mjs';
@ -132,6 +132,7 @@ export function Repl({ embedded = false }) {
isLineWrappingEnabled, isLineWrappingEnabled,
panelPosition, panelPosition,
isZen, isZen,
activePattern,
} = useSettings(); } = useSettings();
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]); const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
@ -148,6 +149,7 @@ export function Repl({ embedded = false }) {
cleanupDraw(); cleanupDraw();
}, },
afterEval: ({ code, meta }) => { afterEval: ({ code, meta }) => {
updateUserCode(code);
setMiniLocations(meta.miniLocations); setMiniLocations(meta.miniLocations);
setWidgets(meta.widgets); setWidgets(meta.widgets);
setPending(false); setPending(false);
@ -229,7 +231,7 @@ export function Repl({ embedded = false }) {
const handleChangeCode = useCallback( const handleChangeCode = useCallback(
(c) => { (c) => {
setCode(c); setCode(c);
//started && logger('[edit] code changed. hit ctrl+enter to update'); // started && logger('[edit] code changed. hit ctrl+enter to update');
}, },
[started], [started],
); );
@ -248,14 +250,21 @@ export function Repl({ embedded = false }) {
stop(); stop();
} }
}; };
const handleUpdate = () => { const handleUpdate = async (newCode, reset = false) => {
isDirty && activateCode(); if (reset) {
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight'); clearCanvas();
resetLoadedSounds();
scheduler.setCps(1);
await prebake(); // declare default samples
}
(newCode || isDirty) && activateCode(newCode);
logger('[repl] code updated!');
}; };
const handleShuffle = async () => { const handleShuffle = async () => {
const { code, name } = getRandomTune(); const { code, name } = getRandomTune();
logger(`[repl] ✨ loading random tune "${name}"`); logger(`[repl] ✨ loading random tune "${name}"`);
setActivePattern(name);
clearCanvas(); clearCanvas();
resetLoadedSounds(); resetLoadedSounds();
scheduler.setCps(1); scheduler.setCps(1);
@ -350,12 +359,12 @@ export function Repl({ embedded = false }) {
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
/> />
</section> </section>
{panelPosition === 'right' && !isEmbedded && <Footer context={context} />} {panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
</div> </div>
{error && ( {error && (
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div> <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
)} )}
{panelPosition === 'bottom' && !isEmbedded && <Footer context={context} />} {panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
</div> </div>
</ReplContext.Provider> </ReplContext.Provider>
); );

View file

@ -0,0 +1,45 @@
import { cx } from '@strudel.cycles/react';
import React from 'react';
export function ConsoleTab({ log }) {
return (
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
`}</pre>
{log.map((l, i) => {
const message = linkify(l.message);
return (
<div key={l.id} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}

View file

@ -1,6 +1,6 @@
import { Fragment, useEffect } from 'react'; import { Fragment, useEffect } from 'react';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { isAudioFile, readDir, dir, playFile } from './files.mjs'; import { isAudioFile, readDir, dir, playFile } from '../files.mjs';
export function FilesTab() { export function FilesTab() {
const [path, setPath] = useState([]); const [path, setPath] = useState([]);

View file

@ -0,0 +1,24 @@
import { cx } from '@strudel.cycles/react';
import React from 'react';
export function ButtonGroup({ value, onChange, items }) {
return (
<div className="flex max-w-lg">
{Object.entries(items).map(([key, label], i, arr) => (
<button
key={key}
onClick={() => onChange(key)}
className={cx(
'px-2 border-b h-8',
// i === 0 && 'rounded-l-md',
// i === arr.length - 1 && 'rounded-r-md',
// value === key ? 'bg-background' : 'bg-lineHighlight',
value === key ? 'border-foreground' : 'border-transparent',
)}
>
{label.toLowerCase()}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,119 @@
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
import { logger } from '@strudel.cycles/core';
import { cx, useEvent } from '@strudel.cycles/react';
import { nanoid } from 'nanoid';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { setActiveFooter, useSettings } from '../../settings.mjs';
import { ConsoleTab } from './ConsoleTab';
import { FilesTab } from './FilesTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
const TAURI = window.__TAURI__;
export function Panel({ context }) {
const footerContent = useRef();
const [log, setLog] = useState([]);
const { activeFooter, isZen, panelPosition } = useSettings();
useLayoutEffect(() => {
if (footerContent.current && activeFooter === 'console') {
// scroll log box to bottom when log changes
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
}
}, [log, activeFooter]);
useLayoutEffect(() => {
if (!footerContent.current) {
} else if (activeFooter === 'console') {
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
} else {
footerContent.current.scrollTop = 0;
}
}, [activeFooter]);
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const PanelTab = ({ children, name, label }) => (
<>
<div
onClick={() => setActiveFooter(name)}
className={cx(
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
activeFooter === name ? 'border-foreground' : 'border-transparent',
)}
>
{label || name}
</div>
{activeFooter === name && <>{children}</>}
</>
);
if (isZen) {
return null;
}
const isActive = activeFooter !== '';
let positions = {
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
};
return (
<nav className={cx('bg-lineHighlight z-[10] flex flex-col', positions[panelPosition])}>
<div className="flex justify-between px-2">
<div className={cx('flex select-none max-w-full overflow-auto', activeFooter && 'pb-2')}>
<PanelTab name="intro" label="welcome" />
<PanelTab name="patterns" />
<PanelTab name="sounds" />
<PanelTab name="console" />
<PanelTab name="reference" />
<PanelTab name="settings" />
{TAURI && <PanelTab name="files" />}
</div>
{activeFooter !== '' && (
<button onClick={() => setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
{activeFooter !== '' && (
<div className="relative overflow-hidden">
<div className="text-white overflow-auto h-full max-w-full" ref={footerContent}>
{activeFooter === 'intro' && <WelcomeTab context={context} />}
{activeFooter === 'patterns' && <PatternsTab context={context} />}
{activeFooter === 'console' && <ConsoleTab log={log} />}
{activeFooter === 'sounds' && <SoundsTab />}
{activeFooter === 'reference' && <Reference />}
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
{activeFooter === 'files' && <FilesTab />}
</div>
</div>
)}
</nav>
);
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}

View file

@ -0,0 +1,146 @@
import { useMemo } from 'react';
import * as tunes from '../tunes.mjs';
import {
useSettings,
clearUserPatterns,
newUserPattern,
setActivePattern,
deleteActivePattern,
duplicateActivePattern,
getUserPattern,
getUserPatterns,
renameActivePattern,
addUserPattern,
setUserPatterns,
} from '../../settings.mjs';
import { logger } from '@strudel.cycles/core';
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/20/solid';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
}
export function PatternsTab({ context }) {
const { userPatterns, activePattern } = useSettings();
const isExample = useMemo(() => activePattern && !!tunes[activePattern], [activePattern]);
return (
<div className="px-4 w-full dark:text-white text-stone-900 space-y-4 pb-4">
<section>
{activePattern && (
<div className="flex items-center mb-2 space-x-2 overflow-auto">
<h1 className="text-xl">{activePattern}</h1>
<div className="space-x-4 flex w-min">
{!isExample && (
<button className="hover:opacity-50" onClick={() => renameActivePattern()} title="Rename">
<PencilSquareIcon className="w-5 h-5" />
{/* <PencilIcon className="w-5 h-5" /> */}
</button>
)}
<button className="hover:opacity-50" onClick={() => duplicateActivePattern()} title="Duplicate">
<DocumentDuplicateIcon className="w-5 h-5" />
</button>
{!isExample && (
<button className="hover:opacity-50" onClick={() => deleteActivePattern()} title="Delete">
<TrashIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
)}
<div className="font-mono text-sm">
{Object.entries(userPatterns).map(([key, up]) => (
<a
key={key}
className={classNames(
'mr-4 hover:opacity-50 cursor-pointer inline-block',
key === activePattern ? 'outline outline-1' : '',
)}
onClick={() => {
const { code } = up;
setActivePattern(key);
context.handleUpdate(code, true);
}}
>
{key}
</a>
))}
</div>
<div className="pr-4 space-x-4 border-b border-foreground mb-2 h-8 flex overflow-auto max-w-full items-center">
<button
className="hover:opacity-50"
onClick={() => {
const name = newUserPattern();
const { code } = getUserPattern(name);
context.handleUpdate(code, true);
}}
>
new
</button>
<button className="hover:opacity-50" onClick={() => clearUserPatterns()}>
clear
</button>
<label className="hover:opacity-50 cursor-pointer">
<input
style={{ display: 'none' }}
type="file"
multiple
accept="text/plain,application/json"
onChange={async (e) => {
const files = Array.from(e.target.files);
await Promise.all(
files.map(async (file, i) => {
const content = await file.text();
if (file.type === 'application/json') {
const userPatterns = getUserPatterns() || {};
setUserPatterns({ ...userPatterns, ...JSON.parse(content) });
} else if (file.type === 'text/plain') {
const name = file.name.replace(/\.[^/.]+$/, '');
addUserPattern(name, { code: content });
}
}),
);
logger(`import done!`);
}}
/>
import
</label>
<button
className="hover:opacity-50"
onClick={() => {
const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' });
const downloadLink = document.createElement('a');
downloadLink.href = window.URL.createObjectURL(blob);
const date = new Date().toISOString().split('T')[0];
downloadLink.download = `strudel_patterns_${date}.json`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}}
>
export
</button>
</div>
</section>
<section>
<h2 className="text-xl mb-2">Examples</h2>
<div className="font-mono text-sm">
{Object.entries(tunes).map(([key, tune]) => (
<a
key={key}
className={classNames(
'mr-4 hover:opacity-50 cursor-pointer inline-block',
key === activePattern ? 'outline outline-1' : '',
)}
onClick={() => {
setActivePattern(key);
context.handleUpdate(tune, true);
}}
>
{key}
</a>
))}
</div>
</section>
</div>
);
}

View file

@ -1,4 +1,4 @@
import jsdocJson from '../../../doc.json'; import jsdocJson from '../../../../doc.json';
const visibleFunctions = jsdocJson.docs const visibleFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description) .filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name)); .sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));

View file

@ -0,0 +1,187 @@
import React from 'react';
import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
import { themes } from '../themes.mjs';
import { ButtonGroup } from './Forms.jsx';
function Checkbox({ label, value, onChange }) {
return (
<label>
<input type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
}
function SelectInput({ value, options, onChange }) {
return (
<select
className="p-2 bg-background rounded-md text-foreground"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{Object.entries(options).map(([k, label]) => (
<option key={k} className="bg-background" value={k}>
{label}
</option>
))}
</select>
);
}
function NumberSlider({ value, onChange, step = 1, ...rest }) {
return (
<div className="flex space-x-2 gap-1">
<input
className="p-2 grow"
type="range"
value={value}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
{...rest}
/>
<input
type="number"
value={value}
step={step}
className="w-16 bg-background rounded-md"
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
function FormItem({ label, children }) {
return (
<div className="grid gap-2">
<label>{label}</label>
{children}
</div>
);
}
const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
const fontFamilyOptions = {
monospace: 'monospace',
BigBlueTerminal: 'BigBlueTerminal',
x3270: 'x3270',
PressStart: 'PressStart2P',
galactico: 'galactico',
'we-come-in-peace': 'we-come-in-peace',
FiraCode: 'FiraCode',
'FiraCode-SemiBold': 'FiraCode SemiBold',
teletext: 'teletext',
mode7: 'mode7',
};
export function SettingsTab() {
const {
theme,
keybindings,
isLineNumbersDisplayed,
isActiveLineHighlighted,
isAutoCompletionEnabled,
isTooltipEnabled,
isLineWrappingEnabled,
fontSize,
fontFamily,
panelPosition,
} = useSettings();
return (
<div className="text-foreground p-4 space-y-4">
{/* <FormItem label="Tempo">
<div className="space-x-4">
<button
onClick={() => {
scheduler.setCps(scheduler.cps - 0.1);
}}
>
slower
</button>
<button
onClick={() => {
scheduler.setCps(scheduler.cps + 0.1);
}}
>
faster
</button>
</div>
</FormItem> */}
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormItem label="Font Family">
<SelectInput
options={fontFamilyOptions}
value={fontFamily}
onChange={(fontFamily) => settingsMap.setKey('fontFamily', fontFamily)}
/>
</FormItem>
<FormItem label="Font Size">
<NumberSlider
value={fontSize}
onChange={(fontSize) => settingsMap.setKey('fontSize', fontSize)}
min={10}
max={40}
step={2}
/>
</FormItem>
</div>
<FormItem label="Keybindings">
<ButtonGroup
value={keybindings}
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
></ButtonGroup>
</FormItem>
<FormItem label="Panel Position">
<ButtonGroup
value={panelPosition}
onChange={(value) => settingsMap.setKey('panelPosition', value)}
items={{ bottom: 'Bottom', right: 'Right' }}
></ButtonGroup>
</FormItem>
<FormItem label="Code Settings">
<Checkbox
label="Display line numbers"
onChange={(cbEvent) => settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
value={isLineNumbersDisplayed}
/>
<Checkbox
label="Highlight active line"
onChange={(cbEvent) => settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
value={isActiveLineHighlighted}
/>
<Checkbox
label="Enable auto-completion"
onChange={(cbEvent) => settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
value={isAutoCompletionEnabled}
/>
<Checkbox
label="Enable tooltips on Ctrl and hover"
onChange={(cbEvent) => settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
value={isTooltipEnabled}
/>
<Checkbox
label="Enable line wrapping"
onChange={(cbEvent) => settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
value={isLineWrappingEnabled}
/>
</FormItem>
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
<FormItem label="Reset Settings">
<button
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={() => {
if (confirm('Sure?')) {
settingsMap.set(defaultSettings);
}
}}
>
restore default settings
</button>
</FormItem>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { useEvent } from '@strudel.cycles/react';
// import { cx } from '@strudel.cycles/react';
import { useStore } from '@nanostores/react';
import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio';
import React, { useMemo, useRef } from 'react';
import { settingsMap, useSettings } from '../../settings.mjs';
import { ButtonGroup } from './Forms.jsx';
const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
export function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const soundEntries = useMemo(() => {
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
if (!sounds) {
return [];
}
if (soundsFilter === 'user') {
return filtered.filter(([key, { data }]) => !data.prebake);
}
if (soundsFilter === 'drums') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
}
if (soundsFilter === 'samples') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
}
if (soundsFilter === 'synths') {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
return filtered;
}, [sounds, soundsFilter]);
// holds mutable ref to current triggered sound
const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
trigRef.current = undefined;
t?.then((ref) => {
ref?.stop(getAudioContext().currentTime + 0.01);
});
});
return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<div className="pb-2 flex-none">
<ButtonGroup
value={soundsFilter}
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
items={{
samples: 'samples',
drums: 'drum-machines',
synths: 'Synths',
user: 'User',
}}
></ButtonGroup>
</div>
<div className="min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal">
{soundEntries.map(([name, { data, onTrigger }]) => (
<span
key={name}
className="cursor-pointer hover:opacity-50"
onMouseDown={async () => {
const ctx = getAudioContext();
const params = {
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
s: name,
clip: 1,
release: 0.5,
};
const time = ctx.currentTime + 0.05;
const onended = () => trigRef.current?.node?.disconnect();
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
trigRef.current.then((ref) => {
connectToDestination(ref?.node);
});
}}
>
{' '}
{name}
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
</span>
))}
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { cx } from '@strudel.cycles/react';
import React from 'react';
import * as tunes from '../tunes.mjs';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function WelcomeTab({ context }) {
return (
<div className="prose dark:prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
<h3>
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
</h3>
<p>
You have found <span className="underline">strudel</span>, a new live coding platform to write dynamic music
pieces in the browser! It is free and open-source and made for beginners and experts alike. To get started:
<br />
<br />
<span className="underline">1. hit play</span> - <span className="underline">2. change something</span> -{' '}
<span className="underline">3. hit update</span>
<br />
If you don't like what you hear, try <span className="underline">shuffle</span>!
</p>
<p>
To learn more about what this all means, check out the{' '}
<a href={`${baseNoTrailing}/workshop/getting-started/`} target="_blank">
interactive tutorial
</a>
. Also feel free to join the{' '}
<a href="https://discord.com/invite/HGEdXmRkzT" target="_blank">
tidalcycles discord channel
</a>{' '}
to ask any questions, give feedback or just say hello.
</p>
<h3>about</h3>
<p>
strudel is a JavaScript version of{' '}
<a href="https://tidalcycles.org/" target="_blank">
tidalcycles
</a>
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
<a href="https://github.com/tidalcycles/strudel" target="_blank">
github
</a>
. Please consider to{' '}
<a href="https://opencollective.com/tidalcycles" target="_blank">
support this project
</a>{' '}
to ensure ongoing development 💖
</p>
</div>
);
}

View file

@ -3,6 +3,9 @@ import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel.cycle
import './piano.mjs'; import './piano.mjs';
import './files.mjs'; import './files.mjs';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export async function prebake() { export async function prebake() {
// https://archive.org/details/SalamanderGrandPianoV3 // https://archive.org/details/SalamanderGrandPianoV3
// License: CC-by http://creativecommons.org/licenses/by/3.0/ Author: Alexander Holm // License: CC-by http://creativecommons.org/licenses/by/3.0/ Author: Alexander Holm
@ -14,16 +17,16 @@ export async function prebake() {
// => getting "window is not defined", as soon as "@strudel.cycles/soundfonts" is imported statically // => getting "window is not defined", as soon as "@strudel.cycles/soundfonts" is imported statically
// seems to be a problem with soundfont2 // seems to be a problem with soundfont2
import('@strudel.cycles/soundfonts').then(({ registerSoundfonts }) => registerSoundfonts()), import('@strudel.cycles/soundfonts').then(({ registerSoundfonts }) => registerSoundfonts()),
samples(`./piano.json`, `./piano/`, { prebake: true }), samples(`${baseNoTrailing}/piano.json`, `${baseNoTrailing}/piano/`, { prebake: true }),
// https://github.com/sgossner/VCSL/ // https://github.com/sgossner/VCSL/
// https://api.github.com/repositories/126427031/contents/ // https://api.github.com/repositories/126427031/contents/
// LICENSE: CC0 general-purpose // LICENSE: CC0 general-purpose
samples(`./vcsl.json`, 'github:sgossner/VCSL/master/', { prebake: true }), samples(`${baseNoTrailing}/vcsl.json`, 'github:sgossner/VCSL/master/', { prebake: true }),
samples(`./tidal-drum-machines.json`, 'github:ritchse/tidal-drum-machines/main/machines/', { samples(`${baseNoTrailing}/tidal-drum-machines.json`, 'github:ritchse/tidal-drum-machines/main/machines/', {
prebake: true, prebake: true,
tag: 'drum-machines', tag: 'drum-machines',
}), }),
samples(`./EmuSP12.json`, `./EmuSP12/`, { prebake: true, tag: 'drum-machines' }), samples(`${baseNoTrailing}/EmuSP12.json`, `${baseNoTrailing}/EmuSP12/`, { prebake: true, tag: 'drum-machines' }),
samples( samples(
{ {
casio: ['casio/high.wav', 'casio/low.wav', 'casio/noise.wav'], casio: ['casio/high.wav', 'casio/low.wav', 'casio/noise.wav'],

View file

@ -1,6 +1,7 @@
import { persistentMap } from '@nanostores/persistent'; import { persistentMap } from '@nanostores/persistent';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { register } from '@strudel.cycles/core'; import { register } from '@strudel.cycles/core';
import * as tunes from './repl/tunes.mjs';
export const defaultSettings = { export const defaultSettings = {
activeFooter: 'intro', activeFooter: 'intro',
@ -17,6 +18,8 @@ export const defaultSettings = {
isZen: false, isZen: false,
soundsFilter: 'all', soundsFilter: 'all',
panelPosition: 'bottom', panelPosition: 'bottom',
userPatterns: '{}',
activePattern: '',
}; };
export const settingsMap = persistentMap('strudel-settings', defaultSettings); export const settingsMap = persistentMap('strudel-settings', defaultSettings);
@ -33,6 +36,7 @@ export function useSettings() {
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false, isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
fontSize: Number(state.fontSize), fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom', panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom',
userPatterns: JSON.parse(state.userPatterns),
}; };
} }
@ -57,3 +61,137 @@ export const fontFamily = patternSetting('fontFamily');
export const fontSize = patternSetting('fontSize'); export const fontSize = patternSetting('fontSize');
export const settingPatterns = { theme, fontFamily, fontSize }; export const settingPatterns = { theme, fontFamily, fontSize };
export function getUserPatterns() {
return JSON.parse(settingsMap.get().userPatterns);
}
function getSetting(key) {
return settingsMap.get()[key];
}
export function setUserPatterns(obj) {
settingsMap.setKey('userPatterns', JSON.stringify(obj));
}
export function addUserPattern(name, config) {
if (typeof config !== 'object') {
throw new Error('addUserPattern expected object as second param');
}
if (!config.code) {
throw new Error('addUserPattern expected code as property of second param');
}
const userPatterns = getUserPatterns();
setUserPatterns({ ...userPatterns, [name]: config });
}
export function newUserPattern() {
const userPatterns = getUserPatterns();
const date = new Date().toISOString().split('T')[0];
const todays = Object.entries(userPatterns).filter(([name]) => name.startsWith(date));
const num = String(todays.length + 1).padStart(3, '0');
const defaultNewPattern = 's("hh")';
const name = date + '_' + num;
addUserPattern(name, { code: defaultNewPattern });
setActivePattern(name);
return name;
}
export function clearUserPatterns() {
if (!confirm(`This will delete all your patterns. Are you really sure?`)) {
return;
}
setUserPatterns({});
}
export function getNextCloneName(key) {
const userPatterns = getUserPatterns();
const clones = Object.entries(userPatterns).filter(([name]) => name.startsWith(key));
const num = String(clones.length + 1).padStart(3, '0');
return key + '_' + num;
}
export function getUserPattern(key) {
const userPatterns = getUserPatterns();
return userPatterns[key];
}
export function renameActivePattern() {
let activePattern = getSetting('activePattern');
let userPatterns = getUserPatterns();
if (!userPatterns[activePattern]) {
alert('Cannot rename examples');
return;
}
const newName = prompt('Enter new name', activePattern);
if (newName === null) {
// canceled
return;
}
if (userPatterns[newName]) {
alert('Name already taken!');
return;
}
userPatterns[newName] = userPatterns[activePattern]; // copy code
delete userPatterns[activePattern];
setUserPatterns({ ...userPatterns });
setActivePattern(newName);
}
export function updateUserCode(code) {
const userPatterns = getUserPatterns();
let activePattern = getSetting('activePattern');
// check if code is that of an example tune
const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || [];
if (example && (!activePattern || activePattern === example)) {
// select example
setActivePattern(example);
return;
}
if (!activePattern) {
// create new user pattern
activePattern = newUserPattern();
setActivePattern(activePattern);
} else if (!!tunes[activePattern] && code !== tunes[activePattern]) {
// fork example
activePattern = getNextCloneName(activePattern);
setActivePattern(activePattern);
}
setUserPatterns({ ...userPatterns, [activePattern]: { code } });
}
export function deleteActivePattern() {
let activePattern = getSetting('activePattern');
if (!activePattern) {
console.warn('cannot delete: no pattern selected');
return;
}
const userPatterns = getUserPatterns();
if (!userPatterns[activePattern]) {
alert('Cannot delete examples');
return;
}
if (!confirm(`Really delete the selected pattern "${activePattern}"?`)) {
return;
}
setUserPatterns(Object.fromEntries(Object.entries(userPatterns).filter(([key]) => key !== activePattern)));
setActivePattern('');
}
export function duplicateActivePattern() {
let activePattern = getSetting('activePattern');
let latestCode = getSetting('latestCode');
if (!activePattern) {
console.warn('cannot duplicate: no pattern selected');
return;
}
const userPatterns = getUserPatterns();
activePattern = getNextCloneName(activePattern);
setUserPatterns({ ...userPatterns, [activePattern]: { code: latestCode } });
setActivePattern(activePattern);
}
export function setActivePattern(key) {
settingsMap.setKey('activePattern', key);
}
export function importUserPatternJSON(jsonString) {}