From dc359a9e61b1ce73645653567803aeea47bf2e62 Mon Sep 17 00:00:00 2001 From: Metacube Date: Sun, 5 Apr 2026 20:26:14 +0200 Subject: [PATCH] Persist selected audio devices and auto-start meter on launch --- vu1_gui.py | 157 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 17 deletions(-) diff --git a/vu1_gui.py b/vu1_gui.py index f363485..da1eb75 100644 --- a/vu1_gui.py +++ b/vu1_gui.py @@ -9,6 +9,7 @@ Audio-Passthrough und Physik-Visualisierung. import requests, time, argparse, numpy as np, sys, math import threading, json, psutil +from pathlib import Path from flask import Flask, render_template_string, jsonify, request as flask_request try: @@ -28,6 +29,7 @@ disk_dial_uid = None running = False current_level = 0 current_peak = 0 +SETTINGS_FILE = Path(__file__).with_name("vu1_audio_settings.json") # ══════════════════════════════════════════════════════════════ # HTML / CSS / JS — Skeuomorphes VU-Meter @@ -410,12 +412,14 @@ body { +
+ @@ -447,7 +451,12 @@ body { IEC True Ballistics — Lobdell-Modell
|x| → Biquad LPF 2.224 Hz, Q 0.6053
300ms Anstiegszeit, ~1% Überschwingen
- Der Filter ist die Nadelphysik. + Der Filter ist die Nadelphysik (+ kurzer Transient-Assist). +
+ @@ -561,10 +570,11 @@ function setMode(mode){ fetch('/set/mode/'+mode); document.getElementById('dualband-box').style.display=mode==='dualband'?'block':'none'; document.getElementById('iec-box').style.display=mode==='iec_true'?'block':'none'; - document.getElementById('physics-ctrls').style.display=mode==='iec_true'?'none':'block'; - ['full','dual','iec'].forEach(m=>{ + document.getElementById('natural-box').style.display=mode==='natural_formula'?'block':'none'; + document.getElementById('physics-ctrls').style.display=(mode==='iec_true'||mode==='natural_formula')?'none':'block'; + ['full','dual','iec','natural'].forEach(m=>{ const btn=document.getElementById('mode-'+m); - const act=(m==='full'&&mode==='full')||(m==='dual'&&mode==='dualband')||(m==='iec'&&mode==='iec_true'); + const act=(m==='full'&&mode==='full')||(m==='dual'&&mode==='dualband')||(m==='iec'&&mode==='iec_true')||(m==='natural'&&mode==='natural_formula'); btn.classList.toggle('active', act); }); } @@ -590,6 +600,7 @@ function preset(name){ const P={ iec_vu:{mass:1.0,damping:1.5,spring:4.0,gravity:0}, bbc_ppm:{mass:0.3,damping:2.0,spring:12.0,gravity:0}, + natural_vu:{mass:1.35,damping:2.6,spring:6.4,gravity:0.1}, bouncy:{mass:0.6,damping:0.4,spring:6.0,gravity:0}, heavy:{mass:3.0,damping:2.5,spring:2.0,gravity:1.0}, fast:{mass:0.1,damping:1.8,spring:15.0,gravity:0}, @@ -658,12 +669,13 @@ function drawVU(level, peak){ const ctx = vuCtx; ctx.clearRect(0,0,w,h); - // Center pivot point - const cx = w/2, cy = h + 40; - const radius = h + 20; + // Center pivot point (inside canvas so needle is always visible) + const cx = w/2; + const cy = h - 12; + const radius = Math.min(w * 0.45, h * 0.78); - // Scale arc - const arcStart = Math.PI + 0.35; + // Scale arc (immer obere Hälfte, links -> rechts) + const arcStart = -2.80; const arcEnd = -0.35; // Draw scale markings @@ -703,7 +715,7 @@ function drawVU(level, peak){ // "VU" label ctx.font = 'italic 24px "Instrument Serif"'; ctx.fillStyle = '#8a7a60'; - ctx.fillText('VU', cx, h*0.45); + ctx.fillText('VU', cx, h*0.62); // Sub minor ticks for(let p=0; p<=100; p+=2){ @@ -1060,6 +1072,11 @@ class PhysicsVU: self._iec_coeffs = self._calc_biquad_lpf(2.224, 0.6053) self._iec_z = np.zeros(2) self._iec_level = 0.0 + self._iec_fast = 0.0 + self.iec_transient_boost = 0.22 + + # Natural+ Formel (neue Ballistik) + self._natural_env = 0.0 # ── Biquad helpers ── def _calc_biquad(self, fc): @@ -1143,7 +1160,19 @@ class PhysicsVU: if self.mode == 'iec_true': rect = np.abs(mono).astype(np.float64) filt = self._biquad_process(self._iec_coeffs, self._iec_z, rect) - self._iec_level = float(filt[-1]) + # Kleiner schneller Pfad gegen subjektiven Bass-Delay + block_dt = max(1.0 / self.sample_rate, len(rect) / self.sample_rate) + block_peak = float(np.max(rect)) + atk_t = 0.012 + rel_t = 0.140 + alpha_a = 1.0 - math.exp(-block_dt / atk_t) + alpha_r = 1.0 - math.exp(-block_dt / rel_t) + alpha = alpha_a if block_peak > self._iec_fast else alpha_r + self._iec_fast += (block_peak - self._iec_fast) * alpha + + iec_slow = float(filt[-1]) + self._iec_level = ((1.0 - self.iec_transient_boost) * iec_slow + + self.iec_transient_boost * self._iec_fast) # ── Audio Output ── # Monitor: Signal hören (mit Filter/Solo je nach Mode) @@ -1267,6 +1296,50 @@ class PhysicsVU: self.peak_pos = max(0.0, min(100.0, self.peak_pos)) return self.needle_pos, self.peak_pos + # Natural+ (eigene Formel): + # 1) getrennte Attack/Release-Hüllkurve + # 2) 2.-Ordnung Nadelmodell für natürliches Ein-/Ausschwingen + if self.mode == 'natural_formula': + target = self._rms_to_percent(self._latest_rms) + + atk_t = 0.085 # schneller Angriff + rel_t = 0.650 # weicher Rücklauf + alpha_a = 1.0 - math.exp(-dt / atk_t) + alpha_r = 1.0 - math.exp(-dt / rel_t) + alpha = alpha_a if target > self._natural_env else alpha_r + self._natural_env += (target - self._natural_env) * alpha + + # 2nd-order Needle (kritisch nahe gedämpft) + stiffness = 58.0 + damping = 14.5 + acc = stiffness * (self._natural_env - self.needle_pos) - damping * self.needle_vel + self.needle_vel += acc * dt + self.needle_pos += self.needle_vel * dt + self.needle_pos = max(0.0, min(100.0, self.needle_pos)) + + # Bei sehr kleinen Pegeln ruhig auf 0 setzen (kein Mikrozappeln) + if self._natural_env < 0.3 and self.needle_pos < 0.3: + self._natural_env = 0.0 + self.needle_pos = 0.0 + self.needle_vel *= 0.6 + + self._target = self._natural_env + self._f_spring = 0.0 + self._f_damping = 0.0 + self._f_gravity = 0.0 + + peak_t = self._rms_to_percent(self._latest_peak) + if peak_t > self.peak_pos: + self.peak_pos = peak_t + self.peak_hold_t = 1.2 + else: + if self.peak_hold_t > 0: + self.peak_hold_t -= dt + else: + self.peak_pos -= 16.0 * dt + self.peak_pos = max(0.0, min(100.0, self.peak_pos)) + return self.needle_pos, self.peak_pos + # Target if self.mode == 'dualband': target = min(100.0, self._rms_to_percent(self._latest_rms_lo)*self.band_lo_weight + @@ -1301,6 +1374,34 @@ class PhysicsVU: return self.needle_pos, self.peak_pos +# ── Persistenz: zuletzt gewählte Audio-Geräte ── +def load_audio_settings(): + defaults = {"audio_device_in": None, "audio_device_out": -1} + try: + if not SETTINGS_FILE.exists(): + return defaults + data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + in_dev = data.get("audio_device_in", None) + out_dev = data.get("audio_device_out", -1) + if out_dev is None: + out_dev = -1 + return {"audio_device_in": in_dev, "audio_device_out": out_dev} + except Exception as e: + print(f"⚠️ Konnte Audio-Settings nicht laden: {e}") + return defaults + + +def save_audio_settings(in_dev, out_dev): + try: + payload = { + "audio_device_in": in_dev, + "audio_device_out": out_dev if out_dev is not None else -1 + } + SETTINGS_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8") + except Exception as e: + print(f"⚠️ Konnte Audio-Settings nicht speichern: {e}") + + # ══════════════════════════════════════════════════════════════ # Flask Routes # ══════════════════════════════════════════════════════════════ @@ -1348,6 +1449,7 @@ def set_device(which, dev_id): if meter.start(): running = True threading.Thread(target=update_loop, daemon=True).start() + save_audio_settings(app.config.get('audio_device_in'), app.config.get('audio_device_out', -1)) return jsonify({"ok": True}) @app.route('/toggle') @@ -1411,7 +1513,11 @@ def set_param(param, value): if not meter: return jsonify({"ok": False}) if param == 'mode': meter.mode = value - if value == 'iec_true': meter._iec_z[:] = 0 + if value == 'iec_true': + meter._iec_z[:] = 0 + meter._iec_fast = meter._latest_peak + if value == 'natural_formula': + meter._natural_env = meter.needle_pos elif param == 'monitor': meter.monitor = value == '1' elif param == 'bypass': @@ -1444,7 +1550,7 @@ def reset(): meter.needle_pos=0; meter.needle_vel=0; meter.peak_pos=0 meter.mode='full'; meter.monitor=False; meter.bypass=False; meter.solo='off' meter.band_crossover=250; meter.band_lo_weight=0.6; meter.band_hi_weight=0.4 - meter._iec_z[:]=0; meter._iec_level=0 + meter._iec_z[:]=0; meter._iec_level=0; meter._iec_fast=0; meter._natural_env=0 return jsonify({"ok": True}) @@ -1484,7 +1590,7 @@ def update_loop(): # ── Main ── def main(): - global client, dial_uid, meter + global client, dial_uid, meter, running parser = argparse.ArgumentParser() parser.add_argument("--api-key", required=True) @@ -1494,8 +1600,13 @@ def main(): parser.add_argument("--port", type=int, default=8080) args = parser.parse_args() - app.config['audio_device_in'] = args.audio_device - app.config['audio_device_out'] = args.output_device + saved = load_audio_settings() + chosen_in = args.audio_device if args.audio_device is not None else saved.get('audio_device_in') + chosen_out = args.output_device if args.output_device is not None else saved.get('audio_device_out', -1) + + app.config['audio_device_in'] = chosen_in + app.config['audio_device_out'] = chosen_out if chosen_out is not None else -1 + save_audio_settings(app.config['audio_device_in'], app.config['audio_device_out']) client = VU1Client(api_key=args.api_key) dials = client.get_dials() @@ -1519,7 +1630,19 @@ def main(): globals()['disk_dial_uid'] = others[1] print(f"✅ Disk-Dial: ({others[1][:8]}…)") - meter = PhysicsVU(device_in=args.audio_device, device_out=args.output_device) + out_for_meter = app.config['audio_device_out'] + meter = PhysicsVU( + device_in=app.config['audio_device_in'], + device_out=out_for_meter if out_for_meter is not None and out_for_meter >= 0 else None + ) + + # Auto-Start mit gespeicherten Geräten + if meter.start(): + running = True + threading.Thread(target=update_loop, daemon=True).start() + print("▶ Auto-Start aktiv (gespeicherte Audio-Ein-/Ausgänge)") + else: + print("⚠️ Auto-Start fehlgeschlagen; bitte Geräte prüfen und manuell starten.") print(f"\n🎛️ VU1 Meter Web GUI v3") print(f"{'='*40}")