112 lines
4.5 KiB
Python
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()
|