mirror of
https://github.com/eliasstepanik/strudel-docker.git
synced 2026-01-14 07:08:30 +00:00
Merge pull request #621 from tidalcycles/tauri-fs
desktop: play samples from disk
This commit is contained in:
commit
e79b6c514a
@ -21,7 +21,7 @@ function humanFileSize(bytes, si) {
|
||||
return bytes.toFixed(1) + ' ' + units[u];
|
||||
}
|
||||
|
||||
export const getSampleBufferSource = async (s, n, note, speed, freq, bank) => {
|
||||
export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => {
|
||||
let transpose = 0;
|
||||
if (freq !== undefined && note !== undefined) {
|
||||
logger('[sampler] hap has note and freq. ignoring note', 'warning');
|
||||
@ -45,6 +45,9 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank) => {
|
||||
transpose = -midiDiff(closest); // semitones to repitch
|
||||
sampleUrl = bank[closest][n % bank[closest].length];
|
||||
}
|
||||
if (resolveUrl) {
|
||||
sampleUrl = await resolveUrl(sampleUrl);
|
||||
}
|
||||
let buffer = await loadBuffer(sampleUrl, ac, s, n);
|
||||
if (speed < 0) {
|
||||
// should this be cached?
|
||||
@ -91,6 +94,46 @@ export const getLoadedBuffer = (url) => {
|
||||
return bufferCache[url];
|
||||
};
|
||||
|
||||
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
|
||||
return Object.entries(sampleMap).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
value = [value];
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('wrong sample map format for ' + key);
|
||||
}
|
||||
baseUrl = value._base || baseUrl;
|
||||
const replaceUrl = (v) => (baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/');
|
||||
if (Array.isArray(value)) {
|
||||
//return [key, value.map(replaceUrl)];
|
||||
value = value.map(replaceUrl);
|
||||
} else {
|
||||
// must be object
|
||||
value = Object.fromEntries(
|
||||
Object.entries(value).map(([note, samples]) => {
|
||||
return [note, (typeof samples === 'string' ? [samples] : samples).map(replaceUrl)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
fn(key, value);
|
||||
});
|
||||
};
|
||||
|
||||
// allows adding a custom url prefix handler
|
||||
// for example, it is used by the desktop app to load samples starting with '~/music'
|
||||
let resourcePrefixHandlers = {};
|
||||
export function registerSamplesPrefix(prefix, resolve) {
|
||||
resourcePrefixHandlers[prefix] = resolve;
|
||||
}
|
||||
// finds a prefix handler for the given url (if any)
|
||||
function getSamplesPrefixHandler(url) {
|
||||
const handler = Object.entries(resourcePrefixHandlers).find(([key]) => url.startsWith(key));
|
||||
if (handler) {
|
||||
return handler[1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a collection of samples to use with `s`
|
||||
* @example
|
||||
@ -107,6 +150,11 @@ export const getLoadedBuffer = (url) => {
|
||||
|
||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||
if (typeof sampleMap === 'string') {
|
||||
// check if custom prefix handler
|
||||
const handler = getSamplesPrefixHandler(sampleMap);
|
||||
if (handler) {
|
||||
return handler(sampleMap);
|
||||
}
|
||||
if (sampleMap.startsWith('github:')) {
|
||||
let [_, path] = sampleMap.split('github:');
|
||||
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
@ -130,39 +178,23 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
|
||||
});
|
||||
}
|
||||
const { prebake, tag } = options;
|
||||
Object.entries(sampleMap).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
value = [value];
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('wrong sample map format for ' + key);
|
||||
}
|
||||
baseUrl = value._base || baseUrl;
|
||||
const replaceUrl = (v) => (baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/');
|
||||
if (Array.isArray(value)) {
|
||||
//return [key, value.map(replaceUrl)];
|
||||
value = value.map(replaceUrl);
|
||||
} else {
|
||||
// must be object
|
||||
value = Object.fromEntries(
|
||||
Object.entries(value).map(([note, samples]) => {
|
||||
return [note, (typeof samples === 'string' ? [samples] : samples).map(replaceUrl)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
|
||||
type: 'sample',
|
||||
samples: value,
|
||||
baseUrl,
|
||||
prebake,
|
||||
tag,
|
||||
});
|
||||
});
|
||||
processSampleMap(
|
||||
sampleMap,
|
||||
(key, value) =>
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
|
||||
type: 'sample',
|
||||
samples: value,
|
||||
baseUrl,
|
||||
prebake,
|
||||
tag,
|
||||
}),
|
||||
baseUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const cutGroups = [];
|
||||
|
||||
export async function onTriggerSample(t, value, onended, bank) {
|
||||
export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||
const {
|
||||
s,
|
||||
freq,
|
||||
@ -188,7 +220,7 @@ export async function onTriggerSample(t, value, onended, bank) {
|
||||
//const soundfont = getSoundfontKey(s);
|
||||
const time = t + nudge;
|
||||
|
||||
const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank);
|
||||
const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank, resolveUrl);
|
||||
|
||||
// asny stuff above took too long?
|
||||
if (ac.currentTime > t) {
|
||||
|
||||
@ -17,7 +17,7 @@ tauri-build = { version = "1.4.0", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.4.0", features = [] }
|
||||
tauri = { version = "1.4.0", features = ["fs-all"] }
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:3000",
|
||||
"distDir": "../website/dist"
|
||||
"distDir": "../website/dist",
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
"package": {
|
||||
"productName": "Strudel",
|
||||
@ -12,7 +13,11 @@
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false
|
||||
"all": false,
|
||||
"fs": {
|
||||
"all": true,
|
||||
"scope": ["$HOME/**", "$HOME", "$HOME/*"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
75
website/src/repl/FilesTab.jsx
Normal file
75
website/src/repl/FilesTab.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { Fragment, useEffect } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { isAudioFile, readDir, dir, playFile } from './files.mjs';
|
||||
|
||||
export function FilesTab() {
|
||||
const [path, setPath] = useState([]);
|
||||
useEffect(() => {
|
||||
let init = false;
|
||||
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 subpath = useMemo(
|
||||
() =>
|
||||
path
|
||||
.slice(1)
|
||||
.map((p) => p.name)
|
||||
.join('/'),
|
||||
[path],
|
||||
);
|
||||
const folders = useMemo(() => current?.children.filter((e) => !!e.children), [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 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>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
))}
|
||||
{files?.map((e, i) => (
|
||||
<div
|
||||
className="text-gray-500 cursor-pointer select-none"
|
||||
key={i}
|
||||
onClick={async () => playFile(`${subpath}/${e.name}`)}
|
||||
>
|
||||
{e.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,9 @@ import { themes } from './themes.mjs';
|
||||
import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
|
||||
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { FilesTab } from './FilesTab';
|
||||
|
||||
const TAURI = window.__TAURI__;
|
||||
|
||||
export function Footer({ context }) {
|
||||
const footerContent = useRef();
|
||||
@ -77,6 +80,7 @@ export function Footer({ context }) {
|
||||
<FooterTab name="console" />
|
||||
<FooterTab name="reference" />
|
||||
<FooterTab name="settings" />
|
||||
{TAURI && <FooterTab name="files" />}
|
||||
</div>
|
||||
{activeFooter !== '' && (
|
||||
<button onClick={() => setActiveFooter('')} className="text-foreground" aria-label="Close Panel">
|
||||
@ -91,6 +95,7 @@ export function Footer({ context }) {
|
||||
{activeFooter === 'sounds' && <SoundsTab />}
|
||||
{activeFooter === 'reference' && <Reference />}
|
||||
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
|
||||
{activeFooter === 'files' && <FilesTab />}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
107
website/src/repl/files.mjs
Normal file
107
website/src/repl/files.mjs
Normal file
@ -0,0 +1,107 @@
|
||||
import {
|
||||
processSampleMap,
|
||||
registerSamplesPrefix,
|
||||
registerSound,
|
||||
onTriggerSample,
|
||||
getAudioContext,
|
||||
loadBuffer,
|
||||
} from '@strudel.cycles/webaudio';
|
||||
|
||||
let TAURI;
|
||||
if (typeof window !== 'undefined') {
|
||||
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 { registerSynthSounds, samples } from '@strudel.cycles/webaudio';
|
||||
import './piano.mjs';
|
||||
import './files.mjs';
|
||||
|
||||
export async function prebake() {
|
||||
// https://archive.org/details/SalamanderGrandPianoV3
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user