Compare commits

..

11 Commits

Author SHA1 Message Date
prezmop d92d93e37d Merge branch 'tidalcycles:main' into prepare 2025-06-01 12:07:24 +00:00
prezmop 685937911c correct the signnature of prepare() in README 2025-05-24 05:55:04 +09:00
prezmop 31a164a09f document prepare() in the README file 2025-05-24 05:40:01 +09:00
prezmop be8b4d7873 make prepare() async 2025-05-24 04:34:02 +09:00
prezmop 60388e5798 lint 2025-04-26 18:09:48 +09:00
prezmop 88823d26b1 formatting 2025-04-26 18:08:07 +09:00
prezmop e4099946cd use prepare in web 2025-04-26 18:05:16 +09:00
prezmop 3a49ba6eb1 use prepare in repl 2025-04-26 18:04:50 +09:00
prezmop eac45d06b3 add prepare to cyclist 2025-04-26 18:03:44 +09:00
prezmop 8674b61c2c add prepare functionality to webaudio 2025-04-26 17:47:54 +09:00
prezmop af27364c45 add prepare() to superdough 2025-04-26 17:47:27 +09:00
14 changed files with 109 additions and 209 deletions
-2
View File
@@ -24,7 +24,6 @@ import { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs';
import { persistentAtom } from '@nanostores/persistent';
import { dragDropPlugin } from './dragdrop.mjs';
const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
@@ -79,7 +78,6 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
javascript(),
sliderPlugin,
widgetPlugin,
dragDropPlugin,
// indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle),
-112
View File
@@ -1,112 +0,0 @@
import { ViewPlugin } from '@codemirror/view';
import { logger } from '@strudel/core';
// Helper function to read file content
async function readFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
// Check for common text file formats, to avoid
// accidentally loading images or other files
function isCodeFile(file) {
const codeExtensions = ['.js', '.strudel', '.str'];
const fileName = file.name.toLowerCase();
return codeExtensions.some((ext) => fileName.endsWith(ext)) || file.type.startsWith('text/');
}
// Create drag and drop extension
export const dragDropPlugin = ViewPlugin.fromClass(
class {
constructor(view) {
this.view = view;
this.handleDrop = this.handleDrop.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnter = this.handleDragEnter.bind(this);
this.handleDragLeave = this.handleDragLeave.bind(this);
// Add event listeners
view.dom.addEventListener('drop', this.handleDrop);
view.dom.addEventListener('dragover', this.handleDragOver);
view.dom.addEventListener('dragenter', this.handleDragEnter);
view.dom.addEventListener('dragleave', this.handleDragLeave);
}
handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}
handleDragEnter(e) {
e.preventDefault();
e.stopPropagation();
this.view.dom.classList.add('cm-drag-over');
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
// Only remove the class if we're leaving the editor entirely
if (!this.view.dom.contains(e.relatedTarget)) {
this.view.dom.classList.remove('cm-drag-over');
}
}
async handleDrop(e) {
e.preventDefault();
e.stopPropagation();
this.view.dom.classList.remove('cm-drag-over');
const files = Array.from(e.dataTransfer.files);
// Filter for code files only
const codeFiles = files.filter(isCodeFile);
if (codeFiles.length === 0) {
logger('No code files were dropped. Please drop text-based files.', 'warning');
return;
}
try {
// Read all files
const fileContents = await Promise.all(
codeFiles.map(async (file) => {
const content = await readFileContent(file);
return `// File: ${file.name}\n${content}`;
}),
);
// Combine content
const newContent = fileContents.join('\n\n');
// Replace entire editor contents
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: newContent },
selection: { anchor: newContent.length },
});
// Focus the editor
this.view.focus();
// Show success message
const fileNames = codeFiles.map((f) => f.name).join(', ');
logger(`Successfully loaded ${codeFiles.length} file(s): ${fileNames}`, 'highlight');
} catch (error) {
console.error('Error reading dropped files:', error);
logger(`Error loading files: ${error.message}`, 'error');
}
}
destroy() {
this.view.dom.removeEventListener('drop', this.handleDrop);
this.view.dom.removeEventListener('dragover', this.handleDragOver);
this.view.dom.removeEventListener('dragenter', this.handleDragEnter);
this.view.dom.removeEventListener('dragleave', this.handleDragLeave);
}
},
);
+35
View File
@@ -11,6 +11,7 @@ export class Cyclist {
constructor({
interval,
onTrigger,
onPrepare,
onToggle,
onError,
getTime,
@@ -18,6 +19,7 @@ export class Cyclist {
setInterval,
clearInterval,
beforeStart,
prepareTime = 4,
}) {
this.started = false;
this.beforeStart = beforeStart;
@@ -26,6 +28,8 @@ export class Cyclist {
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.lastBegin = 0; // query begin of last tick
this.lastEnd = 0; // query end of last tick
this.preparedUntil = 0;
this.prepareTime = prepareTime;
this.getTime = getTime; // get absolute time
this.num_cycles_at_cps_change = 0;
this.seconds_at_cps_change; // clock phase when cps was changed
@@ -85,6 +89,33 @@ export class Cyclist {
setInterval,
clearInterval,
);
onPrepare
? (this.prepClock = createClock(
getTime,
(phase, duration, _, t) => {
try {
const start = Math.max(t, this.preparedUntil);
const end = t + this.prepareTime;
this.preparedUntil = end;
const haps = this.pattern.queryArc(start, end, { _cps: 1 });
haps.forEach((hap) => {
onPrepare?.(hap);
});
} catch (e) {
logger(`[cyclist] error: ${e.message}`);
onError?.(e);
}
},
1, // duration of each cycle
1,
0,
setInterval,
clearInterval,
))
: null;
}
now() {
if (!this.started) {
@@ -106,16 +137,19 @@ export class Cyclist {
}
logger('[cyclist] start');
this.clock.start();
this.prepClock?.start();
this.setStarted(true);
}
pause() {
logger('[cyclist] pause');
this.clock.pause();
this.prepClock?.pause();
this.setStarted(false);
}
stop() {
logger('[cyclist] stop');
this.clock.stop();
this.prepClock?.stop();
this.lastEnd = 0;
this.setStarted(false);
}
@@ -130,6 +164,7 @@ export class Cyclist {
return;
}
this.cps = cps;
this.preparedUntil = 0;
this.num_ticks_since_cps_change = 0;
}
log(begin, end, haps) {
+12
View File
@@ -8,6 +8,7 @@ import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
export function repl({
defaultOutput,
defaultPrepare,
onEvalError,
beforeEval,
beforeStart,
@@ -47,6 +48,7 @@ export function repl({
const schedulerOptions = {
onTrigger: getTrigger({ defaultOutput, getTime }),
onPrepare: getPrepare({ defaultPrepare }),
getTime,
onToggle: (started) => {
updateState({ started });
@@ -238,3 +240,13 @@ export const getTrigger =
logger(`[cyclist] error: ${err.message}`, 'error');
}
};
export const getPrepare =
({ defaultPrepare }) =>
async (hap) => {
try {
await defaultPrepare(hap);
} catch (err) {
logger(`[cyclist] error: ${err.message}`, 'error');
}
};
+23
View File
@@ -92,6 +92,29 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
- `deadline`: seconds until the sound should play (0 = immediate)
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
### prepare(value)
Informs superdough that a sound will be needed in the future.
If the sound is a sample that is not loaded yet, it will be fetched.
Otherwise does nothing.
`value` has a syntax identical to the one used `superdough()`.
```js
prepare({ s: 'bd', delay: 0.5 });
// some time later
superdough({ s: 'bd', delay: 0.5 }, 0, 1);
```
Can be awaited to ensure that a given sound is ready to play.
```js
const sound = { s: 'hh' };
await prepare(sound);
superdough(sound, 0, 1);
```
### registerSynthSounds()
Loads the default waveforms `sawtooth`, `square`, `triangle` and `sine`. Use them like this:
+16 -7
View File
@@ -265,19 +265,28 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
processSampleMap(
sampleMap,
(key, bank) =>
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: bank,
baseUrl,
prebake,
tag,
}),
registerSound(
key,
(t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank),
{
type: 'sample',
samples: bank,
baseUrl,
prebake,
tag,
},
(hapValue) => onPrepareSample(hapValue, bank),
),
baseUrl,
);
};
const cutGroups = [];
export async function onPrepareSample(hapValue, bank, resolveUrl) {
await getSampleBuffer(hapValue, bank, resolveUrl);
}
export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
let {
s,
+12 -2
View File
@@ -30,9 +30,9 @@ export function setMultiChannelOrbits(bool) {
export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) {
export function registerSound(key, onTrigger, data = {}, onPrepare = () => {}) {
key = key.toLowerCase().replace(/\s+/g, '_');
soundMap.setKey(key, { onTrigger, data });
soundMap.setKey(key, { onTrigger, data, onPrepare });
}
let gainCurveFunc = (val) => val;
@@ -757,3 +757,13 @@ export const superdough = async (value, t, hapDuration, cps) => {
export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps);
};
export const prepare = async (value) => {
const { onPrepare } = getSound(value.s);
if (onPrepare) {
if (value.bank && value.s) {
value.s = `${value.bank}_${value.s}`;
}
await onPrepare(value);
}
};
+4 -1
View File
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as strudel from '@strudel/core';
import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough';
import { superdough, prepare, getAudioContext, setLogger, doughTrigger } from 'superdough';
const { Pattern, logger, repl } = strudel;
setLogger(logger);
@@ -20,6 +20,8 @@ export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(h
export const webaudioOutput = (hap, deadline, hapDuration, cps, t) =>
superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration);
export const webaudioPrepare = (hap) => prepare(hap2value(hap));
Pattern.prototype.webaudio = function () {
return this.onTrigger(webaudioOutputTrigger);
};
@@ -28,6 +30,7 @@ export function webaudioRepl(options = {}) {
options = {
getTime: () => getAudioContext().currentTime,
defaultOutput: webaudioOutput,
defaultPrepare: webaudioPrepare,
...options,
};
return repl(options);
-37
View File
@@ -69,40 +69,3 @@
text-decoration: underline 0.18rem;
text-underline-offset: 0.22rem;
}
/* Drag and drop styles */
#code .cm-editor.cm-drag-over {
outline: 2px dashed #4caf50;
outline-offset: -2px;
background-color: rgba(76, 175, 80, 0.05) !important;
}
/* Download button styles */
.download-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 100;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 6px;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
transition: all 0.2s ease;
pointer-events: auto;
}
.download-button:hover {
background: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.9);
border-color: rgba(255, 255, 255, 0.2);
}
.download-button:active {
transform: scale(0.95);
}
.download-button svg {
display: block;
}
+3 -7
View File
@@ -1,16 +1,14 @@
import DownloadButton from './DownloadButton';
// type Props = {
// containerRef: React.MutableRefObject<HTMLElement | null>,
// editorRef: React.MutableRefObject<HTMLElement | null>,
// init: () => void
// }
export function Code(Props) {
const { editorRef, containerRef, init, context } = Props;
const { editorRef, containerRef, init } = Props;
return (
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow relative'}
className={'text-gray-100 cursor-text pb-0 overflow-auto grow'}
id="code"
ref={(el) => {
containerRef.current = el;
@@ -18,8 +16,6 @@ export function Code(Props) {
init();
}
}}
>
{context && <DownloadButton context={context} />}
</section>
></section>
);
}
@@ -1,39 +0,0 @@
import { useCallback } from 'react';
export default function DownloadButton({ context }) {
const handleDownload = useCallback(() => {
// Get the current code from the editor
const code = context.editorRef?.current?.code || '';
// Create a blob with the code
const blob = new Blob([code], { type: 'text/javascript' });
// Create a temporary URL for the blob
const url = window.URL.createObjectURL(blob);
// Create a temporary anchor element and trigger download
const a = document.createElement('a');
a.href = url;
a.download = `strudel-pattern-${new Date().toISOString().slice(0, 10)}.js`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Clean up the URL
window.URL.revokeObjectURL(url);
}, []);
return (
<button
onClick={handleDownload}
className="download-button"
title="Download pattern as .js file"
aria-label="Download pattern"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12L3 7L4.4 5.6L7 8.2V0H9V8.2L11.6 5.6L13 7L8 12Z" fill="currentColor" />
<path d="M14 14V10H16V16H0V10H2V14H14Z" fill="currentColor" />
</svg>
</button>
);
}
-1
View File
@@ -1,6 +1,5 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
import ArrowDownTrayIcon from '@heroicons/react/20/solid/ArrowDownTrayIcon';
import cx from '@src/cx.mjs';
import { useSettings, setIsZen } from '../../settings.mjs';
import '../Repl.css';
+1 -1
View File
@@ -20,7 +20,7 @@ export default function ReplEditor(Props) {
<Loader active={pending} />
<Header context={context} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} context={context} />
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
{!isZen && panelPosition === 'right' && <VerticalPanel context={context} />}
</div>
<UserFacingErrorMessage error={error} />
+3
View File
@@ -10,6 +10,7 @@ import { transpiler } from '@strudel/transpiler';
import {
getAudioContextCurrentTime,
webaudioOutput,
webaudioPrepare,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
@@ -63,6 +64,7 @@ export function useReplContext() {
const { isSyncEnabled, audioEngineTarget } = useSettings();
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const defaultPrepare = shouldUseWebaudio ? webaudioPrepare : undefined;
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
const init = useCallback(() => {
@@ -71,6 +73,7 @@ export function useReplContext() {
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput,
defaultPrepare,
getTime,
setInterval,
clearInterval,