Skip to content

Commit

Permalink
feat!(interval): ✨ add fromSemitones(int) and rename `fromSizeAndSe…
Browse files Browse the repository at this point in the history
…mitones` factory constructors (#476)

* feat: Interval.fromSemitones(int)

* test: Size.nearestFromSemitones()

* test: Interval.fromSemitones()

* refactor(interval): ♻️ consistently rename `abs` related variables

Signed-off-by: Albert Mañosa <26429103+albertms10@users.noreply.github.com>

---------

Signed-off-by: Albert Mañosa <26429103+albertms10@users.noreply.github.com>
Co-authored-by: Albert Mañosa <26429103+albertms10@users.noreply.github.com>
  • Loading branch information
plammens and albertms10 committed May 13, 2024
1 parent dce0c25 commit 451203c
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 87 deletions.
21 changes: 15 additions & 6 deletions lib/src/interval/interval.dart
Original file line number Diff line number Diff line change
Expand Up @@ -182,20 +182,28 @@ 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;

return Interval._(size, qualityConstructor(semitones));
}

/// 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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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));
}
Expand Down
13 changes: 8 additions & 5 deletions lib/src/interval/interval_class.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,14 @@ final class IntervalClass implements Comparable<IntervalClass> {

if (size != null) {
return SplayTreeSet<Interval>.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),
],
});
}
Expand All @@ -94,11 +97,11 @@ final class IntervalClass implements Comparable<IntervalClass> {

return SplayTreeSet<Interval>.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,
),
Expand Down
62 changes: 43 additions & 19 deletions lib/src/interval/size.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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`.
///
Expand All @@ -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].
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/src/note/note.dart
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ final class Note extends Scalable<Note> implements Comparable<Note> {
/// 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,
);
Expand Down
2 changes: 1 addition & 1 deletion lib/src/note/pitch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ final class Pitch extends Scalable<Pitch> implements Comparable<Pitch> {
final octaveShift =
(7 + (intervalSize.isNegative ? 2 : 0)) * (other.octave - octave);

return Interval.fromSemitones(
return Interval.fromSizeAndSemitones(
Size(intervalSize + octaveShift),
difference(other),
);
Expand Down
123 changes: 68 additions & 55 deletions test/src/interval/interval_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
42 changes: 42 additions & 0 deletions test/src/interval/size_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 451203c

Please sign in to comment.