mirror of
https://codeberg.org/uzu/strudel
synced 2026-08-19 02:34:22 -04:00
Better export UI
This commit is contained in:
@@ -1,25 +1,51 @@
|
||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
import cx from '@src/cx.mjs';
|
||||
import NumberInput from './NumberInput';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 }) {
|
||||
return (
|
||||
<label>
|
||||
<label className={cx(disabled && "opacity-50")}>
|
||||
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
|
||||
{' ' + 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) {
|
||||
const { started, isEmbedded, handleExport } = Props;
|
||||
|
||||
const [startCycle, setStartCycle] = useState(0) // TODO: make a form?
|
||||
const [endCycle, setEndCycle] = useState(0)
|
||||
const [downloadName, setDownloadName] = useState("") // TODO: make a form?
|
||||
const [startCycle, setStartCycle] = useState(0)
|
||||
const [endCycle, setEndCycle] = useState(1)
|
||||
const [sampleRate, setSampleRate] = useState(48000)
|
||||
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true)
|
||||
const [maxPolyphony, setMaxPolyphony] = useState(1024)
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
@@ -37,90 +63,154 @@ export default function ExportModal(Props) {
|
||||
>
|
||||
{!isEmbedded && <span>export</span>}
|
||||
</button>
|
||||
<dialog closedby={exporting ? "none" : "closerequest"} id='exportModal' className='bg-background text-foreground'>
|
||||
<button autofocus onClick={() => {
|
||||
if (exporting) return;
|
||||
const modal = document.getElementById("exportModal");
|
||||
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)
|
||||
<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();
|
||||
})
|
||||
}}>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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -203,8 +203,8 @@ export function useReplContext() {
|
||||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
const handleExport = async (begin, end) => {
|
||||
await editorRef.current.exportAudio(begin, end);
|
||||
const handleExport = async (begin, end, sampleRate, downloadName = undefined) => {
|
||||
await editorRef.current.exportAudio(begin, end, sampleRate, downloadName);
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
|
||||
Reference in New Issue
Block a user