monome / crow

Crow speaks and listens and remembers bits of text. A scriptable USB-CV-II machine
GNU General Public License v3.0
166 stars 34 forks source link

new timeline module #447

Closed trentgill closed 1 year ago

trentgill commented 2 years ago

this is not production ready yet!

still need to add:

should be drop-in for norns, just need to change the aliases up top into include or requires.


the new thing!

timeline is a lua library leveraging the clock and sequins libraries from norns/crow. the library is focused on a data-first representation of events in time. the intention is to enable basic (as well as complex) looping & sequencing in time, without having to write clock routines yourself.

timeline comes in 3 varieties: 1) timeline.loop for repeating patterns, timed as durations of events (in beats) 2) timeline.score for arranging events in a linear fashion, using beats as timestamps relative to the start 3) timeline.real for playing events linearly with arbitrary time, timestamped relative to the start in seconds

syntax is a plain table in pairs:

tl = timeline

-- trigger outputs 1 & 2 alternately, every beat
tl.loop{ 1, function() output[1](pulse()) end
       , 1, function() output[2](pulse()) end
       }

-- for clarity it's idiomatic to predefine your events where possible
kick = function() output[1](pulse()) end
snare = function() output[2](pulse()) end

-- now the timeline is a clear description of times & events
tl.loop{ 1, kick
       , 1, snare
       }

of course it's possible to simply have a single pair of time & event:

tl.loop{4, boom} -- calls boom() every 4 beats

launch quantization

there are 2 "pre-methods" for modifying the playback & timing of timelines:

tl.launch(n) will quantize the start of the timeline to next multiple of n beats, relative to the global clock. this makes it easy to bring in new elements in phase with each other:

tl = timeline

tl.launch(8) -- will wait until the clock reaches the next multiple of 8 beats before starting
  :loop{1/4, hihat}

note how tl.launch comes before the loop, as the function will modify the wait before the timeline begins.

if you omit tl.launch, the timeline will default to a launch quantization of 1.

playback control

you may have noticed that timelines always start as soon you execute tl.loop() etc. this makes it fast and easy to fire off repeating timelines. sometimes you don't want to start a timeline immediately though, and you're not sure when to queue it up with a launch command. here you have 2 options:

1) just store the table of time/event pairs, then pass them to tl.loop etc later

mypairs = {1, kick, 2, snare} -- just a regular lua table
timeline.loop(mypairs) -- pass the table to the looper

or a more flexible option (that carries any other modifiers with it:

2) use the queue pre-method:

tl = timeline
mysnare = tl.queue():loop{2, snare} -- we have to save the timeline into a variable
mysnare:play() -- and call the play() method on it when you want it to start

-- after some time you may decide a loop has run its course, at which point you can stop:
mysnare:stop()

note here that :stop() is a method that can be called on any timeline at any point.

panic!

sometimes you forget to save your timelines into a variable (so they can be stopped), in which case you can stop them all by calling:

timeline.cleanup()

this actually just calls clock.cleanup() so beware it will also stop any running clocks if you are using them as well.

duration & timestamp vs sync

in contrast to typical clock routines that use the clock.sync(n) function, timeline is based around durations (for loop) and timestamps (for score). it's not better, but it certainly feels different.

more

loop post-methods

loop has some special "post-methods" that can be added to the end, modifying the looping behaviour. specifically, these methods allow the loop to stop at some point in the future.

unless

this is the general method. when the loop reaches the end of the table of event pairs, it will check the condition in unless and if it returns true, the timeline will stop.

tl.loop{1, kick}:unless(false) -- it'll run forever!

the condition argument to unless can either be a boolean value, or a function that returns a boolean. if a function, it will be re-evaluated each repetition through the timeline.

times

sometimes you want a pattern or sequence to run a specific number of times. you could write your own function to do it with unless, but we did it for you and it's called times. simply attach the number of repetitions to a pattern:

tl.loop{4, random_note}:times(64) -- play 64 random notes, one note every 4 beats

(nb: tl:once() has been removed. trying to focus the API and reduce overhead to learning the library. it could easily be added back in future if deemed desirable. for now use tl.:times(1)).

score

very similar to timeline.loop, score is still a table of pairs, but the timing values have a different meaning:

timeline.score{
    0, intro
  , 32, verse
  , 64, chorus
  , 128, outro
  }

see how the numbers on the left are relative to 0. thus verse is called 32 beats after intro, and chorus is another 32 beats after verse.

this can make it easier to write certain kinds of rhythms compared to loop. example

real

ie "realtime". very similar to score, the only difference here is that timing is described in seconds instead of beats. this can be super useful for things like strumming notes in a chord, or other examples:

timeline.real{ 0, note_1, 0.1, note_2, 0.25, note_3 }

sequins

now we get to the good stuff! pretty much any value in a timeline is able to be a sequins element. this includes timing data, functions, method parameters, and more (no really).

lots of examples

just copy them from my notes

function table

often your actions will want to accept data, rather than just do the same thing every time. of course you can wrap any function in an anonymous function inline, but that ends up baking the musical data in with the functionality. timeline is all about avoiding this intermingling of data & code. instead we use a table-syntax for passing parameters to a function whenever it's called. the parameters can themselves be sequins, which will be called for you each time:

function note(n, v) ii.jf.play_note(n/12, v) end -- a helper function to keep things clear

melody = tl.loop{ 1, {note, s{0,2,4,6,8,10}, 2}}

note how the timeline is all data. there's quite a few braces, but with some syntax highlighting it should be relatively manageable.

a bunch more here i'm tired

swing example

tl.loop{ s{0.55, 0.45}, hats }

strum example

tl.real{ 0.1, {ii.wsyn.play_note, s{0,4,7,11,16}, 2}} -- strums an arpeggio on w/syn

reset

both timeline.score & timeline.timed can be reset to the start by using the keyword 'reset' as an action. this converts this one-shot style timelines into loops.

timeline.score{
    0, intro
  , 32, verse
  , 64, 'reset' -- will jump to beat 0
  }

note here that you can use a function that returns the string reset which allows for probabilistic repetition. something like: function() if math.random() then 'reset' end end

iter

sometimes you might want your actions to change as the timeline plays. you can use the timeline.iter() method to return the number of the current iteration. this value is 0 if the timeline is queued and hasn't started, and/or while in the launch-quantization phase. note you'll need to refer to the timeline name in your action function.

mytl = timeline.loop{ 1, kick
                    , 2, snare}
-- wait some time, then
print(mytl:iter()) --> 13 (or some other count of loops)

-- note how we have to refer to "itl" before it exists
function itsybitsy() print('iteration = '..itl:iter()) end
itl = timeline.loop{ s{1,2}, itsybitsy }

hotswap

similar to the sequins:settable(new_seq) method, timelines can also be swapped out on the fly, preserving the current timing & phase. hotswapping is particularly useful in a live context, or any time you want to gradually modify a timeline, without stopping the groove.

note: when hot-swapping timelines, you can only change the table of times & events. specifically not the 'mode' of the timeline (ie loop, score, real).

TODO additionally, any nested sequins in the timeline will also be hotswapped, so you can use this feature to modify the action data as well as the timing information.

TODO nested timelines will also be preserved (think a :score triggering a set of :loop s)

dndrks commented 1 year ago

dang, @trentgill, this system is so powerful + such a blast to use!!

ran through the docs, only found two wonky zones with the firmware you linked:

:times edge-case?

situation: when i'm using tl.queue, i'd like to also leverage :times, sorta the way one might want to launch a non-looping clip of notes in Ableton Live on-demand, eg:

tl = timeline
kick = function() output[1](pulse()) end
mykick = tl.queue():loop{2, kick}:times(3)

inital success: executing mykick:play() will send three pulses through output 1 and stop, as expected. expectation: since mykick has terminated, subsequent mykick:play() calls should restart the set of three pulses results: after the initial :times has been performed, i can't seem to retrigger the condition for mykick. it just continues to loop on subsequent calls unless i re-run the script. what i tried:

note: i can get the desired results with timed and score, which each self-terminate and allow subsequent :play() calls, eg:

tl = timeline
kick = function() output[1](pulse()) end
mysong = tl.queue():score{
  0, kick
, 2, kick
, 4, kick
}

and then use mysong:play()

'reset' times out CPU

situation: i'd like to loop a .score, eg:

tl = timeline
kick = function() output[1](pulse()) end
snare = function() output[2](pulse()) end

tl.score{
  0, kick
, s{2,1}, snare
, 2, kick
, 3, snare
, 4, 'reset'
}

results: the first 4 beats execute, but then when 'reset' is reached, both the outputs pulse and druid returns:

CPU timed out.
error: user code timeout exceeded
trentgill commented 1 year ago

Thanks @dndrks ! I'll take a look at these and certain I can find a solution for both issues.

Glad you're enjoying the system! I'm super excited for it if for no other reason than it's a bit cleaner for really simple looping things. When i've been testing CPU, it's great to just type timeline.loop{1/4, function() print(cputime()) end} --- really quick way of running a function on a timer in a single step!

trentgill commented 1 year ago

2 issues i'm seeing:

functions seem to be captured

function bang() print 'a' end
timeline.loop{ 1, bang }
--> prints 'a' repeating
function bang() print 'b' end
--> continues printing 'a'
-- expect printing 'b'

i guess we're capturing the function as a closure? seems strange as i assumed it should just reference the named global function

edit after some research, the issue is that the timeline table captures the function by value, not reference. thus when you redefine bang the original version still exists inside the timeline table, and a new global bang is created. i'm not sure if it's possible to make the function mutable like i was expecting, and even if it's possible, i doubt it's possible without some additional syntax.

furthermore, i could likely add a check if the function provided in the table is a global, and thus allow redirection -- however, this breaks the general scoping & val/ref dichotomy of lua. thus it could create confusion as it would be "incorrect" in the eyes of a regular lua user.

not sure if this is a deal-breaker -- there's many other ways to make the params of a timeline function change (could be via a sequins with next=0 and using the select method to choose value. alternatively, if one wants to modify an ASL, the dyn system could be used to change the behaviour in a stateful way).

for now, a good solution is using my_timeline:hotswap method and just redefining the table entries in full.

long-running complex timelines will eventually stop

i'm seeing this when using a function-table with a sequins inside:

timeline.loop{ 1, {function() output[1](oscillate(v)) end, sequins{1,2,3,4}}}

runs for a while (maybe a couple of minutes?) then simply stops. the system keeps running, but re-executing the timeline doesn't do anything.

it could be related to the "beat-duration" calculation creating some invalid time. or perhaps it's running out of memory, or having some other low-level issue. need to do some debugging to find out why. and would be good to try and minimize the test case (not sure if it's linked to the function-table, or the sequins, or both).

trentgill commented 1 year ago

@dndrks tl.loop{}:times(n) now restarts the times count when the timeline plays to completion. note that if you call :stop() then :play() the times will not be reset (so acts more like a pause). only when all repetitions are complete will it reset.

if you'd prefer :play() to reset the times count whenever it is called that's possible too. perhaps that would make more sense? is there any reason one would want to pause a timeline and restart it without resetting the count? i just went with the first solution that came to mind, but perhaps this alteration makes more sense?

(this is currently untested as i'm waiting for my current unit to crash to track down an issue posted above. download is here https://github.com/monome/crow/suites/11196281318/artifacts/572375412)


tl.score{ ... , 4, 'reset'} should now work correctly now too. i believe tl.timed with a 'reset' key should work fine as it was. (download is same as above)

? any thoughts on whether tl.loop should allow a 'reset' command? this would specifically be useful for probabilistic behaviours (eg a table pair of {2, function() if math.random() > 0.5 then return 'reset' end}). one could then have a the main part of their loop first, and the later part only execute sometimes (or based on a stateful predicate).

could this get too confusing? i think the difficulty is that :loop works differently in that it calls the action, then waits an amount of time, whereas the :timed and :score methods wait first, then call the action. so the reset action would still wait for the beat-sleep before restarting...


i'm looking at the code and wondering if timeline.timed is a poor name? for one it's very similar to the :times modifier, but also i'm not sure it fully communicates the use-case. loop is obviously for making patterns in time. score speaks directly to beat/bar numbers & events over the course of time. feels like timed could have a better metaphor that speaks more directly to its use-case?

any thoughts here are welcome (and annoyingly short one-word names [<7 chars] preferred)!

dndrks commented 1 year ago

hihi! hope you had a nice weekend :)

(this is currently untested as i'm waiting for my current unit to crash to track down an issue posted above. download is here https://github.com/monome/crow/suites/11196281318/artifacts/572375412)

just a heads up, this version didn't seem to have the fixes in it! but, can totally share thoughts re: your q's otherwise!

edit: ah! figured out how to snag the files from dev4.0 and clock_overhaul -- but each still exhibits the same behavior.

tl.loop

everything you wrote makes sense to me, so just to summarize:

:play() on a not-yet-started loop will play from the start and iterate through the provided n count. once the loop has iterated according to the counter, a subsequent :play() message will reset the count and allow another run which will terminate once the n count is reached.

:stop() on a running loop will pause the execution without resetting the n count, which allows expressions like "i only want 16 of this event to happen total, but i want to selectively gate the iteration of those 16 occurrences". feels very tape-like, love it.

i'd expect a 'reset' command to follow the above formatting, so more like :reset(). if i have a :times(n) modifier appended, i'd expect this to reset the n count on-demand but otherwise not affect playing status. in a case like this:

tl = timeline

kick = function() output[1](pulse()) end
snare = function() output[2](pulse()) end
mypairs = {1, kick, 2, snare, 0.5, snare, 0.3, kick, 0.2, snare}
timeline.loop(mypairs)

... where my loop will start with 1,kick, i'd expect a reset command (regardless of formatting) to swing back to the 1,kick step from wherever it is in the loop.


bug?: timeline.loop with non-integer values

getting some weird behavior with this example script and the firmware you posted:

tl = timeline

kick = function() output[1](pulse()) end
snare = function() output[2](pulse()) end

mypairs = {1, kick, 2, snare, 0.5, snare, 0.3, kick, 0.2, snare}
timeline.loop(mypairs)

pulses run for a few minutes (around 4 mins), but then they stall out with no errors sent back to druid. after another few minutes of silence, a couple of pulse cycles will occasionally come through, but nothing seemingly consistent.

if i try to rerun the script, i don't get consistent behavior until i power-cycle.


naming

re: timeline.timed, i don't think it's terrible, but for the sake of the game:

hope that all helps! lmk if i can test with any other builds <3

trentgill commented 1 year ago

thanks @dndrks!

regarding the reset idea, i wonder if we could outline the different use-cases for :play and :stop? i’m wondering if we can keep the api simpler (ie no :reset) by altering what those main 2 functions do?

if :play always restarted the whole timeline would that compromise anything? is there any real purpose for pausing and resuming a timeline? when :play is sent to an already running timeline, does it wait for the launch-quantization sync before happening? if so, do subsequent events that would occur before the launch-quant is finished need to keep running until that point (ala ableton clips)?

:stop could also just be the same as reaching the end of the timeline (resetting everything). this would remove the idea of a paused timeline which i’m probably in favour of.

not that ableton is the be-all but i cant help but think of timelines as programmatic clips. makes me think a play & stop button is all we need.

somewhat related, it would be a good idea to test whether a timeline whose actions are functions that launch other timelines works. would allow similar syntax in the small (ie drum & melody patterns) and large (ie arrangement).

—-

regarding the timelines stopping, this is the same bug as i mentioned above (long running timelines stop). it’s caused by issues in the clock library which i believe i’ve fixed in the clock-direct-sync PR. testing now, and will post a link once confimed.

—-

apologies that the linked zip wasnt right. i wish it were easier to point at the correct PR artifact on github!

that said, i’m also about to test these to confirm my changes solved both problems.

—-

re naming: i like timeline.real!

also considering timeline.sleep — ie. a sequence of events separated by clock.sleep. this might be more understandable to folks who already know the clock library, but is it less so for folks who are new to the platform?

i imagine newcomers being inclined to try timeline before learning about clocks (the syntax is simpler, and more directly speaks to musical usage).

On 27 Feb 2023, at 07:49, dan derks @.***> wrote:

 hihi! hope you had a nice weekend :)

(this is currently untested as i'm waiting for my current unit to crash to track down an issue posted above. download is here https://github.com/monome/crow/suites/11196281318/artifacts/572375412)

just a heads up, this version didn't seem to have the fixes in it! but, can totally share thoughts re: your q's otherwise!

tl.loop

everything you wrote makes sense to me, so just to summarize:

:play() on a not-yet-started loop will play from the start and iterate through the provided n count. once the loop has iterated according to the counter, a subsequent :play() message will reset the count and allow another run which will terminate once the n count is reached.

:stop() on a running loop will pause the execution without resetting the n count, which allows expressions like "i only want 16 of this event to happen total, but i want to selectively gate the iteration of those 16 occurrences". feels very tape-like, love it.

i'd expect a 'reset' command to follow the above formatting, so more like :reset(). if i have a :times(n) modifier appended, i'd expect this to reset the n count on-demand but otherwise not affect playing status. in a case like this:

tl = timeline

kick = function() output[1](pulse()) end snare = function() output[2](pulse()) end mypairs = {1, kick, 2, snare, 0.5, snare, 0.3, kick, 0.2, snare} timeline.loop(mypairs) ... where my loop will start with 1,kick, i'd expect a reset command (regardless of formatting) to swing back to the 1,kick step from wherever it is in the loop.

bug?: timeline.loop with non-integer values

getting some weird behavior with this example script and the firmware you posted:

tl = timeline

kick = function() output[1](pulse()) end snare = function() output[2](pulse()) end

mypairs = {1, kick, 2, snare, 0.5, snare, 0.3, kick, 0.2, snare} timeline.loop(mypairs) pulses run for a few minutes (around 4 mins), but then they stall out with no errors sent back to druid. after another few minutes of silence, a couple of pulse cycles will occasionally come through, but nothing seemingly consistent.

re: timeline.timed naming, i don't think it's terrible, but for the sake of the game:

timeline.sec (as in seconds) timeline.real (as in 'real' time vs. 'score' time) timeline.exact or timeline.direct (to differentiate between the relative timing of score) timeline.iter or timeline.auto or timeline.fixed (to suggest a process that operates without regard for other senses of time) hope that all helps! lmk if i can test with any other downloads <3

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you were mentioned.

dndrks commented 1 year ago

ayyy happy to help!

:play + :stop

totally in favor of refining these two and avoiding :reset altogether.

summarizing my understanding from your great rundown...

if a timeline isn't running

if a timeline is running without a times

if a timeline is running with a times

somewhat related, it would be a good idea to test whether a timeline whose actions are functions that launch other timelines works. would allow similar syntax in the small (ie drum & melody patterns) and large (ie arrangement).

thiiiiis is cool, yeah! will test with the next download!


naming

timeline.sleep sorta muddies things for me -- eg. it's not legible whether the number represents the sleep before the listed event or the sleep after. but also, sleep in clock describes how much time should pass from its execution; whereas if timeline.sleep is a time-based version of timeline.score, then we're describing absolute timestamps (vs. waiting periods).

also, the idea of a score is kinda primary / above the specifics of how time within that score is measured, you know? if a score is just a collection of actions along a timeline (independent of how time moves), then maybe timeline.score could just own both beats/seconds, depending on what the author specifies? eg.

-- musical timestamps:
timeline.score{
    {0, beat, intro}
  , {32, beat, verse}
  , {64, beat, chorus}
  , {128, beat, outro}
  }

-- 'real' timestamps:
timeline.score{
    {0, sec, intro}
  , {6, sec, verse}
  , {19, sec, chorus}
  , {31, sec, outro}
  }

maybe with an assumption that if no argument is provided, it's always beats?

trentgill commented 1 year ago

@dndrks i really like the idea of having timing be handled universally (beats vs seconds), and even built that in my own fennel version of timeline. Nice thing about that language is there's special syntax for strings, so i used base numbers to represent seconds (0.0, 3.0), and strings to represent beats (:2, :1/4). in lua we could do the same but it probably looks weird to have '1/4' and to know that if it's a string it's a 'beats' time.

i don't want to add the extra argument because it breaks how the table is parsed (and making it optional would make the parsing way more complicated, or maybe even impossible owing to the possibility that everything can be a sequins).

i think we should stick with a separate method for now simply because it avoids the need for rote knowledge (eg. "i want a beats time. should that be a number or a string?").

one option would be to use ~ as a prefix for beats time: ~2, ~1/4 but again suffers from the rote knowledge thing. it also looks like "at approximately 2 seconds".

EDIT: actually i don't think i can make it work directly right now. if both times and beats are acceptable, one would assume they can be interspersed, but timeline requires all events to be listed in order of execution which is not always apparent when switching back and forth (ie. their order depends on the tempo).

i propose we stick with what we have, and call it timeline.real. this method is typically going to be used for a non-score-like sequence of events (i can't imagine scoring a song in terms of seconds), so feels best to treat it independently.


re the :play :stop discussion:

the only thing missing is: if a timeline is running with or without a times:

^^ in the :play above, is it ok if events are cancelled during the launch-quantization period? at present there's no simple way to start the launch-quantization counter, but continue producing events until that moment. it's definitely possible, but could be quite convoluted i fear. if we can push this to 4.1 (or just not do it) i'd be happy to just get the base functionality of :play and :stop built as discussed above.

dndrks commented 1 year ago

i propose we stick with what we have, and call it timeline.real

into iiiiiit!!

^^ in the :play above, is it ok if events are cancelled during the launch-quantization period?

@trentgill i think that's fine, as it generally opens up a lot of great functionality even if there might be use for the 'keep going until the new launch' approach. this is maybe where the ableton metaphor traps things -- you're not building a clip launcher whose primary use is a ton of manual beat-juggling, you know? crow will most likely receive a :play trigger from a well-synced source, so i can imagine someone just opting for a shorter launch-quantization period (or none at all) to retain the events leading up to the timeline restart :)