"""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()