diff --git a/repl/src/App.tsx b/repl/src/App.tsx index 905f2364..ddaee1ad 100644 --- a/repl/src/App.tsx +++ b/repl/src/App.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import * as Tone from 'tone'; -import CodeMirror from './CodeMirror'; +import CodeMirror, { markEvent } from './CodeMirror'; import cx from './cx'; import { evaluate } from './evaluate'; import logo from './logo.svg'; @@ -36,32 +36,10 @@ const randomTune = getRandomTune(); function App() { const [editor, setEditor] = useState(); - const doc = useMemo(() => editor?.getDoc(), [editor]); const { setCode, setPattern, error, code, cycle, dirty, log, togglePlay, activateCode, pattern, pushLog } = useRepl({ tune: decoded || randomTune, defaultSynth, - onEvent: useCallback( - (event) => { - const locs = event.value.locations; - if (!locs) { - return; - } - // mark active event - const marks = locs.map(({ start, end }) => - doc.markText( - { line: start.line - 1, ch: start.column }, - { line: end.line - 1, ch: end.column }, - { css: 'background-color: gray;' } - ) - ); - //Tone.Transport.schedule(() => { // problem: this can be cleared by scheduler... - setTimeout(() => { - marks.forEach((mark) => mark.clear()); - // }, '+' + event.duration * 0.5); - }, event.duration * 0.9 * 1000); - }, - [doc] - ), + onEvent: useCallback(markEvent(editor), [editor]), }); const logBox = useRef(); // scroll log box to bottom when log changes @@ -136,6 +114,7 @@ function App() { theme: 'material', lineNumbers: true, styleSelectedText: true, + cursorBlinkRate: 0, }} onChange={(_: any, __: any, value: any) => setCode(value)} /> diff --git a/repl/src/CodeMirror.tsx b/repl/src/CodeMirror.tsx index 0d81a341..0dc27b54 100644 --- a/repl/src/CodeMirror.tsx +++ b/repl/src/CodeMirror.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { Controlled as CodeMirror2 } from 'react-codemirror2'; import 'codemirror/mode/javascript/javascript.js'; import 'codemirror/mode/pegjs/pegjs.js'; -import 'codemirror/theme/material.css'; +// import 'codemirror/theme/material.css'; import 'codemirror/lib/codemirror.css'; +import 'codemirror/theme/material.css'; export default function CodeMirror({ value, onChange, options, editorDidMount }: any) { options = options || { @@ -11,6 +12,29 @@ export default function CodeMirror({ value, onChange, options, editorDidMount }: theme: 'material', lineNumbers: true, styleSelectedText: true, + cursorBlinkRate: 500, }; return ; } + +export const markEvent = (editor) => (event) => { + const locs = event.value.locations; + if (!locs || !editor) { + return; + } + // mark active event + const marks = locs.map(({ start, end }) => + editor + .getDoc() + .markText( + { line: start.line - 1, ch: start.column }, + { line: end.line - 1, ch: end.column }, + { css: 'background-color: #FFCA28; color: black' } + ) + ); + //Tone.Transport.schedule(() => { // problem: this can be cleared by scheduler... + setTimeout(() => { + marks.forEach((mark) => mark.clear()); + // }, '+' + event.duration * 0.5); + }, event.duration * 0.9 * 1000); +}; diff --git a/repl/src/evaluate.ts b/repl/src/evaluate.ts index 6ad25f7c..0eddc937 100644 --- a/repl/src/evaluate.ts +++ b/repl/src/evaluate.ts @@ -37,10 +37,12 @@ export const evaluate: any = (code: string) => { if (typeof evaluated === 'function') { evaluated = evaluated(); } - const pattern = minify(evaluated); // eval and minify (if user entered a string) - if (pattern?.constructor?.name !== 'Pattern') { - const message = `got "${typeof pattern}" instead of pattern`; - throw new Error(message + (typeof pattern === 'function' ? ', did you forget to call a function?' : '.')); + if (typeof evaluated === 'string') { + evaluated = strudel.withLocationOffset(minify(evaluated), { start: { line: 1, column: -1 } }); } - return { mode: 'javascript', pattern: pattern }; + if (evaluated?.constructor?.name !== 'Pattern') { + const message = `got "${typeof evaluated}" instead of pattern`; + throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.')); + } + return { mode: 'javascript', pattern: evaluated }; }; diff --git a/repl/src/shapeshifter.js b/repl/src/shapeshifter.js index ec702b15..f4a6eb6e 100644 --- a/repl/src/shapeshifter.js +++ b/repl/src/shapeshifter.js @@ -1,7 +1,13 @@ import { parseScriptWithLocation } from './shift-parser/index.js'; // npm module does not work in the browser import traverser from './shift-traverser'; // npm module does not work in the browser const { replace } = traverser; -import { LiteralStringExpression, IdentifierExpression, CallExpression, StaticMemberExpression } from 'shift-ast'; +import { + LiteralStringExpression, + IdentifierExpression, + CallExpression, + StaticMemberExpression, + Script, +} from 'shift-ast'; import codegen from 'shift-codegen'; import * as strudel from '../../strudel.mjs'; @@ -12,20 +18,50 @@ const isNote = (name) => /^[a-gC-G][bs]?[0-9]$/.test(name); const addLocations = true; export const addMiniLocations = true; +/* +not supported for highlighting: +- 'b3'.p +- mini('b3') / m('b3') +- 'b3'.m / 'b3'.mini +*/ + export default (code) => { const ast = parseScriptWithLocation(code); - const nodesWithLocation = []; + const artificialNodes = []; const parents = []; const shifted = replace(ast.tree, { enter(node, parent) { parents.push(parent); - const isSynthetic = parents.some((p) => nodesWithLocation.includes(p)); + const isSynthetic = parents.some((p) => artificialNodes.includes(p)); if (isSynthetic) { return node; } - const grandparent = parents[parents.length - 2]; - const isTimeCat = parent?.type === 'ArrayExpression' && isPatternFactory(grandparent); - const isMarkable = isPatternFactory(parent) || isTimeCat; + + // replace template string `xxx` with 'xxx'.m + if (isBackTickString(node)) { + const minified = getMinified(node.elements[0].rawValue); + return wrapLocationOffset(minified, node, ast.locations, artificialNodes); + } + + // allows to use top level strings, which are normally directives... but we don't need directives + if (node.type === 'Script' && node.directives.length === 1 && !node.statements.length) { + const minified = getMinified(node.directives[0].rawValue); + const wrapped = wrapLocationOffset(minified, node.directives[0], ast.locations, artificialNodes); + return new Script({ directives: [], statements: [wrapped] }); + } + + // replace double quote string "xxx" with 'xxx'.m + if (isStringWithDoubleQuotes(node, ast.locations, code)) { + const minified = getMinified(node.value); + return wrapLocationOffset(minified, node, ast.locations, artificialNodes); + } + + // replace double quote string "xxx" with 'xxx'.m + if (isStringWithDoubleQuotes(node, ast.locations, code)) { + const minified = getMinified(node.value); + return wrapLocationOffset(minified, node, ast.locations, artificialNodes); + } + // operator overloading => still not done const operators = { '*': 'fast', @@ -41,22 +77,28 @@ export default (code) => { ) { let arg = node.left; if (node.left.type === 'IdentifierExpression') { - arg = wrapReify(node.left); + arg = wrapFunction('reify', node.left); } return new CallExpression({ callee: new StaticMemberExpression({ property: operators[node.operator], - object: wrapReify(arg), + object: wrapFunction('reify', arg), }), arguments: [node.right], }); } + + const isMarkable = isPatternArg(parents) || hasModifierCall(parent); + // add to location to pure(x) calls + if (node.type === 'CallExpression' && node.callee.name === 'pure') { + return reifyWithLocation(node.arguments[0].name, node.arguments[0], ast.locations, artificialNodes); + } // replace pseudo note variables if (node.type === 'IdentifierExpression') { if (isNote(node.name)) { const value = node.name[1] === 's' ? node.name.replace('s', '#') : node.name; if (addLocations && isMarkable) { - return reifyWithLocation(value, node, ast.locations, nodesWithLocation); + return reifyWithLocation(value, node, ast.locations, artificialNodes); } return new LiteralStringExpression({ value }); } @@ -66,10 +108,10 @@ export default (code) => { } if (addLocations && node.type === 'LiteralStringExpression' && isMarkable) { // console.log('add', node); - return reifyWithLocation(node.value, node, ast.locations, nodesWithLocation); + return reifyWithLocation(node.value, node, ast.locations, artificialNodes); } if (!addMiniLocations) { - return node; + return wrapFunction('reify', node); } // mini notation location handling const miniFunctions = ['mini', 'm']; @@ -81,11 +123,11 @@ export default (code) => { console.warn('multi arg mini locations not supported yet...'); return node; } - return wrapLocationOffset(node, node.arguments, ast.locations, nodesWithLocation); + return wrapLocationOffset(node, node.arguments, ast.locations, artificialNodes); } if (node.type === 'StaticMemberExpression' && miniFunctions.includes(node.property) && !isAlreadyWrapped) { // 'c3'.mini or 'c3'.m - return wrapLocationOffset(node, node.object, ast.locations, nodesWithLocation); + return wrapLocationOffset(node, node.object, ast.locations, artificialNodes); } return node; }, @@ -96,15 +138,58 @@ export default (code) => { return codegen(shifted); }; -function wrapReify(node) { +function wrapFunction(name, ...arguments) { return new CallExpression({ - callee: new IdentifierExpression({ - name: 'reify', - }), - arguments: [node], + callee: new IdentifierExpression({ name }), + arguments, }); } +function getMinified(value) { + return new StaticMemberExpression({ + object: new LiteralStringExpression({ value }), + property: 'm', + }); +} + +function isBackTickString(node) { + return node.type === 'TemplateExpression' && node.elements.length === 1; +} + +function isStringWithDoubleQuotes(node, locations, code) { + if (node.type !== 'LiteralStringExpression') { + return false; + } + const loc = locations.get(node); + const snippet = code.slice(loc.start.offset, loc.end.offset); + return snippet[0] === '"'; // we can trust the end is also ", as the parsing did not fail +} + +// returns true if the given parents belong to a pattern argument node +// this is used to check if a node should receive a location for highlighting +function isPatternArg(parents) { + if (!parents.length) { + return false; + } + const ancestors = parents.slice(0, -1); + const parent = parents[parents.length - 1]; + if (isPatternFactory(parent)) { + return true; + } + if (parent?.type === 'ArrayExpression') { + return isPatternArg(ancestors); + } + return false; +} + +function hasModifierCall(parent) { + // TODO: modifiers are more than composables, for example every is not composable but should be seen as modifier.. + // need all prototypes of Pattern + return ( + parent?.type === 'StaticMemberExpression' && Object.keys(Pattern.prototype.composable).includes(parent.property) + ); +} + function isPatternFactory(node) { return node?.type === 'CallExpression' && Object.keys(Pattern.prototype.factories).includes(node.callee.name); } @@ -115,7 +200,7 @@ function canBeOverloaded(node) { } // turn node into withLocationOffset(node, location) -function wrapLocationOffset(node, stringNode, locations, nodesWithLocation) { +function wrapLocationOffset(node, stringNode, locations, artificialNodes) { // console.log('wrapppp', stringNode); const expression = { type: 'CallExpression', @@ -125,28 +210,22 @@ function wrapLocationOffset(node, stringNode, locations, nodesWithLocation) { }, arguments: [node, getLocationObject(stringNode, locations)], }; - nodesWithLocation.push(expression); + artificialNodes.push(expression); // console.log('wrapped', codegen(expression)); return expression; } // turns node in reify(value).withLocation(location), where location is the node's location in the source code // with this, the reified pattern can pass its location to the event, to know where to highlight when it's active -function reifyWithLocation(value, node, locations, nodesWithLocation) { - // console.log('reifyWithLocation', value, node); +function reifyWithLocation(value, node, locations, artificialNodes) { const withLocation = new CallExpression({ callee: new StaticMemberExpression({ - object: new CallExpression({ - callee: new IdentifierExpression({ - name: 'reify', - }), - arguments: [new LiteralStringExpression({ value })], - }), + object: wrapFunction('reify', new LiteralStringExpression({ value })), property: 'withLocation', }), arguments: [getLocationObject(node, locations)], }); - nodesWithLocation.push(withLocation); + artificialNodes.push(withLocation); return withLocation; } diff --git a/repl/src/tunes.ts b/repl/src/tunes.ts index d4a50bb9..5968dacf 100644 --- a/repl/src/tunes.ts +++ b/repl/src/tunes.ts @@ -440,3 +440,20 @@ export const caverave = `() => { synths ).slow(2); }`; + +export const callcenterhero = `()=>{ + const bpm = 90; + const lead = polysynth().set({...osc('sine4'),...adsr(.004)}).chain(vol(0.15),out) + const bass = fmsynth({...osc('sawtooth6'),...adsr(0.05,.6,0.8,0.1)}).chain(vol(0.6), out); + const s = scale(slowcat('F3 minor', 'Ab3 major', 'Bb3 dorian', 'C4 phrygian dominant').slow(4)); + return stack( + "0 2".groove(" [x ~]").edit(s).scaleTranspose(stack(0,2)).tone(lead), + "<6 7 9 7>".groove("[~ [x ~]*2]*2").edit(s).scaleTranspose('[0,2] [2,4]'.m.fast(2).every(4,rev)).tone(lead), + "-14".groove("[~ x@0.8]*2".early(0.01)).edit(s).tone(bass), + "c2*2".tone(membrane().chain(vol(0.6), out)), + "~ c2".tone(noise().chain(vol(0.2), out)), + "c4*4".tone(metal(adsr(0,.05,0)).chain(vol(0.03), out)) + ) + .slow(120 / bpm) +} +`; diff --git a/repl/src/tutorial/MiniRepl.tsx b/repl/src/tutorial/MiniRepl.tsx index 9617d417..4d50c38a 100644 --- a/repl/src/tutorial/MiniRepl.tsx +++ b/repl/src/tutorial/MiniRepl.tsx @@ -1,7 +1,7 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import * as Tone from 'tone'; import useRepl from '../useRepl'; -import CodeMirror from '../CodeMirror'; +import CodeMirror, { markEvent } from '../CodeMirror'; import cx from '../cx'; const defaultSynth = new Tone.PolySynth().chain(new Tone.Gain(0.5), Tone.Destination).set({ @@ -11,42 +11,87 @@ const defaultSynth = new Tone.PolySynth().chain(new Tone.Gain(0.5), Tone.Destina }, }); -function MiniRepl({ tune, height = 100 }) { - const { code, setCode, activateCode, activeCode, setPattern, error, cycle, dirty, log, togglePlay } = useRepl({ +function MiniRepl({ tune, maxHeight = 500 }) { + const [editor, setEditor] = useState(); + const { code, setCode, activateCode, activeCode, setPattern, error, cycle, dirty, log, togglePlay, hash } = useRepl({ tune, defaultSynth, autolink: false, + onEvent: useCallback(markEvent(editor), [editor]), }); + const lines = code.split('\n').length; + const height = Math.min(lines * 30 + 30, maxHeight); return ( -
-
- - +
+
+
+ + +
+
{error && {error.message}}
{' '}
- setCode(value)} - /> - {/*