/* App.js - Copyright (C) 2022 Strudel contributors - see 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 . */ // import { evaluate } from '@strudel.cycles/eval'; import { CodeMirror, cx, flash, useHighlighting } from '@strudel.cycles/react'; // import { cleanupDraw, cleanupUi, Tone } from '@strudel.cycles/tone'; import React, { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import './App.css'; import logo from './logo.svg'; import * as tunes from './tunes.mjs'; import { prebake } from './prebake.mjs'; import * as WebDirt from 'WebDirt'; import { resetLoadedSamples, getAudioContext, getLoadedSamples } from '@strudel.cycles/webaudio'; import { controls, evalScope, logger } from '@strudel.cycles/core'; import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; import { useStrudel } from '@strudel.cycles/react'; import { webaudioOutput, initAudioOnFirstClick } from '@strudel.cycles/webaudio'; import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon'; import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon'; import CommandLineIcon from '@heroicons/react/20/solid/CommandLineIcon'; import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon'; import LinkIcon from '@heroicons/react/20/solid/LinkIcon'; import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon'; import AcademicCapIcon from '@heroicons/react/20/solid/AcademicCapIcon'; initAudioOnFirstClick(); // Create a single supabase client for interacting with your database const supabase = createClient( 'https://pidxdsxphlhzjnzmifth.supabase.co', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM', ); const modules = [ import('@strudel.cycles/core'), // import('@strudel.cycles/tone'), import('@strudel.cycles/tonal'), import('@strudel.cycles/mini'), import('@strudel.cycles/midi'), import('@strudel.cycles/xen'), import('@strudel.cycles/webaudio'), import('@strudel.cycles/osc'), import('@strudel.cycles/serial'), import('@strudel.cycles/soundfonts'), ]; evalScope( // Tone, controls, // sadly, this cannot be exported from core direclty { WebDirt }, ...modules, ); let loadedSamples = []; const presets = prebake(); Promise.all([...modules, presets]).then((data) => { // console.log('modules and sample registry loade', data); loadedSamples = Object.entries(getLoadedSamples() || {}); }); const hideHeader = false; const pending = false; const getTime = () => getAudioContext().currentTime; async function initCode() { // load code from url hash (either short hash from database or decode long hash) try { const initialUrl = window.location.href; const hash = initialUrl.split('?')[1]?.split('#')?.[0]; const codeParam = window.location.href.split('#')[1]; // looking like https://strudel.tidalcycles.org/?J01s5i1J0200 (fixed hash length) if (codeParam) { // looking like https://strudel.tidalcycles.org/#ImMzIGUzIg%3D%3D (hash length depends on code length) return atob(decodeURIComponent(codeParam || '')); } else if (hash) { return supabase .from('code') .select('code') .eq('hash', hash) .then(({ data, error }) => { if (error) { console.warn('failed to load hash', err); } if (data.length) { console.log('load hash from database', hash); return data[0].code; } }); } } catch (err) { console.warn('failed to decode', err); } } function getRandomTune() { const allTunes = Object.entries(tunes); const randomItem = (arr) => arr[Math.floor(Math.random() * arr.length)]; const [name, code] = randomItem(allTunes); return { name, code }; } const { code: randomTune, name } = getRandomTune(); const isEmbedded = window.location !== window.parent.location; function App() { const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); const [activeFooter, setActiveFooter] = useState('intro'); // logger const [log, setLog] = useState([]); 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 footerContent = useRef(); useLayoutEffect(() => { if (footerContent.current && activeFooter === 'console') { // scroll log box to bottom when log changes footerContent.current.scrollTop = footerContent.current?.scrollHeight; } }, [log, activeFooter]); const { code, setCode, scheduler, evaluate, activateCode, error, isDirty, activeCode, pattern, started, stop } = useStrudel({ initialCode: '// LOADING', defaultOutput: webaudioOutput, getTime, autolink: true, }); // init code useEffect(() => { initCode().then((decoded) => { logger( `🌀 Welcome to Strudel! ${ decoded ? `I have loaded the code from the URL.` : `A random code snippet named "${name}" has been loaded!` } Press play or hit ctrl+enter to run it!`, 'highlight', ); setCode(decoded || randomTune); }); }, []); useKeydown( useCallback( async (e) => { if (e.ctrlKey || e.altKey) { if (e.code === 'Enter') { e.preventDefault(); flash(view); await activateCode(); } else if (e.code === 'Period') { stop(); e.preventDefault(); } } }, [activateCode, stop, view], ), ); useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), getTime: () => scheduler.getPhase(), }); // // UI Actions // const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { logger('[repl] started. tip: you can also start by pressing ctrl+enter', 'highlight'); activateCode(); } else { logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight'); stop(); } }; const handleUpdate = () => { isDirty && activateCode(); logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight'); }; const handleShuffle = async () => { const { code, name } = getRandomTune(); logger(`[repl] ✨ loading random tune "${name}"`); /* cleanupDraw(); cleanupUi(); */ resetLoadedSamples(); await prebake(); // declare default samples await evaluate(code, false); }; const handleShare = async () => { const codeToShare = activeCode || code; if (lastShared === codeToShare) { logger(`Link already generated!`, 'error'); return; } // generate uuid in the browser const hash = nanoid(12); const { data, error } = await supabase.from('code').insert([{ code: codeToShare, hash }]); if (!error) { setLastShared(activeCode || code); const shareUrl = window.location.origin + '?' + hash; // copy shareUrl to clipboard navigator.clipboard.writeText(shareUrl); const message = `Link copied to clipboard: ${shareUrl}`; // alert(message); logger(message, 'highlight'); } else { console.log('error', error); const message = `Error: ${error.message}`; // alert(message); logger(message); } }; const handleChangeCode = (c) => { setCode(c); started && logger('[edit] code changed. hit ctrl+enter to update'); }; const FooterTab = ({ children, name }) => ( <>
setActiveFooter(name)} className={cx( 'h-8 px-2 text-white cursor-pointer hover:text-tertiary flex items-center space-x-1 border-b', activeFooter === name ? 'border-white hover:border-tertiary' : 'border-transparent', )} > {name}
{activeFooter === name && <>{children}} ); return ( // bg-gradient-to-t from-blue-900 to-slate-900 // bg-gradient-to-t from-green-900 to-slate-900
{!hideHeader && ( )}
{activeFooter !== '' && ( )}
{activeFooter !== '' && (
{activeFooter === 'intro' && (

😻 Hello Friend

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 💖

)} {activeFooter === 'console' && (
{log.map((l, i) => { const message = linkify(l.message); return (
{l.count ? ` (${l.count})` : ''}
); })}
)} {activeFooter === 'samples' && (
{loadedSamples.length} banks loaded: {loadedSamples.map(([name, samples]) => ( {}}> {' '} {name}( {Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1} ){' '} ))}
)}
)}
); } export default App; function useEvent(name, onTrigger, useCapture = false) { useEffect(() => { document.addEventListener(name, onTrigger, useCapture); return () => { document.removeEventListener(name, onTrigger, useCapture); }; }, [onTrigger]); } function useLogger(onTrigger) { useEvent(logger.key, onTrigger); } function useKeydown(onTrigger) { useEvent('keydown', onTrigger, true); } 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; }