Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Standardise representation of values #60

Closed
yaxu opened this issue Apr 13, 2022 · 25 comments
Closed

Standardise representation of values #60

yaxu opened this issue Apr 13, 2022 · 25 comments
Labels
compatibility Mainline Tidal Compatibility

Comments

@yaxu
Copy link
Member

yaxu commented Apr 13, 2022

Bring in unused value.mjs, and put all data in the value as an object, leaving context for things independent of value, like source code position.

@felixroos
Copy link
Collaborator

related #63

@felixroos
Copy link
Collaborator

felixroos commented Apr 18, 2022

I'll try to summarize the problem with hap values.

  • I'll add numbers to allow us to reference ideas quickly
  • I'll add checkboxes to tell what is currently implemented

1. haps with object values

  • n("0 1") produces hap values { n: 0 } and { n: 1 }.
  • see controls

2. haps with primitive values

  • "0 1" will produce hap values 0 and 1.
  • Alternatively, that syntax could also be represented with a reserved object key, producing hap values
    { value: 0 } and { value: 1 },

3. pattern methods that apply union

  • n("0 1").room(.5) produces hap values { n: 0, room: .5 }
  • see controls

4. pattern methods that assign to hap context

  • "0 1".velocity(.5) currently adds velocity value to .context, keeping values primitive.

  • scale, set by .scale, referenced by .scaleTranspose

  • onTrigger called by REPL when the event is scheduled, see sound engines and alternative outputs #64 "How to implement a custom output". Currently referenced by all the outputs .speak, .osc, .tone, .out

  • instrument, set by .tone, now unreferenced, can be removed

  • .type set by .tune, .xen and .tuning to frequency, referenced by getPlayableNoteValue. Should be refactored to use { freq } as value

  • velocity, set by .velocity, referenced by .midi, .tone, .adsr, .pianoroll

  • locations, set by .withLocation, .withMiniLocation to indicate source relevant source code locations, referenced by `markEvent``

  • color, set by .color, referenced by .pianoroll, markEvent

Which of the above should be refactored? Probably all except onTrigger, locations?
I think of this change as an implementation detail, hoping that converting context to object values won't break anything / won't require changing user code syntax, but we'll see

5. primitive haps = (2) + union method (3)

"0 1".room(.5) could produce hap values { value: 0, room: .5 }

6. pattern methods that modify hap values

  • "0 1".add(1) produces 1, 2
  • "0 1".add(1) could also produce { value: 1 }, { value: 2 } ?
  • n("0 1").add(1) should produce { n: 1 }, { n: 2 } ?
  • n("0 1").add(n(1)) should produce { n: 1 }, { n: 2 }
  • n("0 1").room(.5).add(room(.2)) should produce { n: 1, room: .7 }, { n: 2, room: .7 }
  • .add, .sub, .mul, .div, .round, .floor, .ceil will currently try to convert / parse hap values as numbers.
  • .union will currently expect hap values being objects. (Dirtify plain values #63 would to objectify them)

7. outputs should support 1 and 2

  • "42 43" (defaultSynth)
  • n("42 43") (defaultSynth)
  • "42 43".midi()
  • n("42 43").midi()
  • "42 43".tone(...)
  • n("42 43").tone(...)
  • "42 43".pianoroll()
  • n("42 43").pianoroll()
  • "hello".speak()
  • xxx('hello').speak() (which key should we use here?)
  • n("42 43").osc()
  • "42 43".osc()
  • "42 43".superdirt() see Dirtify plain values #63
  • n("42 43").superdirt() see Dirtify plain values #63
  • "42 43".wave('sine').out()
  • n("42 43").wave('sine').out()

Maybe we should first discuss if the snippets I've added here should work? If yes, we could convert them to tests and implement them.

There are quite some things to refactor but I think it's inevitable, and it will make strudel much more flexible!

@yaxu
Copy link
Member Author

yaxu commented Apr 18, 2022

Nice summary!

  1. Should always have patterns of objects? I'm not really sure, but it might turn out easier that way.

  2. Yes agreed all of these should go in value apart from onTrigger and locations, as these aren't data to be sent to the synth output.

  3. I think all these look good. Another question is what should n("0 1").room(.5).add(.2) do? The main options would be

  • a. { n: 2.2, room: .7 } - add to all numbers (what about strings?)
  • b. { n: 2, room: .7 } - add to the most recently added parameter - strudel would have to keep note of this
  • c. { n: 2, room: .5 } - do nothing (because there is not a plain 'value' to add to)
  • d. raise an error

I see merits in all these options, but I quite like the last one, as it's ambiguous and raising an error is maybe the least confusing thing to do. But in that case it would be a bit confusing if n("0 1").add(1) did work.

  1. I still quite like the idea of just being explicit about what's being specified. It's not many characters saved. That reservation aside, they all look good.
    I like vocable as the key for speech synthesis.

@felixroos
Copy link
Collaborator

felixroos commented Apr 19, 2022

I think all these look good. Another question is what should n("0 1").room(.5).add(.2) do? The main options would be
a. { n: 2.2, room: .7 } - add to all numbers (what about strings?)
b. { n: 2, room: .7 } - add to the most recently added parameter - strudel would have to keep note of this
c. { n: 2, room: .5 } - do nothing (because there is not a plain 'value' to add to)
d. raise an error

(I took the liberty to add letters to your options)

I'd go for a or d, preferably a.

8. core repl example

Let's take this unmodified example from the core repl:

sequence(880, [440, 660], 440, 660) // 880, ...
.div(slowcat(3,2).slow(2)) // 880/3, ...
.off(1/8,mul(2))
.off(1/4,mul(3))

Note that the core repl will treat numbers as frequencies by default, so no need for objects.

9. example with a

If we would want to adapt that for a, we only need to change the first line:

freq(sequence(880, [440, 660], 440, 660)) // { freq: 880 }, ...
.div(slowcat(3,2).slow(2))  // { freq: 880*3 }, ...
.off(1/8,mul(2))
.off(1/4,mul(3))

10.1 example with d

For d, we would either need to wrap all arithmetic operations in freq (creating a little more paren confusion):

freq(
  sequence(880, [440, 660], 440, 660) // 880, ...
  .div(slowcat(3,2).slow(2)) // 880 / 3, ...
  .off(1/8,mul(2))
  .off(1/4,mul(3))
) // { freq: 880 / 3 }, ...

10.2 example with d, alternative

... or wrap arithmetic operations in freq (which is imho more confusing):

sequence(880, [440, 660], 440, 660) // 880, ...
.div(freq(slowcat(3,2).slow(2))) // 880/3, ...
.off(1/8,mul(freq(2)))
.off(1/4,mul(freq(3))) // { freq: 880 / 3 }, ...

If we don't find a case where d can do things a cannot, i think a is simpler.

11.1 multi member object operations with a

In some cases, it can even be benefitial to apply the operation on all object members with a:

freq(220) // { freq: 220 }
.cutoff(1000) // { freq: 220, cutoff: 1000 }
.mul(2)  // { freq: 440, cutoff: 2000 } <-- cutoff follows frequency!
.s('supersaw') // { freq: 440, cutoff: 2000, s: 'supersaw' }

11.2 multi member objects, single member operations

If we don't want to apply it to both keys, we just need to change the order:

freq(220) // { freq: 220 }
.mul(2)  // { freq: 440 }
.cutoff(1000) // { freq: 440, cutoff: 1000 } <-- cutoff is not multiplied
.s('supersaw') // { freq: 440, cutoff: 1000, s: 'supersaw' }

11.3 multi member objects, single member operations

or

cutoff(1000)
.mul(2)  // { cutoff: 2000 }
.freq(220) // { cutoff: 2000, freq: 220 } // <-- freq is not multiplied
.s('supersaw') // { cutoff: 2000, freq: 220, s: 'supersaw' }

12 multi member objects, single member operations

If we want to replicate 11.1 with d:

freq(220) // { freq: 220 }
.cutoff(1000) // { freq: 220, cutoff: 1000 }
.mul(freq(cutoff(2)))  // { freq: 440, cutoff: 2000 } <-- cutoff follows frequency!
.s('supersaw') // { freq: 440, cutoff: 2000, s: 'supersaw' }

or is there another way?

Those are just some thoughts to keep the ball rolling..

what about strings?

add could be used to append strings, the other operations would probably best to throw an error if strings are specified.

@yaxu
Copy link
Member Author

yaxu commented Apr 19, 2022

Hm well I think 10.1 is the clearest expression. I don't see the parenthesis introducing confusion, only resolving ambiguity. Parenthesis is all about being clear about scope and execution order, and that's what we're trying to do here.

Having to deal with nested parentheses can be confusing, counting and matching brackets.. But that's a bit inevitable with trying to represent tree structures in text, and editor features can help with that.

Things are also confusing with the mixing of nouns like freq and cutoff, with verbs like mul and off. More explicit would be

freq(220).union(cutoff(1000).mul("2 4"))

... where we don't treat nouns as methods of other nouns.

Then if we wanted to multiply both we could do it to the union:

freq(220).union(cutoff(1000)).mul("2 4")

I guess the main reason for avoiding this is again the difficulty with bracket matching. That might be the best design choice but I think we should take a bit of care to make sure that this really is the best trade-off.

For example if we overloaded an operator instead of union we could do something like:

freq(220) >> cutoff(1000).mul("2 4")

That mul would presumably only operate on the cutoff. If we really wanted to operate on both then parenthesis could come to the rescue in that (relatively rare) case:

(freq(220) >> cutoff(1000)).mul("2 4")

@felixroos
Copy link
Collaborator

felixroos commented Apr 19, 2022

Hm well I think 10.1 is the clearest expression. I don't see the parenthesis introducing confusion, only resolving ambiguity. Parenthesis is all about being clear about scope and execution order, and that's what we're trying to do here.

Agreed that it resolves ambiguity. But we should note that 10.1 would still work with a, but 9 would not work with d, so a is more forgiving.

Having to deal with nested parentheses can be confusing, counting and matching brackets.. But that's a bit inevitable with trying to represent tree structures in text, and editor features can help with that.

Totally, but in many cases, you don't want a separate tree branch as you only have one object member

Things are also confusing with the mixing of nouns like freq and cutoff, with verbs like mul and off. More explicit would be freq(220).union(cutoff(1000).mul("2 4"))

Yes it can get a bit confusing mixing those words. So would this snippet be a different approach then, implying that a and d both shouln't work?

Wouldn't cutoff(1000).mul("2 4") require a to work, because cutoff(1000) produces { cutoff: 1000 }? With d it would need to be:

freq(220).union(cutoff("1000".mul("2 4")))

Agreed that operator overloading helps against too much parens. We should still keep in mind that operator overloading will only work with shapeshifting and will not be part of the core api.

As I see it atm, a can do everything d can do but more, so the design decision here is a trade off between

  • a: more forgiving / less verbose, but allows writing more ambiguous code
  • d: less forgiving / more verbose, but code is more clear and explicit

@yaxu
Copy link
Member Author

yaxu commented Apr 19, 2022

Sorry I was assuming a in the above, but where . has higher precedence than >>, so the mul here is local:

freq(220) >> cutoff(1000).mul("2 4")

I think a is definitely the winner there, because as far as I can see, there are no surprises.

Then with * overloaded too:

freq(220) >> cutoff(1000) * "2 4"

With shapeshifting do we have control over precedence in the above?

Hm it's a shame that shapeshifting is needed for this. I do worry though that by using . for everything we end up with a language about threading beads together into a sequence instead of making patterns from generative trees, working at multiple scales.

@yaxu
Copy link
Member Author

yaxu commented Apr 19, 2022

As an aside, the CDN is useful for mapping out and talking about these decisions: https://en.wikipedia.org/wiki/Cognitive_dimensions_of_notations

For example terseness seems like a plus for live coding, but I think viscosity is often more important.

@felixroos
Copy link
Collaborator

felixroos commented Apr 19, 2022

Sorry I was assuming a in the above, but where . has higher precedence than >>, so the mul here is local:

"." has higher precedence. Just checked with https://astexplorer.net/

Hm it's a shame that shapeshifting is needed for this

Yup. Maybe we'll need that full on DSL someday...

Then with * overloaded too: freq(220) >> cutoff(1000) * "2 4"

This will also work in the expected order. (freq(220) >> cutoff(1000)) * "2 4" should also work to apply "*" at the end.

I think before we talk about operator overloading, we should have an API that is acceptable without, maybe fixing some verbosity with operators. I don't really see the danger of ending up with bead sequences, as we always have the option to explicitly nest stuff if we want..

@yaxu
Copy link
Member Author

yaxu commented Apr 19, 2022

That's good on precedence.

Without shapeshifting, we could also use union like:

union(
  freq(220),
  cutoff(2000).mul(2)
)

I don't really like the word union for this, maybe merge, fuse, match or mix or something is better..

We're not completely free to nest stuff with method chaining, as this won't work:

freq(220).(cutoff(2000).mul(2))

@felixroos
Copy link
Collaborator

We're not completely free to nest stuff with method chaining, as this won't work: freq(220).(cutoff(2000).mul(2))

Why not just

freq(220).cutoff("2000".mul(2))

@yaxu
Copy link
Member Author

yaxu commented Apr 19, 2022

Yes, that's better, but there isn't a good reason why freq(220).(cutoff(2000).mul(2)) shouldn't work, if we're using . as an operator to combine parameters.

It is also possible to contrive an example where you would want to group two parameters with ():

freq("220(5,8)")
  . (cutoff(sine.range(200,400))
     . room(saw)
    ).every(3, fast(2))

@felixroos
Copy link
Collaborator

unfortunately, using dots this way wont work with js. we could still add union for such cases. but i wonder if you even need parens for union, because they say it‘s associative: https://proofwiki.org/wiki/Union_is_Associative
So if we are talking about union, parens won‘t make a difference, as x.(y.z) should equal to x.y.z

At least in your example, dropping the parens won‘t make a difference, but I might totally miss something here

@yaxu
Copy link
Member Author

yaxu commented Apr 20, 2022

Ah right, so I'd need more parenthesis..

freq("220(5,8)")
  . ((cutoff(sine.range(200,400))
      . room(saw)
     ).every(3, fast(2)))

Except that doesn't work, so it'd have to be:

freq("220(5,8)")
  . (union((cutoff(sine.range(200,400))
      . room(saw)
     )).every(3, fast(2)))

Or with an overloaded operator:

freq("220(5,8)")
  >> (cutoff(sine.range(200,400))
      >> room(saw)
     ).every(3, fast(2))

Having . seem to work both as a -> a -> a and a -> (a -> a) -> a is confusing, and it's better to use different operators for that (where shapeshifting is available).

Also making it seem like . works for a -> a -> a is a leaky abstraction because things don't always work where you'd expect, as with the above example and also:

var foo = freq(200) . cutoff(4000);
s("bd sd") . foo // couldn't work
s("bd sd") >> foo // could work with shapeshifting
s("bd sd") . union(foo) // works
union(
  s("bd sd"),
  foo
) // also works

Of course what's really happening is that room and . room are different functions and . is always a > (a -> a) -> a (or more generally, a (a -> b) -> b). So another way out of this could be to give them different names that are verbs. fixRoom or something. That doesn't feel satisfying though..

@yaxu
Copy link
Member Author

yaxu commented Apr 20, 2022

Maybe plain javascript is just too restrictive to host a DSL, and we should put shapeshifting in core, or at least make it highly recommended for end-user live coding.

@felixroos
Copy link
Collaborator

felixroos commented Apr 20, 2022

Ah right, so I'd need more parenthesis..

freq("220(5,8)")
  . ((cutoff(sine.range(200,400))
      . room(saw)
     ).every(3, fast(2)))

So this example is implying we want the every to run on the sub branch only, which makes the expression non-associative, right? (aka if we drop the parens, it won't be the same)

Couldn't the same be achieved much simpler, using:

freq('220(5,8)').union(
  cutoff(sine.range(200, 400))
  .room(saw)
  .every(3, fast(2))
);

I think this is pretty readable even without an operator. With operator:

freq('220(5,8)') >>>
  cutoff(sine.range(200, 400))
  .room(saw)
  .every(3, fast(2))

Also making it seem like . works for a -> a -> a is a leaky abstraction

I don't think this is a leaky abstraction, you could see the dot not as an operator, but more as a syntactical separator of functions (like "," for arguments).
Instead of learning the behaviour of different operators, you could just learn the behaviour of different functions, so for example, .fast will modify the pattern time, so it's non associative:

freq(220).cutoff("800 1000").fast(2) 
!== 
freq(220).cutoff("800 1000".fast(2))

On the left, the fast will be applied to the whole pattern, while on the right it will be applied only to the cutoff pattern.
I think this is pretty easy to grasp, even for a beginner.

The same applies for other modifier functions:

freq(220).cutoff("800 1000").mul(2) 
// { freq: 440, cutoff: 1600 }, ...
!== 
freq(220).cutoff("800 1000".mul(2))
// { freq: 220, cutoff: 1600 }, ...

Of course what's really happening is that room and . room are different functions

We could ensure .room and room behave exactly the same, so the brain can chunk them as being the same, although they are defined separately:

    freq("220 440").room(".5 1") 
=== room(".5 1")   .freq("220 440") 
=== freq("220 440" .room(".5 1")) 
=== room(".5 1"    .freq("220 440"))

Here, freq and room are just doing union operations, so they are associative and commutative.
Generally, all "pattern factories", meaning functions that create patterns (n, s, freq, room, ...) are just associative union operations, while "pattern modifiers", such as .fast, .every, .add are non-associative operations.

I think "syntax sugar" like that (being able to union without explictly writing .union) won't hurt if there is still an explicit alternative. I guess most strudlers won't think about those language intricacies, and just learn the behaviour in a more implicit way, so any shortcuts are beneficial and make it more accessible. I think use cases that require .union are pretty rare so learning the function is not really important for a beginner (probably adds more confusion up front if it's mandatory).

But maybe there are special cases or further complexities I am missing so far. We could also discuss some more code examples, using different notations. As you've said earlier, this discussion is really about core design choices, so we should pay attention to detail here.

@yaxu
Copy link
Member Author

yaxu commented Apr 20, 2022

My argument wasn't that

freq("220(5,8)")
  . ((cutoff(sine.range(200,400))
      . room(saw)
     ).every(3, fast(2)))

is the best way to write the pattern, but that it should work, because . looks like an operator to combine two patterns, like # and friends in tidal.

It feels better without the space though e.g. .room vs . room, when . looks less like an operator.

It's a shame that something like these won't work:

freq(220).every(3, .room(0.5))
freq(220).every(3, >> room(0.5))

Alternatives:

freq(220).every(3, x => x.room(0.5))
freq(220).every(3, x => x >> room(0.5))
freq(220).every(3, room(0.5).union)

They aren't commutative when they have different structures:

freq("220 440").room(".5 1 2") 

We could make it so they are, by doing the union with appBoth instead of appLeft. I'm very used to the appLeft behaviour, but could be worth an experiment..

(In tidal there are different operators for appLeft |> / appRight >| / appBoth |>|, although I generally only use the |> form via its # alias.)

The freq("220 440" .room(".5 1")) style is interesting!

@yaxu
Copy link
Member Author

yaxu commented Apr 21, 2022

The problem with this:

freq("220 230 240").every(3, room("0.5 0.25").union)

Is that event fragments would take the whole timespan from the 'room', and you'd end up with two event onsets instead of three, which might be surprising.

Considering #82, the original structure could be maintained with setFlip:

freq("220 230 240").every(3, room("0.5 0.25").setFlip)

@yaxu
Copy link
Member Author

yaxu commented Apr 21, 2022

So in the end I agree that methods should try their best to apply to the whole pattern, e.g. choice 'a' where this adds to both room and n values:

n("0 1").room(.5).add(.2)

and that we keep .x(...) as shorthand for .set(x(...)) (renamed from .union(x(...)) if #82 is agreed).

Maybe we should resolve #82 next.

@felixroos
Copy link
Collaborator

felixroos commented Apr 21, 2022

okay so I kind of lost track of the common thread, but I think we should be alright with union approach a..

It feels better without the space though e.g. .room vs . room, when . looks less like an operator.

happy to hear that.

It's a shame that something like these won't work: freq(220).every(3, .room(0.5))

Yep.. In this case, it would be desirable if room was a curried function, with v => pat => pat.room(v). This would allow writing freq(220).every(3, room(0.5)) instead of freq(220).every(3, x => x.room(0.5)). It's also how modifiers currently work: because they have no pattern factory counterpart, we were able to reuse the name to create a curried version. But of course, this clashes with the function already being used to create a pattern. It's a little bit confusing to have a separation here.. ( add(5) returns a function, room(5) not ).

So the problem is: we want 3 different behaviours (1. create pattern, 2. assign value to pattern 3. create pattern modifier function), but we only have 2 functions to spare..

But there may be a way out of this: What if every overloads its second argument to also accept a pattern that is then unioned? That way, room(0.5) would still create a pattern, but: freq(220).every(3, room(0.5)) would work analogous to how pattern modifiers work. In Code:

  every(n, func) {
    if (func instanceof Pattern) { // <-- this
      const p = func;
      func = (x) => x.union(p);
    }
    const pat = this;
    const pats = Array(n - 1).fill(pat);
    pats.unshift(func(pat));
    return slowcatPrime(...pats);
  }

I already tested this and it works. Similar tactic could be used for other functions that expect a pattern modifier function.

What do you think?

@yaxu
Copy link
Member Author

yaxu commented Apr 21, 2022

Yes that looks good, as it also allows this to work:

freq(220).every(3, room(0.5).size(0.5))

(With #83, that would be x.set(p) rather than x.union(p))

@felixroos felixroos added the compatibility Mainline Tidal Compatibility label Apr 24, 2022
@yaxu
Copy link
Member Author

yaxu commented Aug 16, 2022

Do we agree that velocity should go into the value object?

@felixroos
Copy link
Collaborator

clearly it should

@yaxu yaxu mentioned this issue Aug 16, 2022
9 tasks
@yaxu
Copy link
Member Author

yaxu commented Mar 1, 2023

I think this issue is out of date, apart from some parameters velocity, duration, legato need bringing from 'context' metadata into the main control objects.

@felixroos
Copy link
Collaborator

I think this issue is out of date, apart from some parameters velocity, duration, legato need bringing from 'context' metadata into the main control objects.

yep let's close this monster issue..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
compatibility Mainline Tidal Compatibility
Projects
None yet
Development

No branches or pull requests

2 participants