VoicePult/audio_meter.py
2026-10-03 11:07:47 +02:00

112 lines
4.5 KiB
Python

"""Short-lived, memory-only PCM analysis for the local waveform panel."""
import array
import math
import select
import subprocess
import sys
import threading
import time
def analyze(raw, bins=80):
pcm = array.array('h', raw)
if sys.byteorder != 'little':
pcm.byteswap()
channels = [pcm[0::2], pcm[1::2]]
result = []
for samples in channels:
if not samples:
raise ValueError('Leerer Audioblock')
normalized = [v / 32768 for v in samples]
peak = max(abs(v) for v in normalized)
rms = math.sqrt(sum(v*v for v in normalized) / len(normalized))
wave = []
for i in range(min(bins, len(samples))):
start = i * len(samples) // min(bins, len(samples))
end = (i + 1) * len(samples) // min(bins, len(samples))
part = normalized[start:end]
wave.append([round(min(part), 5), round(max(part), 5)])
result.append({'wave': wave, 'peak': peak, 'rms': rms,
'db': round(20 * math.log10(rms), 1) if rms > 0 else None,
'clipping': peak >= 0.999})
return result
class MeterHub:
def __init__(self):
self.lock = threading.Lock()
self.entries = {}
self.closed = False
def snapshot(self, source):
with self.lock:
if self.closed:
return {'ready': False, 'error': 'Audioanzeige beendet.'}
entry = self.entries.get(source)
if entry is None:
entry = {'touch': time.monotonic(), 'data': {'ready': False}, 'process': None}
self.entries[source] = entry
threading.Thread(target=self.capture, args=(source, entry), daemon=True).start()
entry['touch'] = time.monotonic()
data = dict(entry['data'])
if data.get('ready') and time.monotonic() - entry.get('updated', 0) > .5:
return {'ready': False, 'error': 'Warte auf Audiodaten.'}
return data
def capture(self, source, entry):
process = None
try:
process = subprocess.Popen(['parec', '--device='+source, '--raw', '--format=s16le',
'--rate=16000', '--channels=2', '--latency-msec=50',
'--client-name=VoicePult Pegel', '--stream-name=VoicePult Pegel'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
with self.lock:
entry['process'] = process
buffer = bytearray()
block_size = 800 * 4 # 50 ms, 16 kHz, stereo int16
while True:
with self.lock:
if self.closed or time.monotonic() - entry['touch'] > 2:
break
if process.poll() is not None:
raise RuntimeError('Audioquelle nicht erreichbar.')
readable, _, _ = select.select([process.stdout], [], [], .2)
if not readable:
continue
chunk = process.stdout.read1(8192)
if not chunk:
raise RuntimeError('Audiostream wurde beendet.')
buffer.extend(chunk)
while len(buffer) >= block_size:
data = analyze(buffer[:block_size])
del buffer[:block_size]
with self.lock:
entry['updated'] = time.monotonic()
entry['data'] = {'ready': True, 'channels': data}
except (OSError, RuntimeError) as exc:
with self.lock:
entry['data'] = {'ready': False, 'error': 'Live-Anzeige nicht verfügbar: ' + str(exc)}
# Keep failures visible briefly; later requests can retry.
time.sleep(1)
finally:
if process:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
process.stdout.close()
with self.lock:
if self.entries.get(source) is entry:
del self.entries[source]
def close(self):
with self.lock:
self.closed = True
for entry in self.entries.values():
process = entry['process']
if process and process.poll() is None:
process.terminate()