Skip to content

Commit

Permalink
Add MTS-SysEx exporter
Browse files Browse the repository at this point in the history
  • Loading branch information
vsicurella committed Jul 8, 2023
1 parent c75afb2 commit cb066db
Show file tree
Hide file tree
Showing 7 changed files with 288 additions and 1 deletion.
18 changes: 17 additions & 1 deletion src/components/ScaleBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { APP_TITLE } from "@/constants";
import { sanitizeFilename, midiNoteNumberToName } from "@/utils";
import { exportFile, type ExporterKey } from "@/exporters";
import Modal from "@/components/ModalDialog.vue";
import ReaperExportModal from "@/components/modals/ReaperExport.vue";
import ReaperExportModal from "@/components/modals/export/ReaperExport.vue";
import MtsSysexExportModal from "@/components/modals/export/MtsSysexExport.vue";
import ShareUrlModal from "@/components/modals/ShareUrl.vue";
import EqualTemperamentModal from "@/components/modals/generation/EqualTemperament.vue";
import HarmonicSeriesModal from "@/components/modals/generation/HarmonicSeries.vue";
Expand Down Expand Up @@ -137,6 +138,7 @@ function doExport(exporter: ExporterKey) {
}
const showReaperExportModal = ref(false);
const showMtsSysexExportModal = ref(false);
const showShareUrlModal = ref(false);
const shareUrlModal = ref<any>(null);
Expand Down Expand Up @@ -491,6 +493,10 @@ function copyToClipboard() {
<p><strong>Reaper note name map (.txt)</strong></p>
<p>Displays custom note names on Reaper's piano roll</p>
</a>
<a href="#" class="btn" @click="showMtsSysexExportModal = true">
<p><strong>MTS Sysex Bulk Tuning Dump (.syx)</strong></p>
<p>Binary data of a Bulk Tuning Dump SysEx message</p>
</a>
<a
href="#"
class="btn"
Expand Down Expand Up @@ -532,6 +538,16 @@ function copyToClipboard() {
:scale="scale"
/>

<MtsSysexExportModal
:show="showMtsSysexExportModal"
@confirm="showMtsSysexExportModal = false"
@cancel="showMtsSysexExportModal = false"
:newline="props.newline"
:scaleName="scaleName"
:baseMidiNote="baseMidiNote"
:scale="scale"
/>

<RankTwoModal
:show="showRankTwoModal"
:centsFractionDigits="centsFractionDigits"
Expand Down
116 changes: 116 additions & 0 deletions src/components/modals/export/MtsSysexExport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<script setup lang="ts">
import MtsSysexExporter from "@/exporters/mts-sysex";
import { sanitizeFilename } from "@/utils";
import { ref } from "vue";
import Modal from "@/components/ModalDialog.vue";
import type { Scale } from "scale-workshop-core";
import { clamp } from "xen-dev-utils";
const props = defineProps<{
newline: string;
scaleName: string;
baseMidiNote: number;
scale: Scale;
}>();
const emit = defineEmits(["confirm", "cancel"]);
function clampName(name: string): string {
return name.slice(0, 16);
}
function formatPresetIndex(input: string | number): number {
let number =
typeof input === "string" ? parseInt(input.replace(/\D/g, "")) : input;
if (Number.isNaN(number)) return 0;
return clamp(0, 127, number);
}
// Rarely implemented parameters
// const deviceId = ref(0);
// const bank = ref(0); (only for message 0x0804)
const name = ref(clampName(props.scaleName));
const presetIndex = ref(0);
function doExport() {
const params = {
newline: props.newline,
scale: props.scale,
filename: sanitizeFilename(props.scaleName),
baseMidiNote: props.baseMidiNote,
name: name.value,
presetIndex: presetIndex.value,
};
const exporter = new MtsSysexExporter(params);
exporter.saveFile();
emit("confirm");
}
</script>

<template>
<Modal @confirm="doExport" @cancel="$emit('cancel')">
<template #header>
<h2>Export MTS Bulk Tuning Dump</h2>
</template>
<template #body>
<div class="control-group">
<div class="control">
<label for="name">Name (16-character limit)</label>
<input
class="half"
type="text"
id="name"
v-model="name"
maxlength="16"
/>
</div>
<div class="control">
<label for="preset-index">
Preset Index&nbsp;
<span
@click="$event.preventDefault()"
class="info-question"
title="Refer to your synth's manual for a valid range"
>
?
</span>
</label>
<input
class="half"
type="number"
id="preset-index"
v-model="presetIndex"
@input="presetIndex = formatPresetIndex(presetIndex)"
/>
</div>
</div>
</template>
</Modal>
</template>

<style>
input.half {
flex-grow: 0.25 !important;
}
span.info-question {
border-radius: 50%;
border-width: 2px;
border-style: solid;
padding-left: 4px;
padding-right: 4px;
font-size: smaller;
transition: 0.3s ease;
}
span.info-question:hover {
background: white;
color: black;
border-color: white;
transition: all 0.3s ease;
}
</style>
File renamed without changes.
60 changes: 60 additions & 0 deletions src/exporters/__tests__/mts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createHash } from "crypto";
import { describe, it, expect } from "vitest";

import { getTestData } from "./test-data";
import MtsSysexExporter from "../mts-sysex";

describe("MTS exporter", () => {
it("can handle all line types", async () => {
const params = getTestData(
"MTS Sysex Bulk Tuning Dump exporter unit test v0.0.0"
);
params.presetIndex = 1;

const exporter = new MtsSysexExporter(params);

const scaleData = exporter.getBulkTuningData();
expect(createHash("sha256").update(scaleData).digest("base64")).toBe(
"Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg="
);

// Name is padded with spaces
const nameData = exporter.getNameData();
expect(nameData).toEqual([
84, 101, 115, 116, 32, 83, 99, 97, 108, 101, 32, 32, 32, 32, 32, 32,
]);

const sysExMsg = exporter.buildSysExDump();
expect(createHash("sha256").update(sysExMsg).digest("base64")).toBe(
"Dx+z9IBMNqBrj7/g4EJ3XQxMPKbtQqmpfjLdoPPrV48="
);
});

it("can gracefully handle invalid parameters", async () => {
const params = getTestData(
"MTS Sysex Bulk Tuning Dump exporter unit test v0.0.0"
);
params.name = "Super Special Test Scale";
params.presetIndex = -1;

const exporter = new MtsSysexExporter(params);

const scaleData = exporter.getBulkTuningData();
expect(createHash("sha256").update(scaleData).digest("base64")).toBe(
"Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg="
);

// Name is truncated
const nameData = exporter.getNameData();
expect(nameData).toEqual([
83, 117, 112, 101, 114, 32, 83, 112, 101, 99, 105, 97, 108, 32, 84, 101,
]);

const sysExMsg = exporter.buildSysExDump();
expect(createHash("sha256").update(sysExMsg).digest("base64")).toBe(
"8TgBDEn+mTm/xar39zeO9NBCcRn6KymbO/ZeMfa8rq0="
);
});

return;
});
1 change: 1 addition & 0 deletions src/exporters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ExporterParams = {
centsRoot?: number;
displayPeriod?: boolean;
integratePeriod?: boolean;
presetIndex?: number;
};

export class BaseExporter {
Expand Down
2 changes: 2 additions & 0 deletions src/exporters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import MaxMSPExporter from "@/exporters/max-msp";
import PureDataExporter from "@/exporters/pure-data";
import { ScalaSclExporter, ScalaKbmExporter } from "@/exporters/scala";
import SoniccoutureExporter from "@/exporters/soniccouture";
import MtsSysexExporter from "@/exporters/mts-sysex";

const EXPORTERS = {
anamarkv1: AnaMarkV1Exporter,
Expand All @@ -22,6 +23,7 @@ const EXPORTERS = {
sytrus: SytrusExporter,
mnlgtuns: MnlgtunsExporter,
mnlgtuno: MnlgtunoExporter,
mtsSysex: MtsSysexExporter,
deflemask: DeflemaskExporter,
};

Expand Down
92 changes: 92 additions & 0 deletions src/exporters/mts-sysex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { clamp, frequencyToMtsBytes } from "xen-dev-utils";
import { BaseExporter, type ExporterParams } from "./base";

export function frequencyTableToBinaryData(
frequencyTableIn: number[]
): Uint8Array {
const dataSize = frequencyTableIn.length * 3;
const data = new Uint8Array(dataSize);
let dataIndex = 0;
frequencyTableIn.forEach((f: number) => {
const bytes = frequencyToMtsBytes(f);
data[dataIndex] = bytes[0];
data[dataIndex + 1] = bytes[1];
data[dataIndex + 2] = bytes[2];
dataIndex += 3;
});

return data;
}

export function getSysexChecksum(data: number[]): number {
const checksum = data
.filter((byte: number) => byte >= 0 && byte < 128)
.reduce((sum: number, byte: number) => sum ^ byte, 0xff);
return checksum & 0x7f;
}

export default class MtsSysexExporter extends BaseExporter {
params: ExporterParams;

constructor(params: ExporterParams) {
super();
this.params = params;
}

getBulkTuningData() {
const scale = this.params.scale;
const baseMidiNote = this.params.baseMidiNote;

const frequencies = scale.getFrequencyRange(
-baseMidiNote,
128 - baseMidiNote
);

const scaleData = frequencyTableToBinaryData(frequencies);
return scaleData;
}

getNameData() {
let name = this.params.name ?? "";
while (name.length < 16) {
name += " ";
}

const nameData = Array.from(name)
.slice(0, 16)
.map((char: string) => char.charCodeAt(0));
return nameData;
}

buildSysExDump() {
if (this.params.presetIndex === undefined)
throw new Error("No preset index defined");

const presetIndex = clamp(0, 0x7f, this.params.presetIndex);

const nameData = this.getNameData();
const scaleData = this.getBulkTuningData();

const data: number[] = [];
data.push(
0xf0,
0x7e, // SysEx header
0x00,
0x08,
0x01, // protocol IDs
presetIndex,
...nameData,
...scaleData
);
const checksum = getSysexChecksum(data);
data.push(checksum);
data.push(0xf7);

return Uint8Array.from(data);
}

saveFile() {
const sysexDump = this.buildSysExDump();
super.saveFile(this.params.filename + ".syx", sysexDump, true);
}
}

0 comments on commit cb066db

Please sign in to comment.