Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions midilifx/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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():
Expand All @@ -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.",
)
Expand All @@ -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


Expand Down
18 changes: 1 addition & 17 deletions midilifx/colors.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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."""
Expand Down
59 changes: 0 additions & 59 deletions midilifx/midi.py

This file was deleted.

29 changes: 29 additions & 0 deletions midilifx/modes/base.py
Original file line number Diff line number Diff line change
@@ -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)
78 changes: 78 additions & 0 deletions midilifx/modes/drums.py
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 70 additions & 0 deletions midilifx/modes/keyboard.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 4 additions & 4 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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
Expand Down