tidalcycles / strudel

Web-based environment for live coding algorithmic patterns, incorporating a faithful port of TidalCycles to JavaScript
https://strudel.cc/
GNU Affero General Public License v3.0
671 stars 115 forks source link

Treat patterns as functions? #463

Open yaxu opened 1 year ago

yaxu commented 1 year ago

I shied away from this in #390, but maybe it's possible.

The problem is that currently fast(2).n(3) works as a shorthand for fast(2).set.in(n(3)), but n(3).fast(2) doesn't work as a shorthand for set.in.n(3).fast(2).

This is because n(3) is a pattern, when we want it to be shorthand for the function set.n(3), depending on the context.

We can't just treat a pattern as a function when we expect functions, because we need to support patterns of functions.

Worse still, we can't even catch this as an error until we're already querying the pattern at render-time, causing awkward silences.

Possible solutions/trade-offs:

  1. Make things consistent, by removing support for .x() as a shorthand for set.x() completely
  2. Make strudel more context aware, by implementing some kind of type system
  3. Move to a different language/javascript extension that has built-in type system / type inference

I think 2 is probably the nicest, if it's reasonably possible. The problem is specific to controls, so we 'just' need a way of marking out a control pattern as a pattern that contains values, and not functions. Then we know that when it's being applied as a function, it should be applied via a .set operation rather than as a pattern of functions.

That's fairly straightforward, but we'd also want to support expressions like n(2).fast(2). This would need to have a double life both as a pattern, and the equivalent of x => x.set(n(2)).fast(2).

Maybe we want to support both interpretations of the same expression, e.g.:

var x = n(2).fast(2)
stack(
  s("bd sd").set(x),
  s("gabba(3,8)").every(3, x)
)

It's a bit surprising in the above that "bd sd" isn't sped up, but "gabba(3,8)" is, but still I think that makes the most sense overall.

I got somewhere with this in #368 but got stuck and started again with #390 which solves functional composition but not this issue.

felixroos commented 1 year ago

I think it would be the most intuitive if control methods behaved the same way as non control methods, so in an ideal world, both of these would work:

s("bd").every(3, fast(2))
s("bd").every(3, s("sd"))

when it comes to multiple functions, the order of methods shouldn't matter, making these the same:

s("bd").every(3, fast(2).s("sd"))
s("bd").every(3, s("sd").fast(2))

then there are 2 behaviors to apply the transformation:

(a) seems more useful / logical to me, but there could be additional syntax to allow (b). In your example, .set is doing (b), while the other pattern is doing (a). In case I don't miss something here, I think set is something the users should not care about..

for the 3 options:

  1. would be a massive drag in terms of readability / writability
  2. sounds most sane to me, although I am not sure how that would look like
  3. in the js world, the obvious choice would be typescript, though type inference is probably not as powerful as you'd want (https://www.typescriptlang.org/docs/handbook/type-inference.html). the only other option would be to move logic to a whole different language like rust, but that would essentially be a rewrite

There might be a fourth option, which is using the transpiler to prepend x=>x. to all modifiers:

s("bd").every(3, fast(2).s("sd"))
// is transpiled to
s("bd").every(3, x=>x.fast(2).s("sd"))

the implementation of that would be rather trivial, the only challenge would be to find a reliable way to decide which ast nodes are modified. It might make sense to declare them in the register call:

register(['firstOf', 'every'], function (n, func, pat) {
/* implementation */
}, {
  modifiers: [1] // <-- param with index 1 is a modifier function
})

With that info, the transpiler can target all the correct params and transpile them if they are not already a function. Of course, library users would still need to pass functions. This transpilation step would implement behavior (a), while (b) could still be made possible with some syntax, like:

s("bd").every(3, xxx.s("sd").fast(2))

maybe that is what set. is for? It feels like we already talked about most of this stuff, I hope we we're running in spirals and not in circles :)

yaxu commented 1 year ago

I think it would be the most intuitive if control methods behaved the same way as non control methods, so in an ideal world, both of these would work:

s("bd").every(3, fast(2))
s("bd").every(3, s("sd"))

I don't think we can say s("bd").every(3, s("sd")) is intuitive, as it's using s() both to make a pattern and a function. But I think if we can make it work consistently, it'd be possible to learn what's going on.

when it comes to multiple functions, the order of methods shouldn't matter, making these the same:

s("bd").every(3, fast(2).s("sd"))
s("bd").every(3, s("sd").fast(2))

Agreed it doesn't make much sense for one to be a valid expression and one not. But of course the order of methods often does matter, e.g. in

s("bd").every(3, fast(2).s("sd cp"))
s("bd").every(3, s("sd cp").fast(2))

then there are 2 behaviors to apply the transformation:

  • (a) to the outer pattern or

  • (b) only to the inner pattern

(a) seems more useful / logical to me, but there could be additional syntax to allow (b). In your example, .set is doing (b), while the other pattern is doing (a). In case I don't miss something here, I think set is something the users should not care about..

I think they should care, as set isn't just a thing for use in this case, but stands for a member of the operator family along with add, div, mul, keep, etc. I think it's good to understand that the default method for combining patterns as set.in, along the way of learning how to use alternatives (add.squeeze, trig.out, etc).

I think it's nice that set.in is the default, but making it the default is the source of all this confusion, as it leaves us with bare controls like s('bd') being in a quantum state of both pattern and function.

for the 3 options:

  1. would be a massive drag in terms of readability / writability
  2. sounds most sane to me, although I am not sure how that would look like
  3. in the js world, the obvious choice would be typescript, though type inference is probably not as powerful as you'd want (https://www.typescriptlang.org/docs/handbook/type-inference.html). the only other option would be to move logic to a whole different language like rust, but that would essentially be a rewrite

Yes hopefully 2) doesn't get too complicated.

There might be a fourth option, which is using the transpiler to prepend x=>x. to all modifiers:

s("bd").every(3, fast(2).s("sd"))
// is transpiled to
s("bd").every(3, x=>x.fast(2).s("sd"))

I guess that would turn

s("bd").every(3, slow(3, sequence(fast(2), n(3), iter(3))))
// into
s("bd").every(3, x => x.slow(3, sequence(fast(2), n(3), iter(3)))

Which would break. We want that to stay as a pattern of functions, with the n(3) treated like set.in.n(3)

This behavior of controls making either patterns or functions depending on the context, introduces ambiguity to everything, that I think we can only really resolve with types.

felixroos commented 1 year ago

There might be a fourth option, which is using the transpiler to prepend x=>x. to all modifiers:

s("bd").every(3, fast(2).s("sd"))
// is transpiled to
s("bd").every(3, x=>x.fast(2).s("sd"))

I guess that would turn

s("bd").every(3, slow(3, sequence(fast(2), n(3), iter(3))))
// into
s("bd").every(3, x => x.slow(3, sequence(fast(2), n(3), iter(3)))

Which would break. We want that to stay as a pattern of functions, with the n(3) treated like set.in.n(3)

This behavior of controls making either patterns or functions depending on the context, introduces ambiguity to everything, that I think we can only really resolve with types.

ah yes i was (maybe once again) forgetting about that important detail...

So in terms of implementation, it would be desirable to have every only accept a pattern of functions. In the example sequence(fast(2), n(3).fast(2), iter(3)), the n(3).fast(2) is not a pattern of functions, which is a problem, because every gets passed just the value { n: 3 }. Like we already found out, that value could be applied to the outer pattern via set, but the the fast has already collapsed and so we cannot use it to modify the outer pattern.

If the transpiler should handle this, it might need to convert the example to

sequence(fast(2), x=>x.n(3).fast(2), iter(3))

This could potentially work by detecting calls to control functions. We already have a list of all controls.. so with some clever ast climbing we could do these rewrites:

x.every(3, n(1)) // x.every(3, x=>x.n(1))
x.every(3, cat(n(1), n(2))) // x.every(3, cat(x=>x.n(1), x=>x.n(2)))
x.every(3, fast(2).n(3)) // already works without transpilation?
x.every(3, n(3).fast(2)) // x.every(3, x=>x.n(3).fast(2))

So it seems possible to implement, the questions would be

  1. are there still problems with this approach?
  2. is this too hacky?
  3. do we even want that syntax? (using controls as both patterns and functions without extra syntax)

I'd say yes to 3, because it's just shorter and less prone for errors and misunderstandings. When writing everything with dot syntax, it does not make a syntactical difference if you're calling .n or .fast, which is nice and easy. In the same fashion, it shouldn't make a difference if you're calling cat(fast(2), iter(4)) or cat(n(3), s("bd")). Maybe this goes too far but I think alignments could one day also be consistent across controls and non controls:

s("bd sd").fast.squeeze("2 4")
s("bd sd").n.squeeze("0 1")

Having a prefix like set. required only for controls feels counter intuitive, and opens the question why this does not work:

s("bd").every(3, set.fast(2))

These are just design ideas, and the implementation of this is probably really hard with plain JS... In any case of this turning out, adding typescript to the project might make sense to not go crazy.

And one last crazy thought before I go: Assuming we want 3, and we do not want to use the transpiler, couldn't control functions maybe return functions instead? so you could write s("bd", n(3)). Of course there are many open questions to this, but if this would be the case, the whole dilemma could be solved

yaxu commented 1 year ago

So in terms of implementation, it would be desirable to have every only accept a pattern of functions. In the example sequence(fast(2), n(3).fast(2), iter(3)), the n(3).fast(2) is not a pattern of functions, which is a problem, because every gets passed just the value { n: 3 }. Like we already found out, that value could be applied to the outer pattern via set, but the the fast has already collapsed and so we cannot use it to modify the outer pattern.

To make sure there isn't an understanding, set.n(3).fast(2) composes to x => x.set.n(3).fast(2), so fast(2) does modify the outer pattern.

If the transpiler should handle this, it might need to convert the example to

sequence(fast(2), x=>x.n(3).fast(2), iter(3))

This could potentially work by detecting calls to control functions. ... the questions would be

1. are there still problems with this approach?
2. is this too hacky?
3. do we even want that syntax? (using controls as both patterns and functions without extra syntax)

I'd say yes to 3, because it's just shorter and less prone for errors and misunderstandings.

I think it's hard to really introspect about that, because we already understand it.

Really s("bd").n("3 4") is underspecified and ripe for misunderstanding, with a control pattern being called as a method on another control pattern as if it was a function. Introducing this confusion for the sake of brevity is a trade-off.

... I think alignments could one day also be consistent across controls and non controls:

s("bd sd").fast.squeeze("2 4")
s("bd sd").n.squeeze("0 1")

s("bd sd").every.squeeze(3).squeeze(rev, iter 4) maybe..

But n.squeeze() doesn't make sense to me as n("0 1") returns a pattern rather than a function. s("bd sd").squeeze.n("0 1") works though as shorthand for s("bd sd").squeeze.set(n("0 1"))

Having a prefix like set. required only for controls feels counter intuitive, and opens the question why this does not work:

s("bd").every(3, set.fast(2))

It doesn't work because set.x is shorthand for set(x) (or to be more precise, set.in(x)), and set(fast(x)) would try to replace the values in s("bd") with fast(2).

And one last crazy thought before I go: Assuming we want 3, and we do not want to use the transpiler, couldn't control functions maybe return functions instead? so you could write s("bd", n(3)). Of course there are many open questions to this, but if this would be the case, the whole dilemma could be solved

Yes this could go somewhere, if we make all controls into functions, and make it properly consistent. So a control never makes a pattern of objects, but instead returns a function that adds a pattern of objects to another pattern.

If s("bd sd") is a function and n("3 4") is a function, then so is s("bd sd").n("3 4")

This could then be resolved by having a top level pattern assigner, like in tidal.

d1.s("bd sd").n("3 4")

They'd then automatically compose, so n("3 4").fast(2) would speed up the outer pattern.

To speed up only the inner pattern we'd need an extra trick.

felixroos commented 1 year ago

Really s("bd").n("3 4") is underspecified and ripe for misunderstanding, with a control pattern being called as a method on another control pattern as if it was a function. Introducing this confusion for the sake of brevity is a trade-off.

I am not really going for brevity, more for API consistency.. brevity is just a side effect of a logical and simple API surface. It would be unconsistent to support a specific syntax for some functions and not (or different) for others. So maybe it should be a function then? not sure if this is now an example of form follows function or function follows form :-P

But n.squeeze() doesn't make sense to me as n("0 1") returns a pattern rather than a function. s("bd sd").squeeze.n("0 1") works though as shorthand for s("bd sd").squeeze.set(n("0 1"))

Hm but then it's inconsistent that s("bd sd").squeeze.n("0 1") uses how.what (squeeze.n), and sth like note("40 42").add.squeeze("0 12") uses what.how (add.squeeze). It should either everything be the same order, or both orders should work in all cases.

Of course it is a whole different question of how this can be implemented and maybe it is not possible.. But when thinking about what would be the most elegant API, it is useful to only be guided by what's the most consistent, simple and valid piece of JS to express a certain thing.

To speed up only the inner pattern we'd need an extra trick.

it would already work by pulling it in

s("bd").every(3, 
  n("3 4".fast(2))
)

But maybe there could be some kind of prefix to support that too

yaxu commented 1 year ago

Really s("bd").n("3 4") is underspecified and ripe for misunderstanding, with a control pattern being called as a method on another control pattern as if it was a function. Introducing this confusion for the sake of brevity is a trade-off.

I am not really going for brevity, more for API consistency..

s("bd").n("3 4") is inconsistent because s and n are in theory the same kind of thing, but are either patterns or functions depending on the context.

It's about brevity because s("bd").n("3 4 ") is shorthand for s("bd").set.in(n("3 4")) or set.in(s("bd"), n("3 4")).

Hm but then it's inconsistent that s("bd sd").squeeze.n("0 1") uses how.what (squeeze.n)

No that's just how to align s("bd sd") and n("0 1"). The what operation (add, set, mul or whatever) has been missed off, so the default set is used.. squeeze on its own is shorthand for set.squeeze.

Remember in Tidal this is done with infix operators. ||< is keep.squeeze, +| is add.out, |> is set.in etc. The patterns either side are then the two arguments to the infix operator.

We can't have custom infix operators in js, so we have to spell it out. This is a bit annoying to do every time, so we have some sugar so that the most common operator set.in is the default, in such a way that squeeze is short for set.squeeze, add is short for add.in, etc. This makes it look like the . operator is doing the set.in, but that's not really the case..

If we really wanted to prioritise consistency over brevity we would get rid of the default operator and force the end-user to spell out the how.what every time.

yaxu commented 1 year ago

Converting all controls to standard 'registered' functions does seem to work though..

https://strudel.tidalcycles.org/?Mh-42Slq_Bru

It does need a top level thingie to start things off, but this can be a step towards https://github.com/tidalcycles/strudel/issues/34 which would also need named patterns (maybe via tidal-style d1, d2 etc).

It also needs to support alternative operations besides implicit 'set', and for register to support alternative alignments (which would benefit all the other methods).

yaxu commented 1 year ago

The problem with controls-as-functions is that you can't combine them like patterns any more e.g. this doesn't work:

stack(s("bd sd").n("3"),
      s("cp(3,8)")
     )

nor this:

play.stack(s("bd sd").n("3"),
      s("cp(3,8)")
     )

but this does:

stack(play.s("bd sd").n("3"),
      play.s("cp(3,8)")
     )

and this:

play.s("bd sd").n("3").stack(play.s("cp(3,8)"))

... so either controls are functions, and we have to always be explicit when we want to use them like patterns, or they're patterns, and we have to be explicit when we want to use them like functions..

yaxu commented 1 year ago

. the only other option would be to move logic to a whole different language like rust, but that would essentially be a rewrite

Rust would be fun, but we already have a Haskell implementation that can run in the browser.. :)

felixroos commented 1 year ago

so either controls are functions, and we have to always be explicit when we want to use them like patterns, or they're patterns, and we have to be explicit when we want to use them like functions

thats plausible.. good to see the other side (controls as functions). seems more unusable than the other way around (controls as patterns). I am starting to wonder if x=>x. could just be the default for functions :P

felixroos commented 1 year ago

Rust would be fun, but we already have a Haskell implementation that can run in the browser.. :)

true.. although I think rust is much more interoperable than haskell. but better not open too much cans of worms for now