diff --git a/packages/music_notes/lib/src/tuning_system/five_limit_tuning.dart b/packages/music_notes/lib/src/tuning_system/five_limit_tuning.dart index 93561a84..73309f18 100644 --- a/packages/music_notes/lib/src/tuning_system/five_limit_tuning.dart +++ b/packages/music_notes/lib/src/tuning_system/five_limit_tuning.dart @@ -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 = { - .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 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; } } diff --git a/packages/music_notes/lib/src/tuning_system/just_intonation.dart b/packages/music_notes/lib/src/tuning_system/just_intonation.dart index 6a4db5a0..9121e6d4 100644 --- a/packages/music_notes/lib/src/tuning_system/just_intonation.dart +++ b/packages/music_notes/lib/src/tuning_system/just_intonation.dart @@ -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], diff --git a/packages/music_notes/test/src/five_limit_tuning_test.dart b/packages/music_notes/test/src/five_limit_tuning_test.dart index f920770b..c1f292a1 100644 --- a/packages/music_notes/test/src/five_limit_tuning_test.dart +++ b/packages/music_notes/test/src/five_limit_tuning_test.dart @@ -9,48 +9,72 @@ void main() { }); test('returns the 5-limit ratio for the diatonic notes', () { - expect(const FiveLimitTuning().ratio(Note.d.inOctave(4)), 9 / 8); - expect(const FiveLimitTuning().ratio(Note.e.inOctave(4)), 5 / 4); - expect(const FiveLimitTuning().ratio(Note.f.inOctave(4)), 4 / 3); - expect(const FiveLimitTuning().ratio(Note.g.inOctave(4)), 3 / 2); - expect(const FiveLimitTuning().ratio(Note.a.inOctave(4)), 5 / 3); - expect(const FiveLimitTuning().ratio(Note.b.inOctave(4)), 15 / 8); + expect( + const FiveLimitTuning().ratio(Note.d.inOctave(4)), + closeTo(9 / 8, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.e.inOctave(4)), + closeTo(5 / 4, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.f.inOctave(4)), + closeTo(4 / 3, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.g.inOctave(4)), + closeTo(3 / 2, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.a.inOctave(4)), + closeTo(5 / 3, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.b.inOctave(4)), + closeTo(15 / 8, 1e-12), + ); }); test('returns the 5-limit ratio for the chromatic notes', () { expect( const FiveLimitTuning().ratio(Note.c.sharp.inOctave(4)), - 135 / 128, + closeTo(25 / 24, 1e-12), ); expect( const FiveLimitTuning().ratio(Note.d.flat.inOctave(4)), - 16 / 15, + closeTo(16 / 15, 1e-12), ); expect( const FiveLimitTuning().ratio(Note.d.sharp.inOctave(4)), - 75 / 64, + closeTo(75 / 64, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.e.flat.inOctave(4)), + closeTo(6 / 5, 1e-12), ); - expect(const FiveLimitTuning().ratio(Note.e.flat.inOctave(4)), 6 / 5); expect( const FiveLimitTuning().ratio(Note.f.sharp.inOctave(4)), - 45 / 32, + closeTo(45 / 32, 1e-12), ); expect( const FiveLimitTuning().ratio(Note.g.flat.inOctave(4)), - 64 / 45, + closeTo(64 / 45, 1e-12), ); expect( const FiveLimitTuning().ratio(Note.g.sharp.inOctave(4)), - 25 / 16, + closeTo(25 / 16, 1e-12), + ); + expect( + const FiveLimitTuning().ratio(Note.a.flat.inOctave(4)), + closeTo(8 / 5, 1e-12), ); - expect(const FiveLimitTuning().ratio(Note.a.flat.inOctave(4)), 8 / 5); expect( const FiveLimitTuning().ratio(Note.a.sharp.inOctave(4)), - 225 / 128, + closeTo(225 / 128, 1e-12), ); expect( const FiveLimitTuning().ratio(Note.b.flat.inOctave(4)), - 16 / 9, + closeTo(16 / 9, 1e-12), ); }); @@ -88,15 +112,137 @@ void main() { expect(const FiveLimitTuning().ratio(Note.g.inOctave(3)), 0.75); }); - test('throws for notes outside the standard 12-note chromatic set', () { + test('computes ratios for notes outside the standard 12, unlike a ' + 'fixed lookup table', () { + // F𝄪 (double sharp, distance 13): 3 ascending thirds and 1 + // ascending fifth, landing in the same octave as the fork. + expect( + const FiveLimitTuning().ratio(Note.f.sharp.sharp.inOctave(4)), + closeTo(375 / 256, 1e-12), + ); + }); + + test('respects real octave placement even when it crosses a letter ' + 'boundary', () { + // C♭ ’s accidental pushes its real pitch height *below* its + // letter’s octave: C♭4 is enharmonically B3 (see + // Pitch.respelledSimple’s doc comment). Its 5-limit pitch class, + // 48/25 (2 descending thirds + 1 ascending fifth), therefore gets + // folded down by one octave to land where C♭4 really sits: just + // under the C4 fork, not almost an octave above it. + expect( + const FiveLimitTuning().ratio(Note.c.flat.inOctave(4)), + closeTo(24 / 25, 1e-9), + ); + expect( + const FiveLimitTuning().ratio(Note.c.flat.inOctave(4)), + lessThan(1), + ); + }); + + test('prefers the fewest fifths, matching the “asymmetric scale” ' + 'convention for C♯ (25/24, not the also-common 135/128)', () { + expect( + const FiveLimitTuning().ratio(Note.c.sharp.inOctave(4)), + closeTo(25 / 24, 1e-12), + ); + }); + }); + + group('.pitchClassRatioFrom()', () { + test('returns 1 for the origin (0 fifths, 0 thirds)', () { + expect( + const FiveLimitTuning().pitchClassRatioFrom(fifths: 0, thirds: 0), + 1, + ); + }); + + test('returns the pure fifth/third for a single step on each axis', () { expect( - () => const FiveLimitTuning().ratio(Note.c.flat.inOctave(4)), - throwsUnsupportedError, + const FiveLimitTuning().pitchClassRatioFrom(fifths: 1, thirds: 0), + 3 / 2, ); expect( - () => const FiveLimitTuning().ratio(Note.f.sharp.sharp.inOctave(4)), - throwsUnsupportedError, + const FiveLimitTuning().pitchClassRatioFrom(fifths: 0, thirds: 1), + 5 / 4, + ); + }); + + test('matches the coordinates .ratio() resolves to, for a pitch in ' + 'the fork’s own octave', () { + // F♯4 (distance 6) is a pitch in the same octave as the C4 fork, + // so no additional octave adjustment applies and .ratio() should + // equal the canonical path’s pitchClassRatioFrom() exactly. + expect( + const FiveLimitTuning().ratio(Note.f.sharp.inOctave(4)), + const FiveLimitTuning().pitchClassRatioFrom(fifths: 2, thirds: 1), + ); + }); + }); + + group('.pathsTo()', () { + test('every path satisfies fifths + 4 * thirds == fifthsDistance', () { + final paths = const FiveLimitTuning().pathsTo( + Note.f.sharp.inOctave(4), + ); + for (final path in paths) { + expect(path.fifths + 4 * path.thirds, 6); // F♯ distance from C + } + }); + + test('exactly one path is canonical, and it matches .ratio()', () { + final pitch = Note.f.sharp.inOctave(4); + final paths = const FiveLimitTuning().pathsTo(pitch); + final canonical = paths.where((path) => path.isCanonical); + + expect(canonical, hasLength(1)); + expect(canonical.single.ratio, const FiveLimitTuning().ratio(pitch)); + }); + + test('returns the two commonly-cited alternatives for F♯ (45/32 vs ' + 'the juster but fifths-heavier 25/18)', () { + final paths = const FiveLimitTuning().pathsTo( + Note.f.sharp.inOctave(4), + ); + + final conventional = paths.firstWhere( + (path) => path.thirds == 1 && path.fifths == 2, + ); + expect(conventional.ratio, 45 / 32); + expect(conventional.isCanonical, isTrue); + + final juster = paths.firstWhere( + (path) => path.thirds == 2 && path.fifths == -2, + ); + expect(juster.ratio, closeTo(25 / 18, 1e-9)); + expect(juster.isCanonical, isFalse); + }); + + test('adjacent paths differ by exactly the syntonic comma, away from ' + 'octave-fold boundaries', () { + // F♯ (distance 6): none of these paths cross an octave fold, so + // every step divides the ratio by exactly 81/80 as thirds + // increases by one. + final paths = const FiveLimitTuning().pathsTo( + Note.f.sharp.inOctave(4), + ); + const comma = JustIntonation.syntonicCommaRatio; + + for (var i = 1; i < paths.length; i++) { + expect( + paths[i - 1].ratio / paths[i].ratio, + closeTo(comma, 1e-9), + reason: 'thirds ${paths[i - 1].thirds} -> ${paths[i].thirds}', + ); + } + }); + + test('respects a custom maxThirds window', () { + final paths = const FiveLimitTuning().pathsTo( + Note.c.inOctave(4), + maxThirds: 1, ); + expect(paths, hasLength(3)); // thirds in {-1, 0, 1} }); });