Source code for jvconnected.interfaces.midi.mapper

from loguru import logger

from typing import (
    Any, Union, Dict, Sequence, Optional, ClassVar, Iterator, Tuple,
)
import dataclasses
from dataclasses import dataclass

[docs]@dataclass class Map: """Stores information for mapping MIDI messages to :class:`~jvconnected.interfaces.paramspec.ParameterGroupSpec` definitions """ name: str = '' """The :attr:`~.paramspec.ParameterSpec.name` of the parameter within its :class:`~.paramspec.ParameterGroupSpec` """ group_name: str = '' """The :attr:`~.paramspec.ParameterGroupSpec.name` of the :class:`~.paramspec.ParameterGroupSpec` """ full_name: str = '' """Combination of :attr:`group_name` and :attr:`name`, separated by a "." ``"{group_name}.{name}"`` """ map_type: ClassVar[str] = '' """A unique name to identify subclasses """ index: int = -1 """The map index If not set (or ``-1``), this will be assigned by :class:`MidiMapper` when the instance is added to it """ is_14_bit: ClassVar[bool] = False """True if the map uses 14 bit values """ def __post_init__(self): if not self.full_name: self.full_name = '.'.join([self.group_name, self.name]) else: group_name, name = self.full_name.split('.') if not self.group_name: self.group_name = group_name if not self.name: self.name = name assert self.group_name == group_name assert self.name == name @classmethod def get_class_for_map_type(cls, map_type: str) -> 'Map': def iter_subcls(_cls): if _cls is not Map: yield _cls for subcls in _cls.__subclasses__(): yield from iter_subcls(subcls) for _cls in iter_subcls(Map): if _cls.map_type == map_type: return _cls raise ValueError(f'No subclass found with map_type "{map_type}"')
[docs]@dataclass class ControllerMap(Map): controller: int = 0 #: The Midi controller number for the mapping map_type: ClassVar[str] = 'controller'
[docs]@dataclass class Controller14BitMap(ControllerMap): map_type: ClassVar[str] = 'controller/14' is_14_bit: ClassVar[bool] = True def __post_init__(self): super().__post_init__() assert self.controller < 0x20 @property def controller_msb(self) -> int: """The controller index containing the most-significant 7 bits This will always be equal to the :attr:`controller` value """ return self.controller @property def controller_lsb(self) -> int: """The controller index containing the least-significant 7 bits Per the MIDI 1.0 specification, this will be :attr:`controller_msb` + 32 """ return self.controller + 32
[docs]@dataclass class NoteMap(Map): note: int = 0 #: The Midi note number for the mapping map_type: ClassVar[str] = 'note'
[docs]@dataclass class AdjustControllerMap(Map): controller: int = 0 #: The Midi controller number for the mapping map_type: ClassVar[str] = 'adjust_controller'
MapOrDict = Union[Map, Dict] DEFAULT_MAPPING: Sequence[Map] = ( Controller14BitMap(group_name='exposure', name='iris_pos', controller=0), AdjustControllerMap(group_name='exposure', name='master_black_pos', controller=1), AdjustControllerMap(group_name='exposure', name='gain_pos', controller=2), ControllerMap(group_name='paint', name='red_normalized', controller=3), ControllerMap(group_name='paint', name='blue_normalized', controller=4), AdjustControllerMap(group_name='paint', name='detail_pos', controller=5), NoteMap(group_name='tally', name='preview', note=126), NoteMap(group_name='tally', name='program', note=127), ) """Default Midi mapping The mapping uses the following layout for each camera index (where the channels will become the camera index) .. csv-table:: :header: "Index", "Parameter", "Type", "Controller/Note" :widths: auto 0, "Iris", :class:`Controller14BitMap`, "0 (MSB), 32 (LSB)" 1, "Master Black", :class:`AdjustControllerMap`, 1 2, "Gain", :class:`AdjustControllerMap`, 2 3, "Red Paint", :class:`ControllerMap`, 3 4, "Blue Paint", :class:`ControllerMap`, 4 5, "Detail", :class:`AdjustControllerMap`, 5 6, "PGM Tally", :class:`NoteMap`, 126 7, "PVW Tally", :class:`NoteMap`, 127 """
[docs]class MidiMapper: """Container for MIDI mapping definitions Arguments: maps: If given, a sequence of either :class:`Map` instances or :class:`dicts <dict>` to pass to the :meth:`add_map` method. If not provided, the :data:`DEFAULT_MAPPING` will be used. The maps can be accessed by their :attr:`~Map.full_name` using :class:`dict` methods. >>> from jvconnected.interfaces.midi.mapper import MidiMapper, ControllerMap, NoteMap >>> mapper = MidiMapper() >>> gain = mapper['exposure.gain_pos'] >>> print(gain) AdjustControllerMap(name='gain_pos', group_name='exposure', full_name='exposure.gain_pos', index=2, controller=2) >>> mapper.get('exposure.gain_pos') AdjustControllerMap(name='gain_pos', group_name='exposure', full_name='exposure.gain_pos', index=2, controller=2) >>> 'exposure.gain_pos' in mapper True When iterating over the mapper, either directly or through the :meth:`keys`, :meth:`values` or :meth:`items` methods, the results will be sorted by their :attr:`~Map.group_name` then their :attr:`~Map.name` attributes >>> [key for key in mapper] #doctest: +NORMALIZE_WHITESPACE ['exposure.gain_pos', 'exposure.iris_pos', 'exposure.master_black_pos', 'paint.blue_normalized', 'paint.detail_pos', 'paint.red_normalized', 'tally.preview', 'tally.program'] >>> [map_obj.full_name for map_obj in mapper.values()] #doctest: +NORMALIZE_WHITESPACE ['exposure.gain_pos', 'exposure.iris_pos', 'exposure.master_black_pos', 'paint.blue_normalized', 'paint.detail_pos', 'paint.red_normalized', 'tally.preview', 'tally.program'] Maps can also be sorted by their :attr:`indices <Map.index>` using the :meth:`iter_indexed` method >>> [map_obj.full_name for map_obj in mapper.iter_indexed()] #doctest: +NORMALIZE_WHITESPACE ['exposure.iris_pos', 'exposure.master_black_pos', 'exposure.gain_pos', 'paint.red_normalized', 'paint.blue_normalized', 'paint.detail_pos', 'tally.preview', 'tally.program'] >>> [map_obj.index for map_obj in mapper.values()] [2, 0, 1, 4, 5, 3, 6, 7] By default, MidiMapper will use a set of :data:`predefined maps <DEFAULT_MAPPING>` when initialized. This can be overridden by passing a sequence of map definitions (or an empty one) when creating it >>> mapper = MidiMapper([]) >>> len(mapper) 0 Then use :meth:`add_map` to create maps using a :class:`dict` >>> pgm_tally = mapper.add_map(dict(map_type='note', full_name='tally.program', note=127)) >>> mapper['tally.program'] NoteMap(name='program', group_name='tally', full_name='tally.program', index=0, note=127) Or existing :class:`Map` instances >>> pvw_tally = NoteMap(group_name='tally', name='preview', note=126) >>> pvw_tally NoteMap(name='preview', group_name='tally', full_name='tally.preview', index=-1, note=126) >>> mapper.add_map(pvw_tally) #doctest: +IGNORE_RESULT >>> mapper['tally.preview'] NoteMap(name='preview', group_name='tally', full_name='tally.preview', index=1, note=126) >>> mapper['tally.preview'] is pvw_tally True """ map: Dict[str, Map] """The :class:`Map` definitions stored using their :attr:`~Map.full_name` as keys """ map_grouped: Dict[str, Dict[str, Map]] """The :class:`Map` definitions stored as nested dicts by :attr:`~Map.group_name` and :attr:`~Map.name` """ map_by_index: Dict[int, Map] """The :class:`Map` definitions stored using their :attr:`~Map.index` as keys """ def __init__(self, maps: Optional[Sequence[MapOrDict]] = None): self.map = {} self.map_grouped = {} self.map_by_index = {} if maps is None: maps = DEFAULT_MAPPING for map_obj in maps: self.add_map(map_obj)
[docs] def add_map(self, map_obj: MapOrDict) -> Map: """Add or create a :class:`Map` definition * If the given argument is a :class:`dict`, it must contain a value for "map_type" as described in the :meth:`create_map` method, with the remaining items passed as keyword arguments. * If the given argument is a :class:`Map` instance, it is added using :meth:`add_map_obj`. """ if isinstance(map_obj, dict): map_type = map_obj.pop('map_type') map_obj = self.create_map(map_type, **map_obj) self.add_map_obj(map_obj) return map_obj
[docs] def create_map(self, map_type: str, **kwargs) -> Map: """Create a :class:`Map` with the given arguments and add it Arguments: map_type (str): The :attr:`~Map.map_type` of the :class:`Map` subclass to create **kwargs: Keyword arguments used to create the instance """ cls = Map.get_class_for_map_type(map_type) kw = kwargs.copy() obj = cls(**kw) return obj
[docs] def add_map_obj(self, map_obj: Map): """Add an existing :class:`Map` instance """ if map_obj.index == -1 or map_obj.index in self.map_by_index: if not len(self): map_obj.index = 0 else: map_obj.index = max(self.map_by_index.keys()) + 1 self.map[map_obj.full_name] = map_obj self.map_by_index[map_obj.index] = map_obj if map_obj.group_name not in self.map_grouped: self.map_grouped[map_obj.group_name] = {} self.map_grouped[map_obj.group_name][map_obj.name] = map_obj
[docs] def get(self, full_name: str) -> Optional[Map]: """Get the :class:`Map` instance matching the given :attr:`~Map.full_name` If not found, ``None`` is returned """ return self.map.get(full_name)
def __getitem__(self, full_name: str) -> Map: return self.map[full_name]
[docs] def keys(self) -> Iterator[str]: """Iterate over all the :attr:`~Map.full_name` of all stored instances This will be sorted first by :attr:`~Map.group_name`, then by :attr:`~Map.name` """ for grp_key in sorted(self.map_grouped.keys()): d = self.map_grouped[grp_key] for key in sorted(d.keys()): yield d[key].full_name
[docs] def values(self) -> Iterator[Map]: """Iterate over all stored instances, sorted as described in :meth:`keys` """ for key in self: yield self[key]
[docs] def items(self) -> Iterator[Tuple[str, Map]]: """Iterate over pairs of :meth:`keys` and :meth:`values` """ for key in self: yield key, self[key]
[docs] def iter_indexed(self) -> Iterator[Map]: """Iterate over all stored instances, sorted by their :attr:`~Map.index` """ for ix in sorted(self.map_by_index.keys()): yield self.map_by_index[ix]
def __iter__(self): return self.keys() def __len__(self): return len(self.map) def __contains__(self, key: str): return key in self.map def serialize(self): d = {'maps':[]} for map_obj in self.values(): _d = dataclasses.asdict(map_obj) _d['map_type'] = map_obj.map_type d['maps'].append(_d) return d