Merge branch 'main' into sampler-refactoring
This commit is contained in:
commit
b188fc6b6d
151 changed files with 6183 additions and 8589 deletions
|
|
@ -4,264 +4,15 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
|
|||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { code2hash, logger, silence } from '@strudel/core';
|
||||
import { getDrawContext } from '@strudel/draw';
|
||||
import cx from '@src/cx.mjs';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContext,
|
||||
initAudioOnFirstClick,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
} from '@strudel/webaudio';
|
||||
import { defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { getAudioDevices, setAudioDevice } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
import { clearHydra } from '@strudel/hydra';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { settingsMap, useSettings } from '../settings.mjs';
|
||||
import {
|
||||
setActivePattern,
|
||||
setLatestCode,
|
||||
createPatternID,
|
||||
userPattern,
|
||||
getViewingPatternData,
|
||||
setViewingPatternData,
|
||||
} from '../user_pattern_utils.mjs';
|
||||
import { Header } from './Header';
|
||||
import Loader from './Loader';
|
||||
import { Panel } from './panel/Panel';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { prebake } from './prebake.mjs';
|
||||
import { getRandomTune, initCode, loadModules, shareCode, ReplContext } from './util.mjs';
|
||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
import './Repl.css';
|
||||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
|
||||
const { latestCode } = settingsMap.get();
|
||||
|
||||
let modulesLoading, presets, drawContext, clearCanvas, isIframe;
|
||||
if (typeof window !== 'undefined') {
|
||||
initAudioOnFirstClick();
|
||||
modulesLoading = loadModules();
|
||||
presets = prebake();
|
||||
drawContext = getDrawContext();
|
||||
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
|
||||
isIframe = window.location !== window.parent.location;
|
||||
}
|
||||
|
||||
async function getModule(name) {
|
||||
if (!modulesLoading) {
|
||||
return;
|
||||
}
|
||||
const modules = await modulesLoading;
|
||||
return modules.find((m) => m.packageName === name);
|
||||
}
|
||||
import { isIframe, isUdels } from './util.mjs';
|
||||
import UdelsEditor from '@components/Udels/UdelsEditor';
|
||||
import ReplEditor from './components/ReplEditor';
|
||||
import EmbeddedReplEditor from './components/EmbeddedReplEditor';
|
||||
import { useReplContext } from './useReplContext';
|
||||
|
||||
export function Repl({ embedded = false }) {
|
||||
const isEmbedded = embedded || isIframe;
|
||||
const { panelPosition, isZen, isSyncEnabled } = useSettings();
|
||||
const init = useCallback(() => {
|
||||
const drawTime = [-2, 2];
|
||||
const drawContext = getDrawContext();
|
||||
const editor = new StrudelMirror({
|
||||
sync: isSyncEnabled,
|
||||
defaultOutput: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
transpiler,
|
||||
autodraw: false,
|
||||
root: containerRef.current,
|
||||
initialCode: '// LOADING',
|
||||
pattern: silence,
|
||||
drawTime,
|
||||
drawContext,
|
||||
prebake: async () => Promise.all([modulesLoading, presets]),
|
||||
onUpdateState: (state) => {
|
||||
setReplState({ ...state });
|
||||
},
|
||||
onToggle: (playing) => {
|
||||
if (!playing) {
|
||||
clearHydra();
|
||||
}
|
||||
},
|
||||
afterEval: (all) => {
|
||||
const { code } = all;
|
||||
setLatestCode(code);
|
||||
window.location.hash = '#' + code2hash(code);
|
||||
setDocumentTitle(code);
|
||||
const viewingPatternData = getViewingPatternData();
|
||||
const data = { ...viewingPatternData, code };
|
||||
let id = data.id;
|
||||
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
||||
|
||||
if (isExamplePattern) {
|
||||
const codeHasChanged = code !== viewingPatternData.code;
|
||||
if (codeHasChanged) {
|
||||
// fork example
|
||||
const newPattern = userPattern.duplicate(data);
|
||||
id = newPattern.id;
|
||||
setViewingPatternData(newPattern.data);
|
||||
}
|
||||
} else {
|
||||
id = userPattern.isValidID(id) ? id : createPatternID();
|
||||
setViewingPatternData(userPattern.update(id, data).data);
|
||||
}
|
||||
setActivePattern(id);
|
||||
},
|
||||
bgFill: false,
|
||||
});
|
||||
|
||||
// init settings
|
||||
|
||||
initCode().then(async (decoded) => {
|
||||
let code, msg;
|
||||
if (decoded) {
|
||||
code = decoded;
|
||||
msg = `I have loaded the code from the URL.`;
|
||||
} else if (latestCode) {
|
||||
code = latestCode;
|
||||
msg = `Your last session has been loaded!`;
|
||||
} else {
|
||||
const { code: randomTune, name } = await getRandomTune();
|
||||
code = randomTune;
|
||||
msg = `A random code snippet named "${name}" has been loaded!`;
|
||||
}
|
||||
editor.setCode(code);
|
||||
setDocumentTitle(code);
|
||||
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
const [replState, setReplState] = useState({});
|
||||
const { started, isDirty, error, activeCode, pending } = replState;
|
||||
const editorRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
||||
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
|
||||
// this will be the case when the main repl is being replaced
|
||||
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
|
||||
useEffect(() => {
|
||||
let editorSettings = {};
|
||||
Object.keys(defaultSettings).forEach((key) => {
|
||||
if (_settings.hasOwnProperty(key)) {
|
||||
editorSettings[key] = _settings[key];
|
||||
}
|
||||
});
|
||||
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
|
||||
//
|
||||
|
||||
const setDocumentTitle = (code) => {
|
||||
const meta = getMetadata(code);
|
||||
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
|
||||
};
|
||||
|
||||
const handleTogglePlay = async () => {
|
||||
editorRef.current?.toggle();
|
||||
};
|
||||
|
||||
const resetEditor = async () => {
|
||||
(await getModule('@strudel/tonal'))?.resetVoicings();
|
||||
resetGlobalEffects();
|
||||
clearCanvas();
|
||||
clearHydra();
|
||||
resetLoadedSounds();
|
||||
editorRef.current.repl.setCps(0.5);
|
||||
await prebake(); // declare default samples
|
||||
};
|
||||
|
||||
const handleUpdate = async (patternData, reset = false) => {
|
||||
setViewingPatternData(patternData);
|
||||
editorRef.current.setCode(patternData.code);
|
||||
if (reset) {
|
||||
await resetEditor();
|
||||
handleEvaluate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
const code = patternData.code;
|
||||
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
|
||||
setActivePattern(patternData.id);
|
||||
setViewingPatternData(patternData);
|
||||
await resetEditor();
|
||||
editorRef.current.setCode(code);
|
||||
editorRef.current.repl.evaluate(code);
|
||||
};
|
||||
|
||||
const handleShare = async () => shareCode(replState.code);
|
||||
const context = {
|
||||
embedded,
|
||||
started,
|
||||
pending,
|
||||
isDirty,
|
||||
activeCode,
|
||||
handleTogglePlay,
|
||||
handleUpdate,
|
||||
handleShuffle,
|
||||
handleShare,
|
||||
handleEvaluate,
|
||||
};
|
||||
|
||||
return (
|
||||
<ReplContext.Provider value={context}>
|
||||
<div className={cx('h-full flex flex-col relative')}>
|
||||
<Loader active={pending} />
|
||||
<Header context={context} />
|
||||
{isEmbedded && !started && (
|
||||
<button
|
||||
onClick={() => handleTogglePlay()}
|
||||
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
|
||||
>
|
||||
<PlayCircleIcon className="w-6 h-6" />
|
||||
<span>play</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="grow flex relative overflow-hidden">
|
||||
<section
|
||||
className={'text-gray-100 cursor-text pb-0 overflow-auto grow' + (isZen ? ' px-10' : '')}
|
||||
id="code"
|
||||
ref={(el) => {
|
||||
containerRef.current = el;
|
||||
if (!editorRef.current) {
|
||||
init();
|
||||
}
|
||||
}}
|
||||
></section>
|
||||
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
|
||||
)}
|
||||
{panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
|
||||
</div>
|
||||
</ReplContext.Provider>
|
||||
);
|
||||
const isEmbedded = embedded || isIframe();
|
||||
const Editor = isUdels() ? UdelsEditor : isEmbedded ? EmbeddedReplEditor : ReplEditor;
|
||||
const context = useReplContext();
|
||||
return <Editor context={context} />;
|
||||
}
|
||||
|
|
|
|||
22
website/src/repl/components/BigPlayButton.jsx
Normal file
22
website/src/repl/components/BigPlayButton.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
|
||||
// type Props = {
|
||||
// started: boolean;
|
||||
// handleTogglePlay: () => void;
|
||||
// };
|
||||
export default function BigPlayButton(Props) {
|
||||
const { started, handleTogglePlay } = Props;
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleTogglePlay()}
|
||||
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
|
||||
>
|
||||
<PlayCircleIcon className="w-6 h-6" />
|
||||
<span>play</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
website/src/repl/components/Code.jsx
Normal file
21
website/src/repl/components/Code.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// type Props = {
|
||||
// containerRef: React.MutableRefObject<HTMLElement | null>,
|
||||
// editorRef: React.MutableRefObject<HTMLElement | null>,
|
||||
// init: () => void
|
||||
// }
|
||||
export function Code(Props) {
|
||||
const { editorRef, containerRef, init } = Props;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={'text-gray-100 cursor-text pb-0 overflow-auto grow'}
|
||||
id="code"
|
||||
ref={(el) => {
|
||||
containerRef.current = el;
|
||||
if (!editorRef.current) {
|
||||
init();
|
||||
}
|
||||
}}
|
||||
></section>
|
||||
);
|
||||
}
|
||||
25
website/src/repl/components/EmbeddedReplEditor.jsx
Normal file
25
website/src/repl/components/EmbeddedReplEditor.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Loader from '@src/repl/components/Loader';
|
||||
import { Code } from '@src/repl/components/Code';
|
||||
import BigPlayButton from '@src/repl/components/BigPlayButton';
|
||||
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
|
||||
import { Header } from './Header';
|
||||
|
||||
// type Props = {
|
||||
// context: replcontext,
|
||||
// }
|
||||
|
||||
export default function EmbeddedReplEditor(Props) {
|
||||
const { context } = Props;
|
||||
const { pending, started, handleTogglePlay, containerRef, editorRef, error, init } = context;
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
<Loader active={pending} />
|
||||
<Header context={context} embedded={true} />
|
||||
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
|
||||
<div className="grow flex relative overflow-hidden">
|
||||
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
|
||||
</div>
|
||||
<UserFacingErrorMessage error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,24 +5,15 @@ import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
|||
import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon';
|
||||
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
|
||||
import cx from '@src/cx.mjs';
|
||||
import { useSettings, setIsZen } from '../settings.mjs';
|
||||
// import { ReplContext } from './Repl';
|
||||
import './Repl.css';
|
||||
import { useSettings, setIsZen } from '../../settings.mjs';
|
||||
import '../Repl.css';
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
|
||||
export function Header({ context }) {
|
||||
const {
|
||||
embedded,
|
||||
started,
|
||||
pending,
|
||||
isDirty,
|
||||
activeCode,
|
||||
handleTogglePlay,
|
||||
handleEvaluate,
|
||||
handleShuffle,
|
||||
handleShare,
|
||||
} = context;
|
||||
export function Header({ context, embedded = false }) {
|
||||
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare } =
|
||||
context;
|
||||
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
|
||||
const { isZen } = useSettings();
|
||||
|
||||
54
website/src/repl/components/NumberInput.jsx
Normal file
54
website/src/repl/components/NumberInput.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
function Button(Props) {
|
||||
const { children, onClick } = Props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
data-input-counter-increment="counter-input"
|
||||
className="flex-shrink-0 bg-gray-700 hover:bg-gray-600 border-gray-600 inline-flex items-center justify-center border rounded-md h-5 w-5 focus:ring-gray-700 focus:ring-2 focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export default function NumberInput(Props) {
|
||||
const { value = 0, setValue, max, min } = Props;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<Button onClick={() => setValue(value - 1)}>
|
||||
<svg
|
||||
className="w-2.5 h-2.5 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 18 2"
|
||||
>
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 1h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
<input
|
||||
min={min}
|
||||
max={max}
|
||||
type="text"
|
||||
data-input-counter
|
||||
className="flex-shrink-0 text-white border-0 bg-transparent text-sm font-normal focus:outline-none focus:ring-0 max-w-[2.5rem] text-center"
|
||||
placeholder=""
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<Button onClick={() => setValue(value + 1)}>
|
||||
<svg
|
||||
className="w-2.5 h-2.5 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 18 18"
|
||||
>
|
||||
<path strokeLinecap="round" stroke="currentColor" strokeLinejoin="round" strokeWidth="2" d="M9 1v16M1 9h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
website/src/repl/components/ReplEditor.jsx
Normal file
30
website/src/repl/components/ReplEditor.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import Loader from '@src/repl/components/Loader';
|
||||
import { Panel } from '@src/repl/components/panel/Panel';
|
||||
import { Code } from '@src/repl/components/Code';
|
||||
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
|
||||
import { Header } from './Header';
|
||||
import { useSettings } from '@src/settings.mjs';
|
||||
|
||||
// type Props = {
|
||||
// context: replcontext,
|
||||
// }
|
||||
|
||||
export default function ReplEditor(Props) {
|
||||
const { context } = Props;
|
||||
const { containerRef, editorRef, error, init, pending } = context;
|
||||
const settings = useSettings();
|
||||
const { panelPosition } = settings;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
<Loader active={pending} />
|
||||
<Header context={context} />
|
||||
<div className="grow flex relative overflow-hidden">
|
||||
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
|
||||
{panelPosition === 'right' && <Panel context={context} />}
|
||||
</div>
|
||||
<UserFacingErrorMessage error={error} />
|
||||
{panelPosition === 'bottom' && <Panel context={context} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
website/src/repl/components/UserFacingErrorMessage.jsx
Normal file
8
website/src/repl/components/UserFacingErrorMessage.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// type Props = { error: Error | null };
|
||||
export default function UserFacingErrorMessage(Props) {
|
||||
const { error } = Props;
|
||||
if (error == null) {
|
||||
return;
|
||||
}
|
||||
return <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { getAudioDevices, setAudioDevice } from '../util.mjs';
|
||||
import { getAudioDevices, setAudioDevice } from '../../util.mjs';
|
||||
import { SelectInput } from './SelectInput';
|
||||
|
||||
const initdevices = new Map();
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { audioEngineTargets } from '../../../settings.mjs';
|
||||
import { SelectInput } from './SelectInput';
|
||||
|
||||
// Allows the user to select an audio interface for Strudel to play through
|
||||
export function AudioEngineTargetSelector({ target, onChange, isDisabled }) {
|
||||
const onTargetChange = (target) => {
|
||||
onChange(target);
|
||||
};
|
||||
const options = new Map([
|
||||
[audioEngineTargets.webaudio, audioEngineTargets.webaudio],
|
||||
[audioEngineTargets.osc, audioEngineTargets.osc],
|
||||
]);
|
||||
return (
|
||||
<div className=" flex flex-col gap-1">
|
||||
<SelectInput isDisabled={isDisabled} options={options} value={target} onChange={onTargetChange} />
|
||||
{target === audioEngineTargets.osc && (
|
||||
<div>
|
||||
<p className="text-sm italic">
|
||||
⚠ All events routed to OSC, audio is silenced! See{' '}
|
||||
<a className="text-blue-500" href="https://strudel.cc/learn/input-output/">
|
||||
Docs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Fragment, useEffect } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { isAudioFile, readDir, dir, playFile } from '../files.mjs';
|
||||
import { isAudioFile, readDir, dir, playFile } from '../../files.mjs';
|
||||
|
||||
export function FilesTab() {
|
||||
const [path, setPath] = useState([]);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../idbutils.mjs';
|
||||
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../../idbutils.mjs';
|
||||
|
||||
//choose a directory to locally import samples
|
||||
export default function ImportSoundsButton({ onComplete }) {
|
||||
|
|
@ -10,6 +10,7 @@ export default function ImportSoundsButton({ onComplete }) {
|
|||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
|
||||
await uploadSamplesToDB(userSamplesDBConfig, fileUploadRef.current.files).then(() => {
|
||||
registerSamplesFromDB(userSamplesDBConfig, () => {
|
||||
onComplete();
|
||||
|
|
@ -32,7 +33,7 @@ export default function ImportSoundsButton({ onComplete }) {
|
|||
directory=""
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
accept="audio/*"
|
||||
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
|
||||
onChange={() => {
|
||||
onChange();
|
||||
}}
|
||||
|
|
@ -4,7 +4,7 @@ import useEvent from '@src/useEvent.mjs';
|
|||
import cx from '@src/cx.mjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useLayoutEffect, useEffect, useRef, useState } from 'react';
|
||||
import { setActiveFooter, useSettings } from '../../settings.mjs';
|
||||
import { setActiveFooter, useSettings } from '../../../settings.mjs';
|
||||
import { ConsoleTab } from './ConsoleTab';
|
||||
import { FilesTab } from './FilesTab';
|
||||
import { Reference } from './Reference';
|
||||
|
|
@ -5,13 +5,13 @@ import {
|
|||
useActivePattern,
|
||||
useViewingPatternData,
|
||||
userPattern,
|
||||
} from '../../user_pattern_utils.mjs';
|
||||
} from '../../../user_pattern_utils.mjs';
|
||||
import { useMemo } from 'react';
|
||||
import { getMetadata } from '../../metadata_parser';
|
||||
import { useExamplePatterns } from '../useExamplePatterns';
|
||||
import { parseJSON } from '../util.mjs';
|
||||
import { getMetadata } from '../../../metadata_parser.js';
|
||||
import { useExamplePatterns } from '../../useExamplePatterns.jsx';
|
||||
import { parseJSON, isUdels } from '../../util.mjs';
|
||||
import { ButtonGroup } from './Forms.jsx';
|
||||
import { settingsMap, useSettings } from '../../settings.mjs';
|
||||
import { settingsMap, useSettings } from '../../../settings.mjs';
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
|
|
@ -99,7 +99,7 @@ export function PatternsTab({ context }) {
|
|||
};
|
||||
const viewingPatternID = viewingPatternData?.id;
|
||||
|
||||
const autoResetPatternOnChange = !window.parent?.location.pathname.includes('oodles');
|
||||
const autoResetPatternOnChange = !isUdels();
|
||||
|
||||
return (
|
||||
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 pb-4 flex flex-col overflow-hidden max-h-full">
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import jsdocJson from '../../../../doc.json';
|
||||
const visibleFunctions = jsdocJson.docs
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import jsdocJson from '../../../../../doc.json';
|
||||
const availableFunctions = jsdocJson.docs
|
||||
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
|
||||
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
|
||||
|
||||
|
|
@ -10,9 +12,29 @@ const getInnerText = (html) => {
|
|||
};
|
||||
|
||||
export function Reference() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const visibleFunctions = useMemo(() => {
|
||||
return availableFunctions.filter((entry) => {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return entry.name.includes(search) || (entry.synonyms?.some((s) => s.includes(search)) ?? false);
|
||||
});
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full pt-2 text-foreground overflow-hidden">
|
||||
<div className="w-42 flex-none h-full overflow-y-auto overflow-x-hidden pr-4">
|
||||
<div class="w-full ml-2 mb-2 top-0 sticky">
|
||||
<input
|
||||
className="w-full p-1 bg-background rounded-md"
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onInput={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{visibleFunctions.map((entry, i) => (
|
||||
<a
|
||||
key={i}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
|
||||
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
|
||||
import { themes } from '@strudel/codemirror';
|
||||
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';
|
||||
|
||||
function Checkbox({ label, value, onChange }) {
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
<label>
|
||||
<input type="checkbox" checked={value} onChange={onChange} />
|
||||
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
|
||||
{' ' + label}
|
||||
</label>
|
||||
);
|
||||
|
|
@ -77,6 +80,8 @@ const fontFamilyOptions = {
|
|||
galactico: 'galactico',
|
||||
};
|
||||
|
||||
const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
|
||||
|
||||
export function SettingsTab({ started }) {
|
||||
const {
|
||||
theme,
|
||||
|
|
@ -95,19 +100,41 @@ export function SettingsTab({ started }) {
|
|||
fontFamily,
|
||||
panelPosition,
|
||||
audioDeviceName,
|
||||
audioEngineTarget,
|
||||
} = useSettings();
|
||||
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
return (
|
||||
<div className="text-foreground p-4 space-y-4">
|
||||
{AudioContext.prototype.setSinkId != null && (
|
||||
{canChangeAudioDevice && (
|
||||
<FormItem label="Audio Output Device">
|
||||
<AudioDeviceSelector
|
||||
isDisabled={started}
|
||||
audioDeviceName={audioDeviceName}
|
||||
onChange={(audioDeviceName) => settingsMap.setKey('audioDeviceName', audioDeviceName)}
|
||||
onChange={(audioDeviceName) => {
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
settingsMap.setKey('audioDeviceName', audioDeviceName);
|
||||
return window.location.reload();
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormItem label="Audio Engine Target">
|
||||
<AudioEngineTargetSelector
|
||||
target={audioEngineTarget}
|
||||
onChange={(target) => {
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
settingsMap.setKey('audioEngineTarget', target);
|
||||
return window.location.reload();
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Theme">
|
||||
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
|
||||
</FormItem>
|
||||
|
|
@ -192,11 +219,15 @@ export function SettingsTab({ started }) {
|
|||
<Checkbox
|
||||
label="Sync across Browser Tabs / Windows"
|
||||
onChange={(cbEvent) => {
|
||||
if (confirm('Changing this setting requires the window to reload itself. OK?')) {
|
||||
settingsMap.setKey('isSyncEnabled', cbEvent.target.checked);
|
||||
window.location.reload();
|
||||
}
|
||||
const newVal = cbEvent.target.checked;
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r) {
|
||||
settingsMap.setKey('isSyncEnabled', newVal);
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={shouldAlwaysSync}
|
||||
value={isSyncEnabled}
|
||||
/>
|
||||
</FormItem>
|
||||
|
|
@ -205,9 +236,11 @@ export function SettingsTab({ started }) {
|
|||
<button
|
||||
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
|
||||
onClick={() => {
|
||||
if (confirm('Sure?')) {
|
||||
settingsMap.set(defaultSettings);
|
||||
}
|
||||
confirmDialog('Sure?').then((r) => {
|
||||
if (r) {
|
||||
settingsMap.set(defaultSettings);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
restore default settings
|
||||
|
|
@ -2,7 +2,7 @@ import useEvent from '@src/useEvent.mjs';
|
|||
import { useStore } from '@nanostores/react';
|
||||
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { settingsMap, useSettings } from '../../settings.mjs';
|
||||
import { settingsMap, useSettings } from '../../../settings.mjs';
|
||||
import { ButtonGroup } from './Forms.jsx';
|
||||
import ImportSoundsButton from './ImportSoundsButton.jsx';
|
||||
|
||||
|
|
@ -13,7 +13,9 @@ export function SoundsTab() {
|
|||
const sounds = useStore(soundMap);
|
||||
const { soundsFilter } = useSettings();
|
||||
const soundEntries = useMemo(() => {
|
||||
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
|
||||
let filtered = Object.entries(sounds)
|
||||
.filter(([key]) => !key.startsWith('_'))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
if (!sounds) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -43,7 +45,7 @@ export function SoundsTab() {
|
|||
});
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
|
||||
<div className="pb-2 flex shrink-0 overflow-auto">
|
||||
<div className="pb-2 flex shrink-0 flex-wrap">
|
||||
<ButtonGroup
|
||||
value={soundsFilter}
|
||||
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
|
||||
|
|
@ -75,7 +75,8 @@ export const walkFileTree = (node, fn) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
|
||||
export const isAudioFile = (filename) =>
|
||||
['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac'].includes(filename.split('.').slice(-1)[0]);
|
||||
|
||||
function uint8ArrayToDataURL(uint8Array) {
|
||||
const blob = new Blob([uint8Array], { type: 'audio/*' });
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const userSamplesDBConfig = {
|
|||
};
|
||||
|
||||
// deletes all of the databases, useful for debugging
|
||||
const clearIDB = () => {
|
||||
function clearIDB() {
|
||||
window.indexedDB
|
||||
.databases()
|
||||
.then((r) => {
|
||||
|
|
@ -21,52 +21,81 @@ const clearIDB = () => {
|
|||
.then(() => {
|
||||
alert('All data cleared.');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// queries the DB, and registers the sounds so they can be played
|
||||
export const registerSamplesFromDB = (config = userSamplesDBConfig, onComplete = () => {}) => {
|
||||
export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete = () => {}) {
|
||||
openDB(config, (objectStore) => {
|
||||
let query = objectStore.getAll();
|
||||
const query = objectStore.getAll();
|
||||
query.onerror = (e) => {
|
||||
logger('User Samples failed to load ', 'error');
|
||||
onComplete();
|
||||
console.error(e?.target?.error);
|
||||
};
|
||||
|
||||
query.onsuccess = (event) => {
|
||||
const soundFiles = event.target.result;
|
||||
if (!soundFiles?.length) {
|
||||
return;
|
||||
}
|
||||
const sounds = new Map();
|
||||
[...soundFiles]
|
||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.forEach((soundFile) => {
|
||||
const title = soundFile.title;
|
||||
if (!isAudioFile(title)) {
|
||||
return;
|
||||
}
|
||||
const splitRelativePath = soundFile.id?.split('/');
|
||||
const parentDirectory = splitRelativePath[splitRelativePath.length - 2];
|
||||
const soundPath = soundFile.blob;
|
||||
const soundPaths = sounds.get(parentDirectory) ?? new Set();
|
||||
soundPaths.add(soundPath);
|
||||
sounds.set(parentDirectory, soundPaths);
|
||||
});
|
||||
|
||||
sounds.forEach((soundPaths, key) => {
|
||||
const bank = Array.from(soundPaths);
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
|
||||
type: 'sample',
|
||||
samples: bank,
|
||||
baseUrl: undefined,
|
||||
prebake: false,
|
||||
tag: undefined,
|
||||
Promise.all(
|
||||
[...soundFiles]
|
||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.map((soundFile, i) => {
|
||||
const title = soundFile.title;
|
||||
if (!isAudioFile(title)) {
|
||||
return;
|
||||
}
|
||||
const splitRelativePath = soundFile.id.split('/');
|
||||
let parentDirectory =
|
||||
//fallback to file name before period and seperator if no parent directory
|
||||
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);
|
||||
return;
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
sounds.forEach((soundPaths, key) => {
|
||||
const value = Array.from(soundPaths);
|
||||
|
||||
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
|
||||
type: 'sample',
|
||||
samples: value,
|
||||
baseUrl: undefined,
|
||||
prebake: false,
|
||||
tag: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
logger('imported sounds registered!', 'success');
|
||||
onComplete();
|
||||
})
|
||||
.catch((error) => {
|
||||
logger('Something went wrong while registering saved samples from the index db', 'error');
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
logger('imported sounds registered!', 'success');
|
||||
onComplete();
|
||||
};
|
||||
});
|
||||
};
|
||||
// creates a blob from a buffer that can be read
|
||||
async function bufferToDataUrl(buf) {
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
return new Promise((resolve) => {
|
||||
var blob = new Blob([buf], { type: 'application/octet-binary' });
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
resolve(event.target.result);
|
||||
|
|
@ -74,8 +103,9 @@ async function bufferToDataUrl(buf) {
|
|||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
//open db and initialize it if necessary
|
||||
const openDB = (config, onOpened) => {
|
||||
function openDB(config, onOpened) {
|
||||
const { dbName, version, table, columns } = config;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
|
|
@ -100,52 +130,63 @@ const openDB = (config, onOpened) => {
|
|||
|
||||
dbOpen.onsuccess = () => {
|
||||
const db = dbOpen.result;
|
||||
|
||||
const // lock store for writing
|
||||
writeTransaction = db.transaction([table], 'readwrite'),
|
||||
// get object store
|
||||
objectStore = writeTransaction.objectStore(table);
|
||||
// lock store for writing
|
||||
const writeTransaction = db.transaction([table], 'readwrite');
|
||||
// get object store
|
||||
const objectStore = writeTransaction.objectStore(table);
|
||||
onOpened(objectStore, db);
|
||||
};
|
||||
};
|
||||
return dbOpen;
|
||||
}
|
||||
|
||||
const processFilesForIDB = async (files) => {
|
||||
return await Promise.all(
|
||||
async function processFilesForIDB(files) {
|
||||
return Promise.all(
|
||||
Array.from(files)
|
||||
.map(async (s) => {
|
||||
.map((s) => {
|
||||
const title = s.name;
|
||||
|
||||
if (!isAudioFile(title)) {
|
||||
return;
|
||||
}
|
||||
//create obscured url to file system that can be fetched
|
||||
const sUrl = URL.createObjectURL(s);
|
||||
//fetch the sound and turn it into a buffer array
|
||||
const buf = await fetch(sUrl).then((res) => res.arrayBuffer());
|
||||
//create a url blob containing all of the buffer data
|
||||
const base64 = await bufferToDataUrl(buf);
|
||||
return {
|
||||
title,
|
||||
blob: base64,
|
||||
id: s.webkitRelativePath,
|
||||
};
|
||||
return fetch(sUrl).then((res) => {
|
||||
return res.blob().then((blob) => {
|
||||
const path = s.webkitRelativePath;
|
||||
let id = path?.length ? path : title;
|
||||
if (id == null || title == null || blob == null) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
title,
|
||||
blob,
|
||||
id,
|
||||
};
|
||||
});
|
||||
});
|
||||
})
|
||||
.filter(Boolean),
|
||||
).catch((error) => {
|
||||
logger('Something went wrong while processing uploaded files', 'error');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const uploadSamplesToDB = async (config, files) => {
|
||||
export async function uploadSamplesToDB(config, files) {
|
||||
logger('procesing user samples...');
|
||||
await processFilesForIDB(files).then((files) => {
|
||||
logger('user samples processed... opening db');
|
||||
const onOpened = (objectStore, _db) => {
|
||||
logger('index db opened... writing files to db');
|
||||
files.forEach((file) => {
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
objectStore.put(file);
|
||||
});
|
||||
logger('user samples written successfully');
|
||||
};
|
||||
openDB(config, onOpened);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
239
website/src/repl/useReplContext.jsx
Normal file
239
website/src/repl/useReplContext.jsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
Repl.jsx - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
|
||||
import { getDrawContext } from '@strudel/draw';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContextCurrentTime,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
} from '@strudel/webaudio';
|
||||
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
import { clearHydra } from '@strudel/hydra';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { settingsMap, useSettings } from '../settings.mjs';
|
||||
import {
|
||||
setActivePattern,
|
||||
setLatestCode,
|
||||
createPatternID,
|
||||
userPattern,
|
||||
getViewingPatternData,
|
||||
setViewingPatternData,
|
||||
} from '../user_pattern_utils.mjs';
|
||||
import { superdirtOutput } from '@strudel/osc/superdirtoutput';
|
||||
import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { prebake } from './prebake.mjs';
|
||||
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
|
||||
import './Repl.css';
|
||||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
|
||||
const { latestCode } = settingsMap.get();
|
||||
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
audioReady = initAudioOnFirstClick();
|
||||
modulesLoading = loadModules();
|
||||
presets = prebake();
|
||||
drawContext = getDrawContext();
|
||||
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
|
||||
}
|
||||
|
||||
async function getModule(name) {
|
||||
if (!modulesLoading) {
|
||||
return;
|
||||
}
|
||||
const modules = await modulesLoading;
|
||||
return modules.find((m) => m.packageName === name);
|
||||
}
|
||||
|
||||
export function useReplContext() {
|
||||
const { isSyncEnabled, audioEngineTarget } = useSettings();
|
||||
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
|
||||
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
|
||||
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
|
||||
|
||||
const init = useCallback(() => {
|
||||
const drawTime = [-2, 2];
|
||||
const drawContext = getDrawContext();
|
||||
const editor = new StrudelMirror({
|
||||
sync: isSyncEnabled,
|
||||
defaultOutput,
|
||||
getTime,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
transpiler,
|
||||
autodraw: false,
|
||||
root: containerRef.current,
|
||||
initialCode: '// LOADING',
|
||||
pattern: silence,
|
||||
drawTime,
|
||||
drawContext,
|
||||
prebake: async () => Promise.all([modulesLoading, presets]),
|
||||
onUpdateState: (state) => {
|
||||
setReplState({ ...state });
|
||||
},
|
||||
onToggle: (playing) => {
|
||||
if (!playing) {
|
||||
clearHydra();
|
||||
}
|
||||
},
|
||||
beforeEval: () => audioReady,
|
||||
afterEval: (all) => {
|
||||
const { code } = all;
|
||||
//post to iframe parent (like Udels) if it exists...
|
||||
window.parent?.postMessage(code);
|
||||
|
||||
setLatestCode(code);
|
||||
window.location.hash = '#' + code2hash(code);
|
||||
setDocumentTitle(code);
|
||||
const viewingPatternData = getViewingPatternData();
|
||||
setVersionDefaultsFrom(code);
|
||||
const data = { ...viewingPatternData, code };
|
||||
let id = data.id;
|
||||
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
||||
|
||||
if (isExamplePattern) {
|
||||
const codeHasChanged = code !== viewingPatternData.code;
|
||||
if (codeHasChanged) {
|
||||
// fork example
|
||||
const newPattern = userPattern.duplicate(data);
|
||||
id = newPattern.id;
|
||||
setViewingPatternData(newPattern.data);
|
||||
}
|
||||
} else {
|
||||
id = userPattern.isValidID(id) ? id : createPatternID();
|
||||
setViewingPatternData(userPattern.update(id, data).data);
|
||||
}
|
||||
setActivePattern(id);
|
||||
},
|
||||
bgFill: false,
|
||||
});
|
||||
window.strudelMirror = editor;
|
||||
|
||||
// init settings
|
||||
initCode().then(async (decoded) => {
|
||||
let code, msg;
|
||||
if (decoded) {
|
||||
code = decoded;
|
||||
msg = `I have loaded the code from the URL.`;
|
||||
} else if (latestCode) {
|
||||
code = latestCode;
|
||||
msg = `Your last session has been loaded!`;
|
||||
} else {
|
||||
const { code: randomTune, name } = await getRandomTune();
|
||||
code = randomTune;
|
||||
msg = `A random code snippet named "${name}" has been loaded!`;
|
||||
}
|
||||
editor.setCode(code);
|
||||
setDocumentTitle(code);
|
||||
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
const [replState, setReplState] = useState({});
|
||||
const { started, isDirty, error, activeCode, pending } = replState;
|
||||
const editorRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
||||
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
|
||||
// this will be the case when the main repl is being replaced
|
||||
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
|
||||
useEffect(() => {
|
||||
let editorSettings = {};
|
||||
Object.keys(defaultSettings).forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
|
||||
editorSettings[key] = _settings[key];
|
||||
}
|
||||
});
|
||||
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
|
||||
//
|
||||
|
||||
const setDocumentTitle = (code) => {
|
||||
const meta = getMetadata(code);
|
||||
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
|
||||
};
|
||||
|
||||
const handleTogglePlay = async () => {
|
||||
editorRef.current?.toggle();
|
||||
};
|
||||
|
||||
const resetEditor = async () => {
|
||||
(await getModule('@strudel/tonal'))?.resetVoicings();
|
||||
resetGlobalEffects();
|
||||
clearCanvas();
|
||||
clearHydra();
|
||||
resetLoadedSounds();
|
||||
editorRef.current.repl.setCps(0.5);
|
||||
await prebake(); // declare default samples
|
||||
};
|
||||
|
||||
const handleUpdate = async (patternData, reset = false) => {
|
||||
setViewingPatternData(patternData);
|
||||
editorRef.current.setCode(patternData.code);
|
||||
if (reset) {
|
||||
await resetEditor();
|
||||
handleEvaluate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
const code = patternData.code;
|
||||
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
|
||||
setActivePattern(patternData.id);
|
||||
setViewingPatternData(patternData);
|
||||
await resetEditor();
|
||||
editorRef.current.setCode(code);
|
||||
editorRef.current.repl.evaluate(code);
|
||||
};
|
||||
|
||||
const handleShare = async () => shareCode(replState.code);
|
||||
const context = {
|
||||
started,
|
||||
pending,
|
||||
isDirty,
|
||||
activeCode,
|
||||
handleTogglePlay,
|
||||
handleUpdate,
|
||||
handleShuffle,
|
||||
handleShare,
|
||||
handleEvaluate,
|
||||
init,
|
||||
error,
|
||||
editorRef,
|
||||
containerRef,
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext } from '@strudel/webaudio';
|
||||
|
||||
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, setVersionDefaults } from '@strudel/webaudio';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { isTauri } from '../tauri.mjs';
|
||||
import './Repl.css';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { writeText } from '@tauri-apps/api/clipboard';
|
||||
import { createContext } from 'react';
|
||||
import { $featuredPatterns, loadDBPatterns } from '@src/user_pattern_utils.mjs';
|
||||
|
||||
// Create a single supabase client for interacting with your database
|
||||
|
|
@ -82,6 +80,7 @@ export function loadModules() {
|
|||
import('@strudel/serial'),
|
||||
import('@strudel/soundfonts'),
|
||||
import('@strudel/csound'),
|
||||
import('@strudel/tidal'),
|
||||
];
|
||||
if (isTauri()) {
|
||||
modules = modules.concat([
|
||||
|
|
@ -95,6 +94,16 @@ export function loadModules() {
|
|||
|
||||
return evalScope(settingPatterns, ...modules);
|
||||
}
|
||||
// confirm dialog is a promise in webkit and a boolean in other browsers... normalize it to be a promise everywhere
|
||||
export function confirmDialog(msg) {
|
||||
const confirmed = confirm(msg);
|
||||
if (confirmed instanceof Promise) {
|
||||
return confirmed;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolve(confirmed);
|
||||
});
|
||||
}
|
||||
|
||||
let lastShared;
|
||||
export async function shareCode(codeToShare) {
|
||||
|
|
@ -103,34 +112,49 @@ export async function shareCode(codeToShare) {
|
|||
logger(`Link already generated!`, 'error');
|
||||
return;
|
||||
}
|
||||
const isPublic = confirm(
|
||||
|
||||
confirmDialog(
|
||||
'Do you want your pattern to be public? If no, press cancel and you will get just a private link.',
|
||||
);
|
||||
// generate uuid in the browser
|
||||
const hash = nanoid(12);
|
||||
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
|
||||
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
|
||||
if (!error) {
|
||||
lastShared = codeToShare;
|
||||
// copy shareUrl to clipboard
|
||||
if (isTauri()) {
|
||||
await writeText(shareUrl);
|
||||
).then(async (isPublic) => {
|
||||
const hash = nanoid(12);
|
||||
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
|
||||
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
|
||||
if (!error) {
|
||||
lastShared = codeToShare;
|
||||
// copy shareUrl to clipboard
|
||||
if (isTauri()) {
|
||||
await writeText(shareUrl);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
}
|
||||
const message = `Link copied to clipboard: ${shareUrl}`;
|
||||
alert(message);
|
||||
// alert(message);
|
||||
logger(message, 'highlight');
|
||||
} else {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
console.log('error', error);
|
||||
const message = `Error: ${error.message}`;
|
||||
// alert(message);
|
||||
logger(message);
|
||||
}
|
||||
const message = `Link copied to clipboard: ${shareUrl}`;
|
||||
alert(message);
|
||||
// alert(message);
|
||||
logger(message, 'highlight');
|
||||
} else {
|
||||
console.log('error', error);
|
||||
const message = `Error: ${error.message}`;
|
||||
// alert(message);
|
||||
logger(message);
|
||||
});
|
||||
}
|
||||
|
||||
export const isIframe = () => window.location !== window.parent.location;
|
||||
function isCrossOriginFrame() {
|
||||
try {
|
||||
return !window.top.location.hostname;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const ReplContext = createContext(null);
|
||||
export const isUdels = () => {
|
||||
if (isCrossOriginFrame()) {
|
||||
return false;
|
||||
}
|
||||
return window.top?.location?.pathname.includes('udels');
|
||||
};
|
||||
|
||||
export const getAudioDevices = async () => {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
|
@ -163,3 +187,13 @@ export const setAudioDevice = async (id) => {
|
|||
}
|
||||
initializeAudioOutput();
|
||||
};
|
||||
|
||||
export function setVersionDefaultsFrom(code) {
|
||||
try {
|
||||
const metadata = getMetadata(code);
|
||||
setVersionDefaults(metadata.version);
|
||||
} catch (err) {
|
||||
console.error('Error parsing metadata..');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue