From c5ddff5d5c5d8615928fbc583b20df1d34fe902f Mon Sep 17 00:00:00 2001 From: Josh Symes Date: Sun, 23 Jul 2023 19:18:38 +0100 Subject: [PATCH] Add ability to control range of midi, values, more refactoring --- henon2midi/base.py | 38 ++++--- henon2midi/cli.py | 119 +++++++++++++++++--- henon2midi/data_point_to_midi_conversion.py | 30 +++-- henon2midi/henon_equations.py | 28 ++++- henon2midi/midi.py | 16 ++- tests/test_henon_equations.py | 74 +++++++----- 6 files changed, 232 insertions(+), 73 deletions(-) diff --git a/henon2midi/base.py b/henon2midi/base.py index cc8d1a5..513ca80 100644 --- a/henon2midi/base.py +++ b/henon2midi/base.py @@ -1,9 +1,10 @@ -from mido import MetaMessage, MidiFile, MidiTrack, bpm2tempo +from mido import MidiFile from henon2midi.data_point_to_midi_conversion import ( create_midi_messages_from_data_point, ) from henon2midi.henon_equations import RadiallyExpandingHenonMappingsGenerator +from henon2midi.midi import create_midi_file_from_messages def create_midi_file_from_data_generator( @@ -15,20 +16,25 @@ def create_midi_file_from_data_generator( clip: bool = False, x_midi_parameter_mappings_set: set[str] = {"note"}, y_midi_parameter_mappings_set: set[str] = {"velocity", "pan"}, + source_range_x: tuple[float, float] = (-1.0, 1.0), + source_range_y: tuple[float, float] = (-1.0, 1.0), + midi_range_x: tuple[int, int] = (0, 127), + midi_range_y: tuple[int, int] = (0, 127), ) -> 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)) - 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, + messages = [] + for datapoint in henon_midi_generator: + messages.extend( + 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, + source_range_x=source_range_x, + source_range_y=source_range_y, + midi_range_x=midi_range_x, + midi_range_y=midi_range_y, + ) ) - track.extend(midi_messages) - return mid + return create_midi_file_from_messages(messages, ticks_per_beat, bpm) diff --git a/henon2midi/cli.py b/henon2midi/cli.py index 775ab20..3f79095 100644 --- a/henon2midi/cli.py +++ b/henon2midi/cli.py @@ -80,6 +80,20 @@ show_default=True, type=str, ) +@click.option( + "--x-midi-value-range", + default="0,127", + help="The MIDI value range for the x data point.", + show_default=True, + type=str, +) +@click.option( + "--y-midi-value-range", + default="0,127", + help="The MIDI value range for the y data point.", + show_default=True, + type=str, +) @click.option( "-r", "--starting-radius", @@ -117,6 +131,12 @@ help="Clip the MIDI messages to the range of the MIDI parameter.", type=bool, ) +@click.option( + "--continual-loop", + is_flag=True, + help="Loop back to start when Henon data is exhausted.", + type=bool, +) def cli( a_parameter: float, iterations_per_orbit: int, @@ -126,12 +146,15 @@ def cli( notes_per_beat: int, x_midi_parameter_mappings: str, y_midi_parameter_mappings: str, + x_midi_value_range: str, + y_midi_value_range: str, starting_radius: float, radial_step: float, out: str, draw_ascii_art: bool, sustain: bool, clip: bool, + continual_loop: bool, ): """An application that generates midi from procedurally generated Henon mappings.""" @@ -150,6 +173,26 @@ def cli( notes_per_beat = notes_per_beat x_midi_parameter_mappings_set = set(x_midi_parameter_mappings.split(",")) y_midi_parameter_mappings_set = set(y_midi_parameter_mappings.split(",")) + x_midi_value_range_split = x_midi_value_range.split(",") + if len(x_midi_value_range_split) != 2: + raise ValueError( + "x_midi_value_range must be a comma separated list of 2 values" + ) + else: + midi_range_x = ( + int(x_midi_value_range_split[0]), + int(x_midi_value_range_split[1]), + ) + y_midi_value_range_split = y_midi_value_range.split(",") + if len(y_midi_value_range_split) != 2: + raise ValueError( + "y_midi_value_range must be a comma separated list of 2 values" + ) + else: + midi_range_y = ( + int(y_midi_value_range_split[0]), + int(y_midi_value_range_split[1]), + ) starting_radius = starting_radius radial_step = radial_step draw_ascii_art = draw_ascii_art @@ -166,6 +209,8 @@ def cli( f"\tnotes per beat: {notes_per_beat}\n" f"\tx midi parameter mappings: {x_midi_parameter_mappings_set}\n" f"\ty midi parameter mappings: {y_midi_parameter_mappings_set}\n" + f"\tx midi value range: {midi_range_x}\n" + f"\ty midi value range: {midi_range_y}\n" f"\tstarting radius: {starting_radius}\n" f"\tradial step: {radial_step}\n" f"\tout: {midi_output_file_name}\n" @@ -191,6 +236,10 @@ def cli( clip=clip, x_midi_parameter_mappings_set=x_midi_parameter_mappings_set, y_midi_parameter_mappings_set=y_midi_parameter_mappings_set, + source_range_x=(-1.0, 1.0), + source_range_y=(-1.0, 1.0), + midi_range_x=midi_range_x, + midi_range_y=midi_range_y, ) mid.save(midi_output_file_name) @@ -200,7 +249,6 @@ def cli( ascii_art_canvas = AsciiArtCanvas( ascii_art_canvas_width, ascii_art_canvas_height ) - art_string = "" if midi_output_name: hennon_mappings_generator = RadiallyExpandingHenonMappingsGenerator( @@ -221,6 +269,10 @@ def cli( clip=clip, x_midi_parameter_mappings=x_midi_parameter_mappings_set, y_midi_parameter_mappings=y_midi_parameter_mappings_set, + source_range_x=(-1.0, 1.0), + source_range_y=(-1.0, 1.0), + midi_range_x=midi_range_x, + midi_range_y=midi_range_y, ) current_iteration = hennon_mappings_generator.current_iteration @@ -228,6 +280,10 @@ def cli( current_data_point = hennon_mappings_generator.current_data_point is_new_orbit = hennon_mappings_generator.is_new_orbit() + if not continual_loop: + if hennon_mappings_generator.get_times_reset() > 0: + break + try: midi_message_player.send(messages) except KeyboardInterrupt: @@ -247,12 +303,15 @@ def cli( ) if draw_ascii_art: - art_string = get_ascii_art( + art_string = build_art_string( current_data_point, + ascii_art_canvas, current_iteration, is_new_orbit, - ascii_art_canvas, + clip=clip, ) + else: + art_string = "" click.clear() screen_render = ( @@ -261,24 +320,52 @@ def cli( click.echo(screen_render) -def get_ascii_art( - data_point: tuple[float, float], +def build_art_string( + new_data_point: tuple[float, float], + ascii_art_canvas: AsciiArtCanvas, current_iteration: int, is_new_orbit: bool, - ascii_art_canvas: AsciiArtCanvas, + clip: bool = False, ) -> str: - if current_iteration == 0: + if current_iteration == 1: ascii_art_canvas.clear() - x = data_point[0] - y = data_point[1] - draw_point_coord = ( - round(rescale_number_to_range(x, (-1.0, 1.0), (0, ascii_art_canvas.width - 1))), - round( - rescale_number_to_range(y, (-1.0, 1.0), (0, ascii_art_canvas.height - 1)) - ), + draw_data_point_on_canvas( + new_data_point, + ascii_art_canvas, + is_new_orbit, + clip=clip, ) - ascii_art_canvas.draw_point(draw_point_coord[0], draw_point_coord[1], ".") + return ascii_art_canvas.generate_string() + +def draw_data_point_on_canvas( + data_point: tuple[float, float], + ascii_art_canvas: AsciiArtCanvas, + is_new_orbit: bool, + clip: bool = False, +): + x = data_point[0] + y = data_point[1] if is_new_orbit: ascii_art_canvas.set_color("random") - return ascii_art_canvas.generate_string() + try: + x_canvas_coord = round( + rescale_number_to_range( + x, + (-1.0, 1.0), + (0, ascii_art_canvas.width - 1), + clip_value=clip, + ) + ) + y_canvas_coord = round( + rescale_number_to_range( + y, + (-1.0, 1.0), + (0, ascii_art_canvas.height - 1), + clip_value=clip, + ) + ) + except ValueError: + pass + else: + ascii_art_canvas.draw_point(x_canvas_coord, y_canvas_coord, ".") diff --git a/henon2midi/data_point_to_midi_conversion.py b/henon2midi/data_point_to_midi_conversion.py index 4924a42..5ee1468 100644 --- a/henon2midi/data_point_to_midi_conversion.py +++ b/henon2midi/data_point_to_midi_conversion.py @@ -10,6 +10,10 @@ def create_midi_messages_from_data_point( clip: bool = False, x_midi_parameter_mappings: set[str] = {"note"}, y_midi_parameter_mappings: set[str] = {"velocity", "pan"}, + source_range_x: tuple[float, float] = (-1.0, 1.0), + source_range_y: tuple[float, float] = (-1.0, 1.0), + midi_range_x: tuple[int, int] = (0, 127), + midi_range_y: tuple[int, int] = (0, 127), ) -> list[Message]: x = datapoint[0] y = datapoint[1] @@ -21,16 +25,24 @@ def create_midi_messages_from_data_point( } 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 + if (x > source_range_x[1] or x < source_range_x[0]) and not clip: + midi_values["velocity"] = 0 else: - midi_values[x_midi_parameter_mapping] = midi_value_from_data_point_value(x) + midi_values[x_midi_parameter_mapping] = midi_value_from_data_point_value( + x, + source_range=source_range_x, + midi_range=midi_range_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 + if (y > source_range_y[1] or y < source_range_y[0]) and not clip: + midi_values["velocity"] = 0 else: - midi_values[y_midi_parameter_mapping] = midi_value_from_data_point_value(y) + midi_values[y_midi_parameter_mapping] = midi_value_from_data_point_value( + y, + source_range=source_range_y, + midi_range=midi_range_y, + ) note_on = Message( "note_on", @@ -85,14 +97,14 @@ def create_midi_messages_from_data_point( def midi_value_from_data_point_value( x: float, - data_point_range: tuple[float, float] = (-1.0, 1.0), + source_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] + min_data_point_value = source_range[0] + max_data_point_value = source_range[1] return round( rescale_number_to_range( diff --git a/henon2midi/henon_equations.py b/henon2midi/henon_equations.py index 67fdc18..118f348 100644 --- a/henon2midi/henon_equations.py +++ b/henon2midi/henon_equations.py @@ -54,14 +54,19 @@ def __init__( self.henon_mapping_generator = henon_mapping_generator( a_parameter, starting_radius, starting_radius ) - self.data_point_generator = self.radially_expending_henon_mappings_generator() + self.data_point_generator = self._radially_expanding_henon_mappings_generator() self.current_orbital_iteration = 0 self.current_iteration = 0 self.iteration_of_current_orbit = 0 + self.times_reset = 0 self.current_radius = starting_radius self.current_data_point = (starting_radius, starting_radius) def generate_next_data_point(self) -> tuple[float, float]: + """ + Returns the next data point in the sequence. + If the sequence has reached the end, it will restart the sequence and return the first data point. + """ try: data_point = next(self.data_point_generator) except StopIteration: @@ -70,12 +75,20 @@ def generate_next_data_point(self) -> tuple[float, float]: return data_point def restart_data_point_generator(self): - self.data_point_generator = self.radially_expending_henon_mappings_generator() + """ + Creates a new data point generator. This is useful if you want to restart the sequence. + """ + self.times_reset += 1 + self.data_point_generator = self._radially_expanding_henon_mappings_generator() - def radially_expending_henon_mappings_generator( + def _radially_expanding_henon_mappings_generator( self, ) -> Generator[tuple[float, float], None, None]: self._reset_to_starting_radius() + self.current_iteration = 0 + self.current_orbital_iteration = 0 + self.iteration_of_current_orbit = 0 + while self.current_radius <= 1: self.iteration_of_current_orbit = 0 self.current_orbital_iteration += 1 @@ -117,3 +130,12 @@ def get_iteration_of_current_orbit(self) -> int: def is_new_orbit(self) -> bool: return self.iteration_of_current_orbit == 1 + + def get_times_reset(self) -> int: + return self.times_reset + + def __iter__(self): + return self + + def __next__(self): + return next(self.data_point_generator) diff --git a/henon2midi/midi.py b/henon2midi/midi.py index 73ee552..4044973 100644 --- a/henon2midi/midi.py +++ b/henon2midi/midi.py @@ -1,8 +1,11 @@ from time import sleep, time +from typing import Optional from mido import ( Message, + MetaMessage, MidiFile, + MidiTrack, bpm2tempo, get_output_names, open_output, @@ -47,5 +50,14 @@ def get_default_midi_output_name() -> str: return output_names[0] -def save_midi_file(midi: MidiFile, filename: str): - midi.save(filename) +def create_midi_file_from_messages( + messages: list[Message], ticks_per_beat: int = 960, bpm: Optional[int] = None +) -> MidiFile: + mid = MidiFile(ticks_per_beat=ticks_per_beat) + track = MidiTrack() + mid.tracks.append(track) + if bpm is not None: + tempo = bpm2tempo(bpm) + track.append(MetaMessage("set_tempo", tempo=tempo)) + track.extend(messages) + return mid diff --git a/tests/test_henon_equations.py b/tests/test_henon_equations.py index a291051..35b6261 100644 --- a/tests/test_henon_equations.py +++ b/tests/test_henon_equations.py @@ -19,74 +19,78 @@ def test_radially_expanding_henon_mappings_generator_initial_data_point( a_parameter=1.333, starting_radius=starting_radius, ) - assert data_point_generator.current_data_point == (starting_radius, starting_radius) + assert data_point_generator.get_current_data_point() == ( + starting_radius, + starting_radius, + ) def test_radially_expanding_henon_mappings_generator_changing_orbit(): iterations_per_orbit = 3 - initial_radius = 0.0 + starting_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, + starting_radius=starting_radius, radial_step=radial_step, ) - assert data_point_generator.current_radius == initial_radius + assert data_point_generator.get_current_radius() == starting_radius for _ in range(iterations_per_orbit): data_point_generator.generate_next_data_point() - assert data_point_generator.current_radius == initial_radius + assert data_point_generator.get_current_radius() == starting_radius data_point_generator.generate_next_data_point() - assert data_point_generator.current_radius == initial_radius + radial_step + assert data_point_generator.get_current_radius() == starting_radius + radial_step def test_radially_expanding_henon_mappings_generator_state(): iterations_per_orbit = 3 - initial_radius = 0.0 + starting_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, + starting_radius=starting_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 + assert data_point_generator.get_current_iteration() == 0 + assert data_point_generator.get_current_orbital_iteration() == 0 + assert data_point_generator.get_current_radius() == starting_radius - destination_radius = 1.0 - number_of_orbits = int(destination_radius / radial_step) + end_radius = 1.0 + number_of_orbits = int(end_radius / radial_step) + 1 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 + assert data_point_generator.get_current_iteration() == number_of_iterations + assert data_point_generator.get_current_orbital_iteration() == number_of_orbits + assert data_point_generator.get_current_radius() == end_radius 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 + assert data_point_generator.get_current_iteration() == 1 + assert data_point_generator.get_current_orbital_iteration() == 1 + assert data_point_generator.get_current_radius() == starting_radius + assert data_point_generator.get_current_data_point() == data_point + assert data_point_generator.get_times_reset() == 1 def test_radially_expanding_henon_mappings_generator_resets_when_generator_ends_iteration( mocker, ): iterations_per_orbit = 200 - initial_radius = 1.0 + starting_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, + starting_radius=starting_radius, radial_step=radial_step, ) @@ -103,18 +107,34 @@ def test_radially_expanding_henon_mappings_generator_resets_when_generator_ends_ def test_radially_expanding_henon_mappings_generator_does_not_raise_exception_when_generator_ends_iteration(): iterations_per_orbit = 200 - initial_radius = 1.0 + starting_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, + starting_radius=starting_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 + assert data_point_generator.get_current_radius() == 1.0 + + +def test_radially_expanding_henon_mappings_generator_is_iterable(): + iterations_per_orbit = 5 + starting_radius = 0.0 + radial_step = 0.2 + data_point_generator = RadiallyExpandingHenonMappingsGenerator( + a_parameter=1.333, + iterations_per_orbit=iterations_per_orbit, + starting_radius=starting_radius, + radial_step=radial_step, + ) + end_radius = 1.0 + number_of_orbits = int(end_radius / radial_step) + 1 + expected_number_of_iterations = number_of_orbits * iterations_per_orbit + + assert iter(data_point_generator) == data_point_generator + assert (len(list(data_point_generator))) == expected_number_of_iterations