Changeset - 0e90dd50e8c4
[Not reviewed]
default
0 3 0
drewp@bigasterisk.com - 8 months ago 2024-05-14 19:16:53
drewp@bigasterisk.com
midifade: improve the midi-event-skip behavior
3 files changed with 47 insertions and 26 deletions:
0 comments (0 inline, 0 general)
src/light9/midifade/eventqueue.py
Show inline comments
 
import asyncio
 
from dataclasses import dataclass
 
import logging
 
import traceback
 

	
 
log = logging.getLogger()
 

	
 
@dataclass
 
class Event:
 
    dev: str
 
    type: str
 
    control: int
 
    value: int
 
    def __repr__(self):
 
        return f"<Event {self.dev}/{self.type}/{self.control}->{self.value}>"
 

	
 

	
 
class EventQueue:
 
    """midi events come in fast; graph consumes them slower"""
 

	
 
    def __init__(self, MAX_SEND_RATE: float, onMessage) -> None:
 
        self.MAX_SEND_RATE = MAX_SEND_RATE
 
        self.onMessage = onMessage
 
        self.msgs = asyncio.Queue()
 
        self.newEvents: asyncio.Queue[Event] = asyncio.Queue()
 

	
 
    def callbackFor(self, dev):
 
        mainThreadLoop = asyncio.get_running_loop()
 

	
 
        def cb(message):
 
            # this is running in mido's thread
 
            log.info(f'enqueue {message} {"*" * message.dict()["value"]}')
 
            d = message.dict()
 
            ev = Event(dev, d['type'], d['control'], d['value'])
 
            log.info(f'enqueue {ev} {"*" * ev.value}')
 
            mainThreadLoop.call_soon_threadsafe(
 
                self.msgs.put_nowait,
 
                message.dict() | {'dev': dev},
 
                self.newEvents.put_nowait,
 
                ev,
 
            )
 

	
 
        return cb
 

	
 
    async def run(self):
 
        while True:
 
            recents = [await self.msgs.get()]
 
            while not self.msgs.empty():
 
                recents.append(self.msgs.get_nowait())
 
            recentEvents = [await self.newEvents.get()]
 
            while not self.newEvents.empty():
 
                recentEvents.append(self.newEvents.get_nowait())
 
            log.info(f'{recentEvents=}')
 
            try:
 
                for msg in reduceToLatestValue(recents):
 
                    # log.info(f'handle {msg=}')
 
                for msg in latestEventPerControl(recentEvents):
 
                    log.info(f'handle {msg=}')
 
                    await self.onMessage(msg)
 
            except Exception:
 
                traceback.print_exc()
 
                log.warning("error in onMessage- continuing anyway")
 
            await asyncio.sleep(1 / self.MAX_SEND_RATE)
 

	
 

	
 
def reduceToLatestValue(ms: list[dict]) -> list[dict]:
 
    merge = {}
 
    for m in ms:
 
        normal_key = tuple(sorted(dict((k, v) for k, v in m.items() if k != 'value')))
 
        merge[normal_key] = m
 
    return list(merge.values())
 
def latestEventPerControl(evs: list[Event]) -> list[Event]:
 
    ret = []
 
    seenControl = set()
 
    for ev in reversed(evs):
 
        c = (ev.dev, ev.control)
 
        if c in seenControl:
 
            continue
 
        seenControl.add(c)
 
        ret.append(ev)
 
    ret.reverse()
 
    return ret
src/light9/midifade/midifade.py
Show inline comments
 
@@ -2,25 +2,25 @@
 
Read midi events, write fade levels to graph
 

	
 
Device troubleshooting:
 
    amidi -l
 
"""
 
import asyncio
 
from functools import partial
 
import logging
 

	
 
import mido
 
from light9 import networking
 
from light9.effect.edit import clamp
 
from light9.midifade.eventqueue import EventQueue
 
from light9.midifade.eventqueue import Event, EventQueue
 
from light9.midifade.mididevs import connectToMidiOutput, listenToMidiInputs
 
from light9.midifade.pages import Pages
 
from light9.namespaces import L9
 
from light9.newtypes import decimalLiteral
 
from light9.run_local import log
 
from light9.showconfig import showUri
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
from rdflib import URIRef
 

	
 

	
 
# inline of patchObject to make it async, not just create_task
 
async def asyncPatchObject(graph, context: URIRef, subject, predicate: URIRef, newObject):
 
@@ -35,46 +35,46 @@ async def writeHwValueToGraph(graph: Syn
 
    valueLit = decimalLiteral(round(strength, 3))
 
    with graph.currentState() as g:
 
        fadeSet = g.value(fader, L9['setting'])
 
    if fadeSet is None:
 
        raise ValueError(f'fader {fader} has no :setting')
 
    await asyncPatchObject(graph, ctx, fadeSet, L9['value'], valueLit)
 

	
 

	
 
def changeGrandMaster(graph: SyncedGraph, newValue: float, ctx: URIRef):
 
    graph.patchObject(ctx, L9.grandMaster, L9['value'], decimalLiteral(newValue))
 

	
 

	
 
async def onMessage(graph: SyncedGraph, pages: Pages, ctx: URIRef, _lastSet: dict[int, int], m: dict):
 
    if m['type'] == 'active_sensing':
 
async def onMessage(graph: SyncedGraph, pages: Pages, ctx: URIRef, _lastSet: dict[int, int], m: Event):
 
    if m.type == 'active_sensing':
 
        return
 
    if m['type'] == 'control_change':
 
        if m['dev'] == 'bcf2000' and m['control'] == 91:
 
    if m.type == 'control_change':
 
        if m.dev == 'bcf2000' and m.control == 91:
 
            pages.changePage(-1)
 
            return
 
        if m['dev'] == 'bcf2000' and m['control'] == 92:
 
        if m.dev == 'bcf2000' and m.control == 92:
 
            pages.changePage(1)
 
            return
 
        if m['dev'] == 'bcf2000' and m['control'] == 8:
 
            changeGrandMaster(graph, clamp(m['value'] / 127 * 1.5, 0, 1), ctx)
 
        if m.dev == 'bcf2000' and m.control == 8:
 
            changeGrandMaster(graph, clamp(m.value / 127 * 1.5, 0, 1), ctx)
 
            return
 

	
 
        try:
 
            fader = pages.lookupFader(m['dev'], m['control'])
 
            fader = pages.lookupFader(m.dev, m.control)
 
        except KeyError:
 
            log.info(f'unknown control {m}')
 
            return
 
        try:
 
            await writeHwValueToGraph(graph, ctx, fader, m['value'] / 127)
 
            _lastSet[m['control']] = m['value']
 
            await writeHwValueToGraph(graph, ctx, fader, m.value / 127)
 
            _lastSet[m.control] = m.value
 
        except ValueError as e:
 
            log.warning(f'{e!r} - ignoring')
 
    else:
 
        log.info(f'unhandled message {m}')
 

	
 

	
 
async def main():
 
    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
 
    logging.getLogger('syncedgraph').setLevel(logging.INFO)
 
    logging.getLogger('graphedit').setLevel(logging.INFO)
 

	
 
    mido.set_backend('alsa_midi.mido_backend')
src/light9/midifade/pages.py
Show inline comments
 
@@ -61,28 +61,31 @@ class Pages:
 

	
 
    def getGraphMappingNode(self, g: ReadOnlyConjunctiveGraph | SyncedGraph) -> URIRef:
 
        mapping = g.value(L9['midiControl'], L9['map'])
 
        if mapping is None:
 
            raise ValueError('no :midiControl :map ?mapping')
 
        midiDev = g.value(mapping, L9['midiDev'])
 
        ourDev = 'bcf2000'
 
        if midiDev != Literal(ourDev):
 
            raise NotImplementedError(f'need {mapping} to have :midiDev {ourDev!r}')
 
        return mapping
 

	
 
    def compileCurrents(self):
 
        log.info('compileCurrents')
 
        self.currentFaders.clear()
 
        try:
 
            new = self.getChansToFaders()
 
        except ValueError:
 
            return  # e.g. empty-graph startup
 
        self.currentFaders.update(new)
 

	
 
    def lookupFader(self, dev: str, control: int) -> URIRef:
 
        if not self.currentFaders:
 
            log.warn("lookupFader called when we had no current control->fader mapping")
 
        return {
 
            'quneo': {
 
                44: L9['show/dance2023/fadePage1f0'],
 
                45: L9['show/dance2023/fadePage1f0'],
 
                46: L9['show/dance2023/fadePage1f0'],
 
            },
 
            'bcf2000': self.currentFaders,
 
        }[dev][control]
0 comments (0 inline, 0 general)