Compare commits

..

12 Commits

Author SHA1 Message Date
Felix Roos 2dc921e375 fix: add fun fact 2026-01-16 10:14:17 +01:00
Felix Roos 3d13955c41 fix: prefix usernames with @ 2026-01-16 10:08:41 +01:00
Felix Roos ae9638c353 update changelog + fix script to not miss entries 2026-01-16 09:58:14 +01:00
Switch Angel AKA Jade Rose d642c0e12f Merge pull request 'Add shortcut for navigating through labels!' (#1807) from jade/dollar_shortcut into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1807
2026-01-13 00:18:08 +01:00
Switch Angel AKA Jade Rose efe7c9394f Merge branch 'main' into jade/dollar_shortcut 2026-01-12 20:30:14 +01:00
Alex McLean 75db9ff16e Merge pull request 'add warm.strudel.cc to faq' (#1891) from faq-warm into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1891
2026-01-12 00:17:36 +01:00
alex 31cacc3c29 add warm to faq 2026-01-11 16:23:17 +00:00
Switch Angel AKA Jade Rose af88471f6b Merge branch 'main' into jade/dollar_shortcut 2026-01-11 06:51:47 +01:00
Switch Angel AKA Jade Rose f9a9993c42 Merge branch 'main' into jade/dollar_shortcut 2026-01-11 06:32:29 +01:00
Jade (Rose) Rowland f0aac2098a update_shortcut 2025-12-03 22:59:20 -05:00
Jade (Rose) Rowland c1a185e852 fix null case 2025-12-03 15:25:05 -05:00
Jade (Rose) Rowland b9fce15042 working 2025-12-03 15:18:32 -05:00
8 changed files with 1143 additions and 245 deletions
+1061 -123
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -24,6 +24,7 @@ import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -119,6 +120,14 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
preventDefault: true,
run: () => onStop?.(),
},
{
key: 'Alt-w',
run: (view) => jumpToCharacter(view, '$', 1),
},
{
key: 'Alt-q',
run: (view) => jumpToCharacter(view, '$', -1),
},
/* {
key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()),
+31
View File
@@ -0,0 +1,31 @@
import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search';
export function jumpToCharacter(view, character, direction = 1) {
const { state, dispatch } = view;
const pos = state.selection.main.head;
const cursor = new SearchCursor(state.doc, character);
let characterPositions = [];
let jumpPos;
while (!cursor.next().done) {
characterPositions.push(cursor.value.to);
}
if (!characterPositions.length) {
return false;
}
if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
} else {
jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0);
}
if (jumpPos == null) {
return false;
}
dispatch({
selection: EditorSelection.cursor(jumpPos - 1),
scrollIntoView: true,
});
return true;
}
-12
View File
@@ -3076,15 +3076,3 @@ export const bmod = (config) => pure({}).bmod(config);
export const { transient } = registerControl(['transient', 'transsustain']);
export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr');
/**
* Input audio device to be used as a sound source in a pattern
*
* @name input
* @param {string | number | Pattern} input Name or index of the input audio device to use
* @example
* // Please be careful about feedback!
* input("1 2").gain(0.5)
* .stretch(0.5).lfo({ s: 0.5 })
*/
export const { input } = registerControl('input');
+6 -85
View File
@@ -14,11 +14,9 @@ import {
createFilter,
effectSend,
gainNode,
getADSRValues,
getCompressor,
getDistortion,
getLfo,
getParamADSR,
getWorklet,
releaseAudioNode,
webAudioTimeout,
@@ -178,19 +176,6 @@ export const getAudioDevices = async () => {
return devicesMap;
};
let AUDIO_INPUTS;
export const getAudioInputs = async () => {
await navigator.mediaDevices.getUserMedia({ audio: true });
let mediaDevices = await navigator.mediaDevices.enumerateDevices();
mediaDevices = mediaDevices.filter((device) => device.kind === 'audioinput' && device.deviceId !== 'default');
const devicesMap = new Map();
devicesMap.set(DEFAULT_AUDIO_DEVICE_NAME, '');
mediaDevices.forEach((device) => {
devicesMap.set(device.label, device.deviceId);
});
return devicesMap;
};
let defaultDefaultValues = {
s: 'triangle',
gain: 0.8,
@@ -269,7 +254,6 @@ export function loadWorklets() {
return workletsLoading;
}
let AUDIO_OUTPUTS;
// this function should be called on first user interaction (to avoid console warning)
export async function initAudio(options = {}) {
const {
@@ -290,8 +274,8 @@ export async function initAudio(options = {}) {
if (audioDeviceName != null && audioDeviceName != DEFAULT_AUDIO_DEVICE_NAME) {
try {
AUDIO_OUTPUTS = await getAudioDevices();
const id = AUDIO_OUTPUTS.get(audioDeviceName);
const devices = await getAudioDevices();
const id = devices.get(audioDeviceName);
const isValidID = (id ?? '').length > 0;
if (audioCtx.sinkId !== id && isValidID) {
await audioCtx.setSinkId(id);
@@ -332,51 +316,6 @@ export async function initAudioOnFirstClick(options) {
return audioReady;
}
const CACHED_INPUTS = {};
export async function getInput(input) {
let sourceNode = CACHED_INPUTS[input];
if (sourceNode === undefined) {
const ac = getAudioContext();
if (!AUDIO_INPUTS) {
AUDIO_INPUTS = await getAudioInputs();
}
const available = Array.from(AUDIO_INPUTS.keys());
// Convert numerical inputs to their corresponding device name
const deviceName = typeof input === 'number' ? available[input] : input;
const inputId = AUDIO_INPUTS.get(deviceName);
if (inputId === undefined) {
throw new Error(`[superdough] input "${input}" not found. Available inputs: ${available.join(', ')}`);
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: inputId },
channelCount: { ideal: 2 },
sampleRate: { ideal: ac.sampleRate },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
latency: { ideal: 0.01 },
},
});
sourceNode = ac.createMediaStreamSource(stream);
CACHED_INPUTS[input] = sourceNode;
}
return sourceNode;
}
async function getInputHandle(input, value, start, end) {
const inputNode = await getInput(input);
const envGain = gainNode(0);
inputNode.connect(envGain);
const [attack, decay, sustain, release] = getADSRValues(
[value.attack, value.decay, value.sustain, value.release],
'linear',
[0.001, 0.05, 0.6, 0.01],
);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, start, end, 'linear');
return { inputNode, envGain };
}
let controller;
export function getSuperdoughAudioController() {
if (controller == null) {
@@ -469,9 +408,9 @@ function mapChannelNumbers(channels) {
}
class Chain {
constructor() {
this.audioNodes = [];
this.tails = [];
constructor(head) {
this.audioNodes = [head];
this.tails = [head];
}
connect(...nodes) {
nodes.forEach((node) => {
@@ -558,7 +497,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
fft = getDefaultValue('fft'), // fftSize 0 - 10
FX = [],
FXrelease,
input,
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
@@ -601,28 +539,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
value.s = s;
}
const chain = new Chain(); // connection manager which tracks audio nodes for releasing
// get source AudioNode
let sourceNode;
if (source) {
sourceNode = source(t, value, hapDuration, cps);
nodes.main['source'] = [sourceNode];
} else if (input) {
const { inputNode, envGain } = await getInputHandle(input, value, t, end);
sourceNode = envGain;
webAudioTimeout(
ac,
() => {
chain.releaseNodes();
activeSoundSources.delete(chainID);
// We disconnect inputNode instead of releasing it
// because we want inputs to persist
inputNode.disconnect(envGain);
},
t,
endWithRelease,
);
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
@@ -658,7 +579,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
return;
}
chain.connect(sourceNode);
const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing
FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
for (let [idx, fx] of Object.entries(FX)) {
const key = idx == FX.length - 1 ? 'main' : idx;
-13
View File
@@ -5484,19 +5484,6 @@ exports[`runs examples > example "inhabit" example index 1 1`] = `
]
`;
exports[`runs examples > example "input" example index 0 1`] = `
[
"[ 0/1 → 1/2 | input:1 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 1/2 → 1/1 | input:2 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 1/1 → 3/2 | input:1 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 3/2 → 2/1 | input:2 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 2/1 → 5/2 | input:1 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 5/2 → 3/1 | input:2 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 3/1 → 7/2 | input:1 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
"[ 7/2 → 4/1 | input:2 gain:0.5 stretch:0.5 lfo:{0:{control:stretch sync:0.5} __ids:{}} ]",
]
`;
exports[`runs examples > example "inside" example index 0 1`] = `
[
"[ 0/1 → 1/8 | note:D3 ]",
+28 -12
View File
@@ -1,14 +1,30 @@
fetch('https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&page=1')
.then((res) => res.json())
.then((pulls) => {
const r = pulls
.filter((pull) => pull.merged)
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
.map((pull) => `${pull.closed_at} ${pull.title} by ${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `)
.join('\n');
console.log(r);
});
// this script loads all merged PRs within the given page range
// it can be used to update the CHANGELOG.md file in a semi-automated way
// the problem: codeberg doesn't support loading merged PRs, so we have to filter them in memory
// luckily, we can sort after "recentupdate", which means we can do incremental changelog generation
// todo: support setting a "last_updated" date, so the script would automatically check how far it has to go
/*
async function main() {
let pageStart = 1;
let pageEnd = 1;
let prs = [];
for (let p = pageStart; p <= pageEnd; p++) {
console.log(`load page ${p}/${pageEnd}`);
const res = await fetch(
`https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&sort=recentupdate&page=${p}`,
);
const pulls = await res.json();
const merged = pulls.filter((pull) => pull.merged);
prs = prs.concat(merged);
}
const output = prs
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
.map(
(pull) => `- ${pull.closed_at} ${pull.title} by @${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `,
)
.join('\n');
console.log('-------------');
console.log(output);
}
*/
main();
+8
View File
@@ -19,6 +19,14 @@ While there is no charge there are some caveats, e.g.:
- the source code must stay free, i.e. you cannot distribute strudel or tidal as part of projects with incompatible licenses - see the [license](https://www.gnu.org/licenses/agpl-3.0.en.html) for details.
- the contributed examples and tracks are also separately licensed, and must not e.g. be used to train AI models without permission.
## How do I try out the latest features?
The main, stable strudel website is [strudel.cc](https://strudel.cc/). There is also [warm.strudel.cc](https://warm.strudel.cc), known as "warm strudel", which has the latest development features. You might find warm strudel has bug fixes and features that the main website doesn't, but it will often be less stable and probably not suitable for important performances.
Alternatively, you can run strudel locally to try out the latest features. You can find development-oriented [instructions for that here](https://codeberg.org/uzu/strudel/src/branch/main/CONTRIBUTING.md#project-setup).
You can see the [latest changes here](https://codeberg.org/uzu/strudel/pulls?q=&type=all&sort=recentupdate&state=closed&labels=&milestone=0&project=0&assignee=0&poster=0), as 'pull requests'.
## How to record or export audio?
Strudel is not a digital audio workstation and does not operate following the same principles shared by most traditional audio softwares. However, there are multiple ways to record the audio -- and video -- output of Strudel: