mirror of
https://github.com/eliasstepanik/strudel-docker.git
synced 2026-01-24 20:18:34 +00:00
feat: can now load disk samples via samples
+ encapsulate file logic from FilesTab
This commit is contained in:
parent
fb3ca9f960
commit
b50075f102
@ -16,7 +16,7 @@
|
|||||||
"all": false,
|
"all": false,
|
||||||
"fs": {
|
"fs": {
|
||||||
"all": true,
|
"all": true,
|
||||||
"scope": ["$AUDIO/**", "$AUDIO"]
|
"scope": ["$HOME/**", "$HOME", "$HOME/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@ -1,59 +1,21 @@
|
|||||||
import { Fragment, useEffect } from 'react';
|
import { Fragment, useEffect } from 'react';
|
||||||
import { getAudioContext, loadBuffer, processSampleMap } from '@strudel.cycles/webaudio';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { isAudioFile, readDir, dir } from './files.mjs';
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilesTab() {
|
export function FilesTab() {
|
||||||
const [path, setPath] = useState([]);
|
const [path, setPath] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let init = false;
|
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 () => {
|
return () => {
|
||||||
init = true;
|
init = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const current = useMemo(() => path[path.length - 1], [path]);
|
const current = useMemo(() => path[path.length - 1], [path]);
|
||||||
const strudelJson = useMemo(() => current?.children?.find((e) => e.name === 'strudel.json'), [current]);
|
|
||||||
const subpath = useMemo(
|
const subpath = useMemo(
|
||||||
() =>
|
() =>
|
||||||
path
|
path
|
||||||
@ -63,47 +25,36 @@ export function FilesTab() {
|
|||||||
[path],
|
[path],
|
||||||
);
|
);
|
||||||
const folders = useMemo(() => current?.children.filter((e) => !!e.children), [current]);
|
const folders = useMemo(() => current?.children.filter((e) => !!e.children), [current]);
|
||||||
|
const files = useMemo(() => current?.children.filter((e) => !e.children && isAudioFile(e.name)), [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 select = (e) => setPath((p) => p.concat([e]));
|
const select = (e) => setPath((p) => p.concat([e]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 flex flex-col h-full">
|
<div className="px-4 flex flex-col h-full">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between font-mono pb-1">
|
||||||
<div>
|
<div>
|
||||||
|
<span>{`samples('`}</span>
|
||||||
{path?.map((p, i) => {
|
{path?.map((p, i) => {
|
||||||
if (i < path.length - 1) {
|
if (i < path.length - 1) {
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<span className="cursor-pointer underline" onClick={() => setPath((p) => p.slice(0, i + 1))}>
|
<span className="cursor-pointer underline" onClick={() => setPath((p) => p.slice(0, i + 1))}>
|
||||||
{p.name}
|
{p.name}
|
||||||
</span>{' '}
|
</span>
|
||||||
/{' '}
|
<span>/</span>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="underline" key={i}>
|
<span className="cursor-pointer underline" key={i}>
|
||||||
{p.name}{' '}
|
{p.name}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
<span>{`')`}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
|
{!folders?.length && !files?.length && <span className="text-gray-500">Nothing here</span>}
|
||||||
{folders?.map((e, i) => (
|
{folders?.map((e, i) => (
|
||||||
<div className="cursor-pointer" key={i} onClick={() => select(e)}>
|
<div className="cursor-pointer" key={i} onClick={() => select(e)}>
|
||||||
{e.name}
|
{e.name}
|
||||||
@ -113,75 +64,12 @@ export function FilesTab() {
|
|||||||
<div
|
<div
|
||||||
className="text-gray-500 cursor-pointer select-none"
|
className="text-gray-500 cursor-pointer select-none"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={async () => {
|
onClick={async () => playFile(`${subpath}/${e.name}`)}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{e.name}
|
{e.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
website/src/repl/files.mjs
Normal file
104
website/src/repl/files.mjs
Normal 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);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Pattern, noteToMidi, valueToMidi } from '@strudel.cycles/core';
|
import { Pattern, noteToMidi, valueToMidi } from '@strudel.cycles/core';
|
||||||
import { registerSynthSounds, samples } from '@strudel.cycles/webaudio';
|
import { registerSynthSounds, samples } from '@strudel.cycles/webaudio';
|
||||||
import './piano.mjs';
|
import './piano.mjs';
|
||||||
|
import './files.mjs';
|
||||||
|
|
||||||
export async function prebake() {
|
export async function prebake() {
|
||||||
// https://archive.org/details/SalamanderGrandPianoV3
|
// https://archive.org/details/SalamanderGrandPianoV3
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user