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}