diff --git a/package.json b/package.json index cc95a733..03d41570 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,12 @@ "jszip": "^3.10.1", "moment-of-symmetry": "^0.3.1", "qs": "^6.11.0", + "scale-workshop-core": "github:xenharmonic-devs/scale-workshop-core#v0.0.6", "temperaments": "^0.4.0", "vue": "^3.2.33", "vue-router": "^4.1.5", "webmidi": "^3.0.21", - "xen-dev-utils": "^0.1.4", - "scale-workshop-core": "github:xenharmonic-devs/scale-workshop-core#v0.0.1" + "xen-dev-utils": "^0.1.4" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", diff --git a/src/assets/base.css b/src/assets/base.css index efdecc24..cdaed62f 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; @@ -300,3 +305,16 @@ span.info-question:hover { 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); +} \ No newline at end of file diff --git a/src/components/modals/export/KorgExport.vue b/src/components/modals/export/KorgExport.vue index fd053335..e4288a7f 100644 --- a/src/components/modals/export/KorgExport.vue +++ b/src/components/modals/export/KorgExport.vue @@ -22,11 +22,20 @@ const models = [ ]; const modelName = ref("minilogue"); -const useScaleFormat = ref(true); +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 useScaleFormat.value ? format.scale : format.octave; + return useOctaveFormat.value ? format.octave : format.scale; }); function doExport() { @@ -40,7 +49,7 @@ function doExport() { const exporter = new KorgExporter( params, modelName.value, - useScaleFormat.value + useOctaveFormat.value ); exporter.saveFile(); @@ -66,11 +75,11 @@ function doExport() {
@@ -78,6 +87,22 @@ function doExport() { {{ fileTypePreview }}

+
+

+ {{ dialogErrorMessage }} +

+
+ + + diff --git a/src/exporters/__tests__/korg.spec.ts b/src/exporters/__tests__/korg.spec.ts index 64516ff4..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 { KorgExporter, KorgModels } from "../korg"; +import { KorgExporter, KorgModels, KorgExporterError } from "../korg"; import { getTestData } from "./test-data"; @@ -19,7 +19,7 @@ describe("Korg exporters", () => { 256 ); - const exporter = new KorgExporter(params, KorgModels.MINILOGUE, true); + 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( - "O6YaWtzo33MdIBcMRYlVq7PnzuoMd5Yyp6sBT/3oDnc=" + "LvpKRSKkPVHun2VShSXSBx5zWy52voZcZGduTSVmeEY=" ); } // Other contents didn't seem to have issues so we ignore them here. @@ -46,7 +46,7 @@ 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 KorgExporter(params, KorgModels.MINILOGUE, true); + const exporter = new KorgExporter(params, KorgModels.MINILOGUE, false); const [zip, fileType] = exporter.getFileContents(); expect(fileType).toBe(".mnlgtuns"); @@ -63,7 +63,7 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg=" + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" ); } else { const content = await file.async("string"); @@ -82,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 KorgExporter(params, KorgModels.MINILOGUE, false); + + // 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"); @@ -101,7 +170,7 @@ describe("Korg exporters", () => { if (path.endsWith("bin")) { const content = await file.async("uint8array"); expect(createHash("sha256").update(content).digest("base64")).toBe( - "hnBNRPHvVHYkBfgM/ss+wTqd2Sy4UQ6bBk8aJqQWPzI=" + "XwQptSiZLUa8LL/41LEeN1fUNvFr8GUptkga2k+tYJE=" ); } else { const content = await file.async("string"); diff --git a/src/exporters/__tests__/mts.spec.ts b/src/exporters/__tests__/mts.spec.ts index bb466a23..30c78b99 100644 --- a/src/exporters/__tests__/mts.spec.ts +++ b/src/exporters/__tests__/mts.spec.ts @@ -15,7 +15,7 @@ describe("MTS exporter", () => { const scaleData = exporter.getBulkTuningData(); expect(createHash("sha256").update(scaleData).digest("base64")).toBe( - "Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg=" + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" ); // Name is padded with spaces @@ -26,7 +26,7 @@ describe("MTS exporter", () => { const sysExMsg = exporter.buildSysExDump(); expect(createHash("sha256").update(sysExMsg).digest("base64")).toBe( - "Dx+z9IBMNqBrj7/g4EJ3XQxMPKbtQqmpfjLdoPPrV48=" + "WYM++7Jej+d9FhBWw0hqKyj/YMhgtSbCfFA6QryKhdI=" ); }); @@ -41,7 +41,7 @@ describe("MTS exporter", () => { const scaleData = exporter.getBulkTuningData(); expect(createHash("sha256").update(scaleData).digest("base64")).toBe( - "Ps5Ddp9lBYZgCn7Y8aSBnhXOcfIm+sh9AcnybiLX4Zg=" + "z7mQ6pS8tVYimN2B5V3WIgN7NR4lFMwrlIjxKJkWEss=" ); // Name is truncated @@ -52,7 +52,7 @@ describe("MTS exporter", () => { const sysExMsg = exporter.buildSysExDump(); expect(createHash("sha256").update(sysExMsg).digest("base64")).toBe( - "8TgBDEn+mTm/xar39zeO9NBCcRn6KymbO/ZeMfa8rq0=" + "vrtx5APwYW8zLF0CVgkcBE3z/kGSXYKfDHDrd0oh8xI=" ); }); diff --git a/src/exporters/korg.ts b/src/exporters/korg.ts index 0b20f956..dddde4b8 100644 --- a/src/exporters/korg.ts +++ b/src/exporters/korg.ts @@ -1,7 +1,8 @@ import JSZip from "jszip"; import { BaseExporter, type ExporterParams } from "@/exporters/base"; -import { mtof } from "xen-dev-utils"; +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. @@ -48,8 +49,14 @@ export const KORG_MODEL_INFO = { }, }; -const OCTAVE_SIZE = 12; -const SCALE_SIZE = 128; +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.", +} + +const OCTAVE_FORMAT_SIZE = 12; +const SCALE_FORMAT_SIZE = 128; export function getKorgModelInfo(modelName: string) { switch (modelName) { @@ -69,17 +76,52 @@ export function getKorgModelInfo(modelName: string) { export class KorgExporter extends BaseExporter { params: ExporterParams; modelName: string; - useScaleFormat: boolean; + useOctaveFormat: boolean; constructor( params: ExporterParams, modelName: string, - useScaleFormat: boolean + useOctaveFormat: boolean ) { super(); this.params = params; this.modelName = modelName; - this.useScaleFormat = useScaleFormat; + this.useOctaveFormat = useOctaveFormat; + + if (this.useOctaveFormat) { + const errorMessage = KorgExporter.getOctaveFormatErrorMessage( + params.scale + ); + if (errorMessage !== "") throw new Error(errorMessage); + } + } + + static getOctaveFormatErrorMessage(scale: Scale): string { + const octave = new Interval( + ExtendedMonzo.fromFraction(new Fraction(2, 1), 3), + "ratio" + ); + + if (scale.equave.compare(octave) !== 0) { + return KorgExporterError.OCTAVE_INVALID_EQUAVE; + } + + if (scale.intervals.length !== 12) { + return KorgExporterError.OCTAVE_INVALID_SIZE; + } + + const unison = new Interval( + ExtendedMonzo.fromFraction(new Fraction(1, 1), 3), + "ratio" + ); + + for (const interval of scale.intervals) { + if (interval.compare(unison) < 0 || interval.compare(octave) > 0) { + return KorgExporterError.OCTAVE_INVALID_INTERVAL; + } + } + + return ""; } getTuningInfoXml(model: string, programmer = "Scale Workshop", comment = "") { @@ -87,9 +129,9 @@ export class KorgExporter extends BaseExporter { const name = format.name; const tagName = name.replace(" ", "").toLowerCase(); - const rootName = this.useScaleFormat - ? `${tagName}_TuneScaleInformation` - : `${tagName}_TuneOctInformation`; + const rootName = this.useOctaveFormat + ? `${tagName}_TuneOctInformation` + : `${tagName}_TuneScaleInformation`; const xml = `\n` + @@ -111,9 +153,9 @@ export class KorgExporter extends BaseExporter { fileNameHeader, dataName, binName, - ] = this.useScaleFormat - ? ["1", "0", "TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"] - : ["0", "1", "TunO_000.TunO_", "TuneOctData", "TuneOctBinary"]; + ] = this.useOctaveFormat + ? ["0", "1", "TunO_000.TunO_", "TuneOctData", "TuneOctBinary"] + : ["1", "0", "TunS_000.TunS_", "TuneScaleData", "TuneScaleBinary"]; const xml = `\n` + @@ -137,31 +179,18 @@ export class KorgExporter extends BaseExporter { const scale = this.params.scale; const baseMidiNote = this.params.baseMidiNote; - let frequencies = scale.getFrequencyRange( - -baseMidiNote, - SCALE_SIZE - baseMidiNote - ); - - if (!this.useScaleFormat) { - // Normalize to lowest definable pitch, C = 0 "cents" - // Choose C below base MIDI note - const cNote = Math.trunc(baseMidiNote / OCTAVE_SIZE) * OCTAVE_SIZE; - + let frequencies: number[]; + if (this.useOctaveFormat) { 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; - }); + 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 + ); } const binaryData = frequencyTableToBinaryData(frequencies); @@ -174,9 +203,9 @@ export class KorgExporter extends BaseExporter { this.params.name ?? "" ); const fileInfo = this.getFileInfoXml(this.modelName); - const [fileNameHeader, fileType] = this.useScaleFormat - ? ["TunS_000.TunS_", format.scale] - : ["TunO_000.TunO_", format.octave]; + const [fileNameHeader, fileType] = this.useOctaveFormat + ? ["TunO_000.TunO_", format.octave] + : ["TunS_000.TunS_", format.scale]; const zip = new JSZip(); zip.file(fileNameHeader + "bin", binaryData);