Merge pull request #621 from tidalcycles/tauri-fs
desktop: play samples from disk
This commit is contained in:
commit
e79b6c514a
7 changed files with 259 additions and 34 deletions
|
|
@ -21,7 +21,7 @@ function humanFileSize(bytes, si) {
|
||||||
return bytes.toFixed(1) + ' ' + units[u];
|
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;
|
let transpose = 0;
|
||||||
if (freq !== undefined && note !== undefined) {
|
if (freq !== undefined && note !== undefined) {
|
||||||
logger('[sampler] hap has note and freq. ignoring note', 'warning');
|
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
|
transpose = -midiDiff(closest); // semitones to repitch
|
||||||
sampleUrl = bank[closest][n % bank[closest].length];
|
sampleUrl = bank[closest][n % bank[closest].length];
|
||||||
}
|
}
|
||||||
|
if (resolveUrl) {
|
||||||
|
sampleUrl = await resolveUrl(sampleUrl);
|
||||||
|
}
|
||||||
let buffer = await loadBuffer(sampleUrl, ac, s, n);
|
let buffer = await loadBuffer(sampleUrl, ac, s, n);
|
||||||
if (speed < 0) {
|
if (speed < 0) {
|
||||||
// should this be cached?
|
// should this be cached?
|
||||||
|
|
@ -91,6 +94,46 @@ export const getLoadedBuffer = (url) => {
|
||||||
return bufferCache[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`
|
* Loads a collection of samples to use with `s`
|
||||||
* @example
|
* @example
|
||||||
|
|
@ -107,6 +150,11 @@ export const getLoadedBuffer = (url) => {
|
||||||
|
|
||||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||||
if (typeof sampleMap === 'string') {
|
if (typeof sampleMap === 'string') {
|
||||||
|
// check if custom prefix handler
|
||||||
|
const handler = getSamplesPrefixHandler(sampleMap);
|
||||||
|
if (handler) {
|
||||||
|
return handler(sampleMap);
|
||||||
|
}
|
||||||
if (sampleMap.startsWith('github:')) {
|
if (sampleMap.startsWith('github:')) {
|
||||||
let [_, path] = sampleMap.split('github:');
|
let [_, path] = sampleMap.split('github:');
|
||||||
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
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;
|
const { prebake, tag } = options;
|
||||||
Object.entries(sampleMap).forEach(([key, value]) => {
|
processSampleMap(
|
||||||
if (typeof value === 'string') {
|
sampleMap,
|
||||||
value = [value];
|
(key, value) =>
|
||||||
}
|
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
|
||||||
if (typeof value !== 'object') {
|
type: 'sample',
|
||||||
throw new Error('wrong sample map format for ' + key);
|
samples: value,
|
||||||
}
|
baseUrl,
|
||||||
baseUrl = value._base || baseUrl;
|
prebake,
|
||||||
const replaceUrl = (v) => (baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/');
|
tag,
|
||||||
if (Array.isArray(value)) {
|
}),
|
||||||
//return [key, value.map(replaceUrl)];
|
baseUrl,
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cutGroups = [];
|
const cutGroups = [];
|
||||||
|
|
||||||
export async function onTriggerSample(t, value, onended, bank) {
|
export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
||||||
const {
|
const {
|
||||||
s,
|
s,
|
||||||
freq,
|
freq,
|
||||||
|
|
@ -188,7 +220,7 @@ export async function onTriggerSample(t, value, onended, bank) {
|
||||||
//const soundfont = getSoundfontKey(s);
|
//const soundfont = getSoundfontKey(s);
|
||||||
const time = t + nudge;
|
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?
|
// asny stuff above took too long?
|
||||||
if (ac.currentTime > t) {
|
if (ac.currentTime > t) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ tauri-build = { version = "1.4.0", features = [] }
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.4.0", features = [] }
|
tauri = { version = "1.4.0", features = ["fs-all"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
# 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",
|
"beforeBuildCommand": "npm run build",
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devPath": "http://localhost:3000",
|
"devPath": "http://localhost:3000",
|
||||||
"distDir": "../website/dist"
|
"distDir": "../website/dist",
|
||||||
|
"withGlobalTauri": true
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "Strudel",
|
"productName": "Strudel",
|
||||||
|
|
@ -12,7 +13,11 @@
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
"all": false
|
"all": false,
|
||||||
|
"fs": {
|
||||||
|
"all": true,
|
||||||
|
"scope": ["$HOME/**", "$HOME", "$HOME/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"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 { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
|
||||||
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
|
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { FilesTab } from './FilesTab';
|
||||||
|
|
||||||
|
const TAURI = window.__TAURI__;
|
||||||
|
|
||||||
export function Footer({ context }) {
|
export function Footer({ context }) {
|
||||||
const footerContent = useRef();
|
const footerContent = useRef();
|
||||||
|
|
@ -77,6 +80,7 @@ export function Footer({ context }) {
|
||||||
<FooterTab name="console" />
|
<FooterTab name="console" />
|
||||||
<FooterTab name="reference" />
|
<FooterTab name="reference" />
|
||||||
<FooterTab name="settings" />
|
<FooterTab name="settings" />
|
||||||
|
{TAURI && <FooterTab name="files" />}
|
||||||
</div>
|
</div>
|
||||||
{activeFooter !== '' && (
|
{activeFooter !== '' && (
|
||||||
<button onClick={() => setActiveFooter('')} className="text-foreground" aria-label="Close Panel">
|
<button onClick={() => setActiveFooter('')} className="text-foreground" aria-label="Close Panel">
|
||||||
|
|
@ -91,6 +95,7 @@ export function Footer({ context }) {
|
||||||
{activeFooter === 'sounds' && <SoundsTab />}
|
{activeFooter === 'sounds' && <SoundsTab />}
|
||||||
{activeFooter === 'reference' && <Reference />}
|
{activeFooter === 'reference' && <Reference />}
|
||||||
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
|
{activeFooter === 'settings' && <SettingsTab scheduler={context.scheduler} />}
|
||||||
|
{activeFooter === 'files' && <FilesTab />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</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 { 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…
Add table
Add a link
Reference in a new issue