monome / crow

Crow speaks and listens and remembers bits of text. A scriptable USB-CV-II machine
GNU General Public License v3.0
166 stars 34 forks source link

Qscale library #452

Open trentgill opened 2 years ago

trentgill commented 2 years ago

WIP

Qscale

This is a small scale-quantizer library in a similar style to input[n].mode('scale',...) and output[n].scale = ....

it enables the same syntax as the input and output libs, but decouples the behaviour from hardware.

using qscale is a 2-part process: 1) create a qscale function with qscale(...) or qscale.ji(...) 2) use it by passing your value-to-be-quantized through the generated function.

looks like this:

myquantizer = qscale{0,2,4,7,9}
print( myquantizer(0.4) ) --> 0
print( myquantizer(15) ) --> 14/12 (1 octave + 2semitones)

in the above example, we use a table call which expects the table to be in 12TET note numbers, and will convert the output to a voltage (divides the quantized value by 12).

in/out scaling

you can provide input & output scaling, similar to the in/out libs

-- here 12,12 means note table is in 12TET and output should be as well
s1 = qscale({0,3,6,9},12,12)

-- this is the same as the defaults (ie scale output to 1.0 per octave)
s1 = qscale({0,3,6,9},12,1.0)

plus there are shortcuts for chromatic scaling if you want that:

-- uses default scaling 12TET->1V/8ve
s1 = qscale()
-- equivalent to:
s1 = qscale{0,1,2,3,4,5,6,7,8,9,10,11}

-- or provide your own scaling if you want
s1 = qscale({}, 12, 12)
-- equivalent to:
s1 = qscale({0,1,2,3,4,5,6,7,8,9,10,11}, 12, 12)

random access sequencers

the values don't have to be a linear scale! you can (mis)use qscale as a lookup table to build random-access sequencers:

lut = qscale{0,7,14,9,4,11,6}

specifically note here that 1) note-table values can be outside the octave range (eg: 14 above) 2) note-table need not be in order 3) octaves will be added and subtracted based on input values.

if you want to define a lookup table that repeats every 2 octaves, you could try:

lut = qscale({0,7,14,9,4,11,6}, 24, 2.0)

^^ this is untested, but it should be something close to what you expect...

just intonation

just like the input/output libs, you can provide the note table as a series of just ratios, using the special function qscale.ji. this differs from the input/output libs where you pass 'ji' as the 'divisions' value. in our case, we still need divs to represent the scaling of the input (eg. is input from an input channel (1.0) or a note-number generator (12), and output remains the same)

sj1 = sk.ji({1/1, 10/9, 9/8, 5/4, 4/3, 3/2, 8/5}, 12, 1)
trentgill commented 2 years ago

ok there is an issue with input as a voltage. it's because we are assuming the note-table is always in 12TET, and the input is as well.

consider there are 3 different scalings: 1) input source (voltage, 12TET, something else?) 2) note table (12TET, nTET, just intonation) 3) output scaling (voltage, 12TET, nTET)

we have already handled all of the output scaling. when changing the nature of the note-table to just intonation, we use a helper function qscale.ji then convert the ratios to floating point 12TET internally.

Currently unhandled is nTET note-tables. this could be via other helpers. could be indexed against qscale, so qscale[19](...) would interpret the note-table as 19TET.

If we do this, then the divs argument is explicitly what the input format is. allowing for it to be hooked to a voltage input, or a note-num generator, or anything else!

trentgill commented 2 years ago

added some improvements so input scale of 1.0 will work to accept a volt-per-octave input. table is required to be in 12TET, unless using the .ji function.

unconvinced if the indexed library syntax is clean or just confusing. i'm not adding it for now as i think there's very few people working in nTET EDO scales, and if someone asks for 19TET or whatever, i'm happy to open a ticket to discuss syntax.

trentgill commented 2 years ago

more examples:

-- make a scale object
myscale = qscale({0,2,4,7,9}, 1.0, 1.0)
-- last 2 numbers say map v/8 input to v/8 output

-- same usage as input[1].scale
input[1].stream = function(v)
    output[1].volts = myscale(v)
end

-- ideally suited for talking to ii devices.
-- you'll want to check if the value changed.
local lastv = -10
input[1].stream = function(v)
    v = myscale(v)
    if v ~= lastv then
        lastv = v -- memo change
        ii.wsyn.play_note(v, 2) -- note the v is already in volts!
    end
end
trentgill commented 1 year ago

Just mentioning that we should probably consider how to throttle by only sending an output if the quantized value hasn't changed.

At present the syntax would require the scripter to implement some kind of nil check themselves. Before including this in the main branch, it would be worthwhile exploring an alternative syntax. The qscale function would also take the function to call as an argument, so that it's able to nil-check internally and only call it if there is a change in value. This fn could be optional such that without it there is no throttling (and the consumer would just re-send duplicates, or manage them explictly).

myscale = qscale({0,2,4,7,9}, 1.0, 1.0)
myscale(0) --> 0
myscale(0.01) --> 0 -- note repetition allowed!

myscalefn = qscale(function(v) ii.wsyn.play_note(v, 2) end, {0,2,4,7,9}, 1.0, 1.0)
myscalefn(0) --> calls ii.wsyn.play_note(v,2)
myscalefn(0.01) --> ignored! quantizer output is the same, so don't send another note

with this change in mind, i think the name of the function/library should change. perhaps with_scale or do_scale or q_function... lotsof other options. really just commenting that the name isn't necessarily very communicative.