diff --git a/src/__tests__/conversion.spec.ts b/src/__tests__/conversion.spec.ts index e50af77..b089de3 100644 --- a/src/__tests__/conversion.spec.ts +++ b/src/__tests__/conversion.spec.ts @@ -3,9 +3,14 @@ import { centOffsetToFrequency, centsToValue, frequencyToCentOffset, + ftomts, + frequencyToMtsBytes, ftom, mtof, valueToCents, + mtsToMtsBytes, + mtsBytesToHex, + mtsBytesToFrequency, } from '../conversion'; describe('Ratio to cents converter', () => { @@ -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); diff --git a/src/conversion.ts b/src/conversion.ts index fa3a78a..8bc555e 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -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. @@ -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); + 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.