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

Add MTS pitch and SysEx format conversion methods #7

Merged
merged 5 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions src/__tests__/conversion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import {
centOffsetToFrequency,
centsToValue,
frequencyToCentOffset,
ftomts,
frequencyToMtsBytes,
ftom,
mtof,
valueToCents,
mtsToMtsBytes,
mtsBytesToHex,
mtsBytesToFrequency,
} from '../conversion';

describe('Ratio to cents converter', () => {
Expand Down Expand Up @@ -38,6 +43,241 @@ describe('MIDI to frequency converter', () => {
});
});

describe('Frequency to MTS converter', () => {
it('converts known frequency values (based on MIDI Tuning Spec)', () => {
expect(ftomts(8.175799)).toBe(0);
expect(ftomts(8.175828)).toBe(0.000062);
expect(ftomts(8.661957)).toBe(1);
expect(ftomts(16.351598)).toBe(12);
expect(ftomts(261.625565)).toBe(60);
expect(ftomts(277.182631)).toBe(61);
expect(ftomts(439.998449)).toBe(68.999939);
expect(ftomts(440)).toBe(69);
expect(ftomts(440.001551)).toBe(69.000061);
expect(ftomts(8372.01809)).toBe(120);
expect(ftomts(8372.047605)).toBe(120.000061);
expect(ftomts(12543.853951)).toBe(127);
expect(ftomts(12543.898175)).toBe(127.000061);
expect(ftomts(13289.656616)).toBe(127.999878);
expect(ftomts(13289.656616, true)).toBe(127.999878);
});
it('clamps values beyond the specified MTS frequency range', () => {
expect(ftomts(-100, false)).toBe(0);
expect(ftomts(14080, false)).toBe(127.999878);
expect(ftomts(28980, false)).toBe(127.999878);
});
it('does not clamp values above the specified MTS frequency range if ignoreLimit is true', () => {
expect(ftomts(-100, true)).toBe(0);
expect(ftomts(14080, true)).toBe(129);
expect(ftomts(28980, true)).toBeCloseTo(141.496923, 4);
});
});

describe('MTS to MTS sysex value', () => {
it('converts known values from MIDI Tuning spec', () => {
expect(mtsToMtsBytes(0)).toMatchObject(new Uint8Array([0, 0, 0]));
expect(mtsToMtsBytes(0.00006)).toMatchObject(new Uint8Array([0, 0, 1]));
expect(mtsToMtsBytes(1)).toMatchObject(new Uint8Array([1, 0, 0]));
expect(mtsToMtsBytes(12)).toMatchObject(new Uint8Array([12, 0, 0]));
expect(mtsToMtsBytes(60)).toMatchObject(new Uint8Array([60, 0, 0]));
expect(mtsToMtsBytes(61)).toMatchObject(new Uint8Array([61, 0, 0]));
expect(mtsToMtsBytes(68.99994)).toMatchObject(
new Uint8Array([68, 127, 127])
);
expect(mtsToMtsBytes(69)).toMatchObject(new Uint8Array([69, 0, 0]));
expect(mtsToMtsBytes(69.000061)).toMatchObject(new Uint8Array([69, 0, 1]));
expect(mtsToMtsBytes(120)).toMatchObject(new Uint8Array([120, 0, 0]));
expect(mtsToMtsBytes(120.000061)).toMatchObject(
new Uint8Array([120, 0, 1])
);
expect(mtsToMtsBytes(127)).toMatchObject(new Uint8Array([127, 0, 0]));
expect(mtsToMtsBytes(127.000061)).toMatchObject(
new Uint8Array([127, 0, 1])
);
expect(mtsToMtsBytes(127.999878)).toMatchObject(
new Uint8Array([127, 127, 126])
);
});
it('clamps values beyond the specified MTS frequency range', () => {
expect(mtsToMtsBytes(-1)).toMatchObject(new Uint8Array([0, 0, 0]));
expect(mtsToMtsBytes(127.9999)).toMatchObject(
new Uint8Array([127, 127, 126])
);
expect(mtsToMtsBytes(128)).toMatchObject(new Uint8Array([127, 127, 126]));
});
});

describe('Frequency to MTS sysex value', () => {
it('converts known values from MIDI Tuning spec', () => {
expect(frequencyToMtsBytes(8.175799)).toMatchObject(
new Uint8Array([0, 0, 0])
);

expect(frequencyToMtsBytes(8.175828)).toMatchObject(
new Uint8Array([0, 0, 1])
);

expect(frequencyToMtsBytes(8.661957)).toMatchObject(
new Uint8Array([1, 0, 0])
);

expect(frequencyToMtsBytes(16.351598)).toMatchObject(
new Uint8Array([12, 0, 0])
);

expect(frequencyToMtsBytes(261.625565)).toMatchObject(
new Uint8Array([60, 0, 0])
);

expect(frequencyToMtsBytes(277.182631)).toMatchObject(
new Uint8Array([61, 0, 0])
);

expect(frequencyToMtsBytes(439.998449)).toMatchObject(
new Uint8Array([68, 127, 127])
);

expect(frequencyToMtsBytes(440)).toMatchObject(new Uint8Array([69, 0, 0]));
expect(frequencyToMtsBytes(440.001551)).toMatchObject(
new Uint8Array([69, 0, 1])
);

expect(frequencyToMtsBytes(8372.01809)).toMatchObject(
new Uint8Array([120, 0, 0])
);

expect(frequencyToMtsBytes(8372.047605)).toMatchObject(
new Uint8Array([120, 0, 1])
);

expect(frequencyToMtsBytes(12543.853951)).toMatchObject(
new Uint8Array([127, 0, 0])
);

expect(frequencyToMtsBytes(12543.898175)).toMatchObject(
new Uint8Array([127, 0, 1])
);

expect(frequencyToMtsBytes(13289.656616)).toMatchObject(
new Uint8Array([127, 127, 126])
);
});
it('converts other known values', () => {
expect(frequencyToMtsBytes(255.999612)).toMatchObject(
new Uint8Array([59, 79, 106])
);

expect(frequencyToMtsBytes(256)).toMatchObject(
new Uint8Array([59, 79, 106])
);

expect(frequencyToMtsBytes(441.999414)).toMatchObject(
new Uint8Array([69, 10, 6])
);

expect(frequencyToMtsBytes(442)).toMatchObject(new Uint8Array([69, 10, 6]));
});
it('clamps values beyond supported MTS range', () => {
expect(frequencyToMtsBytes(-1)).toMatchObject(new Uint8Array([0, 0, 0]));
expect(frequencyToMtsBytes(14000)).toMatchObject(
new Uint8Array([127, 127, 126])
);
});
});

describe('MTS sysex value to frequency ', () => {
it('converts known values from MIDI Tuning spec', () => {
expect(mtsBytesToFrequency(new Uint8Array([0, 0, 0]))).toBeCloseTo(
8.175799,
4
);
expect(mtsBytesToFrequency(new Uint8Array([0, 0, 1]))).toBeCloseTo(
8.175828,
4
);
expect(mtsBytesToFrequency(new Uint8Array([1, 0, 0]))).toBeCloseTo(
8.661957,
4
);
expect(mtsBytesToFrequency(new Uint8Array([12, 0, 0]))).toBeCloseTo(
16.351598,
4
);
expect(mtsBytesToFrequency(new Uint8Array([60, 0, 0]))).toBeCloseTo(
261.625565,
4
);
expect(mtsBytesToFrequency(new Uint8Array([61, 0, 0]))).toBeCloseTo(
277.182631,
4
);
expect(mtsBytesToFrequency(new Uint8Array([68, 127, 127]))).toBeCloseTo(
439.998449,
4
);
expect(mtsBytesToFrequency(new Uint8Array([69, 0, 0]))).toBe(440);
expect(mtsBytesToFrequency(new Uint8Array([69, 0, 1]))).toBeCloseTo(
440.001551,
4
);
expect(mtsBytesToFrequency(new Uint8Array([120, 0, 0]))).toBeCloseTo(
8372.01809,
4
);
expect(mtsBytesToFrequency(new Uint8Array([120, 0, 1]))).toBeCloseTo(
8372.047605,
4
);
expect(mtsBytesToFrequency(new Uint8Array([127, 0, 0]))).toBeCloseTo(
12543.853951,
4
);
expect(mtsBytesToFrequency(new Uint8Array([127, 0, 1]))).toBeCloseTo(
12543.898175,
4
);
expect(mtsBytesToFrequency(new Uint8Array([127, 127, 126]))).toBeCloseTo(
13289.656616,
4
);
});
it('converts other known values', () => {
expect(mtsBytesToFrequency(new Uint8Array([59, 79, 106]))).toBeCloseTo(
255.999612,
4
);
expect(mtsBytesToFrequency(new Uint8Array([69, 10, 6]))).toBeCloseTo(
441.999414,
4
);
});
it('clamps values beyond supported MTS range', () => {
expect(mtsBytesToFrequency(new Uint8Array([68, 199, 199]))).toBeCloseTo(
439.998449,
4
);
expect(mtsBytesToFrequency(new Uint8Array([199, 199, 199]))).toBeCloseTo(
13289.656616,
4
);
});
});

describe('MTS data hex string converter', () => {
it('converts other known values', () => {
expect(mtsBytesToHex(new Uint8Array([60, 0, 0]))).toEqual('3c0000');
expect(mtsBytesToHex(new Uint8Array([69, 0, 0]))).toEqual('450000');
expect(mtsBytesToHex(new Uint8Array([69, 10, 6]))).toEqual('450a06');
});
it('clamps int values above 0x7f to 0x7f', () => {
expect(mtsBytesToHex(new Uint8Array([69, 240, 6]))).toEqual('457f06');
expect(mtsBytesToHex(new Uint8Array([128, 255, 128]))).toEqual('7f7f7f');
});
it('allow value reserved for "no tuning change"', () => {
expect(mtsBytesToHex(new Uint8Array([127, 127, 127]))).toEqual('7f7f7f');
});
});

describe('Frequency to MIDI converter', () => {
it('converts a known value', () => {
const [index, offset] = ftom(261.625565);
Expand Down
88 changes: 87 additions & 1 deletion src/conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,25 @@ export function centOffsetToFrequency(offset: number, baseFrequency = 440) {
const MIDI_NOTE_NUMBER_OF_A4 = 69;
/**
* Convert MIDI note number to frequency.
* @param index MIDI note number.
* @param index MIDI note number or MTS value.
* @returns Frequency in Hertz.
*/
export function mtof(index: number) {
return 440 * Math.pow(2, (index - MIDI_NOTE_NUMBER_OF_A4) / 12);
}

/**
* Convert frequency to MTS number (semitones with A440=69).
* @param frequency Frequency in Hertz.
* @returns MTS value
*/
export function ftomts(frequency: number, ignoreLimit = false): number {
if (frequency <= 0) return 0;
if (frequency > 13289.656616 && !ignoreLimit) return 127.999878; // Highest possible MTS value, corresponding to 7F 7F 7E
const mts = MIDI_NOTE_NUMBER_OF_A4 + 12 * Math.log2(frequency / 440);
return Math.round(mts * 1000000) / 1000000;
}

/**
* Convert frequency to MIDI note number and pitch offset measured in cents.
* @param frequency Frequency in Hertz.
Expand All @@ -70,6 +82,80 @@ export function ftom(frequency: number): [number, number] {
return [midiNoteNumber, centsOffset];
}

/**
* Convert MTS pitch value to 3-byte representation
* @param number MTS pitch value
* @returns Uint8Array 3-byte of 7-bit MTS data
*/
export function mtsToMtsBytes(mtsValue: number): Uint8Array {
if (mtsValue <= 0) return new Uint8Array([0, 0, 0]);
if (mtsValue > 127.999878) return new Uint8Array([127, 127, 126]);

const noteNumber = Math.trunc(mtsValue);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a test where mtsValue is negative? I'm wondering if we should worry about Math.trunc rounding towards zero instead of negative infinity. SW can produce silly scales with nanohertz frequencies.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair call, I think that would only happen with bad usage but it would be good practice to clamp it to 0 otherwise you could be producing junk byte data.

const fine = Math.round(0x4000 * (mtsValue - noteNumber));

const data = new Uint8Array(3);
data[0] = noteNumber;
data[1] = (fine >> 7) & 0x7f;
data[2] = fine & 0x7f;
return data;
}

/**
* Convert frequency to 3-byte MTS value
* @param frequency Frequency in Hertz.
* @returns Uint8Array of length 3
*/
export function frequencyToMtsBytes(frequency: number): Uint8Array {
const mtsValue = ftomts(frequency);
return mtsToMtsBytes(mtsValue);
}

/**
* Convert 3-byte MTS value to frequency
* @param Uint8Array of 3-bytes of 7-bit MTS values
* @returns frequency Frequency in Hertz
*/
export function mtsBytesToMts(mtsBytes: Uint8Array): number {
const msb = mtsBytes[1] > 0x7f ? 0x7f : mtsBytes[1];
let lsb = mtsBytes[2];

const noteNumber = mtsBytes[0] > 0x7f ? 0x7f : mtsBytes[0];
if (noteNumber === 0x7f) {
if (lsb >= 0x7f) lsb = 0x7e;
} else if (lsb > 0x7f) lsb = 0x7f;

const fine = ((msb << 7) + lsb) / 0x4000;
return noteNumber + fine;
}

/**
* Convert 3-byte MTS value to frequency
* @param Uint8Array of 3-bytes of 7-bit MTS values
* @returns frequency Frequency in Hertz
*/
export function mtsBytesToFrequency(mtsBytes: Uint8Array): number {
const mts = mtsBytesToMts(mtsBytes);
const frequency = mtof(mts);
return Math.round(frequency * 1000000) / 1000000;
}

/** Convert MTS Data value into readable hex string
* @param Uint8Array of 3-bytes of 7-bit MTS values
* @returns String representation of MTS value in hexadecimal
* can be used in MIDI messages
*/
export function mtsBytesToHex(mtsBytes: Uint8Array): String {
const noteNumber = mtsBytes[0] > 0x7f ? 0x7f : mtsBytes[0];
const msb = mtsBytes[1] > 0x7f ? 0x7f : mtsBytes[1];
const lsb = mtsBytes[2] > 0x7f ? 0x7f : mtsBytes[2];
return (
noteNumber.toString(16).padStart(2, '0') +
msb.toString(16).padStart(2, '0') +
lsb.toString(16).padStart(2, '0')
);
}

/**
* Convert cents to natural units.
* @param cents Musical interval in cents.
Expand Down