subsequence.display
Live terminal dashboard for composition playback.
Provides a persistent status line showing the current bar, section, chord, BPM, and key. Optionally renders an ASCII grid visualisation of all running patterns above the status line, showing which steps have notes and at what velocity.
Log messages scroll above the dashboard without disruption.
Enable it with a single call before play():
composition.display() # status line only
composition.display(grid=True) # status line + pattern grid
composition.play()
The status line updates every beat and looks like::
125.00 BPM Key: E Bar: 17.1 [chorus 1/8] Chord: Em7
The grid (when enabled) updates every bar and looks like::
kick_2 |█ · · · █ · · · █ · · · █ · · ·|
snare_1 |· · · · ▓ · · · · · · · ▓ · · ·|
bass |▓ · · ▓ · · ▓ · ▓ · · · ▓ · · ·|
1"""Live terminal dashboard for composition playback. 2 3Provides a persistent status line showing the current bar, section, chord, BPM, 4and key. Optionally renders an ASCII grid visualisation of all running patterns 5above the status line, showing which steps have notes and at what velocity. 6 7Log messages scroll above the dashboard without disruption. 8 9Enable it with a single call before ``play()``: 10 11```python 12composition.display() # status line only 13composition.display(grid=True) # status line + pattern grid 14composition.play() 15``` 16 17The status line updates every beat and looks like:: 18 19 125.00 BPM Key: E Bar: 17.1 [chorus 1/8] Chord: Em7 20 21The grid (when enabled) updates every bar and looks like:: 22 23 kick_2 |█ · · · █ · · · █ · · · █ · · ·| 24 snare_1 |· · · · ▓ · · · · · · · ▓ · · ·| 25 bass |▓ · · ▓ · · ▓ · ▓ · · · ▓ · · ·| 26""" 27 28import logging 29import shutil 30import sys 31import threading 32import typing 33 34import subsequence.constants 35 36if typing.TYPE_CHECKING: 37 from subsequence.composition import Composition 38 39 40_NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] 41 42_MAX_GRID_COLUMNS = 32 43_LABEL_WIDTH = 16 44_MIN_TERMINAL_WIDTH = 40 45_SUSTAIN = -1 46 47 48class GridDisplay: 49 50 """Multi-line ASCII grid visualisation of running pattern steps. 51 52 Renders one block per pattern showing which grid steps have notes and at 53 what velocity. Drum patterns (those with a ``drum_note_map``) show one 54 row per drum sound; pitched patterns show a single summary row. 55 56 Not used directly — instantiated by ``Display`` when ``grid=True``. 57 """ 58 59 def __init__ (self, composition: "Composition", scale: float = 1.0) -> None: 60 61 """Store composition reference for reading pattern state. 62 63 Parameters: 64 composition: The ``Composition`` instance to read running 65 patterns from. 66 scale: Horizontal zoom factor. Snapped to the nearest 67 integer ``cols_per_step`` so that all on-grid markers 68 are uniformly spaced. Default ``1.0`` = one visual 69 column per grid step (current behaviour). 70 """ 71 72 self._composition = composition 73 self._scale = scale 74 self._lines: typing.List[str] = [] 75 76 @property 77 def line_count (self) -> int: 78 79 """Number of terminal lines the grid currently occupies.""" 80 81 return len(self._lines) 82 83 # ------------------------------------------------------------------ 84 # Static helpers 85 # ------------------------------------------------------------------ 86 87 @staticmethod 88 def _velocity_char (velocity: typing.Union[int, float]) -> str: 89 90 """Map a MIDI velocity (0-127) to a single ANSI block character. 91 92 Returns: 93 ``">"`` for sustain (note still sounding), 94 ``"░"`` for velocity > 0 to < 31.75 (0 - < 25%), 95 ``"▒"`` for velocity >= 31.75 to < 63.5 (25% to < 50%), 96 ``"▓"`` for velocity >= 63.5 to < 95.25 (50% to < 75%), 97 ``"█"`` for velocity >= 95.25 (75% to 100%). 98 """ 99 100 if velocity == _SUSTAIN: 101 return ">" 102 if velocity == 0: 103 return "·" 104 105 pct = velocity / 127.0 106 if pct < 0.25: 107 return "░" 108 if pct < 0.50: 109 return "▒" 110 if pct < 0.75: 111 return "▓" 112 return "█" 113 114 @staticmethod 115 def _cell_char (velocity: typing.Union[int, float], is_on_grid: bool) -> str: 116 117 """Return the display character for a grid cell. 118 119 Non-zero velocities (attacks and sustain) always show their 120 velocity glyph. Empty on-grid positions show ``"·"``, empty 121 between-grid positions show ``" "`` (space). 122 """ 123 124 if velocity != 0: 125 return GridDisplay._velocity_char(velocity) 126 return "·" if is_on_grid else " " 127 128 @staticmethod 129 def _midi_note_name (pitch: int) -> str: 130 131 """Convert a MIDI note number to a human-readable name. 132 133 Examples: 60 → ``"C4"``, 42 → ``"F#2"``, 36 → ``"C2"``. 134 """ 135 136 octave = (pitch // 12) - 1 137 note = _NOTE_NAMES[pitch % 12] 138 return f"{note}{octave}" 139 140 # ------------------------------------------------------------------ 141 # Grid building 142 # ------------------------------------------------------------------ 143 144 def build (self) -> None: 145 146 """Rebuild grid lines from the current state of all running patterns.""" 147 148 lines: typing.List[str] = [] 149 term_width = shutil.get_terminal_size(fallback=(80, 24)).columns 150 151 if term_width < _MIN_TERMINAL_WIDTH: 152 self._lines = [] 153 return 154 155 for name, pattern in self._composition.running_patterns.items(): 156 157 grid_size = min(getattr(pattern, "_default_grid", 16), _MAX_GRID_COLUMNS) 158 muted = getattr(pattern, "_muted", False) 159 drum_map: typing.Optional[typing.Dict[str, int]] = getattr(pattern, "_drum_note_map", None) 160 161 # Snap to integer cols_per_step for uniform marker spacing. 162 cols_per_step = max(1, round(self._scale)) 163 visual_cols = grid_size * cols_per_step 164 display_cols = self._fit_columns(visual_cols, term_width) 165 166 # On-grid columns: exact multiples of cols_per_step. 167 on_grid = frozenset( 168 i * cols_per_step 169 for i in range(grid_size) 170 if i * cols_per_step < display_cols 171 ) 172 173 if muted: 174 lines.extend(self._render_muted(name, display_cols, on_grid)) 175 elif drum_map: 176 lines.extend(self._render_drum_pattern(name, pattern, drum_map, visual_cols, display_cols, on_grid)) 177 else: 178 lines.extend(self._render_pitched_pattern(name, pattern, visual_cols, display_cols, on_grid)) 179 180 self._lines = lines 181 182 # ------------------------------------------------------------------ 183 # Rendering helpers 184 # ------------------------------------------------------------------ 185 186 def _render_muted (self, name: str, display_cols: int, on_grid: typing.FrozenSet[int]) -> typing.List[str]: 187 188 """Render a muted pattern as a single row of dashes.""" 189 190 cells = " ".join("-" if col in on_grid else " " for col in range(display_cols)) 191 label = f"({name})"[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 192 return [f"{label}|{cells}|"] 193 194 def _render_drum_pattern ( 195 self, 196 name: str, 197 pattern: typing.Any, 198 drum_map: typing.Dict[str, int], 199 visual_cols: int, 200 display_cols: int, 201 on_grid: typing.FrozenSet[int], 202 ) -> typing.List[str]: 203 204 """Render a drum pattern with one row per distinct drum sound.""" 205 206 lines: typing.List[str] = [] 207 208 # Build reverse map: {midi_note: drum_name}. 209 reverse_map: typing.Dict[int, str] = {} 210 for drum_name, midi_note in drum_map.items(): 211 if midi_note not in reverse_map: 212 reverse_map[midi_note] = drum_name 213 214 # Discover which pitches are present in the pattern. 215 velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) 216 217 if not velocity_grid: 218 return lines 219 220 # Sort rows by MIDI pitch (lowest first — kick before hi-hat). 221 222 for pitch in sorted(velocity_grid): 223 label_text = reverse_map.get(pitch, self._midi_note_name(pitch)) 224 label = label_text[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 225 cells = " ".join( 226 self._cell_char(v, col in on_grid) 227 for col, v in enumerate(velocity_grid[pitch][:display_cols]) 228 ) 229 lines.append(f"{label}|{cells}|") 230 231 return lines 232 233 def _render_pitched_pattern ( 234 self, 235 name: str, 236 pattern: typing.Any, 237 visual_cols: int, 238 display_cols: int, 239 on_grid: typing.FrozenSet[int], 240 ) -> typing.List[str]: 241 242 """Render a pitched pattern as a single summary row.""" 243 244 # Collapse all pitches into a single row using max velocity per slot. 245 velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) 246 247 summary = [0] * display_cols 248 for pitch_velocities in velocity_grid.values(): 249 for i, vel in enumerate(pitch_velocities[:display_cols]): 250 if vel > summary[i] or (vel == _SUSTAIN and summary[i] == 0): 251 summary[i] = vel 252 253 label = name[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 254 cells = " ".join( 255 self._cell_char(v, col in on_grid) 256 for col, v in enumerate(summary) 257 ) 258 return [f"{label}|{cells}|"] 259 260 # ------------------------------------------------------------------ 261 # Internal helpers 262 # ------------------------------------------------------------------ 263 264 def _build_velocity_grid ( 265 self, 266 pattern: typing.Any, 267 grid_size: int, 268 display_cols: int, 269 ) -> typing.Dict[int, typing.List[int]]: 270 271 """Scan pattern steps and build a {pitch: [velocity_per_slot]} dict. 272 273 Each pitch gets a list of length *display_cols*. At each grid slot 274 the highest velocity from any note at that position is stored. 275 """ 276 277 total_pulses = int(pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) 278 279 if total_pulses <= 0 or grid_size <= 0: 280 return {} 281 282 pulses_per_slot = total_pulses / grid_size 283 284 velocity_grid: typing.Dict[int, typing.List[int]] = {} 285 286 for pulse, step in pattern.steps.items(): 287 # Map pulse → grid slot. 288 slot = int(pulse / pulses_per_slot) 289 290 if slot < 0 or slot >= display_cols: 291 continue 292 293 for note in step.notes: 294 if note.pitch not in velocity_grid: 295 velocity_grid[note.pitch] = [0] * display_cols 296 297 if note.velocity > velocity_grid[note.pitch][slot]: 298 velocity_grid[note.pitch][slot] = note.velocity 299 300 # Fill sustain markers for slots where the note is 301 # still sounding. Short notes (drums, staccato) never 302 # enter this loop. 303 end_pulse = pulse + note.duration 304 for s in range(slot + 1, display_cols): 305 if s * pulses_per_slot >= end_pulse: 306 break 307 if velocity_grid[note.pitch][s] == 0: 308 velocity_grid[note.pitch][s] = _SUSTAIN 309 310 return velocity_grid 311 312 @staticmethod 313 def _fit_columns (grid_size: int, term_width: int) -> int: 314 315 """Determine how many grid columns fit in the terminal. 316 317 Each column occupies 2 characters (char + space), plus the label 318 prefix and pipe delimiters. 319 """ 320 321 # label (LABEL_WIDTH) + "|" + cells + "|" 322 # Each cell is "X " (2 chars) but last cell has no trailing space 323 # inside the pipes: "X . X ." is grid_size * 2 - 1 chars. 324 overhead = _LABEL_WIDTH + 2 # label + pipes 325 available = term_width - overhead 326 327 if available <= 0: 328 return 0 329 330 # Each column needs 2 chars (char + space), except the last needs 1. 331 max_cols = (available + 1) // 2 332 333 return min(grid_size, max_cols) 334 335 336class DisplayLogHandler (logging.Handler): 337 338 """Logging handler that clears and redraws the status line around log output. 339 340 Installed by ``Display.start()`` and removed by ``Display.stop()``. Ensures 341 log messages do not overwrite or corrupt the persistent status line. 342 """ 343 344 def __init__ (self, display: "Display") -> None: 345 346 """Store reference to the display for clear/redraw calls.""" 347 348 super().__init__() 349 self._display = display 350 351 def emit (self, record: logging.LogRecord) -> None: 352 353 """Clear the status line, write the log message, then redraw.""" 354 355 try: 356 # emit() runs on whatever thread logged; hold the display's render 357 # lock across the whole clear → write → redraw sequence so a 358 # concurrent event-loop draw() cannot interleave ANSI sequences. 359 with self._display._render_lock: 360 361 self._display.clear_line() 362 363 msg = self.format(record) 364 sys.stderr.write(msg + "\n") 365 sys.stderr.flush() 366 367 self._display.draw() 368 369 except Exception: 370 self.handleError(record) 371 372 373class Display: 374 375 """Live-updating terminal dashboard showing composition state. 376 377 Reads bar, section, chord, BPM, and key from the ``Composition`` and renders 378 a persistent region to stderr. When ``grid=True`` an ASCII pattern grid is 379 rendered above the status line. A custom ``DisplayLogHandler`` ensures log 380 messages scroll cleanly above the dashboard. 381 382 Example: 383 ```python 384 composition.display(grid=True) 385 composition.play() 386 ``` 387 """ 388 389 def __init__ (self, composition: "Composition", grid: bool = False, grid_scale: float = 1.0) -> None: 390 391 """Store composition reference for reading playback state. 392 393 Parameters: 394 composition: The ``Composition`` instance to read state from. 395 grid: When True, render an ASCII grid of running patterns 396 above the status line. 397 grid_scale: Horizontal zoom factor for the grid (default 398 ``1.0``). Snapped internally to the nearest integer 399 ``cols_per_step`` for uniform marker spacing. 400 """ 401 402 self._composition = composition 403 self._active: bool = False 404 self._handler: typing.Optional[DisplayLogHandler] = None 405 self._saved_handlers: typing.List[logging.Handler] = [] 406 self._last_line: str = "" 407 self._last_bar: typing.Optional[int] = None 408 self._cached_section: typing.Any = None 409 self._grid: typing.Optional[GridDisplay] = GridDisplay(composition, scale=grid_scale) if grid else None 410 self._last_grid_bar: typing.Optional[int] = None 411 self._drawn_line_count: int = 0 412 # Serialises terminal writes: update()/draw() run on the event loop, 413 # but DisplayLogHandler.emit() runs on whatever thread logged (e.g. 414 # the web UI's HTTP worker) — unsynchronised, an emit mid-draw would 415 # interleave ANSI sequences and garble the dashboard. RLock because 416 # emit() holds it across its clear_line() → write → draw() sequence. 417 self._render_lock = threading.RLock() 418 419 def start (self) -> None: 420 421 """Install the log handler and activate the display. 422 423 Saves existing root logger handlers and replaces them with a 424 ``DisplayLogHandler`` that clears/redraws the status line around 425 each log message. Original handlers are restored by ``stop()``. 426 """ 427 428 if self._active: 429 return 430 431 self._active = True 432 433 root_logger = logging.getLogger() 434 435 # Save existing handlers so we can restore them on stop. 436 self._saved_handlers = list(root_logger.handlers) 437 438 # Build the replacement handler, inheriting the formatter from the 439 # first existing handler (if any) for consistent log formatting. 440 self._handler = DisplayLogHandler(self) 441 442 if self._saved_handlers and self._saved_handlers[0].formatter: 443 self._handler.setFormatter(self._saved_handlers[0].formatter) 444 else: 445 self._handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) 446 447 root_logger.handlers.clear() 448 root_logger.addHandler(self._handler) 449 450 def stop (self) -> None: 451 452 """Clear the status line and restore original log handlers.""" 453 454 if not self._active: 455 return 456 457 self.clear_line() 458 self._active = False 459 460 root_logger = logging.getLogger() 461 root_logger.handlers.clear() 462 463 for handler in self._saved_handlers: 464 root_logger.addHandler(handler) 465 466 self._saved_handlers = [] 467 self._handler = None 468 469 def update (self, _: int = 0) -> None: 470 471 """Rebuild and redraw the dashboard; called on ``"bar"`` and ``"beat"`` events. 472 473 The integer argument (bar or beat number) is ignored — state is read directly from 474 the composition. 475 476 Note: "bar" and "beat" events are emitted as ``asyncio.create_task`` at the start 477 of each pulse, but the tasks only execute *after* ``_advance_pulse()`` completes 478 (which includes sending MIDI via ``_process_pulse()``). The display therefore 479 always trails the audio slightly — this is inherent to the architecture and cannot 480 be avoided without restructuring the sequencer loop. 481 """ 482 483 if not self._active: 484 return 485 486 self._last_line = self._format_status() 487 488 # Rebuild grid data only when the bar counter changes. 489 if self._grid is not None: 490 current_bar = self._composition.sequencer.current_bar 491 if current_bar != self._last_grid_bar: 492 self._last_grid_bar = current_bar 493 self._grid.build() 494 495 self.draw() 496 497 def draw (self) -> None: 498 499 """Write the current dashboard to the terminal.""" 500 501 if not self._active or not self._last_line: 502 return 503 504 with self._render_lock: 505 506 grid_lines = self._grid._lines if self._grid is not None else [] 507 total = len(grid_lines) + 1 # grid lines + status line 508 509 if grid_lines: 510 total += 1 # separator line 511 512 # Move cursor up to overwrite the previously drawn region. 513 # Cursor sits on the last line (status) with no trailing newline, 514 # so we move up (total - 1) to reach the first line. 515 if self._drawn_line_count > 1: 516 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 517 518 if grid_lines: 519 sep = "-" * len(grid_lines[0]) 520 sys.stderr.write(f"\r\033[K{sep}\n") 521 for line in grid_lines: 522 sys.stderr.write(f"\r\033[K{line}\n") 523 524 # Status line (no trailing newline — cursor stays on this line). 525 sys.stderr.write(f"\r\033[K{self._last_line}") 526 # Clear to end of screen in case the grid shrank since the last draw 527 # (e.g. a pattern disappeared), which would otherwise leave the old 528 # status line stranded one line below the new one. 529 sys.stderr.write("\033[J") 530 sys.stderr.flush() 531 532 self._drawn_line_count = total 533 534 def clear_line (self) -> None: 535 536 """Erase the entire dashboard region from the terminal.""" 537 538 if not self._active: 539 return 540 541 with self._render_lock: 542 543 if self._drawn_line_count > 1: 544 # Cursor is on the last line (no trailing newline). 545 # Move up (total - 1) to reach the first line. 546 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 547 548 # Clear each line. 549 for _ in range(self._drawn_line_count): 550 sys.stderr.write("\r\033[K\n") 551 552 # Move cursor back up to the starting position. 553 sys.stderr.write(f"\033[{self._drawn_line_count}A") 554 else: 555 sys.stderr.write("\r\033[K") 556 557 sys.stderr.flush() 558 self._drawn_line_count = 0 559 560 def _format_status (self) -> str: 561 562 """Build the status string from current composition state.""" 563 564 parts: typing.List[str] = [] 565 comp = self._composition 566 567 parts.append(f"{comp.sequencer.current_bpm:.2f} BPM") 568 569 if comp.key: 570 parts.append(f"Key: {comp.key}") 571 572 bar = max(0, comp.sequencer.current_bar) + 1 573 beat = max(0, comp.sequencer.current_beat) + 1 574 parts.append(f"Bar: {bar}.{beat}") 575 576 # Section info (only when form is configured). 577 # Cache refreshes only when the bar counter changes, keeping 578 # the section display in sync with the bar display even though 579 # the form state advances one beat early (due to lookahead). 580 if comp.form_state is not None: 581 current_bar = comp.sequencer.current_bar 582 583 if current_bar != self._last_bar: 584 self._last_bar = current_bar 585 self._cached_section = comp.form_state.get_section_info() 586 587 section = self._cached_section 588 589 if section: 590 section_str = f"[{section.name} {section.bar + 1}/{section.bars}" 591 if section.next_section: 592 section_str += f" \u2192 {section.next_section}" 593 section_str += "]" 594 parts.append(section_str) 595 else: 596 parts.append("[form finished]") 597 598 # Current chord (only when harmony is configured). Reads the harmony 599 # window at the playhead, so it tracks sub-bar harmonic rhythm and 600 # covers progression-only mode (no engine) — the display refreshes 601 # per beat, which bounds how stale it can be. 602 chord = comp.current_chord() 603 if chord is not None: 604 parts.append(f"Chord: {chord.name()}") 605 606 # Conductor signals (when any are registered). builder_bar is the 607 # lookahead bar - deliberately the same time base the pattern builders 608 # read, so the status line shows the values shaping what you are about 609 # to hear (the section line above shows playing-bar time instead). 610 conductor = comp.conductor 611 if conductor.signal_names: 612 beat = comp.builder_bar * comp.sequencer.time_signature[0] 613 for name in conductor.signal_names: 614 value = conductor.get(name, beat) 615 parts.append(f"{name.title()}: {value:.2f}") 616 617 return " ".join(parts)
49class GridDisplay: 50 51 """Multi-line ASCII grid visualisation of running pattern steps. 52 53 Renders one block per pattern showing which grid steps have notes and at 54 what velocity. Drum patterns (those with a ``drum_note_map``) show one 55 row per drum sound; pitched patterns show a single summary row. 56 57 Not used directly — instantiated by ``Display`` when ``grid=True``. 58 """ 59 60 def __init__ (self, composition: "Composition", scale: float = 1.0) -> None: 61 62 """Store composition reference for reading pattern state. 63 64 Parameters: 65 composition: The ``Composition`` instance to read running 66 patterns from. 67 scale: Horizontal zoom factor. Snapped to the nearest 68 integer ``cols_per_step`` so that all on-grid markers 69 are uniformly spaced. Default ``1.0`` = one visual 70 column per grid step (current behaviour). 71 """ 72 73 self._composition = composition 74 self._scale = scale 75 self._lines: typing.List[str] = [] 76 77 @property 78 def line_count (self) -> int: 79 80 """Number of terminal lines the grid currently occupies.""" 81 82 return len(self._lines) 83 84 # ------------------------------------------------------------------ 85 # Static helpers 86 # ------------------------------------------------------------------ 87 88 @staticmethod 89 def _velocity_char (velocity: typing.Union[int, float]) -> str: 90 91 """Map a MIDI velocity (0-127) to a single ANSI block character. 92 93 Returns: 94 ``">"`` for sustain (note still sounding), 95 ``"░"`` for velocity > 0 to < 31.75 (0 - < 25%), 96 ``"▒"`` for velocity >= 31.75 to < 63.5 (25% to < 50%), 97 ``"▓"`` for velocity >= 63.5 to < 95.25 (50% to < 75%), 98 ``"█"`` for velocity >= 95.25 (75% to 100%). 99 """ 100 101 if velocity == _SUSTAIN: 102 return ">" 103 if velocity == 0: 104 return "·" 105 106 pct = velocity / 127.0 107 if pct < 0.25: 108 return "░" 109 if pct < 0.50: 110 return "▒" 111 if pct < 0.75: 112 return "▓" 113 return "█" 114 115 @staticmethod 116 def _cell_char (velocity: typing.Union[int, float], is_on_grid: bool) -> str: 117 118 """Return the display character for a grid cell. 119 120 Non-zero velocities (attacks and sustain) always show their 121 velocity glyph. Empty on-grid positions show ``"·"``, empty 122 between-grid positions show ``" "`` (space). 123 """ 124 125 if velocity != 0: 126 return GridDisplay._velocity_char(velocity) 127 return "·" if is_on_grid else " " 128 129 @staticmethod 130 def _midi_note_name (pitch: int) -> str: 131 132 """Convert a MIDI note number to a human-readable name. 133 134 Examples: 60 → ``"C4"``, 42 → ``"F#2"``, 36 → ``"C2"``. 135 """ 136 137 octave = (pitch // 12) - 1 138 note = _NOTE_NAMES[pitch % 12] 139 return f"{note}{octave}" 140 141 # ------------------------------------------------------------------ 142 # Grid building 143 # ------------------------------------------------------------------ 144 145 def build (self) -> None: 146 147 """Rebuild grid lines from the current state of all running patterns.""" 148 149 lines: typing.List[str] = [] 150 term_width = shutil.get_terminal_size(fallback=(80, 24)).columns 151 152 if term_width < _MIN_TERMINAL_WIDTH: 153 self._lines = [] 154 return 155 156 for name, pattern in self._composition.running_patterns.items(): 157 158 grid_size = min(getattr(pattern, "_default_grid", 16), _MAX_GRID_COLUMNS) 159 muted = getattr(pattern, "_muted", False) 160 drum_map: typing.Optional[typing.Dict[str, int]] = getattr(pattern, "_drum_note_map", None) 161 162 # Snap to integer cols_per_step for uniform marker spacing. 163 cols_per_step = max(1, round(self._scale)) 164 visual_cols = grid_size * cols_per_step 165 display_cols = self._fit_columns(visual_cols, term_width) 166 167 # On-grid columns: exact multiples of cols_per_step. 168 on_grid = frozenset( 169 i * cols_per_step 170 for i in range(grid_size) 171 if i * cols_per_step < display_cols 172 ) 173 174 if muted: 175 lines.extend(self._render_muted(name, display_cols, on_grid)) 176 elif drum_map: 177 lines.extend(self._render_drum_pattern(name, pattern, drum_map, visual_cols, display_cols, on_grid)) 178 else: 179 lines.extend(self._render_pitched_pattern(name, pattern, visual_cols, display_cols, on_grid)) 180 181 self._lines = lines 182 183 # ------------------------------------------------------------------ 184 # Rendering helpers 185 # ------------------------------------------------------------------ 186 187 def _render_muted (self, name: str, display_cols: int, on_grid: typing.FrozenSet[int]) -> typing.List[str]: 188 189 """Render a muted pattern as a single row of dashes.""" 190 191 cells = " ".join("-" if col in on_grid else " " for col in range(display_cols)) 192 label = f"({name})"[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 193 return [f"{label}|{cells}|"] 194 195 def _render_drum_pattern ( 196 self, 197 name: str, 198 pattern: typing.Any, 199 drum_map: typing.Dict[str, int], 200 visual_cols: int, 201 display_cols: int, 202 on_grid: typing.FrozenSet[int], 203 ) -> typing.List[str]: 204 205 """Render a drum pattern with one row per distinct drum sound.""" 206 207 lines: typing.List[str] = [] 208 209 # Build reverse map: {midi_note: drum_name}. 210 reverse_map: typing.Dict[int, str] = {} 211 for drum_name, midi_note in drum_map.items(): 212 if midi_note not in reverse_map: 213 reverse_map[midi_note] = drum_name 214 215 # Discover which pitches are present in the pattern. 216 velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) 217 218 if not velocity_grid: 219 return lines 220 221 # Sort rows by MIDI pitch (lowest first — kick before hi-hat). 222 223 for pitch in sorted(velocity_grid): 224 label_text = reverse_map.get(pitch, self._midi_note_name(pitch)) 225 label = label_text[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 226 cells = " ".join( 227 self._cell_char(v, col in on_grid) 228 for col, v in enumerate(velocity_grid[pitch][:display_cols]) 229 ) 230 lines.append(f"{label}|{cells}|") 231 232 return lines 233 234 def _render_pitched_pattern ( 235 self, 236 name: str, 237 pattern: typing.Any, 238 visual_cols: int, 239 display_cols: int, 240 on_grid: typing.FrozenSet[int], 241 ) -> typing.List[str]: 242 243 """Render a pitched pattern as a single summary row.""" 244 245 # Collapse all pitches into a single row using max velocity per slot. 246 velocity_grid = self._build_velocity_grid(pattern, visual_cols, display_cols) 247 248 summary = [0] * display_cols 249 for pitch_velocities in velocity_grid.values(): 250 for i, vel in enumerate(pitch_velocities[:display_cols]): 251 if vel > summary[i] or (vel == _SUSTAIN and summary[i] == 0): 252 summary[i] = vel 253 254 label = name[:_LABEL_WIDTH].ljust(_LABEL_WIDTH) 255 cells = " ".join( 256 self._cell_char(v, col in on_grid) 257 for col, v in enumerate(summary) 258 ) 259 return [f"{label}|{cells}|"] 260 261 # ------------------------------------------------------------------ 262 # Internal helpers 263 # ------------------------------------------------------------------ 264 265 def _build_velocity_grid ( 266 self, 267 pattern: typing.Any, 268 grid_size: int, 269 display_cols: int, 270 ) -> typing.Dict[int, typing.List[int]]: 271 272 """Scan pattern steps and build a {pitch: [velocity_per_slot]} dict. 273 274 Each pitch gets a list of length *display_cols*. At each grid slot 275 the highest velocity from any note at that position is stored. 276 """ 277 278 total_pulses = int(pattern.length * subsequence.constants.MIDI_QUARTER_NOTE) 279 280 if total_pulses <= 0 or grid_size <= 0: 281 return {} 282 283 pulses_per_slot = total_pulses / grid_size 284 285 velocity_grid: typing.Dict[int, typing.List[int]] = {} 286 287 for pulse, step in pattern.steps.items(): 288 # Map pulse → grid slot. 289 slot = int(pulse / pulses_per_slot) 290 291 if slot < 0 or slot >= display_cols: 292 continue 293 294 for note in step.notes: 295 if note.pitch not in velocity_grid: 296 velocity_grid[note.pitch] = [0] * display_cols 297 298 if note.velocity > velocity_grid[note.pitch][slot]: 299 velocity_grid[note.pitch][slot] = note.velocity 300 301 # Fill sustain markers for slots where the note is 302 # still sounding. Short notes (drums, staccato) never 303 # enter this loop. 304 end_pulse = pulse + note.duration 305 for s in range(slot + 1, display_cols): 306 if s * pulses_per_slot >= end_pulse: 307 break 308 if velocity_grid[note.pitch][s] == 0: 309 velocity_grid[note.pitch][s] = _SUSTAIN 310 311 return velocity_grid 312 313 @staticmethod 314 def _fit_columns (grid_size: int, term_width: int) -> int: 315 316 """Determine how many grid columns fit in the terminal. 317 318 Each column occupies 2 characters (char + space), plus the label 319 prefix and pipe delimiters. 320 """ 321 322 # label (LABEL_WIDTH) + "|" + cells + "|" 323 # Each cell is "X " (2 chars) but last cell has no trailing space 324 # inside the pipes: "X . X ." is grid_size * 2 - 1 chars. 325 overhead = _LABEL_WIDTH + 2 # label + pipes 326 available = term_width - overhead 327 328 if available <= 0: 329 return 0 330 331 # Each column needs 2 chars (char + space), except the last needs 1. 332 max_cols = (available + 1) // 2 333 334 return min(grid_size, max_cols)
Multi-line ASCII grid visualisation of running pattern steps.
Renders one block per pattern showing which grid steps have notes and at
what velocity. Drum patterns (those with a drum_note_map) show one
row per drum sound; pitched patterns show a single summary row.
Not used directly — instantiated by Display when grid=True.
60 def __init__ (self, composition: "Composition", scale: float = 1.0) -> None: 61 62 """Store composition reference for reading pattern state. 63 64 Parameters: 65 composition: The ``Composition`` instance to read running 66 patterns from. 67 scale: Horizontal zoom factor. Snapped to the nearest 68 integer ``cols_per_step`` so that all on-grid markers 69 are uniformly spaced. Default ``1.0`` = one visual 70 column per grid step (current behaviour). 71 """ 72 73 self._composition = composition 74 self._scale = scale 75 self._lines: typing.List[str] = []
Store composition reference for reading pattern state.
Arguments:
- composition: The
Compositioninstance to read running patterns from. - scale: Horizontal zoom factor. Snapped to the nearest
integer
cols_per_stepso that all on-grid markers are uniformly spaced. Default1.0= one visual column per grid step (current behaviour).
77 @property 78 def line_count (self) -> int: 79 80 """Number of terminal lines the grid currently occupies.""" 81 82 return len(self._lines)
Number of terminal lines the grid currently occupies.
145 def build (self) -> None: 146 147 """Rebuild grid lines from the current state of all running patterns.""" 148 149 lines: typing.List[str] = [] 150 term_width = shutil.get_terminal_size(fallback=(80, 24)).columns 151 152 if term_width < _MIN_TERMINAL_WIDTH: 153 self._lines = [] 154 return 155 156 for name, pattern in self._composition.running_patterns.items(): 157 158 grid_size = min(getattr(pattern, "_default_grid", 16), _MAX_GRID_COLUMNS) 159 muted = getattr(pattern, "_muted", False) 160 drum_map: typing.Optional[typing.Dict[str, int]] = getattr(pattern, "_drum_note_map", None) 161 162 # Snap to integer cols_per_step for uniform marker spacing. 163 cols_per_step = max(1, round(self._scale)) 164 visual_cols = grid_size * cols_per_step 165 display_cols = self._fit_columns(visual_cols, term_width) 166 167 # On-grid columns: exact multiples of cols_per_step. 168 on_grid = frozenset( 169 i * cols_per_step 170 for i in range(grid_size) 171 if i * cols_per_step < display_cols 172 ) 173 174 if muted: 175 lines.extend(self._render_muted(name, display_cols, on_grid)) 176 elif drum_map: 177 lines.extend(self._render_drum_pattern(name, pattern, drum_map, visual_cols, display_cols, on_grid)) 178 else: 179 lines.extend(self._render_pitched_pattern(name, pattern, visual_cols, display_cols, on_grid)) 180 181 self._lines = lines
Rebuild grid lines from the current state of all running patterns.
337class DisplayLogHandler (logging.Handler): 338 339 """Logging handler that clears and redraws the status line around log output. 340 341 Installed by ``Display.start()`` and removed by ``Display.stop()``. Ensures 342 log messages do not overwrite or corrupt the persistent status line. 343 """ 344 345 def __init__ (self, display: "Display") -> None: 346 347 """Store reference to the display for clear/redraw calls.""" 348 349 super().__init__() 350 self._display = display 351 352 def emit (self, record: logging.LogRecord) -> None: 353 354 """Clear the status line, write the log message, then redraw.""" 355 356 try: 357 # emit() runs on whatever thread logged; hold the display's render 358 # lock across the whole clear → write → redraw sequence so a 359 # concurrent event-loop draw() cannot interleave ANSI sequences. 360 with self._display._render_lock: 361 362 self._display.clear_line() 363 364 msg = self.format(record) 365 sys.stderr.write(msg + "\n") 366 sys.stderr.flush() 367 368 self._display.draw() 369 370 except Exception: 371 self.handleError(record)
Logging handler that clears and redraws the status line around log output.
Installed by Display.start() and removed by Display.stop(). Ensures
log messages do not overwrite or corrupt the persistent status line.
345 def __init__ (self, display: "Display") -> None: 346 347 """Store reference to the display for clear/redraw calls.""" 348 349 super().__init__() 350 self._display = display
Store reference to the display for clear/redraw calls.
352 def emit (self, record: logging.LogRecord) -> None: 353 354 """Clear the status line, write the log message, then redraw.""" 355 356 try: 357 # emit() runs on whatever thread logged; hold the display's render 358 # lock across the whole clear → write → redraw sequence so a 359 # concurrent event-loop draw() cannot interleave ANSI sequences. 360 with self._display._render_lock: 361 362 self._display.clear_line() 363 364 msg = self.format(record) 365 sys.stderr.write(msg + "\n") 366 sys.stderr.flush() 367 368 self._display.draw() 369 370 except Exception: 371 self.handleError(record)
Clear the status line, write the log message, then redraw.
374class Display: 375 376 """Live-updating terminal dashboard showing composition state. 377 378 Reads bar, section, chord, BPM, and key from the ``Composition`` and renders 379 a persistent region to stderr. When ``grid=True`` an ASCII pattern grid is 380 rendered above the status line. A custom ``DisplayLogHandler`` ensures log 381 messages scroll cleanly above the dashboard. 382 383 Example: 384 ```python 385 composition.display(grid=True) 386 composition.play() 387 ``` 388 """ 389 390 def __init__ (self, composition: "Composition", grid: bool = False, grid_scale: float = 1.0) -> None: 391 392 """Store composition reference for reading playback state. 393 394 Parameters: 395 composition: The ``Composition`` instance to read state from. 396 grid: When True, render an ASCII grid of running patterns 397 above the status line. 398 grid_scale: Horizontal zoom factor for the grid (default 399 ``1.0``). Snapped internally to the nearest integer 400 ``cols_per_step`` for uniform marker spacing. 401 """ 402 403 self._composition = composition 404 self._active: bool = False 405 self._handler: typing.Optional[DisplayLogHandler] = None 406 self._saved_handlers: typing.List[logging.Handler] = [] 407 self._last_line: str = "" 408 self._last_bar: typing.Optional[int] = None 409 self._cached_section: typing.Any = None 410 self._grid: typing.Optional[GridDisplay] = GridDisplay(composition, scale=grid_scale) if grid else None 411 self._last_grid_bar: typing.Optional[int] = None 412 self._drawn_line_count: int = 0 413 # Serialises terminal writes: update()/draw() run on the event loop, 414 # but DisplayLogHandler.emit() runs on whatever thread logged (e.g. 415 # the web UI's HTTP worker) — unsynchronised, an emit mid-draw would 416 # interleave ANSI sequences and garble the dashboard. RLock because 417 # emit() holds it across its clear_line() → write → draw() sequence. 418 self._render_lock = threading.RLock() 419 420 def start (self) -> None: 421 422 """Install the log handler and activate the display. 423 424 Saves existing root logger handlers and replaces them with a 425 ``DisplayLogHandler`` that clears/redraws the status line around 426 each log message. Original handlers are restored by ``stop()``. 427 """ 428 429 if self._active: 430 return 431 432 self._active = True 433 434 root_logger = logging.getLogger() 435 436 # Save existing handlers so we can restore them on stop. 437 self._saved_handlers = list(root_logger.handlers) 438 439 # Build the replacement handler, inheriting the formatter from the 440 # first existing handler (if any) for consistent log formatting. 441 self._handler = DisplayLogHandler(self) 442 443 if self._saved_handlers and self._saved_handlers[0].formatter: 444 self._handler.setFormatter(self._saved_handlers[0].formatter) 445 else: 446 self._handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) 447 448 root_logger.handlers.clear() 449 root_logger.addHandler(self._handler) 450 451 def stop (self) -> None: 452 453 """Clear the status line and restore original log handlers.""" 454 455 if not self._active: 456 return 457 458 self.clear_line() 459 self._active = False 460 461 root_logger = logging.getLogger() 462 root_logger.handlers.clear() 463 464 for handler in self._saved_handlers: 465 root_logger.addHandler(handler) 466 467 self._saved_handlers = [] 468 self._handler = None 469 470 def update (self, _: int = 0) -> None: 471 472 """Rebuild and redraw the dashboard; called on ``"bar"`` and ``"beat"`` events. 473 474 The integer argument (bar or beat number) is ignored — state is read directly from 475 the composition. 476 477 Note: "bar" and "beat" events are emitted as ``asyncio.create_task`` at the start 478 of each pulse, but the tasks only execute *after* ``_advance_pulse()`` completes 479 (which includes sending MIDI via ``_process_pulse()``). The display therefore 480 always trails the audio slightly — this is inherent to the architecture and cannot 481 be avoided without restructuring the sequencer loop. 482 """ 483 484 if not self._active: 485 return 486 487 self._last_line = self._format_status() 488 489 # Rebuild grid data only when the bar counter changes. 490 if self._grid is not None: 491 current_bar = self._composition.sequencer.current_bar 492 if current_bar != self._last_grid_bar: 493 self._last_grid_bar = current_bar 494 self._grid.build() 495 496 self.draw() 497 498 def draw (self) -> None: 499 500 """Write the current dashboard to the terminal.""" 501 502 if not self._active or not self._last_line: 503 return 504 505 with self._render_lock: 506 507 grid_lines = self._grid._lines if self._grid is not None else [] 508 total = len(grid_lines) + 1 # grid lines + status line 509 510 if grid_lines: 511 total += 1 # separator line 512 513 # Move cursor up to overwrite the previously drawn region. 514 # Cursor sits on the last line (status) with no trailing newline, 515 # so we move up (total - 1) to reach the first line. 516 if self._drawn_line_count > 1: 517 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 518 519 if grid_lines: 520 sep = "-" * len(grid_lines[0]) 521 sys.stderr.write(f"\r\033[K{sep}\n") 522 for line in grid_lines: 523 sys.stderr.write(f"\r\033[K{line}\n") 524 525 # Status line (no trailing newline — cursor stays on this line). 526 sys.stderr.write(f"\r\033[K{self._last_line}") 527 # Clear to end of screen in case the grid shrank since the last draw 528 # (e.g. a pattern disappeared), which would otherwise leave the old 529 # status line stranded one line below the new one. 530 sys.stderr.write("\033[J") 531 sys.stderr.flush() 532 533 self._drawn_line_count = total 534 535 def clear_line (self) -> None: 536 537 """Erase the entire dashboard region from the terminal.""" 538 539 if not self._active: 540 return 541 542 with self._render_lock: 543 544 if self._drawn_line_count > 1: 545 # Cursor is on the last line (no trailing newline). 546 # Move up (total - 1) to reach the first line. 547 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 548 549 # Clear each line. 550 for _ in range(self._drawn_line_count): 551 sys.stderr.write("\r\033[K\n") 552 553 # Move cursor back up to the starting position. 554 sys.stderr.write(f"\033[{self._drawn_line_count}A") 555 else: 556 sys.stderr.write("\r\033[K") 557 558 sys.stderr.flush() 559 self._drawn_line_count = 0 560 561 def _format_status (self) -> str: 562 563 """Build the status string from current composition state.""" 564 565 parts: typing.List[str] = [] 566 comp = self._composition 567 568 parts.append(f"{comp.sequencer.current_bpm:.2f} BPM") 569 570 if comp.key: 571 parts.append(f"Key: {comp.key}") 572 573 bar = max(0, comp.sequencer.current_bar) + 1 574 beat = max(0, comp.sequencer.current_beat) + 1 575 parts.append(f"Bar: {bar}.{beat}") 576 577 # Section info (only when form is configured). 578 # Cache refreshes only when the bar counter changes, keeping 579 # the section display in sync with the bar display even though 580 # the form state advances one beat early (due to lookahead). 581 if comp.form_state is not None: 582 current_bar = comp.sequencer.current_bar 583 584 if current_bar != self._last_bar: 585 self._last_bar = current_bar 586 self._cached_section = comp.form_state.get_section_info() 587 588 section = self._cached_section 589 590 if section: 591 section_str = f"[{section.name} {section.bar + 1}/{section.bars}" 592 if section.next_section: 593 section_str += f" \u2192 {section.next_section}" 594 section_str += "]" 595 parts.append(section_str) 596 else: 597 parts.append("[form finished]") 598 599 # Current chord (only when harmony is configured). Reads the harmony 600 # window at the playhead, so it tracks sub-bar harmonic rhythm and 601 # covers progression-only mode (no engine) — the display refreshes 602 # per beat, which bounds how stale it can be. 603 chord = comp.current_chord() 604 if chord is not None: 605 parts.append(f"Chord: {chord.name()}") 606 607 # Conductor signals (when any are registered). builder_bar is the 608 # lookahead bar - deliberately the same time base the pattern builders 609 # read, so the status line shows the values shaping what you are about 610 # to hear (the section line above shows playing-bar time instead). 611 conductor = comp.conductor 612 if conductor.signal_names: 613 beat = comp.builder_bar * comp.sequencer.time_signature[0] 614 for name in conductor.signal_names: 615 value = conductor.get(name, beat) 616 parts.append(f"{name.title()}: {value:.2f}") 617 618 return " ".join(parts)
Live-updating terminal dashboard showing composition state.
Reads bar, section, chord, BPM, and key from the Composition and renders
a persistent region to stderr. When grid=True an ASCII pattern grid is
rendered above the status line. A custom DisplayLogHandler ensures log
messages scroll cleanly above the dashboard.
Example:
composition.display(grid=True) composition.play()
390 def __init__ (self, composition: "Composition", grid: bool = False, grid_scale: float = 1.0) -> None: 391 392 """Store composition reference for reading playback state. 393 394 Parameters: 395 composition: The ``Composition`` instance to read state from. 396 grid: When True, render an ASCII grid of running patterns 397 above the status line. 398 grid_scale: Horizontal zoom factor for the grid (default 399 ``1.0``). Snapped internally to the nearest integer 400 ``cols_per_step`` for uniform marker spacing. 401 """ 402 403 self._composition = composition 404 self._active: bool = False 405 self._handler: typing.Optional[DisplayLogHandler] = None 406 self._saved_handlers: typing.List[logging.Handler] = [] 407 self._last_line: str = "" 408 self._last_bar: typing.Optional[int] = None 409 self._cached_section: typing.Any = None 410 self._grid: typing.Optional[GridDisplay] = GridDisplay(composition, scale=grid_scale) if grid else None 411 self._last_grid_bar: typing.Optional[int] = None 412 self._drawn_line_count: int = 0 413 # Serialises terminal writes: update()/draw() run on the event loop, 414 # but DisplayLogHandler.emit() runs on whatever thread logged (e.g. 415 # the web UI's HTTP worker) — unsynchronised, an emit mid-draw would 416 # interleave ANSI sequences and garble the dashboard. RLock because 417 # emit() holds it across its clear_line() → write → draw() sequence. 418 self._render_lock = threading.RLock()
Store composition reference for reading playback state.
Arguments:
- composition: The
Compositioninstance to read state from. - grid: When True, render an ASCII grid of running patterns above the status line.
- grid_scale: Horizontal zoom factor for the grid (default
1.0). Snapped internally to the nearest integercols_per_stepfor uniform marker spacing.
420 def start (self) -> None: 421 422 """Install the log handler and activate the display. 423 424 Saves existing root logger handlers and replaces them with a 425 ``DisplayLogHandler`` that clears/redraws the status line around 426 each log message. Original handlers are restored by ``stop()``. 427 """ 428 429 if self._active: 430 return 431 432 self._active = True 433 434 root_logger = logging.getLogger() 435 436 # Save existing handlers so we can restore them on stop. 437 self._saved_handlers = list(root_logger.handlers) 438 439 # Build the replacement handler, inheriting the formatter from the 440 # first existing handler (if any) for consistent log formatting. 441 self._handler = DisplayLogHandler(self) 442 443 if self._saved_handlers and self._saved_handlers[0].formatter: 444 self._handler.setFormatter(self._saved_handlers[0].formatter) 445 else: 446 self._handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) 447 448 root_logger.handlers.clear() 449 root_logger.addHandler(self._handler)
Install the log handler and activate the display.
Saves existing root logger handlers and replaces them with a
DisplayLogHandler that clears/redraws the status line around
each log message. Original handlers are restored by stop().
451 def stop (self) -> None: 452 453 """Clear the status line and restore original log handlers.""" 454 455 if not self._active: 456 return 457 458 self.clear_line() 459 self._active = False 460 461 root_logger = logging.getLogger() 462 root_logger.handlers.clear() 463 464 for handler in self._saved_handlers: 465 root_logger.addHandler(handler) 466 467 self._saved_handlers = [] 468 self._handler = None
Clear the status line and restore original log handlers.
470 def update (self, _: int = 0) -> None: 471 472 """Rebuild and redraw the dashboard; called on ``"bar"`` and ``"beat"`` events. 473 474 The integer argument (bar or beat number) is ignored — state is read directly from 475 the composition. 476 477 Note: "bar" and "beat" events are emitted as ``asyncio.create_task`` at the start 478 of each pulse, but the tasks only execute *after* ``_advance_pulse()`` completes 479 (which includes sending MIDI via ``_process_pulse()``). The display therefore 480 always trails the audio slightly — this is inherent to the architecture and cannot 481 be avoided without restructuring the sequencer loop. 482 """ 483 484 if not self._active: 485 return 486 487 self._last_line = self._format_status() 488 489 # Rebuild grid data only when the bar counter changes. 490 if self._grid is not None: 491 current_bar = self._composition.sequencer.current_bar 492 if current_bar != self._last_grid_bar: 493 self._last_grid_bar = current_bar 494 self._grid.build() 495 496 self.draw()
Rebuild and redraw the dashboard; called on "bar" and "beat" events.
The integer argument (bar or beat number) is ignored — state is read directly from the composition.
Note: "bar" and "beat" events are emitted as asyncio.create_task at the start
of each pulse, but the tasks only execute after _advance_pulse() completes
(which includes sending MIDI via _process_pulse()). The display therefore
always trails the audio slightly — this is inherent to the architecture and cannot
be avoided without restructuring the sequencer loop.
498 def draw (self) -> None: 499 500 """Write the current dashboard to the terminal.""" 501 502 if not self._active or not self._last_line: 503 return 504 505 with self._render_lock: 506 507 grid_lines = self._grid._lines if self._grid is not None else [] 508 total = len(grid_lines) + 1 # grid lines + status line 509 510 if grid_lines: 511 total += 1 # separator line 512 513 # Move cursor up to overwrite the previously drawn region. 514 # Cursor sits on the last line (status) with no trailing newline, 515 # so we move up (total - 1) to reach the first line. 516 if self._drawn_line_count > 1: 517 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 518 519 if grid_lines: 520 sep = "-" * len(grid_lines[0]) 521 sys.stderr.write(f"\r\033[K{sep}\n") 522 for line in grid_lines: 523 sys.stderr.write(f"\r\033[K{line}\n") 524 525 # Status line (no trailing newline — cursor stays on this line). 526 sys.stderr.write(f"\r\033[K{self._last_line}") 527 # Clear to end of screen in case the grid shrank since the last draw 528 # (e.g. a pattern disappeared), which would otherwise leave the old 529 # status line stranded one line below the new one. 530 sys.stderr.write("\033[J") 531 sys.stderr.flush() 532 533 self._drawn_line_count = total
Write the current dashboard to the terminal.
535 def clear_line (self) -> None: 536 537 """Erase the entire dashboard region from the terminal.""" 538 539 if not self._active: 540 return 541 542 with self._render_lock: 543 544 if self._drawn_line_count > 1: 545 # Cursor is on the last line (no trailing newline). 546 # Move up (total - 1) to reach the first line. 547 sys.stderr.write(f"\033[{self._drawn_line_count - 1}A") 548 549 # Clear each line. 550 for _ in range(self._drawn_line_count): 551 sys.stderr.write("\r\033[K\n") 552 553 # Move cursor back up to the starting position. 554 sys.stderr.write(f"\033[{self._drawn_line_count}A") 555 else: 556 sys.stderr.write("\r\033[K") 557 558 sys.stderr.flush() 559 self._drawn_line_count = 0
Erase the entire dashboard region from the terminal.