diff --git a/lib/src/interval/interval.dart b/lib/src/interval/interval.dart index ffcbcbc5..b42934a8 100644 --- a/lib/src/interval/interval.dart +++ b/lib/src/interval/interval.dart @@ -182,7 +182,7 @@ final class Interval ); /// Creates a new [Interval] from [size] and [Quality.semitones]. - factory Interval.fromQualitySemitones(Size size, int semitones) { + factory Interval.fromSizeAndQualitySemitones(Size size, int semitones) { final qualityConstructor = size.isPerfect ? PerfectQuality.new : ImperfectQuality.new; @@ -190,12 +190,20 @@ final class Interval } /// Creates a new [Interval] from [size] and [Interval.semitones]. - factory Interval.fromSemitones(Size size, int semitones) => - Interval.fromQualitySemitones( + factory Interval.fromSizeAndSemitones(Size size, int semitones) => + Interval.fromSizeAndQualitySemitones( size, semitones * size.sign - size.semitones.abs(), ); + /// Creates a new [Interval] from the given distance in [semitones]. + /// The size is inferred. + factory Interval.fromSemitones(int semitones) => + Interval.fromSizeAndSemitones( + Size.nearestFromSemitones(semitones), + semitones, + ); + /// Parse [source] as an [Interval] and return its value. /// /// If the [source] string does not contain a valid [Interval], a @@ -321,7 +329,8 @@ final class Interval /// Interval.A4.respellBySize(Size.fifth) == Interval.d5 /// Interval.d3.respellBySize(Size.second) == Interval.M2 /// ``` - Interval respellBySize(Size size) => Interval.fromSemitones(size, semitones); + Interval respellBySize(Size size) => + Interval.fromSizeAndSemitones(size, semitones); /// The iteration distance of this [Interval] between [scalable1] and /// [scalable2], including all visited `notes`. @@ -372,10 +381,10 @@ final class Interval T scalable, { required int distance, }) sync* { - final distanceAbs = distance.abs(); + final absDistance = distance.abs(); yield scalable; var last = scalable; - for (var i = 0; i < distanceAbs; i++) { + for (var i = 0; i < absDistance; i++) { yield last = last.transposeBy(descending(isDescending: distance.isNegative)); } diff --git a/lib/src/interval/interval_class.dart b/lib/src/interval/interval_class.dart index 18420a3d..9154f871 100644 --- a/lib/src/interval/interval_class.dart +++ b/lib/src/interval/interval_class.dart @@ -81,11 +81,14 @@ final class IntervalClass implements Comparable { if (size != null) { return SplayTreeSet.of({ - Interval.fromSemitones(size, semitones), + Interval.fromSizeAndSemitones(size, semitones), for (var i = 1; i <= distance; i++) ...[ if (size.incrementBy(-i) != 0) - Interval.fromSemitones(Size(size.incrementBy(-i)), semitones), - Interval.fromSemitones(Size(size.incrementBy(i)), semitones), + Interval.fromSizeAndSemitones( + Size(size.incrementBy(-i)), + semitones, + ), + Interval.fromSizeAndSemitones(Size(size.incrementBy(i)), semitones), ], }); } @@ -94,11 +97,11 @@ final class IntervalClass implements Comparable { return SplayTreeSet.of({ for (var i = 1; i <= distanceClamp; i++) ...[ - Interval.fromSemitones( + Interval.fromSizeAndSemitones( Size.fromSemitones(semitones.incrementBy(-i))!, semitones, ), - Interval.fromSemitones( + Interval.fromSizeAndSemitones( Size.fromSemitones(semitones.incrementBy(i))!, semitones, ), diff --git a/lib/src/interval/size.dart b/lib/src/interval/size.dart index 567d0b3f..666c7f91 100644 --- a/lib/src/interval/size.dart +++ b/lib/src/interval/size.dart @@ -1,4 +1,4 @@ -import 'package:collection/collection.dart' show IterableExtension; +import 'package:collection/collection.dart' show IterableExtension, minBy; import 'package:meta/meta.dart' show redeclare; import 'package:music_notes/utils.dart'; @@ -63,6 +63,28 @@ extension type const Size._(int size) implements int { octave: 12, // P }; + /// Map a semitones value to a value between 0 and 12. + static int _normalizeSemitones(int semitones) { + final absSemitones = semitones.abs(); + + return absSemitones == chromaticDivisions + ? chromaticDivisions + : absSemitones % chromaticDivisions; + } + + /// Scale a given normalized Size (one of the entries in [_sizeToSemitones]) + /// to the given [semitones]. + factory Size._scaleToSemitones(Size normalizedSize, int semitones) { + final absSemitones = semitones.abs(); + if (absSemitones == chromaticDivisions) { + return Size(normalizedSize * semitones.sign); + } + + final absResult = normalizedSize + (absSemitones ~/ chromaticDivisions) * 7; + + return Size(absResult * semitones.nonZeroSign); + } + /// The [Size] that matches with [semitones] in [_sizeToSemitones]. /// Otherwise, returns `null`. /// @@ -74,23 +96,25 @@ extension type const Size._(int size) implements int { /// Size.fromSemitones(4) == null /// ``` static Size? fromSemitones(int semitones) { - final absoluteSemitones = semitones.abs(); - final matchingSize = _sizeToSemitones.keys.firstWhereOrNull( - (size) => - (absoluteSemitones == chromaticDivisions - ? chromaticDivisions - : absoluteSemitones % chromaticDivisions) == - _sizeToSemitones[size], - ); + final normalizedSemitones = _normalizeSemitones(semitones); + final matchingSize = _sizeToSemitones.entries + .firstWhereOrNull((entry) => entry.value == normalizedSemitones) + ?.key; if (matchingSize == null) return null; - if (absoluteSemitones == chromaticDivisions) { - return Size(matchingSize * semitones.sign); - } - final absResult = - matchingSize + (absoluteSemitones ~/ chromaticDivisions) * 7; + return Size._scaleToSemitones(matchingSize, semitones); + } - return Size(absResult * semitones.nonZeroSign); + /// The [Size] that is nearest, truncating towards zero, to the given + /// interval in [semitones]. + factory Size.nearestFromSemitones(int semitones) { + final normalizedSemitones = _normalizeSemitones(semitones); + final closest = minBy( + _sizeToSemitones.entries, + (entry) => (normalizedSemitones - entry.value).abs(), + )!; + + return Size._scaleToSemitones(closest.key, semitones); } /// The number of semitones of this [Size] as in [_sizeToSemitones]. @@ -105,20 +129,20 @@ extension type const Size._(int size) implements int { /// (-Size.ninth).semitones == -13 /// ``` int get semitones { - final simpleAbs = simple.abs(); + final absSimple = simple.abs(); final octaveShift = chromaticDivisions * (absShift ~/ octave); // We exclude perfect octaves (simplified as 8) from the lookup to consider // them 0 (as if they were modulo `Size.octave`). - final size = Size(simpleAbs == octave ? 1 : simpleAbs); + final size = Size(absSimple == octave ? 1 : absSimple); return (_sizeToSemitones[size]! + octaveShift) * sign; } /// The absolute [Size] value taking octave shift into account. int get absShift { - final sizeAbs = abs(); + final absSize = abs(); - return sizeAbs + sizeAbs ~/ octave; + return absSize + absSize ~/ octave; } /// The [PerfectQuality.diminished] or [ImperfectQuality.diminished] interval diff --git a/lib/src/note/note.dart b/lib/src/note/note.dart index a7f06a94..132ab580 100644 --- a/lib/src/note/note.dart +++ b/lib/src/note/note.dart @@ -377,7 +377,7 @@ final class Note extends Scalable implements Comparable { /// Note.d.interval(Note.a.flat) == Interval.d5 /// ``` @override - Interval interval(Note other) => Interval.fromSemitones( + Interval interval(Note other) => Interval.fromSizeAndSemitones( baseNote.intervalSize(other.baseNote), difference(other) % chromaticDivisions, ); diff --git a/lib/src/note/pitch.dart b/lib/src/note/pitch.dart index 712e17c4..0eeaf9a4 100644 --- a/lib/src/note/pitch.dart +++ b/lib/src/note/pitch.dart @@ -384,7 +384,7 @@ final class Pitch extends Scalable implements Comparable { final octaveShift = (7 + (intervalSize.isNegative ? 2 : 0)) * (other.octave - octave); - return Interval.fromSemitones( + return Interval.fromSizeAndSemitones( Size(intervalSize + octaveShift), difference(other), ); diff --git a/test/src/interval/interval_test.dart b/test/src/interval/interval_test.dart index c621eec3..261f0f59 100644 --- a/test/src/interval/interval_test.dart +++ b/test/src/interval/interval_test.dart @@ -18,63 +18,76 @@ void main() { }); }); + group('.fromSizeAndSemitones()', () { + test('creates a new Interval from size and semitones', () { + expect(Interval.fromSizeAndSemitones(Size.unison, -1), Interval.d1); + expect(Interval.fromSizeAndSemitones(-Size.unison, 1), -Interval.d1); + expect(Interval.fromSizeAndSemitones(Size.unison, 0), Interval.P1); + expect(Interval.fromSizeAndSemitones(-Size.unison, 0), -Interval.P1); + expect(Interval.fromSizeAndSemitones(Size.unison, 1), Interval.A1); + expect(Interval.fromSizeAndSemitones(-Size.unison, -1), -Interval.A1); + + expect(Interval.fromSizeAndSemitones(Size.second, 0), Interval.d2); + expect(Interval.fromSizeAndSemitones(-Size.second, 0), -Interval.d2); + expect(Interval.fromSizeAndSemitones(Size.second, 1), Interval.m2); + expect(Interval.fromSizeAndSemitones(-Size.second, -1), -Interval.m2); + expect(Interval.fromSizeAndSemitones(Size.second, 2), Interval.M2); + expect(Interval.fromSizeAndSemitones(-Size.second, -2), -Interval.M2); + expect(Interval.fromSizeAndSemitones(Size.second, 3), Interval.A2); + expect(Interval.fromSizeAndSemitones(-Size.second, -3), -Interval.A2); + + expect(Interval.fromSizeAndSemitones(Size.third, 2), Interval.d3); + expect(Interval.fromSizeAndSemitones(-Size.third, -2), -Interval.d3); + expect(Interval.fromSizeAndSemitones(Size.third, 3), Interval.m3); + expect(Interval.fromSizeAndSemitones(-Size.third, -3), -Interval.m3); + expect(Interval.fromSizeAndSemitones(Size.third, 4), Interval.M3); + expect(Interval.fromSizeAndSemitones(-Size.third, -4), -Interval.M3); + expect(Interval.fromSizeAndSemitones(Size.third, 5), Interval.A3); + expect(Interval.fromSizeAndSemitones(-Size.third, -5), -Interval.A3); + + expect(Interval.fromSizeAndSemitones(Size.fourth, 4), Interval.d4); + expect(Interval.fromSizeAndSemitones(-Size.fourth, -4), -Interval.d4); + expect(Interval.fromSizeAndSemitones(Size.fourth, 5), Interval.P4); + expect(Interval.fromSizeAndSemitones(-Size.fourth, -5), -Interval.P4); + expect(Interval.fromSizeAndSemitones(Size.fourth, 6), Interval.A4); + expect(Interval.fromSizeAndSemitones(-Size.fourth, -6), -Interval.A4); + + expect(Interval.fromSizeAndSemitones(Size.fifth, 6), Interval.d5); + expect(Interval.fromSizeAndSemitones(-Size.fifth, -6), -Interval.d5); + expect(Interval.fromSizeAndSemitones(Size.fifth, 7), Interval.P5); + expect(Interval.fromSizeAndSemitones(-Size.fifth, -7), -Interval.P5); + expect(Interval.fromSizeAndSemitones(Size.fifth, 8), Interval.A5); + expect(Interval.fromSizeAndSemitones(-Size.fifth, -8), -Interval.A5); + + expect(Interval.fromSizeAndSemitones(Size.sixth, 8), Interval.m6); + expect(Interval.fromSizeAndSemitones(-Size.sixth, -8), -Interval.m6); + expect(Interval.fromSizeAndSemitones(Size.sixth, 9), Interval.M6); + expect(Interval.fromSizeAndSemitones(-Size.sixth, -9), -Interval.M6); + + expect(Interval.fromSizeAndSemitones(Size.seventh, 10), Interval.m7); + expect(Interval.fromSizeAndSemitones(-Size.seventh, -10), -Interval.m7); + expect(Interval.fromSizeAndSemitones(Size.seventh, 11), Interval.M7); + expect(Interval.fromSizeAndSemitones(-Size.seventh, -11), -Interval.M7); + + expect(Interval.fromSizeAndSemitones(Size.octave, 11), Interval.d8); + expect(Interval.fromSizeAndSemitones(-Size.octave, -11), -Interval.d8); + expect(Interval.fromSizeAndSemitones(Size.octave, 12), Interval.P8); + expect(Interval.fromSizeAndSemitones(-Size.octave, -12), -Interval.P8); + expect(Interval.fromSizeAndSemitones(Size.octave, 13), Interval.A8); + expect(Interval.fromSizeAndSemitones(-Size.octave, -13), -Interval.A8); + }); + }); + group('.fromSemitones()', () { test('creates a new Interval from semitones', () { - expect(Interval.fromSemitones(Size.unison, -1), Interval.d1); - expect(Interval.fromSemitones(-Size.unison, 1), -Interval.d1); - expect(Interval.fromSemitones(Size.unison, 0), Interval.P1); - expect(Interval.fromSemitones(-Size.unison, 0), -Interval.P1); - expect(Interval.fromSemitones(Size.unison, 1), Interval.A1); - expect(Interval.fromSemitones(-Size.unison, -1), -Interval.A1); - - expect(Interval.fromSemitones(Size.second, 0), Interval.d2); - expect(Interval.fromSemitones(-Size.second, 0), -Interval.d2); - expect(Interval.fromSemitones(Size.second, 1), Interval.m2); - expect(Interval.fromSemitones(-Size.second, -1), -Interval.m2); - expect(Interval.fromSemitones(Size.second, 2), Interval.M2); - expect(Interval.fromSemitones(-Size.second, -2), -Interval.M2); - expect(Interval.fromSemitones(Size.second, 3), Interval.A2); - expect(Interval.fromSemitones(-Size.second, -3), -Interval.A2); - - expect(Interval.fromSemitones(Size.third, 2), Interval.d3); - expect(Interval.fromSemitones(-Size.third, -2), -Interval.d3); - expect(Interval.fromSemitones(Size.third, 3), Interval.m3); - expect(Interval.fromSemitones(-Size.third, -3), -Interval.m3); - expect(Interval.fromSemitones(Size.third, 4), Interval.M3); - expect(Interval.fromSemitones(-Size.third, -4), -Interval.M3); - expect(Interval.fromSemitones(Size.third, 5), Interval.A3); - expect(Interval.fromSemitones(-Size.third, -5), -Interval.A3); - - expect(Interval.fromSemitones(Size.fourth, 4), Interval.d4); - expect(Interval.fromSemitones(-Size.fourth, -4), -Interval.d4); - expect(Interval.fromSemitones(Size.fourth, 5), Interval.P4); - expect(Interval.fromSemitones(-Size.fourth, -5), -Interval.P4); - expect(Interval.fromSemitones(Size.fourth, 6), Interval.A4); - expect(Interval.fromSemitones(-Size.fourth, -6), -Interval.A4); - - expect(Interval.fromSemitones(Size.fifth, 6), Interval.d5); - expect(Interval.fromSemitones(-Size.fifth, -6), -Interval.d5); - expect(Interval.fromSemitones(Size.fifth, 7), Interval.P5); - expect(Interval.fromSemitones(-Size.fifth, -7), -Interval.P5); - expect(Interval.fromSemitones(Size.fifth, 8), Interval.A5); - expect(Interval.fromSemitones(-Size.fifth, -8), -Interval.A5); - - expect(Interval.fromSemitones(Size.sixth, 8), Interval.m6); - expect(Interval.fromSemitones(-Size.sixth, -8), -Interval.m6); - expect(Interval.fromSemitones(Size.sixth, 9), Interval.M6); - expect(Interval.fromSemitones(-Size.sixth, -9), -Interval.M6); - - expect(Interval.fromSemitones(Size.seventh, 10), Interval.m7); - expect(Interval.fromSemitones(-Size.seventh, -10), -Interval.m7); - expect(Interval.fromSemitones(Size.seventh, 11), Interval.M7); - expect(Interval.fromSemitones(-Size.seventh, -11), -Interval.M7); - - expect(Interval.fromSemitones(Size.octave, 11), Interval.d8); - expect(Interval.fromSemitones(-Size.octave, -11), -Interval.d8); - expect(Interval.fromSemitones(Size.octave, 12), Interval.P8); - expect(Interval.fromSemitones(-Size.octave, -12), -Interval.P8); - expect(Interval.fromSemitones(Size.octave, 13), Interval.A8); - expect(Interval.fromSemitones(-Size.octave, -13), -Interval.A8); + expect(Interval.fromSemitones(0), Interval.P1); + expect(Interval.fromSemitones(2), Interval.M2); + expect(Interval.fromSemitones(-2), -Interval.M2); + expect(Interval.fromSemitones(3), Interval.m3); + expect(Interval.fromSemitones(-3), -Interval.m3); + expect(Interval.fromSemitones(5), Interval.P4); + expect(Interval.fromSemitones(20), Interval.m13); + expect(Interval.fromSemitones(-20), -Interval.m13); }); }); diff --git a/test/src/interval/size_test.dart b/test/src/interval/size_test.dart index caaa3085..89e7cc44 100644 --- a/test/src/interval/size_test.dart +++ b/test/src/interval/size_test.dart @@ -45,6 +45,48 @@ void main() { }); }); + group('.nearestFromSemitones()', () { + test('returns the Size corresponding exactly to the given semitones', () { + expect(Size.nearestFromSemitones(-12), -Size.octave); + expect(Size.nearestFromSemitones(-5), -Size.fourth); + expect(Size.nearestFromSemitones(-3), -Size.third); + expect(Size.nearestFromSemitones(-1), -Size.second); + expect(Size.nearestFromSemitones(0), Size.unison); + expect(Size.nearestFromSemitones(1), Size.second); + expect(Size.nearestFromSemitones(3), Size.third); + expect(Size.nearestFromSemitones(5), Size.fourth); + expect(Size.nearestFromSemitones(7), Size.fifth); + expect(Size.nearestFromSemitones(8), Size.sixth); + expect(Size.nearestFromSemitones(10), Size.seventh); + expect(Size.nearestFromSemitones(12), Size.octave); + expect(Size.nearestFromSemitones(13), Size.ninth); + expect(Size.nearestFromSemitones(15), Size.tenth); + expect(Size.nearestFromSemitones(17), Size.eleventh); + expect(Size.nearestFromSemitones(19), Size.twelfth); + expect(Size.nearestFromSemitones(20), Size.thirteenth); + expect(Size.nearestFromSemitones(22), const Size(14)); + expect(Size.nearestFromSemitones(24), const Size(15)); + expect(Size.nearestFromSemitones(36), const Size(22)); + expect(Size.nearestFromSemitones(48), const Size(29)); + }); + + test( + 'returns the nearest Size when no Size' + ' corresponds exactly to the given semitones', + () { + expect(Size.nearestFromSemitones(-4), -Size.third); + expect(Size.nearestFromSemitones(-2), -Size.second); + expect(Size.nearestFromSemitones(2), Size.second); + expect(Size.nearestFromSemitones(4), Size.third); + expect(Size.nearestFromSemitones(6), Size.fourth); + expect(Size.nearestFromSemitones(9), Size.sixth); + expect(Size.nearestFromSemitones(11), Size.seventh); + expect(Size.nearestFromSemitones(14), Size.ninth); + expect(Size.nearestFromSemitones(-20), -Size.thirteenth); + }, + ); + }); + group('.perfect', () { test('returns the perfect Interval from this Size', () { expect(Size.unison.perfect, Interval.P1);