Merge remote-tracking branch 'origin/main' into uzu
This commit is contained in:
commit
2da03ab46f
43 changed files with 1726 additions and 418 deletions
|
|
@ -4,5 +4,9 @@ export default function UserFacingErrorMessage(Props) {
|
|||
if (error == null) {
|
||||
return;
|
||||
}
|
||||
return <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>;
|
||||
return (
|
||||
<div className="text-background px-2 py-1 bg-foreground w-full ml-auto">
|
||||
Error: {error.message || 'Unknown Error :-/'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { getAudioDevices, setAudioDevice } from '../../util.mjs';
|
||||
|
||||
import { SelectInput } from './SelectInput';
|
||||
import { getAudioDevices } from '@strudel/webaudio';
|
||||
|
||||
const initdevices = new Map();
|
||||
|
||||
|
|
@ -21,9 +22,7 @@ export function AudioDeviceSelector({ audioDeviceName, onChange, isDisabled }) {
|
|||
if (!devicesInitialized) {
|
||||
return;
|
||||
}
|
||||
const deviceID = devices.get(deviceName);
|
||||
onChange(deviceName);
|
||||
setAudioDevice(deviceID);
|
||||
};
|
||||
const options = new Map();
|
||||
Array.from(devices.keys()).forEach((deviceName) => {
|
||||
|
|
|
|||
|
|
@ -20,25 +20,42 @@ export default function ImportSoundsButton({ onComplete }) {
|
|||
});
|
||||
|
||||
return (
|
||||
<label
|
||||
style={{ alignItems: 'center' }}
|
||||
className="flex bg-background ml-2 pl-2 pr-2 max-w-[300px] rounded-md hover:opacity-50 whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<input
|
||||
disabled={isUploading}
|
||||
ref={fileUploadRef}
|
||||
id="audio_file"
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
directory=""
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
|
||||
onChange={() => {
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
{isUploading ? 'importing...' : 'import sounds'}
|
||||
</label>
|
||||
<div>
|
||||
<label
|
||||
style={{ alignItems: 'center', borderColor: 'red', border: 1 }}
|
||||
className="flex bg-background p-4 w-fit rounded-xl hover:opacity-50 whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-6 mr-2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v7.5a2.25 2.25 0 0 0 2.25 2.25h7.5a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75m0-3-3-3m0 0-3 3m3-3v11.25m6-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v7.5a2.25 2.25 0 0 1-2.25 2.25h-7.5a2.25 2.25 0 0 1-2.25-2.25v-.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<input
|
||||
disabled={isUploading}
|
||||
ref={fileUploadRef}
|
||||
id="audio_file"
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
directory=""
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
|
||||
onChange={() => {
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
{isUploading ? 'importing...' : 'import sounds folder'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export function SelectInput({ value, options, onChange, onClick, isDisabled }) {
|
|||
<select
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
className="p-2 bg-background rounded-md text-foreground"
|
||||
className="p-2 bg-background rounded-md text-foreground border-foreground"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
|
||||
import { themes } from '@strudel/codemirror';
|
||||
import { Textbox } from '../textbox/Textbox.jsx';
|
||||
import { isUdels } from '../../util.mjs';
|
||||
import { ButtonGroup } from './Forms.jsx';
|
||||
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
|
||||
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
|
||||
import { confirmDialog } from '../../util.mjs';
|
||||
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony } from '@strudel/webaudio';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
|
|
@ -18,7 +20,7 @@ function Checkbox({ label, value, onChange, disabled = false }) {
|
|||
function SelectInput({ value, options, onChange }) {
|
||||
return (
|
||||
<select
|
||||
className="p-2 bg-background rounded-md text-foreground"
|
||||
className="p-2 bg-background rounded-md text-foreground border-foreground"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
|
|
@ -53,7 +55,7 @@ function NumberSlider({ value, onChange, step = 1, ...rest }) {
|
|||
);
|
||||
}
|
||||
|
||||
function FormItem({ label, children }) {
|
||||
function FormItem({ label, children, sublabel }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label>{label}</label>
|
||||
|
|
@ -105,6 +107,7 @@ export function SettingsTab({ started }) {
|
|||
audioDeviceName,
|
||||
audioEngineTarget,
|
||||
togglePanelTrigger,
|
||||
maxPolyphony,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
|
|
@ -139,6 +142,26 @@ export function SettingsTab({ started }) {
|
|||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Maximum Polyphony">
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? DEFAULT_MAX_POLYPHONY : v;
|
||||
setMaxPolyphony(v);
|
||||
settingsMap.setKey('maxPolyphony', v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = Math.max(1, parseInt(v));
|
||||
settingsMap.setKey('maxPolyphony', isNaN(v) ? undefined : v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
value={maxPolyphony ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Theme">
|
||||
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
|
||||
</FormItem>
|
||||
|
|
@ -179,25 +202,7 @@ export function SettingsTab({ started }) {
|
|||
value={togglePanelTrigger}
|
||||
onChange={(value) => settingsMap.setKey('togglePanelTrigger', value)}
|
||||
items={{ click: 'Click', hover: 'Hover' }}
|
||||
></ButtonGroup>
|
||||
{/* <Checkbox
|
||||
label="Click"
|
||||
onChange={(cbEvent) => {
|
||||
if (cbEvent.target.checked) {
|
||||
settingsMap.setKey('togglePanelTrigger', 'click');
|
||||
}
|
||||
}}
|
||||
value={togglePanelTrigger != 'hover'}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Hover"
|
||||
onChange={(cbEvent) => {
|
||||
if (cbEvent.target.checked) {
|
||||
settingsMap.setKey('togglePanelTrigger', 'hover');
|
||||
}
|
||||
}}
|
||||
value={togglePanelTrigger == 'hover'}
|
||||
/> */}
|
||||
</FormItem>
|
||||
<FormItem label="More Settings">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export function SoundsTab() {
|
|||
const sounds = useStore(soundMap);
|
||||
const { soundsFilter } = useSettings();
|
||||
const [search, setSearch] = useState('');
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
|
||||
const soundEntries = useMemo(() => {
|
||||
if (!sounds) {
|
||||
|
|
@ -37,6 +39,9 @@ export function SoundsTab() {
|
|||
if (soundsFilter === 'synths') {
|
||||
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
|
||||
}
|
||||
if (soundsFilter === 'importSounds') {
|
||||
return [];
|
||||
}
|
||||
return filtered;
|
||||
}, [sounds, soundsFilter, search]);
|
||||
|
||||
|
|
@ -51,7 +56,6 @@ export function SoundsTab() {
|
|||
ref?.stop(getAudioContext().currentTime + 0.01);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full text-foreground">
|
||||
<Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
|
||||
|
|
@ -65,9 +69,9 @@ export function SoundsTab() {
|
|||
drums: 'drum-machines',
|
||||
synths: 'Synths',
|
||||
user: 'User',
|
||||
importSounds: 'import-sounds',
|
||||
}}
|
||||
></ButtonGroup>
|
||||
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
|
||||
|
|
@ -101,7 +105,55 @@ export function SoundsTab() {
|
|||
</span>
|
||||
);
|
||||
})}
|
||||
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
|
||||
{!soundEntries.length && soundsFilter === 'importSounds' ? (
|
||||
<div className="prose dark:prose-invert min-w-full pt-2 pb-8 px-4">
|
||||
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
|
||||
<p>
|
||||
To import sounds into strudel, they must be contained{' '}
|
||||
<a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds-folder`} target="_blank">
|
||||
within a folder or subfolder
|
||||
</a>
|
||||
. The best way to do this is to upload a “samples” folder containing subfolders of individual sounds or
|
||||
soundbanks (see diagram below).{' '}
|
||||
</p>
|
||||
<pre className="bg-background" key={'sample-diagram'}>
|
||||
{`└─ samples <-- import this folder
|
||||
├─ swoop
|
||||
│ ├─ swoopshort.wav
|
||||
│ ├─ swooplong.wav
|
||||
│ └─ swooptight.wav
|
||||
└─ smash
|
||||
├─ smashhigh.wav
|
||||
├─ smashlow.wav
|
||||
└─ smashmiddle.wav`}
|
||||
</pre>
|
||||
<p>
|
||||
The name of a subfolder corresponds to the sound name under the “user” tab. Multiple samples within a
|
||||
subfolder are all labelled with the same name, but can be accessed using “.n( )” - remember sounds are
|
||||
zero-indexed and in alphabetical order!
|
||||
</p>
|
||||
<p>
|
||||
For more information, and other ways to use your own sounds in strudel,{' '}
|
||||
<a href={`${baseNoTrailing}/learn/samples/#from-disk-via-import-sounds-folder`} target="_blank">
|
||||
check out the docs
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
<h3>Preview Sounds</h3>
|
||||
<pre className="bg-background" key={'sample-preview'}>
|
||||
n("0 1 2 3 4 5").s("sample-name")
|
||||
</pre>
|
||||
<p>
|
||||
Paste the line above into the main editor to hear the uploaded folder. Remember to use the name of your
|
||||
sample as it appears under the "user" tab.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{!soundEntries.length && soundsFilter !== 'importSounds'
|
||||
? 'No custom sounds loaded in this pattern (yet).'
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import cx from '@src/cx.mjs';
|
|||
export function Textbox({ onChange, className, ...inputProps }) {
|
||||
return (
|
||||
<input
|
||||
className={cx('p-1 bg-background rounded-md my-2 border-foreground', className)}
|
||||
className={cx(
|
||||
'p-2 bg-background rounded-md border-foreground text-foreground placeholder-foreground',
|
||||
className,
|
||||
)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -54,25 +54,23 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
|||
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
|
||||
const blob = soundFile.blob;
|
||||
|
||||
// Files used to be uploaded as base64 strings, After Jan 1 2025 this check can be safely deleted
|
||||
if (typeof blob === 'string') {
|
||||
const soundPaths = sounds.get(parentDirectory) ?? new Set();
|
||||
soundPaths.add(blob);
|
||||
sounds.set(parentDirectory, soundPaths);
|
||||
return;
|
||||
}
|
||||
|
||||
return blobToDataUrl(blob).then((soundPath) => {
|
||||
const soundPaths = sounds.get(parentDirectory) ?? new Set();
|
||||
soundPaths.add(soundPath);
|
||||
sounds.set(parentDirectory, soundPaths);
|
||||
const titlePathMap = sounds.get(parentDirectory) ?? new Map();
|
||||
|
||||
titlePathMap.set(title, soundPath);
|
||||
|
||||
sounds.set(parentDirectory, titlePathMap);
|
||||
return;
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
sounds.forEach((soundPaths, key) => {
|
||||
const value = Array.from(soundPaths);
|
||||
sounds.forEach((titlePathMap, key) => {
|
||||
const value = Array.from(titlePathMap.keys())
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => titlePathMap.get(title));
|
||||
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
|
||||
type: 'sample',
|
||||
|
|
|
|||
|
|
@ -115,6 +115,29 @@ export async function prebake() {
|
|||
'numbers/7.wav',
|
||||
'numbers/8.wav',
|
||||
],
|
||||
num: [
|
||||
'num/00.wav',
|
||||
'num/01.wav',
|
||||
'num/02.wav',
|
||||
'num/03.wav',
|
||||
'num/04.wav',
|
||||
'num/05.wav',
|
||||
'num/06.wav',
|
||||
'num/07.wav',
|
||||
'num/08.wav',
|
||||
'num/09.wav',
|
||||
'num/10.wav',
|
||||
'num/11.wav',
|
||||
'num/12.wav',
|
||||
'num/13.wav',
|
||||
'num/14.wav',
|
||||
'num/15.wav',
|
||||
'num/16.wav',
|
||||
'num/17.wav',
|
||||
'num/18.wav',
|
||||
'num/19.wav',
|
||||
'num/20.wav',
|
||||
],
|
||||
},
|
||||
'github:tidalcycles/dirt-samples',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
} from '@strudel/webaudio';
|
||||
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
|
||||
import { setVersionDefaultsFrom } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
import { clearHydra } from '@strudel/hydra';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
setViewingPatternData,
|
||||
} from '../user_pattern_utils.mjs';
|
||||
import { superdirtOutput } from '@strudel/osc/superdirtoutput';
|
||||
import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { audioEngineTargets } from '../settings.mjs';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { prebake } from './prebake.mjs';
|
||||
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
|
||||
|
|
@ -36,11 +36,11 @@ import './Repl.css';
|
|||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
|
||||
const { latestCode } = settingsMap.get();
|
||||
const { latestCode, maxPolyphony, audioDeviceName } = settingsMap.get();
|
||||
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
audioReady = initAudioOnFirstClick();
|
||||
audioReady = initAudioOnFirstClick({ maxPolyphony, audioDeviceName });
|
||||
modulesLoading = loadModules();
|
||||
presets = prebake();
|
||||
drawContext = getDrawContext();
|
||||
|
|
@ -159,20 +159,6 @@ export function useReplContext() {
|
|||
editorRef.current?.updateSettings(editorSettings);
|
||||
}, [_settings]);
|
||||
|
||||
// on first load, set stored audio device if possible
|
||||
useEffect(() => {
|
||||
const { audioDeviceName } = _settings;
|
||||
if (audioDeviceName !== defaultAudioDeviceName) {
|
||||
getAudioDevices().then((devices) => {
|
||||
const deviceID = devices.get(audioDeviceName);
|
||||
if (deviceID == null) {
|
||||
return;
|
||||
}
|
||||
setAudioDevice(deviceID);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
//
|
||||
// UI Actions
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, setVersionDefaults } from '@strudel/webaudio';
|
||||
import { settingPatterns } from '../settings.mjs';
|
||||
import { setVersionDefaults } from '@strudel/webaudio';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { isTauri } from '../tauri.mjs';
|
||||
import './Repl.css';
|
||||
|
|
@ -160,38 +160,6 @@ export const isUdels = () => {
|
|||
return window.top?.location?.pathname.includes('udels');
|
||||
};
|
||||
|
||||
export const getAudioDevices = async () => {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
let mediaDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default');
|
||||
const devicesMap = new Map();
|
||||
devicesMap.set(defaultAudioDeviceName, '');
|
||||
mediaDevices.forEach((device) => {
|
||||
devicesMap.set(device.label, device.deviceId);
|
||||
});
|
||||
return devicesMap;
|
||||
};
|
||||
|
||||
export const setAudioDevice = async (id) => {
|
||||
let audioCtx = getAudioContext();
|
||||
if (audioCtx.sinkId === id) {
|
||||
return;
|
||||
}
|
||||
await audioCtx.suspend();
|
||||
await audioCtx.close();
|
||||
audioCtx = setDefaultAudioContext();
|
||||
await audioCtx.resume();
|
||||
const isValidID = (id ?? '').length > 0;
|
||||
if (isValidID) {
|
||||
try {
|
||||
await audioCtx.setSinkId(id);
|
||||
} catch {
|
||||
logger('failed to set audio interface', 'warning');
|
||||
}
|
||||
}
|
||||
initializeAudioOutput();
|
||||
};
|
||||
|
||||
export function setVersionDefaultsFrom(code) {
|
||||
try {
|
||||
const metadata = getMetadata(code);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue