Octavian
Utilities for reasoning about musical notes, frequencies, and intervals
Octavian
Type-safe music theory utilities for working with notes, intervals, chords, and scales in TypeScript.
Installation
npm install octavian
pnpm add octavian
yarn add octavian
bun add octavian
[!NOTE] Works in Node 22+ (ESM), Bun 1.3+, and any browser bundler (Vite, webpack, Rollup). A single browser-safe ESM bundle is published — resolution is automatic via the
exportsmap.
Design Goals
- Immutable value objects for
Note,Chord, andScale - Runtime validation at every trust boundary
- Theory-first spelling for interval, chord, and scale construction
- Sharp-preferred simplification by default when a pitch is derived from raw MIDI or semitone math;
flat-preferred available via
'flats'preference - 100% test coverage with full validation gates
Quick Start
import { Chord, Note, Scale } from 'octavian';
const cSharp = Note.create('C#4');
const eb = cSharp.transpose('minorThird');
const cMajorSeven = Chord.create('C4', 'maj7');
const cMajor = Scale.create('C4', 'major');
console.log(String(eb)); // "E4"
console.log(cMajorSeven.notes.map(String)); // ["C4", "E4", "G4", "B4"]
console.log(cMajor.mode('dorian').toString()); // "D dorian"
Types
All public types are importable directly from 'octavian':
import type {
NoteName,
ChordSuffix,
ScaleType,
Interval,
MidiKey,
Frequency,
Semitones,
Octave,
Tuning,
CadenceType,
HarmonicFunction,
RomanNumeralLike,
KeySignatureMode,
IntervalConsonance,
FiguredBass,
} from 'octavian';
Key exported type names:
NoteName: A valid note spelling such as'C#'or'Bb'.ChordSuffix: A canonical chord suffix such as'majorSeventh'or'minorTriad'.ScaleType: A canonical scale type such as'major'or'melodicMinor'.Interval: A canonical interval name such as'majorThird'or'perfectFifth'.MidiKey: A brandednumberin the range0..127.Frequency: A brandednumberin hertz.Semitones: A branded integer semitone distance.Octave: A branded octave value in the range-1..9. UsecreateOctave(n)to produce one.Tuning: A tuning reference, e.g.{ reference: 'A4', frequency: createFrequency(440) }.CadenceType: A cadence label such as'authentic-perfect','half', or'phrygian'.HarmonicFunction:'tonic' | 'predominant' | 'dominant'.KeySignatureMode:'major' | 'minor'.IntervalConsonance:'perfect-consonance' | 'imperfect-consonance' | 'mild-dissonance' | 'sharp-dissonance'.FiguredBass: An array ofFiguredBassFigureobjects representing a figured-bass stack.
Core Types
Notes
Note is the base value object for pitch spelling, MIDI conversion, and frequency conversion.
import { Note } from 'octavian';
const note = Note.create('Bb3');
note.note; // "Bb"
note.octave; // 3
note.midi; // 58
note.frequency; // 233.08... (always standard tuning A4 = 440 Hz)
note.chromaticIndex; // pitch class 0–11
note.enharmonics; // ["A#", "Cbb"]
Constructing notes
import { Note, createFrequency, STANDARD_TUNING } from 'octavian';
Note.create('C#4'); // from a note-name-with-octave string
Note.create({ note: 'C#', octave: 4 }); // from a structured object
Note.fromMidi(61); // "C#4" — sharp-preferred by default
Note.fromMidi(61, 'flats'); // "Db4" — flat-preferred
Note.nearestTo(440); // nearest equal-tempered note under standard tuning
Note.nearestTo(432, { reference: 'A4', frequency: createFrequency(432) }); // alternate tuning
Note.nearestTo(277.18, STANDARD_TUNING, 'flats'); // "Db4"
Transposition and movement
const c = Note.create('C4');
c.transpose('majorThird').toString(); // "E4" — theory-correct spelling
c.transposeBy(1).toString(); // "C#4" — sharp-preferred by default
c.transposeBy(1, 'flats').toString(); // "Db4"
c.up().toString(); // "C5"
c.up(2).toString(); // "C6"
c.down().toString(); // "C3"
c.withOctave(5).toString(); // "C5"
c.simplify().toString(); // "C4" — sharp-preferred common spelling
c.simplify('flats').toString(); // flat-preferred common spelling
Comparison and distance
const a = Note.create('C4');
const b = Note.create('G4');
a.distanceTo(b); // "perfectFifth"
a.semitonesTo(b); // 7
a.equals(Note.create('C4')); // true
a.isEnharmonicTo(Note.create('B#3')); // true
Note.compare(a, b); // -1 | 0 | 1 (by MIDI key)
Frequency and tuning
note.frequency always returns the standard-tuning (A4 = 440 Hz) value. For alternate tunings, use
frequencyAt or the free function noteToFrequency:
import { Note, noteToFrequency, createFrequency } from 'octavian';
const a4 = Note.create('A4');
const tuning432 = { reference: 'A4', frequency: createFrequency(432) };
a4.frequency; // 440 (always standard tuning)
a4.frequencyAt(tuning432); // 432
noteToFrequency(a4, tuning432); // 432
noteToFrequency('A4'); // 440 (accepts any NoteLike, defaults to standard tuning)
Serialization
const note = Note.create('C4');
note.toString(); // "C4"
note.valueOf(); // 60 (MIDI key — makes notes sortable and comparable as numbers)
note.toJSON(); // { note: "C", octave: 4, midi: 60, frequency: 261.63 }
Note.create(note.toJSON()); // round-trips via Note.create
[...note]; // [note, octave, midi, frequency] — iterable
Intervals
The library exports a full interval catalog and helpers for alias resolution.
import {
INTERVALS,
applyInterval,
findCanonicalIntervalBySemitonesAndDegree,
resolveInterval,
} from 'octavian';
resolveInterval('tone'); // "majorSecond"
resolveInterval('P5'); // "perfectFifth"
findCanonicalIntervalBySemitonesAndDegree(6, 5); // "diminishedFifth"
applyInterval(Note.create('F#4'), 'majorThird').toString(); // "A#4"
INTERVALS.majorSixth.symbol; // "M6"
INTERVALS.majorSixth.semitones; // 9
INTERVALS.majorSixth.quality; // "major"
INTERVALS.majorSixth.degree; // 6
Chords
Chord normalizes symbols and suffix aliases into a canonical suffix while keeping immutable note
collections.
Use Chord.create(root, suffix) to construct a chord. To recreate a chord from serialized data, use
Chord.fromJSON(serialized).
import { Chord, Note } from 'octavian';
const chord = Chord.create('C4', 'maj7');
chord.name; // "Cmaj7"
chord.symbol; // "maj7"
chord.suffix; // "majorSeventh"
chord.quality; // "major"
chord.root.toString(); // "C4"
chord.bass.toString(); // "C4"
chord.notes.map(String); // ["C4", "E4", "G4", "B4"]
chord.midi; // [60, 64, 67, 71]
chord.size; // 4
Inversions:
chord.invert().name; // "Cmaj7/E"
chord.invert(2).name; // "Cmaj7/G"
chord.inversion(1).name; // "Cmaj7/E"
chord.lowerFromTop(2).notes.map(String); // ["G3", "C4", "E4", "B4"]
chord.closeVoicing().notes.map(String); // close-position voicing
Chord editing stays catalog-backed:
Chord.create('C4', 'maj7').omit('majorSeventh').name; // "C"
Chord.create('C4', 'major').add('majorSeventh').name; // "Cmaj7"
Chord.create('C4', 'major').alter('perfectFifth', 'augmentedFifth').name; // "Caug"
Chord.create('C4', 'major').slash(Note.create('G3')).name; // "C/G"
Chord catalog helpers:
import { CHORDS, chordQualityForSuffix, createChordName, createSlashChordName } from 'octavian';
CHORDS.majorSeventh.intervals; // ["unison", "majorThird", "perfectFifth", "majorSeventh"]
chordQualityForSuffix('minorSeventh'); // "minor"
createChordName('C', 'maj7'); // "Cmaj7"
createSlashChordName('C', 'maj7', 'E'); // "Cmaj7/E"
Scales
Scale builds theory-correct spellings from a root note and normalized scale type.
import { Scale, Note } from 'octavian';
const scale = Scale.create(Note.create('C4'), 'major');
scale.notes.map(String); // ["C4", "D4", "E4", "F4", "G4", "A4", "B4"]
scale.root.toString(); // "C4"
scale.type; // "major"
scale.size; // 7
Navigation and relationships:
scale.relative('naturalMinor').toString(); // "A naturalMinor"
scale.parallel('naturalMinor').toString(); // "C naturalMinor"
scale.mode('lydian').toString(); // "F lydian"
scale.modes().map((m) => m.toString()); // all 7 modes
scale.rotate(2).toString(); // rotation by 2 positions
scale.next().toString(); // next scale root (semitone up)
scale.previous().toString(); // previous scale root
Degrees and chords:
scale.degree(1).toString(); // "C4"
scale.degree(5).toString(); // "G4"
scale.degreeOf(Note.create('E4')); // 3
scale.chord(5, 'seventh').name; // "G7"
scale.chords().map((c) => c.name); // triads for every degree
scale.seventhChords().map((c) => c.name); // seventh chords for every degree
scale.triad(1).name; // "C"
Ascending/descending helpers return notes from a given starting pitch:
scale.ascendingFrom(Note.create('C3')).map(String);
scale.descendingFrom(Note.create('C5')).map(String);
Scale catalog:
import { SCALES, resolveScaleType, scaleTypeForMode, isDiatonicModeFamily } from 'octavian';
SCALES.major.intervals; // ["unison", "majorSecond", "majorThird", ...]
resolveScaleType('ionian'); // "major"
scaleTypeForMode('dorian'); // "major"
isDiatonicModeFamily('lydian'); // true
isDiatonicModeFamily('blues'); // false
MIDI and Frequency
The library uses standard equal temperament with A4 = 440 Hz.
import {
Note,
STANDARD_TUNING,
midiToFrequency,
midiToNoteNameWithOctave,
noteNameToMidi,
} from 'octavian';
STANDARD_TUNING; // { reference: 'A4', frequency: 440 }
Note.fromMidi(69).toString(); // "A4"
Note.fromMidi(61, 'flats').toString(); // "Db4"
Note.nearestTo(440); // Note at A4
midiToFrequency(69); // 440
midiToNoteNameWithOctave(69); // { note: "A", octave: 4 }
midiToNoteNameWithOctave(61, 'flats'); // { note: "Db", octave: 4 }
noteNameToMidi('A', createOctave(4)); // 69
For alternate tunings, pass a Tuning object:
import { createFrequency, noteToFrequency } from 'octavian';
const tuning432 = { reference: 'A4', frequency: createFrequency(432) };
Note.nearestTo(432, tuning432).toString(); // "A4" (nearest note under that tuning)
noteToFrequency('A4', tuning432); // 432
Parsing and Validation
import {
isChordSuffix,
isChordSymbol,
isInterval,
isNoteName,
isNoteNameWithOctave,
isScaleType,
parseNoteName,
parseNoteNameWithOctave,
} from 'octavian';
isNoteName('Db'); // true
isNoteNameWithOctave('Db4'); // true
isInterval('sharpEleven'); // true
isChordSuffix('minorTriad'); // true
isChordSymbol('m7b5'); // true
isScaleType('mixolydian'); // true
parseNoteName('C#'); // { note: "C#", natural: "C", accidental: "#" }
parseNoteNameWithOctave('Bb3'); // { note: "Bb", octave: 3 }
Invalid input throws TypeError (wrong shape) or RangeError (out-of-bounds value) from
constructors and factory methods instead of failing silently.
Random Selection
For applications like ear-training, the library provides random-pick helpers with an injectable RNG for reproducible tests or seeded quizzes.
import { randomNote, randomInterval, INTERVALS } from 'octavian';
// Pick a random note in a MIDI range (inclusive, sharp-preferred spelling)
const note = randomNote({ range: ['C4', Note.fromMidi(72)] });
// Pick from an explicit pool
const rootNote = randomNote({ pool: ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4'] });
// Pick a random interval from an explicit pool (aliases are normalized; duplicates weight the distribution)
const interval = randomInterval({ pool: ['majorThird', 'perfectFifth', 'minorSeventh'] });
// Inject a seeded RNG for reproducible results
import seedrandom from 'seedrandom'; // any [0,1) RNG
const rng = seedrandom('my-seed');
const seededNote = randomNote({ range: ['C4', 'C5'], random: rng });
Both functions require either range or pool — there is no default. The injected random
function must return values in [0, 1).
Branded Type Constructors
The library exposes constructors for every branded primitive, which are needed when calling APIs that require branded types directly:
import {
createOctave,
createMidiKey,
createFrequency,
createSemitones,
createChromaticIndex,
OCTAVES,
CHROMATIC_INDEXES,
} from 'octavian';
createOctave(4); // Octave (validated: must be -1..9)
createMidiKey(60); // MidiKey (validated: must be 0..127)
createFrequency(440); // Frequency (validated: must be > 0)
createSemitones(7); // Semitones
createChromaticIndex(0); // ChromaticIndex (0..11)
OCTAVES; // [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const
CHROMATIC_INDEXES; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const
Note-Name Utilities
Low-level helpers for working with note spellings directly:
import {
NATURALS,
ACCIDENTALS,
ALL_NOTE_NAMES,
FLAT_PREFERRED_NOTE_NAMES,
SHARP_PREFERRED_NOTE_NAMES,
NATURAL_CHROMATIC_INDEXES,
ACCIDENTAL_OFFSETS,
buildNoteName,
simplifyNoteName,
enharmonicsForNoteName,
normalizeChromaticIndex,
noteNameToChromaticIndex,
} from 'octavian';
NATURALS; // ["C", "D", "E", "F", "G", "A", "B"]
ACCIDENTALS; // ["", "#", "b", "##", "bb"]
ALL_NOTE_NAMES; // all 35 note names (all naturals × all accidentals)
SHARP_PREFERRED_NOTE_NAMES; // 12-note chromatic scale, sharp spellings
FLAT_PREFERRED_NOTE_NAMES; // 12-note chromatic scale, flat spellings
buildNoteName('C', 1); // "C#" (natural + accidental offset)
simplifyNoteName('Bbb'); // "A" (sharp-preferred common spelling)
enharmonicsForNoteName('C#'); // ["Db", "Bx"] (all enharmonic spellings)
normalizeChromaticIndex(13); // 1 (wraps to 0..11)
noteNameToChromaticIndex('C#'); // 1
Chord and Scale Symbols
import { CHORD_SYMBOLS, resolveChordSuffix } from 'octavian';
CHORD_SYMBOLS; // ["m", "m7", "maj7", "dim", ...] — short display symbols
resolveChordSuffix('m7'); // "minorSeventh"
resolveChordSuffix('Δ7'); // "majorSeventh"
Key Signatures
import { KEY_SIGNATURES, keySignatureFor, keySignatureFromAccidentals } from 'octavian';
keySignatureFor('C', 'major');
// { tonic: 'C', mode: 'major', accidentalCount: 0, accidentals: [], order: 'none', ... }
keySignatureFor('F#', 'major');
// { tonic: 'F#', mode: 'major', accidentalCount: 6, accidentals: ['F#','C#','G#','D#','A#','E#'], order: 'sharps' }
keySignatureFromAccidentals(3, 'flats');
// { tonic: 'Eb', mode: 'major', ... }
KEY_SIGNATURES['C major'].accidentalCount; // 0
KEY_SIGNATURES['G major'].accidentals; // ['F#']
The catalog covers all 30 standard keys (15 major + 15 minor), including enharmonic pairs like C♯/D♭
major and A♯/B♭ minor. Theoretical keys (G♯ major, F♭ major, etc.) are present in KEY_SIGNATURES
for reference but are not constructible as Key instances.
Key
Key models a tonal center with its diatonic scale, key signature, and relationships to neighboring
keys.
import { Key } from 'octavian';
const cMajor = Key.create('C', 'major');
cMajor.tonic.toString(); // "C4"
cMajor.mode; // "major"
cMajor.toString(); // "C major"
cMajor.toJSON(); // { tonic: 'C', mode: 'major' }
Relationships
cMajor.relativeKey.toString(); // "A minor"
cMajor.parallelKey.toString(); // "C minor"
cMajor.dominantKey.toString(); // "G major"
cMajor.subdominantKey.toString(); // "F major"
Key.adjacentKeys(cMajor);
// { dominant: Key("G major"), subdominant: Key("F major") }
Key.enharmonicEquivalent(Key.create('F#', 'major')).toString(); // "Gb major"
Key.distanceInFifths(Key.create('C', 'major'), Key.create('G', 'major')); // 1
Diatonic chords
cMajor.diatonicChords().map((c) => c.name);
// ["C", "Dm", "Em", "F", "G", "Am", "Bdim"]
cMajor.diatonicSeventhChords().map((c) => c.name);
// ["Cmaj7", "Dm7", "Em7", "Fmaj7", "G7", "Am7", "Bm7b5"]
cMajor.contains(Note.create('E4')); // true
cMajor.contains(Chord.create('G4', 'dominant7')); // true
Transposition
cMajor.transpose('majorSecond').toString(); // "D major"
cMajor.transposeBy(7).toString(); // "G major"
Circle of Fifths
import {
CIRCLE_OF_FIFTHS_MAJOR,
CIRCLE_OF_FIFTHS_MINOR,
circleOfFifths,
distanceInFifths,
adjacentKeys,
enharmonicEquivalent,
isOnCircleOfFifths,
} from 'octavian';
CIRCLE_OF_FIFTHS_MAJOR.map((k) => k.tonic);
// ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F']
circleOfFifths('C', 'major', 2); // moves 2 steps clockwise → G major signature
distanceInFifths({ tonic: 'C', mode: 'major', ... }, { tonic: 'G', mode: 'major', ... }); // 1
isOnCircleOfFifths('F#', 'major'); // true
isOnCircleOfFifths('G#', 'major'); // false (theoretical key)
Key.adjacentKeys and Key.enharmonicEquivalent are the ergonomic wrappers for most use cases; the
free functions above operate on raw KeySignatureInformation objects for catalog-level work.
Roman Numerals
import { RomanNumeral, romanNumeralFor, chordFromRomanNumeral } from 'octavian';
const rn = RomanNumeral.create('V7');
rn.degree; // 5
rn.quality; // "major"
rn.inversion; // "7"
rn.toString(); // "V7"
RomanNumeral.create('bVII').alteration; // "flat"
// Analysis: chord → Roman numeral
const cMajor = Key.create('C', 'major');
romanNumeralFor(cMajor, Chord.create('G4', 'dominant7')).toString(); // "V7"
romanNumeralFor(cMajor, Chord.create('E4', 'minor')).toString(); // "iii"
// Synthesis: Roman numeral → chord
chordFromRomanNumeral(cMajor, 'IV').name; // "F"
chordFromRomanNumeral(cMajor, 'V7').name; // "G7"
Harmonic Function
Classifies a chord's role within a key as tonic, predominant, or dominant.
import {
harmonicFunctionFor,
harmonicFunctionForNumeral,
harmonicFunctionForAsAlias,
} from 'octavian';
const cMajor = Key.create('C', 'major');
harmonicFunctionFor(cMajor, Chord.create('C4', 'major')); // "tonic"
harmonicFunctionFor(cMajor, Chord.create('F4', 'major')); // "predominant"
harmonicFunctionFor(cMajor, Chord.create('G4', 'dominant7')); // "dominant"
harmonicFunctionFor(cMajor, Chord.create('D4', 'minor')); // "predominant"
// From a RomanNumeral directly (avoids re-parsing the chord)
harmonicFunctionForNumeral(RomanNumeral.create('ii'), 'major'); // "predominant"
// Alias form: returns 'subdominant' instead of 'predominant'
harmonicFunctionForAsAlias(cMajor, Chord.create('F4', 'major')); // "subdominant"
Returns null for non-diatonic chords or altered/applied numerals.
Figured Bass
import {
figuredBassForChord,
figuredBassInversionFor,
figuredBassToChord,
parseFiguredBass,
formatFiguredBass,
} from 'octavian';
const cMaj = Chord.create('C4', 'major');
const cMajFirstInv = cMaj.invert();
figuredBassForChord(cMaj).length; // 0 (root position — empty stack)
figuredBassInversionFor(cMaj); // "5/3"
figuredBassInversionFor(cMajFirstInv); // "6"
const g7 = Chord.create('G4', 'dominant7');
figuredBassInversionFor(g7.invert()); // "6/5"
figuredBassInversionFor(g7.invert(2)); // "4/3"
figuredBassInversionFor(g7.invert(3)); // "4/2"
// Parse a figured-bass string into a figure stack
parseFiguredBass('6/4'); // [{ digit: 6 }, { digit: 4 }]
parseFiguredBass('♭7'); // [{ digit: 7, accidental: 'flat' }]
parseFiguredBass('6#'); // [{ digit: 6, accidental: 'sharp' }]
// Format a figure stack back to strings
formatFiguredBass([{ digit: 6 }, { digit: 4 }]);
// { stacked: ['6', '4'], inline: '6/4' }
// Reconstruct a chord from a bass note, figures, and key context
const cMajor = Key.create('C', 'major');
figuredBassToChord('E4', '6', cMajor).name; // "C/E" (C major, first inversion)
figuredBassToChord('G4', '6/4', cMajor).name; // "C/G" (C major, second inversion)
Interval Operations
import {
invertInterval,
simplifyInterval,
compoundInterval,
consonanceOf,
isConsonant,
isDissonant,
} from 'octavian';
invertInterval('perfectFifth'); // "perfectFourth"
invertInterval('majorThird'); // "minorSixth"
invertInterval('augmentedFourth'); // "diminishedFifth"
simplifyInterval('perfectEleventh'); // "perfectFourth" (P11 → P4)
simplifyInterval('majorNinth'); // "majorSecond" (M9 → M2)
compoundInterval('perfectFourth', 1); // "perfectEleventh"
compoundInterval('majorSecond', 1); // "majorNinth"
consonanceOf('perfectFifth'); // "perfect-consonance"
consonanceOf('majorThird'); // "imperfect-consonance"
consonanceOf('majorSecond'); // "mild-dissonance"
consonanceOf('minorSecond'); // "sharp-dissonance"
isConsonant('perfectFifth'); // true
isDissonant('augmentedFourth'); // true
Cadences
CadenceInput accepts a Roman numeral string ('V', 'iv6'), a RomanNumeral object, or a
Chord.
import { Key, identifyCadence, identifyCadenceSequence } from 'octavian';
const cMajor = Key.create('C', 'major');
// Via Key methods
cMajor.identifyCadence('V', 'I'); // "authentic-perfect"
cMajor.identifyCadence('V', 'I6'); // "authentic-imperfect"
cMajor.identifyCadence('ii', 'V'); // "half"
cMajor.identifyCadence('IV', 'I'); // "plagal"
cMajor.identifyCadence('V', 'vi'); // "deceptive"
const aMinor = Key.create('A', 'minor');
aMinor.identifyCadence('iv6', 'V'); // "phrygian"
// Non-cadential pair
cMajor.identifyCadence('I', 'IV'); // null
// Via free functions (same Key as first argument)
identifyCadence(cMajor, 'V', 'I'); // "authentic-perfect"
identifyCadence(cMajor, Chord.create('G4', 'dominant7'), Chord.create('C4', 'major'));
// "authentic-perfect"
// Scan a full progression for cadences
cMajor
.identifyCadenceSequence(['I', 'IV', 'I', 'ii', 'V'])
.map((c) => `index ${c.index}: ${c.type}`);
// ["index 0: plagal", "index 3: half"]
identifyCadenceSequence(cMajor, ['I', 'IV', 'V', 'I']);
// [{ index: 1, type: 'half' }, { index: 2, type: 'authentic-perfect' }]
Development
Run the local quality gates with Bun:
bun run typecheck
bun run lint
bun test
bun run build
bun run validate