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) {
console.log(`%c${message}`, 'background-color: black;color:white;padding:4px;border-radius:15px');
export const logKey = 'strudel.log';
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]);
// this will stop the scheduler when hot reloading in development
useEffect(() => {
return () => {
scheduler.stop();
};
}, [scheduler]);
const togglePlay = async () => {
if (started) {
scheduler.pause();

View File

@ -1,13 +1,36 @@
import { logger } from '@strudel.cycles/core';
const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer>
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]) {
logger(`[sampler] load ${label}..`, 'load-sample', { url });
const timestamp = Date.now();
loadCache[url] = fetch(url)
.then((res) => res.arrayBuffer())
.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);
bufferCache[url] = decoded;
return decoded;
@ -29,66 +52,7 @@ export const getLoadedBuffer = (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 };
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`

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 * 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 './reverb.mjs';
import { loadBuffer, reverseBuffer } from './sampler.mjs';
@ -112,9 +112,10 @@ const getSampleBufferSource = async (s, n, note, speed) => {
const bank = samples?.[s];
if (!bank) {
throw new Error(
`sample not found: "${s}", try one of ${Object.keys(samples)
.map((s) => `"${s}"`)
.join(', ')}.`,
`sample not found: "${s}"`,
// , try one of ${Object.keys(samples)
// .map((s) => `"${s}"`)
// .join(', ')}.
);
}
if (typeof bank !== 'object') {
@ -135,7 +136,7 @@ const getSampleBufferSource = async (s, n, note, speed) => {
transpose = -midiDiff(closest); // semitones to repitch
sampleUrl = bank[closest][n % bank[closest].length];
}
let buffer = await loadBuffer(sampleUrl, ac);
let buffer = await loadBuffer(sampleUrl, ac, s, n);
if (speed < 0) {
// should this be cached?
buffer = reverseBuffer(buffer);
@ -337,21 +338,17 @@ export const webaudioOutput = async (hap, deadline, hapDuration) => {
const soundfont = getSoundfontKey(s);
let bufferSource;
try {
if (soundfont) {
// is soundfont
bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac);
} else {
// is sample from loaded samples(..)
bufferSource = await getSampleBufferSource(s, n, note, speed);
}
} catch (err) {
console.warn(err);
return;
if (soundfont) {
// is soundfont
bufferSource = await globalThis.getFontBufferSource(soundfont, note || n, ac);
} else {
// is sample from loaded samples(..)
bufferSource = await getSampleBufferSource(s, n, note, speed);
}
// asny stuff above took too long?
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;
}
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 { CodeMirror, cx, flash, useHighlighting } from '@strudel.cycles/react';
// 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 logo from './logo.svg';
import * as tunes from './tunes.mjs';
@ -96,25 +96,29 @@ function getRandomTune() {
const { code: randomTune, name } = getRandomTune();
const isEmbedded = window.location !== window.parent.location;
function App() {
// const [editor, setEditor] = useState();
const [view, setView] = useState();
const [view, setView] = useState(); // codemirror view
const [lastShared, setLastShared] = useState();
// logger
const [log, setLog] = useState([]);
const pushLog = (message, type) => {
setLog((l) => {
logger(message);
const lastLog = l.length ? l[l.length - 1] : undefined;
const index = (lastLog?.index ?? -1) + 1;
if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, index }]);
} else {
l = l.concat([{ message, type, index }]);
}
return l.slice(-20);
});
};
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
if (type === 'loaded-sample') {
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
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();
useLayoutEffect(() => {
if (logBox.current) {
@ -123,20 +127,19 @@ function App() {
}
}, [log]);
// repl
const { code, setCode, scheduler, evaluate, activateCode, error, isDirty, activeCode, pattern, started, stop } =
useStrudel({
initialCode: '// LOADING',
defaultOutput: webaudioOutput,
getTime,
autolink: true,
onLog: pushLog,
onLog: logger,
});
// init code
useEffect(() => {
initCode().then((decoded) => {
pushLog(
logger(
`🌀 Welcome to Strudel! ${
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!`,
@ -146,33 +149,89 @@ function App() {
});
}, []);
// set active pattern on ctrl+enter
useLayoutEffect(() => {
// TODO: make sure this is only fired when editor has focus
const handleKeyPress = async (e) => {
if (e.ctrlKey || e.altKey) {
if (e.code === 'Enter') {
e.preventDefault();
flash(view);
await activateCode();
} else if (e.code === 'Period') {
stop();
e.preventDefault();
useKeydown(
useCallback(
async (e) => {
if (e.ctrlKey || e.altKey) {
if (e.code === 'Enter') {
e.preventDefault();
flash(view);
await activateCode();
} else if (e.code === 'Period') {
stop();
e.preventDefault();
}
}
}
};
window.addEventListener('keydown', handleKeyPress, true);
return () => window.removeEventListener('keydown', handleKeyPress, true);
}, [activateCode, stop, view]);
},
[activateCode, stop, view],
),
);
useHighlighting({
view,
pattern,
active: started && !activeCode?.includes('strudel disable-highlighting'),
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 (
// bg-gradient-to-t from-blue-900 to-slate-900
<div className="h-screen flex flex-col">
@ -201,17 +260,7 @@ function App() {
</h1>
</div>
<div className="flex max-w-full overflow-auto text-white ">
<button
onClick={async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
activateCode();
} else {
stop();
}
}}
className={cx(!isEmbedded ? 'p-2' : 'px-2')}
>
<button onClick={handleTogglePlay} className={cx(!isEmbedded ? 'p-2' : 'px-2')}>
{!pending ? (
<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" />}
@ -222,10 +271,7 @@ function App() {
)}
</button>
<button
onClick={() => {
isDirty && activateCode();
pushLog('Code updated! Tip: You can also update the code by pressing ctrl+enter.');
}}
onClick={handleUpdate}
className={cx(
'flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2',
@ -236,22 +282,9 @@ function App() {
<span>update</span>
</button>
{!isEmbedded && (
<button
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);
}}
>
<button className="hover:text-primary p-2 flex items-center space-x-1" onClick={handleShuffle}>
<SparklesIcon className="w-5 h-5" />
<span> shuffle</span>
{/* <MusicalNoteIcon /> <RadioIcon/> */}
</button>
)}
{!isEmbedded && (
@ -269,35 +302,10 @@ function App() {
'cursor-pointer hover:text-primary flex items-center space-x-1',
!isEmbedded ? 'p-2' : 'px-2',
)}
onClick={async () => {
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);
}
}}
onClick={handleShare}
>
<LinkIcon className="w-5 h-5" />
<span>share{lastShared && lastShared === (activeCode || code) ? 'd!' : ''}</span>
{/* GlobaAlt Megaphone PaperAirplane Share */}
</button>
)}
{isEmbedded && (
@ -324,39 +332,25 @@ function App() {
</header>
)}
<section className="grow flex text-gray-100 relative overflow-auto cursor-text pb-4" id="code">
<CodeMirror
value={code}
onChange={(c) => {
setCode(c);
started && pushLog('[edit] code changed. hit ctrl+enter to update');
}}
onViewChanged={setView}
/>
<CodeMirror value={code} onChange={handleChangeCode} onViewChanged={setView} />
</section>
<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
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) => (
<div
key={l.index}
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'text-highlight')}
>
&gt; {l.message}
{l.count ? ` (${l.count})` : ''}
</div>
))}
{log.map((l, i) => {
const message = linkify(l.message);
return (
<div
key={l.id}
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'text-highlight')}
>
&gt; <span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
</footer>
</div>
@ -365,59 +359,40 @@ function App() {
export default App;
function ActionButton({ children, onClick, className }) {
return (
<button
className={cx(
'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',
className,
)}
onClick={onClick}
>
{children}
</button>
);
function useEvent(name, onTrigger, useCapture = false) {
useEffect(() => {
document.addEventListener(name, onTrigger, useCapture);
return () => {
document.removeEventListener(name, onTrigger, useCapture);
};
}, [onTrigger]);
}
function FloatingBottomMenu() {
{
/* <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">
{!started
? `press ctrl+enter to play\n`
: isDirty
? `press ctrl+enter to update\n`
: 'press ctrl+dot do stop\n'}
</span>*/
}
return (
<div className="flex justify-center w-full absolute bottom-0 z-[20]">
<ActionButton
onClick={async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
activateCode();
} else {
stop();
}
}}
>
{!pending ? (
<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" />}
<span>{started ? 'stop' : 'play'}</span>
</span>
) : (
<>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>
);
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}
function useKeydown(onTrigger) {
useEvent('keydown', onTrigger, true);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}