Skip to content

Commit

Permalink
Port Scala to Sunvox
Browse files Browse the repository at this point in the history
  • Loading branch information
jangler committed Mar 21, 2024
1 parent 347a279 commit d2fec1f
Show file tree
Hide file tree
Showing 13 changed files with 2,216 additions and 9 deletions.
1,911 changes: 1,910 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
},
"scripts": {
"test": "jest"
},
"dependencies": {
"local-web-server": "^5.3.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width">
<meta name="description" content="Generate Scala scales for the Exquis">
<title>Exquis .scl generator</title>
<link rel="stylesheet" href="normalize.css">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="/normalize.css">
<link rel="stylesheet" href="/styles.css">
<script src="script.js" defer></script>
</head>

Expand Down Expand Up @@ -38,7 +38,7 @@ <h1>Exquis .scl generator</h1>

<footer>
<ul>
<li><a href=".">Index</a></li>
<li><a href="/">Index</a></li>
<li><a href="https://github.com/jangler/tuning">GitHub</a></li>
</ul>
</footer>
Expand Down
File renamed without changes.
3 changes: 2 additions & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ <h1>Microtonal web utilities</h1>

<main>
<ul>
<li><a href="exquis-scl-generator">Exquis .scl generator</a></li>
<li><a href="exquis-scl-generator/">Exquis .scl generator</a></li>
<li><a href="layout-heatmaps/">Layout heatmaps</a></li>
<li><a href="scala-to-sunvox/">Scala to SunVox</a></li>
</ul>
</main>

Expand Down
6 changes: 3 additions & 3 deletions src/layout-heatmaps/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width">
<meta name="description" content="Interactive plots showing best step size pairs for approximating LCJI on various MIDI controllers">
<title>Isomorphic note layout heatmaps</title>
<link rel="stylesheet" href="../normalize.css">
<link rel="stylesheet" href="../styles.css">
<link rel="stylesheet" href="/normalize.css">
<link rel="stylesheet" href="/styles.css">
</head>

<body>
Expand Down Expand Up @@ -49,7 +49,7 @@ <h1>Isomorphic note layout heatmaps</h1>

<footer>
<ul>
<li><a href="..">Index</a></li>
<li><a href="/">Index</a></li>
<li><a href="https://github.com/jangler/tuning">GitHub</a></li>
</ul>
</footer>
Expand Down
52 changes: 52 additions & 0 deletions src/scala-to-sunvox/curve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// curve generation code

export { generateCurve };

import { Scale } from "./scl.js";
import { Keymap } from "./kbm.js";

const TOTAL_KEYS = 128;
const C5_UNITS = 0x7c00;
const C5_KEY = 60;
const CENTS_PER_SEMITONE = 100;
const UNITS_PER_SEMITONE = 0x100;
const MIN_UNITS = 0;
const MAX_UNITS = 0xffff;

function generateCurve(scale: Scale, keymap: Keymap, offset: number): Uint8Array {
const buf = new Uint8Array(TOTAL_KEYS * 2);
for (let key = 0; key < TOTAL_KEYS; key++) {
const units = (key >= keymap.firstNote && key <= keymap.lastNote) ?
calcPitch(scale, keymap, key, offset) :
C5_UNITS + (key - C5_KEY) * UNITS_PER_SEMITONE;
buf[key * 2] = units & 0xff;
buf[key * 2 + 1] = units >> 8;
}
return buf;
}

function modulo(x: number, y: number): number {
let n = x % y;
while (n < 0) {
n += y;
}
return n;
}

function calcPitch(
scale: Scale, keymap: Keymap, key: number, centsOffset: number): number {
// sorry for all the increment/decrement nonsense
const offset = key - 1 - keymap.middleNote;
const octave = Math.floor(offset / keymap.size);
let mapIndex = modulo(offset + 1, keymap.size);
const scaleIndex = keymap.mapping[mapIndex];
const units = scaleIndex === null ?
MIN_UNITS :
C5_UNITS + Math.round(
(scale.notes[modulo(scaleIndex - 1, scale.notes.length)] +
octave * scale.notes[scale.notes.length - 1] +
centsOffset) *
UNITS_PER_SEMITONE / CENTS_PER_SEMITONE
);
return Math.max(MIN_UNITS, Math.min(MAX_UNITS, units));
}
62 changes: 62 additions & 0 deletions src/scala-to-sunvox/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en-US">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="description" content="Convert Scala scales and keyboard maps to SunVox pitch curves">
<title>Scala to SunVox</title>
<link rel="stylesheet" href="/normalize.css">
<link rel="stylesheet" href="/styles.css">
<script src="script.js" type="module" defer></script>
</head>

<body>

<header>
<h1>Scala to SunVox</h1>
</header>

<main>

<section>
<p>
This webpage converts
<a href="https://www.huygens-fokker.org/scala/">Scala</a> scales and
keyboard maps to <a href="https://warmplace.ru/soft/sunvox/">SunVox</a>
pitch curves.
</p>
</section>

<section>
<label for="sclInput">Select .scl:</label>
<input type="file" id="sclInput" accept=".scl">
</section>

<section>
<label for="kbmInput">Select .kbm (optional):</label>
<input type="file" id="kbmInput" accept=".kbm">
</section>

<section>
<label for="centsInput">Cents offset:</label>
<input type="number" id="centsInput" value="0">
</section>

<section>
<button id="convertButton">Convert to .curve16bit</button>
<p id="messageArea"></p>
</section>

</main>

<footer>
<ul>
<li><a href="/">Index</a></li>
<li><a href="https://github.com/jangler/tuning">GitHub</a></li>
</ul>
</footer>

</body>

</html>
49 changes: 49 additions & 0 deletions src/scala-to-sunvox/kbm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// kbm-parsing code

export { Keymap, parseKbm, defaultMap };

type Keymap = {
size: number;
firstNote: number;
lastNote: number;
middleNote: number;
referenceNote: number;
frequency: number;
formalOctave: number;
mapping: Array<number | null>;
}

function parseKbm(text: string): Keymap {
const lines = text.split('\n').filter((s) => !s.startsWith('!'));
if (lines.length < 7) {
throw new Error('Invalid mapping file')
}
const keymap = {
size: parseInt(lines[0]),
firstNote: parseInt(lines[1]),
lastNote: parseInt(lines[2]),
middleNote: parseInt(lines[3]),
referenceNote: parseInt(lines[4]),
frequency: parseFloat(lines[5]),
formalOctave: parseInt(lines[6]),
mapping: lines.slice(7).map((s) =>
s.trim() == 'x' ? null : parseInt(s)),
};
if (keymap.mapping.length != keymap.size) {
throw new Error('Wrong number of keys in mapping');
}
return keymap;
}

function defaultMap(size: number): Keymap {
return {
size: size,
firstNote: 0,
lastNote: 127,
middleNote: 60,
referenceNote: 69,
frequency: 440.0,
formalOctave: size,
mapping: [...new Array(size).keys()],
};
}
42 changes: 42 additions & 0 deletions src/scala-to-sunvox/scl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// scl-parsing code

export { Scale, parseScl };

type Scale = {
description: string;
notes: Array<number>;
}

function centsFromRatio(num: number, den: number): number {
return 1200 * Math.log(num / den) / Math.log(2);
}

function parseNote(line: string): number {
line = line.trim();
if (/^\d+$/.test(line)) {
const num = parseInt(line);
return centsFromRatio(num, 1);
} else if (/^\d+\/\d+$/.test(line)) {
const tokens = line.split('/').map((x) => parseInt(x));
return centsFromRatio(tokens[0], tokens[1]);
} else if (/^\d*.\d*$/.test(line)) {
return parseFloat(line);
}
throw new Error(`Could not parse pitch value: ${line}`)
}

function parseScl(text: string): Scale {
const lines = text.split('\n').filter((s) => !s.startsWith('!'));
if (lines.length < 2) {
throw new Error('Invalid scale file')
}
const scale = {
description: lines[0],
notes: lines.slice(2).filter((s) => s.length > 0).map(parseNote),
};
const count = parseInt(lines[1]);
if (scale.notes.length != count) {
throw new Error('Wrong number of notes in scale');
}
return scale;
}
80 changes: 80 additions & 0 deletions src/scala-to-sunvox/script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// front-end

import { parseScl } from './scl.js';
import { parseKbm, defaultMap } from './kbm.js';
import { generateCurve } from './curve.js';

const sclInput =
document.querySelector('#sclInput') as HTMLInputElement;
const kbmInput =
document.querySelector('#kbmInput') as HTMLInputElement;
const centsInput =
document.querySelector('#centsInput') as HTMLInputElement;
const convertButton =
document.querySelector('#convertButton') as HTMLButtonElement;
const messageArea =
document.querySelector('#messageArea') as HTMLParagraphElement;

function reportError(err: Error) {
messageArea.setAttribute('style', 'display: block;');
messageArea.innerText = err.message + '.';
}

function clearError() {
messageArea.setAttribute('style', 'display: none;')
}

// https://stackoverflow.com/questions/19327749/
function download(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('style', 'display: none');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(url);
document.body.removeChild(a);
});
}

convertButton.addEventListener('click', (event) => {
const scl = sclInput.files?.item(0);
const kbm = kbmInput.files?.item(0);
const offset = centsInput.valueAsNumber;
if (isNaN(offset)) {
reportError(new Error('Invalid cents offset'));
} else if (scl) {
scl.text().then((sclText) => {
const scale = parseScl(sclText);
if (kbm) {
kbm.text().then((kbmText) => {
const keymap = parseKbm(kbmText);
const buf = generateCurve(scale, keymap, offset);
finish(buf, scl.name);
}).catch((err) => {
reportError(err);
});
} else {
const keymap = defaultMap(scale.notes.length);
const buf = generateCurve(scale, keymap, offset);
finish(buf, scl.name);
}
}).catch((err) => {
reportError(err);
});
} else {
reportError(new Error('No scale selected'));
}
});

function finish(buf: Uint8Array, filename: string) {
clearError();
try {
const blob = new Blob([buf], { type: 'application/octet-stream' });
download(blob, filename.replace('.scl', '.curve16bit'));
} catch (err) {
reportError(err as Error);
}
}
9 changes: 9 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,13 @@ table {
padding: 0.5rem;
border: 1px solid lightgray;
}
}

section {
margin: 1rem 0;
}

/* Not sure why this text looks bigger than everything else at medium! */
input[type="file"] {
font-size: small;
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */

/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"module": "es6", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
Expand Down

0 comments on commit d2fec1f

Please sign in to comment.