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);