jevstrudel.git / website / src / repl / useReplContext.jsx
useReplContext.jsx405 lines · 16.2 KB · raw
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}