jevstrudel.git / website / src / repl / components / panel / SoundsTab.jsx
SoundsTab.jsx212 lines · 8.0 KB · raw
1import useEvent from '@src/useEvent.mjs';
2import { useStore } from '@nanostores/react';
3import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
4import { useMemo, useRef, useState } from 'react';
5import { settingsMap, soundFilterType, useSettings } from '../../../settings.mjs';
6import { ButtonGroup } from './Forms.jsx';
7import ImportSoundsButton from './ImportSoundsButton.jsx';
8import { Textbox } from '@src/repl/components/panel/SettingsTab.jsx';
9import { ActionButton } from '../button/action-button.jsx';
10import { confirmDialog } from '@src/repl/util.mjs';
11import { clearIDB, userSamplesDBConfig } from '@src/repl/idbutils.mjs';
12import { prebake } from '@src/repl/prebake.mjs';
13import { inCategory } from '@src/jev/sounds.mjs';
14
15const getSamples = (samples) =>
16  Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
17
18function wait(ms) {
19  return new Promise((resolve) => setTimeout(resolve, ms));
20}
21
22export function SoundsTab() {
23  const sounds = useStore(soundMap);
24
25  const { soundsFilter } = useSettings();
26  const [search, setSearch] = useState('');
27  const { BASE_URL } = import.meta.env;
28  const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
29
30  const soundEntries = useMemo(() => {
31    if (!sounds) {
32      return [];
33    }
34
35    let filtered = Object.entries(sounds)
36      .filter(([key]) => !key.startsWith('_'))
37      .sort((a, b) => a[0].localeCompare(b[0]))
38      .filter(([name]) => name.toLowerCase().includes(search.toLowerCase()));
39
40    // jevstrudel: the categories are jev/sounds.mjs's, which the MCP reads too
41    if (soundsFilter !== soundFilterType.ALL && soundsFilter !== 'importSounds') {
42      return filtered.filter(([_, { data }]) => inCategory(data, soundsFilter));
43    }
44    //TODO: tidy this up, it does not need to be saved in settings
45    if (soundsFilter === 'importSounds') {
46      return [];
47    }
48    return filtered;
49  }, [sounds, soundsFilter, search]);
50
51  // holds mutable ref to current triggered sound
52  const trigRef = useRef();
53  const numRef = useRef(0);
54
55  // stop current sound on mouseup
56  useEvent('mouseup', () => {
57    const ref = trigRef.current;
58    trigRef.current = undefined;
59    ref?.stop?.(getAudioContext().currentTime + 0.01);
60  });
61  useEvent('keydown', (e) => {
62    if (!isNaN(Number(e.key))) {
63      numRef.current = Number(e.key);
64    }
65  });
66  useEvent('keyup', (e) => {
67    numRef.current = 0;
68  });
69  return (
70    <div id="sounds-tab" className="flex flex-col w-full h-full text-foreground">
71      <Textbox placeholder="Search..." className="border-0" value={search} onChange={(v) => setSearch(v)} />
72
73      <div className="flex shrink-0 flex-wrap border-y border-muted">
74        <ButtonGroup
75          wrap
76          value={soundsFilter}
77          onChange={(value) => settingsMap.setKey('soundsFilter', value)}
78          items={{
79            samples: 'samples',
80            drums: 'drum-machines',
81            synths: 'Synths',
82            wavetables: 'Wavetables',
83            user: 'User',
84            importSounds: 'import-sounds',
85          }}
86        ></ButtonGroup>
87      </div>
88
89      {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && (
90        <ActionButton
91          className="pl-2"
92          label="delete-all"
93          onClick={async () => {
94            try {
95              const confirmed = await confirmDialog('Delete all imported user samples?');
96              if (confirmed) {
97                clearIDB(userSamplesDBConfig.dbName);
98                soundMap.set({});
99                await prebake();
100              }
101            } catch (e) {
102              console.error(e);
103            }
104          }}
105        />
106      )}
107
108      <div className="min-h-0 max-h-full grow overflow-auto break-normal p-2">
109        {soundEntries.map(([name, { data, onTrigger }]) => {
110          return (
111            <span
112              key={name}
113              className="cursor-pointer hover:opacity-50"
114              onMouseDown={async () => {
115                const ctx = getAudioContext();
116                const params = {
117                  note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
118                  s: name,
119                  n: numRef.current,
120                  clip: 1,
121                  release: 0.5,
122                  sustain: 1,
123                  duration: 0.5,
124                };
125                const onended = () => trigRef.current?.node?.disconnect();
126                // Attempt to play the sample and retry every 200ms until 10 attempts have been reached
127                let errMsg;
128                for (let attempt = 0; attempt < 10; attempt++) {
129                  try {
130                    // Pre-load the sample by calling onTrigger with a future time
131                    // This triggers the loading but schedules playback for later
132                    const time = ctx.currentTime + 0.05; // Give 50ms for loading
133                    const ref = await onTrigger(time, params, onended);
134                    trigRef.current = ref;
135                    if (ref?.node) {
136                      connectToDestination(ref.node);
137                      break;
138                    }
139                  } catch (err) {
140                    errMsg = err;
141                  }
142                  if (attempt == 9) {
143                    console.warn('Failed to trigger sound after 10 attempts' + (errMsg ? `: ${errMsg}` : ''));
144                  } else {
145                    await wait(200);
146                  }
147                }
148              }}
149            >
150              {' '}
151              {name}
152              {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
153              {data?.type === 'wavetable' ? `(${getSamples(data.tables)})` : ''}
154              {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
155            </span>
156          );
157        })}
158        {!soundEntries.length && soundsFilter === 'importSounds' ? (
159          <div className="prose dark:prose-invert min-w-full text-sm">
160            <ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
161            <p>
162              To import sounds into strudel, they must be contained{' '}
163              <a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds-folder`} target="_blank">
164                within a folder or subfolder
165              </a>
166              . The best way to do this is to upload a “samples” folder containing subfolders of individual sounds or
167              soundbanks (see diagram below).{' '}
168            </p>
169            <pre className="bg-background" key={'sample-diagram'}>
170              {`└─ samples <-- import this folder
171   ├─ swoop
172   │  ├─ swoopshort.wav
173   │  ├─ swooplong.wav
174   │  └─ swooptight.wav
175   └─ smash
176      ├─ smashhigh.wav
177      ├─ smashlow.wav
178      └─ smashmiddle.wav`}
179            </pre>
180            <p>
181              The name of a subfolder corresponds to the sound name under the “user” tab. Multiple samples within a
182              subfolder are all labelled with the same name, but can be accessed using “.n( )” - remember sounds are
183              zero-indexed and in alphabetical order!
184            </p>
185            <p>
186              For more information, and other ways to use your own sounds in strudel,{' '}
187              <a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds-folder`} target="_blank">
188                check out the docs
189              </a>
190              !
191            </p>
192            <h3>Preview Sounds</h3>
193            <pre className="bg-background" key={'sample-preview'}>
194              n("0 1 2 3 4 5").s("sample-name")
195            </pre>
196            <p>
197              Paste the line above into the main editor to hear the uploaded folder. Remember to use the name of your
198              sample as it appears under the "user" tab.
199            </p>
200          </div>
201        ) : (
202          ''
203        )}
204        {!soundEntries.length && soundsFilter !== 'importSounds'
205          ? search == ''
206            ? 'No sounds loaded'
207            : 'No sounds found'
208          : ''}
209      </div>
210    </div>
211  );
212}