Key quantization?

What are some approaches for key quantization (in other words, conforming an input signal to an octave scale)? It seems like something could be done with a non-interpolated buffer reader but it might be weighted oddly.

Unsure what your input signal is, or the desired output. I’ll assume you mean a signal on the server, but… audio or control signal? What are the expected values and how do they behave?

In any case, the keyword in SC for converting between a scale representation of pitch and a chromatic one is degreeToKey. Searching help turns up this UGen:

http://doc.sccode.org/Classes/DegreeToKey.html

hjh

Good points. Maybe this is an X/Y problem. DegreeToKey looks promising but it’s a bit different from what I had in mind.

What I ultimately want is to be able to plug, say, a saw LFO into a frequency parameter, but instead of having a continuous slide between frequencies, it would be stepped based on the scale, both frequency-wise and timing-wise (due to the potentially irregular quantizing).

I think what I’m ultimately looking for is something like KeyToDegree -> round/truncate -> DegreeToKey.

For example, for quantizing the continuous notes to major pentatonic, this is what I’d expect to see:

// Input Key:     0---1---2---3---4---5---6---7---8---9--10--11--12
// Degree:        |---I---|----II-----|--III--|--IV---|-----V-----|
// Quantized Key: |---0---|-----2-----|---5---|---7---|-----9-----|

(This would be a truncate and round would probably make more sense)

EDIT: Somehow I didn’t think KeyToDegree was a thing, but apparently it is. It sounds like it may have issues but I’ll give it a shot.

Ah, I see.

You could load one octave’s worth of frequency ratios into a buffer (1 = root, 2 = octave up). Then maybe along these lines (not at the computer so this is untested):

// you'll need to set root to a frequency first
var freqOverRoot = freq / root,
numOctaves = freqOverRoot.log2.floor,
octaveMul = 2 ** numOctaves,
freqOneOctave = freqOverRoot / octaveMul,
index = IndexInBetween.kr(scaleBuf, freqOneOctave).round(1);

freq = Index.kr(scaleBuf, index) * root * octaveMul;

Or load the buffer with multiple octaves and drop the octave math.

hjh

Tested now. This does seem to do it:

s.boot;

b = Buffer.alloc(s, 13, 1);
// load with a just intonation chromatic scale, you can use any
// or diatonic, just make it 8 frames instead of 13
// note that you should include the wraparound "2" value
// so it's 12 chromatic divisions + 1 = 13
b.setn(0, [1, 25/24, 9/8, 6/5, 5/4, 4/3, 45/32, 3/2, 8/5, 5/3, 9/5, 15/8, 2]);

a = {
	var freq = MouseX.kr(220, 880, 2, 0.1),
	root = 220,
	freqOverRoot = freq / root,
	numOctaves = freqOverRoot.log2.floor,
	octaveMul = 2 ** numOctaves,
	freqOneOctave = freqOverRoot / octaveMul,
	index = IndexInBetween.kr(b, freqOneOctave).round(1);
	
	freq = Index.kr(b, index) * root * octaveMul;
	
	SinOsc.ar(freq, 0, 0.1).dup
}.play;

a.trace;
a.free;

hjh

1 Like

This seems to be a very common use case, wouldn’t it be nice to have a UGen or a Pseudo-UGen covering it?
I made this, it seems to work fine ( for an octave repeating scale).

  • It takes a frequency, a buffer with scale degrees (such as Scale.major.degrees), and a root pitch class from 0 to 12.

  • It outputs both the tuned frequency and the tuning ratio ( it would probably be better to have two different ugens for these two )

FreqToScale : MultiOutUGen {
    *kr { arg freq=440, scaleBuf=0, rootPitchClass=0;
        ^this.multiNew('control', freq,scaleBuf,rootPitchClass);
    }

    *new1 { arg ugen_rate, freq=440, scaleBuf=0, rootPitchClass=0;
        var degree = freq.cpsmidi - rootPitchClass % 12;
		
        var tunedDegree = Index.kr(
			scaleBuf,
			IndexInBetween.kr(scaleBuf,degree).round
        );

        var tuneInterval = (tunedDegree-degree).midiratio;
        var tunedFreq = freq * tuneInterval;

        ^[tunedFreq,tuneInterval];
    }
}
2 Likes

This looks like a good idea, but your implementation seems to assume 12TET, which could be misleading for other temperaments.

True, but i think other temperaments would be fine as long as they can be expressed as midi intervals using floating point numbers, please correct me if I’m wrong.
The main assumption here is that the scale repeats exactly for every octave.

1 Like

This implementation definitely works. I modified it a bit to get closer to what I’m shooting for:

s.boot;
b = Buffer.alloc(s, 13, 1);

b.setn(
	0,
	(0..12).collect({|i| 
		var scale = Scale.majorPentatonic;
		var nearestKey = i.keyToDegree(scale, 12).round.degreeToKey(scale, 12);
		nearestKey.midicps / 0.midicps
	})
)

(
a = {
	var freq = SawDPW.kr(
		freq: MouseX.kr(1/8, 6, 1, 1),
		mul: MouseY.kr(12, 48, 1, 1), 
		add: 60
	).midicps,
	root = 220,
	freqOverRoot = freq / root,
	numOctaves = freqOverRoot.log2.floor,
	octaveMul = 2 ** numOctaves,
	freqOneOctave = freqOverRoot / octaveMul,
	index = IndexInBetween.kr(b, freqOneOctave).round(1);
	
	freq = Index.kr(b, index) * root * octaveMul;
	
	LFTri.ar(freq, 0, 0.1).dup
}.play;
)

a.trace;
a.free;

EDIT: That said, I prefer @elgiano’s approach.

I’m trying to factor out the buffer but it looks like the control signal doesn’t understand keyToDegree.

(
a = {
	var scale = Scale.majorPentatonic;
	var inputKey = SawDPW.kr(
		freq: MouseX.kr(1/8, 6, 1, 1),
		mul: MouseY.kr(12, 48, 1, 1), 
		add: 60
	);
	var nearestKey = inputKey.keyToDegree(scale, 12).round.degreeToKey(scale, 12);
	LFTri.ar(nearestKey.midicps, 0, 0.1).dup
}.play;
)

Error:

ERROR: Message 'keyToDegree' not understood.
RECEIVER:
Instance of MulAdd {    (0x55aafd5944c8, gc=AC, fmt=00, flg=00, set=03)
  instance variables [8]
    synthDef : instance of SynthDef (0x55aafe459478, size=16, set=4)
    inputs : instance of Array (0x55aafe3a7488, size=3, set=2)
    rate : Symbol 'control'
    synthIndex : Integer 4
    specialIndex : Integer 0
    antecedents : nil
    descendants : nil
    widthFirstAntecedents : nil
}
ARGS:
Instance of Scale {    (0x55aafe45e7e8, gc=A8, fmt=00, flg=00, set=02)
  instance variables [4]
    degrees : instance of Array (0x55aafc045300, size=5, set=3)
    pitchesPerOctave : Integer 12
    tuning : instance of Tuning (0x55aafe45e378, size=3, set=2)
    name : "Major Pentatonic"
}
   Integer 12

PROTECTED CALL STACK:
	Meta_MethodError:new	0x55aafa8f0400
		arg this = DoesNotUnderstandError
		arg what = nil
		arg receiver = a MulAdd
	Meta_DoesNotUnderstandError:new	0x55aafa8f23c0
		arg this = DoesNotUnderstandError
		arg receiver = a MulAdd
		arg selector = keyToDegree
		arg args = [ Scale([ 0, 2, 4, 7, 9 ], 12, Tuning([ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0 ], 2.0, "ET12"), "Major Pentatonic"), 12 ]
	Object:doesNotUnderstand	0x55aafa179040
		arg this = a MulAdd
		arg selector = keyToDegree
		arg args = nil
	a FunctionDef	0x55aafe4a71c8
		sourceCode = "{
	var scale = Scale.majorPentatonic;
	var inputKey = SawDPW.kr(
		freq: MouseX.kr(1/8, 6, 1, 1),
		mul: MouseY.kr(12, 48, 1, 1), 
		add: 60
	).keyToDegree(scale, 12).round.degreeToKey(scale, 12);
	
	LFTri.ar(inputKey.midicps, 0, 0.1).dup
}"
		var scale = Scale([ 0, 2, 4, 7, 9 ], 12, Tuning([ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0 ], 2.0, "ET12"), "Major Pentatonic")
		var inputKey = nil
	SynthDef:buildUgenGraph	0x55aafbeda2c0
		arg this = SynthDef:temp__28
		arg func = a Function
		arg rates = nil
		arg prependArgs = [  ]
		var result = nil
		var saveControlNames = [ ControlName  P 0 i_out scalar 0 ]
	a FunctionDef	0x55aafb6ff680
		sourceCode = "<an open Function>"
		arg i_out = an OutputProxy
		var result = nil
		var rate = nil
		var env = nil
	SynthDef:buildUgenGraph	0x55aafbeda2c0
		arg this = SynthDef:temp__28
		arg func = a Function
		arg rates = nil
		arg prependArgs = [  ]
		var result = nil
		var saveControlNames = nil
	a FunctionDef	0x55aafbed8900
		sourceCode = "<an open Function>"
	Function:prTry	0x55aafabb3900
		arg this = a Function
		var result = nil
		var thread = a Thread
		var next = nil
		var wasInProtectedFunc = false
	
CALL STACK:
	DoesNotUnderstandError:reportError
		arg this = <instance of DoesNotUnderstandError>
	Nil:handleError
		arg this = nil
		arg error = <instance of DoesNotUnderstandError>
	Thread:handleError
		arg this = <instance of Thread>
		arg error = <instance of DoesNotUnderstandError>
	Object:throw
		arg this = <instance of DoesNotUnderstandError>
	Function:protect
		arg this = <instance of Function>
		arg handler = <instance of Function>
		var result = <instance of DoesNotUnderstandError>
	SynthDef:build
		arg this = <instance of SynthDef>
		arg ugenGraphFunc = <instance of Function>
		arg rates = nil
		arg prependArgs = nil
	Function:play
		arg this = <instance of Function>
		arg target = <instance of Group>
		arg outbus = 0
		arg fadeTime = 0.02
		arg addAction = 'addToHead'
		arg args = nil
		var def = nil
		var synth = nil
		var server = <instance of Server>
		var bytes = nil
		var synthMsg = nil
	< closed FunctionDef >  (no arguments or variables)
	Interpreter:interpretPrintCmdLine
		arg this = <instance of Interpreter>
		var res = nil
		var func = <instance of Function>
		var code = "(
a = {
	var scale = Scale.m..."
		var doc = nil
		var ideClass = <instance of Meta_ScIDE>
	Process:interpretPrintCmdLine
		arg this = <instance of Main>
^^ The preceding error dump is for ERROR: Message 'keyToDegree' not understood.
RECEIVER: a MulAdd

Exactly. Search for degreeToKey, you’ll find it’s implemented by many classes, including UGen, while keyToDegree is not. Looking into the source code for UGen:degreeToKey, you’ll find that it’s using the DegreeToKey UGen… and there isn’t any KeyToDegree UGen.

Long story short: you can’t search an array in a SynthDef without using a buffer. This is because if the search has to be dynamic or controlled by ugens, it has to be performed on the server, and then the server has to get the data in a buffer.

If you want to factor out the buffer from your SynthDef you need to perform your searches on the client, which will probably mean sending back to the language some index (in this case your inputKey) through SendReply/OSCFunc. It gets more complex… so I would prefer using a buffer here

OSCdef('test',{|msg| 
	var scale = Scale.majorPentatonic;
	var key = msg[3].keyToDegree(scale, 12).round.degreeToKey(scale, 12);
	a.set(\nearestKey, key.postln);
},"/index");

a = {
	var inputKey = SawDPW.kr(
		freq: MouseX.kr(1/8, 6, 1, 1),
		mul: MouseY.kr(12, 48, 1, 1), 
		add: 60
	);
	
	SendReply.kr(Changed.kr(inputKey,0.1),"/index",inputKey);
	LFTri.ar(\nearestKey.kr.midicps, 0, 0.1).dup
}.play

PS:

I’ve tried it with Tuning.partch.semitones and it works fine

1 Like

I managed to do this without a buffer using IEnvGen. It’s a little bit hacky (12 values and 10 durations) due to my inexperience with arrays (edit: collections) in SC, but it seems to work:

(
{
	var scale = Scale.majorPentatonic;
	var scaleKeys = scale.as(List);
	var envArgs = (scaleKeys ++ 12).inject(nil, {|acc, val|
		if(
			acc.isNil, 
			{ [val.dup, []] }, 
			{ 
				var last = acc[0][acc[0].size - 1];
				var diff = val - last;
				[acc[0] ++ val.dup, acc[1] ++ [diff, 0]]
			}
		);
	});
	var env = Env.new(envArgs[0], envArgs[1]);
	var indexInput = LFSaw.ar(1/2, 0, 12, 60);
	var index = indexInput % 12;
	var octave = indexInput - index;
	
	SinOsc.ar((IEnvGen.ar(env, index) + octave).midicps);
}.play;
)

I just put this together and have not tested it with any other scales or temperaments.

Ok! Glad you found something that works for you! It’s actually interesting to define a scale in terms of both notes and durations of each note :slight_smile:
A few things about collections and envelopes (which you might already know or not):

Envelopes do .wrapExtend(levels.size-1) to durations in order to make the durations array the correct size no matter what. It means that you can have an Env([1,2,3,4,5],1),
where all segments will have duration 1; an Env([0,1],[1,2,3,4,5]), where the only segment present will have duration 1 (the durations array gets truncated); or an Env([1,2,3,4,5],[0,1]) where the [0,1] pattern is repeated (in this case it becomes [0,1,0,1,0]).

I see your inject block, and it looks kind of difficult to read. The following are some little things you might not know:

  • acc[0][acc[0].size - 1] can be written more simply as acc[0].last
  • you can use differentiate to get the pairwise differences between every element in a collection (e.g [1,2,3,40].differentiate -> [1,1,1,37])

You can use Env.step to simplify your life a lot

(
{
	var scale = Scale.majorPentatonic;
	var scaleKeys = scale.as(List) ++ 12;
	var env = Env.step(scaleKeys,scaleKeys.differentiate);
	var indexInput = LFSaw.ar(1/2, 0, 12, 60);
	var index = indexInput % 12;
	var octave = indexInput - index;
	
	SinOsc.ar((IEnvGen.ar(env, index) + octave).midicps);
}.play;
)
1 Like

Oh my, excellent tidbits. Thank you!