WIP non-realtime exporting
This commit is contained in:
parent
6e58c973af
commit
0fbde61b2d
12 changed files with 217 additions and 31 deletions
|
|
@ -268,6 +268,10 @@ export class StrudelMirror {
|
||||||
this.flash();
|
this.flash();
|
||||||
await this.repl.evaluate(this.code);
|
await this.repl.evaluate(this.code);
|
||||||
}
|
}
|
||||||
|
async exportAudio(begin, end) {
|
||||||
|
// await this.repl.evaluate(this.code, false)
|
||||||
|
this.repl.exportAudio(begin, end);
|
||||||
|
}
|
||||||
async stop() {
|
async stop() {
|
||||||
this.repl.scheduler.stop();
|
this.repl.scheduler.stop();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||||
|
|
||||||
import createClock from './zyklus.mjs';
|
import createClock from './zyklus.mjs';
|
||||||
import { errorLogger, logger } from './logger.mjs';
|
import { errorLogger, logger } from './logger.mjs';
|
||||||
|
import { loadBuffer, renderPatternAudio, setAudioContext, superdough } from '@strudel/webaudio';
|
||||||
|
|
||||||
export class Cyclist {
|
export class Cyclist {
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -98,6 +99,7 @@ export class Cyclist {
|
||||||
this.started = v;
|
this.started = v;
|
||||||
this.onToggle?.(v);
|
this.onToggle?.(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
await this.beforeStart?.();
|
await this.beforeStart?.();
|
||||||
this.num_ticks_since_cps_change = 0;
|
this.num_ticks_since_cps_change = 0;
|
||||||
|
|
@ -109,6 +111,17 @@ export class Cyclist {
|
||||||
this.clock.start();
|
this.clock.start();
|
||||||
this.setStarted(true);
|
this.setStarted(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportAudio(begin, end) {
|
||||||
|
if (!this.pattern) {
|
||||||
|
throw new Error('Scheduler: no pattern set! call .setPattern first.');
|
||||||
|
}
|
||||||
|
logger('[cyclist] exporting');
|
||||||
|
// this.clock.start();
|
||||||
|
// this.setStarted(true);
|
||||||
|
await renderPatternAudio(this.pattern, this.cps, begin, end)
|
||||||
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
logger('[cyclist] pause');
|
logger('[cyclist] pause');
|
||||||
this.clock.pause();
|
this.clock.pause();
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export function repl({
|
||||||
|
|
||||||
const stop = () => scheduler.stop();
|
const stop = () => scheduler.stop();
|
||||||
const start = () => scheduler.start();
|
const start = () => scheduler.start();
|
||||||
|
const exportAudio = (begin, end) => scheduler.exportAudio(begin, end);
|
||||||
const pause = () => scheduler.pause();
|
const pause = () => scheduler.pause();
|
||||||
const toggle = () => scheduler.toggle();
|
const toggle = () => scheduler.toggle();
|
||||||
const setCps = (cps) => {
|
const setCps = (cps) => {
|
||||||
|
|
@ -257,7 +258,7 @@ export function repl({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const setCode = (code) => updateState({ code });
|
const setCode = (code) => updateState({ code });
|
||||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
return { scheduler, evaluate, start, exportAudio, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTrigger =
|
export const getTrigger =
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ export const setDefaultAudioContext = () => {
|
||||||
return audioContext;
|
return audioContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setAudioContext = (context) => {
|
||||||
|
audioContext = context
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
export const getAudioContext = () => {
|
export const getAudioContext = () => {
|
||||||
if (!audioContext) {
|
if (!audioContext) {
|
||||||
return setDefaultAudioContext();
|
return setDefaultAudioContext();
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ if (typeof DelayNode !== 'undefined') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
|
||||||
return new FeedbackDelayNode(this, wet, time, feedback);
|
return new FeedbackDelayNode(this, wet, time, feedback);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
|
||||||
import { clamp } from './util.mjs';
|
import { clamp } from './util.mjs';
|
||||||
|
|
||||||
if (typeof AudioContext !== 'undefined') {
|
if (typeof AudioContext !== 'undefined') {
|
||||||
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
BaseAudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
|
||||||
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
|
||||||
const newLength = buffer.sampleRate * duration;
|
const newLength = buffer.sampleRate * duration;
|
||||||
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
|
||||||
|
|
@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
|
||||||
return newBuffer;
|
return newBuffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
BaseAudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
|
||||||
const convolver = this.createConvolver();
|
const convolver = this.createConvolver();
|
||||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
|
||||||
convolver.duration = d;
|
convolver.duration = d;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorkle
|
||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { loadBuffer } from './sampler.mjs';
|
import { loadBuffer } from './sampler.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext, setAudioContext } from './audioContext.mjs';
|
||||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||||
|
|
||||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||||
|
|
@ -367,6 +367,7 @@ function mapChannelNumbers(channels) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
|
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
|
||||||
|
controller = null;
|
||||||
// new: t is always expected to be the absolute target onset time
|
// new: t is always expected to be the absolute target onset time
|
||||||
const ac = getAudioContext();
|
const ac = getAudioContext();
|
||||||
const audioController = getSuperdoughAudioController();
|
const audioController = getSuperdoughAudioController();
|
||||||
|
|
@ -387,13 +388,12 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
// duration is passed as value too..
|
// duration is passed as value too..
|
||||||
value.duration = hapDuration;
|
value.duration = hapDuration;
|
||||||
// calculate absolute time
|
// calculate absolute time
|
||||||
|
if (t < ac.currentTime && !(ac instanceof OfflineAudioContext)) {
|
||||||
if (t < ac.currentTime) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
|
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
} // FIXME: fix
|
||||||
// destructure
|
// destructure
|
||||||
let {
|
let {
|
||||||
tremolo,
|
tremolo,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ if (typeof GainNode !== 'undefined') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContext.prototype.createVowelFilter = function (letter) {
|
BaseAudioContext.prototype.createVowelFilter = function (letter) {
|
||||||
return new VowelNode(this, letter);
|
return new VowelNode(this, letter);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 } from 'superdough';
|
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet, setAudioContext, getSampleBufferSource, loadBuffer, getSampleInfo, getSound } from 'superdough';
|
||||||
import './supradough.mjs';
|
import './supradough.mjs';
|
||||||
import { workletUrl } from 'supradough';
|
import { workletUrl } from 'supradough';
|
||||||
|
|
||||||
|
|
@ -26,6 +26,60 @@ 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) {
|
||||||
|
let audioContext = new OfflineAudioContext(2, (end - begin) / cps * 48000, 48000);
|
||||||
|
setAudioContext(audioContext)
|
||||||
|
logger('[webaudio] start rendering');
|
||||||
|
console.log(audioContext)
|
||||||
|
|
||||||
|
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, audioContext, 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());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let context = getAudioContext()
|
||||||
|
context.startRendering().then((renderedBuffer) => {
|
||||||
|
// console.log(renderedBuffer)
|
||||||
|
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||||
|
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||||
|
let downloadName = ''
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
// remove leading dash if they want an empty download name
|
||||||
|
if (downloadName == '') {
|
||||||
|
downloadName = `${new Date().toISOString()}.wav`;
|
||||||
|
} else {
|
||||||
|
downloadName = downloadName + `-${new Date().toISOString()}.wav`;
|
||||||
|
}
|
||||||
|
a.download = `${downloadName}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setAudioContext(null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function webaudioRepl(options = {}) {
|
export function webaudioRepl(options = {}) {
|
||||||
options = {
|
options = {
|
||||||
getTime: () => getAudioContext().currentTime,
|
getTime: () => getAudioContext().currentTime,
|
||||||
|
|
@ -38,3 +92,98 @@ export function webaudioRepl(options = {}) {
|
||||||
Pattern.prototype.dough = function () {
|
Pattern.prototype.dough = function () {
|
||||||
return this.onTrigger(doughTrigger, 1);
|
return this.onTrigger(doughTrigger, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function audioBufferToWav(buffer, opt) {
|
||||||
|
opt = opt || {}
|
||||||
|
|
||||||
|
var numChannels = buffer.numberOfChannels
|
||||||
|
var sampleRate = buffer.sampleRate
|
||||||
|
var format = opt.float32 ? 3 : 1
|
||||||
|
var bitDepth = format === 3 ? 32 : 16
|
||||||
|
|
||||||
|
var result
|
||||||
|
if (numChannels === 2) {
|
||||||
|
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1))
|
||||||
|
} else {
|
||||||
|
result = buffer.getChannelData(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeWAV(result, format, sampleRate, numChannels, bitDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
|
||||||
|
var bytesPerSample = bitDepth / 8
|
||||||
|
var blockAlign = numChannels * bytesPerSample
|
||||||
|
|
||||||
|
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample)
|
||||||
|
var view = new DataView(buffer)
|
||||||
|
|
||||||
|
/* RIFF identifier */
|
||||||
|
writeString(view, 0, 'RIFF')
|
||||||
|
/* RIFF chunk length */
|
||||||
|
view.setUint32(4, 36 + samples.length * bytesPerSample, true)
|
||||||
|
/* RIFF type */
|
||||||
|
writeString(view, 8, 'WAVE')
|
||||||
|
/* format chunk identifier */
|
||||||
|
writeString(view, 12, 'fmt ')
|
||||||
|
/* format chunk length */
|
||||||
|
view.setUint32(16, 16, true)
|
||||||
|
/* sample format (raw) */
|
||||||
|
view.setUint16(20, format, true)
|
||||||
|
/* channel count */
|
||||||
|
view.setUint16(22, numChannels, true)
|
||||||
|
/* sample rate */
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
/* byte rate (sample rate * block align) */
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true)
|
||||||
|
/* block align (channel count * bytes per sample) */
|
||||||
|
view.setUint16(32, blockAlign, true)
|
||||||
|
/* bits per sample */
|
||||||
|
view.setUint16(34, bitDepth, true)
|
||||||
|
/* data chunk identifier */
|
||||||
|
writeString(view, 36, 'data')
|
||||||
|
/* data chunk length */
|
||||||
|
view.setUint32(40, samples.length * bytesPerSample, true)
|
||||||
|
if (format === 1) { // Raw PCM
|
||||||
|
floatTo16BitPCM(view, 44, samples)
|
||||||
|
} else {
|
||||||
|
writeFloat32(view, 44, samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function interleave(inputL, inputR) {
|
||||||
|
var length = inputL.length + inputR.length
|
||||||
|
var result = new Float32Array(length)
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
var inputIndex = 0
|
||||||
|
|
||||||
|
while (index < length) {
|
||||||
|
result[index++] = inputL[inputIndex]
|
||||||
|
result[index++] = inputR[inputIndex]
|
||||||
|
inputIndex++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFloat32(output, offset, input) {
|
||||||
|
for (var i = 0; i < input.length; i++, offset += 4) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeString(view, offset, string) {
|
||||||
|
for (var i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ 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 } =
|
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare, handleExport } =
|
||||||
context;
|
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();
|
||||||
|
|
@ -93,6 +93,16 @@ export function Header({ context, embedded = false }) {
|
||||||
>
|
>
|
||||||
{!isEmbedded && <span>update</span>}
|
{!isEmbedded && <span>update</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(1, 16)}
|
||||||
|
title="export"
|
||||||
|
className={cx(
|
||||||
|
'flex items-center space-x-1',
|
||||||
|
!isEmbedded ? 'p-2' : 'px-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isEmbedded && <span>export</span>}
|
||||||
|
</button>
|
||||||
{/* !isEmbedded && (
|
{/* !isEmbedded && (
|
||||||
<button
|
<button
|
||||||
title="shuffle"
|
title="shuffle"
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export function SettingsTab({ started }) {
|
||||||
isMultiCursorEnabled,
|
isMultiCursorEnabled,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const shouldAlwaysSync = isUdels();
|
const shouldAlwaysSync = isUdels();
|
||||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
const canChangeAudioDevice = BaseAudioContext.prototype.setSinkId != null;
|
||||||
return (
|
return (
|
||||||
<div className="text-foreground p-4 space-y-4 w-full" style={{ fontFamily }}>
|
<div className="text-foreground p-4 space-y-4 w-full" style={{ fontFamily }}>
|
||||||
{canChangeAudioDevice && (
|
{canChangeAudioDevice && (
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,9 @@ export function useReplContext() {
|
||||||
const handleEvaluate = () => {
|
const handleEvaluate = () => {
|
||||||
editorRef.current.evaluate();
|
editorRef.current.evaluate();
|
||||||
};
|
};
|
||||||
|
const handleExport = (begin, end) => {
|
||||||
|
editorRef.current.exportAudio(begin, end);
|
||||||
|
};
|
||||||
const handleShuffle = async () => {
|
const handleShuffle = async () => {
|
||||||
const patternData = await getRandomTune();
|
const patternData = await getRandomTune();
|
||||||
const code = patternData.code;
|
const code = patternData.code;
|
||||||
|
|
@ -225,6 +228,7 @@ export function useReplContext() {
|
||||||
handleShuffle,
|
handleShuffle,
|
||||||
handleShare,
|
handleShare,
|
||||||
handleEvaluate,
|
handleEvaluate,
|
||||||
|
handleExport,
|
||||||
init,
|
init,
|
||||||
error,
|
error,
|
||||||
editorRef,
|
editorRef,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue