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

Mapping identifiers and numbers to notes in Cycles #13

Closed
emuell opened this issue May 19, 2024 · 9 comments
Closed

Mapping identifiers and numbers to notes in Cycles #13

emuell opened this issue May 19, 2024 · 9 comments
Labels
enhancement New feature or request skipped

Comments

@emuell
Copy link
Owner

emuell commented May 19, 2024

Right now, only note strings and integers are supported in cycle. e.g:
cycle("c4 c5") or cycle("48 60")

But it's possible to use arbitrary strings as identifiers, and floating point numbers in the mini notation too:
cycle("bd sn")

Such identifiers are currently ignored and will emit nothing.


The easiest and probably most obvious way to map such identifiers to notes (or later other event types) could be using a map function which takes a table or function as argument:

  1. using a Lua table to map events:

table:

cycle("bd sn"):map({ bd = "c4", sn = "d4" })
  1. using a lambda function to dynamically map stuff

function:

---@param value string|number single value from the cycle
---@param emitter_context EmitterContext Runtime context as passed to `emit` functions
---Should return something that can be converted to a Note
cycle("bd sn"):map(function(value, emitter_context) 
  if value ==  "bd" then
    return emitter_context.trigger_note + 1
  elseif value ==  "sn" then
    return "d4"
  else
    return nil
  end
end)

I think 1. could be implemented quite easily using the following rules:

  • everything can be remapped, including valid note strings.
  • identifiers which are not mapped (not defined in the map) should result in an error (to help identifying typos).
  • values of maps must be convertible to notes, e.g. "c4" 48 or { key = "c4" }
  1. is quite powerful, but also pretty verbose. We could either add some preconfigured map functions to for example ease doing stuff with scales or could add new map_with_scale funtions for that.

@unlessgames you had some whishes idea here, if I remember well. Any idea how this could look like?

@emuell emuell added the enhancement New feature or request label May 19, 2024
@unlessgames
Copy link
Collaborator

My wishes were around scales, either with some scaled function that maps integers to scale degrees like

cycle("0 2 [4 5] [6 4]"):scaled("minor"):transposed("d")

Or have the scale object contain common mapping functions that can be used without lambdas, I like this a bit more because you can define a scale at one place and reuse it.

local cmin = scale("penta", "c")
cycle("0 2 [4 5] [6 4]"):map(cmin.notes)
cycle("0 2 [4 5] [6 4]"):map(cmin.chords)

It might be nice to also support something like

local cmaj = scale("major", "c")
cycle("ii V I"):map(cmaj.chords)

But the issue with these approaches I think is that just taking the chords from scales is not very interesting, you often want to invert a chord, extend it or use something out of the scale at some point. If there was some more general chord mapper that would be really handy.

For example we could make use of the Target field from events to build chords on root notes, or do something similar with strings mapped to chords without target or root note.

cycle("c4:m7 f3:7#11 c3:M9i1"):as_chords()

@emuell
Copy link
Owner Author

emuell commented May 22, 2024

I like this one:

local cmin = scale("penta", "c")
cycle("1 2 [4 5] [6 4]"):map(cmin.notes)
cycle("1 2 [4 5] [6 4]"):map(cmin.chords)

This would work out of the box with the suggested mapping feature.

chords needs an extra argument though (number of notes), so it must be a member function.


But the issue with these approaches I think is that just taking the chords from scales is not very interesting, you often want to invert a chord, extend it or use something out of the scale at some point. If there was some more general chord mapper that would be really handy.

For example we could make use of the Target field from events to build chords on root notes, or do something similar with strings mapped to chords without target or root note.

cycle("c4:m7 f3:7#11 c3:M9i1"):as_chords()

Actually would be nice if the existing note chord's syntax could be directly used in cycle as well. I had borrowed that from Tidal already.

---Create a transposed copy of the note or chord.

cycle("c4'm7 f3'7#11 c3'M9")

I guess this could be added directly to the cycle impl - without any custom mapping?


Good point about the : operator:

Both should be passed as arguments to a map lambda:

local cmin = scale("c", "minor")
local function my_scale_map(value, target) 
  local degree, note_count = value, target
  return cmin:chord(degree, note_count) 
end
cycle("I:3 V:3 IIV:5"):map(my_scale_map)

quite verbose, but I personally don't have a problem with that.

@emuell
Copy link
Owner Author

emuell commented Jun 14, 2024

With the basic mapping working, I'll start looking into automatically mapping chords as c4'maj in cycles now and allowing chords as values in maps:

local cmin = scale("c", "minor")
cycle("i v vi iv"):map(function(context, degree) 
  return cmin:chord(degree) 
end)

Once that's done, we can start thinking about how to make some of the more common things, like chords, easier to map.

@emuell
Copy link
Owner Author

emuell commented Jul 8, 2024

Started experimenting with this now here:
https://github.com/emuell/afseq/blob/feature/cycle-mappings/types/nerdo/library/mappings.lua

Example:

cycle("[1 5 6 <_ 4>]/4"):map(
  mappings.chord_from_degrees(scale("a", "major"))
)

Idea is to provide a set of functions which return cycle map functions here.

This is hard to generalize. Also mappings currently can't be stacked, which would be nice. This could be solved in plain lua or by allowing multiple mapping callbacks to be passed to the cycle map.

@unlessgames I think you're more after a functional programming approach here. Any ideas?


I've also noticed that the event merging in the cycle is counter productive here. In this example, the elongate event is merged with the previous note event, so the step value in the context can no longer be referenced in cycle mapping function:

--- step number 3 in map context here refers to `g4` instead of `_`, because the `_` gets removed in cycle.generate.
cycle("[c4 c5 _ g4]/4"):map(
  mappings.with_volume({1.0, 0.5, 0.25})
)

Assuming the merging is just cosmetics or optimization, we should disable, remove it again.

@unlessgames
Copy link
Collaborator

unlessgames commented Jul 9, 2024

It might be better to add such functionality to the cycle implementation as that would open up more possibilities and also solve the issues with the mapping you mention below (both stacks and holds/rests would be treated by it).

In tidalcycles you can combine mini-notation patterns to achieve mappings like these. Instead of mapping on a per-step basis, it essentially works by generating 2 patterns and overlaying them. It's a bit counter-intuitive at first but overall quite powerful way to compose.

For example the above examples would generate two patterns (assuming the second is [1.0 0.5 0.25])

c4____c5__________g4____
1.0_____0.5_____0.25____

When overlapped like this, c4 and c5 would get 1.0 as volume, g4 would get 0.25. (In tidal you'd write this as n "c4 c5 _ g4" # gain "1.0 0.5 0.25")

Similar overlapping logic will be implemented in cycle.rs anyway for the cases of [a b c d]*[2 3 4], so we could make it so that you can give another cycle to a cycle and ask for some mapping operation to be done to the generated events or something like that.

This would also allow for easy variation on both sides like using 1.0 <0.5 0.3> for volumes or adding note values together like 0 1 2 3 + <0 1 2> 0 <0 2 5> 0 and so on.

I think you're more after a functional programming approach here. Any ideas?

The mappings style you used above is already pretty nice even if the naming is a bit too verbose for my taste. I understand it's a matter of preference but in a livecoding scenario I'd prefer to be able to omit extra words like with if they don't add much meaning and don't have "without-with" counterparts, I think it would be more concise to use maps and volume here and it would still read like English, like maps.volume(...) as in "this function maps notes to volume etc but would save some keystrokes without being too obscure.

As an alternative, it might be nice to keep the verbose way but also provide some shorthand that can even by a single char or something. For example tidal aliases note as n and so on. It's a good solution to cater to both those who just want to type out stuff asap and those who aren't in a rush or aren't yet familiar with the options.


That said, in lua these could be better expressed by having these common functions be present on the cycle which could be chained? For example you'd write
cycle("0 13 _ 7").volume({1.0, 0.5, 0.25}).scale("maj")
This could also be more easily extended to accept mini notation strings later in place of the table of volumes here, it is also much easier to read as a non-programmer I think, as nesting, mapping and passing functions can be quite confusing at first.

On that note, since there is already a chord notation syntax present in cycles, maybe the same could be used for scales too so that the user don't have to remember two different vocabularies?

For mapping chords and scales, I'd unwrap the transposition somehow and make it a separate operation. So you could map to chords or scales and transpose later, and transpose without scales or chords the same way.

cycle("0 1 2 3").chords("maj").transpose(9)
cycle("0 1 2 3").scale("dim").transpose("g4")
cycle("c4 e4 g4").transpose(11)

This would make it easier on the eyes to parse expressions because you don't have to crawl into the nested structures to understand where the transposition happens for example as well as allow easy experimentation where you could just split into new-lines over the dots and toggle each operation as comments.


I've also noticed that the event merging in the cycle is counter productive here. In this example, the elongate event is merged with the previous note event, so the step value in the context can no longer be referenced in cycle mapping

I think this is the correct behaviour as a hold is not really a step in a composition sense, just a way to notate a longer note. In the above example I'd expect the 3rd value to pair with the third note. Otherwise it would be increasingly hard to count steps in more complicated patterns, the same thing applies to rests. It's meaningless to map a rest or a hold to a volume or chords so I think it's good you don't have to include dummy values here to account for these.

Assuming the merging is just cosmetics or optimization, we should disable, remove it again.

Not sure I understand what you are proposing here.

We could make holds be applied at parse-time. This would disallow some tricks that are impossible in tidal as well, which might be also desired. For example [a <b c>] <_ d> will hold whatever comes out of the first subdivision last (hold it only every second cycle), while in tidal a hold would get removed if it didn't follow a single step (and the above would actually be a parse error).

But overall this won't solve the above issue as the hold "disappears" from the output pattern either way.

Do you mean the output events should just contain holds as is? Same for all rests?

@emuell
Copy link
Owner Author

emuell commented Jul 9, 2024

cycle("0 1 2 3").chords("maj").transpose(9)

This is indeed easier to read and write. I'll see if and how I can add that. Also agree on shortening the names where possible.

I still want to keep the mapping functions as they are, because accessing the context will be necessary to make the mappings (and various other parts of the rhythm) truly dynamic. Responding to input parameters (which isn't implemented yet), bpm changes and so on.

Also the freeform mapping cycle("a b c"):map(function(context, value) [[do something here]] end) allows you to really do "anything". Maybe not very elegantly and as short as possible, but for a first release that's the most important part IMHO.


I think this is the correct behaviour as a hold is not really a step in a composition sense, just a way to notate a longer note.

To me, this just makes things harder to understand and to use - in the map function. Else I don't really care.

In Tidal, combined expressions like n "c4 c5 _ g4" # gain "1.0 0.5 0.25") are really two independent cycles controlling two different things - but are played together? Gain doesn't set the velocity of the note events, it sets the volume of the synth, right? So writing n "c4 c5 _ g4" # gain "1.0 0.5") here means that the volume of the synth is reduced to 0.5 half way through the cycle. Or am I wrong? In this case it indeed makes sense, or at least doesn;t hurt to merge the holds and rests.

With the map thing, we have to match the steps exactly. And removing steps automatically doesn't make it any easier to write the mappings, as you need to know how exactly they are evaluated and merged down.

In this cycle for example: <c _><_ d e>' it's pretty hard to set the volume of note e` to a certain value via a mapping, unless you replace all e's via a string match instead of counting steps.

And on the other hand, the merge doesn't really do anything useful. The playback engine doesn't care if two note offs are fired after each other. Hold simply does nothing (it does not stop), so you can either merge them or not without making a difference in the playback.

Do you mean the output events should just contain holds as is? Same for all rests?

Yup, exactly. But just to make it easier to map them via the context.step counter.


Apropos: instead of mapping, it would be much easier in many cases to define various note properties directly in the cycle. In note strings outside of the cycle, this can now be hackily done with c4 #1 v0.5 p0.0 d0.5 (key, instrument, volume, panning, delay). We could probably add this as an extension to the tidal note definition. Perhaps without the spaces.

PS: this actually could solve or at least is related to #29 as well...

@emuell
Copy link
Owner Author

emuell commented Jul 11, 2024

Another example where merging doesn't work well with mapping:

emit = cycle("[1 3 4 1 3 4 <7 ~>]/7"):map(
    mappings.combine(
      mappings.intervals(scale("c5", "natural minor")),
      mappings.transpose({ 0, 0, 0, 0, 0, 0, -12 })
    )
  )

The 7 can't be transposed here, because the alternate ~ is removed, making the sequence either 6 or 7 notes long.


I've cleaned up the naming and added chord' and interval' mappings. While I was at it, I also removed the with_XXX names from the note and sequence.

Not a fan of skipping the key in scale for consistency with the global scale. But it can be transposed either way if necessary.

Still working on how to make all these things methods of the cycle:

emit = cycle("[1 3 4 1 3 4 <7 ~>]/7"):intervals(scale("c5", "natural minor")):transpose({ 0, 0, 0, 0, 0, 0, -12 })

Once we have those, all those mapping.xxx functions actually can be removed.


Regarding the ability to specify vol/pan and co in cycles a la c4'maj7_#1_v0.5_p0.0_d0.5:
Do you see any reason why this should not be possible? I'm open to changing the syntax here. But if we change it, we should also change it for the regular note("") strings...

@unlessgames
Copy link
Collaborator

Gain doesn't set the velocity of the note events, it sets the volume of the synth, right? So writing n "c4 c5 _ g4" # gain "1.0 0.5") here means that the volume of the synth is reduced to 0.5 half way through the cycle.

The gain is overlayed as steps, and it's applied only for discrete steps that come from the "main" pattern, not mid-way between steps as you assume. I think in most cases midway stepping of volume or other parameter changes is undesirable and having to precisely line-up events would be finicky (especially for varying patterns), so this system works nicely in tidal.

The 7 can't be transposed here, because the alternate ~ is removed, making the sequence either 6 or 7 notes long.

The problem here is that it is very common to have patterns with varying step count over different iterations like

  • <a [a a]>
  • [a b [c d e]]/3
  • [a | b | [a b] | [a b c d]]
    These are all very simple examples that make use of the fundamental feature of the notation that allows for writing patterns that change structure over cycles.

Regarding the ability to specify vol/pan and co in cycles a la c4'maj7_#1_v0.5_p0.0_d0.5:
Do you see any reason why this should not be possible?

I like the idea. But ideally it would be something more general than only these hardcoded parameter types. I wonder if making the name operator : work in a way that you could attach multiple "tags" with a normal value to any step? Like a:v.1:p.2:foo.5 b.
If it would be its own thing I'd prefer the look of the equal sign (or the minus) more than the underscore here like c4=v.5=p.0=d.5.

@emuell
Copy link
Owner Author

emuell commented Jul 12, 2024

I'll skip the mapping stuff for now and agree that it would be better to do this by combining cycles instead of using value arrays in mappings.

So using e.g. cycle("[a b <c d>]"):transpose("[0 12 5]") instead of cycle("[a b <c d>]"):transpose({0, 12, 5})

Let's also skip the scale/interval mapping stuff until this is sorted out.


I wonder if making the name operator : work in a way that you could attach multiple "tags" with a normal value to any step?

That's nice, and actually how this works in strudel too:

$: s("[bd:<1 2>:<.8 .4>]*4")
  .bank("RolandTR909")

The group behind the second colon defines the velocity in this example.

I'd prefer if the target attribute (instrument, volume, panning...) could be addressed directly with a prefix:

$: s("[bd:<v.8 v.4>]*4")
  .bank("RolandTR909")

which makes it possible to e.g. only access panning or volume without specifying a target instrument.

In strudel you have to skip through them via:

$: s("[bd:_:<.8 .4>]*4")
  .bank("RolandTR909")

With two attributes that's maybe ok, but definitely not if you want to reach the 4th attribute (delay in our case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request skipped
Projects
None yet
Development

No branches or pull requests

2 participants