backpack-backbone 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,496 @@
1
+ """Audio value editors: the volume adjustment (RVA2) and the equaliser."""
2
+ from __future__ import annotations
3
+ import sys
4
+ from backbone.prompt.core import (
5
+ _get_term_attrs, _set_raw, _restore_term_attrs, _wait_for_keypress, _read_key, _cols,
6
+ _Widget, _hint_pin_target, screen_takeover_next,
7
+ )
8
+ from backbone import keys, ui
9
+ from backbone.nav import QuitToTerminal
10
+ from backbone.prompt.chrome import (
11
+ append_chrome, CHROME_HANDLED, chrome_hint_lines, CHROME_REDRAW, consume_chrome, disable_mouse, enable_mouse,
12
+ )
13
+ from backbone.prompt.text import text
14
+ from backbone.prompt.core import C
15
+
16
+
17
+ # ─── Graphic equaliser widget (EQU2) ────────────────────────────────────────
18
+ _EQ_GAIN_MAX = 12.0
19
+
20
+
21
+ _EQ_STEP = 0.5
22
+
23
+
24
+ _EQ_COARSE = 3.0
25
+
26
+
27
+ _EQ_ISO_BANDS = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
28
+
29
+
30
+ _EQ_PRESETS = [
31
+ ("Flat", {}),
32
+ ("Bass boost", {31: 6, 62: 5, 125: 3, 250: 1}),
33
+ ("Treble boost",{4000: 2, 8000: 4, 16000: 6}),
34
+ ("V-shape", {31: 5, 62: 4, 125: 2, 500: -2, 1000: -3, 2000: -2, 8000: 4, 16000: 5}),
35
+ ("Vocal", {250: 1, 500: 2, 1000: 3, 2000: 3, 4000: 2}),
36
+ ("Loudness", {31: 6, 62: 4, 8000: 3, 16000: 5}),
37
+ ("Rock", {31: 4, 62: 3, 125: 2, 500: -1, 1000: -1, 2000: 1, 4000: 3, 8000: 4, 16000: 4}),
38
+ ("Pop", {125: 2, 250: 3, 500: 4, 1000: 2, 2000: 1, 4000: 1}),
39
+ ("Jazz", {31: 3, 62: 2, 500: 1, 1000: 2, 2000: 2, 4000: 1, 8000: 2}),
40
+ ("Classical", {31: 3, 62: 2, 125: 1, 4000: 1, 8000: 2, 16000: 3}),
41
+ ("Electronic", {31: 5, 62: 4, 125: 2, 1000: -1, 4000: 2, 8000: 3, 16000: 4}),
42
+ ("Hip-hop", {31: 6, 62: 5, 125: 3, 250: 1, 1000: -1, 2000: 1, 4000: 2}),
43
+ ("R&B", {31: 4, 62: 4, 125: 2, 500: 2, 1000: 1, 2000: 2, 4000: 2, 8000: 3}),
44
+ ("Acoustic", {62: 2, 125: 3, 250: 3, 500: 2, 1000: 2, 2000: 2, 4000: 1}),
45
+ ("Dance", {31: 4, 62: 5, 125: 3, 500: -1, 1000: -1, 4000: 3, 8000: 4, 16000: 3}),
46
+ ("Country", {125: 2, 250: 2, 500: 1, 2000: 2, 4000: 3, 8000: 2}),
47
+ ("Metal", {31: 5, 62: 4, 125: 1, 500: -2, 1000: -2, 2000: 1, 4000: 4, 8000: 5, 16000: 5}),
48
+ ("Folk", {125: 2, 250: 3, 500: 2, 1000: 2, 2000: 1, 4000: 1, 8000: 1}),
49
+ ("Latin", {31: 3, 62: 2, 250: 2, 500: 2, 1000: 2, 2000: 3, 4000: 2, 8000: 2}),
50
+ ("Speech", {250: 2, 500: 3, 1000: 4, 2000: 4, 4000: 3, 31: -3, 62: -2, 16000: -2}),
51
+ ("Bass cut", {31: -6, 62: -5, 125: -3, 250: -1}),
52
+ ("Treble cut", {4000: -2, 8000: -4, 16000: -6}),
53
+ ("Piano", {62: 2, 125: 2, 250: 1, 500: 1, 1000: 2, 2000: 3, 4000: 3, 8000: 2, 16000: 1}),
54
+ ("Night mode", {31: -4, 62: -3, 125: -2, 4000: -1, 8000: -2, 16000: -3}),
55
+ ]
56
+
57
+
58
+ _EQ_UP_BLOCKS = ' ▁▂▃▄▅▆▇'
59
+
60
+
61
+
62
+ # The volume-adjustment (RVA2) and equaliser editors.
63
+ keys.define("levels", "Volume and equaliser", [
64
+ ("up", ("UP",), "raise"),
65
+ ("down", ("DOWN",), "lower"),
66
+ ("up_big", ("PGUP",), "raise by 3"),
67
+ ("down_big", ("PGDN",), "lower by 3"),
68
+ ("zero", ("0",), "back to zero"),
69
+ ("prev_band", ("LEFT",), "previous band (equaliser)"),
70
+ ("next_band", ("RIGHT",), "next band (equaliser)"),
71
+ ("add", ("a", "A"), "add a band (equaliser)"),
72
+ ("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band (equaliser)"),
73
+ ("flat", ("f", "F"), "flatten every band (equaliser)"),
74
+ ("preset", ("p", "P"), "next preset (equaliser)"),
75
+ ("save", ("ENTER",), "save"),
76
+ ("back", ("ESC",), "back"),
77
+ ], within=("list", "global"))
78
+
79
+
80
+ def _eq_fmt_freq(freq: float) -> str:
81
+ """Format a band frequency compactly, e.g. 1000 -> '1k', 1500 -> '1.5k'."""
82
+ f = int(round(freq))
83
+ if f >= 1000:
84
+ k = f / 1000.0
85
+ return f"{k:.0f}k" if k == int(k) else f"{k:.1f}k"
86
+ return str(f)
87
+
88
+
89
+ def _eq_render_lines(bands: list, cursor: int, message: str, status: str,
90
+ cols: int, rows: int, show_curve: bool = True) -> list[str]:
91
+ """Render the graphic-EQ plot: vertical bands from a 0 dB baseline, a dim
92
+ response curve through the band tops, dB axis and frequency labels."""
93
+ out = [
94
+ f" {C.DIM}{message}{C.RESET}",
95
+ f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}",
96
+ ]
97
+ n = len(bands)
98
+ plot_w = max(10, cols - 5) # 4 cols for the dB label + 1 gap
99
+ avail = rows - 9
100
+ # Three rows either side of the baseline is the comfortable minimum, but on a
101
+ # very short terminal (especially with the now-playing box taking rows), holding
102
+ # that floor pushed the plot over its budget and into the now-playing box. Give
103
+ # ground to 1 row per side rather than overrun: coarse, but still readable,
104
+ # and the numbers beside it stay exact.
105
+ half_h = max(3, min(8, avail // 2)) if avail > 6 else max(1, min(3, avail // 2))
106
+ db_per_row = _EQ_GAIN_MAX / half_h
107
+ baseline = half_h
108
+ total_rows = 2 * half_h + 1
109
+
110
+ band_x = _eq_band_x(n, plot_w)
111
+ x_to_band = {x: i for i, x in enumerate(band_x)}
112
+
113
+ # Interpolated response curve (linear in dB between adjacent band centres).
114
+ curve: list = [None] * plot_w
115
+ if show_curve and n >= 1:
116
+ for x in range(plot_w):
117
+ if x <= band_x[0]:
118
+ curve[x] = bands[0][1]
119
+ elif x >= band_x[-1]:
120
+ curve[x] = bands[-1][1]
121
+ else:
122
+ for j in range(n - 1):
123
+ if band_x[j] <= x <= band_x[j + 1]:
124
+ x0, x1, g0, g1 = band_x[j], band_x[j + 1], bands[j][1], bands[j + 1][1]
125
+ t = (x - x0) / (x1 - x0) if x1 > x0 else 0.0
126
+ curve[x] = g0 + (g1 - g0) * t
127
+ break
128
+
129
+ def _band_char(i: int, r: int):
130
+ """Glyph for band `i` at plot row `r`: full/partial block for the bar, or None outside it."""
131
+ g = bands[i][1]
132
+ if g >= 0:
133
+ cells = g / db_per_row
134
+ full = int(cells)
135
+ frac = cells - full
136
+ if r == baseline and full == 0 and frac <= 0.06:
137
+ return '▪' # zero-gain node
138
+ if baseline - full <= r <= baseline:
139
+ return '█'
140
+ if r == baseline - full - 1 and frac > 0.06:
141
+ return _EQ_UP_BLOCKS[max(1, min(7, round(frac * 8)))]
142
+ return None
143
+ cells = (-g) / db_per_row
144
+ full = int(cells)
145
+ frac = cells - full
146
+ if baseline <= r <= baseline + full:
147
+ return '█'
148
+ if r == baseline + full + 1 and frac >= 0.5:
149
+ return '▀'
150
+ return None
151
+
152
+ db_labels = {0: f"+{int(_EQ_GAIN_MAX)}", baseline: "0", 2 * half_h: f"-{int(_EQ_GAIN_MAX)}"}
153
+ if half_h >= 4:
154
+ db_labels[half_h // 2] = f"+{int(_EQ_GAIN_MAX / 2)}"
155
+ db_labels[half_h + half_h // 2] = f"-{int(_EQ_GAIN_MAX / 2)}"
156
+
157
+ for r in range(total_rows):
158
+ cells = []
159
+ for x in range(plot_w):
160
+ ch, color = ' ', None
161
+ if x in x_to_band:
162
+ bc = _band_char(x_to_band[x], r)
163
+ if bc:
164
+ ch = bc
165
+ color = C.ACCENT if x_to_band[x] == cursor else C.DIM
166
+ if ch == ' ':
167
+ if r == baseline:
168
+ ch, color = '─', C.DIM
169
+ elif curve[x] is not None and round(baseline - curve[x] / db_per_row) == r:
170
+ ch, color = '·', C.DIM
171
+ cells.append(f"{color}{ch}{C.RESET}" if color else ch)
172
+ lbl = db_labels.get(r, "")
173
+ out.append(f"{C.DIM}{lbl:>3}{C.RESET} " + "".join(cells))
174
+
175
+ # Frequency labels + selection caret beneath the plot.
176
+ axis = [' '] * plot_w
177
+ caret = [' '] * plot_w
178
+ for i, x in enumerate(band_x):
179
+ lab = _eq_fmt_freq(bands[i][0])
180
+ for k, c in enumerate(lab):
181
+ xx = x - len(lab) // 2 + k
182
+ if 0 <= xx < plot_w:
183
+ axis[xx] = c
184
+ if i == cursor and 0 <= x < plot_w:
185
+ caret[x] = '▲'
186
+ out.append(" " + f"{C.DIM}{''.join(axis)}{C.RESET}")
187
+ out.append(" " + f"{C.ACCENT}{''.join(caret)}{C.RESET}")
188
+ out.append("")
189
+ out.append(f" {status}")
190
+ return out
191
+
192
+
193
+ _RVA2_GAIN_MAX = 12.0
194
+
195
+
196
+ _RVA2_STEP = 0.5
197
+
198
+
199
+ _RVA2_COARSE = 3.0
200
+
201
+
202
+ def _rva2_render_lines(gain: float, message: str, avail: int | None = None) -> list[str]:
203
+ """Narrow vertical gain meter: 1 row per dB, half-block for 0.5 dB precision.
204
+
205
+ Each row at integer `db` is centered on that dB value and spans ±0.5 dB:
206
+ Boost rows (db > 0): bar fills upward; ▄ lights first (bottom half, at gain ≥ db−0.5),
207
+ then █ when gain ≥ db.
208
+ Cut rows (db < 0): bar fills downward; ▀ lights first (top half, at gain ≤ db+0.5),
209
+ then █ when gain ≤ db.
210
+ Every 0.5 dB step changes a visible half-block, so no increment is invisible.
211
+ """
212
+ out = [
213
+ f" {C.DIM}{message}{C.RESET}",
214
+ f"{C.DIM}{'─' * 20}{C.RESET}",
215
+ ]
216
+
217
+ # One row per dB is the ideal, but the meter must still fit above the hint
218
+ # bar and the now-playing box: on a short terminal it would otherwise run off the
219
+ # bottom and take its own hints with it. Widen the dB-per-row step until the
220
+ # scale fits, keeping it symmetric so 0 dB always lands on a row.
221
+ peak = int(_RVA2_GAIN_MAX)
222
+ step = 1
223
+ if avail and avail > 0:
224
+ while 2 * (peak // step) + 1 > avail and step < peak:
225
+ step += 1
226
+ ups = list(range(0, peak + 1, step))
227
+ scale = [d for d in reversed(ups) if d > 0] + [0] + [-d for d in ups if d > 0]
228
+ half = step / 2.0
229
+
230
+ for i, db in enumerate(scale):
231
+ # Label every other row (and always the ends and 0) so the axis stays
232
+ # readable whatever step we settled on.
233
+ show = (db == 0 or i == 0 or i == len(scale) - 1 or (db % (step * 2) == 0))
234
+ lbl = (f"{db:+d}" if db != 0 else " 0") if show else ""
235
+
236
+ if db == 0:
237
+ bar = f"{C.DIM}──{C.RESET}"
238
+ elif db > 0:
239
+ if gain >= db:
240
+ bar = f"{C.ACCENT}██{C.RESET}"
241
+ elif gain >= db - half:
242
+ bar = f"{C.ACCENT}▄▄{C.RESET}" # bottom half: bar just entered this row
243
+ else:
244
+ bar = " "
245
+ else: # db < 0
246
+ if gain <= db:
247
+ bar = f"{C.DIM}██{C.RESET}"
248
+ elif gain <= db + half:
249
+ bar = f"{C.DIM}▀▀{C.RESET}" # top half: bar just entered this row
250
+ else:
251
+ bar = " "
252
+
253
+ out.append(f" {C.DIM}{lbl:>3}{C.RESET} {bar}")
254
+
255
+ out.append("")
256
+ return out
257
+
258
+
259
+ def rva2_edit(message: str = "Volume adjustment:", gain: float = 0.0) -> float | None:
260
+ """Interactive vertical gain meter for an RVA2 frame.
261
+ Returns the chosen gain in dB, or None if cancelled.
262
+ """
263
+ gain = max(-_RVA2_GAIN_MAX, min(_RVA2_GAIN_MAX, gain))
264
+ fd = sys.stdin.fileno()
265
+ old = _get_term_attrs(fd)
266
+ w = _Widget(fd)
267
+ _hint_cells: dict = {} # clickable hint keys, filled by append_chrome
268
+
269
+ def _clamp(g: float) -> float:
270
+ return max(-_RVA2_GAIN_MAX, min(_RVA2_GAIN_MAX, round(g * 2) / 2))
271
+
272
+ def _render():
273
+ # Budget the meter against the rows left once this widget's own chrome
274
+ # (message, rule, readout, trailing blank) and the hint bar (which grows
275
+ # by two lines when the transport keys join it) are accounted for.
276
+ L = keys.label
277
+ _pairs = [(L("levels.up", "levels.down"), "adjust"), (L("levels.up_big", "levels.down_big"), "±3 dB"),
278
+ (L("levels.zero"), "zero"), (L("levels.save"), "save"), (L("levels.back"), "back"),
279
+ (L("list.quit"), "quit app")]
280
+ _avail = _hint_pin_target() - 4 - len(chrome_hint_lines(_pairs))
281
+ lines = _rva2_render_lines(gain, message, avail=_avail)
282
+ lines.append(f" {C.ACCENT}▸{C.RESET} {C.BOLD}{gain:+.1f} dB{C.RESET}")
283
+ append_chrome(lines, _pairs, _hint_cells)
284
+ w.render(lines)
285
+
286
+ result = None
287
+ try:
288
+ _set_raw(fd)
289
+ enable_mouse()
290
+ screen_takeover_next() # paint over the previous screen, no flash
291
+ _render()
292
+
293
+ while True:
294
+ if ui.consume_resize():
295
+ ui.clear_screen()
296
+ w.anchor_reset()
297
+ _render()
298
+ continue
299
+ if not _wait_for_keypress(0.05):
300
+ continue
301
+
302
+ key = _read_key(fd)
303
+ # A transport key, a click on the now-playing box, or a click on one of our
304
+ # own hint keys, handled the same way on every screen.
305
+ _ch = consume_chrome(key, _hint_cells)
306
+ if _ch is CHROME_HANDLED:
307
+ continue
308
+ if _ch is CHROME_REDRAW:
309
+ w.anchor_reset(); _render(); continue
310
+ if _ch is not None:
311
+ key = _ch
312
+
313
+ act = keys.action(key, "levels")
314
+ if act == 'levels.save':
315
+ result = gain; break
316
+ elif key == 'CTRL_C' or act == 'levels.back': # Ctrl-C cancels, as in every widget
317
+ result = None; break
318
+ elif act == 'list.quit':
319
+ raise QuitToTerminal() # q quits the app; it never just leaves a widget
320
+ elif act == 'levels.up' or key == 'SCROLL_UP':
321
+ gain = _clamp(gain + _RVA2_STEP); _render()
322
+ elif act == 'levels.down' or key == 'SCROLL_DOWN':
323
+ gain = _clamp(gain - _RVA2_STEP); _render()
324
+ elif act == 'levels.up_big':
325
+ gain = _clamp(gain + _RVA2_COARSE); _render()
326
+ elif act == 'levels.down_big':
327
+ gain = _clamp(gain - _RVA2_COARSE); _render()
328
+ elif act == 'levels.zero':
329
+ gain = 0.0; _render()
330
+
331
+ finally:
332
+ disable_mouse()
333
+ _restore_term_attrs(fd, old)
334
+ w.clear()
335
+
336
+ return result
337
+
338
+
339
+ def _eq_band_x(n: int, plot_w: int) -> list[int]:
340
+ """Plot column of each of `n` bands, for drawing them and for clicks."""
341
+ return [min(plot_w - 1, int((i + 0.5) * plot_w / n)) for i in range(n)] if n else []
342
+
343
+
344
+ def equaliser_edit(message: str = "Equalisation:", adjustments: list | None = None) -> list | None:
345
+ """Interactive graphic equaliser for an EQU2 frame.
346
+
347
+ Bands start from the standard ISO set merged with any existing custom
348
+ frequencies. Returns a list of (frequency_hz, gain_db) for non-zero bands,
349
+ or None if cancelled.
350
+ """
351
+ bands = [[float(f), float(g)] for f, g in (adjustments or [])]
352
+ present = {round(f) for f, _ in bands}
353
+ for f in _EQ_ISO_BANDS:
354
+ if f not in present:
355
+ bands.append([float(f), 0.0])
356
+ bands.sort(key=lambda b: b[0])
357
+
358
+ cursor = 0
359
+ preset_idx = -1
360
+ note = ""
361
+ fd = sys.stdin.fileno()
362
+ old = _get_term_attrs(fd)
363
+ w = _Widget(fd)
364
+ _hint_cells: dict = {} # clickable hint keys, filled by append_chrome
365
+
366
+ def _clamp(g: float) -> float:
367
+ return max(-_EQ_GAIN_MAX, min(_EQ_GAIN_MAX, g))
368
+
369
+ def _save() -> list:
370
+ return [(float(f), round(g, 1)) for f, g in bands if abs(g) > 1e-9]
371
+
372
+ def _render():
373
+ nonlocal cursor
374
+ n = len(bands)
375
+ if n:
376
+ cursor = max(0, min(cursor, n - 1))
377
+ f, g = bands[cursor]
378
+ status = f"{C.ACCENT}▸{C.RESET} {_eq_fmt_freq(f)} Hz {C.BOLD}{g:+.1f} dB{C.RESET}"
379
+ if note:
380
+ status += f" {C.DIM}· {note}{C.RESET}"
381
+ else:
382
+ status = f"{C.DIM}no bands: [{keys.label('levels.add', first=True)}] add one{C.RESET}"
383
+ # Size the plot to the rows left above the pinned hint bar and the
384
+ # now-playing box, not to the whole terminal.
385
+ L = keys.label
386
+ _pairs = [(L("levels.up", "levels.down"), "gain"), (L("levels.prev_band", "levels.next_band"), "band"),
387
+ (L("levels.up_big", "levels.down_big"), "±3"), (L("levels.add"), "add"),
388
+ (L("levels.delete", most=1), "delete"), (L("levels.zero"), "zero"), (L("levels.flat"), "flat"),
389
+ (L("levels.preset"), "preset"), (L("levels.save"), "save"), (L("levels.back"), "back"),
390
+ (L("list.quit"), "quit app")]
391
+ lines = _eq_render_lines(bands, cursor, message, status, _cols(),
392
+ _hint_pin_target() - len(chrome_hint_lines(_pairs)))
393
+ append_chrome(lines, _pairs, _hint_cells, help_key=True)
394
+ w.render(lines)
395
+
396
+ result = None
397
+ try:
398
+ _set_raw(fd)
399
+ enable_mouse()
400
+ screen_takeover_next() # paint over the previous screen, no flash
401
+ _render()
402
+
403
+ while True:
404
+ if ui.consume_resize():
405
+ ui.clear_screen()
406
+ w.anchor_reset()
407
+ _render()
408
+ continue
409
+ if not _wait_for_keypress(0.05):
410
+ continue
411
+
412
+ key = _read_key(fd)
413
+ # A transport key, a click on the now-playing box, or a click on one of our
414
+ # own hint keys, handled the same way on every screen.
415
+ _ch = consume_chrome(key, _hint_cells)
416
+ if _ch is CHROME_HANDLED:
417
+ continue
418
+ if _ch is CHROME_REDRAW:
419
+ w.anchor_reset(); _render(); continue
420
+ if _ch is not None:
421
+ key = _ch
422
+ n = len(bands)
423
+
424
+ act = keys.action(key, "levels")
425
+ if act == 'levels.save':
426
+ result = _save(); break
427
+ elif key == 'CTRL_C' or act == 'levels.back': # Ctrl-C cancels, as in every widget
428
+ result = None; break
429
+ elif act == 'list.quit':
430
+ raise QuitToTerminal() # q quits the app; it never just leaves a widget
431
+ elif act == 'levels.prev_band' and n:
432
+ cursor = (cursor - 1) % n; note = ""; _render()
433
+ elif act == 'levels.next_band' and n:
434
+ cursor = (cursor + 1) % n; note = ""; _render()
435
+ elif act == 'levels.up' and n:
436
+ bands[cursor][1] = _clamp(bands[cursor][1] + _EQ_STEP); _render()
437
+ elif act == 'levels.down' and n:
438
+ bands[cursor][1] = _clamp(bands[cursor][1] - _EQ_STEP); _render()
439
+ elif act == 'levels.up_big' and n:
440
+ bands[cursor][1] = _clamp(bands[cursor][1] + _EQ_COARSE); _render()
441
+ elif act == 'levels.down_big' and n:
442
+ bands[cursor][1] = _clamp(bands[cursor][1] - _EQ_COARSE); _render()
443
+ elif key == 'SCROLL_UP' and n:
444
+ bands[cursor][1] = _clamp(bands[cursor][1] + _EQ_STEP); _render()
445
+ elif key == 'SCROLL_DOWN' and n:
446
+ bands[cursor][1] = _clamp(bands[cursor][1] - _EQ_STEP); _render()
447
+ elif act == 'levels.zero' and n:
448
+ bands[cursor][1] = 0.0; _render()
449
+ elif act == 'levels.flat':
450
+ for b in bands:
451
+ b[1] = 0.0
452
+ note = "flattened"; _render()
453
+ elif act == 'levels.preset':
454
+ preset_idx = (preset_idx + 1) % len(_EQ_PRESETS)
455
+ name, gains = _EQ_PRESETS[preset_idx]
456
+ bands[:] = [[float(f), float(gains.get(f, 0.0))] for f in _EQ_ISO_BANDS]
457
+ note = f"preset: {name}"; _render()
458
+ elif act == 'levels.add':
459
+ _restore_term_attrs(fd, old)
460
+ disable_mouse()
461
+ freq_str = text("Add band frequency (Hz):")
462
+ _set_raw(fd)
463
+ enable_mouse()
464
+ screen_takeover_next() # paint over the previous screen, no flash
465
+ w.anchor_reset()
466
+ if freq_str:
467
+ try:
468
+ f = float(freq_str.strip())
469
+ if f > 0 and round(f) not in {round(b[0]) for b in bands}:
470
+ bands.append([f, 0.0])
471
+ bands.sort(key=lambda b: b[0])
472
+ cursor = next(i for i, b in enumerate(bands) if round(b[0]) == round(f))
473
+ note = ""
474
+ except ValueError:
475
+ pass
476
+ _render()
477
+ elif act == 'levels.delete' and n:
478
+ bands.pop(cursor)
479
+ cursor = min(cursor, len(bands) - 1) if bands else 0
480
+ note = ""; _render()
481
+ elif key.startswith('MOUSE_CLICK:') and n:
482
+ parts = key.split(':')
483
+ col = int(parts[3]) if len(parts) > 3 else 1
484
+ plot_w = max(10, _cols() - 5)
485
+ x = col - 5 # the 3-col dB label + a space; the plot starts at col 5
486
+ if 0 <= x < plot_w:
487
+ bx = _eq_band_x(n, plot_w)
488
+ cursor = min(range(n), key=lambda i: abs(bx[i] - x))
489
+ note = ""; _render()
490
+
491
+ finally:
492
+ disable_mouse()
493
+ _restore_term_attrs(fd, old)
494
+ w.clear()
495
+
496
+ return result