Skip to content

  • Projects
  • Groups
  • Snippets
  • Help
  • This project
    • Loading...
  • Sign in / Register
T
Tidal
  • Overview
    • Overview
    • Details
    • Activity
    • Cycle Analytics
  • Repository
    • Repository
    • Files
    • Commits
    • Branches
    • Tags
    • Contributors
    • Graph
    • Compare
    • Charts
  • Issues 0
    • Issues 0
    • List
    • Board
    • Labels
    • Milestones
  • Merge Requests 0
    • Merge Requests 0
  • CI / CD
    • CI / CD
    • Pipelines
    • Jobs
    • Schedules
    • Charts
  • Wiki
    • Wiki
  • Snippets
    • Snippets
  • Members
    • Members
  • Collapse sidebar
  • Activity
  • Graph
  • Charts
  • Create a new issue
  • Jobs
  • Commits
  • Issue Boards
  • PLN
  • Tidal
  • Repository

Switch branch/tag
  • Tidal
  • tools
  • bridge
  • tests
  • test_midiviz.py
Find file
BlameHistoryPermalink
  • PLN (Algolia)'s avatar
    feat(bridge): midiviz — the MIDI stream as a picture, not a transcript · 30a704ec
    PLN, 2026-08-29: "midiwatch still naively verbose [`14:0 Control change ch0,
    controller 29, value 15`] improve it massively. no more console, tiny borderless
    window that exits on q/exit/ctrl+c/alt+f4, that renders the stream in a nicer,
    cyberpunk matrix dense cryptic purple-on-dark, style. no word like 'control
    change' pure viz"
    
    The complaint is not about verbosity, it is about REGISTER. One line per event is
    the right shape for a debugger and the wrong shape for a performer: a fader sweep
    emits ~400 events a second, so midimon's prose scrolls past faster than an eye
    can land on it, and the one question you actually have mid-set — "which orbit did
    I just touch" — is the one thing a sentence makes you decode. So this is a lens,
    not a log.
    
    The picture
    -----------
    Zero English words render. An event's identity is its POSITION, its type is a
    GLYPH CLASS, its value is a BAR plus two hex digits, and its recency is
    BRIGHTNESS. The layout is the authored surface itself — lcxl_grid's rows A..F in
    PHYSICAL_ORDER down, columns 1..8 across, column N *is* orbit N — so the answer
    is spatial and needs no decoding. A per-orbit charge glow behind each column says
    "this one is live"; the column digit brightens with it. Buttons render as latches
    (filled above half, hollow below) because rows E/F are gates, not levels — a bar
    would have lied about what they do. Channel shifts the hue of the digits, not of
    the cell, so the cell keeps meaning "which control" while the tint carries "from
    where". Only what the grid CANNOT place — foreign CCs, notes, bend, program —
    falls as rain in the right-hand gutter, which makes "that came from somewhere
    else" a visible fact rather than a thing you read. A ribbon along the bottom
    scrolls the raw order of arrival, brightest at the right.
    
    Every hue stays inside the violet→magenta arc, so eight roles stay
    distinguishable while the window still reads as one colour.
    
    Choices worth keeping
    ---------------------
    * **Mapped-but-idle must not be black.** All 48 grid cells carry a floor tint
      even when nothing has ever arrived on them. "Dark = not mapped" is the reading
      a cockpit trains you into, and a dark cell that IS mapped reads as a hardware
      fault — the same trap the LED work settled.
    * **Toolkit: PySide6 (Qt6), already installed (6.11.2).** PyQt6 and pygame are
      not present; GTK4 + pycairo are, but Qt gave frameless + always-on-top hints +
      startSystemMove (the only way a Wayland client may reposition itself) without
      hand-rolling a drag.
    * **Two frame rates, not a busy loop.** This machine performs live audio. ~25 fps
      while events arrive, dropping to 5 fps two seconds after the last one, where
      the only motion is a slow dim pulse. Colours are a precomputed LUT (8 families
      x 16 decay levels), paint uses the int overloads, the CRT scanline texture is
      one cached pixmap blitted once, and the ribbon is bounded to exactly what fits
      so a frame copies nothing.
    * **Profiled rather than guessed.** The first version cost 30.6% of a core under
      a sustained 400 ev/s sweep. Stage-by-stage timing put the cost in the sheer
      NUMBER of drawText calls, not in pixels: gutter rain alone was 3.1 ms of an
      8.9 ms frame (a 96-drop cap with a trail glyph each = up to 192 calls). Capping
      rain at 32 heads with a trail only on the brightest, batching the ribbon into
      same-colour RUNS, and 33→40 ms took the frame to 5.5 ms and the sweep to 17.7%.
      Idle is 1.4%. The ribbon batches only hex glyphs, because those are guaranteed
      to come from the monospaced face at exactly one advance — a class glyph (◆ ≈ ≡)
      may be served by a fallback face and would drift the ribbon off its grid.
    
    Port choice: delegated, not re-derived
    --------------------------------------
    `midimon.resolve_port()` owns "which port", and midiviz delegates to it. Two
    things about it are load-bearing and were nearly re-derived wrong here: the
    preference order is authored, and it matches the whole `aseqdump -l` ROW rather
    than the CLIENT column — python-rtmidi registers the lcxl3 driver's client as
    'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT. A client-name match
    (which is what surface.resolve_port does, correctly, for its own purpose) falls
    straight through to the raw hardware: the monitor would work and show the wrong
    stream. Confirmed live on this rig, which has both up — the driver is 133:0 under
    'RtMidiOut Client'. The local fallback copy exists only for trees whose midimon
    predates the helper, and a test asserts it cannot drift from the original.
    
    Launcher
    --------
    The `midimon` KEY is kept, so the Bridge hub's and the tray's existing button open
    the new thing with no change on either face; midimon.py is untouched and remains
    the terminal fallback. `terminal: True` is dropped — midiviz draws its own
    window, and wrapping a GUI in konsole would have parked an empty black terminal
    behind it for the length of the set. That also re-enables the already-running
    guard, so the button is now idempotent instead of spawning a window per click.
    
    Verified
    --------
    * `ast.parse` clean; full bridge suite 90 passed (was 78 + 12 new).
    * `midiviz.py --selftest`: 3 s of synthetic events — every one of the 48 authored
      grid cells, four channels, un-gridded CCs, notes, bend, program, aftertouch,
      sysex — pushed through the REAL parse_line + enrich + ingest + paint path, 140
      paints, 68 grabs, 139 distinct sampled colours. It grabs the widget each tick,
      so the drawing code is exercised for real and offscreen, not merely constructed.
    * Exit paths run, not assumed: SIGINT and SIGTERM each exit 0 from a live process
      (q/Esc/close share that path).
    * Plumbing, not just pure functions: a real Reader subscribed to the resolved
      driver port with no error, and a second one pinned to Midi Through received 8
      genuine events played in with aplaymidi — aseqdump → parse_line → enrich →
      drain, end to end against real ALSA.
    * Rendered and looked at, at 420x260 and at 2x, before and after the
      optimizations: identical output, which is the point of the run-length batching.
    
    Found on the way: data-less realtime messages (Start/Stop/Continue/Clock) print
    with no data column, and midimon's row regex requires one, so they never parse —
    midimon's console silently omits transport too, and midiviz has four glyphs that
    cannot light up. Left in midimon (one parser per concept) and pinned by a test
    that says what to delete when it is fixed.
    PLN (Algolia) authored Aug 29, 2026
    30a704ec
test_midiviz.py 6.47 KB
EditWeb IDE
×

Replace test_midiviz.py

Attach a file by drag & drop or click to upload


Cancel
A new branch will be created in your fork and a new merge request will be started.