Skip to content

Commit

Permalink
Merge pull request #147 from tandav/dev
Browse files Browse the repository at this point in the history
v2.1
  • Loading branch information
tandav authored Sep 30, 2023
2 parents d38d6f7 + ee69588 commit 44ad517
Show file tree
Hide file tree
Showing 11 changed files with 77 additions and 100 deletions.
4 changes: 2 additions & 2 deletions src/musiclib/midi/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ class MidiPitch:

@dataclasses.dataclass
class Midi:
notes: list[MidiNote]
pitchbend: list[MidiPitch]
notes: list[MidiNote] = dataclasses.field(default_factory=list)
pitchbend: list[MidiPitch] = dataclasses.field(default_factory=list)
ticks_per_beat: int = 96


Expand Down
14 changes: 8 additions & 6 deletions src/musiclib/noterange.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@
class NoteRange(Sequence[SpecificNote]):
def __init__(
self,
start: SpecificNote | str,
stop: SpecificNote | str,
start: SpecificNote,
stop: SpecificNote,
noteset: NoteSet = CHROMATIC_NOTESET,
) -> None:
if isinstance(start, str):
start = SpecificNote.from_str(start)
if isinstance(stop, str):
stop = SpecificNote.from_str(stop)
if not (isinstance(start, SpecificNote) and isinstance(stop, SpecificNote)):
raise TypeError('start and stop should be SpecificNote instances')

if start > stop: # both ends included
raise ValueError('start should be <= stop')
Expand All @@ -35,6 +33,10 @@ def __init__(
self.noteset = noteset
self._key = self.start, self.stop, self.noteset

@classmethod
def from_str(cls, start: str, stop: str, noteset: NoteSet = CHROMATIC_NOTESET) -> NoteRange:
return cls(SpecificNote.from_str(start), SpecificNote.from_str(stop), noteset)

def _getitem_int(self, item: int) -> SpecificNote:
if 0 <= item < len(self):
return self.noteset.add_note(self.start, item)
Expand Down
41 changes: 41 additions & 0 deletions src/musiclib/noteset.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,44 @@ def subsets(noteset: NoteSet, min_notes: int = 1) -> frozenset[NoteSet]:
for notes in itertools.combinations(noteset, n_subset):
out.add(NoteSet(frozenset(notes)))
return frozenset(out)


class ComparedNoteSets(Cached):
"""
this is compared scale
local terminology: left scale is compared to right
left is kinda parent, right is kinda child
"""

def __init__(self, left: NoteSet, right: NoteSet) -> None:
if not (isinstance(left, NoteSet) and isinstance(right, NoteSet)):
raise TypeError(f'expected NoteSet, got {type(left)} and {type(right)}')
self.left = left
self.right = right
self.key = left, right
self.shared_notes = frozenset(left.notes) & frozenset(right.notes)
self.new_notes = frozenset(right.notes) - frozenset(left.notes)
self.del_notes = frozenset(left.notes) - frozenset(right.notes)

def _repr_svg_(self, **kwargs: Any) -> str:
from musiclib.svg.piano import Piano
kwargs.setdefault(
'note_colors',
dict.fromkeys(self.del_notes, config.RED) |
dict.fromkeys(self.new_notes, config.GREEN) |
dict.fromkeys(self.shared_notes, config.BLUE),
)
kwargs.setdefault('classes', ('card',))
kwargs.setdefault('title', f'{self.left} | {self.right}')
return Piano(**kwargs)._repr_svg_()

def __eq__(self, other: object) -> bool:
if not isinstance(other, ComparedNoteSets):
return NotImplemented
return self.key == other.key

def __hash__(self) -> int:
return hash(self.key)

def __repr__(self) -> str:
return f'ComparedNoteSets({self.left} | {self.right})'
2 changes: 1 addition & 1 deletion src/musiclib/pitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def __init__(
transpose: float = 0,
) -> None:
"""
origin_note: in midi format, A4 midi ~ A4 ableton ~ 440Hz
origin_note: in midi format, A4 midi (A3 in ableton) ~ 440Hz
"""
self.hz_tuning = hz_tuning
self.origin_note = origin_note
Expand Down
62 changes: 0 additions & 62 deletions src/musiclib/scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@
if TYPE_CHECKING:
from collections.abc import Iterator
from musiclib import config
from musiclib.config import BLACK_BRIGHT
from musiclib.config import BLUE
from musiclib.config import GREEN
from musiclib.config import RED
from musiclib.note import Note
from musiclib.noteset import NoteSet
from musiclib.svg.piano import Piano
Expand Down Expand Up @@ -130,61 +126,3 @@ def _repr_svg_(self, **kwargs: Any) -> str:
kwargs.setdefault('title', f'{self.str_names}')
kwargs.setdefault('classes', ('card', *self.names))
return Piano(**kwargs)._repr_svg_()


class ComparedScales:
"""
this is compared scale
local terminology: left scale is compared to right
left is kinda parent, right is kinda child
"""

def __init__(self, left: Scale, right: Scale) -> None:
self.left = left
self.right = right
self.key = left, right
self.shared_notes = frozenset(left.notes) & frozenset(right.notes)
self.new_notes = frozenset(right.notes) - frozenset(left.notes)
self.del_notes = frozenset(left.notes) - frozenset(right.notes)
self.left_triads = frozenset(left.nths(config.nths['triads']))
self.right_triads = frozenset(right.nths(config.nths['triads']))
self.shared_triads = self.left_triads & self.right_triads

def _repr_svg_(self, **kwargs: Any) -> str:
if self.right.note_scales is not None and self.left.root in self.right.note_scales:
kwargs.setdefault('background_color', config.interval_colors[self.right.note_to_interval[self.left.root]])

chord_colors = {
frozenset({'major_0'}): config.interval_colors[0],
frozenset({'minor_0'}): config.interval_colors[8],
frozenset({'dim_0'}): config.interval_colors[11],
}

kwargs.setdefault('note_colors', {note: config.interval_colors[interval] for note, interval in self.left.note_to_interval.items()})
kwargs.setdefault('top_rect_colors', dict.fromkeys(self.del_notes, RED) | dict.fromkeys(self.new_notes, GREEN) | dict.fromkeys(self.shared_notes, BLUE))
kwargs.setdefault(
'squares', {
chord.root: {
'fill_color': chord_colors[chord.names],
'border_color': BLUE if chord in self.shared_triads else BLACK_BRIGHT,
'text_color': BLUE if chord in self.shared_triads else BLACK_BRIGHT,
'text': chord.root.name,
'onclick': f'play_chord("{chord}")',
}
for chord in self.right_triads
} if set(self.right.name_kinds.values()) == {'natural'} else {},
)
kwargs.setdefault('classes', ('card',))
kwargs.setdefault('title', f'{self.left.str_names} | {self.right.str_names}')
return Piano(**kwargs)._repr_svg_()

def __eq__(self, other: object) -> bool:
if not isinstance(other, ComparedScales):
return NotImplemented
return self.key == other.key

def __hash__(self) -> int:
return hash(self.key)

def __repr__(self) -> str:
return f'ComparedScale({self.left.str_names} | {self.right.str_names})'
14 changes: 5 additions & 9 deletions tests/noterange_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_note_range(start, stop, noteset, expected):

@pytest.mark.parametrize(
('x', 's', 'r'), [
(NoteRange('C1', 'C2'), "NoteRange('C1', 'C2', noteset='CdDeEFfGaAbB')", "NoteRange('C1', 'C2', noteset='CdDeEFfGaAbB')"),
(NoteRange.from_str('C1', 'C2'), "NoteRange('C1', 'C2', noteset='CdDeEFfGaAbB')", "NoteRange('C1', 'C2', noteset='CdDeEFfGaAbB')"),
],
)
def test_str_repr(x, s, r):
Expand All @@ -34,14 +34,10 @@ def test_str_repr(x, s, r):
@pytest.mark.parametrize(
('start', 'stop', 'noterange'), [
('C0', 'C1', NoteRange(SpecificNote('C', 0), SpecificNote('C', 1))),
('E1', 'f3', NoteRange(SpecificNote('E', 1), SpecificNote('f', 3))),
(SpecificNote('E', 1), 'f3', NoteRange(SpecificNote('E', 1), SpecificNote('f', 3))),
('E1', SpecificNote('f', 3), NoteRange(SpecificNote('E', 1), SpecificNote('f', 3))),
(SpecificNote('E', 1), SpecificNote('f', 3), NoteRange(SpecificNote('E', 1), SpecificNote('f', 3))),
],
)
def test_note_range_from_str(start, stop, noterange):
assert NoteRange(start, stop) == noterange
assert NoteRange.from_str(start, stop) == noterange


def test_noterange_bounds():
Expand Down Expand Up @@ -93,9 +89,9 @@ def test_noterange_getitem():
assert nr[0:1] == NoteRange(SpecificNote('C', 1), SpecificNote('d', 1))
assert nr[0:2] == NoteRange(SpecificNote('C', 1), SpecificNote('D', 1))
assert nr[0:12] == NoteRange(SpecificNote('C', 1), SpecificNote('C', 2))
assert nr[1:] == NoteRange('d1', 'C2')
assert nr[:4] == NoteRange('C1', 'E1')
assert nr[:] == NoteRange('C1', 'C2')
assert nr[1:] == NoteRange.from_str('d1', 'C2')
assert nr[:4] == NoteRange.from_str('C1', 'E1')
assert nr[:] == NoteRange.from_str('C1', 'C2')

with pytest.raises(IndexError):
nr[13]
Expand Down
12 changes: 12 additions & 0 deletions tests/noteset_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from musiclib import config
from musiclib.note import Note
from musiclib.note import SpecificNote
from musiclib.noteset import ComparedNoteSets
from musiclib.noteset import NoteSet
from musiclib.scale import Scale

Expand Down Expand Up @@ -233,3 +234,14 @@ def test_subtract_types():
noteset.subtract('C', SpecificNote('D', 1)) # type: ignore[arg-type]
with pytest.raises(TypeError):
noteset.subtract('D1', Note('C')) # type: ignore[arg-type]


def test_compared():
left = Scale.from_name('C', 'major').noteset
right = Scale.from_name('A', 'minor').noteset
assert ComparedNoteSets(left, right).shared_notes == frozenset(left.notes)

right = Scale.from_name('C', 'mixolydian').noteset
c = ComparedNoteSets(left, right)
assert c.new_notes == frozenset({Note('b')})
assert c.del_notes == frozenset({Note('B')})
12 changes: 0 additions & 12 deletions tests/scale_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import pytest
from musiclib import config
from musiclib.note import Note
from musiclib.scale import ComparedScales
from musiclib.scale import Scale


Expand Down Expand Up @@ -234,14 +233,3 @@ def test_nths(notes, name, nths):
@pytest.mark.parametrize('notes', ['CDEFGAB', 'BdeEfab', 'deFfabC'])
def test_note_scales(notes):
assert Scale.from_str(f'{notes}/{notes[0]}').note_scales == {'natural': dict(zip(map(Note, notes), config.scale_order['natural'], strict=True))}


def test_compared():
left = Scale.from_name('C', 'major')
right = Scale.from_name('A', 'minor')
assert ComparedScales(left, right).shared_notes == frozenset(left.notes)

right = Scale.from_name('C', 'mixolydian')
c = ComparedScales(left, right)
assert c.new_notes == frozenset({Note('b')})
assert c.del_notes == frozenset({Note('B')})
4 changes: 2 additions & 2 deletions tests/svg/piano_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ def test_specific_overrides_abstract(element, class_, info_part, keyarg, payload

@pytest.mark.parametrize(
('noterange', 'black_small', 'start', 'stop'), [
(NoteRange('d2', 'b2'), True, SpecificNote('C', 2), SpecificNote('B', 2)),
(NoteRange('d2', 'b2'), False, SpecificNote('d', 2), SpecificNote('b', 2)),
(NoteRange.from_str('d2', 'b2'), True, SpecificNote('C', 2), SpecificNote('B', 2)),
(NoteRange.from_str('d2', 'b2'), False, SpecificNote('d', 2), SpecificNote('b', 2)),
],
)
def test_startswith_endswith_white_key(noterange, black_small, start, stop):
Expand Down
10 changes: 5 additions & 5 deletions tests/svg/repr_svg_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from colortool import Color
from musiclib import config
from musiclib.noterange import NoteRange
from musiclib.noteset import ComparedNoteSets
from musiclib.noteset import NoteSet
from musiclib.noteset import SpecificNoteSet
from musiclib.scale import ComparedScales
from musiclib.scale import Scale

TITLE = 'title_fUYsZHfC'
Expand Down Expand Up @@ -97,11 +97,11 @@ def test_svg_scale(kind, title, subtitle, title_href, background_color, all_scal
@pytest.mark.parametrize('subtitle', [None, SUBTITLE])
@pytest.mark.parametrize('title_href', [None, TITLE_HREF])
@pytest.mark.parametrize('background_color', [BACKGROUND_COLOR])
def test_svg_compared_scale(scale0, scale1, title, subtitle, title_href, background_color):
def test_svg_compared_notesets(scale0, scale1, title, subtitle, title_href, background_color):
if title is None and title_href is not None:
pytest.skip('title_href requires title')
classes = ('cls1', 'cls2')
svg = ComparedScales(scale0, scale1)._repr_svg_(
svg = ComparedNoteSets(scale0.noteset, scale1.noteset)._repr_svg_(
classes=classes,
title=title,
subtitle=subtitle,
Expand Down Expand Up @@ -138,8 +138,8 @@ def test_svg_specific_noteset(sns, title, subtitle, title_href, background_color

@pytest.mark.parametrize(
'noterange', [
NoteRange('C2', 'C5'),
NoteRange('D2', 'G2', noteset=NoteSet.from_str('CDG')),
NoteRange.from_str('C2', 'C5'),
NoteRange.from_str('D2', 'G2', noteset=NoteSet.from_str('CDG')),
],
)
@pytest.mark.parametrize('title', [None, TITLE])
Expand Down
2 changes: 1 addition & 1 deletion tests/voice_leading/transition_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_transition(a, b, expected):
)
def test_chord_transitions(start, stop, noteset, chord_str, transitions, unique_abstract, same_length):
chord = SpecificNoteSet.from_str(chord_str)
noterange = NoteRange(start, stop, noteset)
noterange = NoteRange.from_str(start, stop, noteset)
assert set(map(str, transition.chord_transitions(chord, noterange, unique_abstract=unique_abstract, same_length=same_length))) == transitions


Expand Down

0 comments on commit 44ad517

Please sign in to comment.