Better export UI
This commit is contained in:
parent
418bca79b2
commit
ff83994733
8 changed files with 195 additions and 109 deletions
|
|
@ -268,9 +268,9 @@ export class StrudelMirror {
|
||||||
this.flash();
|
this.flash();
|
||||||
await this.repl.evaluate(this.code);
|
await this.repl.evaluate(this.code);
|
||||||
}
|
}
|
||||||
async exportAudio(begin, end) {
|
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);
|
await this.repl.exportAudio(begin, end, sampleRate, downloadName);
|
||||||
this.repl.scheduler.stop();
|
this.repl.scheduler.stop();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,14 +112,12 @@ export class Cyclist {
|
||||||
this.setStarted(true);
|
this.setStarted(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportAudio(begin, end) {
|
async exportAudio(begin, end, sampleRate, downloadName = undefined) {
|
||||||
if (!this.pattern) {
|
if (!this.pattern) {
|
||||||
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');
|
||||||
// this.clock.start();
|
await renderPatternAudio(this.pattern, this.cps, begin, end, sampleRate, downloadName)
|
||||||
// this.setStarted(true);
|
|
||||||
await renderPatternAudio(this.pattern, this.cps, begin, end)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function repl({
|
||||||
|
|
||||||
const stop = () => scheduler.stop();
|
const stop = () => scheduler.stop();
|
||||||
const start = () => scheduler.start();
|
const start = () => scheduler.start();
|
||||||
const exportAudio = async (begin, end) => await scheduler.exportAudio(begin, end);
|
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) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { analysers, resetGlobalEffects } from "./superdough.mjs";
|
||||||
|
|
||||||
let audioContext;
|
let audioContext;
|
||||||
|
|
||||||
export const setDefaultAudioContext = () => {
|
export const setDefaultAudioContext = () => {
|
||||||
audioContext = new AudioContext();
|
audioContext = new AudioContext();
|
||||||
|
resetGlobalEffects()
|
||||||
return audioContext;
|
return audioContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import './feedbackdelay.mjs';
|
||||||
import './reverb.mjs';
|
import './reverb.mjs';
|
||||||
import './vowel.mjs';
|
import './vowel.mjs';
|
||||||
import { nanFallback, _mod, cycleToSeconds } from './util.mjs';
|
import { nanFallback, _mod, cycleToSeconds } from './util.mjs';
|
||||||
import workletsUrl from './worklets.mjs?audioworklet';
|
import workletsUrl from './worklets.mjs?url';
|
||||||
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
|
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
|
||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
@ -332,7 +332,7 @@ export let analysers = {},
|
||||||
analysersData = {};
|
analysersData = {};
|
||||||
|
|
||||||
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
|
||||||
if (!analysers[id]) {
|
if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
|
||||||
// make sure this doesn't happen too often as it piles up garbage
|
// make sure this doesn't happen too often as it piles up garbage
|
||||||
const analyserNode = getAudioContext().createAnalyser();
|
const analyserNode = getAudioContext().createAnalyser();
|
||||||
analyserNode.fftSize = fftSize;
|
analyserNode.fftSize = fftSize;
|
||||||
|
|
@ -722,7 +722,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyser
|
// analyser
|
||||||
if (analyze) {
|
if (analyze && !(ac instanceof OfflineAudioContext)) {
|
||||||
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
|
||||||
const analyserSend = effectSend(post, analyserNode, 1);
|
const analyserSend = effectSend(post, analyserNode, 1);
|
||||||
audioNodes.push(analyserSend);
|
audioNodes.push(analyserSend);
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function renderPatternAudio(pattern, cps, begin, end) {
|
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 * 48000, 48000);
|
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))
|
||||||
|
|
@ -63,25 +63,20 @@ export async function renderPatternAudio(pattern, cps, begin, end) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return context.startRendering().then(async (renderedBuffer) => {
|
return context.startRendering().then((renderedBuffer) => {
|
||||||
console.log(renderedBuffer)
|
console.log(renderedBuffer)
|
||||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||||
let downloadName = ''
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
// remove leading dash if they want an empty download name
|
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
|
||||||
if (downloadName == '') {
|
|
||||||
downloadName = `${new Date().toISOString()}.wav`;
|
|
||||||
} else {
|
|
||||||
downloadName = downloadName + `-${new Date().toISOString()}.wav`;
|
|
||||||
}
|
|
||||||
a.download = `${downloadName}`;
|
a.download = `${downloadName}`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
}).finally(async () => {
|
||||||
setAudioContext(null)
|
setAudioContext(null)
|
||||||
setSuperdoughAudioController(null)
|
setSuperdoughAudioController(null)
|
||||||
await initAudio()
|
await initAudio()
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,51 @@
|
||||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||||
import cx from '@src/cx.mjs';
|
import cx from '@src/cx.mjs';
|
||||||
import NumberInput from './NumberInput';
|
import NumberInput from './NumberInput';
|
||||||
import { 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 } from '@strudel/webaudio';
|
import { setMultiChannelOrbits as setStrudelMultiChannelOrbits, setMaxPolyphony as setStrudelMaxPolyphony, getAudioContext } from '@strudel/webaudio';
|
||||||
|
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>
|
<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 }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={cx(disabled && "opacity-50")}>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExportModal(Props) {
|
export default function ExportModal(Props) {
|
||||||
const { started, isEmbedded, handleExport } = Props;
|
const { started, isEmbedded, handleExport } = Props;
|
||||||
|
|
||||||
const [startCycle, setStartCycle] = useState(0) // TODO: make a form?
|
const [downloadName, setDownloadName] = useState("") // TODO: make a form?
|
||||||
const [endCycle, setEndCycle] = useState(0)
|
const [startCycle, setStartCycle] = useState(0)
|
||||||
|
const [endCycle, setEndCycle] = useState(1)
|
||||||
|
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 [length, setLength] = useState(1)
|
||||||
|
|
||||||
|
const refreshProgress = () => {
|
||||||
|
const audioContext = getAudioContext()
|
||||||
|
if (audioContext instanceof OfflineAudioContext) {
|
||||||
|
setProgress(audioContext.currentTime)
|
||||||
|
setLength(audioContext.length / sampleRate)
|
||||||
|
setTimeout(refreshProgress, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
@ -37,90 +63,154 @@ export default function ExportModal(Props) {
|
||||||
>
|
>
|
||||||
{!isEmbedded && <span>export</span>}
|
{!isEmbedded && <span>export</span>}
|
||||||
</button>
|
</button>
|
||||||
<dialog closedby={exporting ? "none" : "closerequest"} id='exportModal' className='bg-background text-foreground'>
|
<dialog closedby={exporting ? "none" : "closerequest"} id='exportModal' className='text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-50'>
|
||||||
<button autofocus onClick={() => {
|
<button
|
||||||
if (exporting) return;
|
onClick={() => {
|
||||||
const modal = document.getElementById("exportModal");
|
if (exporting) return;
|
||||||
modal.close();
|
|
||||||
}}>Close</button>
|
|
||||||
<div className='flex flex-row items-center gap-4'>
|
|
||||||
<p>Start cycle</p>
|
|
||||||
<Textbox
|
|
||||||
min={1}
|
|
||||||
max={Infinity}
|
|
||||||
onBlur={(e) => {
|
|
||||||
let v = parseInt(e.target.value);
|
|
||||||
console.log(v)
|
|
||||||
v = isNaN(v) ? 0 : Math.max(0, v);
|
|
||||||
setStartCycle(v);
|
|
||||||
}}
|
|
||||||
onChange={v => {
|
|
||||||
v = parseInt(v)
|
|
||||||
setStartCycle(v);
|
|
||||||
}}
|
|
||||||
type="number"
|
|
||||||
placeholder=""
|
|
||||||
value={startCycle ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-row items-center gap-4'>
|
|
||||||
<p>End cycle</p>
|
|
||||||
<Textbox
|
|
||||||
min={1}
|
|
||||||
max={Infinity}
|
|
||||||
onBlur={(e) => {
|
|
||||||
let v = parseInt(e.target.value);
|
|
||||||
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
|
|
||||||
setEndCycle(v);
|
|
||||||
}}
|
|
||||||
onChange={(v) => {
|
|
||||||
v = parseInt(v)
|
|
||||||
setEndCycle(v);
|
|
||||||
}}
|
|
||||||
type="number"
|
|
||||||
placeholder=""
|
|
||||||
value={endCycle ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-row items-center gap-4'>
|
|
||||||
<p>Maximum polyphony</p>
|
|
||||||
<Textbox
|
|
||||||
min={1}
|
|
||||||
max={Infinity}
|
|
||||||
onBlur={(e) => {
|
|
||||||
let v = parseInt(e.target.value);
|
|
||||||
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
|
|
||||||
setMaxPolyphony(v);
|
|
||||||
}}
|
|
||||||
onChange={(v) => {
|
|
||||||
v = Math.max(1, parseInt(v));
|
|
||||||
setMaxPolyphony(v);
|
|
||||||
}}
|
|
||||||
type="number"
|
|
||||||
placeholder=""
|
|
||||||
value={maxPolyphony ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Checkbox
|
|
||||||
label="Multi Channel Orbits"
|
|
||||||
onChange={(cbEvent) => {
|
|
||||||
const val = cbEvent.target.checked;
|
|
||||||
setMultiChannelOrbits(val)
|
|
||||||
}}
|
|
||||||
value={multiChannelOrbits}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button disabled={exporting} onClick={async () => {
|
|
||||||
setStrudelMaxPolyphony(maxPolyphony)
|
|
||||||
setStrudelMultiChannelOrbits(multiChannelOrbits)
|
|
||||||
setExporting(true)
|
|
||||||
await handleExport(startCycle, endCycle).then(() => {
|
|
||||||
setExporting(false)
|
|
||||||
const modal = document.getElementById("exportModal");
|
const modal = document.getElementById("exportModal");
|
||||||
modal.close();
|
modal.close();
|
||||||
})
|
}}
|
||||||
}}>Export to wav</button>
|
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);
|
||||||
|
}}
|
||||||
|
onChange={v => {
|
||||||
|
v = parseInt(v)
|
||||||
|
setStartCycle(v);
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
placeholder=""
|
||||||
|
disabled={exporting}
|
||||||
|
className={cx(exporting && "opacity-50 border-opacity-50")}
|
||||||
|
value={startCycle ?? ''}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="End cycle" disabled={exporting}>
|
||||||
|
<Textbox
|
||||||
|
min={1}
|
||||||
|
max={Infinity}
|
||||||
|
onBlur={(e) => {
|
||||||
|
let v = parseInt(e.target.value);
|
||||||
|
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
|
||||||
|
setEndCycle(v);
|
||||||
|
}}
|
||||||
|
onChange={(v) => {
|
||||||
|
v = parseInt(v)
|
||||||
|
setEndCycle(v);
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
placeholder=""
|
||||||
|
disabled={exporting}
|
||||||
|
className={cx(exporting && "opacity-50 border-opacity-50")}
|
||||||
|
value={endCycle ?? ''}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row gap-4'>
|
||||||
|
<FormItem label="Sample rate" disabled={exporting}>
|
||||||
|
<Textbox
|
||||||
|
min={1}
|
||||||
|
max={Infinity}
|
||||||
|
onBlur={(e) => {
|
||||||
|
let v = parseInt(e.target.value);
|
||||||
|
v = isNaN(v) ? 1 : Math.max(1, v);
|
||||||
|
setSampleRate(v);
|
||||||
|
}}
|
||||||
|
onChange={v => {
|
||||||
|
v = parseInt(v)
|
||||||
|
setSampleRate(v);
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
placeholder=""
|
||||||
|
disabled={exporting}
|
||||||
|
className={cx(exporting && "opacity-50 border-opacity-50")}
|
||||||
|
value={sampleRate ?? ''}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="Maximum polyphony" disabled={exporting}>
|
||||||
|
<Textbox
|
||||||
|
min={1}
|
||||||
|
max={Infinity}
|
||||||
|
onBlur={(e) => {
|
||||||
|
let v = parseInt(e.target.value);
|
||||||
|
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
|
||||||
|
setMaxPolyphony(v);
|
||||||
|
}}
|
||||||
|
onChange={(v) => {
|
||||||
|
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>
|
</dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,8 @@ export function useReplContext() {
|
||||||
const handleEvaluate = () => {
|
const handleEvaluate = () => {
|
||||||
editorRef.current.evaluate();
|
editorRef.current.evaluate();
|
||||||
};
|
};
|
||||||
const handleExport = async (begin, end) => {
|
const handleExport = async (begin, end, sampleRate, downloadName = undefined) => {
|
||||||
await editorRef.current.exportAudio(begin, end);
|
await editorRef.current.exportAudio(begin, end, sampleRate, downloadName);
|
||||||
};
|
};
|
||||||
const handleShuffle = async () => {
|
const handleShuffle = async () => {
|
||||||
const patternData = await getRandomTune();
|
const patternData = await getRandomTune();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue