diff --git a/notebooks/mesonic-dev-timeline-plot.ipynb b/notebooks/mesonic-dev-timeline-plot.ipynb new file mode 100644 index 0000000..42de97d --- /dev/null +++ b/notebooks/mesonic-dev-timeline-plot.ipynb @@ -0,0 +1,553 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "a3cc5d63", + "metadata": {}, + "source": [ + "# Timeline.plot testing\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64cf5a35", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "643272df", + "metadata": {}, + "outputs": [], + "source": [ + "import mesonic" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2eaf53d", + "metadata": {}, + "outputs": [], + "source": [ + "import pyamapping as pam\n", + "import math\n", + "import matplotlib.patches as mpatches\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32724ee3", + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = [8, 2]\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8dddc7b", + "metadata": {}, + "outputs": [], + "source": [ + "context = mesonic.create_context()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "523af966", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed72d1f6", + "metadata": {}, + "outputs": [], + "source": [ + "s1m = context.synths.create(\"s1\")\n", + "s1i = context.synths.create(\"s1\", mutable=False)\n", + "s2 = context.synths.create(\"s2\", track=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d17f0484", + "metadata": {}, + "outputs": [], + "source": [ + "context.synths" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cac3172", + "metadata": {}, + "outputs": [], + "source": [ + "context.clear() \n", + "with context.at(0.3):\n", + " s2.start(freq=300, amp=0.1, pan=1)\n", + "for t in range(4,10):\n", + " with context.at(t/10): \n", + " s2.freq = 100 * t\n", + " s2.amp += 0.1\n", + " s2.pan = -1 * s2.pan.value\n", + "with context.at((t+1)/10): \n", + " s2.stop()\n", + "\n", + "context.time = 1.25\n", + "s1m.start(dur=2, pan=1, freq=450) # parameters can be provided by keyword\n", + "for i in range(1,5):\n", + " context.time += 0.25\n", + " s1m.pan *= -1 # it is also possible to use /= , += , -= \n", + " if i % 2 == 0:\n", + " print(\"stop\")\n", + " context.time += 1\n", + " s1m.stop()\n", + " context.time += 1\n", + " s1m.start()\n", + "context.time = None \n", + "\n", + "for i in range(5):\n", + " with context.at(3+i/4):\n", + " s1i.start(dur=1.0, freq=100 * (i+1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa9607c8", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0ffd53b", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline.plot_new()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba90dcc6", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cddc6dfe", + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure()\n", + "context.timeline.plot_new(offset=\"amp\", width=\"freq\")\n", + "#plt.semilogy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef9d734a", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline.plot_new(offset=\"freq\", width=\"pan\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "168d3682", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline.plot_new(offset=\"pan\", width=\"freq\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c07e2905", + "metadata": {}, + "outputs": [], + "source": [ + "def safe_amp_to_db(amp):\n", + " return np.where(amp > 0, pam.amp_to_db(amp), -90)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8efdb710", + "metadata": {}, + "outputs": [], + "source": [ + "safe_amp_to_db(-1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3784465", + "metadata": {}, + "outputs": [], + "source": [ + "from mesonic.events import SynthEventType\n", + "from mesonic.timeline import SynthEventPseudoStop" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14007d07", + "metadata": {}, + "outputs": [], + "source": [ + "def print_events(events, etype):\n", + " print(len(events[etype]), [t[0] for t in events[etype]])\n", + " \n", + "for synth, events in context.timeline._check_consistency().items():\n", + " print()\n", + " print(synth)\n", + " print_events(events, SynthEventType.START)\n", + " print_events(events, SynthEventType.SET)\n", + " print_events(events, SynthEventType.STOP)\n", + " assert len(events[SynthEventType.START]) == len(events[SynthEventType.STOP])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4094e12", + "metadata": {}, + "outputs": [], + "source": [ + "s1i._param_values()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc969104", + "metadata": {}, + "outputs": [], + "source": [ + "val = 2.5\n", + "\n", + "li = list(range(5))\n", + "le = list(range(1,6))\n", + "print(li)\n", + "print(le)\n", + "\n", + "iidx = 0\n", + "for val in [0.5, 1.5,3.5,4.5]:\n", + " print(val)\n", + " while iidx < len(li) and li[iidx] < val:\n", + " iidx += 1\n", + "\n", + " print(val, iidx)\n", + " li.insert(iidx, val)\n", + " le.insert(iidx-1, val)\n", + " print(li)\n", + " print(le)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd0a7184", + "metadata": {}, + "outputs": [], + "source": [ + "synth_plot_info = context.timeline._create_segments()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a26866fe", + "metadata": {}, + "outputs": [], + "source": [ + "for synth, values in synth_plot_info.items():\n", + " print(synth)\n", + " print(synth.mutable)\n", + " for etype, events in values[\"events\"].items():\n", + " print(etype, [t[0] for t in events])\n", + " for key, vs in values[\"segments\"].items():\n", + " print(key, \" \\t\", vs)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cff2b33c", + "metadata": {}, + "outputs": [], + "source": [ + "fig = plt.figure()\n", + "cmap = plt.get_cmap(\"Set1\")\n", + "axes = fig.add_subplot(1, 1, 1)\n", + "\n", + "for synth in synth_plot_info.keys():\n", + " segments = synth_plot_info[synth][\"segments\"]\n", + " quiver = axes.quiver(\n", + " segments[\"begins\"],\n", + " segments[\"offsets\"],\n", + " np.array(segments[\"ends\"]) - np.array(segments[\"begins\"]),\n", + " 0.0,\n", + " angles=0,\n", + " scale=1,\n", + " scale_units=\"x\",\n", + " headwidth=0,\n", + " headlength=0,\n", + " headaxislength=0,\n", + " linewidth=segments[\"widths\"],\n", + " )\n", + " quiver.set_linewidths(segments[\"widths\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d75c16a", + "metadata": {}, + "outputs": [], + "source": [ + "list(zip(zip(segments[\"begins\"], segments[\"offsets\"]), zip(segments[\"ends\"], segments[\"offsets\"])))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22eb1fe3", + "metadata": {}, + "outputs": [], + "source": [ + "fig = plt.figure()\n", + "cmap = plt.get_cmap(\"Set1\")\n", + "axes = fig.add_subplot(1, 1, 1)\n", + "from matplotlib.collections import LineCollection\n", + "\n", + "x_min, x_max = 0, 0\n", + "y_min, y_max = 0, 0\n", + "\n", + "for synth in synth_plot_info.keys():\n", + " segments = synth_plot_info[synth][\"segments\"]\n", + " s = np.array(list(zip(\n", + " list(zip(segments[\"begins\"], segments[\"offsets\"])),\n", + " list(zip(segments[\"ends\"], segments[\"offsets\"])),\n", + " )))\n", + " lc = LineCollection(\n", + " s,\n", + " linewidth=segments[\"widths\"],\n", + " )\n", + " axes.add_collection(lc)\n", + "\n", + " x_min = min(np.min(segments[\"begins\"]), x_min)\n", + " x_max = max(np.max(segments[\"ends\"]), x_max)\n", + " y_min = min(np.min(segments[\"offsets\"]), y_min)\n", + " y_max = max(np.max(segments[\"offsets\"]), y_max)\n", + "\n", + "x_lim = (x_max-x_min) * 0.1\n", + "axes.set_xlim(x_min-x_lim,x_max+x_lim)\n", + "y_lim = (y_max-y_min) * 0.1\n", + "axes.set_ylim(y_min-y_lim,y_max+y_lim)\n", + "axes.grid()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4750d112", + "metadata": {}, + "outputs": [], + "source": [ + "s.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08b5ea46", + "metadata": {}, + "outputs": [], + "source": [ + "synth_plot_info[s2][\"segments\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "393a63bf", + "metadata": {}, + "outputs": [], + "source": [ + "synth_plot_info[s1i][\"segments\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "078512d9", + "metadata": {}, + "outputs": [], + "source": [ + "synth_plot_info[s1m][\"segments\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af131d09", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.arange(0,128)\n", + "plt.plot(x, np.log(pam.midicps(x)))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9f1d715", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.arange(0,128)\n", + "plt.plot(x, (pam.midicps(x)))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c2ec9de", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.arange(0,128)\n", + "plt.plot(x, pam.midicps(x))\n", + "plt.yscale(\"log\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b821ebc-3a48-45cb-b7b9-9e4323c3b189", + "metadata": {}, + "outputs": [], + "source": [ + "s1m.freq.bounds = (300,1000)\n", + "s1i.freq.bounds = (100,1000)\n", + "s2.freq.bounds = (300,1000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1860b8e-7545-481d-b90d-15659aef5198", + "metadata": {}, + "outputs": [], + "source": [ + "s1m.pan.bounds, s1i.pan.bounds, s2.pan.bounds" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d10cca98-07f7-450d-9f53-e839c07e8a1e", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1644995-ce87-4a9c-a179-50caef5db452", + "metadata": {}, + "outputs": [], + "source": [ + "for s in [s1m, s1i, s2]:\n", + " s1m.pan.bounds = -1,1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6800f6ba-876f-4d29-9998-f4dfc2d53ea7", + "metadata": {}, + "outputs": [], + "source": [ + "context.timeline.plot(\"pan\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93cbee9e-0b95-4ce6-9cea-6e3014c3f2ca", + "metadata": {}, + "outputs": [], + "source": [ + "# close the context.\n", + "context.close()\n", + "# if all contexts are closed the backend should also exit." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/mesonic/timeline.py b/src/mesonic/timeline.py index 90d17dd..5706d96 100644 --- a/src/mesonic/timeline.py +++ b/src/mesonic/timeline.py @@ -1,13 +1,26 @@ import copy import math +from collections import defaultdict from threading import RLock from time import time as get_timestamp -from typing import Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple import attr +import numpy as np +import pyamapping as pam +from attrs import define from mesonic.events import Event, SynthEvent, SynthEventType +if TYPE_CHECKING: + from mesonic.synth import Synth + + +@define(kw_only=True, slots=True, repr=True) +class SynthEventPseudoStop(Event): + synth: "Synth" = (attr.field(repr=False),) + etype: SynthEventType = SynthEventType.STOP + @attr.s(slots=True, repr=True) class TimeBundle: @@ -335,11 +348,358 @@ def end_time_offset(self, value): raise ValueError("end_time_offset must larger or equal 0") self._end_time_offset = value - def plot(self, duration_key="dur", default_duration=0.1): + def _split_synth_events(self) -> Dict["Synth", Dict["SynthEventType", List[Event]]]: + splitted_synth_events = {} + for time, events in self.to_dict().items(): + for event in events: + if isinstance(event, SynthEvent): + if event.synth not in splitted_synth_events: + splitted_synth_events[event.synth] = { + SynthEventType.START: [], + SynthEventType.STOP: [], + SynthEventType.SET: [], + } + splitted_synth_events[event.synth][event.etype].append( + (time, event) + ) + return splitted_synth_events + + @staticmethod + def _create_stop_event( + event_info, duration_key="dur", default_duration=0.1 + ) -> Tuple[float, "SynthEventPseudoStop"]: + time, start_event = event_info + duration = start_event.data.get(duration_key, default_duration) + return time + duration, SynthEventPseudoStop( + track=start_event.track, info=start_event.info, synth=start_event.synth + ) + + def _check_consistency(self, duration_key="dur"): + splitted_synth_events = self._split_synth_events() + for synth, events in splitted_synth_events.items(): + if synth.mutable: + start_stop_diff = len(events[SynthEventType.START]) - len( + events[SynthEventType.STOP] + ) + if start_stop_diff == 0: + # for each start there is a stop + continue + elif start_stop_diff == len(events[SynthEventType.START]): + # not stops were made + events[SynthEventType.STOP] = list( + map(self._create_stop_event, events[SynthEventType.START]) + ) + elif start_stop_diff > 0: + # search missing stops and generate pseudo stops + idx = 1 + nr_stops_generated = 0 + while ( + idx < len(events[SynthEventType.START]) + or nr_stops_generated == start_stop_diff + ): + start_1 = events[SynthEventType.START][idx - 1] + start_2 = events[SynthEventType.START][idx] + stop = events[SynthEventType.STOP][idx - 1] + assert start_1[0] < start_2[0], "starts overlap" + assert start_1[0] < stop[0], "stop without prior start" + if start_2[0] < stop[0]: + # stop does stop start_2 -> start_1 is not stopped + start_time, start_event = start_1 + events[SynthEventType.STOP].insert( + idx - 1, self._create_stop_event(start_event) + ) + nr_stops_generated += 1 + idx += 1 + if nr_stops_generated < start_stop_diff: + # last start is unmatched. + events[SynthEventType.STOP].append( + self._create_stop_event(events[SynthEventType.START][-1]) + ) + else: + raise RuntimeError(f"Timeline has more Stop Events for {synth}") + else: # synth immutable + assert len(events[SynthEventType.STOP]) == 0 + assert len(events[SynthEventType.SET]) == 0 + events[SynthEventType.STOP] = list( + map(self._create_stop_event, events[SynthEventType.START]) + ) + return splitted_synth_events + + def _create_segments(self, offset_key="freq", width_key="amp"): + splitted_synth_events = self._check_consistency() + # for quiver X, U, Y (V=0), arrowwidths + segments = { + "begins": [], + "ends": [], + "offsets": [], + "widths": [], + "connections": [], + } + # start & stop tuples (x,y) for scatter, set events: (x,y,x,y2) + markers = {"starts": [], "stops": [], "sets": []} + synth_plot_info = { + synth: { + "events": events, + "segments": copy.deepcopy(segments), + "markers": copy.deepcopy(markers), + } + for synth, events in splitted_synth_events.items() + } + for synth, events in splitted_synth_events.items(): + begins = synth_plot_info[synth]["segments"]["begins"] + ends = synth_plot_info[synth]["segments"]["ends"] + offsets = synth_plot_info[synth]["segments"]["offsets"] + widths = synth_plot_info[synth]["segments"]["widths"] + connections = synth_plot_info[synth]["segments"]["connections"] + + for time, event in events[SynthEventType.START]: + begins.append(time) + offsets.append((event.data[offset_key])) + widths.append((event.data[width_key])) + ends.extend([t[0] for t in events[SynthEventType.STOP]]) + if events[ + SynthEventType.SET + ]: # if there are SET events we need to split segments + # we need to cluster the set events as they are only singular + combined_set_events = defaultdict(list) + for set_time, set_event in events[SynthEventType.SET]: + combined_set_events[set_time].append(set_event) + insert_idx = 0 + for set_time, set_events in combined_set_events.items(): + while insert_idx < len(begins) and begins[insert_idx] < set_time: + insert_idx += 1 + # get properties for the event data + found_offset = False + found_width = False + for set_event in set_events: + if set_event.data["name"] == offset_key: + offset = set_event.data["new_value"] + offsets.insert(insert_idx, offset) + found_offset = True + connections.append( + [ + (set_time, set_event.data["old_value"]), + (set_time, offset), + ] + ) + if set_event.data["name"] == width_key: + width = set_event.data["new_value"] + widths.insert(insert_idx, width) + found_width = True + if found_offset or found_width: + # start_idx is now the index of the segment begin that our set interrupts + begins.insert(insert_idx, set_time) + # insert the new segment end before the old segment end + ends.insert(insert_idx - 1, set_time) + # if a line property was not found use the value from the prior segment + if not found_offset: + offset = offsets[insert_idx - 1] + offsets.insert(insert_idx, offset) + if not found_width: + width = widths[insert_idx - 1] + widths.insert(insert_idx, width) + + assert len(begins) == len(ends) + assert len(begins) == len(offsets) + assert len(begins) == len(widths) + return synth_plot_info + + def plot_new(self, offset="freq", width="amp", scale=None): + """Plot the Timeline. + + Parameters + ---------- + parameter_key : str, optional + The Parameter name for the y-axis offset, by default "freq" + This is used to get the bounds of the parameter and set the + y-axis offset of the synth accordingly. + duration_key : str, optional + The Parameter name for the Synth duration, by default "dur" + This is used to extract the duration from the Events. + default_duration : float, optional + The fallback value if no duration can be found, by default 0.1 + + Raises + ------ + ImportError + If matplotlib cannot be imported. + """ + try: + import matplotlib.patches as mpatches + import matplotlib.pyplot as plt + import matplotlib.ticker as ticker + from matplotlib.collections import LineCollection + + except ImportError as err: + raise ImportError( + "plotting the Timeline is only possible when matplotlib is installed." + ) from err + # do not plot an empty Timeline + if self.is_empty(): + return + + # Events offer + # track, info + # SynthEvents offer + # synth, etype, data + # dict with synth as key and a inner dict + # { + # for each synth synth + # "warp": "lin"|"log"|"exp", + # "color": + # "y_offset": 0 (depending on synth & track) + # + # events + # "starts": [(x,y,s)], + # "stops": [(x,y,s)], (virtuell) + # "sets": [(x,y,s)], + # + # for each segment + # segments + # starts + # stops + # "offsets": [], (depending on 1st value) + # "widths": [], (depending on 2nd value) + # + # } + # so one can use quiver for the segments + + # get segments + synth_plot_info = self._create_segments(offset_key=offset, width_key=width) + + # synth specifics are added later + # "warp": None,s + # "bounds": None, + # "color": None, + # "y_offset": 0, + + fig = plt.figure() # figsize=(8, 2) + ax = fig.add_subplot(1, 1, 1) + cmap = plt.get_cmap("Set1") + + x_min, x_max = 0, 0 + y_min, y_max = 0, 0 + + patches = [] + synth_colors = {} + synth_labels = {} + for synth_idx, synth in enumerate(synth_plot_info.keys()): + synth_colors[synth] = cmap(synth_idx) + synth_labels[synth] = f"{synth.name}" + ( + " (mutable)" if synth.mutable else " (immutable)" + ) + patches.append( + mpatches.Patch(color=synth_colors[synth], label=synth_labels[synth]) + ) + + segments = synth_plot_info[synth]["segments"] + begins = np.array(segments["begins"]) + ends = np.array(segments["ends"]) + offsets = np.array(segments["offsets"]) + widths = np.array(segments["widths"]) + + # TODO implement warps as two or one parameterized fun + def warp_offset(value, scale=None): + return value + + def warp_width(value, scale=None, width_min=2, width_max=4): + if widths.min() == widths.max(): + return (width_max + width_min) / 2 + ret = pam.linlin( + value, widths.min(), widths.max(), width_min, width_max + ) + return ret + + offsets = warp_offset(offsets) + widths = warp_width(widths) + lines = np.array( + list( + zip( + list(zip(begins, offsets)), + list(zip(ends, offsets)), + ) + ) + ) + synth_segments_collection = LineCollection( + lines, linewidth=widths, color=synth_colors[synth] + ) + ax.add_collection(synth_segments_collection) + if synth.mutable: + connections = synth_plot_info[synth]["segments"]["connections"] + synth_connections_collection = LineCollection( + connections, linewidth=0.5, color=synth_colors[synth] + ) + ax.add_collection(synth_connections_collection) + + x_min = min(begins.min(), x_min) + x_max = max(ends.max(), x_max) + y_min = min(offsets.min(), y_min) + y_max = max(offsets.max(), y_max) + + # x_lim = (x_max - x_min) * 0.01 + # ax.set_xlim(x_min - x_lim, x_max + x_lim) + # y_lim = (y_max - y_min) * 0.1 + # axes.set_ylim(y_min - y_lim, y_max + y_lim) + + MAX_LEGEND_NCOLS = 5 + + # test + ax.legend( + handles=patches, + loc="lower left", + mode="expand", + bbox_to_anchor=(0.0, 1.02, 1.0, 0.102), + borderaxespad=0, + ncol=min(len(patches), MAX_LEGEND_NCOLS), + ) + ax.grid() + ax.set_xlabel("time [s]") + + if offset == "freq": + ax.set_yscale("log") + ax.set_ylabel("frequency [Hz]") + + # TODO perhaps we also could want mel? + secax = ax.secondary_yaxis( + location="right", functions=(pam.cps_to_midi, pam.midi_to_cps) + ) + secax.set_ylabel("MIDI Note") + secax.yaxis.set_major_locator( + ticker.MaxNLocator(nbins="auto", steps=[1, 2, 4, 5, 10], integer=True) + ) + secax.yaxis.set_major_formatter(lambda x, pos: str(int(x))) + elif offset == "amp": + ax.set_yscale("log") + ax.set_ylabel("amplitude") + + def safe_amp_to_db(amp): + return np.where(amp > 0, pam.amp_to_db(amp), -90) + + secax = ax.secondary_yaxis( + location="right", functions=(safe_amp_to_db, pam.db_to_amp) + ) + secax.set_ylabel("level [dB]") + secax.yaxis.set_major_locator( + ticker.MaxNLocator(nbins="auto", steps=[1, 2, 4, 5, 10]) + ) + secax.yaxis.set_major_formatter(lambda x, pos: str(int(x))) + else: + ax.set_ylabel(offset) + + ax.autoscale(enable=True) + fig.tight_layout() + + # TODO Idea: convention to order the Synth Params by most probalbe usage + + def plot(self, parameter_key="freq", duration_key="dur", default_duration=0.1): """Plot the Timeline. Parameters ---------- + parameter_key : str, optional + The Parameter name for the y-axis offset, by default "freq" + This is used to get the bounds of the parameter and set the + y-axis offset of the synth accordingly. duration_key : str, optional The Parameter name for the Synth duration, by default "dur" This is used to extract the duration from the Events. @@ -367,9 +727,10 @@ def plot(self, duration_key="dur", default_duration=0.1): axes = fig.add_subplot(1, 1, 1) spacing = 1 + parameter_offset_min, parameter_offset_max = -0.3, 0.3 y_offset = spacing - offsets = {} + synth_offsets = {} colors = {} mutable_synths = {} immutable_synths = { @@ -383,10 +744,28 @@ def plot(self, duration_key="dur", default_duration=0.1): for time, events in self.to_dict().items(): for event in events: if isinstance(event, SynthEvent): - if event.synth not in offsets: - offsets[event.synth] = y_offset + if event.synth not in synth_offsets: + synth_offsets[event.synth] = y_offset y_offset += spacing - offset = offsets[event.synth] + + parameter_offset = 0 + try: + x1, x2 = event.synth.params[parameter_key].bounds + if x1 is not None and x2 is not None: + y1, y2 = parameter_offset_min, parameter_offset_max + if event.etype is SynthEventType.SET: + if event.data.get("name") == parameter_key: + value = event.data.get("new_value") + else: + value = event.data.get( + parameter_key, + ) + parameter_offset = (value - x1) / (x2 - x1) * (y2 - y1) + y1 + print(event.synth, value, parameter_offset) + except KeyError: + parameter_offset = 0 + + offset = synth_offsets[event.synth] + parameter_offset if event.track not in colors: colors[event.track] = cmap(event.track) @@ -401,15 +780,24 @@ def plot(self, duration_key="dur", default_duration=0.1): immutable_synths["color"].append(color) else: # mutable Synth synth_dict = mutable_synths.get( - event.synth, {"start_times": [], "stop_times": []} + event.synth, + { + "start_times": [], + "stop_times": [], + "start_offsets": [], + "stop_offsets": [], + }, ) if event.etype == SynthEventType.START: dur = event.data.get(duration_key, None) if dur: synth_dict["stop_times"].append(time + dur) + synth_dict["stop_offsets"].append(offset) synth_dict["start_times"].append(time) + synth_dict["start_offsets"].append(offset) elif event.etype == SynthEventType.STOP: synth_dict["stop_times"].append(time) + synth_dict["stop_offsets"].append(offset) elif event.etype == SynthEventType.SET: set_events["times"].append(time) set_events["offsets"].append(offset) @@ -429,9 +817,15 @@ def plot(self, duration_key="dur", default_duration=0.1): max_mutable_time = start_times[-1] + durations[-1] axes.quiver( start_times, - [offsets[synth]] * len(start_times), + times["start_offsets"], # [synth_offsets[synth]] * len(start_times), durations, - 0.0, + [ + stop - start + for (start, stop) in zip( + times["start_offsets"], times["stop_offsets"] + ) + ], + angles="xy", scale=1, scale_units="x", color=colors[synth.track], @@ -456,12 +850,12 @@ def plot(self, duration_key="dur", default_duration=0.1): alpha=0.5, ) - yticks = list(offsets.values()) + yticks = list(synth_offsets.values()) axes.set_yticks(yticks) axes.set_yticklabels( [ f"{synth.name}" + (" (mutable)" if synth.mutable else " (immutable)") - for synth in offsets.keys() + for synth in synth_offsets.keys() ] ) axes.set_ylim(yticks[0] - spacing * 0.75, yticks[-1] + spacing * 0.75)