xenharmonic-devs / sonic-weave

The SonicWeave DSL for manipulating musical frequencies, ratios and equal temperaments
MIT License
4 stars 4 forks source link

Movement in time #35

Open frostburn opened 5 months ago

frostburn commented 5 months ago

Break into issues as necessary

Root scale declaration # = 4::8

Rest . (one beat) .. (two beats)

Beat duration declaration . = 300ms

Time signature declaration T = 4 (four beats per bar)

Barline | (runtime error if in the wrong place according to the time signature)

Repeated section |: foo bar :|x3

Duration literal d1/2 (half a beat)

Duration labeling 3/2 d2 (a fifth from 1/1 lasting two beats)

Time advancing 1/1 5/4 3/2 . 2/1 (root, third, fifth, rest, octave) [editor's note: Feels fragile. How is 440 Hz not 440 followed by 1Hz?]

Duration extension (empty statement)

Pedal 1/1 ?? 5/4 3/2 (root lasts for three beats here)

Rewind 1/1 , 6/5 , 3/2 (this is a simultaneous chord)

Playhead |> (start playback from last occurrence)

Playstop >| (stop playback when encountered)

Timestamp T

Jump to timestamp @T

Duration multiplication & n => n & 3 (mainly useful for mapping)

osmiumic commented 4 months ago

hi! this is my acc

osmiumic commented 4 months ago

i believe xenpaper's chord notation was mostly consistent with my own but it requires accepting "significant spaces": [a b c d] where a b c d are intervals i think this is fairly inescapable because you don't want to write anything redundant when composing; i wonder if significant spaces could be the only difference between the SonicWeave syntax and the one used for composing? and towards that end, do you have anything like a summary of all of the "reserved symbols" in SonicWeave or a quick rundown of all the basic features of the language for that matter? just so i have some idea of what to avoid and look out for compatibility-wise

. as beats is very intuitive; think i used that too and you came up with it (or took from xenpaper?) and xenpaper uses it so that's a safe bet for a good notation too

frostburn commented 4 months ago

i believe xenpaper's chord notation was mostly consistent with my own but it requires accepting "significant spaces": [a b c d] where a b c d are intervals i think this is fairly inescapable because you don't want to write anything redundant when composing; i wonder if significant spaces could be the only difference between the SonicWeave syntax and the one used for composing? and towards that end, do you have anything like a summary of all of the "reserved symbols" in SonicWeave or a quick rundown of all the basic features of the language for that matter? just so i have some idea of what to avoid and look out for compatibility-wise

. as beats is very intuitive; think i used that too and you came up with it (or took from xenpaper?) and xenpaper uses it so that's a safe bet for a good notation too

The AST type definitions of BinaryOperator and UnaryExpression give a fairly good overview of what's reserved: https://github.com/xenharmonic-devs/sonic-weave/blob/main/src/ast.ts

The full grammar is here: https://github.com/xenharmonic-devs/sonic-weave/blob/main/src/sonic-weave.pegjs

I'll document everything better eventually... Before 1.0.0 is release at least.

osmiumic commented 4 months ago

first key observation: specifying duration is really important for composing. any grid-based composition notation quickly becomes inconvenient when you want to subdivide something only in a few places in a piece. this becomes especially problematic with polyrhythms. therefore i believe that specification of duration should be very fundamental. i suggest:

a/b [x y z] or [x y z] a/b

(i'm subjectively inclined to put a/b after intuitively but) logically/from first principles, we want the duration at some regular position, so that it's easy to find when sightreading. we can strengthen this reasoning by making it so that the first chord on each line is aligned visually automatically, so that the [ bracket is aligned and therefore so that the modifiers on its left are aligned.

keeping chord modifiers consistently on the left side also means we can list multiple chords in one line if we want, using commas to disambiguate if needed. because we have elementwise application this notation may need to be revised because it could be interpreted as [(a/b) x (a/b) y (a/b) z], but i suspect allowing that would introduce other problems and would repeatedly specify the same note redundantly anyways, so i'll assume that "space operator" elementwise application can be unique to specification of duration relative to the duration of the chord specified and that it must be in the order that the duration is on the left and the chord is on the right.

another reason for this restriction is to mirror xenpaper and scaleworkshop's use of significant newlines to denote new musical information, as i'm assuming that using a newline as a synonym for the space operator will not be problematic as it would give the composer freedom to chunk musical information as they feel is most appropriate/readable where too many newlines might ensue or where the newlines would be too unintuitive w.r.t the music's timing.

i also suggest that the difference between (x y z) and [x y z] is that (x y z) is a set of pitches (AKA array of rationals/numbers) x:y:z while [x y z] implies a default unit duration of 1/1 of a beat (so that each duration-unspecified chord takes up one beat and one line if there's one chord per line). this also allows easy transformation of a timeless chord to a unit-duration chord by enclosing it in [].

here is an example 34 EDO progression in xenpaper:

{34edo}(bpm:60)
[0 11 20]-
[0 14 25]
[9 20 29]-
[0 14 25]
[0 11 20]-
[11 20 31]
[20 31 40]-
[11 20 31]

second key observation: transposition is really important for composing. we don't want to write the same chord in multiple ways if we don't have to/want to. xenpaper solves this by re-specifying the root, but i believe a better solution is introducing our first rational-chord operator, either + or *. both have advantages. if we use + then we can stretch and shrink chords in logarithmic space using *. if we use * then we can use + to denote adding some number to the harmonics of the JI chord in its minimal mode expression. this is resolved simply by using ^ for chord stretching, so that ^2 represents all the pitches in the chord having their distance relative to the root doubled, ^(2/3) represents them being 2/3 of the distance, etc. another important point is we want to be able to specify some note of the chord as a pivot point. i'm not sure what notation thatd be but considering it doesnt make sense to nest chords (because you'd just be doing the equivalent of a set union) i suggest using [p] to highlight a pivot note p which we can do transforms relative to. this also then allows highlighting two pivot points for doing mirroring w.r.t two notes. in other words, we are assuming there is no reason to nest [] so that a [] inside a [] can convey a different meaning.

with that all said let's see how the example looks (note: i'm not sure if how im doing variable assignment here is valid):

tuning = 2.3.5.13.17[34] # makeshift notation for specifying the tuning and mapping to use (requires subgroup rank>2)
M = [0 11 20]
M
1/2 [0 14 25]
9\34 * M # alternatively since we're using 34 EDO we can do 9\* or ~6/5 *
1/2 [0 14 25]
M
1/2 M[[1 2]]^-1 # notation for highlighting 1st & 2nd note above root as pivots s.t ^-1 flips w.r.t their midpoint
~3/2 * M # the ~ means to use the mapping of 3/2, that is, 20\34, rather than the exact ratio
1/2 M[[1 2]]^-1

(i would find using + more intuitive to read, but firstly that's unnecessarily inconsistent and secondly we'd need some operator with lower precedence than + which actually makes sense mathematically. i don't know whether such an operator exists, especially given the niche usecase, so i guess that * is probably better to get used to (unless we find good reasons later to reject this).)

also note that while the duration is required to be on the left of the chord, normal binary operators on chords-as-sets-of-pitches certainly aren't! so ~3/2 * M is equivalent to M * ~3/2 if you prefer the latter for whatever reason.

third key observation: sometimes we want a chord archetype to specify only certain notes and leave others as depending on the situation. in other words, we want to leave obvious "blanks" in the expression that are filled out later. my intuition tells me this has some sort of correspondence with substitution in lambda calculus so is probably a good/powerful idea.

these are my thoughts for now. i'll offer more design thoughts after a response for any significant issues with the notation so that i can include such consideration in the next design draft, where i'll use some more complex xen chord progressions whose simple expression requires the aforementioned "substitution" of notes.

frostburn commented 4 months ago

Thank you for the input and ideas! For sheet music I wouldn't make newlines significant. I'm more of a fan of sections that can be grouped in lines.

For the effective scale I suggest a preample delineated with double curly brackets: {{ /* Scala-type stuff here */ }}. The effective scale defaults to 12ed2 and outside of the preample bare integers refer to zero-indexed scale degrees instead of fractions with a unit denominator.

I'm not warming up to the suggested notation where stuff outside of square brackets means something else than inside square brackets.

For specifying duration I suggest a new binary operator @. By itself 3/2 means to play a note that lasts one beat, advances time by one beat and sound it at 1.5 times the reference frequency. Then 2 @ 3/2 would mean to play a note that lasts two beats, advances time by two beats, sets the default duration to two beats and sounds the note at 1.5 times the reference frequency. As with all operators it vectorizes over arrays 1/2 @ [1/1 5/4 3/2] is syntactic sugar for [1/2@1/1 1/2@5/4 1/2@3/2]. With a tilde ~@ the change in duration isn't permanent.

For the effective tempering I suggest a postample delineated with double curly brackets.

Let's keep comments Javascript: // and /* ... */ not #.

Your example becomes:

"The ballad of osmiumic"
{{
 "31-tone equal temperament"
 ed(31)
}}
const M = [0 11 20] // Storage class definition is mandatory: let or const.
M // Pushes scale degrees 0, 11 and 20 onto the current sheet starting at beat 0 and lasting until beat 1.
1/2 ~@ [0 14 25] // Pushes degrees 0, 14, 25 onto the current sheet starting at beat 1 and lasting until beat 1.5.
M ~^ 9/32 // Left-preferring universal exponentiation i.e. stretching in pitch space. (start = beat 1.5, end = beat 2.5)
1/2 @ [0 14 25] // Start = beat 2.5, end = beat 3 (Now the change of duration is permanent.)
M // Start = beat 3, end = beat 3.5
/* Pivots omitted due to it being way to early to think about it... */
3/2 *~ M // Vectorizes over M, tempered later.
/* Pivot omitted */
{{
  31@ // Temper all just intonation in the current sheet to 31p. Makes that 3/2 into 20\34. Leaving fractions of the octave unchanged relies on tempering being idempotent.
}}
osmiumic commented 4 months ago

avoiding repeat info with @ by default is a good idea but i can't help but think it's unintuitive for it to specify duration of all subsequent things, especially considering that @ is supposed to be syntactic sugar for specifying the duration of individual notes through vectorisation, so i would strongly suggest having the meanings of @ and @~ swapped, or avoiding (an operator like) @~ entirely and using variables specifying the time duration instead (which is more general and programmatic while keeping chord information context-free). i can't say anything about how you've specified the tuning and val as i don't understand the notation involved, so i'm assuming it's consistent with what you currently have? finally, if 3/2 *~ M is tempered later, when why is there a need for ~? what i was thinking is one should be able to freely mix JI with the tempered JI if one wants to, and that the tempered JI should always be of the form ~interval * so that it's obvious we want the approximation rather than the pure tuning so that interval * can take its literal exact value/meaning (as a transposition of the chord upwards by interval) wherever it appears.

finally, if you are taking [0 11 20] as a timeless chord then it doesnt make sense to be able to say M and have it mean "1 beat duration" without specifying a duration of 1@. therefore either [0 11 20] is syntactic sugar for 1@(0 11 20) (so that applying t@M means to multiply the duration of the chord M) or it isn't, in which case you need to specify the duration 1@ before each line (or some syntactic simplification thereof); this follows from your own logic of having interval mean timeless interval/ratio wherever it appears as a "thing to play" in the sheet (at least if i'm understanding correctly).

with these considerations in mind i suggest this notation:

"The ballad of osmiumic"
{{
 "34-tone equal temperament"
 ed(34) // correction to 34 EDO (not sure why it was changed to 31 except maybe as a mistake?)
}}
const M = (0 11 20)
1@ M // Pushes scale degrees 0, 11 and 20 onto the current sheet starting at beat 0 and lasting until beat 1.
1/2@ [0 14 25] // Pushes degrees 0, 14, 25 onto the current sheet starting at beat 1 and lasting until beat 1.5.
1@ 9/34 * M // IMPORTANT: seemed to be wrong in your example: the intention is to transpose M upwards by 9\34. 
1/2@ [0 14 25] // Start = beat 2.5, end = beat 3 (note: change of duration should not be permanent if we wish to keep it the same as the xenpaper example which alternates durations of beats and half-beats.)
1@ M //  Start = beat 3, end = beat 4
1/2@ [11 20 31] // chord written manually instead of (in terms of) a pivot
~3/2 * M // Vectorizes over M, tempered *immediately*.
1/2@ [11 20 31] // chord written manually instead of (in terms of) a pivot
// do not temper just intonation in the current sheet, except where specified. how would this be written?

finally, i do not understand this comment of yours:

For sheet music I wouldn't make newlines significant. I'm more of a fan of sections that can be grouped in lines.

i think you misunderstood;: i was proposing that newlines function exactly the same as the space operator, so they are "significant" in the sense that they convey information to the interpreter/compiler BUT they completely allow grouping chords/sections into lines (which i noted the importance of the freedom to do in my post).

(my reason for choosing # for comments was cuz the syntax already looked pythonic due to lack of line end indicators like ; 😅)

frostburn commented 4 months ago

3/2 *~ M has nothing to do with @~ or ~@ (I'm just running out of ASCII here). It uses "universal wings of preference" an annoying but necessary aspect of SonicWeave's domain system. Compare these expressions: 3/2 * 3/2 is the same as 9/4 while 3/2 * P5 is the same as n7. Using universally linear multiplication preferring the latter argument 3/2 *~ P5 evaluates to M9 as intended.

frostburn commented 4 months ago

SonicWeave doesn't lack semicolons. They're actually required by the grammar! It's Javascript that's being silly and having automatic semicolon insertion which we decided to inherit because Scala .scl didn't have semicolons and we want to look like Scala.

osmiumic commented 4 months ago

3/2 *~ M has nothing to do with @~ or ~@ (I'm just running out of ASCII here). It uses "universal wings of preference" an annoying but necessary aspect of SonicWeave's domain system. Compare these expressions: 3/2 * 3/2 is the same as 9/4 while 3/2 * P5 is the same as n7. Using universally linear multiplication preferring the latter argument 3/2 *~ P5 evaluates to M9 as intended.

i don't understand (for sure) why this is needed in the example (although i have a guess); would the example i wrote work as intended? or would there be some unexpected octave reduction somewhere?

i also don't understand it to be honest 😅 wdym by "3/2 * P5 is the same as n7"? wouldn't it instead be the same as M2? where is n7 coming from and for that matter what is n7?

finally, why wouldn't 3/2 * P5 just equal M9 directly?

frostburn commented 4 months ago

It's needed in the example because the scale steps are in the logarithmic domain while 3/2 is a linear literal.

frostburn commented 4 months ago

n3 is the neutral third. Monzo [-1/2, 1/2> which is 1/2 * P5 so 3/2 * P5 is the neutral seventh i.e. [-3/2, 3/2>.

osmiumic commented 4 months ago

aha i see now, i didn't realise that's what was going on. if * means multiplication in the logarithmic domain that invalidates the + * ^ convention choice i suggested. how does it work if we are using JI for this chord progression? surely it's not using logarithmic domain there too, no? so why is it logarithmic in this case and not in the other? is this something we could reasonably change, so that multiplication by a rational always means transposing the note upwards by that frequency ratio?

frostburn commented 4 months ago

When I said ed(34) (thanks for correcting, by the way) I filled the current scale with logarithmic quantities. In the sheet code when I say 1 it replaces it with 1\34 which is a logarithmic quantity and behaves according to its domain.

Had I said eulerGenus(675) in the preample, 1 in the sheet code would've evaluated to 135/128, a linear quantity.

"Winged" multiplication by a rational always means transposition. It's just one extra ~ to type.

osmiumic commented 4 months ago

is it possible to circumvent this by specifying 34 EDO in terms of absolute frequencies? like would specifying (1\34 2\34 3\34 ... 33\34 34\34) work?

frostburn commented 4 months ago

The linear syntax for edos is 2^1/34 etc.

osmiumic commented 4 months ago

i'll be honest and say i find it a little frustrating that a\b isnt sugar for 2^(a/b) 😅 i presume it's so we can have x\b + y\b = (x+y)\b (and all of its nice consequences)? but how does the code determine whether something is logarithmic or not?

frostburn commented 4 months ago

It uses this table to determine the domain of a literal: https://github.com/xenharmonic-devs/sonic-weave?tab=readme-ov-file#basic-interval-types

frostburn commented 2 months ago

Did some preliminary checks that the grammar is clean enough for time stuff to fit in eventually.

One issue that stands out in debugging is streamability. With scales it's fine to parse the whole thing to an AST, execute the AST, convert resulting runtime objects to frequencies and only then start noodling away at the isomorphic keyboard. However with symphonic scores potentially spanning multiple pages and multiple files it would be really nice if you could do playback as you go. Being able to do vertical alignment in the source is mandatory, so that requires some kind of 1-pass chunking system. Once the time alignment of the chunks are known realtime playback might become possible using finite lookahead. Lookahead is required anyway to do proper articulation in the synths.

osmiumic commented 2 months ago

the easiest way to support this would be to parse at the level of individual "statements", usually corresponding to lines;

in other words, if you assume the score is well-formed then each statement in that score should be separable;

we already want newlines to be significant, right? so why not insist on a guarantee on the form of the program that no expression may be multi-line, as a unique restriction specific to the SWS format; this shouldn't be an issue if one does need to use multiple lines, because the language should be able to support separating musical information into multiple lines; if you can't then you have an extremely long expression for a single musical "entity" that probably (for ease of both reading and writing) should be expressed in terms of one or more functions that build up its component parts

frostburn commented 2 months ago

SonicWeaveScore syntax will need to be compact and horizontal. Separating each note on individual lines is only reasonable in scale design where verticality can be afforded.

osmiumic commented 2 months ago

definitely not each note, i was thinking more like each musical phrase so it could be 1 chord or 1 melody or a few chords per line (maybe we separate individual units with ; in-line?)

the idea being just that the parser would only have to worry about parsing one full line of (presumably valid) input (and it could discard invalid lines/give errors for them without jeopardising the meaning of the rest (at least if no functions are defined that are used later))

frostburn commented 2 months ago

Even with that we can't guarantee that statements won't jump in time all over the place. There needs to be stronger super-statements with stronger time-safety guarantees for a preprocessing step to make sense and start streaming.

osmiumic commented 2 months ago

you mean like the time ranges of the notes in each line could overlap to other lines in a way that makes it nontrivial? hmm that should definitely be solvable i think, just thinking what the easiest way would be

frostburn commented 2 months ago

There are existing text-based music systems out there. I'm sure this is a solved issue. Just need to do research.

osmiumic commented 2 months ago

fwiw, in my simplified imagination of how itd work, i wasnt envisioning each note bleeding into further than the next line, except maybe as like an echo or something (something that can be processed cumulatively in the background as it plays)

and now that i think about it, shouldnt it be fine to allow forwards-bleed as long as backwards-bleed is banned? in other words, if you only allow chords whose notes have nonnegative starting times to be "played" in SWS then it should be fine i think? so every time the line is parsed you look at the resulting attempted notes and if any are nonnegative you reject them/give an error

frostburn commented 1 month ago

(osmiumic on Discord): im thinking also abt how these different notations relate to each-other and can coexist with each-other:

My (frotburn's) reply: At first glance these seem like valid considerations. The major difference to SonicWeave(Scales) would be the lack of , characters. Currently an interval and a string separated by whitespace results in labeling of that interval. That is essential for scale design, but pretty irrelevant for sheet music where there simply isn't enough space to display additional information for every note. It's starting to look like SonicWeaveScore [name pending] is going to be an independent sister project instead of a superset of SonicWeave(Scales).