Persist selected audio devices and auto-start meter on launch

This commit is contained in:
2026-04-05 20:26:14 +02:00
parent 2db940988f
commit dc359a9e61
+140 -17
View File
@@ -9,6 +9,7 @@ Audio-Passthrough und Physik-Visualisierung.
import requests, time, argparse, numpy as np, sys, math import requests, time, argparse, numpy as np, sys, math
import threading, json, psutil import threading, json, psutil
from pathlib import Path
from flask import Flask, render_template_string, jsonify, request as flask_request from flask import Flask, render_template_string, jsonify, request as flask_request
try: try:
@@ -28,6 +29,7 @@ disk_dial_uid = None
running = False running = False
current_level = 0 current_level = 0
current_peak = 0 current_peak = 0
SETTINGS_FILE = Path(__file__).with_name("vu1_audio_settings.json")
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
# HTML / CSS / JS — Skeuomorphes VU-Meter # HTML / CSS / JS — Skeuomorphes VU-Meter
@@ -410,12 +412,14 @@ body {
<button class="btn-sm active" id="mode-full" onclick="setMode('full')">Breitband</button> <button class="btn-sm active" id="mode-full" onclick="setMode('full')">Breitband</button>
<button class="btn-sm" id="mode-dual" onclick="setMode('dualband')">Dual-Band</button> <button class="btn-sm" id="mode-dual" onclick="setMode('dualband')">Dual-Band</button>
<button class="btn-sm" id="mode-iec" onclick="setMode('iec_true')">IEC True</button> <button class="btn-sm" id="mode-iec" onclick="setMode('iec_true')">IEC True</button>
<button class="btn-sm" id="mode-natural" onclick="setMode('natural_formula')">Natural+</button>
</div> </div>
<!-- Presets --> <!-- Presets -->
<div class="btn-row"> <div class="btn-row">
<button class="btn-sm" onclick="preset('iec_vu')">IEC VU</button> <button class="btn-sm" onclick="preset('iec_vu')">IEC VU</button>
<button class="btn-sm" onclick="preset('bbc_ppm')">BBC PPM</button> <button class="btn-sm" onclick="preset('bbc_ppm')">BBC PPM</button>
<button class="btn-sm" onclick="preset('natural_vu')">Natural VU</button>
<button class="btn-sm" onclick="preset('bouncy')">Bouncy</button> <button class="btn-sm" onclick="preset('bouncy')">Bouncy</button>
<button class="btn-sm" onclick="preset('heavy')">Heavy</button> <button class="btn-sm" onclick="preset('heavy')">Heavy</button>
<button class="btn-sm" onclick="preset('fast')">Fast</button> <button class="btn-sm" onclick="preset('fast')">Fast</button>
@@ -447,7 +451,12 @@ body {
<b>IEC True Ballistics</b> — Lobdell-Modell<br> <b>IEC True Ballistics</b> — Lobdell-Modell<br>
|x| → Biquad LPF 2.224 Hz, Q 0.6053<br> |x| → Biquad LPF 2.224 Hz, Q 0.6053<br>
300ms Anstiegszeit, ~1% Überschwingen<br> 300ms Anstiegszeit, ~1% Überschwingen<br>
<span style="color:#555">Der Filter <i>ist</i> die Nadelphysik.</span> <span style="color:#555">Der Filter <i>ist</i> die Nadelphysik (+ kurzer Transient-Assist).</span>
</div>
<div class="iec-box" id="natural-box" style="display:none">
<b>Natural+</b> — neue Ballistikformel<br>
Hüllkurve mit separatem Attack/Release + 2.-Ordnung Nadelmodell<br>
Natürliches Einschwingen, weniger Zappeln, weicher Rücklauf.
</div> </div>
<!-- Physics sliders --> <!-- Physics sliders -->
@@ -561,10 +570,11 @@ function setMode(mode){
fetch('/set/mode/'+mode); fetch('/set/mode/'+mode);
document.getElementById('dualband-box').style.display=mode==='dualband'?'block':'none'; document.getElementById('dualband-box').style.display=mode==='dualband'?'block':'none';
document.getElementById('iec-box').style.display=mode==='iec_true'?'block':'none'; document.getElementById('iec-box').style.display=mode==='iec_true'?'block':'none';
document.getElementById('physics-ctrls').style.display=mode==='iec_true'?'none':'block'; document.getElementById('natural-box').style.display=mode==='natural_formula'?'block':'none';
['full','dual','iec'].forEach(m=>{ 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 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); btn.classList.toggle('active', act);
}); });
} }
@@ -590,6 +600,7 @@ function preset(name){
const P={ const P={
iec_vu:{mass:1.0,damping:1.5,spring:4.0,gravity:0}, 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}, 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}, 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}, 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}, fast:{mass:0.1,damping:1.8,spring:15.0,gravity:0},
@@ -658,12 +669,13 @@ function drawVU(level, peak){
const ctx = vuCtx; const ctx = vuCtx;
ctx.clearRect(0,0,w,h); ctx.clearRect(0,0,w,h);
// Center pivot point // Center pivot point (inside canvas so needle is always visible)
const cx = w/2, cy = h + 40; const cx = w/2;
const radius = h + 20; const cy = h - 12;
const radius = Math.min(w * 0.45, h * 0.78);
// Scale arc // Scale arc (immer obere Hälfte, links -> rechts)
const arcStart = Math.PI + 0.35; const arcStart = -2.80;
const arcEnd = -0.35; const arcEnd = -0.35;
// Draw scale markings // Draw scale markings
@@ -703,7 +715,7 @@ function drawVU(level, peak){
// "VU" label // "VU" label
ctx.font = 'italic 24px "Instrument Serif"'; ctx.font = 'italic 24px "Instrument Serif"';
ctx.fillStyle = '#8a7a60'; ctx.fillStyle = '#8a7a60';
ctx.fillText('VU', cx, h*0.45); ctx.fillText('VU', cx, h*0.62);
// Sub minor ticks // Sub minor ticks
for(let p=0; p<=100; p+=2){ 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_coeffs = self._calc_biquad_lpf(2.224, 0.6053)
self._iec_z = np.zeros(2) self._iec_z = np.zeros(2)
self._iec_level = 0.0 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 ── # ── Biquad helpers ──
def _calc_biquad(self, fc): def _calc_biquad(self, fc):
@@ -1143,7 +1160,19 @@ class PhysicsVU:
if self.mode == 'iec_true': if self.mode == 'iec_true':
rect = np.abs(mono).astype(np.float64) rect = np.abs(mono).astype(np.float64)
filt = self._biquad_process(self._iec_coeffs, self._iec_z, rect) 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 ── # ── Audio Output ──
# Monitor: Signal hören (mit Filter/Solo je nach Mode) # 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)) self.peak_pos = max(0.0, min(100.0, self.peak_pos))
return self.needle_pos, 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 # Target
if self.mode == 'dualband': if self.mode == 'dualband':
target = min(100.0, self._rms_to_percent(self._latest_rms_lo)*self.band_lo_weight + 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 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 # Flask Routes
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
@@ -1348,6 +1449,7 @@ def set_device(which, dev_id):
if meter.start(): if meter.start():
running = True running = True
threading.Thread(target=update_loop, daemon=True).start() 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}) return jsonify({"ok": True})
@app.route('/toggle') @app.route('/toggle')
@@ -1411,7 +1513,11 @@ def set_param(param, value):
if not meter: return jsonify({"ok": False}) if not meter: return jsonify({"ok": False})
if param == 'mode': if param == 'mode':
meter.mode = value 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': elif param == 'monitor':
meter.monitor = value == '1' meter.monitor = value == '1'
elif param == 'bypass': elif param == 'bypass':
@@ -1444,7 +1550,7 @@ def reset():
meter.needle_pos=0; meter.needle_vel=0; meter.peak_pos=0 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.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.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}) return jsonify({"ok": True})
@@ -1484,7 +1590,7 @@ def update_loop():
# ── Main ── # ── Main ──
def main(): def main():
global client, dial_uid, meter global client, dial_uid, meter, running
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True) parser.add_argument("--api-key", required=True)
@@ -1494,8 +1600,13 @@ def main():
parser.add_argument("--port", type=int, default=8080) parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args() args = parser.parse_args()
app.config['audio_device_in'] = args.audio_device saved = load_audio_settings()
app.config['audio_device_out'] = args.output_device 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) client = VU1Client(api_key=args.api_key)
dials = client.get_dials() dials = client.get_dials()
@@ -1519,7 +1630,19 @@ def main():
globals()['disk_dial_uid'] = others[1] globals()['disk_dial_uid'] = others[1]
print(f"✅ Disk-Dial: ({others[1][:8]}…)") 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"\n🎛️ VU1 Meter Web GUI v3")
print(f"{'='*40}") print(f"{'='*40}")