Skip to content

Commit 34bc59a

Browse files
committed
Add VU meter and replace master volume slider with rotary knob
- Add peak level measurement in audio callback for real-time VU display - Create VUMeter widget with green/yellow/red segmented bar and peak hold - Replace tk.Scale master volume with RotaryKnob in STATUS panel - 50ms periodic timer reads engine.peak_level to animate the meter
1 parent 4a0da45 commit 34bc59a

2 files changed

Lines changed: 104 additions & 14 deletions

File tree

synth/engine/audio_engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(self):
1414
self._stream: sd.OutputStream | None = None
1515
self._running = False
1616
self._master_volume = 0.8
17+
self._peak_level = 0.0
1718

1819
def start(self):
1920
self._running = True
@@ -56,6 +57,9 @@ def _audio_callback(self, outdata, frames, time_info, status):
5657
# Soft clip to avoid harsh clipping
5758
out = np.tanh(out)
5859

60+
# Measure peak level for VU meter
61+
self._peak_level = float(np.max(np.abs(out)))
62+
5963
outdata[:, 0] = out.astype(np.float32)
6064

6165
def _process_midi(self, msg):
@@ -89,6 +93,10 @@ def _process_cc(self, channel: Channel, cc: int, value: int):
8993
elif cc == 120 or cc == 123: # All Sound Off / All Notes Off
9094
channel.all_notes_off()
9195

96+
@property
97+
def peak_level(self) -> float:
98+
return self._peak_level
99+
92100
@property
93101
def master_volume(self) -> float:
94102
return self._master_volume

synth/gui/app.py

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,79 @@ def _on_scroll(self, event):
205205
self._cmd(self._value)
206206

207207

208+
# ── VU Meter widget ───────────────────────────────────────────────
209+
210+
class VUMeter(tk.Canvas):
211+
"""Vertical VU meter with green→yellow→red gradient and peak hold."""
212+
213+
_DB_MIN = -60.0
214+
_DB_MAX = 0.0
215+
_PEAK_DECAY = 0.6 # dB drop per update tick
216+
217+
def __init__(self, parent, width=20, height=100):
218+
super().__init__(parent, width=width, height=height,
219+
bg=BG_PANEL, highlightthickness=0)
220+
self._vu_w = width
221+
self._vu_h = height
222+
self._level_db = self._DB_MIN
223+
self._peak_db = self._DB_MIN
224+
self._draw()
225+
226+
def set_level(self, linear):
227+
"""Set the current level from a linear 0..1+ value."""
228+
if linear < 1e-10:
229+
self._level_db = self._DB_MIN
230+
else:
231+
self._level_db = max(self._DB_MIN,
232+
min(self._DB_MAX, 20.0 * math.log10(linear)))
233+
# Peak hold: only rise instantly, decay slowly
234+
if self._level_db > self._peak_db:
235+
self._peak_db = self._level_db
236+
else:
237+
self._peak_db = max(self._DB_MIN,
238+
self._peak_db - self._PEAK_DECAY)
239+
self._draw()
240+
241+
def _db_to_y(self, db):
242+
"""Map dB value to y coordinate (0=top, height=bottom)."""
243+
ratio = (db - self._DB_MIN) / (self._DB_MAX - self._DB_MIN)
244+
ratio = max(0.0, min(1.0, ratio))
245+
return int(self._vu_h * (1.0 - ratio))
246+
247+
def _draw(self):
248+
self.delete("all")
249+
w, h = self._vu_w, self._vu_h
250+
bar_x1, bar_x2 = 3, w - 3
251+
level_y = self._db_to_y(self._level_db)
252+
253+
# Reference marks at -6, -12, -24, -48 dB
254+
for db in (-6, -12, -24, -48):
255+
y = self._db_to_y(db)
256+
self.create_line(0, y, w, y, fill="#333333", width=1)
257+
258+
# Draw filled bar as segments with color gradient
259+
seg_h = max(1, h // 30)
260+
y = h
261+
while y > level_y:
262+
ratio = 1.0 - (y / h) # 0 at bottom, 1 at top
263+
if ratio < 0.6:
264+
color = "#22cc44" # green
265+
elif ratio < 0.8:
266+
color = "#cccc22" # yellow
267+
else:
268+
color = "#cc3322" # red
269+
y_top = max(level_y, y - seg_h)
270+
self.create_rectangle(bar_x1, y_top, bar_x2, y - 1,
271+
fill=color, outline="")
272+
y -= seg_h
273+
274+
# Peak hold indicator
275+
peak_y = self._db_to_y(self._peak_db)
276+
if self._peak_db > self._DB_MIN + 1:
277+
self.create_line(bar_x1, peak_y, bar_x2, peak_y,
278+
fill=AMBER, width=2)
279+
280+
208281
# ── Value formatters ───────────────────────────────────────────────
209282

210283
def _fmt_cutoff(v):
@@ -359,17 +432,6 @@ def _build_top_bar(self):
359432
btn.pack(side=tk.LEFT, padx=1)
360433
self.channel_buttons.append(btn)
361434

362-
tk.Frame(bar, bg=BORDER, width=1, height=24).pack(side=tk.LEFT,
363-
padx=10)
364-
tk.Label(bar, text="MASTER", bg=BG_DARK, fg=CREAM_DIM,
365-
font=("Helvetica", 9)).pack(side=tk.LEFT)
366-
self.master_vol = tk.Scale(
367-
bar, from_=0, to=100, orient=tk.HORIZONTAL, length=120,
368-
bg=BG_DARK, fg=CREAM, troughcolor=TROUGH,
369-
highlightthickness=0, activebackground=AMBER,
370-
sliderrelief=tk.FLAT, command=self._on_master_volume)
371-
self.master_vol.set(int(self.engine.master_volume * 100))
372-
self.master_vol.pack(side=tk.LEFT, padx=4)
373435

374436
def _saved_patch_names(self) -> list[str]:
375437
return [f.replace(".json", "") for f in self.patch_manager.list_saved()]
@@ -585,12 +647,28 @@ def _build_bottom_row(self):
585647
# Status
586648
sf = ttk.LabelFrame(row, text="STATUS", padding=6)
587649
sf.grid(row=0, column=1, sticky="nsew", padx=2, pady=2)
588-
self.voices_label = tk.Label(sf, text="Voices: 0/8", bg=BG_PANEL,
589-
fg=AMBER,
650+
651+
status_inner = tk.Frame(sf, bg=BG_PANEL)
652+
status_inner.pack(fill=tk.BOTH, expand=True)
653+
654+
self.voices_label = tk.Label(status_inner, text="Voices: 0/8",
655+
bg=BG_PANEL, fg=AMBER,
590656
font=("Helvetica", 13, "bold"))
591-
self.voices_label.pack(padx=10, pady=10)
657+
self.voices_label.pack(side=tk.LEFT, padx=10, pady=10)
658+
659+
# VU meter
660+
self.vu_meter = VUMeter(status_inner, width=20, height=80)
661+
self.vu_meter.pack(side=tk.LEFT, padx=(10, 6), pady=4)
662+
663+
# Master volume knob
664+
self.master_knob = RotaryKnob(
665+
status_inner, label="MASTER", from_=0, to=100, resolution=1,
666+
command=self._on_master_volume)
667+
self.master_knob.set(int(self.engine.master_volume * 100))
668+
self.master_knob.pack(side=tk.LEFT, padx=6, pady=4)
592669

593670
self._update_voices_display()
671+
self._update_vu_meter()
594672

595673
# ── Virtual keyboard ────────────────────────────────────────────
596674

@@ -788,6 +866,10 @@ def _set_osc_param(self, idx, key, value):
788866
def _on_master_volume(self, value):
789867
self.engine.master_volume = float(value) / 100.0
790868

869+
def _update_vu_meter(self):
870+
self.vu_meter.set_level(self.engine.peak_level)
871+
self.after(50, self._update_vu_meter)
872+
791873
def _on_cutoff_change(self, value):
792874
freq = 20.0 * (1000.0 ** (value / 1000.0))
793875
freq = min(freq, 20000.0)

0 commit comments

Comments
 (0)