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 } from '@strudel.cycles/webaudio'; import { useStore } from '@nanostores/react'; import { FilesTab } from './FilesTab'; const TAURI = window.__TAURI__; 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) => { ref?.node.connect(ctx.destination); }); }} > {' '} {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, isAutoCompletionEnabled, 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('isAutoCompletionEnabled', cbEvent.target.checked)} value={isAutoCompletionEnabled} /> settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)} value={isLineWrappingEnabled} /> Try clicking the logo in the top left!
); }