Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!(interval): ✨ add fromSemitones(int) and rename fromSizeAndSemitones factory constructors #476

Merged
merged 5 commits into from
May 13, 2024
Merged
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
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