diff --git a/henon2midi/base.py b/henon2midi/base.py index ac5fcf7..cc8d1a5 100644 --- a/henon2midi/base.py +++ b/henon2midi/base.py @@ -1,17 +1,34 @@ from mido import MetaMessage, MidiFile, MidiTrack, bpm2tempo -from henon2midi.henon_midi_generator import HenonMidiGenerator +from henon2midi.data_point_to_midi_conversion import ( + create_midi_messages_from_data_point, +) +from henon2midi.henon_equations import RadiallyExpandingHenonMappingsGenerator -def create_midi_file_from_midi_generator( - henon_midi_generator: HenonMidiGenerator, +def create_midi_file_from_data_generator( + henon_midi_generator: RadiallyExpandingHenonMappingsGenerator, ticks_per_beat: int = 960, bpm: int = 120, + notes_per_beat: int = 4, + sustain: bool = False, + clip: bool = False, + x_midi_parameter_mappings_set: set[str] = {"note"}, + y_midi_parameter_mappings_set: set[str] = {"velocity", "pan"}, ) -> MidiFile: mid = MidiFile(ticks_per_beat=ticks_per_beat) track = MidiTrack() mid.tracks.append(track) tempo = bpm2tempo(bpm) track.append(MetaMessage("set_tempo", tempo=tempo)) - track.extend(henon_midi_generator.generate_all_midi_messages()) + for datapoint in henon_midi_generator.radially_expending_henon_mappings_generator(): + midi_messages = create_midi_messages_from_data_point( + datapoint, + duration_ticks=int(ticks_per_beat / notes_per_beat), + sustain=sustain, + clip=clip, + x_midi_parameter_mappings=x_midi_parameter_mappings_set, + y_midi_parameter_mappings=y_midi_parameter_mappings_set, + ) + track.extend(midi_messages) return mid diff --git a/henon2midi/cli.py b/henon2midi/cli.py index 75c112f..775ab20 100644 --- a/henon2midi/cli.py +++ b/henon2midi/cli.py @@ -3,8 +3,11 @@ from mido import Message from henon2midi.ascii_art import AsciiArtCanvas -from henon2midi.base import create_midi_file_from_midi_generator -from henon2midi.henon_midi_generator import HenonMidiGenerator +from henon2midi.base import create_midi_file_from_data_generator +from henon2midi.data_point_to_midi_conversion import ( + create_midi_messages_from_data_point, +) +from henon2midi.henon_equations import RadiallyExpandingHenonMappingsGenerator from henon2midi.math import rescale_number_to_range from henon2midi.midi import ( MidiMessagePlayer, @@ -173,21 +176,21 @@ def cli( ) click.echo(options_string) - henon_midi_generator = HenonMidiGenerator( - a_parameter=a_parameter, - iterations_per_orbit=iterations_per_orbit, - starting_radius=starting_radius, - radial_step=radial_step, - note_length_ticks=ticks_per_beat // notes_per_beat, - sustain=sustain, - clip=clip, - x_midi_parameter_mappings=x_midi_parameter_mappings_set, - y_midi_parameter_mappings=y_midi_parameter_mappings_set, - ) - if midi_output_file_name: - mid = create_midi_file_from_midi_generator( - henon_midi_generator, ticks_per_beat=ticks_per_beat, bpm=bpm + mid = create_midi_file_from_data_generator( + RadiallyExpandingHenonMappingsGenerator( + a_parameter=a_parameter, + iterations_per_orbit=iterations_per_orbit, + starting_radius=starting_radius, + radial_step=radial_step, + ), + ticks_per_beat=ticks_per_beat, + bpm=bpm, + notes_per_beat=notes_per_beat, + sustain=sustain, + clip=clip, + x_midi_parameter_mappings_set=x_midi_parameter_mappings_set, + y_midi_parameter_mappings_set=y_midi_parameter_mappings_set, ) mid.save(midi_output_file_name) @@ -200,15 +203,31 @@ def cli( art_string = "" if midi_output_name: + hennon_mappings_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=a_parameter, + iterations_per_orbit=iterations_per_orbit, + starting_radius=starting_radius, + radial_step=radial_step, + ) midi_message_player = MidiMessagePlayer( midi_output_name=midi_output_name, ticks_per_beat=ticks_per_beat, bpm=bpm ) + while True: - messages = henon_midi_generator.next_midi_messages() - current_iteration = henon_midi_generator.current_iteration - current_orbit = ( - current_iteration // henon_midi_generator.iterations_per_orbit + messages = create_midi_messages_from_data_point( + hennon_mappings_generator.generate_next_data_point(), + duration_ticks=int(ticks_per_beat / notes_per_beat), + sustain=sustain, + clip=clip, + x_midi_parameter_mappings=x_midi_parameter_mappings_set, + y_midi_parameter_mappings=y_midi_parameter_mappings_set, ) + + current_iteration = hennon_mappings_generator.current_iteration + current_orbit = hennon_mappings_generator.current_orbital_iteration + current_data_point = hennon_mappings_generator.current_data_point + is_new_orbit = hennon_mappings_generator.is_new_orbit() + try: midi_message_player.send(messages) except KeyboardInterrupt: @@ -223,15 +242,15 @@ def cli( exit() current_state_string = ( f"Current iteration: {current_iteration}\n" - f"Current orbit: {current_orbit + 1}\n" + f"Current orbit: {current_orbit}\n" "\n" ) if draw_ascii_art: art_string = get_ascii_art( - henon_midi_generator.current_data_point, + current_data_point, current_iteration, - henon_midi_generator.iterations_per_orbit, + is_new_orbit, ascii_art_canvas, ) @@ -244,8 +263,8 @@ def cli( def get_ascii_art( data_point: tuple[float, float], - current_iteration, - iterations_per_orbit, + current_iteration: int, + is_new_orbit: bool, ascii_art_canvas: AsciiArtCanvas, ) -> str: if current_iteration == 0: @@ -260,6 +279,6 @@ def get_ascii_art( ) ascii_art_canvas.draw_point(draw_point_coord[0], draw_point_coord[1], ".") - if current_iteration % iterations_per_orbit == 0: + if is_new_orbit: ascii_art_canvas.set_color("random") return ascii_art_canvas.generate_string() diff --git a/henon2midi/data_point_to_midi_conversion.py b/henon2midi/data_point_to_midi_conversion.py new file mode 100644 index 0000000..4924a42 --- /dev/null +++ b/henon2midi/data_point_to_midi_conversion.py @@ -0,0 +1,104 @@ +from mido import Message + +from henon2midi.math import rescale_number_to_range + + +def create_midi_messages_from_data_point( + datapoint: tuple[float, float], + duration_ticks: float = 960, + sustain: bool = False, + clip: bool = False, + x_midi_parameter_mappings: set[str] = {"note"}, + y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, +) -> list[Message]: + x = datapoint[0] + y = datapoint[1] + + midi_values = { + "note": 64, + "velocity": 64, + "pan": 64, + } + + for x_midi_parameter_mapping in x_midi_parameter_mappings: + if (x > 1.0 or x < -1.0) and not clip: + midi_values[x_midi_parameter_mapping] = 0 + else: + midi_values[x_midi_parameter_mapping] = midi_value_from_data_point_value(x) + + for y_midi_parameter_mapping in y_midi_parameter_mappings: + if (y > 1.0 or y < -1.0) and not clip: + midi_values[y_midi_parameter_mapping] = 0 + else: + midi_values[y_midi_parameter_mapping] = midi_value_from_data_point_value(y) + + note_on = Message( + "note_on", + note=midi_values["note"], + velocity=midi_values["velocity"], + ) + note_off = Message( + "note_off", + note=midi_values["note"], + velocity=midi_values["velocity"], + time=duration_ticks, + ) + + note_messages = [note_on, note_off] + pre_note_messages = [] + post_note_messages = [] + + if "pan" in x_midi_parameter_mappings or "pan" in y_midi_parameter_mappings: + pan = Message( + "control_change", + control=10, + value=midi_values["pan"], + ) + pre_note_messages.append(pan) + + reset_pan = Message( + "control_change", + control=10, + value=64, + ) + post_note_messages.append(reset_pan) + + if sustain: + sustain_on_msg = Message( + "control_change", + control=64, + value=127, + ) + pre_note_messages.append(sustain_on_msg) + else: + sustain_off_msg = Message( + "control_change", + control=64, + value=0, + ) + post_note_messages.append(sustain_off_msg) + + messages = pre_note_messages + note_messages + post_note_messages + + return messages + + +def midi_value_from_data_point_value( + x: float, + data_point_range: tuple[float, float] = (-1.0, 1.0), + midi_range: tuple[int, int] = (0, 127), +) -> int: + min_midi_value = midi_range[0] + max_midi_value = midi_range[1] + + min_data_point_value = data_point_range[0] + max_data_point_value = data_point_range[1] + + return round( + rescale_number_to_range( + x, + (min_data_point_value, max_data_point_value), + (min_midi_value, max_midi_value), + clip_value=True, + ) + ) diff --git a/henon2midi/henon_equations.py b/henon2midi/henon_equations.py index 51741c8..67fdc18 100644 --- a/henon2midi/henon_equations.py +++ b/henon2midi/henon_equations.py @@ -39,14 +39,81 @@ def henon_mapping_generator( yield x, y -def radially_expanding_henon_mappings_generator( - a_parameter: float, - iterations_per_orbit: int = 32, - starting_radius: float = 0.0, - radial_step: float = 0.1, -) -> Generator[tuple[float, float], None, None]: - radius = starting_radius - while radius <= 1: - radius += radial_step - for _ in range(iterations_per_orbit): - yield from henon_mapping_generator(a_parameter, radius, radius) +class RadiallyExpandingHenonMappingsGenerator: + def __init__( + self, + a_parameter: float, + iterations_per_orbit: int = 100, + starting_radius: float = 0.1, + radial_step: float = 0.05, + ): + self.a_parameter = a_parameter + self.iterations_per_orbit = iterations_per_orbit + self.starting_radius = starting_radius + self.radial_step = radial_step + self.henon_mapping_generator = henon_mapping_generator( + a_parameter, starting_radius, starting_radius + ) + self.data_point_generator = self.radially_expending_henon_mappings_generator() + self.current_orbital_iteration = 0 + self.current_iteration = 0 + self.iteration_of_current_orbit = 0 + self.current_radius = starting_radius + self.current_data_point = (starting_radius, starting_radius) + + def generate_next_data_point(self) -> tuple[float, float]: + try: + data_point = next(self.data_point_generator) + except StopIteration: + self.restart_data_point_generator() + data_point = next(self.data_point_generator) + return data_point + + def restart_data_point_generator(self): + self.data_point_generator = self.radially_expending_henon_mappings_generator() + + def radially_expending_henon_mappings_generator( + self, + ) -> Generator[tuple[float, float], None, None]: + self._reset_to_starting_radius() + while self.current_radius <= 1: + self.iteration_of_current_orbit = 0 + self.current_orbital_iteration += 1 + self.henon_mapping_generator = henon_mapping_generator( + self.a_parameter, self.current_radius, self.current_radius + ) + + while self.iteration_of_current_orbit < self.iterations_per_orbit: + try: + data_point = next(self.henon_mapping_generator) + except StopIteration: + break + self.current_data_point = data_point + self.current_iteration += 1 + self.current_data_point = data_point + self.iteration_of_current_orbit += 1 + yield data_point + + self.current_radius += self.radial_step + + def _reset_to_starting_radius(self): + self.current_radius = self.starting_radius + self.current_data_point = (self.starting_radius, self.starting_radius) + + def get_current_iteration(self) -> int: + return self.current_iteration + + def get_current_data_point(self) -> tuple[float, float]: + return self.current_data_point + + def get_current_radius(self) -> float: + return self.current_radius + + def get_current_orbital_iteration(self) -> int: + return self.current_orbital_iteration + + def get_iteration_of_current_orbit(self) -> int: + return self.iteration_of_current_orbit + + def is_new_orbit(self) -> bool: + return self.iteration_of_current_orbit == 1 diff --git a/henon2midi/henon_midi_generator.py b/henon2midi/henon_midi_generator.py deleted file mode 100644 index 7aa729c..0000000 --- a/henon2midi/henon_midi_generator.py +++ /dev/null @@ -1,198 +0,0 @@ -from mido import Message - -from henon2midi.henon_equations import radially_expanding_henon_mappings_generator -from henon2midi.math import rescale_number_to_range - - -class HenonMidiGenerator: - def __init__( - self, - a_parameter: float, - iterations_per_orbit: int = 50, - starting_radius: float = 0.0, - radial_step: float = 0.05, - note_length_ticks: int = 960, - sustain: bool = False, - clip: bool = False, - x_midi_parameter_mappings: set[str] = {"note"}, - y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, - ): - self.a_parameter = a_parameter - self.iterations_per_orbit = iterations_per_orbit - self.starting_radius = starting_radius - self.radial_step = radial_step - self.note_length_ticks = note_length_ticks - self.sustain = sustain - self.clip = clip - self.x_midi_parameter_mappings = x_midi_parameter_mappings - self.y_midi_parameter_mappings = y_midi_parameter_mappings - self.current_iteration = 0 - self.current_radius = self.starting_radius - self.current_data_point = (self.starting_radius, self.starting_radius) - self.current_iteration_midi_messages: list[Message] = [] - self.datapoint_generator = radially_expanding_henon_mappings_generator( - a_parameter=self.a_parameter, - iterations_per_orbit=self.iterations_per_orbit, - starting_radius=self.starting_radius, - radial_step=self.radial_step, - ) - self.reset() - - def next_midi_messages(self) -> list[Message]: - try: - datapoint = next(self.datapoint_generator) - except StopIteration: - self.reset() - midi_messages = self.current_iteration_midi_messages - else: - self.current_data_point = datapoint - self.current_iteration += 1 - if self.current_iteration % self.iterations_per_orbit == 0: - self.current_radius += self.radial_step - self.datapoint_generator = radially_expanding_henon_mappings_generator( - a_parameter=self.a_parameter, - iterations_per_orbit=self.iterations_per_orbit, - starting_radius=self.current_radius, - radial_step=self.radial_step, - ) - midi_messages = create_midi_messages_from_data_point( - datapoint, - duration_ticks=self.note_length_ticks, - sustain=self.sustain, - clip=self.clip, - x_midi_parameter_mappings=self.x_midi_parameter_mappings, - y_midi_parameter_mappings=self.y_midi_parameter_mappings, - ) - self.current_iteration_midi_messages = midi_messages - return midi_messages - - def reset(self): - self.current_iteration = 0 - self.current_radius = self.starting_radius - self.current_data_point = (self.starting_radius, self.starting_radius) - self.current_iteration_midi_messages = create_midi_messages_from_data_point( - self.current_data_point, - duration_ticks=self.note_length_ticks, - sustain=self.sustain, - clip=self.clip, - x_midi_parameter_mappings=self.x_midi_parameter_mappings, - y_midi_parameter_mappings=self.y_midi_parameter_mappings, - ) - self.datapoint_generator = radially_expanding_henon_mappings_generator( - a_parameter=self.a_parameter, - iterations_per_orbit=self.iterations_per_orbit, - starting_radius=self.starting_radius, - radial_step=self.radial_step, - ) - - def generate_all_midi_messages(self) -> list[Message]: - self.reset() - complete = False - midi_messages = [] - midi_messages.extend(self.current_iteration_midi_messages) - while not complete: - midi_messages.extend(self.next_midi_messages()) - if self.current_iteration == 0: - complete = True - return midi_messages - - -def create_midi_messages_from_data_point( - datapoint: tuple[float, float], - duration_ticks: float = 960, - sustain: bool = False, - clip: bool = False, - x_midi_parameter_mappings: set[str] = {"note"}, - y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, -) -> list[Message]: - x = datapoint[0] - y = datapoint[1] - - midi_values = { - "note": 64, - "velocity": 64, - "pan": 64, - } - - for x_midi_parameter_mapping in x_midi_parameter_mappings: - if (x > 1.0 or x < -1.0) and not clip: - midi_values[x_midi_parameter_mapping] = 0 - else: - midi_values[x_midi_parameter_mapping] = midi_value_from_data_point_value(x) - - for y_midi_parameter_mapping in y_midi_parameter_mappings: - if (y > 1.0 or y < -1.0) and not clip: - midi_values[y_midi_parameter_mapping] = 0 - else: - midi_values[y_midi_parameter_mapping] = midi_value_from_data_point_value(y) - - note_on = Message( - "note_on", - note=midi_values["note"], - velocity=midi_values["velocity"], - ) - note_off = Message( - "note_off", - note=midi_values["note"], - velocity=midi_values["velocity"], - time=duration_ticks, - ) - - note_messages = [note_on, note_off] - pre_note_messages = [] - post_note_messages = [] - - if "pan" in x_midi_parameter_mappings or "pan" in y_midi_parameter_mappings: - pan = Message( - "control_change", - control=10, - value=midi_values["pan"], - ) - pre_note_messages.append(pan) - - reset_pan = Message( - "control_change", - control=10, - value=64, - ) - post_note_messages.append(reset_pan) - - if sustain: - sustain_on_msg = Message( - "control_change", - control=64, - value=127, - ) - pre_note_messages.append(sustain_on_msg) - else: - sustain_off_msg = Message( - "control_change", - control=64, - value=0, - ) - post_note_messages.append(sustain_off_msg) - - messages = pre_note_messages + note_messages + post_note_messages - - return messages - - -def midi_value_from_data_point_value( - x: float, - data_point_range: tuple[float, float] = (-1.0, 1.0), - midi_range: tuple[int, int] = (0, 127), -) -> int: - min_midi_value = midi_range[0] - max_midi_value = midi_range[1] - - min_data_point_value = data_point_range[0] - max_data_point_value = data_point_range[1] - - return round( - rescale_number_to_range( - x, - (min_data_point_value, max_data_point_value), - (min_midi_value, max_midi_value), - clip_value=True, - ) - ) diff --git a/tests/test_henon_equations.py b/tests/test_henon_equations.py new file mode 100644 index 0000000..a291051 --- /dev/null +++ b/tests/test_henon_equations.py @@ -0,0 +1,120 @@ +import pytest + +from henon2midi.henon_equations import RadiallyExpandingHenonMappingsGenerator + + +@pytest.mark.parametrize( + ("starting_radius"), + [ + (0.0), + (0.1), + (0.5), + (1.0), + ], +) +def test_radially_expanding_henon_mappings_generator_initial_data_point( + starting_radius, +): + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + starting_radius=starting_radius, + ) + assert data_point_generator.current_data_point == (starting_radius, starting_radius) + + +def test_radially_expanding_henon_mappings_generator_changing_orbit(): + iterations_per_orbit = 3 + initial_radius = 0.0 + radial_step = 0.2 + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + iterations_per_orbit=iterations_per_orbit, + starting_radius=initial_radius, + radial_step=radial_step, + ) + + assert data_point_generator.current_radius == initial_radius + + for _ in range(iterations_per_orbit): + data_point_generator.generate_next_data_point() + + assert data_point_generator.current_radius == initial_radius + + data_point_generator.generate_next_data_point() + + assert data_point_generator.current_radius == initial_radius + radial_step + + +def test_radially_expanding_henon_mappings_generator_state(): + iterations_per_orbit = 3 + initial_radius = 0.0 + radial_step = 0.2 + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + iterations_per_orbit=iterations_per_orbit, + starting_radius=initial_radius, + radial_step=radial_step, + ) + assert data_point_generator.current_iteration == 0 + assert data_point_generator.current_orbital_iteration == 0 + assert data_point_generator.current_radius == initial_radius + + destination_radius = 1.0 + number_of_orbits = int(destination_radius / radial_step) + number_of_iterations = number_of_orbits * iterations_per_orbit + for _ in range(number_of_iterations): + data_point_generator.generate_next_data_point() + + assert data_point_generator.current_iteration == number_of_iterations + assert data_point_generator.current_orbital_iteration == number_of_orbits + assert data_point_generator.current_radius == destination_radius - radial_step + + data_point = data_point_generator.generate_next_data_point() + + assert data_point_generator.current_iteration == number_of_iterations + 1 + assert data_point_generator.current_orbital_iteration == number_of_orbits + 1 + assert data_point_generator.current_radius == destination_radius + assert data_point_generator.current_data_point == data_point + + +def test_radially_expanding_henon_mappings_generator_resets_when_generator_ends_iteration( + mocker, +): + iterations_per_orbit = 200 + initial_radius = 1.0 + radial_step = 0.2 + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + iterations_per_orbit=iterations_per_orbit, + starting_radius=initial_radius, + radial_step=radial_step, + ) + + restart_data_point_generator_method = mocker.patch.object( + data_point_generator, "restart_data_point_generator" + ) + + with pytest.raises(StopIteration): + for _ in range(1000): + data_point_generator.generate_next_data_point() + + assert restart_data_point_generator_method.call_count == 1 + + +def test_radially_expanding_henon_mappings_generator_does_not_raise_exception_when_generator_ends_iteration(): + iterations_per_orbit = 200 + initial_radius = 1.0 + radial_step = 0.2 + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + iterations_per_orbit=iterations_per_orbit, + starting_radius=initial_radius, + radial_step=radial_step, + ) + + for _ in range(1000): + data_point_generator.generate_next_data_point() + + assert data_point_generator.current_iteration == 1000 + assert data_point_generator.current_orbital_iteration > 5 + assert data_point_generator.current_radius == 1.0 diff --git a/tests/test_math.py b/tests/test_math.py index eed9c71..0f82e50 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -1,4 +1,3 @@ -"""Tests for hello function.""" import pytest from henon2midi.math import rescale_number_to_range diff --git a/tests/test_midi.py b/tests/test_midi.py index 4aeb23b..4d03e09 100644 --- a/tests/test_midi.py +++ b/tests/test_midi.py @@ -1,4 +1,3 @@ -"""Tests for hello function.""" import pytest from henon2midi.midi import get_default_midi_output_name