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).
+
+
+ Natural+ — neue Ballistikformel
+ Hüllkurve mit separatem Attack/Release + 2.-Ordnung Nadelmodell
+ Natürliches Einschwingen, weniger Zappeln, weicher Rücklauf.
@@ -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}")