@@ -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
210283def _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