diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 7cbb925d..84cbfa96 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -42,9 +42,9 @@ const extensions = { isMultiCursorEnabled: (on) => on ? [ - EditorState.allowMultipleSelections.of(true), - EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey), - ] + EditorState.allowMultipleSelections.of(true), + EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey), + ] : [], }; 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); } 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); this.repl.scheduler.stop(); - } async stop() { this.repl.scheduler.stop(); diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index 167f1882..185d5588 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -117,7 +117,7 @@ export class Cyclist { throw new Error('Scheduler: no pattern set! call .setPattern first.'); } logger('[cyclist] exporting'); - await renderPatternAudio(this.pattern, this.cps, begin, end, sampleRate, downloadName) + await renderPatternAudio(this.pattern, this.cps, begin, end, sampleRate, downloadName); } pause() { diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 970c15a6..4a35b932 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -91,7 +91,8 @@ export function repl({ const stop = () => scheduler.stop(); 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 toggle = () => scheduler.toggle(); const setCps = (cps) => { @@ -263,18 +264,18 @@ export function repl({ export const getTrigger = ({ getTime, defaultOutput }) => - async (hap, deadline, duration, cps, t) => { - // ^ 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 - try { - if (!hap.context.onTrigger || !hap.context.dominantTrigger) { - 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'); + async (hap, deadline, duration, cps, t) => { + // ^ 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 + try { + if (!hap.context.onTrigger || !hap.context.dominantTrigger) { + 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'); + } + }; diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index ea60722b..9a5913d8 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -1,15 +1,15 @@ -import { analysers, resetGlobalEffects } from "./superdough.mjs"; +import { analysers, resetGlobalEffects } from './superdough.mjs'; let audioContext; export const setDefaultAudioContext = () => { audioContext = new AudioContext(); - resetGlobalEffects() + resetGlobalEffects(); return audioContext; }; export const setAudioContext = (context) => { - audioContext = context + audioContext = context; return audioContext; }; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index cf65c390..a471da9d 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -348,7 +348,7 @@ export function applyFM(param, value, begin) { duration, } = value; let modulator; - let stop = () => { }; + let stop = () => {}; if (fmModulationIndex) { const ac = getAudioContext(); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 2f9eca3e..118a152b 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -212,7 +212,9 @@ export function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); 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; @@ -250,7 +252,7 @@ export async function initAudio(options = {}) { logger('[superdough] failed to set audio interface', 'warning'); } } - if (!audioCtx instanceof OfflineAudioContext) { + if ((!audioCtx) instanceof OfflineAudioContext) { await audioCtx.resume(); } if (disableWorklets) { @@ -288,7 +290,7 @@ function getSuperdoughAudioController() { } export function setSuperdoughAudioController(newController) { - controller = newController + controller = newController; return controller; } @@ -401,7 +403,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) ); return; } // FIXME: fix - // destructure + // destructure let { tremolo, 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) => { superdough(hap, t - ct, hap.duration / cps, cps); -}; \ No newline at end of file +}; diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 27718bd7..43c01146 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -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 { 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 { workletUrl } from 'supradough'; 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) { - const oldAudioCtx = getAudioContext() - await oldAudioCtx.close() - let audioContext = new OfflineAudioContext(2, (end - begin) / cps * sampleRate, sampleRate); - setAudioContext(audioContext) - let context = getAudioContext() - setSuperdoughAudioController(new SuperdoughAudioController(context)) - setMaxPolyphony(1024) - setMultiChannelOrbits(true) - await loadWorklets() + const oldAudioCtx = getAudioContext(); + await oldAudioCtx.close(); + let audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate); + setAudioContext(audioContext); + let context = getAudioContext(); + setSuperdoughAudioController(new SuperdoughAudioController(context)); + setMaxPolyphony(1024); + setMultiChannelOrbits(true); + await loadWorklets(); logger('[webaudio] start rendering'); - let haps = pattern.queryArc(begin, end, { _cps: cps }) - Promise.all(haps.map(async h => { - let s; - if (h.value.s) { - if (h.value.bank) { - s = `${h.value.bank}_${h.value.s}`; + let haps = pattern.queryArc(begin, end, { _cps: cps }); + Promise.all( + haps.map(async (h) => { + let s; + if (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) => { if (hap.hasOnset()) { hap.ensureObjectValue(); 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 = {}) { @@ -96,97 +116,97 @@ Pattern.prototype.dough = function () { return this.onTrigger(doughTrigger, 1); }; - function audioBufferToWav(buffer, opt) { - opt = opt || {} + opt = opt || {}; - var numChannels = buffer.numberOfChannels - var sampleRate = buffer.sampleRate - var format = opt.float32 ? 3 : 1 - var bitDepth = format === 3 ? 32 : 16 + var numChannels = buffer.numberOfChannels; + var sampleRate = buffer.sampleRate; + var format = opt.float32 ? 3 : 1; + var bitDepth = format === 3 ? 32 : 16; - var result + var result; if (numChannels === 2) { - result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)) + result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)); } 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) { - var bytesPerSample = bitDepth / 8 - var blockAlign = numChannels * bytesPerSample + var bytesPerSample = bitDepth / 8; + var blockAlign = numChannels * bytesPerSample; - var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample) - var view = new DataView(buffer) + var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample); + var view = new DataView(buffer); /* RIFF identifier */ - writeString(view, 0, 'RIFF') + writeString(view, 0, 'RIFF'); /* RIFF chunk length */ - view.setUint32(4, 36 + samples.length * bytesPerSample, true) + view.setUint32(4, 36 + samples.length * bytesPerSample, true); /* RIFF type */ - writeString(view, 8, 'WAVE') + writeString(view, 8, 'WAVE'); /* format chunk identifier */ - writeString(view, 12, 'fmt ') + writeString(view, 12, 'fmt '); /* format chunk length */ - view.setUint32(16, 16, true) + view.setUint32(16, 16, true); /* sample format (raw) */ - view.setUint16(20, format, true) + view.setUint16(20, format, true); /* channel count */ - view.setUint16(22, numChannels, true) + view.setUint16(22, numChannels, true); /* sample rate */ - view.setUint32(24, sampleRate, true) + view.setUint32(24, sampleRate, true); /* 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) */ - view.setUint16(32, blockAlign, true) + view.setUint16(32, blockAlign, true); /* bits per sample */ - view.setUint16(34, bitDepth, true) + view.setUint16(34, bitDepth, true); /* data chunk identifier */ - writeString(view, 36, 'data') + writeString(view, 36, 'data'); /* data chunk length */ - view.setUint32(40, samples.length * bytesPerSample, true) - if (format === 1) { // Raw PCM - floatTo16BitPCM(view, 44, samples) + view.setUint32(40, samples.length * bytesPerSample, true); + if (format === 1) { + // Raw PCM + floatTo16BitPCM(view, 44, samples); } else { - writeFloat32(view, 44, samples) + writeFloat32(view, 44, samples); } - return buffer + return buffer; } function interleave(inputL, inputR) { - var length = inputL.length + inputR.length - var result = new Float32Array(length) + var length = inputL.length + inputR.length; + var result = new Float32Array(length); - var index = 0 - var inputIndex = 0 + var index = 0; + var inputIndex = 0; while (index < length) { - result[index++] = inputL[inputIndex] - result[index++] = inputR[inputIndex] - inputIndex++ + result[index++] = inputL[inputIndex]; + result[index++] = inputR[inputIndex]; + inputIndex++; } - return result + return result; } function writeFloat32(output, offset, input) { 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) { for (var i = 0; i < input.length; i++, offset += 2) { - var s = Math.max(-1, Math.min(1, input[i])) - output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true) + var s = Math.max(-1, Math.min(1, input[i])); + output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } function writeString(view, offset, string) { for (var i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)) + view.setUint8(offset + i, string.charCodeAt(i)); } -} \ No newline at end of file +} diff --git a/website/src/repl/components/ExportModal.jsx b/website/src/repl/components/ExportModal.jsx index 4f474f38..036f6f36 100644 --- a/website/src/repl/components/ExportModal.jsx +++ b/website/src/repl/components/ExportModal.jsx @@ -3,215 +3,226 @@ import cx from '@src/cx.mjs'; import NumberInput from './NumberInput'; import { useEffect, useState } from 'react'; 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'; function Checkbox({ label, value, onChange, disabled = false }) { - return ( - - ); + return ( + + ); } function FormItem({ label, children, disabled }) { - return ( -
- - {children} -
- ); + return ( +
+ + {children} +
+ ); } export default function ExportModal(Props) { - const { started, isEmbedded, handleExport } = Props; + const { started, isEmbedded, handleExport } = Props; - 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 [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); - } + const refreshProgress = () => { + const audioContext = getAudioContext(); + if (audioContext instanceof OfflineAudioContext) { + setProgress(audioContext.currentTime); + setLength(audioContext.length / sampleRate); + setTimeout(refreshProgress, 100); } + }; - return ( - <> - + + +
+ + { + 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 ?? ''} + /> + +
+ + { + let v = parseInt(e.target.value); + v = isNaN(v) ? 0 : Math.max(0, v); + setStartCycle(v); }} - title="export" - className={cx( - 'flex items-center space-x-1', - !isEmbedded ? 'p-2' : 'px-2', - started ? 'opacity-50' : 'hover:opacity-50', - )} - > - {!isEmbedded && export} - - - -
- - { - 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 ?? ''} - /> - -
- - { - 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 ?? ''} - /> - - - { - 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 ?? ''} - /> - -
-
- - { - 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 ?? ''} - /> - - - { - 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 ?? ''} - /> - -
-
- { - const val = cbEvent.target.checked; - setMultiChannelOrbits(val) - }} - disabled={exporting} - value={multiChannelOrbits} - /> -
- -
-
- - ); + onChange={(v) => { + v = parseInt(v); + setStartCycle(v); + }} + type="number" + placeholder="" + disabled={exporting} + className={cx(exporting && 'opacity-50 border-opacity-50')} + value={startCycle ?? ''} + /> +
+ + { + 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 ?? ''} + /> + +
+
+ + { + 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 ?? ''} + /> + + + { + 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 ?? ''} + /> + +
+
+ { + const val = cbEvent.target.checked; + setMultiChannelOrbits(val); + }} + disabled={exporting} + value={multiChannelOrbits} + /> +
+ +
+
+ + ); } diff --git a/website/src/repl/components/Header.jsx b/website/src/repl/components/Header.jsx index d5d8b5f7..dd9f684c 100644 --- a/website/src/repl/components/Header.jsx +++ b/website/src/repl/components/Header.jsx @@ -9,8 +9,17 @@ const { BASE_URL } = import.meta.env; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; export function Header({ context, embedded = false }) { - const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare, handleExport } = - context; + const { + started, + pending, + isDirty, + activeCode, + handleTogglePlay, + handleEvaluate, + handleShuffle, + handleShare, + handleExport, + } = context; const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location); const { isZen, isButtonRowHidden, isCSSAnimationDisabled, fontFamily } = useSettings();