Skip to content

Commit

Permalink
Merge pull request #621 from tidalcycles/tauri-fs
Browse files Browse the repository at this point in the history
desktop: play samples from disk
  • Loading branch information
felixroos authored Jun 29, 2023
2 parents aaa894d + b39d948 commit e79b6c5
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 34 deletions.
94 changes: 63 additions & 31 deletions packages/webaudio/sampler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:3000",
"distDir": "../website/dist"
"distDir": "../website/dist",
"withGlobalTauri": true
},
"package": {
"productName": "Strudel",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false
"all": false,
"fs": {
"all": true,
"scope": ["$HOME/**", "$HOME", "$HOME/*"]
}
},
"bundle": {
"active": true,
Expand Down
75 changes: 75 additions & 0 deletions website/src/repl/FilesTab.jsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
5 changes: 5 additions & 0 deletions website/src/repl/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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">
Expand All @@ -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>
Expand Down
107 changes: 107 additions & 0 deletions website/src/repl/files.mjs
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions website/src/repl/prebake.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit e79b6c5

Please sign in to comment.