diff --git a/package-lock.json b/package-lock.json index 72c3beae..50851958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "vue": "^3.2.33", "vue-router": "^4.1.5", "webmidi": "^3.0.21", - "xen-dev-utils": "^0.1.2" + "xen-dev-utils": "github:xenharmonic-devs/xen-dev-utils#mts-methods" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", @@ -4434,14 +4434,6 @@ "xen-dev-utils": "github:xenharmonic-devs/xen-dev-utils#v0.1.1" } }, - "node_modules/scale-workshop-core/node_modules/xen-dev-utils": { - "version": "0.1.1", - "resolved": "git+ssh://git@github.com/xenharmonic-devs/xen-dev-utils.git#6aac4266cb94a0faec1a86e40e3330d227e3ee08", - "license": "MIT", - "dependencies": { - "fraction.js": "^4.2.0" - } - }, "node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -5366,9 +5358,9 @@ } }, "node_modules/xen-dev-utils": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.1.2.tgz", - "integrity": "sha512-5Gya1P0k89veWkRevhUT4bzJDyotEFIb/cYC1bP0KPAxdHB+oTlhdDd540DXSud5kulio78DJpLucUo4BWRA7Q==", + "version": "0.1.3", + "resolved": "git+ssh://git@github.com/xenharmonic-devs/xen-dev-utils.git#4011a5cece1ff8a364c7e014b2ea64072176c060", + "license": "MIT", "dependencies": { "fraction.js": "^4.2.0" } @@ -8601,15 +8593,6 @@ "from": "scale-workshop-core@github:xenharmonic-devs/scale-workshop-core#v0.0.1", "requires": { "xen-dev-utils": "github:xenharmonic-devs/xen-dev-utils#v0.1.1" - }, - "dependencies": { - "xen-dev-utils": { - "version": "git+ssh://git@github.com/xenharmonic-devs/xen-dev-utils.git#6aac4266cb94a0faec1a86e40e3330d227e3ee08", - "from": "xen-dev-utils@github:xenharmonic-devs/xen-dev-utils#v0.1.1", - "requires": { - "fraction.js": "^4.2.0" - } - } } }, "semver": { @@ -9256,9 +9239,8 @@ "requires": {} }, "xen-dev-utils": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.1.2.tgz", - "integrity": "sha512-5Gya1P0k89veWkRevhUT4bzJDyotEFIb/cYC1bP0KPAxdHB+oTlhdDd540DXSud5kulio78DJpLucUo4BWRA7Q==", + "version": "git+ssh://git@github.com/xenharmonic-devs/xen-dev-utils.git#4011a5cece1ff8a364c7e014b2ea64072176c060", + "from": "xen-dev-utils@github:xenharmonic-devs/xen-dev-utils#mts-methods", "requires": { "fraction.js": "^4.2.0" } diff --git a/package.json b/package.json index 3d1cdd02..4a7d6882 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "vue": "^3.2.33", "vue-router": "^4.1.5", "webmidi": "^3.0.21", - "xen-dev-utils": "^0.1.2", + "xen-dev-utils": "github:xenharmonic-devs/xen-dev-utils#mts-methods", "scale-workshop-core": "github:xenharmonic-devs/scale-workshop-core#v0.0.1" }, "devDependencies": { diff --git a/src/assets/base.css b/src/assets/base.css index 5f58eb9a..f08c4c67 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -231,6 +231,9 @@ ul.btn-group, .btn-dropdown-group ul { .control select { flex-grow: 1; } +.control input.half { + flex-grow: 0.25 !important; +} .control.checkbox-container { flex-flow: unset; } @@ -251,7 +254,7 @@ ul.btn-group, .btn-dropdown-group ul { } } -/* UI element - expandable secion */ +/* UI element - expandable section */ .section::after { content: " ▼"; font-size: 0.5rem; @@ -274,3 +277,24 @@ p.section { font-weight: bold; cursor: pointer; } + +/* UI element - question mark with tooltip on hover */ +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::after { + content: '?'; +} +span.info-question:hover { + background: white; + color: black; + border-color: white; + transition: all 0.3s ease; +} diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 211f3628..8d43cef2 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -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"; @@ -137,6 +138,7 @@ function doExport(exporter: ExporterKey) { } const showReaperExportModal = ref(false); +const showMtsSysexExportModal = ref(false); const showShareUrlModal = ref(false); const shareUrlModal = ref(null); @@ -491,6 +493,10 @@ function copyToClipboard() {

Reaper note name map (.txt)

Displays custom note names on Reaper's piano roll

+ +

MTS Sysex Bulk Tuning Dump (.syx)

+

Binary data of a Bulk Tuning Dump SysEx message

+
+ + +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"); +} + + + diff --git a/src/components/modals/ReaperExport.vue b/src/components/modals/export/ReaperExport.vue similarity index 100% rename from src/components/modals/ReaperExport.vue rename to src/components/modals/export/ReaperExport.vue diff --git a/src/exporters/__tests__/mts.spec.ts b/src/exporters/__tests__/mts.spec.ts new file mode 100644 index 00000000..bb466a23 --- /dev/null +++ b/src/exporters/__tests__/mts.spec.ts @@ -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; +}); diff --git a/src/exporters/base.ts b/src/exporters/base.ts index 2ec30ace..01083455 100644 --- a/src/exporters/base.ts +++ b/src/exporters/base.ts @@ -17,6 +17,7 @@ export type ExporterParams = { centsRoot?: number; displayPeriod?: boolean; integratePeriod?: boolean; + presetIndex?: number; }; export class BaseExporter { diff --git a/src/exporters/index.ts b/src/exporters/index.ts index 2df7b8a0..317f69b5 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -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, @@ -22,6 +23,7 @@ const EXPORTERS = { sytrus: SytrusExporter, mnlgtuns: MnlgtunsExporter, mnlgtuno: MnlgtunoExporter, + mtsSysex: MtsSysexExporter, deflemask: DeflemaskExporter, }; diff --git a/src/exporters/mts-sysex.ts b/src/exporters/mts-sysex.ts new file mode 100644 index 00000000..a18ebf60 --- /dev/null +++ b/src/exporters/mts-sysex.ts @@ -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); + } +}