From ed49c41d3c58e8355b3a64106d8e95102d74dc1d Mon Sep 17 00:00:00 2001 From: Vincenzo Sicurella Date: Sat, 8 Jul 2023 18:24:58 -0400 Subject: [PATCH] Refactor Korg exporter to support other formats --- src/components/ScaleBuilder.vue | 25 +- src/components/modals/export/KorgExport.vue | 84 ++++++ src/constants.ts | 12 - src/exporters/__tests__/korg.spec.ts | 23 +- src/exporters/index.ts | 5 - src/exporters/korg.ts | 271 +++++++++++--------- 6 files changed, 257 insertions(+), 163 deletions(-) create mode 100644 src/components/modals/export/KorgExport.vue diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 8d43cef2..82632638 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -9,6 +9,7 @@ import { exportFile, type ExporterKey } from "@/exporters"; import Modal from "@/components/ModalDialog.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"; @@ -137,6 +138,7 @@ function doExport(exporter: ExporterKey) { exportFile(exporter, params); } +const showKorgExportModal = ref(false); const showReaperExportModal = ref(false); const showMtsSysexExportModal = ref(false); const showShareUrlModal = ref(false); @@ -475,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)

@@ -528,6 +525,16 @@ function copyToClipboard() { /> + + +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 useScaleFormat = ref(true); + +const fileTypePreview = computed(() => { + const format = getKorgModelInfo(modelName.value); + return useScaleFormat.value ? format.scale : format.octave; +}); + +function doExport() { + const params = { + newline: props.newline, + scale: props.scale, + filename: sanitizeFilename(props.scaleName), + baseMidiNote: props.baseMidiNote, + }; + + const exporter = new KorgExporter( + params, + modelName.value, + useScaleFormat.value + ); + exporter.saveFile(); + + emit("confirm"); +} + + + 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..64516ff4 100644 --- a/src/exporters/__tests__/korg.spec.ts +++ b/src/exporters/__tests__/korg.spec.ts @@ -4,7 +4,7 @@ import { DEFAULT_NUMBER_OF_COMPONENTS } from "../../constants"; import { Scale } from "scale-workshop-core"; import { describe, it, expect } from "vitest"; -import { MnlgtunsExporter, MnlgtunoExporter } from "../korg"; +import { KorgExporter, KorgModels } 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, true); 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=" + "O6YaWtzo33MdIBcMRYlVq7PnzuoMd5Yyp6sBT/3oDnc=" ); } // 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, true); 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=" + "Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg=" ); } 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' ); } } @@ -83,7 +84,7 @@ describe("Korg exporters", () => { it("can handle all line types (mnlgtuno)", async () => { const params = getTestData("Korg 'logue exporter unit test v0.0.0"); - const exporter = new MnlgtunoExporter(params); + const exporter = new KorgExporter(params, KorgModels.MINILOGUE, false); const [zip, fileType] = exporter.getFileContents(); expect(fileType).toBe(".mnlgtuno"); @@ -100,17 +101,17 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "dQWlBBzfHE/LLvEhmAQqM1AppQg5YsoQ2GQbK6KTUeM=" + "hnBNRPHvVHYkBfgM/ss+wTqd2Sy4UQ6bBk8aJqQWPzI=" ); } 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/index.ts b/src/exporters/index.ts index 317f69b5..18bdc0d5 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -3,12 +3,10 @@ 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"; import SoniccoutureExporter from "@/exporters/soniccouture"; -import MtsSysexExporter from "@/exporters/mts-sysex"; const EXPORTERS = { anamarkv1: AnaMarkV1Exporter, @@ -21,9 +19,6 @@ const EXPORTERS = { soniccouture: SoniccoutureExporter, harmor: HarmorExporter, sytrus: SytrusExporter, - mnlgtuns: MnlgtunsExporter, - mnlgtuno: MnlgtunoExporter, - mtsSysex: MtsSysexExporter, deflemask: DeflemaskExporter, }; diff --git a/src/exporters/korg.ts b/src/exporters/korg.ts index 02baa632..0b20f956 100644 --- a/src/exporters/korg.ts +++ b/src/exporters/korg.ts @@ -1,101 +1,134 @@ -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 { +import { mtof } from "xen-dev-utils"; +import { frequencyTableToBinaryData } from "./mts-sysex"; + +// 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", +} + +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", + }, +}; + +const OCTAVE_SIZE = 12; +const SCALE_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"); + } +} + +export class KorgExporter extends BaseExporter { params: ExporterParams; + modelName: string; useScaleFormat: boolean; - constructor(params: ExporterParams, useScaleFormat: boolean) { + constructor( + params: ExporterParams, + modelName: string, + useScaleFormat: boolean + ) { super(); this.params = params; + this.modelName = modelName; this.useScaleFormat = useScaleFormat; } - 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; - } + getTuningInfoXml(model: string, programmer = "Scale Workshop", comment = "") { + const format = getKorgModelInfo(model); + const name = format.name; + const tagName = name.replace(" ", "").toLowerCase(); - 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); + ? `${tagName}_TuneScaleInformation` + : `${tagName}_TuneOctInformation`; - const Programmer = xml.createElement("Programmer"); - Programmer.textContent = programmer; - xml.documentElement.appendChild(Programmer); - - const Comment = xml.createElement("Comment"); - Comment.textContent = comment; - xml.documentElement.appendChild(Comment); + const xml = + `\n` + + "\n" + + `<${rootName}>\n` + + ` ${programmer}\n` + + ` ${comment}\n` + + `\n`; return xml; } - 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 Product = xml.createElement("Product"); - Product.textContent = product; - xml.documentElement.appendChild(Product); - - 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"); - - const [fileNameHeader, dataName, binName] = this.useScaleFormat - ? ["TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"] - : ["TunO_000.TunO_", "TuneOctData", "TuneOctBinary"]; - - const TuneData = xml.createElement(dataName); - - const Information = xml.createElement("Information"); - Information.textContent = fileNameHeader + "info"; - TuneData.appendChild(Information); - - const BinData = xml.createElement(binName); - BinData.textContent = fileNameHeader + "bin"; - TuneData.appendChild(BinData); - - Contents.appendChild(TuneData); - xml.documentElement.appendChild(Contents); + getFileInfoXml(model: string) { + const format = getKorgModelInfo(model); + + const [ + numTuneScaleData, + numTuneOctData, + fileNameHeader, + dataName, + binName, + ] = this.useScaleFormat + ? ["1", "0", "TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"] + : ["0", "1", "TunO_000.TunO_", "TuneOctData", "TuneOctBinary"]; + + 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 +137,51 @@ 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( + let 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 + SCALE_SIZE - baseMidiNote ); 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)); + // Normalize to lowest definable pitch, C = 0 "cents" + // Choose C below base MIDI note + const cNote = Math.trunc(baseMidiNote / OCTAVE_SIZE) * OCTAVE_SIZE; + + const rootFreq = mtof(0); + const octaveFreq = mtof(12); + + const cRatio = rootFreq / frequencies[cNote]; + frequencies = frequencies + .slice(cNote, cNote + OCTAVE_SIZE) + .map((f: number) => { + // Wrap all frequencies to within first octave + let fnorm = f * cRatio; + if (fnorm > octaveFreq) { + const y = -Math.trunc(Math.log2(fnorm / rootFreq)); + fnorm = fnorm * Math.pow(2, y); + } + return fnorm; + }); } - // 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 fileInfo = this.getFileInfoXml(this.modelName); const [fileNameHeader, fileType] = this.useScaleFormat - ? ["TunS_000.TunS_", ".mnlgtuns"] - : ["TunO_000.TunO_", ".mnlgtuno"]; + ? ["TunS_000.TunS_", format.scale] + : ["TunO_000.TunO_", format.octave]; - // 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 +196,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); - } -}