Changeset - 0761cdd5bff0
[Not reviewed]
default
0 5 0
drewp@bigasterisk.com - 8 months ago 2024-05-31 18:52:47
drewp@bigasterisk.com
fix delay on setting midi, so it doesn't grab faders when you're sliding them
5 files changed with 79 insertions and 40 deletions:
0 comments (0 inline, 0 general)
src/light9/midifade/eventqueue.py
Show inline comments
 
import asyncio
 
import logging
 
import time
 
import traceback
 
from dataclasses import dataclass
 

	
 
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:
 
    def __init__(self, MAX_SEND_RATE: float, _lastMidiInTime, onMessage) -> None:
 
        self.MAX_SEND_RATE = MAX_SEND_RATE
 
        self._lastMidiInTime = _lastMidiInTime
 
        self.onMessage = onMessage
 
        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
 
            d = message.dict()
 
            ev = Event(dev, d['type'], d['control'], d['value'])
 
            log.info(f'enqueue {ev} {"*" * ev.value}')
 
            self._lastMidiInTime[ev.control] = time.time()
 
            mainThreadLoop.call_soon_threadsafe(
 
                self.newEvents.put_nowait,
 
                ev,
 
            )
 

	
 
        return cb
 

	
 
    async def run(self):
 
        while True:
 
            recentEvents = [await self.newEvents.get()]
 
            while not self.newEvents.empty():
 
                recentEvents.append(self.newEvents.get_nowait())
 
            log.info(f'{recentEvents=}')
 
            try:
 
                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 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/mididevs.py
Show inline comments
 
import logging
 

	
 
import mido
 
from light9.midifade.eventqueue import EventQueue
 
from light9.midifade.pages import Pages
 
from light9.midifade.writeback import WriteBackFaders
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 

	
 
log = logging.getLogger()
 

	
 
_csb=[]
 
openPorts = []
 
_openPorts = []
 

	
 

	
 
def listenToMidiInputs(q: EventQueue):
 
    """put midi events on EventQueue (presumably mido does this in a bg
 
    thread)"""
 
    
 

	
 
    for inputName in mido.get_input_names():  # type: ignore
 
        if inputName.startswith('Keystation'):
 
            dev = "keystation"
 
        elif inputName.startswith('BCF2000'):
 
            dev = 'bcf2000'
 
        elif inputName.startswith('QUNEO'):
 
            dev = 'quneo'
 
        else:
 
            continue
 
        log.info(f'listening on input {inputName} {dev=}')
 
        cb = q.callbackFor(dev)
 
        _csb.append(cb)
 
        openPorts.append(mido.open_input(  # type: ignore
 
        _openPorts.append(mido.open_input(  # type: ignore
 
            inputName,  #
 
            callback=cb))
 

	
 

	
 
def connectToMidiOutput(graph: SyncedGraph, pages: Pages, _lastSet: dict[int, int]):
 
def connectToMidiOutput(graph: SyncedGraph, pages: Pages, _lastSet: dict[int, int], _lastMidiInTime: dict[int, float]):
 
    for outputName in mido.get_output_names():  # type: ignore
 
        if outputName.startswith('BCF2000'):
 
            bcf_out = mido.open_output(outputName)  # type: ignore
 
            wb = WriteBackFaders(graph, pages, bcf_out, _lastSet)
 
            graph.addHandler(wb.update)
 
            wb = WriteBackFaders(graph, pages, bcf_out, _lastSet, _lastMidiInTime, {})
 
            graph.addHandler(wb.onGraphPatch)
 
            break
src/light9/midifade/midifade.py
Show inline comments
 
@@ -37,62 +37,63 @@ async def writeHwValueToGraph(graph: Syn
 
        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: Event):
 
    if m.type == 'active_sensing':
 
        return
 
    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:
 
            pages.changePage(1)
 
            return
 
        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)
 
        except KeyError:
 
            log.info(f'unknown control {m}')
 
            return
 
        try:
 
            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')
 

	
 
    MAX_SEND_RATE = 50
 

	
 
    _lastSet = {}  #midictlchannel:value7bit
 
    _lastMidiInTime = {} # midictlchannel:time
 

	
 
    ctx = URIRef(showUri() + '/fade')
 
    graph = SyncedGraph(networking.rdfdb.url, "midifade")
 
    pages = Pages(graph, ctx)
 
    queue = EventQueue(MAX_SEND_RATE, partial(onMessage, graph, pages, ctx, _lastSet))
 
    queue = EventQueue(MAX_SEND_RATE, _lastMidiInTime, partial(onMessage, graph, pages, ctx, _lastSet))
 
    listenToMidiInputs(queue)
 
    connectToMidiOutput(graph, pages, _lastSet)
 
    connectToMidiOutput(graph, pages, _lastSet, _lastMidiInTime)
 
    graph.addHandler(pages.compileCurrents)
 
    # todo: serve fps metrics, at least
 
    await queue.run()
 

	
 

	
 
if __name__ == '__main__':
 
    asyncio.run(main())
src/light9/midifade/pages.py
Show inline comments
 
import logging
 
from typing import cast
 

	
 
from light9.effect.edit import clamp
 
from light9.namespaces import L9
 
from rdfdb.syncedgraph.readonly_graph import ReadOnlyConjunctiveGraph
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
from rdflib import RDF, Literal, URIRef
 

	
 
log = logging.getLogger()
 

	
 

	
 
class Pages:
 
    """converts between fader numbers and FaderUri, which is a mapping that can
 
    be changed by moving between pages"""
 

	
 
    def __init__(self, graph: SyncedGraph, ctx: URIRef):
 
        self.graph = graph
 
        self.ctx = ctx
 

	
 
        self.currentFaders = {}  # midi control channel num : FaderUri
 

	
 
    def getChansToFaders(self) -> dict[int, URIRef]:
 
        fadePage = self.getCurMappedPage()
 
        try:
 
            fadePage = self.getCurMappedPage()
 
        except ValueError:
 
            log.warning("skip", exc_info=True)
 
            return {}
 
        ret = []
 
        for f in self.graph.objects(fadePage, L9.fader):
 
            columnLit = cast(Literal, self.graph.value(f, L9['column']))
 
            col = int(columnLit.toPython())
 
            ret.append((col, f))
 

	
 
        ret.sort()
 
        ctl_channels = list(range(81, 88 + 1))
 
        out = {}
 
        for chan, (col, f) in zip(ctl_channels, ret):
 
            out[chan] = f
 
        return out
 

	
 
    def changePage(self, dp: int):
 
        """dp==-1, make the previous page active, etc. Writes to graph"""
 

	
 
        with self.graph.currentState() as current:
 
            allPages = sorted(current.subjects(RDF.type, L9.FadePage), key=lambda fp: str(fp))
 
            mapping = self.getGraphMappingNode(current)
 
            curPage = current.value(mapping, L9.outputs)
 
        if curPage is None:
 
            curPage = allPages[0]
 
        idx = allPages.index(curPage)
 
        newIdx = clamp(idx + dp, 0, len(allPages) - 1)
 
        log.info(f'change from {idx} {newIdx}')
 
        newPage = allPages[newIdx]
 
        self.setCurMappedPage(mapping, newPage)
 

	
 
    def getCurMappedPage(self) -> URIRef:
 
        mapping = self.getGraphMappingNode(self.graph)
 
        ret = self.graph.value(mapping, L9['outputs'])
 
        assert ret is not None
 
        return ret
 

	
 
    def setCurMappedPage(self, mapping: URIRef, newPage: URIRef):
 
        self.graph.patchObject(self.ctx, mapping, L9.outputs, newPage)
 

	
 
    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):
src/light9/midifade/writeback.py
Show inline comments
 
import asyncio
 
from functools import partial
 
import logging
 
from dataclasses import dataclass
 
import time
 
from typing import cast
 

	
 
from light9.typedgraph import typedValue
 
import mido
 
from debouncer import DebounceOptions, debounce
 
from light9.midifade.pages import Pages
 
from light9.namespaces import L9
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
from rdflib import Literal
 
from rdflib import Literal, URIRef
 

	
 
log = logging.getLogger()
 

	
 

	
 
class FaderSyncer:
 
    """get this midi output set to the right value
 
    
 
    todo: note that these could be active at the moment we switch fader pages,
 
    which might result in the wrong output
 
    """
 

	
 
    def __init__(self, fader: URIRef, getHwValue, getGraphValue, setHwValue, getLastMidiInTime):
 
        self.fader = fader
 
        self.getHwValue = getHwValue
 
        self.getGraphValue = getGraphValue
 
        self.setHwValue = setHwValue
 
        self.getLastMidiInTime = getLastMidiInTime
 
        self.done = False
 
        asyncio.create_task(self.task())
 

	
 
    async def task(self):
 
        try:
 
            await self._waitForPauseInInput()
 
            
 
            goal = self.getGraphValue()
 
            hw = self.getHwValue()
 
            if abs(goal - hw) > 2:
 
                log.info(f'hw at {hw} {goal=} -> send to hw')
 
                self.setHwValue(goal)
 
        finally:
 
            self.done = True
 

	
 
    async def _waitForPauseInInput(self):
 
        while True:
 
            if time.time() > self.getLastMidiInTime() + 1:
 
                return
 
            await asyncio.sleep(.05)
 

	
 

	
 

	
 
@dataclass
 
class WriteBackFaders:
 
    graph: SyncedGraph
 
    pages: Pages
 
    bcf_out: mido.ports.BaseOutput
 
    _lastSet: dict[int, int]
 
    _lastMidiInTime: dict[int, float]
 
    _syncer: dict[int, FaderSyncer]
 

	
 
    def getCurrentValue(self, f):
 
        return self._lastSet.get(f, 0)
 

	
 
    def update(self):
 
        try:
 
            asyncio.create_task(self._update())
 
        except ValueError as e:
 
            log.warning(repr(e))
 
    def onGraphPatch(self):
 
        for midi_ctl_addr, f in self.pages.getChansToFaders().items():
 
            fset = typedValue(URIRef, self.graph, f, L9.setting)
 
            self.graph.value(fset, L9.value)  # dependency for handler
 

	
 
    async def _update(self):
 
        # to make this work again:
 
        #  - track the goal of all sliders
 
        #  - in a debounced handler, sendToBcf
 
        return
 
        g = self.graph
 
        nupdated = 0
 
        m = self.pages.getChansToFaders()
 
        for midi_ctl_addr, f in m.items():
 
            fset = g.value(f, L9.setting)
 
            # could split this to a separate handler per fader
 
            existingSyncer = self._syncer.get(midi_ctl_addr, None)
 
            if not existingSyncer or existingSyncer.done:
 
                self._syncer[midi_ctl_addr] = FaderSyncer(
 
                    f,
 
                    partial(self.getCurrentValue, midi_ctl_addr),
 
                    partial(self.getMidiValue, fset),
 
                    partial(self.sendToBcf, midi_ctl_addr),
 
                    partial(self._lastMidiInTime.get, midi_ctl_addr, 0),
 
                )
 

	
 
    def getMidiValue(self, fset: URIRef) -> int:
 
        with self.graph.currentState() as g:
 
            value = cast(Literal, g.value(fset, L9.value)).toPython()
 
            hwcurrent = self.getCurrentValue(midi_ctl_addr)
 
            hwgoal = int(value * 127)
 
            maxValueDiff = 3
 
            # todo: 3 is no good; we should correct after a short time of no
 
            # movement
 
            if abs(hwcurrent - hwgoal) > maxValueDiff:
 
                log.info(f'writing back to {f} {hwcurrent=} {hwgoal=}')
 
                self.sendToBcf(midi_ctl_addr, hwgoal)
 
                nupdated += 1
 
        if nupdated > 0:
 
            log.info(f'wrote to {nupdated} of {len(m)} mapped faders')
 
            return int(value * 127)
 

	
 
    def sendToBcf(self, control: int, value: int):
 
        self._lastSet[control] = value
 
        msg = mido.Message('control_change', control=control, value=value)
 
        self.bcf_out.send(msg)
0 comments (0 inline, 0 general)