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.
- backbone/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
backbone/prompt/audio.py
ADDED
|
@@ -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
|