This commit is contained in:
Nikita 2025-10-20 20:22:16 +03:00
parent 428a4b3dd7
commit 4bdaf577c0
9 changed files with 360 additions and 318 deletions

View file

@ -42,9 +42,9 @@ const extensions = {
isMultiCursorEnabled: (on) => isMultiCursorEnabled: (on) =>
on on
? [ ? [
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey), EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
] ]
: [], : [],
}; };
const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
@ -269,10 +269,9 @@ export class StrudelMirror {
await this.repl.evaluate(this.code); await this.repl.evaluate(this.code);
} }
async exportAudio(begin, end, sampleRate, downloadName = undefined) { async exportAudio(begin, end, sampleRate, downloadName = undefined) {
await this.repl.evaluate(this.code, false) await this.repl.evaluate(this.code, false);
await this.repl.exportAudio(begin, end, sampleRate, downloadName); await this.repl.exportAudio(begin, end, sampleRate, downloadName);
this.repl.scheduler.stop(); this.repl.scheduler.stop();
} }
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.scheduler.stop();

View file

@ -117,7 +117,7 @@ export class Cyclist {
throw new Error('Scheduler: no pattern set! call .setPattern first.'); throw new Error('Scheduler: no pattern set! call .setPattern first.');
} }
logger('[cyclist] exporting'); logger('[cyclist] exporting');
await renderPatternAudio(this.pattern, this.cps, begin, end, sampleRate, downloadName) await renderPatternAudio(this.pattern, this.cps, begin, end, sampleRate, downloadName);
} }
pause() { pause() {

View file

@ -91,7 +91,8 @@ export function repl({
const stop = () => scheduler.stop(); const stop = () => scheduler.stop();
const start = () => scheduler.start(); const start = () => scheduler.start();
const exportAudio = async (begin, end, sampleRate, downloadName = undefined) => await scheduler.exportAudio(begin, end, sampleRate, downloadName); const exportAudio = async (begin, end, sampleRate, downloadName = undefined) =>
await scheduler.exportAudio(begin, end, sampleRate, downloadName);
const pause = () => scheduler.pause(); const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle(); const toggle = () => scheduler.toggle();
const setCps = (cps) => { const setCps = (cps) => {
@ -263,18 +264,18 @@ export function repl({
export const getTrigger = export const getTrigger =
({ getTime, defaultOutput }) => ({ getTime, defaultOutput }) =>
async (hap, deadline, duration, cps, t) => { async (hap, deadline, duration, cps, t) => {
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004 // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
try { try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps, t); await defaultOutput(hap, deadline, duration, cps, t);
}
if (hap.context.onTrigger) {
// call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t);
}
} catch (err) {
errorLogger(err, 'getTrigger');
} }
}; if (hap.context.onTrigger) {
// call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t);
}
} catch (err) {
errorLogger(err, 'getTrigger');
}
};

View file

@ -1,15 +1,15 @@
import { analysers, resetGlobalEffects } from "./superdough.mjs"; import { analysers, resetGlobalEffects } from './superdough.mjs';
let audioContext; let audioContext;
export const setDefaultAudioContext = () => { export const setDefaultAudioContext = () => {
audioContext = new AudioContext(); audioContext = new AudioContext();
resetGlobalEffects() resetGlobalEffects();
return audioContext; return audioContext;
}; };
export const setAudioContext = (context) => { export const setAudioContext = (context) => {
audioContext = context audioContext = context;
return audioContext; return audioContext;
}; };

View file

@ -348,7 +348,7 @@ export function applyFM(param, value, begin) {
duration, duration,
} = value; } = value;
let modulator; let modulator;
let stop = () => { }; let stop = () => {};
if (fmModulationIndex) { if (fmModulationIndex) {
const ac = getAudioContext(); const ac = getAudioContext();

View file

@ -212,7 +212,9 @@ export function loadWorklets() {
if (!workletsLoading) { if (!workletsLoading) {
const audioCtx = getAudioContext(); const audioCtx = getAudioContext();
const allWorkletURLs = externalWorklets.concat([workletsUrl]); const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(() => workletsLoading = undefined); workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
() => (workletsLoading = undefined),
);
} }
return workletsLoading; return workletsLoading;
@ -250,7 +252,7 @@ export async function initAudio(options = {}) {
logger('[superdough] failed to set audio interface', 'warning'); logger('[superdough] failed to set audio interface', 'warning');
} }
} }
if (!audioCtx instanceof OfflineAudioContext) { if ((!audioCtx) instanceof OfflineAudioContext) {
await audioCtx.resume(); await audioCtx.resume();
} }
if (disableWorklets) { if (disableWorklets) {
@ -288,7 +290,7 @@ function getSuperdoughAudioController() {
} }
export function setSuperdoughAudioController(newController) { export function setSuperdoughAudioController(newController) {
controller = newController controller = newController;
return controller; return controller;
} }
@ -401,7 +403,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
); );
return; return;
} // FIXME: fix } // FIXME: fix
// destructure // destructure
let { let {
tremolo, tremolo,
tremolosync, tremolosync,
@ -743,4 +745,4 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
export const superdoughTrigger = (t, hap, ct, cps) => { export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps); superdough(hap, t - ct, hap.duration / cps, cps);
}; };

View file

@ -5,7 +5,23 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as strudel from '@strudel/core'; import * as strudel from '@strudel/core';
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet, setAudioContext, getSampleBufferSource, loadBuffer, getSampleInfo, getSound, initAudio, setSuperdoughAudioController, loadWorklets, setMaxPolyphony, setMultiChannelOrbits } from 'superdough'; import {
superdough,
getAudioContext,
setLogger,
doughTrigger,
registerWorklet,
setAudioContext,
getSampleBufferSource,
loadBuffer,
getSampleInfo,
getSound,
initAudio,
setSuperdoughAudioController,
loadWorklets,
setMaxPolyphony,
setMultiChannelOrbits,
} from 'superdough';
import './supradough.mjs'; import './supradough.mjs';
import { workletUrl } from 'supradough'; import { workletUrl } from 'supradough';
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs'; import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
@ -28,59 +44,63 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
}; };
export async function renderPatternAudio(pattern, cps, begin, end, sampleRate, downloadName = undefined) { export async function renderPatternAudio(pattern, cps, begin, end, sampleRate, downloadName = undefined) {
const oldAudioCtx = getAudioContext() const oldAudioCtx = getAudioContext();
await oldAudioCtx.close() await oldAudioCtx.close();
let audioContext = new OfflineAudioContext(2, (end - begin) / cps * sampleRate, sampleRate); let audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
setAudioContext(audioContext) setAudioContext(audioContext);
let context = getAudioContext() let context = getAudioContext();
setSuperdoughAudioController(new SuperdoughAudioController(context)) setSuperdoughAudioController(new SuperdoughAudioController(context));
setMaxPolyphony(1024) setMaxPolyphony(1024);
setMultiChannelOrbits(true) setMultiChannelOrbits(true);
await loadWorklets() await loadWorklets();
logger('[webaudio] start rendering'); logger('[webaudio] start rendering');
let haps = pattern.queryArc(begin, end, { _cps: cps }) let haps = pattern.queryArc(begin, end, { _cps: cps });
Promise.all(haps.map(async h => { Promise.all(
let s; haps.map(async (h) => {
if (h.value.s) { let s;
if (h.value.bank) { if (h.value.s) {
s = `${h.value.bank}_${h.value.s}`; if (h.value.bank) {
s = `${h.value.bank}_${h.value.s}`;
} else {
s = h.value.s;
}
let bank = getSound(s).data.samples;
if (bank) {
let { url: sampleUrl, label } = getSampleInfo(h.value, bank);
await loadBuffer(sampleUrl, context, label);
}
} }
else { }),
s = h.value.s );
}
let bank = getSound(s).data.samples
if (bank) {
let { url: sampleUrl, label } = getSampleInfo(h.value, bank);
await loadBuffer(sampleUrl, context, label)
}
}
}))
haps.forEach(async (hap) => { haps.forEach(async (hap) => {
if (hap.hasOnset()) { if (hap.hasOnset()) {
hap.ensureObjectValue(); hap.ensureObjectValue();
await superdough(hap.value, hap.whole.begin.valueOf() / cps, hap.duration, cps, hap.whole?.begin.valueOf()); await superdough(hap.value, hap.whole.begin.valueOf() / cps, hap.duration, cps, hap.whole?.begin.valueOf());
} }
})
return context.startRendering().then((renderedBuffer) => {
console.log(renderedBuffer)
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
a.download = `${downloadName}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}).finally(async () => {
setAudioContext(null)
setSuperdoughAudioController(null)
await initAudio()
}); });
return context
.startRendering()
.then((renderedBuffer) => {
console.log(renderedBuffer);
const wavBuffer = audioBufferToWav(renderedBuffer);
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
a.download = `${downloadName}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.finally(async () => {
setAudioContext(null);
setSuperdoughAudioController(null);
await initAudio();
});
} }
export function webaudioRepl(options = {}) { export function webaudioRepl(options = {}) {
@ -96,97 +116,97 @@ Pattern.prototype.dough = function () {
return this.onTrigger(doughTrigger, 1); return this.onTrigger(doughTrigger, 1);
}; };
function audioBufferToWav(buffer, opt) { function audioBufferToWav(buffer, opt) {
opt = opt || {} opt = opt || {};
var numChannels = buffer.numberOfChannels var numChannels = buffer.numberOfChannels;
var sampleRate = buffer.sampleRate var sampleRate = buffer.sampleRate;
var format = opt.float32 ? 3 : 1 var format = opt.float32 ? 3 : 1;
var bitDepth = format === 3 ? 32 : 16 var bitDepth = format === 3 ? 32 : 16;
var result var result;
if (numChannels === 2) { if (numChannels === 2) {
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)) result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
} else { } else {
result = buffer.getChannelData(0) result = buffer.getChannelData(0);
} }
return encodeWAV(result, format, sampleRate, numChannels, bitDepth) return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
} }
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) { function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
var bytesPerSample = bitDepth / 8 var bytesPerSample = bitDepth / 8;
var blockAlign = numChannels * bytesPerSample var blockAlign = numChannels * bytesPerSample;
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample) var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
var view = new DataView(buffer) var view = new DataView(buffer);
/* RIFF identifier */ /* RIFF identifier */
writeString(view, 0, 'RIFF') writeString(view, 0, 'RIFF');
/* RIFF chunk length */ /* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true) view.setUint32(4, 36 + samples.length * bytesPerSample, true);
/* RIFF type */ /* RIFF type */
writeString(view, 8, 'WAVE') writeString(view, 8, 'WAVE');
/* format chunk identifier */ /* format chunk identifier */
writeString(view, 12, 'fmt ') writeString(view, 12, 'fmt ');
/* format chunk length */ /* format chunk length */
view.setUint32(16, 16, true) view.setUint32(16, 16, true);
/* sample format (raw) */ /* sample format (raw) */
view.setUint16(20, format, true) view.setUint16(20, format, true);
/* channel count */ /* channel count */
view.setUint16(22, numChannels, true) view.setUint16(22, numChannels, true);
/* sample rate */ /* sample rate */
view.setUint32(24, sampleRate, true) view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */ /* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true) view.setUint32(28, sampleRate * blockAlign, true);
/* block align (channel count * bytes per sample) */ /* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true) view.setUint16(32, blockAlign, true);
/* bits per sample */ /* bits per sample */
view.setUint16(34, bitDepth, true) view.setUint16(34, bitDepth, true);
/* data chunk identifier */ /* data chunk identifier */
writeString(view, 36, 'data') writeString(view, 36, 'data');
/* data chunk length */ /* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true) view.setUint32(40, samples.length * bytesPerSample, true);
if (format === 1) { // Raw PCM if (format === 1) {
floatTo16BitPCM(view, 44, samples) // Raw PCM
floatTo16BitPCM(view, 44, samples);
} else { } else {
writeFloat32(view, 44, samples) writeFloat32(view, 44, samples);
} }
return buffer return buffer;
} }
function interleave(inputL, inputR) { function interleave(inputL, inputR) {
var length = inputL.length + inputR.length var length = inputL.length + inputR.length;
var result = new Float32Array(length) var result = new Float32Array(length);
var index = 0 var index = 0;
var inputIndex = 0 var inputIndex = 0;
while (index < length) { while (index < length) {
result[index++] = inputL[inputIndex] result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex] result[index++] = inputR[inputIndex];
inputIndex++ inputIndex++;
} }
return result return result;
} }
function writeFloat32(output, offset, input) { function writeFloat32(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 4) { for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true) output.setFloat32(offset, input[i], true);
} }
} }
function floatTo16BitPCM(output, offset, input) { function floatTo16BitPCM(output, offset, input) {
for (var i = 0; i < input.length; i++, offset += 2) { for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i])) var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true) output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
} }
} }
function writeString(view, offset, string) { function writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) { for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i)) view.setUint8(offset + i, string.charCodeAt(i));
} }
} }

View file

@ -3,215 +3,226 @@ import cx from '@src/cx.mjs';
import NumberInput from './NumberInput'; import NumberInput from './NumberInput';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Textbox } from './textbox/Textbox'; import { Textbox } from './textbox/Textbox';
import { setMultiChannelOrbits as setStrudelMultiChannelOrbits, setMaxPolyphony as setStrudelMaxPolyphony, getAudioContext } from '@strudel/webaudio'; import {
setMultiChannelOrbits as setStrudelMultiChannelOrbits,
setMaxPolyphony as setStrudelMaxPolyphony,
getAudioContext,
} from '@strudel/webaudio';
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon'; import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
function Checkbox({ label, value, onChange, disabled = false }) { function Checkbox({ label, value, onChange, disabled = false }) {
return ( return (
<label className={cx(disabled && "opacity-50")}> <label className={cx(disabled && 'opacity-50')}>
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} /> <input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label} {' ' + label}
</label> </label>
); );
} }
function FormItem({ label, children, disabled }) { function FormItem({ label, children, disabled }) {
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<label className={cx(disabled && "opacity-50")}>{label}</label> <label className={cx(disabled && 'opacity-50')}>{label}</label>
{children} {children}
</div> </div>
); );
} }
export default function ExportModal(Props) { export default function ExportModal(Props) {
const { started, isEmbedded, handleExport } = Props; const { started, isEmbedded, handleExport } = Props;
const [downloadName, setDownloadName] = useState("") // TODO: make a form? const [downloadName, setDownloadName] = useState(''); // TODO: make a form?
const [startCycle, setStartCycle] = useState(0) const [startCycle, setStartCycle] = useState(0);
const [endCycle, setEndCycle] = useState(1) const [endCycle, setEndCycle] = useState(1);
const [sampleRate, setSampleRate] = useState(48000) const [sampleRate, setSampleRate] = useState(48000);
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true) const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
const [maxPolyphony, setMaxPolyphony] = useState(1024) const [maxPolyphony, setMaxPolyphony] = useState(1024);
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0);
const [length, setLength] = useState(1) const [length, setLength] = useState(1);
const refreshProgress = () => { const refreshProgress = () => {
const audioContext = getAudioContext() const audioContext = getAudioContext();
if (audioContext instanceof OfflineAudioContext) { if (audioContext instanceof OfflineAudioContext) {
setProgress(audioContext.currentTime) setProgress(audioContext.currentTime);
setLength(audioContext.length / sampleRate) setLength(audioContext.length / sampleRate);
setTimeout(refreshProgress, 100); setTimeout(refreshProgress, 100);
}
} }
};
return ( return (
<> <>
<button <button
onClick={() => { onClick={() => {
if (started) return; if (started) return;
const modal = document.getElementById("exportModal"); const modal = document.getElementById('exportModal');
modal.showModal(); modal.showModal();
}}
title="export"
className={cx(
'flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2',
started ? 'opacity-50' : 'hover:opacity-50',
)}
>
{!isEmbedded && <span>export</span>}
</button>
<dialog
closedby={exporting ? 'none' : 'closerequest'}
id="exportModal"
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-50"
>
<button
onClick={() => {
if (exporting) return;
const modal = document.getElementById('exportModal');
modal.close();
}}
className={cx(
'absolute text-foreground max-h-8 min-h-8 max-w-8 min-w-8 items-center p-1.5 right-2 top-2 z-50',
exporting && 'opacity-50',
)}
aria-label="Close Modal"
>
<XMarkIcon />
</button>
<div className="bg-lineHighlight p-4 space-y-4 relative">
<FormItem label="File name" disabled={exporting}>
<Textbox
onBlur={(e) => {
setDownloadName(e.target.value);
}}
onChange={(v) => {
setDownloadName(v);
}}
disabled={exporting}
placeholder="Leave empty to use current date"
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
value={downloadName ?? ''}
/>
</FormItem>
<div className="flex flex-row gap-4">
<FormItem label="Start cycle" disabled={exporting}>
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? 0 : Math.max(0, v);
setStartCycle(v);
}} }}
title="export" onChange={(v) => {
className={cx( v = parseInt(v);
'flex items-center space-x-1', setStartCycle(v);
!isEmbedded ? 'p-2' : 'px-2', }}
started ? 'opacity-50' : 'hover:opacity-50', type="number"
)} placeholder=""
> disabled={exporting}
{!isEmbedded && <span>export</span>} className={cx(exporting && 'opacity-50 border-opacity-50')}
</button> value={startCycle ?? ''}
<dialog closedby={exporting ? "none" : "closerequest"} id='exportModal' className='text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-50'> />
<button </FormItem>
onClick={() => { <FormItem label="End cycle" disabled={exporting}>
if (exporting) return; <Textbox
const modal = document.getElementById("exportModal"); min={1}
modal.close(); max={Infinity}
}} onBlur={(e) => {
className={cx( let v = parseInt(e.target.value);
'absolute text-foreground max-h-8 min-h-8 max-w-8 min-w-8 items-center p-1.5 right-2 top-2 z-50', v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
exporting && "opacity-50" setEndCycle(v);
)} }}
aria-label="Close Modal" onChange={(v) => {
> v = parseInt(v);
<XMarkIcon /> setEndCycle(v);
</button> }}
<div className='bg-lineHighlight p-4 space-y-4 relative'> type="number"
<FormItem label="File name" disabled={exporting}> placeholder=""
<Textbox disabled={exporting}
onBlur={(e) => { className={cx(exporting && 'opacity-50 border-opacity-50')}
setDownloadName(e.target.value) value={endCycle ?? ''}
}} />
onChange={v => { </FormItem>
setDownloadName(v) </div>
}} <div className="flex flex-row gap-4">
disabled={exporting} <FormItem label="Sample rate" disabled={exporting}>
placeholder="Leave empty to use current date" <Textbox
className={cx("placeholder:opacity-50", exporting && "opacity-50 border-opacity-50")} min={1}
value={downloadName ?? ''} max={Infinity}
/> onBlur={(e) => {
</FormItem> let v = parseInt(e.target.value);
<div className='flex flex-row gap-4'> v = isNaN(v) ? 1 : Math.max(1, v);
<FormItem label="Start cycle" disabled={exporting}> setSampleRate(v);
<Textbox }}
min={1} onChange={(v) => {
max={Infinity} v = parseInt(v);
onBlur={(e) => { setSampleRate(v);
let v = parseInt(e.target.value); }}
v = isNaN(v) ? 0 : Math.max(0, v); type="number"
setStartCycle(v); placeholder=""
}} disabled={exporting}
onChange={v => { className={cx(exporting && 'opacity-50 border-opacity-50')}
v = parseInt(v) value={sampleRate ?? ''}
setStartCycle(v); />
}} </FormItem>
type="number" <FormItem label="Maximum polyphony" disabled={exporting}>
placeholder="" <Textbox
disabled={exporting} min={1}
className={cx(exporting && "opacity-50 border-opacity-50")} max={Infinity}
value={startCycle ?? ''} onBlur={(e) => {
/> let v = parseInt(e.target.value);
</FormItem> v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
<FormItem label="End cycle" disabled={exporting}> setMaxPolyphony(v);
<Textbox }}
min={1} onChange={(v) => {
max={Infinity} v = Math.max(1, parseInt(v));
onBlur={(e) => { setMaxPolyphony(v);
let v = parseInt(e.target.value); }}
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v; type="number"
setEndCycle(v); placeholder=""
}} disabled={exporting}
onChange={(v) => { className={cx(exporting && 'opacity-50 border-opacity-50')}
v = parseInt(v) value={maxPolyphony ?? ''}
setEndCycle(v); />
}} </FormItem>
type="number" </div>
placeholder="" <div>
disabled={exporting} <Checkbox
className={cx(exporting && "opacity-50 border-opacity-50")} label="Multi Channel Orbits"
value={endCycle ?? ''} onChange={(cbEvent) => {
/> const val = cbEvent.target.checked;
</FormItem> setMultiChannelOrbits(val);
</div> }}
<div className='flex flex-row gap-4'> disabled={exporting}
<FormItem label="Sample rate" disabled={exporting}> value={multiChannelOrbits}
<Textbox />
min={1} </div>
max={Infinity} <button
onBlur={(e) => { className={cx('bg-background p-2 w-full rounded-md hover:opacity-50 relative', exporting && 'opacity-50')}
let v = parseInt(e.target.value); disabled={exporting}
v = isNaN(v) ? 1 : Math.max(1, v); onClick={async () => {
setSampleRate(v); setExporting(true);
}} setStrudelMaxPolyphony(maxPolyphony);
onChange={v => { setStrudelMultiChannelOrbits(multiChannelOrbits);
v = parseInt(v) setTimeout(refreshProgress, 1000);
setSampleRate(v); await handleExport(startCycle, endCycle, sampleRate, downloadName).finally(() => {
}} setExporting(false);
type="number" const modal = document.getElementById('exportModal');
placeholder="" setProgress(0);
disabled={exporting} setLength(1);
className={cx(exporting && "opacity-50 border-opacity-50")} modal.close();
value={sampleRate ?? ''} });
/> }}
</FormItem> >
<FormItem label="Maximum polyphony" disabled={exporting}> <span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
<Textbox <div
min={1} className="bg-foreground opacity-10 absolute top-0 left-0 right-0 bottom-0"
max={Infinity} style={{
onBlur={(e) => { width: `${(progress / length) * 100}%`,
let v = parseInt(e.target.value); }}
v = isNaN(v) ? Math.max(1, parseInt(v)) : v; />
setMaxPolyphony(v); </button>
}} </div>
onChange={(v) => { </dialog>
v = Math.max(1, parseInt(v)); </>
setMaxPolyphony(v); );
}}
type="number"
placeholder=""
disabled={exporting}
className={cx(exporting && "opacity-50 border-opacity-50")}
value={maxPolyphony ?? ''}
/>
</FormItem>
</div>
<div>
<Checkbox
label="Multi Channel Orbits"
onChange={(cbEvent) => {
const val = cbEvent.target.checked;
setMultiChannelOrbits(val)
}}
disabled={exporting}
value={multiChannelOrbits}
/>
</div>
<button
className={cx("bg-background p-2 w-full rounded-md hover:opacity-50 relative", exporting && "opacity-50")}
disabled={exporting}
onClick={async () => {
setExporting(true)
setStrudelMaxPolyphony(maxPolyphony)
setStrudelMultiChannelOrbits(multiChannelOrbits)
setTimeout(refreshProgress, 1000);
await handleExport(startCycle, endCycle, sampleRate, downloadName).finally(() => {
setExporting(false)
const modal = document.getElementById("exportModal");
setProgress(0)
setLength(1)
modal.close();
})
}}
>
<span className='text-foreground'>{exporting ? "Exporting..." : "Export to WAV"}</span>
<div className="bg-foreground opacity-10 absolute top-0 left-0 right-0 bottom-0" style={{
width: `${progress / length * 100}%`
}} />
</button>
</div>
</dialog>
</>
);
} }

View file

@ -9,8 +9,17 @@ const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function Header({ context, embedded = false }) { export function Header({ context, embedded = false }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare, handleExport } = const {
context; started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleEvaluate,
handleShuffle,
handleShare,
handleExport,
} = context;
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location); const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
const { isZen, isButtonRowHidden, isCSSAnimationDisabled, fontFamily } = useSettings(); const { isZen, isButtonRowHidden, isCSSAnimationDisabled, fontFamily } = useSettings();