diff --git a/packages/react/src/hooks/useStrudel.mjs b/packages/react/src/hooks/useStrudel.mjs index a10998e7..dbf2269e 100644 --- a/packages/react/src/hooks/useStrudel.mjs +++ b/packages/react/src/hooks/useStrudel.mjs @@ -74,8 +74,11 @@ function useStrudel({ } }); const activateCode = useCallback( - async (autostart = true) => { - const res = await evaluate(code, autostart); + async (newCode, autostart = true) => { + if (newCode) { + setCode(code); + } + const res = await evaluate(newCode || code, autostart); broadcast({ type: 'start', from: id }); return res; }, diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index b8f10d5d..6df5a6b6 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,4 +1,4 @@ -import { noteToMidi, valueToMidi } from './util.mjs'; +import { noteToMidi, valueToMidi, nanFallback } from './util.mjs'; import { getAudioContext, registerSound } from './index.mjs'; import { getEnvelope } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -33,6 +33,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol const ac = getAudioContext(); let sampleUrl; if (Array.isArray(bank)) { + n = nanFallback(n, 0); sampleUrl = bank[n % bank.length]; } else { const midiDiff = (noteA) => noteToMidi(noteA) - midi; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 2305ab44..3be97615 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th import './feedbackdelay.mjs'; import './reverb.mjs'; import './vowel.mjs'; -import { clamp } from './util.mjs'; +import { clamp, nanFallback } from './util.mjs'; import workletsUrl from './worklets.mjs?url'; import { createFilter, gainNode, getCompressor } from './helpers.mjs'; import { map } from 'nanostores'; @@ -322,6 +322,7 @@ export const superdough = async (value, deadline, hapDuration) => { compressorAttack, compressorRelease, } = value; + gain = nanFallback(gain, 1); //music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index db056376..d49ffd6b 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -1,3 +1,5 @@ +import { logger } from './logger.mjs'; + // currently duplicate with core util.mjs to skip dependency // TODO: add separate util module? @@ -51,3 +53,11 @@ export const valueToMidi = (value, fallbackValue) => { } return fallbackValue; }; + +export function nanFallback(value, fallback) { + if (isNaN(Number(value))) { + logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning'); + return fallback; + } + return value; +} diff --git a/website/src/repl/Footer.jsx b/website/src/repl/Footer.jsx deleted file mode 100644 index cbf0b63b..00000000 --- a/website/src/repl/Footer.jsx +++ /dev/null @@ -1,496 +0,0 @@ -import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon'; -import { logger } from '@strudel.cycles/core'; -import { useEvent, cx } from '@strudel.cycles/react'; -// import { cx } from '@strudel.cycles/react'; -import { nanoid } from 'nanoid'; -import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { Reference } from './Reference'; -import { themes } from './themes.mjs'; -import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs'; -import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio'; -import { useStore } from '@nanostores/react'; -import { FilesTab } from './FilesTab'; - -const TAURI = window.__TAURI__; - -const { BASE_URL } = import.meta.env; -const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; - -export function Footer({ context }) { - const footerContent = useRef(); - const [log, setLog] = useState([]); - const { activeFooter, isZen, panelPosition } = useSettings(); - - useLayoutEffect(() => { - if (footerContent.current && activeFooter === 'console') { - // scroll log box to bottom when log changes - footerContent.current.scrollTop = footerContent.current?.scrollHeight; - } - }, [log, activeFooter]); - useLayoutEffect(() => { - if (!footerContent.current) { - } else if (activeFooter === 'console') { - footerContent.current.scrollTop = footerContent.current?.scrollHeight; - } else { - footerContent.current.scrollTop = 0; - } - }, [activeFooter]); - - useLogger( - useCallback((e) => { - const { message, type, data } = e.detail; - setLog((l) => { - const lastLog = l.length ? l[l.length - 1] : undefined; - const id = nanoid(12); - // if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) { - if (type === 'loaded-sample') { - // const loadIndex = l.length - 1; - const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url); - l[loadIndex] = { message, type, id, data }; - } else if (lastLog && lastLog.message === message) { - l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]); - } else { - l = l.concat([{ message, type, id, data }]); - } - return l.slice(-20); - }); - }, []), - ); - - const FooterTab = ({ children, name, label }) => ( - <> -
setActiveFooter(name)} - className={cx( - 'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b', - activeFooter === name ? 'border-foreground' : 'border-transparent', - )} - > - {label || name} -
- {activeFooter === name && <>{children}} - - ); - if (isZen) { - return null; - } - - const isActive = activeFooter !== ''; - - let positions = { - right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'), - bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''), - }; - return ( - - ); -} - -function useLogger(onTrigger) { - useEvent(logger.key, onTrigger); -} - -function linkify(inputText) { - var replacedText, replacePattern1, replacePattern2, replacePattern3; - - //URLs starting with http://, https://, or ftp:// - replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim; - replacedText = inputText.replace(replacePattern1, '$1'); - - //URLs starting with "www." (without // before it, or it'd re-link the ones done above). - replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim; - replacedText = replacedText.replace( - replacePattern2, - '$1$2', - ); - - //Change email addresses to mailto:: links. - replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim; - replacedText = replacedText.replace(replacePattern3, '$1'); - - return replacedText; -} - -function WelcomeTab() { - return ( -
-

- 🌀 welcome -

-

- You have found strudel, a new live coding platform to write dynamic music - pieces in the browser! It is free and open-source and made for beginners and experts alike. To get started: -
-
- 1. hit play - 2. change something -{' '} - 3. hit update -
- If you don't like what you hear, try shuffle! -

-

- To learn more about what this all means, check out the{' '} - - interactive tutorial - - . Also feel free to join the{' '} - - tidalcycles discord channel - {' '} - to ask any questions, give feedback or just say hello. -

-

about

-

- strudel is a JavaScript version of{' '} - - tidalcycles - - , which is a popular live coding language for music, written in Haskell. You can find the source code at{' '} - - github - - . Please consider to{' '} - - support this project - {' '} - to ensure ongoing development 💖 -

-
- ); -} - -function ConsoleTab({ log }) { - return ( -
-
{`███████╗████████╗██████╗ ██╗   ██╗██████╗ ███████╗██╗     
-██╔════╝╚══██╔══╝██╔══██╗██║   ██║██╔══██╗██╔════╝██║     
-███████╗   ██║   ██████╔╝██║   ██║██║  ██║█████╗  ██║     
-╚════██║   ██║   ██╔══██╗██║   ██║██║  ██║██╔══╝  ██║     
-███████║   ██║   ██║  ██║╚██████╔╝██████╔╝███████╗███████╗
-╚══════╝   ╚═╝   ╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}
- {log.map((l, i) => { - const message = linkify(l.message); - return ( -
- - {l.count ? ` (${l.count})` : ''} -
- ); - })} -
- ); -} - -const getSamples = (samples) => - Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1; - -function SoundsTab() { - const sounds = useStore(soundMap); - const { soundsFilter } = useSettings(); - const soundEntries = useMemo(() => { - let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_')); - if (!sounds) { - return []; - } - if (soundsFilter === 'user') { - return filtered.filter(([key, { data }]) => !data.prebake); - } - if (soundsFilter === 'drums') { - return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines'); - } - if (soundsFilter === 'samples') { - return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines'); - } - if (soundsFilter === 'synths') { - return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type)); - } - return filtered; - }, [sounds, soundsFilter]); - // holds mutable ref to current triggered sound - const trigRef = useRef(); - // stop current sound on mouseup - useEvent('mouseup', () => { - const t = trigRef.current; - trigRef.current = undefined; - t?.then((ref) => { - ref?.stop(getAudioContext().currentTime + 0.01); - }); - }); - return ( -
-
- settingsMap.setKey('soundsFilter', value)} - items={{ - samples: 'samples', - drums: 'drum-machines', - synths: 'Synths', - user: 'User', - }} - > -
-
- {soundEntries.map(([name, { data, onTrigger }]) => ( - { - const ctx = getAudioContext(); - const params = { - note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, - s: name, - clip: 1, - release: 0.5, - }; - const time = ctx.currentTime + 0.05; - const onended = () => trigRef.current?.node?.disconnect(); - trigRef.current = Promise.resolve(onTrigger(time, params, onended)); - trigRef.current.then((ref) => { - connectToDestination(ref?.node); - }); - }} - > - {' '} - {name} - {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''} - {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''} - - ))} - {!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''} -
-
- ); -} - -function Checkbox({ label, value, onChange }) { - return ( - - ); -} - -function ButtonGroup({ value, onChange, items }) { - return ( -
- {Object.entries(items).map(([key, label], i, arr) => ( - - ))} -
- ); -} - -function SelectInput({ value, options, onChange }) { - return ( - - ); -} - -function NumberSlider({ value, onChange, step = 1, ...rest }) { - return ( -
- onChange(Number(e.target.value))} - {...rest} - /> - onChange(Number(e.target.value))} - /> -
- ); -} - -function FormItem({ label, children }) { - return ( -
- - {children} -
- ); -} - -const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k])); -const fontFamilyOptions = { - monospace: 'monospace', - BigBlueTerminal: 'BigBlueTerminal', - x3270: 'x3270', - PressStart: 'PressStart2P', - galactico: 'galactico', - 'we-come-in-peace': 'we-come-in-peace', - FiraCode: 'FiraCode', - 'FiraCode-SemiBold': 'FiraCode SemiBold', - teletext: 'teletext', - mode7: 'mode7', -}; - -function SettingsTab({ scheduler }) { - const { - theme, - keybindings, - isLineNumbersDisplayed, - isActiveLineHighlighted, - isAutoCompletionEnabled, - isTooltipEnabled, - isLineWrappingEnabled, - fontSize, - fontFamily, - panelPosition, - } = useSettings(); - - return ( -
- {/* -
- - -
-
*/} - - settingsMap.setKey('theme', theme)} /> - -
- - settingsMap.setKey('fontFamily', fontFamily)} - /> - - - settingsMap.setKey('fontSize', fontSize)} - min={10} - max={40} - step={2} - /> - -
- - settingsMap.setKey('keybindings', keybindings)} - items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }} - > - - - settingsMap.setKey('panelPosition', value)} - items={{ bottom: 'Bottom', right: 'Right' }} - > - - - settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)} - value={isLineNumbersDisplayed} - /> - settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)} - value={isActiveLineHighlighted} - /> - settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)} - value={isAutoCompletionEnabled} - /> - settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)} - value={isTooltipEnabled} - /> - settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)} - value={isLineWrappingEnabled} - /> - - Try clicking the logo in the top left! - - - -
- ); -} diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 2700bcf9..3d3117ae 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -11,13 +11,13 @@ import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react'; import './Repl.css'; -import { Footer } from './Footer'; +import { Panel } from './panel/Panel'; import { Header } from './Header'; import { prebake } from './prebake.mjs'; import * as tunes from './tunes.mjs'; import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import { themes } from './themes.mjs'; -import { settingsMap, useSettings, setLatestCode } from '../settings.mjs'; +import { settingsMap, useSettings, setLatestCode, updateUserCode } from '../settings.mjs'; import Loader from './Loader'; import { settingPatterns } from '../settings.mjs'; import { code2hash, hash2code } from './helpers.mjs'; @@ -131,6 +131,7 @@ export function Repl({ embedded = false }) { isLineWrappingEnabled, panelPosition, isZen, + activePattern, } = useSettings(); const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]); @@ -147,6 +148,7 @@ export function Repl({ embedded = false }) { cleanupDraw(); }, afterEval: ({ code, meta }) => { + updateUserCode(code); setMiniLocations(meta.miniLocations); setWidgets(meta.widgets); setPending(false); @@ -226,7 +228,7 @@ export function Repl({ embedded = false }) { const handleChangeCode = useCallback( (c) => { setCode(c); - //started && logger('[edit] code changed. hit ctrl+enter to update'); + // started && logger('[edit] code changed. hit ctrl+enter to update'); }, [started], ); @@ -245,8 +247,8 @@ export function Repl({ embedded = false }) { stop(); } }; - const handleUpdate = () => { - isDirty && activateCode(); + const handleUpdate = (newCode) => { + (newCode || isDirty) && activateCode(newCode); logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight'); }; @@ -347,12 +349,12 @@ export function Repl({ embedded = false }) { onSelectionChange={handleSelectionChange} /> - {panelPosition === 'right' && !isEmbedded &&