I am trying to model musical chords and I have many ways of thinking about them:
First, a pitch class can be expressed one of a few ways:
:c
"C"
0
then there’s a pitch type (midi or type + octave)
# note pitch can have all the forms above
%Pitch{octave: 3, pitch: :c}
{:c, 0} # same thing here for the first element, can take three forms above
36
"C3" # string serialized version
I also have an interval type
Interval.interval(5) => %Interval{value: 5}
Interval.name(5) => "^"
I picked a single character to represent each value from 0-11
Then a chord can be:
# root is a pitch, so can also be "E3" or %Pitch{pitch: :e, octave: 3} or any other form
{root: 40, quality: :M} # root is a pitch, raw midi value :M -> major
{root: 40, quality: [interval(5), interval(7), interval(12)]} # major expressed as a list of intervals, `interval` gives a tuple like `interval(5) => %Interval{value: 5}` etc
{root: 40, quality: "5∆8"} # list of intervals represented as string where each interval is one character of the string
# ^ this is useful for data entry and creating 'databases' of chord qualities and voicing
{root: 40, quality: :M, voicing: :closed} # voicing style used to lookup voicing in a table, quality can be any quality type above.
{root: 40, quality: :M, voicing: fn tones -> Enum.map(tones, fn tone -> Interval.new(tone).value) } # function voicing instead of atom
{root: 40, voicing: [interval(1), interval(5), interval(1), interval(3)]} # voicing is described as a tone_stack.
{root: 40, voicing: [0, 5, 7, 12]} # an exact voicing with deltas in midi pitch amounts
[40, 45, 47, 52] # "rendered" chord that can be sent to a synth
You could call the first one and the second one different concepts I guess? Chord
and ChordVoicing
or something?
What is the best way to represent all these options in Elixir? In ML type languages I’d use a sum type, not sure how to do it here.
Also, some of the variants are 100% interchangeable (isomorphic) - do i pick a canonical form or do I write functions in a way that works with both?
Eg
{root: 40, voicing: [0, 5, 7,12]}
= iso = [40, 45, 47, 52]
{root: 40, quality: [interval(5), interval(7), interval(12)]}
= iso = {root: 40, quality: "5∆8"}
(just concatenate the names of the intervals in the forward direction and convert from character to value in the backward)
I’m not clear how best to represent the isomorphisms and the variants in the design.
The challenge is that every function has to be written to support all the different forms, and that I’m manually doing the conversions between them. On top of that, there’s isomorphisms within isomorphisms, so it quickly combinatorically expands! Or I have to write a “sanitizer” function that gives me a predictable form (even though different functions are best expressed with different forms) and then do the operation on that.
Is there some way to auto-generate the missing variants for isomorphic types like this?
for example (not in perfect syntax for brevity):
# transformation functions
to_midi(%Pitch{octave: o, pitch: p}) => 12*(o+1) + p
to_tuple(%Pitch{octave: o, pitch: p}) => {p, o} # how do i get the clause to_tuple(i :: int) => to_tuple(to_struct(i))
to_struct(i) => %Pitch{octave: div(i, 12) - 1, pitch: pitch(rem(i, 12))}
raise(x :: int) = x+1
# don't want to have to write raise(p :: Pitch.t) = to_struct(raise(to_midi(p)))
That doesn’t seem so bad because there’s one variant… now imagine a function on chords:
#transformation functions
to_midi(%ChordVoicing{root: r, voicing: v}) = Enum.map(v, fn x -> x + r end)
to_struct(midi :: [integer]) = Enum.map(midi, fn x -> x-List.first(midi) end)
# how do I "inherit" clauses for roots that are in the form %Pitch{pitch: :c, octave: 3}?
#Then a simple function:
voicing(%ChordVoicing{root: r, voicing: v}) = v
# now i need clauses for all types of voicing and all types of root (pitch descriptions)
# that's 4 types of root * 6 types of voicing = 24 function clauses.
You could say, “look, don’t try to make every function work on every type of input - just choose one input type for most functions, and expose the transforms where applicable”
That’s fine, except even for the translation functions I need n choose 2
such transformations! And after writing all of those, the calling code has the headache of figuring out which ones to use for its particular data type. The hope is that I can “just call a function” and not worry about how the data is represented, so long as it’s valid. I can either standardize the output representation if that is better design, or return whatever representation is given to me.
Thank you for the help in advance! This isn’t an elixir specific problem, I think I’d have the same question in python or haskell or clojure or anything else. Most programming languages do not have a construct for describing or using a “type” that can have multiple representations. The assumption is that the type is defined by its one representation.