From cc6997baec3423a0490c491a8a155f2e83a92cc6 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 29 Jun 2023 02:00:59 +0200 Subject: [PATCH 1/7] - encapsulate sample map handling - add resolveUrl param --- packages/webaudio/sampler.mjs | 74 ++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/webaudio/sampler.mjs b/packages/webaudio/sampler.mjs index 449b5e39..1eea3e03 100644 --- a/packages/webaudio/sampler.mjs +++ b/packages/webaudio/sampler.mjs @@ -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,31 @@ 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); + }); +}; + /** * Loads a collection of samples to use with `s` * @example @@ -130,39 +158,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 +200,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) { From 610ea414c152c164b3100e31c9429d2074d81d91 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 29 Jun 2023 02:01:22 +0200 Subject: [PATCH 2/7] expose tauri fs api --- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 30ad48de..b7401f85 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5392007f..429415c8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": ["$AUDIO/**", "$AUDIO"] + } }, "bundle": { "active": true, From 058b971870b585ab3f05a4094ae82862a995efb2 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 29 Jun 2023 02:02:00 +0200 Subject: [PATCH 3/7] can now play samples from file system - ui still wip --- website/src/repl/FilesTab.jsx | 187 ++++++++++++++++++++++++++++++++++ website/src/repl/Footer.jsx | 5 + 2 files changed, 192 insertions(+) create mode 100644 website/src/repl/FilesTab.jsx diff --git a/website/src/repl/FilesTab.jsx b/website/src/repl/FilesTab.jsx new file mode 100644 index 00000000..ed8d1215 --- /dev/null +++ b/website/src/repl/FilesTab.jsx @@ -0,0 +1,187 @@ +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]; +} + +export function FilesTab() { + const [path, setPath] = useState([]); + useEffect(() => { + let init = false; + loadFiles().then((_tree) => setPath([{ name: 'files', children: _tree, path: BaseDirectory.Audio }])); + 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 + .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) /* || 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])); + + return ( +
+
+
+ {path?.map((p, i) => { + if (i < path.length - 1) { + return ( + + setPath((p) => p.slice(0, i + 1))}> + {p.name} + {' '} + /{' '} + + ); + } else { + return ( + + {p.name}{' '} + + ); + } + })} +
+
+
+ {folders?.map((e, i) => ( +
select(e)}> + {e.name} +
+ ))} + {files?.map((e, i) => ( +
{ + 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} +
+ ))} +
+ +
+ + {strudelJson && ( + <> + + {/* */} + + )} +
+
+ ); +} diff --git a/website/src/repl/Footer.jsx b/website/src/repl/Footer.jsx index 417fb8ed..ed0efaef 100644 --- a/website/src/repl/Footer.jsx +++ b/website/src/repl/Footer.jsx @@ -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 }) { + {TAURI && } {activeFooter !== '' && ( - {strudelJson && ( - <> - - {/* */} - - )} - ); } diff --git a/website/src/repl/files.mjs b/website/src/repl/files.mjs new file mode 100644 index 00000000..35a538a7 --- /dev/null +++ b/website/src/repl/files.mjs @@ -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); +} diff --git a/website/src/repl/prebake.mjs b/website/src/repl/prebake.mjs index ec50de0d..6bdc5c0d 100644 --- a/website/src/repl/prebake.mjs +++ b/website/src/repl/prebake.mjs @@ -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 From c7d8a9cb822554723f3156056e5c9b605b3644c4 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 29 Jun 2023 12:05:09 +0200 Subject: [PATCH 6/7] fix: playFile import --- website/src/repl/FilesTab.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/repl/FilesTab.jsx b/website/src/repl/FilesTab.jsx index 87b40584..e04086b6 100644 --- a/website/src/repl/FilesTab.jsx +++ b/website/src/repl/FilesTab.jsx @@ -1,6 +1,6 @@ import { Fragment, useEffect } from 'react'; import React, { useMemo, useState } from 'react'; -import { isAudioFile, readDir, dir } from './files.mjs'; +import { isAudioFile, readDir, dir, playFile } from './files.mjs'; export function FilesTab() { const [path, setPath] = useState([]); From b39d948d7122293a70f4548e2c1d30e572dbad90 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 29 Jun 2023 12:32:59 +0200 Subject: [PATCH 7/7] fix: build --- website/src/repl/files.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/src/repl/files.mjs b/website/src/repl/files.mjs index 35a538a7..3131c8c4 100644 --- a/website/src/repl/files.mjs +++ b/website/src/repl/files.mjs @@ -7,7 +7,10 @@ import { loadBuffer, } from '@strudel.cycles/webaudio'; -const TAURI = window.__TAURI__; +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