Files
IQ.Pilot/system/micd.py
T
2026-07-13 11:58:40 -05:00

152 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
import numpy as np
import os
from functools import cache
import threading
from cereal import messaging
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.utils import retry
from openpilot.common.swaglog import cloudlog
RATE = 10
FFT_SAMPLES = 1600 # 100ms
REFERENCE_SPL = 2e-5 # newtons/m^2
SAMPLE_RATE = 16000
SAMPLE_BUFFER = 800 # 50ms
@cache
def get_a_weighting_filter():
# Calculate the A-weighting filter
# https://en.wikipedia.org/wiki/A-weighting
freqs = np.fft.fftfreq(FFT_SAMPLES, d=1 / SAMPLE_RATE)
A = 12194 ** 2 * freqs ** 4 / ((freqs ** 2 + 20.6 ** 2) * (freqs ** 2 + 12194 ** 2) * np.sqrt((freqs ** 2 + 107.7 ** 2) * (freqs ** 2 + 737.9 ** 2)))
return A / np.max(A)
def calculate_spl(measurements):
# https://www.engineeringtoolbox.com/sound-pressure-d_711.html
sound_pressure = np.sqrt(np.mean(measurements ** 2)) # RMS of amplitudes
if sound_pressure > 0:
sound_pressure_level = 20 * np.log10(sound_pressure / REFERENCE_SPL) # dB
else:
sound_pressure_level = 0
return sound_pressure, sound_pressure_level
def apply_a_weighting(measurements: np.ndarray) -> np.ndarray:
# Generate a Hanning window of the same length as the audio measurements
measurements_windowed = measurements * np.hanning(len(measurements))
# Apply the A-weighting filter to the signal
return np.abs(np.fft.ifft(np.fft.fft(measurements_windowed) * get_a_weighting_filter()))
class Mic:
def __init__(self):
self.rk = Ratekeeper(RATE)
self.pm = messaging.PubMaster(['soundPressure', 'rawAudioData'])
self.params = Params()
self.measurements = np.empty(0)
self.sound_pressure = 0
self.sound_pressure_weighted = 0
self.sound_pressure_level_weighted = 0
self.lock = threading.Lock()
self.callback_count = 0
self.last_audio_rms = 0.0
self.last_audio_peak = 0.0
self.last_device = None
self.last_status = None
def update(self):
with self.lock:
sound_pressure = self.sound_pressure
sound_pressure_weighted = self.sound_pressure_weighted
sound_pressure_level_weighted = self.sound_pressure_level_weighted
callback_count = self.callback_count
audio_rms = self.last_audio_rms
audio_peak = self.last_audio_peak
device_name = self.last_device
status = self.last_status
msg = messaging.new_message('soundPressure', valid=True)
msg.soundPressure.soundPressure = float(sound_pressure)
msg.soundPressure.soundPressureWeighted = float(sound_pressure_weighted)
msg.soundPressure.soundPressureWeightedDb = float(sound_pressure_level_weighted)
self.pm.send('soundPressure', msg)
if callback_count % RATE == 0:
cloudlog.info(
f"micd health: callbacks={callback_count} rms={audio_rms:.6f} peak={audio_peak:.6f} "
f"device={device_name} status={status!r} livestream={self.params.get_bool('IsLiveStreaming')}"
)
self.rk.keep_time()
def callback(self, indata, frames, time, status):
"""
Using amplitude measurements, calculate an uncalibrated sound pressure and sound pressure level.
Then apply A-weighting to the raw amplitudes and run the same calculations again.
Logged A-weighted equivalents are rough approximations of the human-perceived loudness.
"""
msg = messaging.new_message('rawAudioData', valid=True)
audio_data_int_16 = (indata[:, 0] * 32767).astype(np.int16)
msg.rawAudioData.data = audio_data_int_16.tobytes()
msg.rawAudioData.sampleRate = SAMPLE_RATE
self.pm.send('rawAudioData', msg)
with self.lock:
self.callback_count += 1
self.last_audio_rms = float(np.sqrt(np.mean(np.square(indata[:, 0]))))
self.last_audio_peak = float(np.max(np.abs(indata[:, 0])))
self.last_status = str(status) if status else None
self.measurements = np.concatenate((self.measurements, indata[:, 0]))
while self.measurements.size >= FFT_SAMPLES:
measurements = self.measurements[:FFT_SAMPLES]
self.sound_pressure, _ = calculate_spl(measurements)
measurements_weighted = apply_a_weighting(measurements)
self.sound_pressure_weighted, self.sound_pressure_level_weighted = calculate_spl(measurements_weighted)
self.measurements = self.measurements[FFT_SAMPLES:]
@retry(attempts=10, delay=3)
def get_stream(self, sd):
# reload sounddevice to reinitialize portaudio
sd._terminate()
sd._initialize()
requested_device = os.environ.get("MICD_DEVICE")
device = int(requested_device) if requested_device is not None else None
return sd.InputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER, device=device)
def micd_thread(self):
# sounddevice must be imported after forking processes
import sounddevice as sd
device = sd.default.device
if os.environ.get("MICD_DEVICE") is not None:
device = int(os.environ["MICD_DEVICE"])
sd.default.device = (device, device)
self.last_device = f"{device}: {sd.query_devices(device)['name']}" if isinstance(device, int) else str(device)
cloudlog.info(f"micd selecting input device {self.last_device}")
with self.get_stream(sd) as stream:
cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}")
while True:
self.update()
def main():
mic = Mic()
mic.micd_thread()
if __name__ == "__main__":
main()