mirror of
https://github.com/eliasstepanik/strudel-docker.git
synced 2026-01-14 15:18:33 +00:00
Merge branch 'tidalcycles:main' into file_import
This commit is contained in:
commit
6238be7f2c
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@ -21,10 +21,10 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8.11.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -10,10 +10,10 @@ jobs:
|
||||
node-version: [18]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8.11.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
85
.gitignore
vendored
85
.gitignore
vendored
@ -43,4 +43,87 @@ dev-dist
|
||||
Dirt-Samples
|
||||
tidal-drum-machines
|
||||
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
|
||||
|
||||
@ -66,7 +66,7 @@ To get the project up and running for development, make sure you have installed:
|
||||
|
||||
- [git](https://git-scm.com/)
|
||||
- [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:
|
||||
|
||||
|
||||
@ -381,6 +381,19 @@ const generic_params = [
|
||||
*/
|
||||
['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
|
||||
|
||||
/**
|
||||
|
||||
@ -1985,7 +1985,7 @@ Pattern.prototype.hush = function () {
|
||||
* note("c d e g").palindrome()
|
||||
*/
|
||||
export const palindrome = register('palindrome', function (pat) {
|
||||
return pat.every(2, rev);
|
||||
return pat.lastOf(2, rev);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
slowcat,
|
||||
cat,
|
||||
sequence,
|
||||
palindrome,
|
||||
polymeter,
|
||||
polymeterSteps,
|
||||
polyrhythm,
|
||||
@ -571,6 +572,18 @@ describe('Pattern', () => {
|
||||
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()', () => {
|
||||
it('Can layer up cycles', () => {
|
||||
expect(polyrhythm(['a', 'b'], ['c']).firstCycle()).toStrictEqual(
|
||||
|
||||
@ -74,8 +74,11 @@ function useStrudel({
|
||||
}
|
||||
});
|
||||
const activateCode = useCallback(
|
||||
async (autostart = true) => {
|
||||
const res = await evaluate(code, autostart);
|
||||
async (newCode, autostart = true) => {
|
||||
if (newCode) {
|
||||
setCode(code);
|
||||
}
|
||||
const res = await evaluate(newCode || code, autostart);
|
||||
broadcast({ type: 'start', from: id });
|
||||
return res;
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { noteToMidi, valueToMidi } from './util.mjs';
|
||||
import { noteToMidi, valueToMidi, nanFallback } from './util.mjs';
|
||||
import { getAudioContext, registerSound } from './index.mjs';
|
||||
import { getEnvelope } from './helpers.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
@ -33,6 +33,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol
|
||||
const ac = getAudioContext();
|
||||
let sampleUrl;
|
||||
if (Array.isArray(bank)) {
|
||||
n = nanFallback(n, 0);
|
||||
sampleUrl = bank[n % bank.length];
|
||||
} else {
|
||||
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
||||
|
||||
@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
import './feedbackdelay.mjs';
|
||||
import './reverb.mjs';
|
||||
import './vowel.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
import { clamp, nanFallback } from './util.mjs';
|
||||
import workletsUrl from './worklets.mjs?url';
|
||||
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
|
||||
import { map } from 'nanostores';
|
||||
@ -27,28 +27,16 @@ export function getSound(s) {
|
||||
export const resetLoadedSounds = () => soundMap.set({});
|
||||
|
||||
let audioContext;
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
const maxChannelCount = audioContext.destination.maxChannelCount;
|
||||
audioContext.destination.channelCount = maxChannelCount;
|
||||
}
|
||||
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;
|
||||
|
||||
function loadWorklets() {
|
||||
@ -95,6 +83,39 @@ export async function initAudioOnFirstClick(options) {
|
||||
let delays = {};
|
||||
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) {
|
||||
if (delayfeedback > maxfeedback) {
|
||||
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
|
||||
@ -104,7 +125,7 @@ function getDelay(orbit, delaytime, delayfeedback, t) {
|
||||
const ac = getAudioContext();
|
||||
const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback);
|
||||
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].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t);
|
||||
@ -163,7 +184,7 @@ function getReverb(orbit, duration, fade, lp, dim, ir) {
|
||||
if (!reverbs[orbit]) {
|
||||
const ac = getAudioContext();
|
||||
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
|
||||
reverb.connect(getDestination());
|
||||
connectToDestination(reverb, [0, 1]);
|
||||
reverbs[orbit] = reverb;
|
||||
}
|
||||
if (
|
||||
@ -269,7 +290,7 @@ export const superdough = async (value, deadline, hapDuration) => {
|
||||
bpsustain = 1,
|
||||
bprelease = 0.01,
|
||||
bandq = 1,
|
||||
|
||||
channels = [1, 2],
|
||||
//phaser
|
||||
phaser,
|
||||
phaserdepth = 0.75,
|
||||
@ -301,6 +322,11 @@ export const superdough = async (value, deadline, hapDuration) => {
|
||||
compressorAttack,
|
||||
compressorRelease,
|
||||
} = 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
|
||||
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended
|
||||
const onended = () => {
|
||||
@ -434,9 +460,9 @@ export const superdough = async (value, deadline, hapDuration) => {
|
||||
}
|
||||
|
||||
// last gain
|
||||
const post = gainNode(postgain);
|
||||
const post = new GainNode(ac, { gain: postgain });
|
||||
chain.push(post);
|
||||
post.connect(getDestination());
|
||||
connectToDestination(post, channels);
|
||||
|
||||
// delay
|
||||
let delaySend;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
// currently duplicate with core util.mjs to skip dependency
|
||||
// TODO: add separate util module?
|
||||
|
||||
@ -51,3 +53,11 @@ export const valueToMidi = (value, 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;
|
||||
}
|
||||
|
||||
@ -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`] = `
|
||||
[
|
||||
"[ 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`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | note:g ]",
|
||||
"[ 1/4 → 1/2 | note:e ]",
|
||||
"[ 1/2 → 3/4 | note:d ]",
|
||||
"[ 3/4 → 1/1 | note:c ]",
|
||||
"[ 1/1 → 5/4 | note:c ]",
|
||||
"[ 5/4 → 3/2 | note:d ]",
|
||||
"[ 3/2 → 7/4 | note:e ]",
|
||||
"[ 7/4 → 2/1 | note:g ]",
|
||||
"[ 2/1 → 9/4 | note:g ]",
|
||||
"[ 9/4 → 5/2 | note:e ]",
|
||||
"[ 5/2 → 11/4 | note:d ]",
|
||||
"[ 11/4 → 3/1 | note:c ]",
|
||||
"[ 3/1 → 13/4 | note:c ]",
|
||||
"[ 13/4 → 7/2 | note:d ]",
|
||||
"[ 7/2 → 15/4 | note:e ]",
|
||||
"[ 15/4 → 4/1 | note:g ]",
|
||||
"[ 0/1 → 1/4 | note:c ]",
|
||||
"[ 1/4 → 1/2 | note:d ]",
|
||||
"[ 1/2 → 3/4 | note:e ]",
|
||||
"[ 3/4 → 1/1 | note:g ]",
|
||||
"[ 1/1 → 5/4 | note:g ]",
|
||||
"[ 5/4 → 3/2 | note:e ]",
|
||||
"[ 3/2 → 7/4 | note:d ]",
|
||||
"[ 7/4 → 2/1 | note:c ]",
|
||||
"[ 2/1 → 9/4 | note:c ]",
|
||||
"[ 9/4 → 5/2 | note:d ]",
|
||||
"[ 5/2 → 11/4 | note:e ]",
|
||||
"[ 11/4 → 3/1 | note:g ]",
|
||||
"[ 3/1 → 13/4 | note:g ]",
|
||||
"[ 13/4 → 7/2 | note:e ]",
|
||||
"[ 7/2 → 15/4 | note:d ]",
|
||||
"[ 15/4 → 4/1 | note:c ]",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@ -8372,16 +8372,16 @@ exports[`renders tunes > tune: hyperpop 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: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: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: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: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: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 ]",
|
||||
"[ 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: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: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: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: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: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: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: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: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: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 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@ -13,24 +13,39 @@ import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
const site = `https://strudel.cc/`; // root url without a path
|
||||
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
|
||||
// it wokrs by prepending the absolute page path to anchor links
|
||||
// example: #gain -> /learn/effects/#gain
|
||||
// this rehype plugin fixes relative links
|
||||
// it works by prepending the base + page path to anchor links
|
||||
// and by prepending the base path to other relative links starting with /
|
||||
// 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
|
||||
function absoluteAnchors() {
|
||||
// examples with base as "mybase":
|
||||
// #gain -> /mybase/learn/effects/#gain
|
||||
// /some/page -> /mybase/some/page
|
||||
function relativeURLFix() {
|
||||
return (tree, file) => {
|
||||
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
|
||||
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;
|
||||
}
|
||||
const baseWithSlash = base.endsWith('/') ? base : base + '/';
|
||||
const absoluteUrl = baseWithSlash + path + url.href;
|
||||
// console.log(url.href + ' -> ', absoluteUrl);
|
||||
return absoluteUrl;
|
||||
// console.log(url.href + ' -> ', newHref);
|
||||
return newHref;
|
||||
})(tree);
|
||||
};
|
||||
}
|
||||
@ -40,7 +55,7 @@ const options = {
|
||||
remarkToc,
|
||||
// E.g. `remark-frontmatter`
|
||||
],
|
||||
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'append' }], absoluteAnchors],
|
||||
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'append' }], relativeURLFix],
|
||||
};
|
||||
|
||||
// https://astro.build/config
|
||||
|
||||
@ -3,7 +3,7 @@ import { pwaInfo } from 'virtual:pwa-info';
|
||||
import '../styles/index.css';
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const base = BASE_URL;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
@ -11,20 +11,20 @@ const base = BASE_URL;
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<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
|
||||
name="description"
|
||||
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="apple-touch-icon" href="/icons/apple-icon-180.png" sizes="180x180" />
|
||||
<link rel="icon" href={`${baseNoTrailing}/favicon.ico`} />
|
||||
<link rel="apple-touch-icon" href={`${baseNoTrailing}/icons/apple-icon-180.png`} sizes="180x180" />
|
||||
<meta name="theme-color" content="#222222" />
|
||||
|
||||
<base href={base} />
|
||||
<base href={BASE_URL} />
|
||||
|
||||
<!-- 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>
|
||||
<!-- this does not work for some reason: -->
|
||||
|
||||
@ -16,6 +16,9 @@ const { currentPage } = Astro.props as Props;
|
||||
// const lang = getLanguageFromURL(currentPage);
|
||||
const langCode = 'en'; // getLanguageFromURL(currentPage);
|
||||
const sidebar = SIDEBAR[langCode];
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
---
|
||||
|
||||
<nav
|
||||
@ -23,7 +26,7 @@ const sidebar = SIDEBAR[langCode];
|
||||
title="Top Navigation"
|
||||
>
|
||||
<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">
|
||||
<span>🌀</span>
|
||||
<div class="flex space-x-1 items-baseline">
|
||||
@ -37,7 +40,7 @@ const sidebar = SIDEBAR[langCode];
|
||||
<div class="search-item h-10">
|
||||
<Search client:idle />
|
||||
</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>
|
||||
</a>
|
||||
<div class="md:hidden">
|
||||
|
||||
@ -6,6 +6,8 @@ import './Search.css';
|
||||
|
||||
import { createPortal } from 'react-dom';
|
||||
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. */
|
||||
const DocSearchModal = docSearchReact.DocSearchModal || (docSearchReact as any).default.DocSearchModal;
|
||||
@ -89,9 +91,13 @@ export default function Search() {
|
||||
const a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
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 {
|
||||
...item,
|
||||
url: `${a.pathname}${hash}`,
|
||||
url,
|
||||
};
|
||||
});
|
||||
}}
|
||||
|
||||
@ -9,6 +9,7 @@ type Props = {
|
||||
const { currentPage } = Astro.props as Props;
|
||||
const { BASE_URL } = import.meta.env;
|
||||
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 sidebar = SIDEBAR[langCode];
|
||||
@ -23,7 +24,7 @@ const sidebar = SIDEBAR[langCode];
|
||||
<h2>{header}</h2>
|
||||
<ul>
|
||||
{children.map((child) => {
|
||||
const url = Astro.site?.pathname + child.link;
|
||||
const url = `${baseNoTrailing}/${child.link}${child.link.endsWith('/') ? '' : '/'}`;
|
||||
return (
|
||||
<li class="">
|
||||
<a
|
||||
|
||||
@ -73,7 +73,7 @@ const TableOfContents: FC<{ headings: MarkdownHeading[]; currentPage: string }>
|
||||
.map((heading, i) => (
|
||||
<li className={`w-full`} key={i}>
|
||||
<a
|
||||
href={`${currentPage}#${heading.slug}`}
|
||||
href={`${currentPage}/#${heading.slug}`}
|
||||
onClick={onLinkClick}
|
||||
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]
|
||||
|
||||
@ -7,7 +7,7 @@ import { MiniRepl } from '../../../docs/MiniRepl';
|
||||
|
||||
# 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"></div>
|
||||
|
||||
Willkommen zum Strudel Workshop!
|
||||
Du hast den richtigen Ort gefunden wenn du lernen möchtest wie man mit Code Musik macht.
|
||||
|
||||
@ -3,6 +3,9 @@ import * as tunes from '../../../src/repl/tunes.mjs';
|
||||
import HeadCommon from '../../components/HeadCommon.astro';
|
||||
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
---
|
||||
|
||||
<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">
|
||||
{
|
||||
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">
|
||||
<span class="bg-slate-800 p-2 rounded-md text-white">{getMetadata(tune)['title'] || name}</span>
|
||||
</div>
|
||||
<img src={`./img/example-${name}.png`} />
|
||||
<img src={`${baseNoTrailing}/img/example-${name}.png`} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { evaluate } from '@strudel.cycles/transpiler';
|
||||
import '../../../../test/runtime.mjs';
|
||||
import * as tunes from '../../repl/tunes.mjs';
|
||||
|
||||
export async function get({ params, request }) {
|
||||
export async function GET({ params, request }) {
|
||||
const { name } = params;
|
||||
const tune = tunes[name];
|
||||
const { pattern } = await evaluate(tune);
|
||||
@ -13,10 +13,7 @@ export async function get({ params, request }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
pianoroll({ time: 4, haps, ctx, playhead: 1, fold: 1, background: 'transparent', playheadColor: 'transparent' });
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
return {
|
||||
body: buffer,
|
||||
encoding: 'binary',
|
||||
};
|
||||
return new Response(buffer);
|
||||
}
|
||||
export function getStaticPaths() {
|
||||
return Object.keys(tunes).map((name) => ({
|
||||
|
||||
@ -145,7 +145,7 @@ In both cases, p4 is derived from the value of `freq` or `note`.
|
||||
## Limitations / Future Plans
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@ -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.
|
||||
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:
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ A standalone app has its own desktop / homescreen icon and launches in a separat
|
||||
without the browser ui.
|
||||
|
||||
<figure>
|
||||
<img src="./pwa/strudel-macos.png" alt="Strudel on MacOS" />
|
||||

|
||||
<figcaption>Strudel on MacOS</figcaption>
|
||||
</figure>
|
||||
|
||||
@ -67,7 +67,7 @@ Without a chromium based browser, you can use [nativefier](https://github.com/na
|
||||
2. run `npx nativefier strudel.cc`
|
||||
|
||||
<figure>
|
||||
<img src="./pwa/strudel-linux.png" alt="Strudel on Linux" />
|
||||

|
||||
<figcaption>Strudel on Linux</figcaption>
|
||||
</figure>
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ You can learn more about both of these approaches in the pages [Synths](/learn/s
|
||||
|
||||
# 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:
|
||||
|
||||
|
||||
@ -96,13 +96,13 @@ If you find a tidal control that's not on the list, please tell!
|
||||
## Sound
|
||||
|
||||
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.
|
||||
|
||||
### Audio Effects
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -110,4 +110,4 @@ Some of these have equivalent operators in the Mini Notation:
|
||||
|
||||
<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/).
|
||||
|
||||
@ -5,6 +5,9 @@ import { Content } from '../../../../my-patterns/README.md';
|
||||
import HeadCommon from '../../components/HeadCommon.astro';
|
||||
|
||||
const myPatterns = await getMyPatterns();
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
---
|
||||
|
||||
<head>
|
||||
@ -23,12 +26,12 @@ const myPatterns = await getMyPatterns();
|
||||
Object.entries(myPatterns).map(([name, tune]) => (
|
||||
<a
|
||||
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">
|
||||
<span class="bg-slate-800 p-2 rounded-md text-white">{name}</span>
|
||||
</div>
|
||||
<img src={`./swatch/${name}.png`} />
|
||||
<img src={`${baseNoTrailing}/swatch/${name}.png`} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { MiniRepl } from '../../docs/MiniRepl';
|
||||
|
||||
# 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"></div>
|
||||
|
||||
Welcome to the Strudel documentation pages!
|
||||
You've come to the right place if you want to learn how to make music with code.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ import React, { useContext } from 'react';
|
||||
import { useSettings, setIsZen } from '../settings.mjs';
|
||||
// import { ReplContext } from './Repl';
|
||||
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 }) {
|
||||
const {
|
||||
@ -85,7 +87,7 @@ export function Header({ context }) {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
onClick={() => handleUpdate()}
|
||||
title="update"
|
||||
className={cx(
|
||||
'flex items-center space-x-1',
|
||||
@ -123,7 +125,7 @@ export function Header({ context }) {
|
||||
{!isEmbedded && (
|
||||
<a
|
||||
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')}
|
||||
>
|
||||
<AcademicCapIcon className="w-6 h-6" />
|
||||
|
||||
@ -11,13 +11,13 @@ import { createClient } from '@supabase/supabase-js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import './Repl.css';
|
||||
import { Footer } from './Footer';
|
||||
import { Panel } from './panel/Panel';
|
||||
import { Header } from './Header';
|
||||
import { prebake } from './prebake.mjs';
|
||||
import * as tunes from './tunes.mjs';
|
||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
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 { settingPatterns } from '../settings.mjs';
|
||||
import { code2hash, hash2code } from './helpers.mjs';
|
||||
@ -132,6 +132,7 @@ export function Repl({ embedded = false }) {
|
||||
isLineWrappingEnabled,
|
||||
panelPosition,
|
||||
isZen,
|
||||
activePattern,
|
||||
} = useSettings();
|
||||
|
||||
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
|
||||
@ -148,6 +149,7 @@ export function Repl({ embedded = false }) {
|
||||
cleanupDraw();
|
||||
},
|
||||
afterEval: ({ code, meta }) => {
|
||||
updateUserCode(code);
|
||||
setMiniLocations(meta.miniLocations);
|
||||
setWidgets(meta.widgets);
|
||||
setPending(false);
|
||||
@ -229,7 +231,7 @@ export function Repl({ embedded = false }) {
|
||||
const handleChangeCode = useCallback(
|
||||
(c) => {
|
||||
setCode(c);
|
||||
//started && logger('[edit] code changed. hit ctrl+enter to update');
|
||||
// started && logger('[edit] code changed. hit ctrl+enter to update');
|
||||
},
|
||||
[started],
|
||||
);
|
||||
@ -248,14 +250,21 @@ export function Repl({ embedded = false }) {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
const handleUpdate = () => {
|
||||
isDirty && activateCode();
|
||||
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight');
|
||||
const handleUpdate = async (newCode, reset = false) => {
|
||||
if (reset) {
|
||||
clearCanvas();
|
||||
resetLoadedSounds();
|
||||
scheduler.setCps(1);
|
||||
await prebake(); // declare default samples
|
||||
}
|
||||
(newCode || isDirty) && activateCode(newCode);
|
||||
logger('[repl] code updated!');
|
||||
};
|
||||
|
||||
const handleShuffle = async () => {
|
||||
const { code, name } = getRandomTune();
|
||||
logger(`[repl] ✨ loading random tune "${name}"`);
|
||||
setActivePattern(name);
|
||||
clearCanvas();
|
||||
resetLoadedSounds();
|
||||
scheduler.setCps(1);
|
||||
@ -350,12 +359,12 @@ export function Repl({ embedded = false }) {
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</section>
|
||||
{panelPosition === 'right' && !isEmbedded && <Footer context={context} />}
|
||||
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
|
||||
</div>
|
||||
{error && (
|
||||
<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>
|
||||
</ReplContext.Provider>
|
||||
);
|
||||
|
||||
45
website/src/repl/panel/ConsoleTab.jsx
Normal file
45
website/src/repl/panel/ConsoleTab.jsx
Normal 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;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Fragment, useEffect } 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() {
|
||||
const [path, setPath] = useState([]);
|
||||
24
website/src/repl/panel/Forms.jsx
Normal file
24
website/src/repl/panel/Forms.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
website/src/repl/panel/Panel.jsx
Normal file
119
website/src/repl/panel/Panel.jsx
Normal 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);
|
||||
}
|
||||
146
website/src/repl/panel/PatternsTab.jsx
Normal file
146
website/src/repl/panel/PatternsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import jsdocJson from '../../../doc.json';
|
||||
import jsdocJson from '../../../../doc.json';
|
||||
const visibleFunctions = jsdocJson.docs
|
||||
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
|
||||
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
|
||||
187
website/src/repl/panel/SettingsTab.jsx
Normal file
187
website/src/repl/panel/SettingsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
website/src/repl/panel/SoundsTab.jsx
Normal file
89
website/src/repl/panel/SoundsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
website/src/repl/panel/WelcomeTab.jsx
Normal file
53
website/src/repl/panel/WelcomeTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,9 @@ import { registerSynthSounds, registerZZFXSounds, samples } from '@strudel.cycle
|
||||
import './piano.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() {
|
||||
// https://archive.org/details/SalamanderGrandPianoV3
|
||||
// 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
|
||||
// seems to be a problem with soundfont2
|
||||
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://api.github.com/repositories/126427031/contents/
|
||||
// LICENSE: CC0 general-purpose
|
||||
samples(`./vcsl.json`, 'github:sgossner/VCSL/master/', { prebake: true }),
|
||||
samples(`./tidal-drum-machines.json`, 'github:ritchse/tidal-drum-machines/main/machines/', {
|
||||
samples(`${baseNoTrailing}/vcsl.json`, 'github:sgossner/VCSL/master/', { prebake: true }),
|
||||
samples(`${baseNoTrailing}/tidal-drum-machines.json`, 'github:ritchse/tidal-drum-machines/main/machines/', {
|
||||
prebake: true,
|
||||
tag: 'drum-machines',
|
||||
}),
|
||||
samples(`./EmuSP12.json`, `./EmuSP12/`, { prebake: true, tag: 'drum-machines' }),
|
||||
samples(`${baseNoTrailing}/EmuSP12.json`, `${baseNoTrailing}/EmuSP12/`, { prebake: true, tag: 'drum-machines' }),
|
||||
samples(
|
||||
{
|
||||
casio: ['casio/high.wav', 'casio/low.wav', 'casio/noise.wav'],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { persistentMap } from '@nanostores/persistent';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { register } from '@strudel.cycles/core';
|
||||
import * as tunes from './repl/tunes.mjs';
|
||||
|
||||
export const defaultSettings = {
|
||||
activeFooter: 'intro',
|
||||
@ -17,6 +18,8 @@ export const defaultSettings = {
|
||||
isZen: false,
|
||||
soundsFilter: 'all',
|
||||
panelPosition: 'bottom',
|
||||
userPatterns: '{}',
|
||||
activePattern: '',
|
||||
};
|
||||
|
||||
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
|
||||
@ -33,6 +36,7 @@ export function useSettings() {
|
||||
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
|
||||
fontSize: Number(state.fontSize),
|
||||
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 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) {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user