jevstrudel.git / website / src / jev / sounds.mjs
1// The sounds tab's sounds as data: which of its sub-tabs a sound is under,
2// how many variants it has, and how a song plays it. The sounds tab
3// (repl/components/panel/SoundsTab.jsx) filters with `inCategory`, and the
4// MCP's list_sounds and ask_jev_sound (tabTools.mjs) read the same, so
5// what an AI is told is what the listener sees.
6//
7// A sound is an entry of superdough's soundMap: `[name, { data }]`, where
8// `data` says its `type` (sample, synth, soundfont, wavetable), whether the
9// REPL loaded it before any song (`prebake`), a `tag` ('drum-machines') and
10// its files (`samples`, `fonts`, `tables`).
11
12// The sub-tabs, as the MCP names them, and the settings value each one is
13// (settings.mjs's soundFilterType); `imported` is the part of `user` the
14// import-sounds button added from disk (idbutils.mjs), whose files are
15// blob: URLs of this browser.
16export const CATEGORIES = ['samples', 'drum-machines', 'synths', 'wavetables', 'user', 'imported'];
17export const FILTER_OF = { samples: 'samples', 'drum-machines': 'drums', synths: 'synths', wavetables: 'wavetables', user: 'user' };
18
19const firstUrl = (samples) => {
20  if (Array.isArray(samples)) return samples[0];
21  if (samples && typeof samples === 'object') {
22    const first = Object.values(samples)[0];
23    return Array.isArray(first) ? first[0] : first;
24  }
25  return typeof samples === 'string' ? samples : undefined;
26};
27const imported = (data) => !data?.prebake && data?.type === 'sample' && String(firstUrl(data.samples) ?? '').startsWith('blob:');
28
29// Whether a sound is under `category` (a sub-tab's filter value from
30// soundFilterType, or an MCP category name); 'all' and undefined take all.
31export function inCategory(data, category) {
32  switch (category) {
33    case 'user':
34      return !data?.prebake;
35    case 'imported':
36      return imported(data);
37    case 'drums':
38    case 'drum-machines':
39      return data?.type === 'sample' && data.tag === 'drum-machines';
40    case 'samples':
41      return data?.type === 'sample' && data.tag !== 'drum-machines';
42    case 'synths':
43      return ['synth', 'soundfont'].includes(data?.type);
44    case 'wavetables':
45      return data?.type === 'wavetable';
46    default:
47      return true;
48  }
49}
50
51// The one sub-tab a sound shows under, besides user (which cuts across).
52export function categoryOf(data) {
53  if (imported(data)) return 'imported';
54  for (const c of ['drum-machines', 'samples', 'synths', 'wavetables']) if (inCategory(data, c)) return c;
55  return 'other';
56}
57
58const count = (x) => (Array.isArray(x) ? x.length : x && typeof x === 'object' ? Object.values(x).length : x ? 1 : 0);
59
60// How many variants a sound has, as the tab shows in brackets: a sample's
61// files (chosen with .n()), a wavetable's tables, a soundfont's fonts.
62export function variantsOf(data) {
63  if (data?.type === 'sample') return count(data.samples);
64  if (data?.type === 'wavetable') return count(data.tables);
65  if (data?.type === 'soundfont') return count(data.fonts);
66  return 0;
67}
68
69const quote = (s) => JSON.stringify(s);
70
71// How a song plays it (its variants are .n(0), .n(1), …). A drum
72// machine's sounds are named `<machine>_<sound>`, played as the sound with
73// the machine as its bank.
74export function useOf(name, data) {
75  if (data?.type === 'synth' || data?.type === 'soundfont' || data?.type === 'wavetable') {
76    return `note("c3 e3 g3").s(${quote(name)})`;
77  }
78  const bank = data?.tag === 'drum-machines' ? /^(.+)_([^_]+)$/.exec(name) : null;
79  if (bank) return `s(${quote(bank[2])}).bank(${quote(bank[1])})`;
80  return `s(${quote(name)})`;
81}
82
83// The sounds a list shows: named, not internal (`_…`), in `category`,
84// whose name contains `filter` (case aside), in name order.
85export function soundList(map, { category, filter = '' } = {}) {
86  const f = String(filter).toLowerCase();
87  return Object.entries(map ?? {})
88    .filter(([name, sound]) => !name.startsWith('_') && inCategory(sound?.data, category))
89    .filter(([name]) => name.toLowerCase().includes(f))
90    .sort((a, b) => a[0].localeCompare(b[0]));
91}
92
93// One sound as the MCP describes it.
94export function soundEntry(name, data) {
95  return { name, category: categoryOf(data), type: String(data?.type ?? ''), variants: variantsOf(data), use: useOf(name, data) };
96}