Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 105 additions & 44 deletions packages/music_notes/lib/src/tuning_system/five_limit_tuning.dart
Original file line number Diff line number Diff line change
@@ -1,67 +1,128 @@
import 'package:music_notes/utils.dart';

import '../note/note.dart';
import '../pitch/pitch.dart';
import 'just_intonation.dart';

/// A single point on the 5-limit lattice: `fifths` ascending fifths and
/// `thirds` ascending major thirds (either may be negative for the
/// descending direction), together with the pitch-class `ratio` (always
/// within `[1, 2)`) that combination produces.
///
/// `isCanonical` marks the specific path [FiveLimitTuning.ratio] actually
/// uses for a given note.
/// ---
/// See [FiveLimitTuning.pathsTo].
typedef FiveLimitPath = ({
int fifths,
int thirds,
num ratio,
bool isCanonical,
});

/// A representation of the five-limit tuning system.
///
/// Unlike [JustIntonation]’s default single-generator chain of fifths (used
/// by e.g. `PythagoreanTuning`), five-limit tuning builds each pitch class
/// from products of the ascending fifth (3/2) *and* the ascending major
/// third (5/4) (e.g., ratios whose prime factors are limited to 2, 3, and
/// 5), following the classic Ptolemaic/just intonation diatonic and
/// from a two-dimensional lattice of ascending fifths (3/2) *and* ascending
/// major thirds (5/4) (e.g., ratios whose prime factors are limited to 2, 3,
/// and 5), following the classic Ptolemaic/just intonation diatonic and
/// chromatic scale.
///
/// Because the number of fifths and thirds needed to reach a given note is
/// not derivable from [NoteCircleOfFifths.fifthsDistanceWith] alone
/// (e.g. E is a single ascending major third from C, not four ascending
/// fifths), this tuning system is defined via an explicit lookup table for the
/// 12 standard chromatic notes rather than a generic chain-building algorithm.
/// A standard [Note] spelling only pins down a single coordinate: its
/// three-limit (Pythagorean) fifths distance from the fork, via
/// [NoteCircleOfFifths.fifthsDistanceWith]. Because four ascending fifths and
/// one ascending major third differ only by the
/// [JustIntonation.syntonicCommaRatio], that single fifths distance can be
/// re-expressed as *infinitely many* different (fifths, thirds)
/// coordinate pairs on the lattice, the choice of which one to use is a
/// genuine convention, not something derivable from the note name alone.
/// [pathsTo] enumerates these alternatives explicitly.
///
/// Enharmonically-equivalent spellings intentionally have different
/// ratios (e.g., C♯ (135/128) and D♭ (16/15)), reflecting the fact that,
/// unlike in 12-tone equal temperament, they are not the same pitch in a
/// just intonation system.
/// This implementation resolves the ambiguity by picking the pair with the
/// fewest remaining fifths, e.g., the number of ascending major thirds
/// nearest to `fifthsDistance / 4` (ties rounding towards zero). This
/// reproduces the conventional 12-tone “asymmetric” 5-limit scale, and
/// extends to *any* [Note], including double-sharps/flats and other spellings
/// outside the 12 standard pitch classes.
///
/// See [Five-limit tuning](https://en.wikipedia.org/wiki/Five-limit_tuning).
class FiveLimitTuning extends JustIntonation {
/// Creates a new [FiveLimitTuning] from [fork].
const FiveLimitTuning({super.fork});

/// The 5-limit ratio (relative to [Note.c]) of each of the 12 standard
/// chromatic notes, expressed as products of the ascending fifth (3/2)
/// and ascending major third (5/4).
static final pitchClassRatios = <Note, Rational>{
.c: const Rational(1),
.c.sharp: const Rational(135, 128),
.d.flat: const Rational(16, 15),
.d: const Rational(9, 8),
.d.sharp: const Rational(75, 64),
.e.flat: const Rational(6, 5),
.e: const Rational(5, 4),
.f: const Rational(4, 3),
.f.sharp: const Rational(45, 32),
.g.flat: const Rational(64, 45),
.g: const Rational(3, 2),
.g.sharp: const Rational(25, 16),
.a.flat: const Rational(8, 5),
.a: const Rational(5, 3),
.a.sharp: const Rational(225, 128),
.b.flat: const Rational(16, 9),
.b: const Rational(15, 8),
};
/// The ratio of an ascending major third.
static const ascendingMajorThirdRatio = 5 / 4;

@override
num ratio(Pitch pitch) {
final pitchClassRatio =
pitchClassRatios[pitch.note] ??
(throw UnsupportedError(
'FiveLimitTuning does not define a ratio for ${pitch.note}. '
'Only the 12 standard chromatic notes (natural notes and single '
'sharps/flats) are supported.',
));
final distance = fork.pitch.note.fifthsDistanceWith(pitch.note);
final thirds = _nearestThirdsCount(distance);

return octaveAdjustedRatio(
pitch,
pitchClassRatioFrom(fifths: distance - 4 * thirds, thirds: thirds),
);
}

/// The pitch-class ratio (within `[1, 2)`) reached by [fifths] ascending
/// fifths and [thirds] ascending major thirds, independent of any
/// particular [Note] spelling.
///
/// This is the building block behind [ratio] and [pathsTo]: rather than
/// resolving which (fifths, thirds) pair best represents a note, it lets
/// you choose the lattice coordinates directly.
num pitchClassRatioFrom({required int fifths, required int thirds}) =>
foldIntoOctave(
chainRatio(fifths, fifthRatio) *
chainRatio(thirds, ascendingMajorThirdRatio),
);

/// Every (fifths, thirds) lattice coordinate (within [maxThirds] thirds of
/// zero) that reaches the same pitch class as [pitch]’s note, e.g., every
/// pair satisfying `fifths + 4 * thirds == distance`, where `distance` is
/// [NoteCircleOfFifths.fifthsDistanceWith] the fork.
///
/// Each entry’s `FiveLimitPath.ratio` is generally different: moving from
/// one path to its neighbor (one more third, four fewer fifths) divides
/// the ratio by exactly [JustIntonation.syntonicCommaRatio], except where
/// that step also crosses an octave fold, which additionally doubles or
/// halves it. The single path with `FiveLimitPath.isCanonical` set is the
/// one [ratio] actually uses.
///
/// Example:
/// ```dart
/// // F♯ (distance 6): the conventional path (1 third, 2 fifths, 45/32)
/// // sits alongside its “juster” but fifths-heavier neighbor (2 thirds,
/// // -2 fifths, 25/18).
/// const FiveLimitTuning().pathsTo(Note.f.sharp.inOctave(4));
/// ```
List<FiveLimitPath> pathsTo(Pitch pitch, {int maxThirds = 3}) {
final distance = fork.pitch.note.fifthsDistanceWith(pitch.note);
final canonicalThirds = _nearestThirdsCount(distance);

return [
for (var thirds = -maxThirds; thirds <= maxThirds; thirds++)
(
fifths: distance - 4 * thirds,
thirds: thirds,
ratio: pitchClassRatioFrom(
fifths: distance - 4 * thirds,
thirds: thirds,
),
isCanonical: thirds == canonicalThirds,
),
];
}

/// The number of ascending major thirds (each standing in for four
/// ascending fifths, up to the syntonic comma) that best approximates
/// [fifthsDistance] while minimizing the number of fifths left over.
///
/// Equivalent to `(fifthsDistance / 4).round()`, with ties (an exact
/// `.5`) broken towards zero.
static int _nearestThirdsCount(int fifthsDistance) {
final quotient = fifthsDistance ~/ 4;
final remainder = fifthsDistance - quotient * 4;

return octaveAdjustedRatio(pitch, pitchClassRatio.toDouble());
return remainder.abs() * 2 > 4 ? quotient + remainder.sign : quotient;
}
}
35 changes: 31 additions & 4 deletions packages/music_notes/lib/src/tuning_system/just_intonation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,55 @@ abstract class JustIntonation extends TuningSystem {
/// (e.g. `MeantoneTuning`) override this with their own tempered fifth.
num get fifthRatio => ascendingFifthRatio;

/// The ratio of the ascending fourth, i.e. the octave complement of
/// The ratio of the ascending fourth, e.g., the octave complement of
/// [fifthRatio] (a fourth and a fifth together span an octave).
num get fourthRatio => 2 / fifthRatio;

@override
num ratio(Pitch pitch) {
final distance = fork.pitch.note.fifthsDistanceWith(pitch.note);

return octaveAdjustedRatio(pitch, chainRatio(distance, fifthRatio));
}

/// Builds a pitch-class ratio by applying [ascendingRatio] (or its octave
/// complement, `2 / ascendingRatio`, if [distance] is negative) up to
/// [distance] times, folding the running product back into a single
/// octave whenever it reaches `2`.
///
/// The result always lies within `[1, 2)`.
@protected
num chainRatio(int distance, num ascendingRatio) {
final descendingRatio = 2 / ascendingRatio;
var ratio = 1.0;
for (var i = 1; i <= distance.abs(); i++) {
ratio *= distance.isNegative ? fourthRatio : fifthRatio;
ratio *= distance.isNegative ? descendingRatio : ascendingRatio;
// When ratio is greater than 2, so greater than [Size.octave],
// divide by 2 to transpose it down by one octave.
if (ratio >= 2) ratio /= 2;
}

return octaveAdjustedRatio(pitch, ratio);
return ratio;
}

/// Folds [ratio] into a single octave, e.g., `[1, 2)`.
@protected
num foldIntoOctave(num ratio) {
var folded = ratio;
while (folded >= 2) {
folded /= 2;
}
while (folded < 1) {
folded *= 2;
}

return folded;
}

/// Applies the correct octave transposition to [pitchClassRatio] for
/// [pitch] relative to [fork].
///
/// [pitchClassRatio] is assumed to lie within `[1, 2)`, i.e. as if [pitch]
/// [pitchClassRatio] is assumed to lie within `[1, 2)`, e.g., as if [pitch]
/// were in the same octave as [fork]. This method derives the real octave
/// delta between [pitch] and [fork] from their real semitone distance,
/// discounting the octave already embedded in [pitchClassRatio],
Expand Down
Loading
Loading