Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
All rights reserved.
|
||||
|
||||
Copyright (c) Thomas / TuxiNet.
|
||||
|
||||
Dette softwareprojekt og alle tilhorende filer er proprietaere.
|
||||
Ingen del ma kopieres, distribueres eller sublicenseres uden skriftlig tilladelse.
|
||||
@@ -0,0 +1,59 @@
|
||||
# TuxDMX MIDI Bridge
|
||||
|
||||
Standalone Python-bro, som lytter på en lokal USB-MIDI-controller og sender normaliserede MIDI-events videre til TuxDMX over HTTP.
|
||||
|
||||
## Krav
|
||||
|
||||
- Python 3.11+
|
||||
- En MIDI-inputenhed, som kan læses af `mido` / `python-rtmidi`
|
||||
- En TuxDMX-instans med en gyldig MIDI-token
|
||||
|
||||
## Hurtig start
|
||||
|
||||
### Windows
|
||||
|
||||
```bat
|
||||
install-windows.bat
|
||||
copy config.example.yaml config.yaml
|
||||
run-windows.bat config.yaml
|
||||
```
|
||||
|
||||
Autostart kan laves via Windows Task Scheduler med:
|
||||
|
||||
```bat
|
||||
.venv\Scripts\python.exe -m tuxdmx_midi_bridge --config C:\path\to\config.yaml
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
./install-linux.sh
|
||||
cp config.example.yaml config.yaml
|
||||
./run-linux.sh config.yaml
|
||||
```
|
||||
|
||||
Eksempel på systemd-service ligger i `tuxdmx-midi-bridge.service.example`.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python -m tuxdmx_midi_bridge --config config.yaml
|
||||
python -m tuxdmx_midi_bridge --list-devices
|
||||
python -m tuxdmx_midi_bridge --test-connection --config config.yaml
|
||||
python -m tuxdmx_midi_bridge --debug --config config.yaml
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
- `server.url`: HTTP-base til TuxDMX bridge-endpoints, fx `http://host:8000/api/v1/integrations/midi/bridge`
|
||||
- `server.api_token`: kan overstyres af `TUXDMX_API_TOKEN`
|
||||
- `midi.device_name`: delnavn der matches mod tilgængelige MIDI-inputs
|
||||
- `midi.channel_filter`: sæt til `0-15` eller `null`
|
||||
- `performance.control_change_interval_ms`: rate limit på fadere og knapper
|
||||
- `performance.suppress_duplicate_values`: ignorer gentagne identiske værdier
|
||||
|
||||
## Sikkerhed
|
||||
|
||||
- Token logges aldrig i klartekst.
|
||||
- TLS-verifikation er standard og slås kun fra eksplicit i konfigurationen.
|
||||
- Bridge sender kun MIDI-events og heartbeats. Den genererer ikke DMX direkte.
|
||||
@@ -0,0 +1,20 @@
|
||||
bridge_id: "bar-laptop"
|
||||
|
||||
server:
|
||||
url: "http://192.168.2.50:8000/api/v1/integrations/midi/bridge"
|
||||
api_token: "CHANGE_ME"
|
||||
reconnect_seconds: 3
|
||||
verify_tls: true
|
||||
|
||||
midi:
|
||||
device_name: "USB MIDI"
|
||||
channel_filter: null
|
||||
ignore_active_sensing: true
|
||||
|
||||
performance:
|
||||
control_change_interval_ms: 25
|
||||
suppress_duplicate_values: true
|
||||
|
||||
logging:
|
||||
level: "INFO"
|
||||
show_midi_events: true
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements.txt
|
||||
|
||||
if [[ ! -f config.yaml ]]; then
|
||||
cp config.example.yaml config.yaml
|
||||
fi
|
||||
|
||||
echo "Installeret. Rediger config.yaml og start med ./run-linux.sh"
|
||||
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
py -3 -m venv .venv
|
||||
call ".venv\Scripts\python.exe" -m pip install --upgrade pip
|
||||
call ".venv\Scripts\python.exe" -m pip install -r requirements.txt
|
||||
|
||||
if not exist "config.yaml" copy /Y "config.example.yaml" "config.yaml" >nul
|
||||
|
||||
echo Installeret. Rediger config.yaml og start med run-windows.bat
|
||||
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tuxdmx-midi-bridge"
|
||||
version = "1.0.0"
|
||||
description = "Standalone MIDI bridge for TuxDMX."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mido>=1.3.2,<2",
|
||||
"python-rtmidi>=1.5.8,<2",
|
||||
"httpx>=0.28,<0.29",
|
||||
"PyYAML>=6.0,<7",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,4 @@
|
||||
mido>=1.3.2,<2
|
||||
python-rtmidi>=1.5.8,<2
|
||||
httpx>=0.28,<0.29
|
||||
PyYAML>=6.0,<7
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [[ ! -d .venv ]]; then
|
||||
echo "Virtuelt miljø mangler. Kør ./install-linux.sh først." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec .venv/bin/python -m tuxdmx_midi_bridge --config "${1:-config.yaml}"
|
||||
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Virtuelt miljoe mangler. Koer install-windows.bat foerst.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
".venv\Scripts\python.exe" -m tuxdmx_midi_bridge --config "%~1"
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tuxdmx_midi_bridge.config import load_config
|
||||
|
||||
|
||||
def test_load_config_prefers_env_token(monkeypatch, tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
bridge_id: bridge-a
|
||||
server:
|
||||
url: "http://127.0.0.1:8000/api/v1/integrations/midi/bridge"
|
||||
api_token: "yaml-token"
|
||||
midi:
|
||||
device_name: "USB MIDI"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("TUXDMX_API_TOKEN", "env-token")
|
||||
config = load_config(config_path)
|
||||
assert config.server.api_token == "env-token"
|
||||
assert config.bridge_id == "bridge-a"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tuxdmx_midi_bridge.midi import MidiEventFilter, normalize_message
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeMessage:
|
||||
type: str
|
||||
channel: int
|
||||
note: int | None = None
|
||||
control: int | None = None
|
||||
program: int | None = None
|
||||
velocity: int | None = None
|
||||
value: int | None = None
|
||||
|
||||
|
||||
def test_normalize_note_on() -> None:
|
||||
payload = normalize_message(
|
||||
FakeMessage(type="note_on", channel=0, note=36, velocity=127),
|
||||
"bridge-a",
|
||||
"USB MIDI",
|
||||
)
|
||||
assert payload is not None
|
||||
assert payload["message"]["type"] == "note_on"
|
||||
assert payload["message"]["number"] == 36
|
||||
assert payload["message"]["value"] == 127
|
||||
|
||||
|
||||
def test_filter_suppresses_duplicates_and_rate_limits_control_change() -> None:
|
||||
event_filter = MidiEventFilter(channel_filter=None, control_change_interval_ms=25, suppress_duplicate_values=True)
|
||||
payload = {
|
||||
"message": {"type": "control_change", "channel": 0, "number": 14, "value": 64}
|
||||
}
|
||||
assert event_filter.should_forward(payload) is True
|
||||
assert event_filter.should_forward(payload) is False
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from tuxdmx_midi_bridge.client import reconnect_delay
|
||||
|
||||
|
||||
def test_reconnect_delay_is_bounded() -> None:
|
||||
assert reconnect_delay(1, 3) == 3
|
||||
assert reconnect_delay(20, 3) == 30
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=TuxDMX MIDI Bridge
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=tuxdmx
|
||||
WorkingDirectory=/opt/tuxdmx-midi-bridge
|
||||
ExecStart=/opt/tuxdmx-midi-bridge/.venv/bin/python -m tuxdmx_midi_bridge --config /opt/tuxdmx-midi-bridge/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Standalone MIDI bridge for TuxDMX."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .main import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import ServerConfig
|
||||
|
||||
|
||||
class TuxDmxMidiClient:
|
||||
def __init__(self, server: ServerConfig, timeout: float = 5.0) -> None:
|
||||
self._server = server
|
||||
self._timeout = timeout
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._server.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def heartbeat_url(self) -> str:
|
||||
return f"{self._server.url}/heartbeat"
|
||||
|
||||
@property
|
||||
def events_url(self) -> str:
|
||||
return f"{self._server.url}/events"
|
||||
|
||||
async def send_heartbeat(self, payload: dict[str, object]) -> dict[str, object]:
|
||||
async with httpx.AsyncClient(timeout=self._timeout, verify=self._server.verify_tls) as client:
|
||||
response = await client.post(self.heartbeat_url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def send_event(self, payload: dict[str, object]) -> dict[str, object]:
|
||||
async with httpx.AsyncClient(timeout=self._timeout, verify=self._server.verify_tls) as client:
|
||||
response = await client.post(self.events_url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def test_connection(self, bridge_id: str, device_name: str) -> dict[str, object]:
|
||||
return await self.send_heartbeat(
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": bridge_id,
|
||||
"device": device_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reconnect_delay(attempt: int, base_seconds: float) -> float:
|
||||
return min(base_seconds * max(1, attempt), 30.0)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServerConfig:
|
||||
url: str
|
||||
api_token: str
|
||||
reconnect_seconds: float = 3.0
|
||||
verify_tls: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MidiConfig:
|
||||
device_name: str
|
||||
channel_filter: int | None = None
|
||||
ignore_active_sensing: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PerformanceConfig:
|
||||
control_change_interval_ms: int = 25
|
||||
suppress_duplicate_values: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LoggingConfig:
|
||||
level: str = "INFO"
|
||||
show_midi_events: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BridgeConfig:
|
||||
bridge_id: str
|
||||
server: ServerConfig
|
||||
midi: MidiConfig
|
||||
performance: PerformanceConfig
|
||||
logging: LoggingConfig
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> BridgeConfig:
|
||||
config_path = Path(path)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Konfigurationsfilen skal være YAML med topniveau som objekt.")
|
||||
|
||||
server_data = raw.get("server") or {}
|
||||
midi_data = raw.get("midi") or {}
|
||||
performance_data = raw.get("performance") or {}
|
||||
logging_data = raw.get("logging") or {}
|
||||
env_token = os.getenv("TUXDMX_API_TOKEN", "").strip()
|
||||
api_token = env_token or str(server_data.get("api_token", "")).strip()
|
||||
if not api_token:
|
||||
raise ValueError("API-token mangler. Angiv det i YAML eller via TUXDMX_API_TOKEN.")
|
||||
|
||||
return BridgeConfig(
|
||||
bridge_id=str(raw.get("bridge_id") or socket.gethostname()).strip(),
|
||||
server=ServerConfig(
|
||||
url=str(server_data.get("url", "")).rstrip("/"),
|
||||
api_token=api_token,
|
||||
reconnect_seconds=float(server_data.get("reconnect_seconds", 3)),
|
||||
verify_tls=bool(server_data.get("verify_tls", True)),
|
||||
),
|
||||
midi=MidiConfig(
|
||||
device_name=str(midi_data.get("device_name", "")).strip(),
|
||||
channel_filter=_optional_int(midi_data.get("channel_filter")),
|
||||
ignore_active_sensing=bool(midi_data.get("ignore_active_sensing", True)),
|
||||
),
|
||||
performance=PerformanceConfig(
|
||||
control_change_interval_ms=int(performance_data.get("control_change_interval_ms", 25)),
|
||||
suppress_duplicate_values=bool(performance_data.get("suppress_duplicate_values", True)),
|
||||
),
|
||||
logging=LoggingConfig(
|
||||
level=str(logging_data.get("level", "INFO")).upper(),
|
||||
show_midi_events=bool(logging_data.get("show_midi_events", True)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> int | None:
|
||||
if value in {None, "", "null"}:
|
||||
return None
|
||||
return int(value)
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from .client import TuxDmxMidiClient, reconnect_delay
|
||||
from .config import BridgeConfig, load_config
|
||||
from .logging_setup import configure_logging
|
||||
from .midi import MidiEventFilter, list_input_devices, match_input_device, normalize_message
|
||||
|
||||
logger = logging.getLogger("tuxdmx_midi_bridge")
|
||||
|
||||
|
||||
async def run_bridge(config: BridgeConfig, debug: bool = False) -> None:
|
||||
if debug:
|
||||
config.logging.level = "DEBUG"
|
||||
config.logging.show_midi_events = True
|
||||
configure_logging(config.logging.level)
|
||||
client = TuxDmxMidiClient(config.server)
|
||||
event_filter = MidiEventFilter(
|
||||
channel_filter=config.midi.channel_filter,
|
||||
control_change_interval_ms=config.performance.control_change_interval_ms,
|
||||
suppress_duplicate_values=config.performance.suppress_duplicate_values,
|
||||
)
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
device_name = match_input_device(config.midi.device_name)
|
||||
if device_name is None:
|
||||
raise RuntimeError(f"MIDI-input matcher ikke '{config.midi.device_name}'.")
|
||||
logger.info("Valgt MIDI-input: %s", device_name)
|
||||
import mido
|
||||
|
||||
with mido.open_input(device_name) as port:
|
||||
await client.send_heartbeat(
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": config.bridge_id,
|
||||
"device": device_name,
|
||||
}
|
||||
)
|
||||
logger.info("Forbundet til TuxDMX som bridge '%s'", config.bridge_id)
|
||||
attempt = 0
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(client, config.bridge_id, device_name))
|
||||
try:
|
||||
while True:
|
||||
for message in port.iter_pending():
|
||||
if config.midi.ignore_active_sensing and getattr(message, "type", "") == "active_sensing":
|
||||
continue
|
||||
payload = normalize_message(message, config.bridge_id, device_name)
|
||||
if payload is None or not event_filter.should_forward(payload):
|
||||
continue
|
||||
if config.logging.show_midi_events:
|
||||
logger.info(
|
||||
"MIDI %s ch=%s no=%s val=%s",
|
||||
payload["message"]["type"],
|
||||
payload["message"]["channel"],
|
||||
payload["message"]["number"],
|
||||
payload["message"]["value"],
|
||||
)
|
||||
await client.send_event(payload)
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await heartbeat_task
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Afslutter MIDI-bridge kontrolleret.")
|
||||
return
|
||||
except Exception as exc:
|
||||
attempt += 1
|
||||
delay = reconnect_delay(attempt, config.server.reconnect_seconds)
|
||||
logger.warning("Bridge-fejl: %s. Nyt forsøg om %.1f sek.", exc, delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
async def _heartbeat_loop(client: TuxDmxMidiClient, bridge_id: str, device_name: str) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
await client.send_heartbeat(
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": bridge_id,
|
||||
"device": device_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="TuxDMX MIDI bridge")
|
||||
parser.add_argument("--config", default="config.yaml", help="Sti til YAML-konfiguration")
|
||||
parser.add_argument("--list-devices", action="store_true", help="List tilgængelige MIDI-inputs")
|
||||
parser.add_argument("--test-connection", action="store_true", help="Test forbindelse til TuxDMX")
|
||||
parser.add_argument("--debug", action="store_true", help="Aktivér debuglog")
|
||||
return parser
|
||||
|
||||
|
||||
async def _async_main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_devices:
|
||||
for name in list_input_devices():
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
config = load_config(args.config)
|
||||
configure_logging("DEBUG" if args.debug else config.logging.level)
|
||||
|
||||
if args.test_connection:
|
||||
client = TuxDmxMidiClient(config.server)
|
||||
device_name = match_input_device(config.midi.device_name) or config.midi.device_name
|
||||
result = await client.test_connection(config.bridge_id, device_name)
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
await run_bridge(config, debug=args.debug)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(_async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
|
||||
def list_input_devices() -> list[str]:
|
||||
import mido
|
||||
|
||||
return list(mido.get_input_names())
|
||||
|
||||
|
||||
def match_input_device(device_name: str, devices: list[str] | None = None) -> str | None:
|
||||
available = devices or list_input_devices()
|
||||
requested = device_name.casefold()
|
||||
for name in available:
|
||||
if requested in name.casefold():
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def normalize_message(message: Any, bridge_id: str, device_name: str) -> dict[str, object] | None:
|
||||
message_type = getattr(message, "type", None)
|
||||
if message_type not in {"note_on", "note_off", "control_change", "program_change"}:
|
||||
return None
|
||||
number = getattr(message, "note", None)
|
||||
if message_type in {"control_change", "program_change"}:
|
||||
number = getattr(message, "control", None) if message_type == "control_change" else getattr(message, "program", None)
|
||||
if number is None:
|
||||
return None
|
||||
value = getattr(message, "velocity", None)
|
||||
if message_type == "control_change":
|
||||
value = getattr(message, "value", 0)
|
||||
if message_type == "program_change":
|
||||
value = getattr(message, "value", 127)
|
||||
if value is None:
|
||||
value = 0
|
||||
return {
|
||||
"type": "midi_event",
|
||||
"protocol_version": 1,
|
||||
"bridge_id": bridge_id,
|
||||
"device": device_name,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"message": {
|
||||
"type": message_type,
|
||||
"channel": int(getattr(message, "channel", 0)),
|
||||
"number": int(number),
|
||||
"value": int(value),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MidiEventFilter:
|
||||
channel_filter: int | None
|
||||
control_change_interval_ms: int
|
||||
suppress_duplicate_values: bool
|
||||
_last_sent_at: dict[tuple[str, int, int], float] = field(default_factory=dict, init=False)
|
||||
_last_values: dict[tuple[str, int, int], int] = field(default_factory=dict, init=False)
|
||||
|
||||
def should_forward(self, payload: dict[str, object]) -> bool:
|
||||
message = payload["message"]
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
message_type = str(message["type"])
|
||||
channel = int(message["channel"])
|
||||
number = int(message["number"])
|
||||
value = int(message["value"])
|
||||
|
||||
if self.channel_filter is not None and channel != self.channel_filter:
|
||||
return False
|
||||
|
||||
key = (message_type, channel, number)
|
||||
if self.suppress_duplicate_values and self._last_values.get(key) == value:
|
||||
return False
|
||||
|
||||
if message_type == "control_change":
|
||||
now_ms = monotonic() * 1000
|
||||
last_sent = self._last_sent_at.get(key, 0.0)
|
||||
if now_ms - last_sent < self.control_change_interval_ms:
|
||||
return False
|
||||
self._last_sent_at[key] = now_ms
|
||||
|
||||
self._last_values[key] = value
|
||||
return True
|
||||
Reference in New Issue
Block a user