diff --git a/src/assets/base.css b/src/assets/base.css index 5f58eb9a..bb99d91c 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -22,6 +22,11 @@ --color-error: red; + /* Mimic Bootstrap alert with 'danger' variant */ + --color-alert-danger: rgba(104, 35, 39, 1.0); + --color-alert-danger-background: rgba(243, 216, 218, 1.0); + --color-alert-danger-border: rgba(239, 199, 204, 1.0); + --section-gap: 160px; --base-font-size: 15px; --base-line-height: 1.5; @@ -231,6 +236,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 +259,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 +282,39 @@ p.section { font-weight: bold; cursor: pointer; } + +/* UI element - question mark with tooltip on hover */ +span.info-question { + background: var(--color-background); + color: var(--color-text); + border-color: var(--color-text); + 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: var(--color-text); + color: var(--color-background); + transition: all 0.3s ease; +} + +/* Padded box for displaying UI feedback message */ +div.alert-box-danger { + background: var(--color-alert-danger-background); + border-width: 1px; + border-radius: 1%; + border-style: solid; + border-color: var(--color-alert-danger-border); + padding: 8px; +} +p.alert-message-danger { + color: var(--color-alert-danger); +} diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 211f3628..82632638 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -7,7 +7,9 @@ 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 KorgExportModal from "@/components/modals/export/KorgExport.vue"; import ShareUrlModal from "@/components/modals/ShareUrl.vue"; import EqualTemperamentModal from "@/components/modals/generation/EqualTemperament.vue"; import HarmonicSeriesModal from "@/components/modals/generation/HarmonicSeries.vue"; @@ -136,7 +138,9 @@ function doExport(exporter: ExporterKey) { exportFile(exporter, params); } +const showKorgExportModal = ref(false); const showReaperExportModal = ref(false); +const showMtsSysexExportModal = ref(false); const showShareUrlModal = ref(false); const shareUrlModal = ref(null); @@ -473,15 +477,10 @@ function copyToClipboard() {

Sytrus pitch map (.fnv)

Envelope state file for the pitch envelope in Image-Line Sytrus

- -

Korg 'logue user scale (.mnlgtuns)

-

Single scale for Korg 'logue Sound Librarian.

-

Full tuning of all MIDI notes

-
- -

Korg 'logue user octave (.mnlgtuno)

-

Single scale for Korg 'logue Sound Librarian.

-

Only supports octave-repeating scales with 12 intervals

+
+

Korg Sound Librarian scale (.mnlgtuns + others)

+

Tuning formats for use with Monologue, Minilogue,

+

Minilogue XD, and Prologue synthesizers

Deflemask reference (.txt)

@@ -491,6 +490,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 { KorgModels, KorgExporter, getKorgModelInfo } from "@/exporters/korg"; +import { sanitizeFilename } from "@/utils"; +import { computed, ref } from "vue"; +import Modal from "@/components/ModalDialog.vue"; +import type { Scale } from "scale-workshop-core"; + +const props = defineProps<{ + newline: string; + scaleName: string; + baseMidiNote: number; + scale: Scale; +}>(); + +const emit = defineEmits(["confirm", "cancel"]); + +const models = [ + KorgModels.MONOLOGUE, + KorgModels.MINILOGUE, + KorgModels.MINILOGUE_XD, + KorgModels.PROLOGUE, +]; + +const modelName = ref("minilogue"); +const useOctaveFormat = ref(false); + +const dialogErrorMessage = computed(() => { + if (useOctaveFormat.value) { + const message = KorgExporter.getOctaveFormatErrorMessage(props.scale); + if (message.length > 0) return message; + } + // Can check for other errors here... + return String(); +}); + +const fileTypePreview = computed(() => { + const format = getKorgModelInfo(modelName.value); + return useOctaveFormat.value ? format.octave : format.scale; +}); + +async function doExport() { + const params = { + newline: props.newline, + scale: props.scale, + filename: sanitizeFilename(props.scaleName), + baseMidiNote: props.baseMidiNote, + }; + + const exporter = new KorgExporter( + params, + modelName.value, + useOctaveFormat.value + ); + await exporter.saveFile(); + + emit("confirm"); +} + + + diff --git a/src/components/modals/export/MtsSysexExport.vue b/src/components/modals/export/MtsSysexExport.vue new file mode 100644 index 00000000..17ca4d96 --- /dev/null +++ b/src/components/modals/export/MtsSysexExport.vue @@ -0,0 +1,105 @@ + + + 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/constants.ts b/src/constants.ts index c6b081b2..2a0ceb32 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,17 +11,5 @@ export const WINDOWS_NEWLINE = "\r\n"; export const NUMBER_OF_NOTES = 128; -// Korg specific constants for exporters and importers -export const KORG = { - programmer: "ScaleWorkshop", - mnlg: { - octaveSize: 12, - scaleSize: 128, - maxCents: 12800, - refA: { val: 6900, ind: 69, freq: 440.0 }, - refC: { val: 6000, ind: 60, freq: 261.6255653 }, - }, -}; - // Browser interaction export const LEFT_MOUSE_BTN = 0; diff --git a/src/exporters/__tests__/korg.spec.ts b/src/exporters/__tests__/korg.spec.ts index cca5e85c..935d223c 100644 --- a/src/exporters/__tests__/korg.spec.ts +++ b/src/exporters/__tests__/korg.spec.ts @@ -1,10 +1,10 @@ import { createHash } from "crypto"; import type { JSZipObject } from "jszip"; import { DEFAULT_NUMBER_OF_COMPONENTS } from "../../constants"; -import { Scale } from "scale-workshop-core"; +import { ExtendedMonzo, Interval, Scale } from "scale-workshop-core"; import { describe, it, expect } from "vitest"; -import { MnlgtunsExporter, MnlgtunoExporter } from "../korg"; +import { KorgExporter, KorgModels, KorgExporterError } from "../korg"; import { getTestData } from "./test-data"; @@ -19,7 +19,7 @@ describe("Korg exporters", () => { 256 ); - const exporter = new MnlgtunsExporter(params); + const exporter = new KorgExporter(params, KorgModels.MINILOGUE, false); const [zip, fileType] = exporter.getFileContents(); expect(fileType).toBe(".mnlgtuns"); @@ -36,7 +36,7 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "/st0f/90q1FNXxNnw2S+SCFeu9TkbXIydn85+qrqnrg=" + "LvpKRSKkPVHun2VShSXSBx5zWy52voZcZGduTSVmeEY=" ); } // Other contents didn't seem to have issues so we ignore them here. @@ -45,7 +45,8 @@ describe("Korg exporters", () => { it("can handle all line types (mnlgtuns)", async () => { const params = getTestData("Korg 'logue exporter unit test v0.0.0"); - const exporter = new MnlgtunsExporter(params); + + const exporter = new KorgExporter(params, KorgModels.MINILOGUE, false); const [zip, fileType] = exporter.getFileContents(); expect(fileType).toBe(".mnlgtuns"); @@ -62,17 +63,17 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "nuHoVQKeaJlIHrNsaAcxFfoepmtzy+NN48LcfoU4fqE=" + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" ); } else { const content = await file.async("string"); if (path === "TunS_000.TunS_info") { expect(content).toBe( - "ScaleWorkshopTest Scale" + '\n\n\n ScaleWorkshop\n Test Scale\n\n' ); } else { expect(content).toBe( - 'minilogueTunS_000.TunS_infoTunS_000.TunS_bin' + '\n\n\n minilogue\n \n \n TunS_000.TunS_info\n TunS_000.TunS_bin\n \n \n\n' ); } } @@ -81,9 +82,78 @@ describe("Korg exporters", () => { return; }); + it("throws error if 12-note octave tuning is selected, but equave is not 2/1", () => { + const params = getTestData("Korg 'logue exporter unit test v0.0.0"); + params.scale.equave = new Interval( + ExtendedMonzo.fromCents(100.0, 3), + "cents" + ); + expect( + () => new KorgExporter(params, KorgModels.MINILOGUE, true) + ).toThrowError(KorgExporterError.OCTAVE_INVALID_EQUAVE); + }); + + it("throws error if 12-note octave tuning is selected, but scale does not have 12 notes", () => { + const params = getTestData("Korg 'logue exporter unit test v0.0.0"); + expect( + () => new KorgExporter(params, KorgModels.MINILOGUE, true) + ).toThrowError(KorgExporterError.OCTAVE_INVALID_SIZE); + }); + + it("throws error if 12-note octave tuning is selected, but scale contains an interval that is below unison", () => { + const params = getTestData("Korg 'logue exporter unit test v0.0.0"); + params.scale.intervals.splice( + 1, + 0, + new Interval(ExtendedMonzo.fromCents(-500.0, 3), "cents") + ); + + // Make sure there's 12 notes in the test scale + while (params.scale.intervals.length < 12) + params.scale.intervals.splice( + 1, + 0, + new Interval(ExtendedMonzo.fromCents(100.0, 3), "cents") + ); + + expect( + () => new KorgExporter(params, KorgModels.MINILOGUE, true) + ).toThrowError(KorgExporterError.OCTAVE_INVALID_INTERVAL); + }); + + it("throws error if 12-note octave tuning is selected, but scale contains an interval that is greater than an octave", () => { + const params = getTestData("Korg 'logue exporter unit test v0.0.0"); + params.scale.intervals.splice( + 1, + 0, + new Interval(ExtendedMonzo.fromCents(1300.0, 3), "cents") + ); + + // Make sure there's 12 notes in the test scale + while (params.scale.intervals.length < 12) + params.scale.intervals.splice( + 1, + 0, + new Interval(ExtendedMonzo.fromCents(100.0, 3), "cents") + ); + + expect( + () => new KorgExporter(params, KorgModels.MINILOGUE, true) + ).toThrowError(KorgExporterError.OCTAVE_INVALID_INTERVAL); + }); + it("can handle all line types (mnlgtuno)", async () => { const params = getTestData("Korg 'logue exporter unit test v0.0.0"); - const exporter = new MnlgtunoExporter(params); + + // Make sure there's 12 notes in the test scale + while (params.scale.intervals.length < 12) + params.scale.intervals.splice( + 1, + 0, + new Interval(ExtendedMonzo.fromCents(100.0, 3), "cents") + ); + + const exporter = new KorgExporter(params, KorgModels.MINILOGUE, true); const [zip, fileType] = exporter.getFileContents(); expect(fileType).toBe(".mnlgtuno"); @@ -100,17 +170,17 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "dQWlBBzfHE/LLvEhmAQqM1AppQg5YsoQ2GQbK6KTUeM=" + "XwQptSiZLUa8LL/41LEeN1fUNvFr8GUptkga2k+tYJE=" ); } else { const content = await file.async("string"); if (path === "TunO_000.TunO_info") { expect(content).toBe( - "ScaleWorkshopTest Scale" + '\n\n\n ScaleWorkshop\n Test Scale\n\n' ); } else { expect(content).toBe( - 'minilogueTunO_000.TunO_infoTunO_000.TunO_bin' + '\n\n\n minilogue\n \n \n TunO_000.TunO_info\n TunO_000.TunO_bin\n \n \n\n' ); } } diff --git a/src/exporters/__tests__/mts.spec.ts b/src/exporters/__tests__/mts.spec.ts new file mode 100644 index 00000000..30c78b99 --- /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( + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" + ); + + // 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( + "WYM++7Jej+d9FhBWw0hqKyj/YMhgtSbCfFA6QryKhdI=" + ); + }); + + 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( + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" + ); + + // 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( + "vrtx5APwYW8zLF0CVgkcBE3z/kGSXYKfDHDrd0oh8xI=" + ); + }); + + 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..18bdc0d5 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -3,7 +3,6 @@ import { AnaMarkV1Exporter, AnaMarkV2Exporter } from "@/exporters/anamark"; import DeflemaskExporter from "@/exporters/deflemask"; import { HarmorExporter, SytrusExporter } from "@/exporters/image-line"; import KontaktExporter from "@/exporters/kontakt"; -import { MnlgtunoExporter, MnlgtunsExporter } from "@/exporters/korg"; import MaxMSPExporter from "@/exporters/max-msp"; import PureDataExporter from "@/exporters/pure-data"; import { ScalaSclExporter, ScalaKbmExporter } from "@/exporters/scala"; @@ -20,8 +19,6 @@ const EXPORTERS = { soniccouture: SoniccoutureExporter, harmor: HarmorExporter, sytrus: SytrusExporter, - mnlgtuns: MnlgtunsExporter, - mnlgtuno: MnlgtunoExporter, deflemask: DeflemaskExporter, }; diff --git a/src/exporters/korg.ts b/src/exporters/korg.ts index 02baa632..dddde4b8 100644 --- a/src/exporters/korg.ts +++ b/src/exporters/korg.ts @@ -1,101 +1,176 @@ -import { KORG } from "@/constants"; import JSZip from "jszip"; import { BaseExporter, type ExporterParams } from "@/exporters/base"; -import { - clamp, - frequencyToCentOffset, - mmod, - valueToCents, -} from "xen-dev-utils"; - -// This exporter converts tuning data into a zip-compressed file for use with Korg's -// 'logue Sound Librarian software, supporting their 'logue series of synthesizers. -// While this exporter preserves accuracy as much as possible, the Sound Librarian software -// unforunately truncates cent values to 1 cent precision. It's unknown whether the tuning accuracy -// from this exporter is written to the synthesizer and used in the synthesis. -class KorgExporter extends BaseExporter { - params: ExporterParams; - useScaleFormat: boolean; +import { Fraction, mtof } from "xen-dev-utils"; +import { frequencyTableToBinaryData } from "./mts-sysex"; +import { ExtendedMonzo, Interval, Scale } from "scale-workshop-core"; + +// This exporter converts tuning data into a zip-compressed file for use with +// Korg's Sound Librarian software, supporting their 'logue series of synthesizers. +// The zip contains a small amount of metadata and a binary file containing a +// tuning table that follows the MTS Bulk Tuning Dump specification. +// The Sound Librarian software falls a bit short of being suitable for +// advanced tuning specifications by ignoring KBM files and truncating to +// 1 cent precision. Since the MTS tuning specifications support 0.0061 cent +// precision with arbitrary mapping, this exporter intends to fully utilize the +// capabilities of the 'logue tuning implementation. However it has not been +// strictly tested if the additional precision is employed in the synthesis. + +export enum KorgModels { + MONOLOGUE = "monologue", + MINILOGUE = "minilogue", + MINILOGUE_XD = "miniloguexd", + PROLOGUE = "prologue", +} - constructor(params: ExporterParams, useScaleFormat: boolean) { - super(); - this.params = params; - this.useScaleFormat = useScaleFormat; - } +export const KORG_MODEL_INFO = { + [KorgModels.MONOLOGUE]: { + name: "monologue", + title: "Monologue", + scale: ".molgtuns", + octave: ".molgtuno", + }, + [KorgModels.MINILOGUE]: { + name: "minilogue", + title: "Minilogue", + scale: ".mnlgtuns", + octave: ".mnlgtuno", + }, + [KorgModels.MINILOGUE_XD]: { + name: "minilogue xd", + title: "Minilogue XD", + scale: ".mnlgxdtuns", + octave: ".mnlgxdtuno", + }, + [KorgModels.PROLOGUE]: { + name: "prologue", + title: "Prologue", + scale: ".prlgtuns", + octave: ".prlgtuno", + }, +}; + +export enum KorgExporterError { + OCTAVE_INVALID_EQUAVE = "Scale equave must be exactly 2/1 for the 12-note Octave format.", + OCTAVE_INVALID_SIZE = "Scale must comprise of exactly 12 intervals for the 12-note Octave format.", + OCTAVE_INVALID_INTERVAL = "Scale cannot contain intervals below unison or above an octave for the 12-note Octave format.", +} - centsTableToMnlgBinary(centsTableIn: number[]) { - const dataSize = centsTableIn.length * 3; - const data = new Uint8Array(dataSize); - let dataIndex = 0; - centsTableIn.forEach((c) => { - // restrict to valid values - const cents = clamp(0, KORG.mnlg.maxCents, c); - - const semitones = cents / 100.0; - const microtones = Math.trunc(semitones); - - const u16a = new Uint16Array([ - Math.round(0x8000 * (semitones - microtones)), - ]); - const u8a = new Uint8Array(u16a.buffer); - - data[dataIndex] = microtones; - data[dataIndex + 1] = u8a[1]; - data[dataIndex + 2] = u8a[0]; - dataIndex += 3; - }); - return data; +const OCTAVE_FORMAT_SIZE = 12; +const SCALE_FORMAT_SIZE = 128; + +export function getKorgModelInfo(modelName: string) { + switch (modelName) { + case "minilogue": + return KORG_MODEL_INFO[KorgModels.MINILOGUE]; + case "miniloguexd": + return KORG_MODEL_INFO[KorgModels.MINILOGUE_XD]; + case "monologue": + return KORG_MODEL_INFO[KorgModels.MONOLOGUE]; + case "prologue": + return KORG_MODEL_INFO[KorgModels.PROLOGUE]; + default: + throw new Error("Unknown Korg model name"); } +} - getMnlgtunTuningInfoXML(programmer: string, comment: string) { - // Builds an XML file necessary for the .mnlgtun file format - const rootName = this.useScaleFormat - ? "minilogue_TuneScaleInformation" - : "minilogue_TuneOctInformation"; - const xml = document.implementation.createDocument(null, rootName); +export class KorgExporter extends BaseExporter { + params: ExporterParams; + modelName: string; + useOctaveFormat: boolean; + + constructor( + params: ExporterParams, + modelName: string, + useOctaveFormat: boolean + ) { + super(); + this.params = params; + this.modelName = modelName; + this.useOctaveFormat = useOctaveFormat; + + if (this.useOctaveFormat) { + const errorMessage = KorgExporter.getOctaveFormatErrorMessage( + params.scale + ); + if (errorMessage !== "") throw new Error(errorMessage); + } + } - const Programmer = xml.createElement("Programmer"); - Programmer.textContent = programmer; - xml.documentElement.appendChild(Programmer); + static getOctaveFormatErrorMessage(scale: Scale): string { + const octave = new Interval( + ExtendedMonzo.fromFraction(new Fraction(2, 1), 3), + "ratio" + ); - const Comment = xml.createElement("Comment"); - Comment.textContent = comment; - xml.documentElement.appendChild(Comment); + if (scale.equave.compare(octave) !== 0) { + return KorgExporterError.OCTAVE_INVALID_EQUAVE; + } - return xml; - } + if (scale.intervals.length !== 12) { + return KorgExporterError.OCTAVE_INVALID_SIZE; + } - getMnlgtunFileInfoXML(product = "minilogue") { - // Builds an XML file necessary for the .mnlgtun file format - const rootName = "KorgMSLibrarian_Data"; - const xml = document.implementation.createDocument(null, rootName); + const unison = new Interval( + ExtendedMonzo.fromFraction(new Fraction(1, 1), 3), + "ratio" + ); - const Product = xml.createElement("Product"); - Product.textContent = product; - xml.documentElement.appendChild(Product); + for (const interval of scale.intervals) { + if (interval.compare(unison) < 0 || interval.compare(octave) > 0) { + return KorgExporterError.OCTAVE_INVALID_INTERVAL; + } + } - const Contents = xml.createElement("Contents"); - Contents.setAttribute("NumProgramData", "0"); - Contents.setAttribute("NumPresetInformation", "0"); - Contents.setAttribute("NumTuneScaleData", this.useScaleFormat ? "1" : "0"); - Contents.setAttribute("NumTuneOctData", this.useScaleFormat ? "0" : "1"); + return ""; + } - const [fileNameHeader, dataName, binName] = this.useScaleFormat - ? ["TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"] - : ["TunO_000.TunO_", "TuneOctData", "TuneOctBinary"]; + getTuningInfoXml(model: string, programmer = "Scale Workshop", comment = "") { + const format = getKorgModelInfo(model); + const name = format.name; + const tagName = name.replace(" ", "").toLowerCase(); - const TuneData = xml.createElement(dataName); + const rootName = this.useOctaveFormat + ? `${tagName}_TuneOctInformation` + : `${tagName}_TuneScaleInformation`; - const Information = xml.createElement("Information"); - Information.textContent = fileNameHeader + "info"; - TuneData.appendChild(Information); + const xml = + `\n` + + "\n" + + `<${rootName}>\n` + + ` ${programmer}\n` + + ` ${comment}\n` + + `\n`; - const BinData = xml.createElement(binName); - BinData.textContent = fileNameHeader + "bin"; - TuneData.appendChild(BinData); + return xml; + } - Contents.appendChild(TuneData); - xml.documentElement.appendChild(Contents); + getFileInfoXml(model: string) { + const format = getKorgModelInfo(model); + + const [ + numTuneScaleData, + numTuneOctData, + fileNameHeader, + dataName, + binName, + ] = this.useOctaveFormat + ? ["0", "1", "TunO_000.TunO_", "TuneOctData", "TuneOctBinary"] + : ["1", "0", "TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"]; + + const xml = + `\n` + + "\n" + + "\n" + + ` ${format.name}\n` + + ' \n` + + ` <${dataName}>\n` + + ` ${fileNameHeader}info\n` + + ` <${binName}>${fileNameHeader}bin\n` + + ` \n` + + " \n" + + "\n"; return xml; } @@ -104,53 +179,38 @@ class KorgExporter extends BaseExporter { const scale = this.params.scale; const baseMidiNote = this.params.baseMidiNote; - // the index of the table that's equal to the baseNote should have the following value - const refOffsetCents = - KORG.mnlg.refA.val + - valueToCents(scale.baseFrequency / KORG.mnlg.refA.freq); - - // This should be similar to Scale Workshop 1 tuning_table.freq - const frequencies = scale.getFrequencyRange( - -baseMidiNote, - KORG.mnlg.scaleSize - baseMidiNote - ); - // This should be similar to Scale Workshop 1 tuning_table.cents - const centss = frequencies.map((freq: number) => - frequencyToCentOffset(freq, scale.baseFrequency) - ); - - // offset cents array for binary conversion (rounded to 3 decimals) - let centsTable = centss.map( - (cents: number) => Math.round((cents + refOffsetCents) * 1000) / 1000 - ); - - if (!this.useScaleFormat) { - // normalize around root, truncate to 12 notes, and wrap flattened Cs - const cNote = - Math.floor(baseMidiNote / KORG.mnlg.octaveSize) * KORG.mnlg.octaveSize; - centsTable = centsTable - .slice(cNote, cNote + KORG.mnlg.octaveSize) - .map((cents) => mmod(cents - KORG.mnlg.refC.val, KORG.mnlg.maxCents)); + let frequencies: number[]; + if (this.useOctaveFormat) { + const rootFreq = mtof(0); + const transposeRatio = rootFreq / scale.baseFrequency; + frequencies = scale + .getFrequencyRange(0, OCTAVE_FORMAT_SIZE) + .map((f: number) => f * transposeRatio); + } else { + frequencies = scale.getFrequencyRange( + -baseMidiNote, + SCALE_FORMAT_SIZE - baseMidiNote + ); } - // convert to binary - const binaryData = this.centsTableToMnlgBinary(centsTable); + const binaryData = frequencyTableToBinaryData(frequencies); // prepare files for zipping - const tuningInfo = this.getMnlgtunTuningInfoXML( + const format = getKorgModelInfo(this.modelName); + const tuningInfo = this.getTuningInfoXml( + this.modelName, "ScaleWorkshop", - this.params.name! + this.params.name ?? "" ); - const fileInfo = this.getMnlgtunFileInfoXML(); - const [fileNameHeader, fileType] = this.useScaleFormat - ? ["TunS_000.TunS_", ".mnlgtuns"] - : ["TunO_000.TunO_", ".mnlgtuno"]; + const fileInfo = this.getFileInfoXml(this.modelName); + const [fileNameHeader, fileType] = this.useOctaveFormat + ? ["TunO_000.TunO_", format.octave] + : ["TunS_000.TunS_", format.scale]; - // build zip const zip = new JSZip(); zip.file(fileNameHeader + "bin", binaryData); - zip.file(fileNameHeader + "info", tuningInfo.documentElement.outerHTML); - zip.file("FileInformation.xml", fileInfo.documentElement.outerHTML); + zip.file(fileNameHeader + "info", tuningInfo); + zip.file("FileInformation.xml", fileInfo); return [zip, fileType]; } @@ -165,15 +225,3 @@ class KorgExporter extends BaseExporter { ); } } - -export class MnlgtunsExporter extends KorgExporter { - constructor(params: ExporterParams) { - super(params, true); - } -} - -export class MnlgtunoExporter extends KorgExporter { - constructor(params: ExporterParams) { - super(params, false); - } -} 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); + } +}