1/* 2Repl.jsx - <short description TODO> 3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/repl/src/App.js> 4This 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/>. 5*/ 6 7import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core'; 8import { getDrawContext } from '@strudel/draw'; 9import { transpiler, evaluate } from '@strudel/transpiler'; 10import { 11 getAudioContextCurrentTime, 12 renderPatternAudio, 13 webaudioOutput, 14 resetGlobalEffects, 15 resetLoadedSounds, 16 initAudioOnFirstClick, 17 resetDefaults, 18 initAudio, 19} from '@strudel/webaudio'; 20import { setVersionDefaultsFrom } from './util.mjs'; 21import { StrudelMirror, defaultSettings } from '@strudel/codemirror'; 22import { clearHydra } from '@strudel/hydra'; 23import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; 24import { parseBoolean, settingsMap, useSettings } from '../settings.mjs'; 25import { useWebSocketMCP } from './useWebSocketMCP'; 26import { 27 setActivePattern, 28 setLatestCode, 29 createPatternID, 30 userPattern, 31 getViewingPatternData, 32 setViewingPatternData, 33} from '../user_pattern_utils.mjs'; 34import { superdirtOutput } from '@strudel/osc/superdirtoutput'; 35import { audioEngineTargets } from '../settings.mjs'; 36import { useStore } from '@nanostores/react'; 37import { prebake } from './prebake.mjs'; 38import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs'; 39import './Repl.css'; 40import { setInterval, clearInterval } from 'worker-timers'; 41import { getMetadata } from '../metadata_parser'; 42import { debugAudiograph } from './audiograph'; 43import { loadListenerSong as showListenerSong, nowPlaying, songPageId, startSong, takeAutoplay } from '../jev/client.mjs'; 44import { guardEditor, sandbox, setOwnCode, showForeign } from '../jev/sandbox.mjs'; 45import { escapeHtml } from '../jev/sandboxProtocol.mjs'; 46import { listenerParam, loadListenerSong } from '../jev/listeners.mjs'; 47import { armReplay, performanceParam } from '../jev/performance.mjs'; 48import { preloadSamples, songAsWritten } from '../jev/preload.mjs'; 49import { askOpening, setWrittenParts } from '../jev/jevCore.mjs'; 50import { initParty, partyBeforeStart, partyStarted, partyToggled } from '../jev/party.mjs'; 51import { initLobby } from '../jev/lobby.mjs'; 52 53// how long a song waits for Jev's opening before it starts on its fallbacks 54const OPENING_WAIT_MS = 4000; 55import { siteName } from '../jev/site.mjs'; 56import { boothStore } from '../jev/boothStore.mjs'; 57 58const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get(); 59let modulesLoading, presets, drawContext, clearCanvas, audioReady; 60 61if (typeof window !== 'undefined') { 62 audioReady = initAudioOnFirstClick({ 63 maxPolyphony, 64 audioDeviceName, 65 multiChannelOrbits: parseBoolean(multiChannelOrbits), 66 }); 67 modulesLoading = loadModules(); 68 presets = prebake(); 69 drawContext = getDrawContext(); 70 clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width); 71} 72 73async function getModule(name) { 74 if (!modulesLoading) { 75 return; 76 } 77 const modules = await modulesLoading; 78 return modules.find((m) => m.packageName === name); 79} 80 81const initialCode = `// LOADING`; 82 83export function useReplContext() { 84 const { isSyncEnabled, audioEngineTarget, prebakeScript, includePrebakeScriptInShare } = useSettings(); 85 const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc; 86 const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput; 87 const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds; 88 const init = useCallback(() => { 89 setActivePattern(getViewingPatternData().id); 90 const drawTime = [-2, 2]; 91 const drawContext = getDrawContext(); 92 const editor = new StrudelMirror({ 93 sync: isSyncEnabled, 94 defaultOutput, 95 getTime, 96 setInterval, 97 clearInterval, 98 transpiler, 99 autodraw: false, 100 root: containerRef.current, 101 initialCode, 102 pattern: silence, 103 drawTime, 104 drawContext, 105 prebake: async () => { 106 await Promise.all([modulesLoading, presets]); 107 if (prebakeScript) { 108 return evaluate(prebakeScript, { addReturn: false }); 109 } 110 }, 111 onUpdateState: (state) => { 112 setReplState({ ...state }); 113 }, 114 onToggle: (playing) => { 115 if (!playing) { 116 clearHydra(); 117 } 118 partyToggled(playing); 119 }, 120 beforeEval: () => { 121 boothStore.beginEval(); 122 return audioReady; 123 }, 124 // jevstrudel: before the first bar, the song's samples load (jev/preload.mjs) 125 // and Jev is asked about its opening (jevCore askOpening). In a 126 // listening party (jev/party.mjs) the host shares the performance and 127 // a guest follows it, starting at the cycle the host is on. 128 beforeStart: async () => { 129 const scheduler = editorRef.current?.repl?.scheduler; 130 const written = songAsWritten(scheduler?.pattern); 131 // which parts play where, before Jev is asked about any section 132 setWrittenParts(written); 133 partyBeforeStart(); 134 await Promise.all([preloadSamples(written), askOpening(OPENING_WAIT_MS)]); 135 // last, so a guest's start is computed right before the first tick 136 await partyStarted(scheduler); 137 }, 138 afterEval: (all) => { 139 const { code } = all; 140 boothStore.commitEval({ block: all.range !== undefined }); 141 //post to iframe parent (like Udels) if it exists... 142 window.parent?.postMessage(code); 143 144 // Get the full buffer content from the editor instead of just the evaluated block 145 const fullBufferCode = editorRef.current?.code || code; 146 setLatestCode(fullBufferCode); 147 148 try { 149 window.location.hash = '#' + code2hash(fullBufferCode); 150 } catch (e) { 151 console.warn('[useReplContext] Failed to update hash:', e.message); 152 } 153 setDocumentTitle(fullBufferCode); 154 const viewingPatternData = getViewingPatternData(); 155 setVersionDefaultsFrom(fullBufferCode); 156 const data = { ...viewingPatternData, code: fullBufferCode }; 157 let id = data.id; 158 const isExamplePattern = viewingPatternData.collection !== userPattern.collection; 159 160 if (isExamplePattern) { 161 const codeHasChanged = fullBufferCode !== viewingPatternData.code; 162 if (codeHasChanged) { 163 // fork example 164 const newPattern = userPattern.duplicate(data); 165 id = newPattern.id; 166 setViewingPatternData(newPattern.data); 167 } 168 } else { 169 id = userPattern.isValidID(id) ? id : createPatternID(); 170 setViewingPatternData(userPattern.update(id, data).data); 171 } 172 setActivePattern(id); 173 }, 174 bgFill: false, 175 }); 176 // jevstrudel: code of foreign provenance (a listener's song, code from 177 // a link) is evaluated only in the sandbox, never in this page 178 // (jev/sandbox.mjs) 179 guardEditor(editor); 180 window.strudelMirror = editor; 181 window.debugAudiograph = debugAudiograph; 182 183 // init settings 184 initCode().then(async (decoded) => { 185 let code, msg; 186 // strudelMirror is exposed synchronously above, but this runs after an 187 // await, so the visitor may already have picked a song (a card plays it 188 // and rewrites the URL). Don't clobber that with the initial load — it 189 // would tear down a listener song already playing in its sandbox. 190 if (editor.code !== initialCode) return; 191 const featured = startSong(); 192 // A hash arrived with a FRESH navigation is a link someone opened: 193 // someone else's code, so it plays in the sandbox. On a reload the same 194 // hash is this page's own last session (afterEval writes the code into 195 // the address every eval), so it stays the page's own and editable. 196 const isReload = performance.getEntriesByType?.('navigation')[0]?.type === 'reload'; 197 if (decoded && !isReload && decoded !== latestCode) { 198 showForeign(editor, decoded, { link: true }); 199 setDocumentTitle(decoded); 200 logger( 201 `I have loaded the code from the link. It is someone else's code, so it plays in a sandbox, apart from this page and your account. Press play or hit ctrl+enter to run it.`, 202 'highlight', 203 ); 204 return; 205 } else if (decoded) { 206 code = decoded; 207 msg = `I have loaded the code from the URL.`; 208 } else if (featured) { 209 // jevstrudel opens on a song rather than the last session (still in 210 // the patterns tab): the featured one, or the one a song page names. On a tab's first visit it 211 // plays: evaluate waits in beforeEval for the first click, since 212 // browsers keep audio off until the visitor interacts. A reload 213 // leaves it loaded and stopped (takeAutoplay). 214 setOwnCode(editor, featured.code); 215 nowPlaying.set(featured.id); 216 document.title = `${featured.title} - ${siteName}`; 217 // A song page's ?performance= replays a recorded performance: armed 218 // before the song is evaluated, so its jev()s play it and ask Jev 219 // nothing (jev/performance.mjs). 220 const replayId = performanceParam(); 221 if (replayId && songPageId() === featured.id) { 222 try { 223 const { changed } = await armReplay(replayId, featured); 224 logger( 225 `Replaying a performance of ${featured.title}${changed ? '. The song has changed since, so it plays the performance where the song still allows' : ''}.`, 226 'highlight', 227 ); 228 } catch (e) { 229 logger(`Could not load that performance (${e.message}); Jev plays ${featured.title} live.`, 'warning'); 230 } 231 } 232 if (takeAutoplay()) { 233 logger(`Welcome to jevstrudel! ${featured.title} plays from the top on your first click.`, 'highlight'); 234 editor.evaluate(); 235 } else { 236 logger(`${featured.title} is loaded. Press play or hit ctrl+enter to run it.`, 'highlight'); 237 } 238 return; 239 } else if (listenerParam()) { 240 // a listener's song link: the song is fetched, loaded and shown on 241 // the jev panel, and plays when the visitor presses play (it is a 242 // listener's code, so it never starts by itself) 243 try { 244 const song = await loadListenerSong(listenerParam()); 245 // with ?performance=, that take replays, in the sandbox too 246 const { replaying, error } = await showListenerSong(editor, song, { replay: performanceParam() }); 247 // the console renders HTML: a listener's words are escaped 248 logger( 249 `${escapeHtml(song.title)}, a song by ${escapeHtml(song.author)}, is loaded. It is a listener's code, so it plays in a sandbox, apart from this page and your account. Press play or hit ctrl+enter to run it.`, 250 'highlight', 251 ); 252 if (replaying) { 253 logger( 254 `It replays a recorded take of ${escapeHtml(song.title)}${replaying.changed ? '; the song has changed since, so it plays the take where the song still allows' : ''}.`, 255 'highlight', 256 ); 257 } else if (error) { 258 logger(`Could not load that take (${escapeHtml(error)}); Jev plays ${escapeHtml(song.title)} live.`, 'warning'); 259 } 260 return; 261 } catch (e) { 262 logger(`Could not load that listener's song (${e.message}).`, 'warning'); 263 code = latestCode || '$: s("[bd <hh oh>]*2").bank("tr909").dec(.4)'; 264 msg = latestCode ? `Your last session has been loaded!` : `Default code has been loaded`; 265 } 266 } else if (latestCode) { 267 code = latestCode; 268 msg = `Your last session has been loaded!`; 269 } else { 270 /* const { code: randomTune, name } = await getRandomTune(); 271 code = randomTune; */ 272 code = '$: s("[bd <hh oh>]*2").bank("tr909").dec(.4)'; 273 msg = `Default code has been loaded`; 274 } 275 setOwnCode(editor, code); 276 setDocumentTitle(code); 277 logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight'); 278 }); 279 280 editorRef.current = editor; 281 initParty(editorRef); 282 initLobby(editorRef); 283 }, []); 284 285 const [replState, setReplState] = useState({}); 286 const { isDirty, error, activeCode } = replState; 287 // playing is the page's scheduler, or the sandbox a listener's song plays in 288 const sandboxState = useSyncExternalStore(sandbox.subscribe, sandbox.get, sandbox.get); 289 const started = replState.started || sandboxState.status === 'playing'; 290 const pending = replState.pending || sandboxState.status === 'loading'; 291 const editorRef = useRef(); 292 const containerRef = useRef(); 293 294 // WebSocket MCP integration 295 const mcpState = useWebSocketMCP(editorRef); 296 297 // this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly! 298 // this will be the case when the main repl is being replaced 299 const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) }); 300 useEffect(() => { 301 let editorSettings = {}; 302 Object.keys(defaultSettings).forEach((key) => { 303 // Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties 304 editorSettings[key] = _settings[key]; 305 }); 306 editorRef.current?.updateSettings(editorSettings); 307 }, [_settings]); 308 309 // 310 // UI Actions 311 // 312 313 const setDocumentTitle = (code) => { 314 const meta = getMetadata(code); 315 document.title = (meta.title ? `${meta.title} - ` : '') + siteName; 316 }; 317 318 const handleTogglePlay = async () => { 319 editorRef.current?.toggle(); 320 }; 321 322 const resetEditor = async () => { 323 (await getModule('@strudel/tonal'))?.resetVoicings(); 324 resetDefaults(); 325 resetGlobalEffects(); 326 clearCanvas(); 327 clearHydra(); 328 resetLoadedSounds(); 329 editorRef.current.repl.setCps(0.5); 330 await prebake(); // declare default samples 331 }; 332 333 const handleUpdate = async (patternData, reset = false) => { 334 setViewingPatternData(patternData); 335 setOwnCode(editorRef.current, patternData.code); 336 if (reset) { 337 await resetEditor(); 338 handleEvaluate(); 339 } 340 }; 341 342 const handleEvaluate = () => { 343 editorRef.current.evaluate(); 344 }; 345 346 const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => { 347 await editorRef.current.evaluate(false); 348 editorRef.current.repl.scheduler.stop(); 349 await renderPatternAudio( 350 editorRef.current.repl.state.pattern, 351 editorRef.current.repl.scheduler.cps, 352 begin, 353 end, 354 sampleRate, 355 maxPolyphony, 356 multiChannelOrbits, 357 downloadName, 358 ).finally(async () => { 359 const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get(); 360 await initAudio({ 361 latestCode, 362 maxPolyphony, 363 audioDeviceName, 364 multiChannelOrbits, 365 }); 366 editorRef.current.repl.scheduler.stop(); 367 }); 368 }; 369 const handleShuffle = async () => { 370 const patternData = await getRandomTune(); 371 const code = patternData.code; 372 logger(`[repl] ✨ loading random tune "${patternData.id}"`); 373 setActivePattern(patternData.id); 374 setViewingPatternData(patternData); 375 await resetEditor(); 376 setOwnCode(editorRef.current, code); 377 editorRef.current.repl.evaluate(code); 378 }; 379 380 const handleShare = async () => { 381 let code = replState.code; 382 if (includePrebakeScriptInShare) { 383 code = prebakeScript + '\n' + code; 384 } 385 shareCode(code); 386 }; 387 const context = { 388 started, 389 pending, 390 isDirty, 391 activeCode, 392 handleTogglePlay, 393 handleUpdate, 394 handleShuffle, 395 handleShare, 396 handleEvaluate, 397 handleExport, 398 init, 399 error, 400 editorRef, 401 containerRef, 402 mcp: mcpState, 403 }; 404 return context; 405}