Merge pull request #14 from tidalcycles/stateful-events

Stateful queries and events (WIP)
This commit is contained in:
Felix Roos
2022-02-27 20:52:51 +01:00
committed by GitHub
16 changed files with 383 additions and 249 deletions
+12 -12
View File
@@ -18,7 +18,7 @@ try {
console.warn('failed to decode', err);
}
// "balanced" | "interactive" | "playback";
Tone.setContext(new Tone.Context({ latencyHint: 'playback', lookAhead: 1 }));
// Tone.setContext(new Tone.Context({ latencyHint: 'playback', lookAhead: 1 }));
const defaultSynth = new Tone.PolySynth().chain(new Tone.Gain(0.5), Tone.getDestination());
defaultSynth.set({
oscillator: { type: 'triangle' },
@@ -37,11 +37,12 @@ const randomTune = getRandomTune();
function App() {
const [editor, setEditor] = useState<any>();
const { setCode, setPattern, error, code, cycle, dirty, log, togglePlay, activateCode, pattern, pushLog } = useRepl({
tune: decoded || randomTune,
defaultSynth,
onDraw: useCallback(markEvent(editor), [editor]),
});
const { setCode, setPattern, error, code, cycle, dirty, log, togglePlay, activateCode, pattern, pushLog, pending } =
useRepl({
tune: decoded || randomTune,
defaultSynth,
onDraw: useCallback(markEvent(editor), [editor]),
});
const logBox = useRef<any>();
// scroll log box to bottom when log changes
useLayoutEffect(() => {
@@ -51,12 +52,11 @@ function App() {
// set active pattern on ctrl+enter
useLayoutEffect(() => {
// TODO: make sure this is only fired when editor has focus
const handleKeyPress = (e: any) => {
const handleKeyPress = async (e: any) => {
if (e.ctrlKey || e.altKey) {
switch (e.code) {
case 'Enter':
activateCode();
!cycle.started && cycle.start();
await activateCode();
break;
case 'Period':
cycle.stop();
@@ -88,11 +88,11 @@ function App() {
</div>
<div className="flex space-x-4">
<button
onClick={() => {
onClick={async () => {
const _code = getRandomTune();
console.log('tune', _code); // uncomment this to debug when random code fails
setCode(_code);
const parsed = evaluate(_code);
const parsed = await evaluate(_code);
// Tone.Transport.cancel(Tone.Transport.seconds);
setPattern(parsed.pattern);
}}
@@ -133,7 +133,7 @@ function App() {
className="flex-none w-full border border-gray-700 p-2 bg-slate-700 hover:bg-slate-500"
onClick={() => togglePlay()}
>
{cycle.started ? 'pause' : 'play'}
{!pending ? <>{cycle.started ? 'pause' : 'play'}</> : <>loading...</>}
</button>
<textarea
className="grow bg-[#283237] border-0 text-xs min-h-[200px]"
+1 -1
View File
@@ -18,7 +18,7 @@ export default function CodeMirror({ value, onChange, options, editorDidMount }:
}
export const markEvent = (editor) => (time, event) => {
const locs = event.value.locations;
const locs = event.context.locations;
if (!locs || !editor) {
return;
}
+2 -2
View File
@@ -29,10 +29,10 @@ hackLiteral(String, ['pure', 'p'], bootstrapped.pure); // comment out this line
// this will add everything to global scope, which is accessed by eval
Object.assign(globalThis, bootstrapped, Tone, toneHelpers);
export const evaluate: any = (code: string) => {
export const evaluate: any = async (code: string) => {
const shapeshifted = shapeshifter(code); // transform syntactically correct js code to semantically usable code
// console.log('shapeshifted', shapeshifted);
let evaluated = eval(shapeshifted);
let evaluated = await eval(shapeshifted);
if (typeof evaluated === 'function') {
evaluated = evaluated();
}
+2 -1
View File
@@ -101,10 +101,11 @@ export function patternifyAST(ast: any): any {
return ast.source_;
}
const { start, end } = ast.location_;
const value = !isNaN(Number(ast.source_)) ? Number(ast.source_) : ast.source_;
// return ast.source_;
// the following line expects the shapeshifter to wrap this in withLocationOffset
// because location_ is only relative to the mini string, but we need it relative to whole code
return pure(ast.source_).withLocation({ start, end });
return pure(value).withLocation({ start, end });
}
return patternifyAST(ast.source_);
case 'stretch':
+7 -6
View File
@@ -92,15 +92,16 @@ export default (code) => {
// add to location to pure(x) calls
if (node.type === 'CallExpression' && node.callee.name === 'pure') {
const literal = node.arguments[0];
const value = literal[{ LiteralNumericExpression: 'value', LiteralStringExpression: 'name' }[literal.type]];
return reifyWithLocation(value + '', node.arguments[0], ast.locations, artificialNodes);
// const value = literal[{ LiteralNumericExpression: 'value', LiteralStringExpression: 'name' }[literal.type]];
// console.log('value',value);
return reifyWithLocation(literal, 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, artificialNodes);
return reifyWithLocation(new LiteralStringExpression({ value }), node, ast.locations, artificialNodes);
}
return new LiteralStringExpression({ value });
}
@@ -110,7 +111,7 @@ export default (code) => {
}
if (addLocations && node.type === 'LiteralStringExpression' && isMarkable) {
// console.log('add', node);
return reifyWithLocation(node.value, node, ast.locations, artificialNodes);
return reifyWithLocation(node, node, ast.locations, artificialNodes);
}
if (!addMiniLocations) {
return wrapFunction('reify', node);
@@ -219,10 +220,10 @@ function wrapLocationOffset(node, stringNode, locations, artificialNodes) {
// 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, artificialNodes) {
function reifyWithLocation(literalNode, node, locations, artificialNodes) {
const withLocation = new CallExpression({
callee: new StaticMemberExpression({
object: wrapFunction('reify', new LiteralStringExpression({ value })),
object: wrapFunction('reify', literalNode),
property: 'withLocation',
}),
arguments: [getLocationObject(node, locations)],
+11 -34
View File
@@ -3,21 +3,6 @@ import { Pattern as _Pattern } from '../../strudel.mjs';
const Pattern = _Pattern as any;
export declare interface NoteEvent {
value: string | number;
scale?: string;
}
function toNoteEvent(event: string | NoteEvent): NoteEvent {
if (typeof event === 'string' || typeof event === 'number') {
return { value: event };
}
if (event.value) {
return event;
}
throw new Error('not a valid note event: ' + JSON.stringify(event));
}
// modulo that works with negative numbers e.g. mod(-1, 3) = 2
const mod = (n: number, m: number): number => (n < 0 ? mod(n + m, m) : n % m);
@@ -60,24 +45,16 @@ function scaleTranspose(scale: string, offset: number, note: string) {
return n + o;
}
Pattern.prototype._mapNotes = function (func: (note: NoteEvent) => NoteEvent) {
return this.fmap((event: string | NoteEvent) => {
const noteEvent = toNoteEvent(event);
// TODO: generalize? this is practical for any event that is expected to be an object with
return { ...noteEvent, ...func(noteEvent) };
});
};
Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {
return this._mapNotes(({ value, scale }: NoteEvent) => {
return this._withEvent((event) => {
const interval = !isNaN(Number(intervalOrSemitones))
? Interval.fromSemitones(intervalOrSemitones as number)
: String(intervalOrSemitones);
if (typeof value === 'number') {
if (typeof event.value === 'number') {
const semitones = typeof interval === 'string' ? Interval.semitones(interval) || 0 : interval;
return { value: value + semitones };
return event.withValue(() => event.value + semitones);
}
return { value: Note.transpose(value, interval), scale };
return event.withValue(() => Note.transpose(event.value, interval));
});
};
@@ -88,26 +65,26 @@ Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {
// or even `stack(c3).superimpose(transpose.slowcat(7, 5))` or
Pattern.prototype._scaleTranspose = function (offset: number | string) {
return this._mapNotes(({ value, scale }: NoteEvent) => {
if (!scale) {
return this._withEvent((event) => {
if (!event.context.scale) {
throw new Error('can only use scaleTranspose after .scale');
}
if (typeof value !== 'string') {
if (typeof event.value !== 'string') {
throw new Error('can only use scaleTranspose with notes');
}
return { value: scaleTranspose(scale, Number(offset), value), scale };
return event.withValue(() => scaleTranspose(event.context.scale, Number(offset), event.value));
});
};
Pattern.prototype._scale = function (scale: string) {
return this._mapNotes((value) => {
let note = value.value;
return this._withEvent((event) => {
let note = event.value;
const asNumber = Number(note);
if (!isNaN(asNumber)) {
let [tonic, scaleName] = Scale.tokenize(scale);
const { pc, oct = 3 } = Note.get(tonic);
note = scaleTranspose(pc + ' ' + scaleName, asNumber, pc + oct);
}
return { ...value, value: note, scale };
return event.withValue(() => note).setContext({ ...event.context, scale });
});
};
+31 -25
View File
@@ -18,6 +18,7 @@ import {
Sampler,
getDestination
} from 'tone';
import { Piano } from '@tonejs/piano';
// what about
// https://www.charlie-roberts.com/gibberish/playground/
@@ -27,18 +28,20 @@ const Pattern = _Pattern as any;
// with this function, you can play the pattern with any tone synth
Pattern.prototype.tone = function (instrument) {
// instrument.toDestination();
return this.fmap((value: any) => {
value = typeof value !== 'object' && !Array.isArray(value) ? { value } : value;
return this._withEvent((event) => {
const onTrigger = (time, event) => {
if (instrument.constructor.name === 'PluckSynth') {
instrument.triggerAttack(value.value, time);
instrument.triggerAttack(event.value, time);
} else if (instrument.constructor.name === 'NoiseSynth') {
instrument.triggerAttackRelease(event.duration, time); // noise has no value
} else if (instrument.constructor.name === 'Piano') {
instrument.keyDown({ note: event.value, time, velocity: 0.5 });
instrument.keyUp({ note: event.value, time: time + event.duration });
} else {
instrument.triggerAttackRelease(value.value, event.duration, time);
instrument.triggerAttackRelease(event.value, event.duration, time);
}
};
return { ...value, instrument, onTrigger };
return event.setContext({ ...event.context, instrument, onTrigger });
});
};
@@ -56,6 +59,11 @@ export const pluck = (options) => new PluckSynth(options);
export const polysynth = (options) => new PolySynth(options);
export const sampler = (options) => new Sampler(options);
export const synth = (options) => new Synth(options);
export const piano = async (options = { velocities: 1 }) => {
const p = new Piano(options);
await p.load();
return p;
};
// effect helpers
export const vol = (v) => new Gain(v);
@@ -106,13 +114,12 @@ Pattern.prototype._poly = function (type: any = 'triangle') {
// this.instrument = new PolySynth(Synth, instrumentConfig).toDestination();
this.instrument = poly(type);
}
return this.fmap((value: any) => {
value = typeof value !== 'object' && !Array.isArray(value) ? { value } : value;
return this._withEvent((event: any) => {
const onTrigger = (time, event) => {
this.instrument.set(instrumentConfig);
this.instrument.triggerAttackRelease(value.value, event.duration, time);
this.instrument.triggerAttackRelease(event.value, event.duration, time);
};
return { ...value, instrumentConfig, onTrigger };
return event.setContext({ ...event.context, instrumentConfig, onTrigger });
});
};
@@ -139,8 +146,7 @@ const getTrigger = (getChain: any, value: any) => (time: number, event: any) =>
};
Pattern.prototype._synth = function (type: any = 'triangle') {
return this.fmap((value: any) => {
value = typeof value !== 'object' && !Array.isArray(value) ? { value } : value;
return this._withEvent((event: any) => {
const instrumentConfig: any = {
oscillator: { type },
envelope: { attack: 0.01, decay: 0.01, sustain: 0.6, release: 0.01 },
@@ -150,39 +156,39 @@ Pattern.prototype._synth = function (type: any = 'triangle') {
instrument.set(instrumentConfig);
return instrument;
};
const onTrigger = getTrigger(() => getInstrument().toDestination(), value.value);
return { ...value, getInstrument, instrumentConfig, onTrigger };
const onTrigger = getTrigger(() => getInstrument().toDestination(), event.value);
return event.setContext({ ...event.context, getInstrument, instrumentConfig, onTrigger });
});
};
Pattern.prototype.adsr = function (attack = 0.01, decay = 0.01, sustain = 0.6, release = 0.01) {
return this.fmap((value: any) => {
if (!value?.getInstrument) {
return this._withEvent((event: any) => {
if (!event.context.getInstrument) {
throw new Error('cannot chain adsr: need instrument first (like synth)');
}
const instrumentConfig = { ...value.instrumentConfig, envelope: { attack, decay, sustain, release } };
const instrumentConfig = { ...event.context.instrumentConfig, envelope: { attack, decay, sustain, release } };
const getInstrument = () => {
const instrument = value.getInstrument();
const instrument = event.context.getInstrument();
instrument.set(instrumentConfig);
return instrument;
};
const onTrigger = getTrigger(() => getInstrument().toDestination(), value.value);
return { ...value, getInstrument, instrumentConfig, onTrigger };
const onTrigger = getTrigger(() => getInstrument().toDestination(), event.value);
return event.setContext({ ...event.context, getInstrument, instrumentConfig, onTrigger });
});
};
Pattern.prototype.chain = function (...effectGetters: any) {
return this.fmap((value: any) => {
if (!value?.getInstrument) {
return this._withEvent((event: any) => {
if (!event.context?.getInstrument) {
throw new Error('cannot chain: need instrument first (like synth)');
}
const chain = (value.chain || []).concat(effectGetters);
const chain = (event.context.chain || []).concat(effectGetters);
const getChain = () => {
const effects = chain.map((getEffect: any) => getEffect());
return value.getInstrument().chain(...effects, getDestination());
return event.context.getInstrument().chain(...effects, getDestination());
};
const onTrigger = getTrigger(getChain, value.value);
return { ...value, getChain, onTrigger, chain };
const onTrigger = getTrigger(getChain, event.value);
return event.setContext({ ...event.context, getChain, onTrigger, chain });
});
};
+9 -2
View File
@@ -309,7 +309,6 @@ export const loungerave = `() => {
//.early("0.25 0");
}`;
export const caverave = `() => {
const delay = new FeedbackDelay(1/8, .4).chain(vol(0.5), out());
const kick = new MembraneSynth().chain(vol(.8), out());
@@ -342,7 +341,6 @@ export const caverave = `() => {
).slow(2);
}`;
export const callcenterhero = `()=>{
const bpm = 90;
const lead = polysynth().set({...osc('sine4'),...adsr(.004)}).chain(vol(0.15),out())
@@ -451,3 +449,12 @@ export const sowhatelse = `()=> {
"[2,4]/4".scale('D dorian').apply(t).tone(instr('pad')).mask("<x x x ~>/8")
).fast(6/8)
}`;
export const barryHarris = `piano()
.then(p => "0,2,[7 6]"
.add("<0 1 2 3 4 5 7 8>")
.scale('C bebop major')
.transpose("<0 1 2 1>/8")
.slow(2)
.tone(p.toDestination()))
`;
+1
View File
@@ -15,6 +15,7 @@ export declare interface Hap<T = any> {
whole: TimeSpan;
part: TimeSpan;
value: T;
context: any;
show: () => string;
}
export declare interface Pattern<T = any> {
+5 -4
View File
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import type { ToneEventCallback } from 'tone';
import * as Tone from 'tone';
import { TimeSpan } from '../../strudel.mjs';
import { TimeSpan, State } from '../../strudel.mjs';
import type { Hap } from './types';
export declare interface UseCycleProps {
onEvent: ToneEventCallback<any>;
onQuery?: (query: TimeSpan) => Hap[];
onQuery?: (state: State) => Hap[];
onSchedule?: (events: Hap[], cycle: number) => void;
onDraw?: ToneEventCallback<any>;
ready?: boolean; // if false, query will not be called on change props
@@ -22,7 +22,7 @@ function useCycle(props: UseCycleProps) {
// pull events with onQuery + count up to next cycle
const query = (cycle = activeCycle()) => {
const timespan = new TimeSpan(cycle, cycle + 1);
const events = onQuery?.(timespan) || [];
const events = onQuery?.(new State(timespan)) || [];
onSchedule?.(events, cycle);
// cancel events after current query. makes sure no old events are player for rescheduled cycles
// console.log('schedule', cycle);
@@ -47,6 +47,7 @@ function useCycle(props: UseCycleProps) {
time: event.part.begin.valueOf(),
duration: event.whole.end.sub(event.whole.begin).valueOf(),
value: event.value,
context: event.context,
};
onEvent(time, toneEvent);
Tone.Draw.schedule(() => {
+15 -10
View File
@@ -17,19 +17,22 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
const [activeCode, setActiveCode] = useState<string>();
const [log, setLog] = useState('');
const [error, setError] = useState<Error>();
const [pending, setPending] = useState(false);
const [hash, setHash] = useState('');
const [pattern, setPattern] = useState<Pattern>();
const dirty = code !== activeCode || error;
const generateHash = () => encodeURIComponent(btoa(code));
const activateCode = (_code = code) => {
!cycle.started && cycle.start();
broadcast({ type: 'start', from: id });
const activateCode = async (_code = code) => {
if (activeCode && !dirty) {
setError(undefined);
!cycle.started && cycle.start();
return;
}
try {
const parsed = evaluate(_code);
setPending(true);
const parsed = await evaluate(_code);
!cycle.started && cycle.start();
broadcast({ type: 'start', from: id });
setPattern(() => parsed.pattern);
if (autolink) {
window.location.hash = '#' + encodeURIComponent(btoa(code));
@@ -37,6 +40,7 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
setHash(generateHash());
setError(undefined);
setActiveCode(_code);
setPending(false);
} catch (err: any) {
err.message = 'evaluation error: ' + err.message;
console.warn(err);
@@ -47,7 +51,7 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
// logs events of cycle
const logCycle = (_events: any, cycle: any) => {
if (_events.length) {
//pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n'));
// pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n'));
}
};
// cycle hook to control scheduling
@@ -57,8 +61,9 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
(time, event) => {
try {
onEvent?.(event);
if (!event.value?.onTrigger) {
const note = event.value?.value || event.value;
const { onTrigger } = event.context;
if (!onTrigger) {
const note = event.value;
if (!isNote(note)) {
throw new Error('not a note: ' + note);
}
@@ -70,7 +75,6 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
/* console.warn('no instrument chosen', event);
throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */
} else {
const { onTrigger } = event.value;
onTrigger(time, event);
}
} catch (err: any) {
@@ -82,9 +86,9 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
[onEvent]
),
onQuery: useCallback(
(span) => {
(state) => {
try {
return pattern?.query(span) || [];
return pattern?.query(state) || [];
} catch (err: any) {
console.warn(err);
err.message = 'query error: ' + err.message;
@@ -146,6 +150,7 @@ function useRepl({ tune, defaultSynth, autolink = true, onEvent, onDraw }: any)
};
return {
pending,
code,
setCode,
pattern,
+8 -7
View File
@@ -19,7 +19,7 @@ Pattern.prototype.fmapNested = function (func) {
.map((event) =>
reify(func(event))
.query(span)
.map((hap) => new Hap(event.whole, event.part, hap.value))
.map((hap) => new Hap(event.whole, event.part, hap.value, hap.context))
)
.flat()
);
@@ -32,17 +32,18 @@ Pattern.prototype.voicings = function (range) {
range = ['F3', 'A4'];
}
return this.fmapNested((event) => {
lastVoicing = getVoicing(event.value?.value || event.value, lastVoicing, range);
return stack(...lastVoicing);
lastVoicing = getVoicing(event.value, lastVoicing, range);
return stack(...lastVoicing)._withContext(() => ({
locations: event.context.locations || [],
}));
});
};
Pattern.prototype.rootNotes = function (octave = 2) {
// range = ['G1', 'C3']
return this._mapNotes((value) => {
const [_, root] = value.value.match(/^([a-gA-G])[b#]?.*$/);
const bassNote = root + octave;
return { ...value, value: bassNote };
return this.fmap((value) => {
const [_, root] = value.match(/^([a-gA-G])[b#]?.*$/);
return root + octave;
});
};