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

stateless voicings + tonleiter lib #647

Merged
merged 29 commits into from Jul 17, 2023
Merged

stateless voicings + tonleiter lib #647

merged 29 commits into from Jul 17, 2023

Conversation

felixroos
Copy link
Collaborator

@felixroos felixroos commented Jul 8, 2023

fixes #506

implements a new algorithm for voicing chords

examples:

new functions:

voicing

voicing is the only function that is not a control, but rather a function that consumes all the voicing related controls and renders them into actual notes. It accepts either strings or objects. Strings are interpreted as chords. Examples:

voicing("<C Am F G>"); // minimal example
voicing(chord("<C Am F G>")); // alternative 1
chord("<C Am F G>").voicing(); // alternative 2

chord

chords are just strings where the beginning is expected to be a valid pitch class (C, C#, Db, ... ) and the rest is considered to be the chord symbol (can also be empty, which is normally the case for major chords).

chord("<C Am F G7>").voicing(); // uses chord symbols '', 'm', '' and '7'

dictionary / dict

dictionary or short dict defines how chord symbols are turned into individual notes, or in other words, how they are "voiced".
You can either pass a string or a dictionary:

// 1. using predefined "triads" dictionary
chord("<Cm Fm>").dict("triads").voicing()

// 2. using custom dictionary
addVoicings('my-triads', {
  '': ['0 4 7', '4 7 12', '7 12 16'], // major chords (no symbol)
  m: ['0 3 7', '3 7 12', '7 12 15'], // minor chords via 'm'
  // and so on..
})
chord("<Cm Fm>").dict("my-triads").voicing()

// 3. dictionary itself
chord("<Cm Fm>")
  .dict({
    '': ['0 4 7', '4 7 12', '7 12 16'], // major chords (no symbol)
    m: ['0 3 7', '3 7 12', '7 12 15'], // minor chords via 'm'
    // and so on..
  })
  .voicing()

If dict is not set, the default dictionary defaultDictionary is used, which understands the most common chord symbols (currently just triads and sevenths chords, but can be extended in the future).

In the dictionary, the numbers are semitones relative to the root note, so ['0 4 7', '4 7 12', '7 12 16'] in C would be ['C E G', 'E G C', 'G C E']. These pitches have no octaves, so each voicing can be played in any octave.

anchor

To decide which voicing to actually play for a certain chord, the anchor control is used.
It expects a single note to align the voicing to. It falls back to E5.
With the above dictionary and the default anchor, a C chord will always be 'G4 C5 E5', using the '7 12 16' option, because that voicing's top note is closest to E5.

The anchor is the actual "trick" to get voice leading without state, as a voicing does not need to know which voicing came last (which was the case with the old voicing algorithm).

mode

The behavior of anchor is not fixed to always align the voicing to the top note. Instead, the mode control can be used to change that alignment to:

  • below: picks voicing with top note <= anchor (default value)
  • above: picks voicing with bottom note >= anchor
  • duck: like below, but filters out top note if it matches anchor (good for harmonizing melodies)

offset

The offset control applies an offset to the selected voicing. For example:

chord("Cm")
  .dict({ m: ['0 3 7', '3 7 12', '7 12 15'] })
  .anchor("E5") // = default
  .voicing()

... the resulting voicing would be G4 C5 E5, which is the voicing with index 2 in the dictionary.
If we apply .offset(1), the selected voicing index will be 2+1 = 3. Because there is no voicing with index 3, we wrap around to index 0, landing on 0 3 7, giving C5 E5 G5. So with this particular set of voicings offset controls the inversions, but that term would not apply for sets of voicings that do not include every rotation.
The above explanation sounds complicated, but when you use it, it makes much more sense..

n

The n control selects an index out of the rendered voicing, which allows to play the voicing like a scale:

n("0 1 2 3")
  .chord("Cm")
  .dict({ m: ['0 3 7', '3 7 12', '7 12 15'] })
  .anchor("G5") // <-- not default
  .voicing()

is equal to

note("C5 E5 G5 C6");

Note that n = 3 wraps around to the first note of the voicing, but 1 octave higher.

edit: updated this whole post after the api changed during the development of this PR. Posts further down might still reference or iterate on the old version of this post.. The above functions are the actual up to date information

@felixroos
Copy link
Collaborator Author

felixroos commented Jul 9, 2023

added another voicing feature: offset:

offset("0 <[2 1] 2>")
  .chord("<Dm7 G7 C^7 A7b9>")
  .anchor("E4")
  .voicing()
  .piano()

offset(0) is the default, other values shift the voicing selection up (>0) or down (<0).
for n>voicings for the current chord, the voicing will be octavated.

@felixroos
Copy link
Collaborator Author

felixroos commented Jul 9, 2023

and another feature: when n is set, voicing will use it to play the voicing as a scale. this is an alternative to arp, allowing to break the octave barrier + it's much more performant and logical imo. this also blurs the lines between scale and voicing, where voicing could also be used as a scale that sticks to an anchor. example:

n(run(12))
  .chord("<Dm7 G7 C^7 A7b9>")
  .voicing()

I am already having a blast using these new functions and I cannot wait to finish them.

@felixroos
Copy link
Collaborator Author

felixroos commented Jul 9, 2023

I am still wondering if the voice dictionary (argument to voicing function) should also be a control..
With the current state, the dictionary can be patterned, but cannot be used as the structure, because voicing will use all the other controls, so it has to come last. Maybe there should be a dictionary control, then the voicing function would only expect the pattern as an argument, with the possibility of being used as .voicing()...

Also, another thought: related to #223 the voicing function could also accept an object with all the controls in it, like

voicing({
  n: run(12),
  chord: "<Dm7 G7 C^7 A7b9>",
  dict: "lefthand"
})

edit: also there should probably be a default dictionary which understands a broad selection of chords and returns the most common voicings

@felixroos
Copy link
Collaborator Author

voicing-scales.mp4

- standalone voicing function
- simplify voicing control names
@felixroos
Copy link
Collaborator Author

felixroos commented Jul 11, 2023

voicing is now a function that takes only a pattern + dictionary / dict is now just a control for setting the voicing dictionary.
I've also simplified to naming of the voicing controls:

voicing(
    n(run(4)) // plays voicing like a scale
    .chord("<Dm7 G7 C^7 A7b9>") // which chord to play
    .dict("steps") // or dictionary
    .anchor("B5") // former called voiceMax / voiceBelow
    .add.squeeze(offset("0,3 1 2")) // how much to shift the voicing up or down
    .mode('max') // if set to below, the anchor note will be filtered out (for "melody ducking")
  ).piano()

alternatively, the above can also be written as:

n(run(4)) // plays voicing like a scale
.chord("<Dm7 G7 C^7 A7b9>") // which chord to play
.dict("steps") // or dictionary
.anchor("B5") // former called voiceMax / voiceBelow
.add.squeeze(offset("0,3 1 2")) // how much to shift the voicing up or down
.mode('max') // if set to below, the anchor note will be filtered out (for "melody ducking")
.voicing().piano()

so the new control names are:

  • dictionary / dict
  • anchor
  • (offset) already exists in the list, what is it?
  • mode

i guess it's not important to namespace the controls for voicings, as they could be reused in another context (similar to n being reused for sample number, scale step, pitch and now voicing steps)

@felixroos felixroos marked this pull request as ready for review July 13, 2023 08:53
@felixroos
Copy link
Collaborator Author

this is now ready to merge. @yaxu ok with the naming of the new controls: https://github.com/tidalcycles/strudel/pull/647/files#diff-b1da86c00bc85a5a5848acac010d999cdfd66a9f97b9f107b09f70fa519c3b51R576 ?

i guess it's not important to namespace the controls for voicings, as they could be reused in another context (similar to n being reused for sample number, scale step, pitch and now voicing steps)

@yaxu
Copy link
Member

yaxu commented Jul 17, 2023

Sorry for being slow!

Are these standard names in the world of harmony? I can't see them being used here, but I don't really know anything about voicing. https://en.wikipedia.org/wiki/Voicing_(music)

offset is used by superdirt to delay playback by the given number of seconds. nudge does the same, but is processed on the tidal side (by adding the nudge value to the osc timestamp). So we have redundancy there. I think this 'offset' is just copied from the original dirt implementation, and probably isn't documented anywhere and might not be used, so we could think about just removing it.

Are you keen on offset, or is there an alternative?

Overall no objections if you think these are the best names.

@felixroos
Copy link
Collaborator Author

Are these standard names in the world of harmony? I can't see them being used here, but I don't really know anything about voicing. https://en.wikipedia.org/wiki/Voicing_(music)

nope, only voicing and chord are terms in that world. the rest are just generic terms to steer the algorithm.
I am not aware of any terms that would accurately describe what they do, so i guess generics are ok.
If there are any musicologists out there reading this, knowing fitting terms, we can add them as aliases.

offset is used by superdirt to delay playback by the given number of seconds. nudge does the same, but is processed on the tidal side (by adding the nudge value to the osc timestamp). So we have redundancy there. I think this 'offset' is just copied from the original dirt implementation, and probably isn't documented anywhere and might not be used, so we could think about just removing it.

Are you keen on offset, or is there an alternative?

Not specifically keen on it. But even if there was a naming collision, it would not be a problem, as voicing will "consume" its controls and only output notes (+ everything that is not related to voicings). Example:

n("0 1 2 3").chord("Cm").voicing().s("gm_epiano1")

// alternative syntax:
voicing(
  n("0 1 2 3").chord("Cm")
).s("gm_epiano1")

In the above example, n is used as voicing steps. After .voicing(), it is gone, so gm_epiano1 won't receive different values for n. The same would apply for all the other voicing related controls, including offset. So they could be seen as being only in a local namespace. This syntax could be generally useful for more complicated functions like that.

@felixroos
Copy link
Collaborator Author

felixroos commented Jul 17, 2023

I've now added a more in depth description of all the new functions at the top of this PR

@felixroos felixroos merged commit 8583ed0 into main Jul 17, 2023
2 checks passed
@felixroos felixroos deleted the tonleiter branch July 17, 2023 21:34
@yaxu
Copy link
Member

yaxu commented Jul 17, 2023

Ah nice! Sorry I hadn't looked into the details of it, the scoping is nice !

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

Successfully merging this pull request may close these issues.

stateless voicing function with anchors
3 participants