""" Gather information about audio devices. """ import multiprocessing import sounddevice as sd import structlog import numpy as np import queue import helpers import time log = structlog.get_logger("audio") def get_audio_devices(): """ return list of input and output audio devices in own process to avoid crashes of portaudio on raspberry pi also uses a process data manager """ # we need to run this on Windows for multiprocessing support # multiprocessing.freeze_support() # multiprocessing.get_context("spawn") # we need to reset and initialize sounddevice before running the multiprocessing part. # If we are not doing this at this early point, not all devices will be displayed #sd._terminate() #sd._initialize() # log.debug("[AUD] get_audio_devices") with multiprocessing.Manager() as manager: proxy_input_devices = manager.list() proxy_output_devices = manager.list() # print(multiprocessing.get_start_method()) proc = multiprocessing.Process( target=fetch_audio_devices, args=(proxy_input_devices, proxy_output_devices) ) proc.start() proc.join(3) # additional logging for audio devices # log.debug("[AUD] get_audio_devices: input_devices:", list=f"{proxy_input_devices}") # log.debug("[AUD] get_audio_devices: output_devices:", list=f"{proxy_output_devices}") return list(proxy_input_devices), list(proxy_output_devices) def device_crc(device) -> str: """Generates a CRC16 checksum for an audio device. This function creates a unique identifier for an audio device based on its name and host API. It uses a CRC16 checksum to generate a hexadecimal representation of the combined device name and host API. Args: device (dict): A dictionary containing the device 'name' and 'hostapi'. Returns: str: The hexadecimal representation of the CRC16 checksum. """ crc_hwid = helpers.get_crc_16(bytes(f"{device['name']}.{device['hostapi']}", encoding="utf-8")) crc_hwid = crc_hwid.hex() return crc_hwid def fetch_audio_devices(input_devices, output_devices): """ get audio devices from portaudio Args: input_devices: proxy variable for input devices output_devices: proxy variable for output devices Returns: """ devices = sd.query_devices(device=None, kind=None) for index, device in enumerate(devices): # Use a try/except block because Windows doesn't have an audio device range try: name = device["name"] # Ignore some Flex Radio devices to make device selection simpler if name.startswith("DAX RESERVED") or name.startswith("DAX IQ"): continue max_output_channels = device["max_output_channels"] max_input_channels = device["max_input_channels"] except KeyError: continue except Exception as err: print(err) max_input_channels = 0 max_output_channels = 0 if max_input_channels > 0: hostapi_name = sd.query_hostapis(device['hostapi'])['name'] new_input_device = {"id": device_crc(device), "name": device['name'], "api": hostapi_name, "native_index":index} # check if device not in device list if new_input_device not in input_devices: input_devices.append(new_input_device) if max_output_channels > 0: hostapi_name = sd.query_hostapis(device['hostapi'])['name'] new_output_device = {"id": device_crc(device), "name": device['name'], "api": hostapi_name, "native_index":index} # check if device not in device list if new_output_device not in output_devices: output_devices.append(new_output_device) return input_devices, output_devices def get_device_index_from_crc(crc, isInput: bool): """Retrieves the native device index and name from the provided CRC. This function searches for an audio device with the given CRC and returns its native index and name. It handles both input and output devices based on the isInput flag. Args: crc (str): The CRC of the audio device. isInput (bool): True if searching for an input device, False for an output device. Returns: tuple: A tuple containing the native device index and name, or (None, None) if not found. """ try: in_devices = [] out_devices = [] fetch_audio_devices(in_devices, out_devices) if isInput: detected_devices = in_devices else: detected_devices = out_devices for i, dev in enumerate(detected_devices): if dev['id'] == crc: return (dev['native_index'], dev['name']) except Exception as e: log.warning(f"Audio device {crc} not detected ", devices=detected_devices, isInput=isInput) return [None, None] def test_audio_devices(input_id: str, output_id: str) -> list: """Tests the specified input and output audio devices. This function checks the validity and settings of the given input and output audio devices. It uses the device CRC to identify the devices and attempts to check their settings using sounddevice. Args: input_id (str): The CRC of the input audio device. output_id (str): The CRC of the output audio device. Returns: list: A list of booleans indicating the test results for input and output devices, respectively. """ test_result = [False, False] try: result = get_device_index_from_crc(input_id, True) if result is None: # in_dev_index, in_dev_name = None, None raise ValueError(f"[Audio-Test] Invalid input device index {input_id}.") else: in_dev_index, in_dev_name = result sd.check_input_settings( device=in_dev_index, channels=1, dtype="int16", samplerate=48000, ) test_result[0] = True except (sd.PortAudioError, ValueError) as e: log.warning(f"[Audio-Test] Input device error ({input_id}):", e=e) test_result[0] = False try: result = get_device_index_from_crc(output_id, False) if result is None: # out_dev_index, out_dev_name = None, None raise ValueError(f"[Audio-Test] Invalid output device index {output_id}.") else: out_dev_index, out_dev_name = result sd.check_output_settings( device=out_dev_index, channels=1, dtype="int16", samplerate=48000, ) test_result[1] = True except (sd.PortAudioError, ValueError) as e: log.warning(f"[Audio-Test] Output device error ({output_id}):", e=e) test_result[1] = False sd._terminate() sd._initialize() return test_result def set_audio_volume(datalist: np.ndarray, dB: float) -> np.ndarray: """ Scale values for the provided audio samples by dB. :param datalist: Audio samples to scale :type datalist: np.ndarray :param dB: Decibels to scale samples, constrained to the range [-50, 50] :type dB: float :return: Scaled audio samples :rtype: np.ndarray """ try: dB = float(dB) except ValueError as e: print(f"[MDM] Changing audio volume failed with error: {e}") dB = 0.0 # 0 dB means no change # Clip dB value to the range [-50, 50] dB = np.clip(dB, -30, 20) # Ensure datalist is an np.ndarray if not isinstance(datalist, np.ndarray): print("[MDM] Invalid data type for datalist. Expected np.ndarray.") return datalist # Convert dB to linear scale scale_factor = 10 ** (dB / 20) # Scale samples scaled_data = datalist * scale_factor # Clip values to int16 range and convert data type return np.clip(scaled_data, -32768, 32767).astype(np.int16) def normalize_audio(datalist: np.ndarray) -> np.ndarray: """ Normalize the audio samples so the loudest value reaches 95% of the maximum possible value for np.int16 :param datalist: Audio samples to normalize :type datalist: np.ndarray :return: Normalized audio samples, clipped to the range of int16 :rtype: np.ndarray """ if not isinstance(datalist, np.ndarray): #print("[MDM] Invalid datalist type. Expected np.ndarray.") return datalist # Ensure datalist is not empty if datalist.size == 0: #print("[MDM] Datalist is empty. Returning unmodified.") return datalist # Find the maximum absolute value in the data max_value = np.max(np.abs(datalist)) # If max_value is 0, return the datalist (avoid division by zero) if max_value == 0: #print("[MDM] Max value is zero. Cannot normalize. Returning unmodified.") return datalist # Define the target max value as 95% of the maximum for np.int16 target_max_value = int(32767 * 0.95) # Compute the normalization factor normalization_factor = target_max_value / max_value # Normalize the audio data normalized_data = datalist * normalization_factor # Clip to the int16 range and cast normalized_data = np.clip(normalized_data, -32768, 32767).astype(np.int16) # Debug information: normalization factor, loudest value before, and after normalization loudest_before = max_value loudest_after = np.max(np.abs(normalized_data)) # print(f"[AUDIO] Normalization factor: {normalization_factor:.6f}, Loudest before: {loudest_before}, Loudest after: {loudest_after}") return normalized_data # Global variables to manage channel status CHANNEL_BUSY_DELAY = 0 # Counter for channel busy delay SLOT_DELAY = [0] * 5 # Counters for delays in each slot # Constants for delay logic DELAY_INCREMENT = 2 # Amount to increase delay MAX_DELAY = 200 # Maximum allowable delay # Predefined frequency ranges (slots) for FFT analysis # These ranges are based on an FFT length of 800 samples SLOT_RANGES = [ (0, 65), # Slot 1: Frequency range from 0 to 65 (65, 120), # Slot 2: Frequency range from 65 to 120 (120, 176), # Slot 3: Frequency range from 120 to 176 (176, 231), # Slot 4: Frequency range from 176 to 231 (231, 315) # Slot 5: Frequency range from 231 to 315 ] # Initialize a queue to store FFT results for visualization fft_queue = queue.Queue() # Variable to track the time of the last RMS calculation last_rms_time = 0 def prepare_data_for_fft(data, target_length_samples=800): """ Prepare the input data for FFT by ensuring it meets the required length. Parameters: - data: numpy.ndarray of type np.int16, representing the audio data. - target_length_samples: int, the desired length of the data in samples. Returns: - numpy.ndarray of type np.int16 with a length of target_length_samples. """ # Check if the input data type is np.int16 if data.dtype != np.int16: raise ValueError("Audio data must be of type np.int16") # If data is shorter than the target length, pad with zeros if len(data) < target_length_samples: return np.pad(data, (0, target_length_samples - len(data)), 'constant', constant_values=(0,)) else: # If data is longer or equal to the target length, truncate it return data[:target_length_samples] def calculate_rms_dbfs(data): """ Calculate the Root Mean Square (RMS) value of the audio data and convert it to dBFS (decibels relative to full scale). Parameters: - data: numpy.ndarray of type np.int16, representing the audio data. Returns: - float: RMS value in dBFS. Returns -100 if the RMS value is 0. """ # Compute the RMS value using int32 to prevent overflow rms = np.sqrt(np.mean(np.square(data, dtype=np.int32), dtype=np.float64)) # Convert the RMS value to dBFS return 20 * np.log10(rms / 32768) if rms > 0 else -100 def calculate_fft(data, fft_queue, states) -> None: """Calculates the FFT of audio data and updates channel busy status. This function performs FFT on the provided audio data, identifies significant frequency components, and updates the channel busy status based on activity within predefined frequency slots. It also calculates and updates the RMS level of the audio data. Args: data (np.ndarray): The audio data as a NumPy array. fft_queue (queue.Queue): A queue to store FFT results for visualization. states (StateManager): The state manager object to update channel busy status. """ global CHANNEL_BUSY_DELAY, last_rms_time try: # Prepare the data for FFT processing by ensuring it meets the target length data = prepare_data_for_fft(data) # Compute the real FFT of the audio data fftarray = np.fft.rfft(data) # Calculate the amplitude spectrum in decibels (dB) dfft = 10.0 * np.log10(np.abs(fftarray) + 1e-12) # Adding a small constant to avoid log(0) # Compute the average amplitude of the spectrum avg_amplitude = np.mean(dfft) # Set the threshold for significant frequency components; adjust the offset as needed threshold = avg_amplitude + 13 # Identify frequency components that exceed the threshold significant_frequencies = dfft > threshold # Check if the system is neither transmitting nor receiving not_transmitting = not states.isTransmitting() not_receiving = not states.is_receiving_codec2_signal() if not_transmitting: # Highlight significant frequencies in the dfft array dfft[significant_frequencies] = 100 # Get the current time current_time = time.time() # Update the RMS value every second if current_time - last_rms_time >= 1.0: # Calculate the RMS value in dBFS audio_dbfs = calculate_rms_dbfs(data) # Update the state with the new RMS value states.set("audio_dbfs", audio_dbfs) # Update the last RMS calculation time last_rms_time = current_time # Convert the dfft array to integers for further processing dfft = dfft.astype(int) # Convert the dfft array to a list for queue insertion dfftlist = dfft.tolist() # Initialize the slot busy status list slotbusy = [False] * len(SLOT_RANGES) # Flag to determine if additional delay should be added addDelay = False # Iterate over each slot range to detect activity for slot, (range_start, range_end) in enumerate(SLOT_RANGES): # Check if any frequency in the slot exceeds the threshold if np.any(significant_frequencies[range_start:range_end]) and not_transmitting and not_receiving: # Mark that additional delay should be added addDelay = True # Set the current slot as busy slotbusy[slot] = True # Increment the slot delay, ensuring it does not exceed the maximum SLOT_DELAY[slot] = min(SLOT_DELAY[slot] + DELAY_INCREMENT, MAX_DELAY) else: # Decrement the slot delay, ensuring it does not go below zero SLOT_DELAY[slot] = max(SLOT_DELAY[slot] - 1, 0) # Set the slot busy status based on the current delay slotbusy[slot] = SLOT_DELAY[slot] > 0 # Update the state with the current slot busy statuses states.set_channel_slot_busy(slotbusy) if addDelay: # Set the channel busy condition due to traffic states.set_channel_busy_condition_traffic(True) # Increment the channel busy delay, ensuring it does not exceed the maximum CHANNEL_BUSY_DELAY = min(CHANNEL_BUSY_DELAY + DELAY_INCREMENT, MAX_DELAY) else: # Decrement the channel busy delay, ensuring it does not go below zero CHANNEL_BUSY_DELAY = max(CHANNEL_BUSY_DELAY - 1, 0) # If the channel busy delay has reset, clear the busy condition if CHANNEL_BUSY_DELAY == 0: states.set_channel_busy_condition_traffic(False) # Clear any existing items in the FFT queue while not fft_queue.empty(): fft_queue.get() # Add the processed dfft list to the FFT queue, limited to the first 315 elements fft_queue.put(dfftlist[:315]) except Exception as err: # Log any exceptions that occur during the FFT calculation print(f"[MDM] calculate_fft: Exception: {err}") def terminate(): """Terminates the audio instance. This function terminates the sounddevice instance if it's initialized, releasing audio resources and preventing potential issues during shutdown. """ log.warning("[SHUTDOWN] terminating audio instance...") if sd._initialized: sd._terminate()