From 4eb5af4e5a655947a8813475e511fc4b5c40f34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Noss?= Date: Mon, 5 May 2025 20:20:00 +0200 Subject: [PATCH 1/2] wip: add drums mode --- midilifx/__main__.py | 32 ++++++++++++---- midilifx/colors.py | 18 +-------- midilifx/midi.py | 59 ---------------------------- midilifx/modes/base.py | 29 ++++++++++++++ midilifx/modes/drums.py | 78 ++++++++++++++++++++++++++++++++++++++ midilifx/modes/keyboard.py | 70 ++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 83 deletions(-) delete mode 100644 midilifx/midi.py create mode 100644 midilifx/modes/base.py create mode 100644 midilifx/modes/drums.py create mode 100644 midilifx/modes/keyboard.py diff --git a/midilifx/__main__.py b/midilifx/__main__.py index 42c97f6..84c9937 100644 --- a/midilifx/__main__.py +++ b/midilifx/__main__.py @@ -7,7 +7,9 @@ from mido import open_input # pylint: disable=E0611 from mido.messages import BaseMessage -from midilifx.midi import midi_light +from midilifx.lights import LifxLight +from midilifx.modes.drums import DrumsMode +from midilifx.modes.keyboard import KeyboardMode LOG = logging.getLogger("midi-light") @@ -33,11 +35,22 @@ async def main(): ) with open_input(args.port, virtual=True) as inport: LOG.info("Opened virtual MIDI port %r", inport.name) - await midi_light( - midi_events=to_async_iterable(inport), - channels=args.channels, - initial_transition_duration=args.transition, - ) + if args.mode == "keyboard": + mode_cls = KeyboardMode + channels = args.channels or {0} + elif args.mode == "drums": + mode_cls = DrumsMode + channels = args.channels or {9} + else: + assert False + async with LifxLight(initial_transition_duration=args.transition) as light: + mode = mode_cls( + light=light, + event_iterator=to_async_iterable(inport), + channels=channels, + ) + LOG.info("Mode: %s", mode) + await mode.run() def get_psr(): @@ -55,7 +68,6 @@ def get_psr(): ) psr.add_argument( "-c", "--channels", - default={0}, type=int_set, help="Channel(s) to listen on, comma separated.", ) @@ -73,6 +85,12 @@ def get_psr(): action="store_true", help="Show debug logs.", ) + psr.add_argument( + "-m", "--mode", + choices=("drums", "keyboard"), + default="keyboard", + help="Mode to use." + ) return psr diff --git a/midilifx/colors.py b/midilifx/colors.py index 0c8d20e..98d551d 100644 --- a/midilifx/colors.py +++ b/midilifx/colors.py @@ -1,7 +1,7 @@ """Color related constants & utilities.""" import enum -from functools import lru_cache from typing import NamedTuple +from functools import lru_cache class HSLHue(enum.IntEnum): @@ -47,22 +47,6 @@ class HSLColor(NamedTuple): TOTAL_NOTES = len(NOTE_NAMES) -@lru_cache(maxsize=None) -def note_to_hsl(midi_note: int, velocity: int) -> HSLColor: - """Convert a midi note and velocity to a HSL color. - - The color is chosen based on Newton's color circle: - https://commons.wikimedia.org/wiki/File:Newton%27s_colour_circle.png - """ - note_name = NOTE_NAMES[midi_note % TOTAL_NOTES] - octave = (midi_note // TOTAL_NOTES) + 1 - return HSLColor( - hue=NEWTON_HUES[note_name], - saturation=velocity / 127 * 100, - lightness=octave / 11 * 100, - ) - - @lru_cache(maxsize=None) def pitch_to_temp(pitch: int, min_temp: int = 2500, max_temp: int = 9000) -> int: """Convert a MIDI pitch (-8192 to 8192) to a kelvin value between min_temp and max_temp.""" diff --git a/midilifx/midi.py b/midilifx/midi.py deleted file mode 100644 index d3c6f8f..0000000 --- a/midilifx/midi.py +++ /dev/null @@ -1,59 +0,0 @@ -"""The module at the center of the action, translates MIDI events for an Lifx light.""" -import logging -from typing import AsyncIterator - -from mido.messages import BaseMessage - -from midilifx.colors import note_to_hsl, pitch_to_temp -from midilifx.lights import LifxLight - -LOG = logging.getLogger(__name__) - -MIDI_CC_MODULATION = 1 - - -async def midi_light( - midi_events: AsyncIterator[BaseMessage], - channels: set[int], - initial_transition_duration: int = 0, - ): - """Changes the colors of a detected Lifx light based on MIDI messages.""" - assert channels, "midi_light is useless without channels" - # A dict of currently playing notes and their velocities - currently_playing: dict[int, int] = {} - async with LifxLight(initial_transition_duration=initial_transition_duration) as light: - LOG.info("Connected to %r (%s) at %s", light.name, light.product.name, light.ip_address) - LOG.info("Listening for MIDI events on channel(s) %s", channels) - async for evt in midi_events: - if getattr(evt, "channel", None) in channels: - LOG.debug("%s received on channel %d", evt.type, evt.channel) - match evt: - case BaseMessage(type="note_on") if evt.velocity: - currently_playing[evt.note] = evt.velocity - case BaseMessage(type="note_off"): - currently_playing.pop(evt.note, None) - case BaseMessage(type="pitchwheel"): - temp = pitch_to_temp( - pitch=evt.pitch, - min_temp=light.product.min_kelvin, - max_temp=light.product.max_kelvin, - ) - light.set_temperature(temp) - # Changing color temp. already updates the bulb. - # No need to call set_color below. - continue - case BaseMessage(type="control_change") if evt.control == MIDI_CC_MODULATION: - light.set_transition_duration(evt.value * 4) - # Changing transition duration does not trigger a change, - # it's for the next color change. No need to call set_color. - continue - case _: - continue - - LOG.debug("Currently playing notes: %s", currently_playing) - if play_list := list(currently_playing.items()): - # set the color to the first currently playing note and velocity - light.set_color(note_to_hsl(*play_list[0])) - else: - # turn "off" the light (ie set its brightness to 0), there is no note - light.set_color(None) diff --git a/midilifx/modes/base.py b/midilifx/modes/base.py new file mode 100644 index 0000000..9a06e60 --- /dev/null +++ b/midilifx/modes/base.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from typing import AsyncIterator +import logging + +from mido.messages import BaseMessage +from midilifx.lights import LifxLight +LOG = logging.getLogger(__name__) + + +class MidiLifxMode(ABC): + def __init__(self, + light: LifxLight, + event_iterator: AsyncIterator[BaseMessage], + channels: set[int] | None = None, + ) -> None: + self.event_iterator = event_iterator + self.channels = channels or {0} + self.light = light + + @abstractmethod + async def process_event(self, event: BaseMessage): + pass + + async def run(self): + async with self.light: + async for evt in self.event_iterator: + if getattr(evt, "channel", None) in self.channels: + LOG.debug("%s received on channel %d", evt.type, evt.channel) + await self.process_event(evt) diff --git a/midilifx/modes/drums.py b/midilifx/modes/drums.py new file mode 100644 index 0000000..26312bf --- /dev/null +++ b/midilifx/modes/drums.py @@ -0,0 +1,78 @@ +import logging +import time +from midilifx.modes.base import MidiLifxMode + +import asyncio +from functools import lru_cache +from mido.messages import BaseMessage +from typing import AsyncIterator +from collections import deque + + +from midilifx.colors import HSLColor +from midilifx.colors import NEWTON_HUES, NOTE_NAMES, TOTAL_NOTES +from midilifx.lights import LifxLight + +LOG = logging.getLogger(__name__) + + +@lru_cache(maxsize=None) +def drum_note_to_hsl(midi_note: int, velocity: int, intensity: float) -> HSLColor: + """Convert a midi note and velocity to a HSL color. + + The color is chosen based on Newton's color circle: + https://commons.wikimedia.org/wiki/File:Newton%27s_colour_circle.png + """ + note_name = NOTE_NAMES[midi_note % TOTAL_NOTES] + return HSLColor( + hue=NEWTON_HUES[note_name], + saturation=int(intensity * 100), + lightness=int(velocity / 127 * 100), + ) + + +def compute_intensity(timestamps: deque[float], max_freq: float = 5.0) -> float: + if len(timestamps) < 2: + return 0.0 + duration = timestamps[-1] - timestamps[0] + freq = (len(timestamps) - 1) / duration + if freq > max_freq: + return 1 + return freq / max_freq + + +class DrumsMode(MidiLifxMode): + + def __init__(self, + light: LifxLight, + event_iterator: AsyncIterator[BaseMessage], + channels: set[int] | None = None, + ) -> None: + super().__init__(light, event_iterator, channels) + self.turnoff_task: asyncio.Task | None = None + self.timestamps: deque[float] = deque(maxlen=10) + + async def _turn_off_after_delay(self, delay: float): + await asyncio.sleep(delay) + self.light.set_transition_duration(200) + self.light.set_color(None) + + def schedule_off(self): + if self.turnoff_task and not self.turnoff_task.done(): + self.turnoff_task.cancel() + self.turnoff_task = asyncio.create_task(self._turn_off_after_delay(.25)) + + async def process_event(self, event: BaseMessage): + if event.type == "note_on" and event.velocity: + self.timestamps.append(time.time()) + intentsity = compute_intensity(self.timestamps) + LOG.debug("intensity %s", intentsity) + hsl = drum_note_to_hsl( + midi_note=event.note, + velocity=event.velocity, + intensity=intentsity, + ) + self.light.set_transition_duration(20) + self.light.set_color(hsl) + self.schedule_off() + await asyncio.sleep(0) diff --git a/midilifx/modes/keyboard.py b/midilifx/modes/keyboard.py new file mode 100644 index 0000000..18d7c63 --- /dev/null +++ b/midilifx/modes/keyboard.py @@ -0,0 +1,70 @@ +from typing import AsyncIterator +import logging +from functools import lru_cache +from mido.messages import BaseMessage +from midilifx.lights import LifxLight +from midilifx.colors import NEWTON_HUES, NOTE_NAMES, TOTAL_NOTES, HSLColor, pitch_to_temp +from midilifx.modes.base import MidiLifxMode +LOG = logging.getLogger(__name__) + +MIDI_CC_MODULATION = 1 + + +@lru_cache(maxsize=None) +def note_to_hsl(midi_note: int, velocity: int) -> HSLColor: + """Convert a midi note and velocity to a HSL color. + + The color is chosen based on Newton's color circle: + https://commons.wikimedia.org/wiki/File:Newton%27s_colour_circle.png + """ + note_name = NOTE_NAMES[midi_note % TOTAL_NOTES] + octave = (midi_note // TOTAL_NOTES) + 1 + return HSLColor( + hue=NEWTON_HUES[note_name], + saturation=int(velocity / 127 * 100), + lightness=int(octave / 11 * 100), + ) + + +class KeyboardMode(MidiLifxMode): + + def __init__(self, + light: LifxLight, + event_iterator: AsyncIterator[BaseMessage], + channels: set[int] | None = None, + ) -> None: + super().__init__(light, event_iterator, channels) + # A dict of currently playing notes and their velocities + self.currently_playing: dict[int, int] = {} + + async def process_event(self, event: BaseMessage): + match event: + case BaseMessage(type="note_on") if event.velocity: + self.currently_playing[event.note] = event.velocity + case BaseMessage(type="note_off") | BaseMessage(type="note_on"): + self.currently_playing.pop(event.note, None) + case BaseMessage(type="pitchwheel"): + temp = pitch_to_temp( + pitch=event.pitch, + min_temp=self.light.product.min_kelvin, + max_temp=self.light.product.max_kelvin, + ) + self.light.set_temperature(temp) + # Changing color temp. already updates the bulb. + # No need to call set_color below. + return + case BaseMessage(type="control_change") if event.control == MIDI_CC_MODULATION: + self.light.set_transition_duration(event.value * 4) + # Changing transition duration does not trigger a change, + # it's for the next color change. No need to call set_color. + return + case _: + return + + LOG.debug("Currently playing notes: %s", self.currently_playing) + if play_list := list(self.currently_playing.items()): + # set the color to the first currently playing note and velocity + self.light.set_color(note_to_hsl(*play_list[0])) + else: + # turn "off" the light (ie set its brightness to 0), there is no note + self.light.set_color(None) From 910023f3f9b243a2ed2299b548fc0d1a7aeb256c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Noss?= Date: Tue, 11 Nov 2025 12:07:24 -1000 Subject: [PATCH 2/2] update test deps and add py313 target for tox --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 5fc4a31..d8e6f78 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ [tox] -envlist = py3{11,12} +envlist = py3{11,12,13} skip_missing_interpreters=true [testenv] deps = -rrequirements.txt - ruff==0.7.0 - flake8==7.1.1 - pylint==3.3.1 + ruff==0.14.4 + flake8==7.3.0 + pylint==4.0.2 set_env = PYTHONPATH=. commands = {envpython} -m pylint midilifx