feat: can now load disk samples via samples

+ encapsulate file logic from FilesTab
This commit is contained in:
Felix Roos 2023-06-29 10:47:20 +02:00
parent fb3ca9f960
commit b50075f102
4 changed files with 122 additions and 129 deletions

View File

@ -16,7 +16,7 @@
"all": false,
"fs": {
"all": true,
"scope": ["$AUDIO/**", "$AUDIO"]
"scope": ["$HOME/**", "$HOME", "$HOME/*"]
}
},
"bundle": {

View File

@ -1,59 +1,21 @@
import { Fragment, useEffect } from 'react';
import { getAudioContext, loadBuffer, processSampleMap } from '@strudel.cycles/webaudio';
import React, { useMemo, useState } from 'react';
const TAURI = window.__TAURI__;
const { BaseDirectory, readDir, readBinaryFile, writeTextFile, readTextFile } = TAURI?.fs || {};
async function loadFiles() {
return readDir('', { dir: BaseDirectory.Audio, recursive: true });
}
const walkFileTree = (node, fn) => {
if (!Array.isArray(node?.children)) {
return;
}
for (const entry of node.children) {
entry.subpath = (node.subpath || []).concat([node.name]);
fn(entry, node);
if (entry.children) {
walkFileTree(entry, fn);
}
}
};
const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
function uint8ArrayToDataURL(uint8Array) {
const blob = new Blob([uint8Array], { type: 'audio/*' });
const dataURL = URL.createObjectURL(blob);
return dataURL;
}
const loadCache = {}; // caches local urls to data urls
async function resolveFileURL(url) {
if (loadCache[url]) {
return loadCache[url];
}
loadCache[url] = (async () => {
const contents = await readBinaryFile(url, {
dir: BaseDirectory.Audio,
});
return uint8ArrayToDataURL(contents);
})();
return loadCache[url];
}
import { isAudioFile, readDir, dir } from './files.mjs';
export function FilesTab() {
const [path, setPath] = useState([]);
useEffect(() => {
let init = false;
loadFiles().then((_tree) => setPath([{ name: 'files', children: _tree, path: BaseDirectory.Audio }]));
readDir('', { dir, recursive: true })
.then((children) => setPath([{ name: '~/music', children }]))
.catch((err) => {
console.log('error loadin files', err);
});
return () => {
init = true;
};
}, []);
const current = useMemo(() => path[path.length - 1], [path]);
const strudelJson = useMemo(() => current?.children?.find((e) => e.name === 'strudel.json'), [current]);
const subpath = useMemo(
() =>
path
@ -63,47 +25,36 @@ export function FilesTab() {
[path],
);
const folders = useMemo(() => current?.children.filter((e) => !!e.children), [current]);
const files = useMemo(
() => current?.children.filter((e) => !e.children && isAudioFile(e.name) /* || e.name === 'strudel.json' */),
[current],
);
const audioFiles = useMemo(() => {
let files = [];
walkFileTree(current, (entry) => {
if (isAudioFile(entry.name)) {
files.push(entry);
}
});
return files;
}, [current]);
const files = useMemo(() => current?.children.filter((e) => !e.children && isAudioFile(e.name)), [current]);
const select = (e) => setPath((p) => p.concat([e]));
return (
<div className="px-4 flex flex-col h-full">
<div className="flex justify-between">
<div className="flex justify-between font-mono pb-1">
<div>
<span>{`samples('`}</span>
{path?.map((p, i) => {
if (i < path.length - 1) {
return (
<Fragment key={i}>
<span className="cursor-pointer underline" onClick={() => setPath((p) => p.slice(0, i + 1))}>
{p.name}
</span>{' '}
/{' '}
</span>
<span>/</span>
</Fragment>
);
} else {
return (
<span className="underline" key={i}>
{p.name}{' '}
<span className="cursor-pointer underline" key={i}>
{p.name}
</span>
);
}
})}
<span>{`')`}</span>
</div>
</div>
<div className="overflow-auto">
{!folders?.length && !files?.length && <span className="text-gray-500">Nothing here</span>}
{folders?.map((e, i) => (
<div className="cursor-pointer" key={i} onClick={() => select(e)}>
{e.name}
@ -113,75 +64,12 @@ export function FilesTab() {
<div
className="text-gray-500 cursor-pointer select-none"
key={i}
onClick={async () => {
const url = await resolveFileURL(`${subpath}/${e.name}`);
const ac = getAudioContext();
const bufferSource = ac.createBufferSource();
bufferSource.buffer = await loadBuffer(url, ac);
bufferSource.connect(ac.destination);
bufferSource.start(ac.currentTime);
}}
onClick={async () => playFile(`${subpath}/${e.name}`)}
>
{e.name}
</div>
))}
</div>
<div className="flex justify-start space-x-2">
<button
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={async () => {
let samples = {};
walkFileTree(current, (entry, parent) => {
if (['wav', 'mp3'].includes(entry.name.split('.').slice(-1)[0])) {
samples[parent.name] = samples[parent.name] || [];
samples[parent.name].push(entry.subpath.slice(1).concat([entry.name]).join('/'));
}
});
const json = JSON.stringify(samples, null, 2);
const filepath = subpath + '/strudel.json';
console.log('strudel.json', json);
await writeTextFile(filepath, json, { dir: BaseDirectory.Audio });
console.log('written strudel.json to:', current.path);
}}
>
save strudel.json with {audioFiles.length} files
</button>
{strudelJson && (
<>
<button
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={async () => {
const contents = await readTextFile(subpath + '/strudel.json', {
dir: BaseDirectory.Audio,
});
const sampleMap = JSON.parse(contents);
processSampleMap(sampleMap, (key, value) => {
registerSound(
key,
(t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value, resolveFileURL),
{
type: 'sample',
samples: value,
fileSystem: true,
tag: 'local',
/* baseUrl,
prebake,
tag, */
},
);
});
}}
>
load existing strudel.json
</button>
{/* <button className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50" onClick={async () => {
}}>
delete existing strudel.json
</button> */}
</>
)}
</div>
</div>
);
}

104
website/src/repl/files.mjs Normal file
View File

@ -0,0 +1,104 @@
import {
processSampleMap,
registerSamplesPrefix,
registerSound,
onTriggerSample,
getAudioContext,
loadBuffer,
} from '@strudel.cycles/webaudio';
const TAURI = window.__TAURI__;
export const { BaseDirectory, readDir, readBinaryFile, writeTextFile, readTextFile, exists } = TAURI?.fs || {};
export const dir = BaseDirectory?.Audio; // https://tauri.app/v1/api/js/path#audiodir
const prefix = '~/music/';
async function hasStrudelJson(subpath) {
return exists(subpath + '/strudel.json', { dir });
}
async function loadStrudelJson(subpath) {
const contents = await readTextFile(subpath + '/strudel.json', { dir });
const sampleMap = JSON.parse(contents);
processSampleMap(sampleMap, (key, value) => {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value, fileResolver(subpath)), {
type: 'sample',
samples: value,
fileSystem: true,
tag: 'local',
});
});
}
async function writeStrudelJson(subpath) {
const children = await readDir(subpath, { dir, recursive: true });
const name = subpath.split('/').slice(-1)[0];
const tree = { name, children };
let samples = {};
let count = 0;
walkFileTree(tree, (entry, parent) => {
if (['wav', 'mp3'].includes(entry.name.split('.').slice(-1)[0])) {
samples[parent.name] = samples[parent.name] || [];
count += 1;
samples[parent.name].push(entry.subpath.slice(1).concat([entry.name]).join('/'));
}
});
const json = JSON.stringify(samples, null, 2);
const filepath = subpath + '/strudel.json';
await writeTextFile(filepath, json, { dir });
console.log(`wrote strudel.json with ${count} samples to ${subpath}!`);
}
registerSamplesPrefix(prefix, async (path) => {
const subpath = path.replace(prefix, '');
const hasJson = await hasStrudelJson(subpath);
if (!hasJson) {
await writeStrudelJson(subpath);
}
return loadStrudelJson(subpath);
});
export const walkFileTree = (node, fn) => {
if (!Array.isArray(node?.children)) {
return;
}
for (const entry of node.children) {
entry.subpath = (node.subpath || []).concat([node.name]);
fn(entry, node);
if (entry.children) {
walkFileTree(entry, fn);
}
}
};
export const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
function uint8ArrayToDataURL(uint8Array) {
const blob = new Blob([uint8Array], { type: 'audio/*' });
const dataURL = URL.createObjectURL(blob);
return dataURL;
}
const loadCache = {}; // caches local urls to data urls
export async function resolveFileURL(url) {
if (loadCache[url]) {
return loadCache[url];
}
loadCache[url] = (async () => {
const contents = await readBinaryFile(url, { dir });
return uint8ArrayToDataURL(contents);
})();
return loadCache[url];
}
const fileResolver = (subpath) => (url) => resolveFileURL(subpath.endsWith('/') ? subpath + url : subpath + '/' + url);
export async function playFile(path) {
const url = await resolveFileURL(path);
const ac = getAudioContext();
const bufferSource = ac.createBufferSource();
bufferSource.buffer = await loadBuffer(url, ac);
bufferSource.connect(ac.destination);
bufferSource.start(ac.currentTime);
}

View File

@ -1,6 +1,7 @@
import { Pattern, noteToMidi, valueToMidi } from '@strudel.cycles/core';
import { registerSynthSounds, samples } from '@strudel.cycles/webaudio';
import './piano.mjs';
import './files.mjs';
export async function prebake() {
// https://archive.org/details/SalamanderGrandPianoV3