Open yaxu opened 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:
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 :)
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 thinkset
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:
- would be a massive drag in terms of readability / writability
- sounds most sane to me, although I am not sure how that would look like
- 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.
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 likeset.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
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
So in terms of implementation, it would be desirable to have
every
only accept a pattern of functions. In the examplesequence(fast(2), n(3).fast(2), iter(3))
, then(3).fast(2)
is not a pattern of functions, which is a problem, becauseevery
gets passed just the value{ n: 3 }
. Like we already found out, that value could be applied to the outer pattern viaset
, but the thefast
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.
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
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.
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).
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..
. 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.. :)
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
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
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 forfast(2).set.in(n(3))
, butn(3).fast(2)
doesn't work as a shorthand forset.in.n(3).fast(2)
.This is because
n(3)
is a pattern, when we want it to be shorthand for the functionset.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:
.x()
as a shorthand forset.x()
completelyI 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 ofx => x.set(n(2)).fast(2)
.Maybe we want to support both interpretations of the same expression, e.g.:
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.