Format
This commit is contained in:
parent
428a4b3dd7
commit
4bdaf577c0
9 changed files with 360 additions and 318 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue