From 85663ae4e34c7d30e7cabb68d787947ea0385e68 Mon Sep 17 00:00:00 2001 From: Ellina Ho Date: Wed, 8 Jul 2026 11:11:03 -0700 Subject: [PATCH 1/3] Added AC RMS filtering before inference, updated configs --- config/example_config.yaml | 7 ++ sagemic_local.py | 176 +++++++++++++++++++++++++++++++------ 2 files changed, 154 insertions(+), 29 deletions(-) diff --git a/config/example_config.yaml b/config/example_config.yaml index 16d2617..b9d0423 100644 --- a/config/example_config.yaml +++ b/config/example_config.yaml @@ -24,6 +24,13 @@ SETTINGS: AUDIO_DEVICE: "exact_name_from_arecord-l" AUDIO_DTYPE: "int16_or_int32" + # 1 for RMS Filtering of Audio Chunks, 0 for Continuous Recording + RMS_FILTER: 1 + # Ambient RMS multiplier to determine trigger threshold + THRESH_MULTIPLIER: 1.1 + # Determines how much new recordings affect ambient noise floor + EMA_ALPHA: 0.1 + MQTT: # Default Port for MQTTS PORT: 8883 diff --git a/sagemic_local.py b/sagemic_local.py index 19f08eb..f874e4b 100644 --- a/sagemic_local.py +++ b/sagemic_local.py @@ -24,51 +24,40 @@ from sagemic.helpers import check_path, get_config, get_device_id -# callback defined here to use with config variables -def audio_callback( - indata, - frames, - time_obj, - status, - recording_buffer, - config=None -): +def run_inference(indata, recording_buffer, config): """Process one audio block from the input stream and print detections. - Called by `sounddevice` for each incoming audio block. Flattens the + Called by `audio_callback()` for each audio block. Flattens the audio into a 1-D array, updates the global `recording_buffer`, runs - BirdNET analysis, and prints detections whose confidence exceeds - `CONFIDENCE_THRESHOLD`. + BirdNET analysis, prints detections whose confidence exceeds + `CONFIDENCE_THRESHOLD`, and saves audio files locally. Args: indata (numpy.ndarray): Audio block with shape (frames, channels). For this script, channels == 1. - frames (int): Number of frames in `indata`. - time_obj: Stream timing information provided by `sounddevice` - (implementation-specific; not used here). - status (sounddevice.CallbackFlags): Callback status flags; printed - if any non-OK condition is reported. + recording_buffer (RecordingBuffer instance): + Holds raw audio data/coordinates and handles analysis pipeline. + config (dict): Holds custom user configuration values for the script. Side Effects: Updates the global `recording_buffer.buffer` and writes detection summaries to stdout. - """ - if status: - print(status) - local_tz = ZoneInfo(config["SETTINGS"]["LOCAL_TZ"]) - base_path = config["PATHS"]["BASE_PATH"] + Saves audio clips with inferences into specified directory, by date. + + """ confidence_threshold = config["SETTINGS"]["CONFIDENCE_THRESHOLD"] sample_rate = config["SETTINGS"]["SAMPLERATE"] + local_tz = ZoneInfo(config["SETTINGS"]["LOCAL_TZ"]) timestamp = datetime.now(local_tz) + + base_path = config["PATHS"]["BASE_PATH"] date = timestamp.strftime('%Y-%m-%d') path = check_path(date, base_path) - # Flatten the data to a 1D array as expected by birdnetlib audio_data = indata.flatten() - # Add data to the buffer, specifying the samplerate here recording_buffer.buffer = audio_data print(f"\nProcessing audio chunk at {timestamp.strftime('%H:%M:%S')}...") @@ -102,6 +91,120 @@ def audio_callback( print("No detections") +def audio_callback_raw( + indata, + frames, + time_obj, + status, + recording_buffer, + config=None +): + + """Audio callback for continuous inference. + + Called by 'sounddevice' for each incoming audio block. + Calls run_inference to perform birdcall inference. + + Args: + indata (numpy.ndarray): Audio block with shape (frames, channels). + For this script, channels == 1. + frames (int): Number of frames in `indata`. + time_obj: Stream timing information provided by `sounddevice` + (implementation-specific; not used here). + status (sounddevice.CallbackFlags): Callback status flags; printed + if any non-OK condition is reported. + recording_buffer (RecordingBuffer instance): + Holds audio data, configs, and coordinates. + Handles analysis pipeline. + config (dict): Holds custom user configuration values for the script. + + """ + if status: + print(status) + + run_inference(indata, recording_buffer, config) + + +def audio_callback_rms( + indata, + frames, + time_obj, + status, + recording_buffer, + config=None, + rms_dict=None +): + + """Audio callback for AC RMS Pre-filtering + + Called by 'sounddevice' for each incoming audio block. + + If the RMS of the indata is above the determined trigger threshold: + Runs inference on block before the trigger & current audio block. + If the RMS of the in data is below determined trigger threshold: + Dynamically adjusts ambient noise floor based on RMS of current block. + + Args: + indata (numpy.ndarray): Audio block with shape (frames, channels). + For this script, channels == 1. + frames (int): Number of frames in `indata`. + time_obj: Stream timing information provided by `sounddevice` + (implementation-specific; not used here). + status (sounddevice.CallbackFlags): Callback status flags; printed + if any non-OK condition is reported. + recording_buffer (RecordingBuffer instance): + Holds raw audio data, configs, and coordinates. + Handles analysis pipeline. + config (dict): Holds custom user configuration values for the script. + rms_dict (dict): Stores data and valus needed for RMS filtering. + ambient_rms: Noise floor for silence or null noise. + prev_block: Buffer that holds data of previous block + prev_processed: Boolean, whether or not prev block was processed + + Side Effects: + Updates global rms_dict values. + """ + + if status: + print(status) + + current_rms = np.std(indata) + + # initialize for first run + if rms_dict["ambient_rms"] == 0.0: + rms_dict["ambient_rms"] = current_rms + rms_dict["prev_block"] = indata.copy() + return + + ambient_multiplier = config["SETTINGS"]["THRESH_MULTIPLIER"] + ambient_rms = rms_dict["ambient_rms"] + prev_processed = rms_dict["prev_processed"] + + trigger_threshold = ambient_rms * ambient_multiplier + + if current_rms > trigger_threshold: + + # if haven't processed pre-trigger block, process it + if not prev_processed and rms_dict["prev_block"] is not None: + print("Processing pre-trigger recording") + run_inference(rms_dict["prev_block"], recording_buffer, config) + + run_inference(indata, recording_buffer, config) + + # Mark this block as processed for the next loop + rms_dict["prev_processed"] = True + else: + print(f"RMS: {current_rms:.5f}, Trig Thresh: {trigger_threshold:.5f}") + alpha = config["SETTINGS"]["EMA_ALPHA"] + rms_dict["ambient_rms"] = ( + (alpha * current_rms) + + ((1 - alpha) * rms_dict["ambient_rms"]) + ) + rms_dict["prev_processed"] = False + + rms_dict["prev_block"] = indata.copy() + + def main(): """ Parses config filepath and initalizes audio variables. @@ -137,11 +240,26 @@ def main(): rate=sample_rate, buffer=audio_buffer ) - arg_callback = partial( - audio_callback, - recording_buffer=recording_buffer, - config=config - ) + if config["SETTINGS"]["RMS_FILTER"] == 1: + + rms_dict = { + "ambient_rms": 0.0, + "prev_block": None, + "prev_processed": False + } + + arg_callback = partial( + audio_callback_rms, + recording_buffer=recording_buffer, + config=config, + rms_dict=rms_dict + ) + else: + arg_callback = partial( + audio_callback_raw, + recording_buffer=recording_buffer, + config=config + ) # start listener print("Scanning for audio devices") From adaa1349e126ed5da0b29af95ce34332df3ba226 Mon Sep 17 00:00:00 2001 From: Ellina Ho Date: Wed, 8 Jul 2026 14:34:44 -0700 Subject: [PATCH 2/3] Fixed logic errors --- sagemic_local.py | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/sagemic_local.py b/sagemic_local.py index f874e4b..b718faa 100644 --- a/sagemic_local.py +++ b/sagemic_local.py @@ -39,6 +39,9 @@ def run_inference(indata, recording_buffer, config): Holds raw audio data/coordinates and handles analysis pipeline. config (dict): Holds custom user configuration values for the script. + Returns: + (bool): True if there is a detection, False otherwise + Side Effects: Updates the global `recording_buffer.buffer` and writes detection summaries to stdout. @@ -87,8 +90,10 @@ def run_inference(indata, recording_buffer, config): os.rename( temp_filename, final_filename ) # to .wav for scansend when done + return True else: print("No detections") + return False def audio_callback_raw( @@ -125,6 +130,20 @@ def audio_callback_raw( run_inference(indata, recording_buffer, config) +def update_ambientrms(old_ambient, curr_rms, config, rms_dict): + """ + Updates ambient noise floor based on given current RMS + + Called by audio_callback_rms. + + Args: + old_ambient (float): current ambient noise floor + curr_rms (float): rms of current audio block being processed + """ + alpha = config["SETTINGS"]["EMA_ALPHA"] + rms_dict["ambient_rms"] = (alpha * curr_rms) + ((1 - alpha) * old_ambient) + + def audio_callback_rms( indata, frames, @@ -177,29 +196,26 @@ def audio_callback_rms( return ambient_multiplier = config["SETTINGS"]["THRESH_MULTIPLIER"] - ambient_rms = rms_dict["ambient_rms"] + curr_ambientrms = rms_dict["ambient_rms"] prev_processed = rms_dict["prev_processed"] - trigger_threshold = ambient_rms * ambient_multiplier + trigger_threshold = curr_ambientrms * ambient_multiplier if current_rms > trigger_threshold: - # if haven't processed pre-trigger block, process it - if not prev_processed and rms_dict["prev_block"] is not None: + if not prev_processed: print("Processing pre-trigger recording") run_inference(rms_dict["prev_block"], recording_buffer, config) - run_inference(indata, recording_buffer, config) + # if no detection, use to update ambient floor + if not run_inference(indata, recording_buffer, config): + update_ambientrms(curr_ambientrms, current_rms, config, rms_dict) - # Mark this block as processed for the next loop rms_dict["prev_processed"] = True + else: print(f"RMS: {current_rms:.5f}, Trig Thresh: {trigger_threshold:.5f}") - alpha = config["SETTINGS"]["EMA_ALPHA"] - rms_dict["ambient_rms"] = ( - (alpha * current_rms) + - ((1 - alpha) * rms_dict["ambient_rms"]) - ) + update_ambientrms(curr_ambientrms, current_rms, config, rms_dict) rms_dict["prev_processed"] = False rms_dict["prev_block"] = indata.copy() From b81651f0c7cc9f8b06a0962d8b2bc703e979085f Mon Sep 17 00:00:00 2001 From: Ellina Ho Date: Wed, 22 Jul 2026 14:41:39 -0700 Subject: [PATCH 3/3] Fixed pylint, added config descriptions, fixed docstrings --- config/example_config.yaml | 12 ++++++++++-- sagemic_local.py | 17 +++++++---------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/config/example_config.yaml b/config/example_config.yaml index b9d0423..c313a27 100644 --- a/config/example_config.yaml +++ b/config/example_config.yaml @@ -26,9 +26,17 @@ SETTINGS: # 1 for RMS Filtering of Audio Chunks, 0 for Continuous Recording RMS_FILTER: 1 - # Ambient RMS multiplier to determine trigger threshold + + # Trigger threshold multiplier (typically 1.05-2.0) + # Multiply current ambient RMS by this value to set trigger threshold + # Example: 1.1 = Trigger inference if sound is 10% louder than backgroud noise. + # Lower values = higher sensitivity, catches faint calls but more false triggers THRESH_MULTIPLIER: 1.1 - # Determines how much new recordings affect ambient noise floor + + # Exponential Moving Average (EMA) smoothing factor for noise floor adaptation + # Typically 0.01 - 0.5, controls how fast ambient noise floor adapts to env. changes + # Example: 0.1 = New non-detection audio chunks contribute 10% to noise floor + # Lower values = slow adaptation (good for stable environments) EMA_ALPHA: 0.1 MQTT: diff --git a/sagemic_local.py b/sagemic_local.py index b718faa..a648412 100644 --- a/sagemic_local.py +++ b/sagemic_local.py @@ -91,9 +91,9 @@ def run_inference(indata, recording_buffer, config): temp_filename, final_filename ) # to .wav for scansend when done return True - else: - print("No detections") - return False + + print("No detections") + return False def audio_callback_raw( @@ -158,10 +158,10 @@ def audio_callback_rms( Called by 'sounddevice' for each incoming audio block. - If the RMS of the indata is above the determined trigger threshold: - Runs inference on block before the trigger & current audio block. - If the RMS of the in data is below determined trigger threshold: - Dynamically adjusts ambient noise floor based on RMS of current block. + If RMS of indata is above trigger threshold, process prev & curr chunks. + Then if there is no detection, use indata to update ambient. + + If RMS of indata is below trigger thresh, use indata to update ambient. Args: indata (numpy.ndarray): Audio block with shape (frames, channels). @@ -176,9 +176,6 @@ def audio_callback_rms( Handles analysis pipeline. config (dict): Holds custom user configuration values for the script. rms_dict (dict): Stores data and valus needed for RMS filtering. - ambient_rms: Noise floor for silence or null noise. - prev_block: Buffer that holds data of previous block - prev_processed: Boolean, whether or not prev block was processed Side Effects: Updates global rms_dict values.