logger works now outside of react

+ dynamic sample loading logs
+ remove old sampler code
This commit is contained in:
Felix Roos 2022-11-12 11:50:39 +01:00
parent 23e059a065
commit 0b9d59bf0e
7 changed files with 430 additions and 470 deletions

View File

@ -1,3 +1,18 @@
export function logger(message) { export const logKey = 'strudel.log';
console.log(`%c${message}`, 'background-color: black;color:white;padding:4px;border-radius:15px');
export function logger(message, type, data = {}) {
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px');
if (typeof CustomEvent !== 'undefined') {
document.dispatchEvent(
new CustomEvent(logKey, {
detail: {
message,
type,
data,
},
}),
);
}
} }
logger.key = logKey;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -63,6 +63,13 @@ function useStrudel({
} }
}, [activateCode, evalOnMount, code]); }, [activateCode, evalOnMount, code]);
// this will stop the scheduler when hot reloading in development
useEffect(() => {
return () => {
scheduler.stop();
};
}, [scheduler]);
const togglePlay = async () => { const togglePlay = async () => {
if (started) { if (started) {
scheduler.pause(); scheduler.pause();

View File

@ -1,13 +1,36 @@
import { logger } from '@strudel.cycles/core';
const bufferCache = {}; // string: Promise<ArrayBuffer> const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer> const loadCache = {}; // string: Promise<ArrayBuffer>
export const getCachedBuffer = (url) => bufferCache[url]; export const getCachedBuffer = (url) => bufferCache[url];
export const loadBuffer = (url, ac) => { function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if (bytes < thresh) return bytes + ' B';
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while (bytes >= thresh);
return bytes.toFixed(1) + ' ' + units[u];
}
export const loadBuffer = (url, ac, s, n = 0) => {
const label = s ? `sound "${s}:${n}"` : 'sample';
if (!loadCache[url]) { if (!loadCache[url]) {
logger(`[sampler] load ${label}..`, 'load-sample', { url });
const timestamp = Date.now();
loadCache[url] = fetch(url) loadCache[url] = fetch(url)
.then((res) => res.arrayBuffer()) .then((res) => res.arrayBuffer())
.then(async (res) => { .then(async (res) => {
const took = (Date.now() - timestamp);
const size = humanFileSize(res.byteLength);
// const downSpeed = humanFileSize(res.byteLength / took);
logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url });
const decoded = await ac.decodeAudioData(res); const decoded = await ac.decodeAudioData(res);
bufferCache[url] = decoded; bufferCache[url] = decoded;
return decoded; return decoded;
@ -29,66 +52,7 @@ export const getLoadedBuffer = (url) => {
return bufferCache[url]; return bufferCache[url];
}; };
/* export const playBuffer = (buffer, time = ac.currentTime, destination = ac.destination) => {
const src = ac.createBufferSource();
src.buffer = buffer;
src.connect(destination);
src.start(time);
};
export const playSample = async (url) => playBuffer(await loadBuffer(url)); */
// https://estuary.mcmaster.ca/samples/resources.json
// Array<{ "url":string, "bank": string, "n": number}>
// ritchse/tidal-drum-machines/tree/main/machines/AkaiLinn
const githubCache = {};
let sampleCache = { current: undefined }; let sampleCache = { current: undefined };
export const loadGithubSamples = async (path, nameFn) => {
const storageKey = 'loadGithubSamples ' + path;
const stored = localStorage.getItem(storageKey);
if (stored) {
console.log('[sampler]: loaded sample list from localstorage', path);
githubCache[path] = JSON.parse(stored);
}
if (githubCache[path]) {
sampleCache.current = githubCache[path];
return githubCache[path];
}
console.log('[sampler]: fetching sample list from github', path);
try {
const [user, repo, ...folders] = path.split('/');
const baseUrl = `https://api.github.com/repos/${user}/${repo}/contents`;
const banks = await fetch(`${baseUrl}/${folders.join('/')}`).then((res) => res.json());
// fetch each subfolder
githubCache[path] = (
await Promise.all(
banks.map(async ({ name, path }) => ({
name,
content: await fetch(`${baseUrl}/${path}`)
.then((res) => res.json())
.catch((err) => {
console.error('could not load path', err);
}),
})),
)
)
.filter(({ content }) => !!content)
.reduce(
(acc, { name, content }) => ({
...acc,
[nameFn?.(name) || name]: content.map(({ download_url }) => download_url),
}),
{},
);
} catch (err) {
console.error('[sampler]: failed to fetch sample list from github', err);
return;
}
sampleCache.current = githubCache[path];
localStorage.setItem(storageKey, JSON.stringify(sampleCache.current));
console.log('[sampler]: loaded samples:', sampleCache.current);
return githubCache[path];
};
/** /**
* Loads a collection of samples to use with `s` * Loads a collection of samples to use with `s`

View File

@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
// import { Pattern, getFrequency, patternify2 } from '@strudel.cycles/core'; // import { Pattern, getFrequency, patternify2 } from '@strudel.cycles/core';
import * as strudel from '@strudel.cycles/core'; import * as strudel from '@strudel.cycles/core';
import { fromMidi, isNote, toMidi } from '@strudel.cycles/core'; import { fromMidi, logger, toMidi } from '@strudel.cycles/core';
import './feedbackdelay.mjs'; import './feedbackdelay.mjs';
import './reverb.mjs'; import './reverb.mjs';
import { loadBuffer, reverseBuffer } from './sampler.mjs'; import { loadBuffer, reverseBuffer } from './sampler.mjs';
@ -112,9 +112,10 @@ const getSampleBufferSource = async (s, n, note, speed) => {
const bank = samples?.[s]; const bank = samples?.[s];
if (!bank) { if (!bank) {
throw new Error( throw new Error(
`sample not found: "${s}", try one of ${Object.keys(samples) `sample not found: "${s}"`,
.map((s) => `"${s}"`) // , try one of ${Object.keys(samples)
.join(', ')}.`, // .map((s) => `"${s}"`)
// .join(', ')}.
); );
} }
if (typeof bank !== 'object') { if (typeof bank !== 'object') {
@ -135,7 +136,7 @@ const getSampleBufferSource = async (s, n, note, speed) => {
transpose = -midiDiff(closest); // semitones to repitch transpose = -midiDiff(closest); // semitones to repitch
sampleUrl = bank[closest][n % bank[closest].length]; sampleUrl = bank[closest][n % bank[closest].length];
} }
let buffer = await loadBuffer(sampleUrl, ac); let buffer = await loadBuffer(sampleUrl, ac, s, n);
if (speed < 0) { if (speed < 0) {
// should this be cached? // should this be cached?
buffer = reverseBuffer(buffer); buffer = reverseBuffer(buffer);
@ -337,21 +338,17 @@ export const webaudioOutput = async (hap, deadline, hapDuration) => {
const soundfont = getSoundfontKey(s); const soundfont = getSoundfontKey(s);
let bufferSource; let bufferSource;
try { if (soundfont) {
if (soundfont) { // is soundfont
// is soundfont bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac);
bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac); } else {
} else { // is sample from loaded samples(..)
// is sample from loaded samples(..) bufferSource = await getSampleBufferSource(s, n, note, speed);
bufferSource = await getSampleBufferSource(s, n, note, speed);
}
} catch (err) {
console.warn(err);
return;
} }
// asny stuff above took too long? // asny stuff above took too long?
if (ac.currentTime > t) { if (ac.currentTime > t) {
console.warn('sample still loading:', s, n); logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight');
// console.warn('sample still loading:', s, n);
return; return;
} }
if (!bufferSource) { if (!bufferSource) {

View File

@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
// import { evaluate } from '@strudel.cycles/eval'; // import { evaluate } from '@strudel.cycles/eval';
import { CodeMirror, cx, flash, useHighlighting } from '@strudel.cycles/react'; import { CodeMirror, cx, flash, useHighlighting } from '@strudel.cycles/react';
// import { cleanupDraw, cleanupUi, Tone } from '@strudel.cycles/tone'; // import { cleanupDraw, cleanupUi, Tone } from '@strudel.cycles/tone';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import './App.css'; import './App.css';
import logo from './logo.svg'; import logo from './logo.svg';
import * as tunes from './tunes.mjs'; import * as tunes from './tunes.mjs';
@ -96,25 +96,29 @@ function getRandomTune() {
const { code: randomTune, name } = getRandomTune(); const { code: randomTune, name } = getRandomTune();
const isEmbedded = window.location !== window.parent.location; const isEmbedded = window.location !== window.parent.location;
function App() { function App() {
// const [editor, setEditor] = useState(); const [view, setView] = useState(); // codemirror view
const [view, setView] = useState();
const [lastShared, setLastShared] = useState(); const [lastShared, setLastShared] = useState();
// logger // logger
const [log, setLog] = useState([]); const [log, setLog] = useState([]);
const pushLog = (message, type) => { useLogger(
setLog((l) => { useCallback((e) => {
logger(message); const { message, type, data } = e.detail;
const lastLog = l.length ? l[l.length - 1] : undefined; setLog((l) => {
const index = (lastLog?.index ?? -1) + 1; const lastLog = l.length ? l[l.length - 1] : undefined;
if (lastLog && lastLog.message === message) { const id = nanoid(12);
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, index }]); if (type === 'loaded-sample') {
} else { const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l = l.concat([{ message, type, index }]); l[loadIndex] = { message, type, id, data };
} } else if (lastLog && lastLog.message === message) {
return l.slice(-20); l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
}); } else {
}; l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const logBox = useRef(); const logBox = useRef();
useLayoutEffect(() => { useLayoutEffect(() => {
if (logBox.current) { if (logBox.current) {
@ -123,20 +127,19 @@ function App() {
} }
}, [log]); }, [log]);
// repl
const { code, setCode, scheduler, evaluate, activateCode, error, isDirty, activeCode, pattern, started, stop } = const { code, setCode, scheduler, evaluate, activateCode, error, isDirty, activeCode, pattern, started, stop } =
useStrudel({ useStrudel({
initialCode: '// LOADING', initialCode: '// LOADING',
defaultOutput: webaudioOutput, defaultOutput: webaudioOutput,
getTime, getTime,
autolink: true, autolink: true,
onLog: pushLog, onLog: logger,
}); });
// init code // init code
useEffect(() => { useEffect(() => {
initCode().then((decoded) => { initCode().then((decoded) => {
pushLog( logger(
`🌀 Welcome to Strudel! ${ `🌀 Welcome to Strudel! ${
decoded ? `I have loaded the code from the URL.` : `A random code snippet named "${name}" has been loaded!` decoded ? `I have loaded the code from the URL.` : `A random code snippet named "${name}" has been loaded!`
} Press play or hit ctrl+enter to run it!`, } Press play or hit ctrl+enter to run it!`,
@ -146,33 +149,89 @@ function App() {
}); });
}, []); }, []);
// set active pattern on ctrl+enter useKeydown(
useLayoutEffect(() => { useCallback(
// TODO: make sure this is only fired when editor has focus async (e) => {
const handleKeyPress = async (e) => { if (e.ctrlKey || e.altKey) {
if (e.ctrlKey || e.altKey) { if (e.code === 'Enter') {
if (e.code === 'Enter') { e.preventDefault();
e.preventDefault(); flash(view);
flash(view); await activateCode();
await activateCode(); } else if (e.code === 'Period') {
} else if (e.code === 'Period') { stop();
stop(); e.preventDefault();
e.preventDefault(); }
} }
} },
}; [activateCode, stop, view],
window.addEventListener('keydown', handleKeyPress, true); ),
return () => window.removeEventListener('keydown', handleKeyPress, true); );
}, [activateCode, stop, view]);
useHighlighting({ useHighlighting({
view, view,
pattern, pattern,
active: started && !activeCode?.includes('strudel disable-highlighting'), active: started && !activeCode?.includes('strudel disable-highlighting'),
getTime: () => scheduler.getPhase(), getTime: () => scheduler.getPhase(),
// getTime: () => Tone.getTransport().seconds,
}); });
//
// UI Actions
//
const handleTogglePlay = async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
activateCode();
} else {
stop();
}
};
const handleUpdate = () => {
isDirty && activateCode();
logger('Code updated! Tip: You can also update the code by pressing ctrl+enter.');
};
const handleShuffle = async () => {
const { code, name } = getRandomTune();
logger(`✨ loading random tune "${name}"`);
/*
cleanupDraw();
cleanupUi(); */
resetLoadedSamples();
await prebake(); // declare default samples
await evaluate(code, false);
};
const handleShare = async () => {
const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
setLastShared(activeCode || code);
const shareUrl = window.location.origin + '?' + hash;
// copy shareUrl to clipboard
navigator.clipboard.writeText(shareUrl);
const message = `Link copied to clipboard: ${shareUrl}`;
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
};
const handleChangeCode = (c) => {
setCode(c);
started && logger('[edit] code changed. hit ctrl+enter to update');
};
return ( return (
// bg-gradient-to-t from-blue-900 to-slate-900 // bg-gradient-to-t from-blue-900 to-slate-900
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
@ -201,17 +260,7 @@ function App() {
</h1> </h1>
</div> </div>
<div className="flex max-w-full overflow-auto text-white "> <div className="flex max-w-full overflow-auto text-white ">
<button <button onClick={handleTogglePlay} className={cx(!isEmbedded ? 'p-2' : 'px-2')}>
onClick={async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
activateCode();
} else {
stop();
}
}}
className={cx(!isEmbedded ? 'p-2' : 'px-2')}
>
{!pending ? ( {!pending ? (
<span className={cx('flex items-center space-x-1 hover:text-primary', isEmbedded ? 'w-16' : 'w-16')}> <span className={cx('flex items-center space-x-1 hover:text-primary', isEmbedded ? 'w-16' : 'w-16')}>
{started ? <StopCircleIcon className="w-5 h-5" /> : <PlayCircleIcon className="w-5 h-5" />} {started ? <StopCircleIcon className="w-5 h-5" /> : <PlayCircleIcon className="w-5 h-5" />}
@ -222,10 +271,7 @@ function App() {
)} )}
</button> </button>
<button <button
onClick={() => { onClick={handleUpdate}
isDirty && activateCode();
pushLog('Code updated! Tip: You can also update the code by pressing ctrl+enter.');
}}
className={cx( className={cx(
'flex items-center space-x-1', 'flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2', !isEmbedded ? 'p-2' : 'px-2',
@ -236,22 +282,9 @@ function App() {
<span>update</span> <span>update</span>
</button> </button>
{!isEmbedded && ( {!isEmbedded && (
<button <button className="hover:text-primary p-2 flex items-center space-x-1" onClick={handleShuffle}>
className="hover:text-primary p-2 flex items-center space-x-1"
onClick={async () => {
const { code, name } = getRandomTune();
pushLog(`✨ loading random tune "${name}"`);
/*
cleanupDraw();
cleanupUi(); */
resetLoadedSamples();
await prebake(); // declare default samples
await evaluate(code, false);
}}
>
<SparklesIcon className="w-5 h-5" /> <SparklesIcon className="w-5 h-5" />
<span> shuffle</span> <span> shuffle</span>
{/* <MusicalNoteIcon /> <RadioIcon/> */}
</button> </button>
)} )}
{!isEmbedded && ( {!isEmbedded && (
@ -269,35 +302,10 @@ function App() {
'cursor-pointer hover:text-primary flex items-center space-x-1', 'cursor-pointer hover:text-primary flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2', !isEmbedded ? 'p-2' : 'px-2',
)} )}
onClick={async () => { onClick={handleShare}
const codeToShare = activeCode || code;
if (lastShared === codeToShare) {
// alert('Link already generated!');
pushLog(`Link already generated!`);
return;
}
// generate uuid in the browser
const hash = nanoid(12);
const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]);
if (!error) {
setLastShared(activeCode || code);
const shareUrl = window.location.origin + '?' + hash;
// copy shareUrl to clipboard
navigator.clipboard.writeText(shareUrl);
const message = `Link copied to clipboard: ${shareUrl}`;
// alert(message);
pushLog(message);
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
pushLog(message);
}
}}
> >
<LinkIcon className="w-5 h-5" /> <LinkIcon className="w-5 h-5" />
<span>share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}</span> <span>share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}</span>
{/* GlobaAlt Megaphone PaperAirplane Share */}
</button> </button>
)} )}
{isEmbedded && ( {isEmbedded && (
@ -324,39 +332,25 @@ function App() {
</header> </header>
)} )}
<section className="grow flex text-gray-100 relative overflow-auto cursor-text pb-4" id="code"> <section className="grow flex text-gray-100 relative overflow-auto cursor-text pb-4" id="code">
<CodeMirror <CodeMirror value={code} onChange={handleChangeCode} onViewChanged={setView} />
value={code}
onChange={(c) => {
setCode(c);
started && pushLog('[edit] code changed. hit ctrl+enter to update');
}}
onViewChanged={setView}
/>
</section> </section>
<footer className="bg-footer"> <footer className="bg-footer">
{/* {error && (
<div
className={cx(
'rounded-md pointer-events-none left-0 p-1 text-sm bg-black px-2 z-[20] max-w-screen break-all',
'text-red-500',
)}
>
{error?.message || 'unknown error'}
</div>
)} */}
<div <div
ref={logBox} ref={logBox}
className="text-white font-mono text-sm h-32 flex-none overflow-auto max-w-full break-all p-4" className="text-white font-mono text-sm h-64 flex-none overflow-auto max-w-full break-all p-4"
> >
{log.map((l, i) => ( {log.map((l, i) => {
<div const message = linkify(l.message);
key={l.index} return (
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'text-highlight')} <div
> key={l.id}
&gt; {l.message} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'text-highlight')}
{l.count ? ` (${l.count})` : ''} >
</div> &gt; <span dangerouslySetInnerHTML={{ __html: message }} />
))} {l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div> </div>
</footer> </footer>
</div> </div>
@ -365,59 +359,40 @@ function App() {
export default App; export default App;
function ActionButton({ children, onClick, className }) { function useEvent(name, onTrigger, useCapture = false) {
return ( useEffect(() => {
<button document.addEventListener(name, onTrigger, useCapture);
className={cx( return () => {
'bg-lineblack py-1 px-2 bottom-0 text-md whitespace-pre text-right pb-2 cursor-pointer flex items-center space-x-1 hover:text-primary', document.removeEventListener(name, onTrigger, useCapture);
className, };
)} }, [onTrigger]);
onClick={onClick}
>
{children}
</button>
);
} }
function FloatingBottomMenu() {
{ function useLogger(onTrigger) {
/* <span className="hidden md:block z-[20] bg-black py-1 px-2 text-sm absolute bottom-1 right-0 text-md whitespace-pre text-right pointer-events-none pb-2"> useEvent(logger.key, onTrigger);
{!started }
? `press ctrl+enter to play\n`
: isDirty function useKeydown(onTrigger) {
? `press ctrl+enter to update\n` useEvent('keydown', onTrigger, true);
: 'press ctrl+dot do stop\n'} }
</span>*/
} function linkify(inputText) {
return ( var replacedText, replacePattern1, replacePattern2, replacePattern3;
<div className="flex justify-center w-full absolute bottom-0 z-[20]">
<ActionButton //URLs starting with http://, https://, or ftp://
onClick={async () => { replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
await getAudioContext().resume(); // fixes no sound in ios webkit replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
if (!started) {
activateCode(); //URLs starting with "www." (without // before it, or it'd re-link the ones done above).
} else { replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
stop(); replacedText = replacedText.replace(
} replacePattern2,
}} '$1<a class="underline" href="http://$2" target="_blank">$2</a>',
> );
{!pending ? (
<span className={cx('flex items-center space-x-1 hover:text-primary', isEmbedded ? 'w-16' : 'w-16')}> //Change email addresses to mailto:: links.
{started ? <StopCircleIcon className="w-5 h-5" /> : <PlayCircleIcon className="w-5 h-5" />} replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
<span>{started ? 'stop' : 'play'}</span> replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
</span>
) : ( return replacedText;
<>loading...</>
)}
</ActionButton>
<ActionButton
onClick={() => {
isDirty && activateCode();
}}
className={cx(!isDirty || !activeCode ? 'opacity-50 hover:text-inherit' : 'hover:text-primary')}
>
<CommandLineIcon className="w-5 h-5" />
<span>update</span>
</ActionButton>
</div>
);
} }