diff --git a/data/processed/lab025/lab025_audio_spectrum.png b/data/processed/lab025/lab025_audio_spectrum.png new file mode 100644 index 0000000..1d4f303 Binary files /dev/null and b/data/processed/lab025/lab025_audio_spectrum.png differ diff --git a/data/processed/lab025/lab025_audio_waveform.png b/data/processed/lab025/lab025_audio_waveform.png new file mode 100644 index 0000000..9c54e4c Binary files /dev/null and b/data/processed/lab025/lab025_audio_waveform.png differ diff --git a/data/processed/lab025/lab025_report.txt b/data/processed/lab025/lab025_report.txt new file mode 100644 index 0000000..6d3d095 --- /dev/null +++ b/data/processed/lab025/lab025_report.txt @@ -0,0 +1,29 @@ +Lab025. Приём и программная WFM-демодуляция + +URI Pluto+: ip:192.168.2.1 +Частота станции: 100100000 Гц +RX LO: 100350000 Гц +Цифровое смещение: +250000 Гц +Частота дискретизации RX: 2400000 Гц +Полоса RX: 1500000 Гц +Количество рабочих буферов: 128 +Количество IQ-сэмплов: 8388608 +Длительность IQ-записи: 3.495253 с +Частота канального сигнала после децимации: 240000 Гц +Длительность итогового WAV: 3.445250 с +Частота WAV: 48000 Гц +Тип PCM: signed PCM16, mono +RMS итогового аудио: 0.389148 +Пиковая амплитуда: 0.974792 +Отсчёты на границе PCM16: 0.000000 % + +Выходные файлы: +WAV: data\processed\lab025\lab025_wfm_audio_100_1mhz.wav +Радиоспектр: data\processed\lab025\lab025_rf_spectrum.png +Звуковая волна: data\processed\lab025\lab025_audio_waveform.png +Спектр звука: data\processed\lab025\lab025_audio_spectrum.png +Отчёт: data\processed\lab025\lab025_report.txt + +Использован только приёмный канал RX1. +Передатчики TX1 и TX2 не использовались. +Необработанные IQ-сэмплы на диск не сохранялись. \ No newline at end of file diff --git a/data/processed/lab025/lab025_rf_spectrum.png b/data/processed/lab025/lab025_rf_spectrum.png new file mode 100644 index 0000000..639aaed Binary files /dev/null and b/data/processed/lab025/lab025_rf_spectrum.png differ diff --git a/data/processed/lab025/lab025_wfm_audio_100_1mhz.wav b/data/processed/lab025/lab025_wfm_audio_100_1mhz.wav new file mode 100644 index 0000000..22b82c7 Binary files /dev/null and b/data/processed/lab025/lab025_wfm_audio_100_1mhz.wav differ diff --git a/tests/lab025_pluto_wfm_demodulation.py b/tests/lab025_pluto_wfm_demodulation.py new file mode 100644 index 0000000..9f62a04 --- /dev/null +++ b/tests/lab025_pluto_wfm_demodulation.py @@ -0,0 +1,875 @@ +""" +Lab025. Приём и программная WFM-демодуляция FM-радиостанции. + +Схема подключения: + антенна 40–860 МГц -> RX1 + +Используется только приёмный канал RX1. Передатчики TX1 и TX2 +не используются. Необработанные IQ-сэмплы на диск не сохраняются. +""" + +from pathlib import Path + +import adi +import matplotlib +import numpy as np +from scipy import signal +from scipy.io import wavfile + + +# Для лабораторной графики сохраняется в файлы без блокирующих окон. +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# --------------------------------------------------------------------- +# Параметры приёмника Pluto+ +# --------------------------------------------------------------------- + +PLUTO_URI = "ip:192.168.2.1" + +STATION_FREQUENCY_HZ = 100_100_000 +LO_OFFSET_HZ = 250_000 + +RX_LO_FREQUENCY_HZ = ( + STATION_FREQUENCY_HZ + LO_OFFSET_HZ +) + +SAMPLE_RATE_HZ = 2_400_000 +RX_BANDWIDTH_HZ = 1_500_000 + +RX_BUFFER_SIZE = 65_536 +DISCARD_BUFFER_COUNT = 3 +CAPTURE_BUFFER_COUNT = 128 + + +# --------------------------------------------------------------------- +# Параметры обработки сигнала +# --------------------------------------------------------------------- + +CHANNEL_DECIMATION = 10 +CHANNEL_SAMPLE_RATE_HZ = 240_000 + +AUDIO_CUTOFF_HZ = 15_000 +AUDIO_SAMPLE_RATE_HZ = 48_000 + +DEEMPHASIS_TIME_CONSTANT_SECONDS = 50e-6 + +AUDIO_TRANSIENT_DURATION_SECONDS = 0.05 +AUDIO_REFERENCE_PERCENTILE = 99.5 +AUDIO_TARGET_LEVEL = 0.85 +MINIMUM_AUDIO_REFERENCE_PEAK = 1e-12 + +RF_WELCH_MAXIMUM_SAMPLE_COUNT = 1_048_576 +RF_WELCH_SEGMENT_LENGTH = 8_192 +RF_WELCH_OVERLAP_LENGTH = 4_096 + +AUDIO_WELCH_SEGMENT_LENGTH = 8_192 + + +# --------------------------------------------------------------------- +# Выходные файлы +# --------------------------------------------------------------------- + +OUTPUT_DIRECTORY = Path("data/processed/lab025") + +WAV_FILE_PATH = ( + OUTPUT_DIRECTORY / "lab025_wfm_audio_100_1mhz.wav" +) +RF_SPECTRUM_FILE_PATH = ( + OUTPUT_DIRECTORY / "lab025_rf_spectrum.png" +) +AUDIO_WAVEFORM_FILE_PATH = ( + OUTPUT_DIRECTORY / "lab025_audio_waveform.png" +) +AUDIO_SPECTRUM_FILE_PATH = ( + OUTPUT_DIRECTORY / "lab025_audio_spectrum.png" +) +REPORT_FILE_PATH = OUTPUT_DIRECTORY / "lab025_report.txt" + + +def configure_receiver() -> adi.Pluto: + """ + Подключается к Pluto+ и настраивает только приёмный канал RX1. + + Частота гетеродина смещена на 250 кГц выше частоты станции. + Благодаря этому полезный сигнал не совпадает с аппаратным DC-пиком + в центре комплексной полосы приёмника. + """ + + print("Подключение к Pluto+...") + + sdr = adi.Pluto(uri=PLUTO_URI) + + # Используем только первый приёмный канал RX1. + sdr.rx_enabled_channels = [0] + + sdr.sample_rate = SAMPLE_RATE_HZ + sdr.rx_lo = RX_LO_FREQUENCY_HZ + sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ + + # Медленная АРУ подходит для приёма вещательной FM-станции. + sdr.gain_control_mode_chan0 = "slow_attack" + sdr.rx_buffer_size = RX_BUFFER_SIZE + + return sdr + + +def receive_samples(sdr: adi.Pluto) -> np.ndarray: + """ + Получает последовательность комплексных IQ-буферов. + + Первые буферы отбрасываются, поскольку после настройки приёмника + в них могут присутствовать переходные процессы АРУ и фильтров. + Рабочие буферы объединяются только в оперативной памяти. + """ + + print() + print( + "Отбрасывание переходных буферов: " + f"{DISCARD_BUFFER_COUNT}..." + ) + + for _ in range(DISCARD_BUFFER_COUNT): + _ = sdr.rx() + + print("Получение рабочих IQ-буферов...") + + received_buffers: list[np.ndarray] = [] + + for buffer_number in range(1, CAPTURE_BUFFER_COUNT + 1): + received_buffer = np.asarray( + sdr.rx(), + dtype=np.complex64, + ) + + if received_buffer.size == 0: + raise RuntimeError( + "Pluto+ вернул пустой рабочий IQ-буфер." + ) + + received_buffers.append(received_buffer) + + if ( + buffer_number % 16 == 0 + or buffer_number == CAPTURE_BUFFER_COUNT + ): + print( + f" Принято {buffer_number:3d}/" + f"{CAPTURE_BUFFER_COUNT} буферов" + ) + + if not received_buffers: + raise RuntimeError("Pluto+ не вернул IQ-сэмплы.") + + combined_samples = np.concatenate(received_buffers) + + if combined_samples.size == 0: + raise RuntimeError("После объединения получен пустой IQ-массив.") + + if not np.all(np.isfinite(combined_samples)): + raise RuntimeError("В IQ-сэмплах обнаружены NaN или Inf.") + + return np.asarray(combined_samples, dtype=np.complex64) + + +def shift_station_to_baseband( + samples: np.ndarray, + sample_rate_hz: float, + frequency_shift_hz: float, +) -> np.ndarray: + """ + Переносит выбранную станцию в центр цифровой полосы. + + При положительном смещении комплексный генератор переносит сигнал, + расположенный на отрицательной относительной частоте, к 0 Гц. + """ + + if samples.size == 0: + raise RuntimeError( + "Невозможно выполнить цифровой перенос пустого IQ-массива." + ) + + sample_indices = np.arange( + len(samples), + dtype=np.float64, + ) + + digital_oscillator = np.exp( + 1j + * 2.0 + * np.pi + * frequency_shift_hz + * sample_indices + / sample_rate_hz + ).astype(np.complex64) + + centered_samples = samples * digital_oscillator + + if not np.all(np.isfinite(centered_samples)): + raise RuntimeError( + "После цифрового переноса обнаружены NaN или Inf." + ) + + return np.asarray(centered_samples, dtype=np.complex64) + + +def extract_wfm_channel( + centered_samples: np.ndarray, +) -> np.ndarray: + """ + Фильтрует WFM-канал и понижает частоту до 240 кГц. + + Полигармоническая передискретизация одновременно выполняет + низкочастотную фильтрацию и децимацию в десять раз. + """ + + calculated_sample_rate_hz = ( + SAMPLE_RATE_HZ / CHANNEL_DECIMATION + ) + + if not np.isclose( + calculated_sample_rate_hz, + CHANNEL_SAMPLE_RATE_HZ, + ): + raise RuntimeError( + "Частота канального сигнала после децимации " + "не равна 240 кГц." + ) + + channel_samples = signal.resample_poly( + centered_samples, + up=1, + down=CHANNEL_DECIMATION, + window=("kaiser", 8.0), + ) + + if channel_samples.size == 0: + raise RuntimeError("После выделения WFM-канала массив пуст.") + + if not np.all(np.isfinite(channel_samples)): + raise RuntimeError( + "После выделения WFM-канала обнаружены NaN или Inf." + ) + + return np.asarray(channel_samples, dtype=np.complex64) + + +def demodulate_fm( + channel_samples: np.ndarray, +) -> np.ndarray: + """ + Выполняет частотную демодуляцию по фазовой разности отсчётов. + + Результат представляет монофонический композитный FM-сигнал + до звуковой фильтрации и коррекции предыскажений. + """ + + if channel_samples.size < 2: + raise RuntimeError( + "Недостаточно канальных отсчётов для FM-демодуляции." + ) + + phase_difference = np.angle( + channel_samples[1:] + * np.conj(channel_samples[:-1]) + ).astype(np.float64) + + demodulated = phase_difference - np.mean(phase_difference) + + if demodulated.size == 0: + raise RuntimeError("FM-дискриминатор вернул пустой массив.") + + if not np.all(np.isfinite(demodulated)): + raise RuntimeError( + "После FM-демодуляции обнаружены NaN или Inf." + ) + + return demodulated + + +def lowpass_audio( + demodulated_samples: np.ndarray, + sample_rate_hz: float, +) -> np.ndarray: + """ + Выделяет монофонический звук L+R в полосе до 15 кГц. + + Фильтр подавляет стереопилот 19 кГц, стереоразностную часть, + RDS и внеполосный высокочастотный шум. + """ + + audio_sos = signal.butter( + 6, + AUDIO_CUTOFF_HZ, + btype="lowpass", + fs=sample_rate_hz, + output="sos", + ) + + filtered_audio = signal.sosfiltfilt( + audio_sos, + demodulated_samples, + ) + + if filtered_audio.size == 0: + raise RuntimeError("Звуковой фильтр вернул пустой массив.") + + if not np.all(np.isfinite(filtered_audio)): + raise RuntimeError( + "После звукового фильтра обнаружены NaN или Inf." + ) + + return np.asarray(filtered_audio, dtype=np.float64) + + +def apply_deemphasis( + audio_samples: np.ndarray, + sample_rate_hz: float, + time_constant_seconds: float, +) -> np.ndarray: + """ + Выполняет европейскую FM-коррекцию предыскажений 50 мкс. + + Используется устойчивый однополюсный рекурсивный фильтр, + реализованный функцией scipy.signal.lfilter. + """ + + alpha = np.exp( + -1.0 + / ( + sample_rate_hz + * time_constant_seconds + ) + ) + + deemphasized = signal.lfilter( + [1.0 - alpha], + [1.0, -alpha], + audio_samples, + ) + + deemphasized = deemphasized - np.mean(deemphasized) + + if deemphasized.size == 0: + raise RuntimeError("De-emphasis вернул пустой массив.") + + if not np.all(np.isfinite(deemphasized)): + raise RuntimeError( + "После de-emphasis обнаружены NaN или Inf." + ) + + return np.asarray(deemphasized, dtype=np.float64) + + +def resample_audio_to_output_rate( + audio_samples: np.ndarray, +) -> np.ndarray: + """ + Преобразует звуковой сигнал с 240 кГц в выходные 48 кГц. + """ + + output_decimation = 5 + calculated_output_rate_hz = ( + CHANNEL_SAMPLE_RATE_HZ / output_decimation + ) + + if not np.isclose( + calculated_output_rate_hz, + AUDIO_SAMPLE_RATE_HZ, + ): + raise RuntimeError( + "Рассчитанная частота WAV не равна 48 кГц." + ) + + output_audio = signal.resample_poly( + audio_samples, + up=1, + down=output_decimation, + ) + + if output_audio.size == 0: + raise RuntimeError( + "После преобразования в 48 кГц получен пустой массив." + ) + + if not np.all(np.isfinite(output_audio)): + raise RuntimeError( + "После преобразования звука обнаружены NaN или Inf." + ) + + return np.asarray(output_audio, dtype=np.float64) + + +def convert_audio_to_pcm16( + audio_samples: np.ndarray, +) -> np.ndarray: + """ + Удаляет переходный участок, нормализует звук и создаёт PCM16. + + Опорный уровень определяется по 99,5-му процентилю модуля, + поэтому единичный выброс не делает весь WAV слишком тихим. + """ + + if audio_samples.size == 0: + raise RuntimeError("Невозможно нормализовать пустой звук.") + + centered_audio = audio_samples - np.mean(audio_samples) + + transient_sample_count = int( + round( + AUDIO_TRANSIENT_DURATION_SECONDS + * AUDIO_SAMPLE_RATE_HZ + ) + ) + + if centered_audio.size <= transient_sample_count: + raise RuntimeError( + "Звуковой массив короче переходного участка 0,05 с." + ) + + centered_audio = centered_audio[transient_sample_count:] + + reference_peak = float( + np.percentile( + np.abs(centered_audio), + AUDIO_REFERENCE_PERCENTILE, + ) + ) + + if ( + not np.isfinite(reference_peak) + or reference_peak <= MINIMUM_AUDIO_REFERENCE_PEAK + ): + raise RuntimeError( + "Сигнал слишком мал для безопасной нормализации PCM16." + ) + + normalized_audio = ( + centered_audio + / reference_peak + * AUDIO_TARGET_LEVEL + ) + + normalized_audio = np.clip( + normalized_audio, + -1.0, + 1.0, + ) + + pcm16_audio = np.round( + normalized_audio * np.iinfo(np.int16).max + ).astype(np.int16) + + if pcm16_audio.size == 0: + raise RuntimeError("После преобразования PCM16 массив пуст.") + + return pcm16_audio + + +def calculate_rf_spectrum( + samples: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """ + Рассчитывает ограниченный спектр исходного IQ методом Уэлча. + + Для графика используется не более 1 048 576 отсчётов, поэтому + полная восьмимиллионная запись не передаётся в одну большую FFT. + """ + + analysis_sample_count = min( + len(samples), + RF_WELCH_MAXIMUM_SAMPLE_COUNT, + ) + + analysis_samples = samples[:analysis_sample_count] + + relative_frequencies_hz, power_density = signal.welch( + analysis_samples, + fs=SAMPLE_RATE_HZ, + window="hann", + nperseg=RF_WELCH_SEGMENT_LENGTH, + noverlap=RF_WELCH_OVERLAP_LENGTH, + detrend=False, + return_onesided=False, + scaling="density", + ) + + relative_frequencies_hz = np.fft.fftshift( + relative_frequencies_hz + ) + power_density = np.fft.fftshift(power_density) + + minimum_positive_value = np.finfo(np.float64).tiny + power_db = 10.0 * np.log10( + power_density + minimum_positive_value + ) + power_db -= np.max(power_db) + + return relative_frequencies_hz, power_db + + +def create_rf_spectrum_graph(samples: np.ndarray) -> None: + """ + Сохраняет спектр до цифрового переноса станции в центр. + """ + + frequencies_hz, power_db = calculate_rf_spectrum(samples) + + figure, axes = plt.subplots(figsize=(13, 7)) + + axes.plot( + frequencies_hz / 1e3, + power_db, + linewidth=0.8, + ) + + expected_station_offset_hz = -LO_OFFSET_HZ + + axes.axvline( + expected_station_offset_hz / 1e3, + color="tab:red", + linestyle="--", + linewidth=1.4, + label=( + "Ожидаемая станция 100,100 МГц " + "(-250 кГц)" + ), + ) + + axes.set_title( + "Lab025. Спектр принятого IQ до цифрового переноса\n" + "RX LO = 100,350 МГц; станция = 100,100 МГц" + ) + axes.set_xlabel("Относительная частота относительно RX LO, кГц") + axes.set_ylabel("Относительная мощность, дБ") + axes.set_ylim(-90, 5) + axes.grid(True) + axes.legend() + + figure.tight_layout() + figure.savefig(RF_SPECTRUM_FILE_PATH, dpi=160) + plt.close(figure) + + +def create_audio_waveform_graph(pcm16_audio: np.ndarray) -> None: + """ + Сохраняет первые 0,1 секунды нормированного звука. + """ + + displayed_sample_count = min( + len(pcm16_audio), + int(round(0.1 * AUDIO_SAMPLE_RATE_HZ)), + ) + + displayed_audio = ( + pcm16_audio[:displayed_sample_count].astype(np.float64) + / np.iinfo(np.int16).max + ) + time_seconds = ( + np.arange(displayed_sample_count, dtype=np.float64) + / AUDIO_SAMPLE_RATE_HZ + ) + + figure, axes = plt.subplots(figsize=(12, 5)) + axes.plot(time_seconds, displayed_audio, linewidth=0.8) + axes.set_title("Lab025. Первые 0,1 секунды демодулированного звука") + axes.set_xlabel("Время, с") + axes.set_ylabel("Нормированная амплитуда") + axes.set_ylim(-1.05, 1.05) + axes.grid(True) + + figure.tight_layout() + figure.savefig(AUDIO_WAVEFORM_FILE_PATH, dpi=160) + plt.close(figure) + + +def create_audio_spectrum_graph(pcm16_audio: np.ndarray) -> None: + """ + Рассчитывает методом Уэлча и сохраняет односторонний спектр WAV. + """ + + normalized_audio = ( + pcm16_audio.astype(np.float64) + / np.iinfo(np.int16).max + ) + + segment_length = min( + AUDIO_WELCH_SEGMENT_LENGTH, + len(normalized_audio), + ) + + frequencies_hz, power_density = signal.welch( + normalized_audio, + fs=AUDIO_SAMPLE_RATE_HZ, + window="hann", + nperseg=segment_length, + noverlap=segment_length // 2, + detrend="constant", + return_onesided=True, + scaling="density", + ) + + minimum_positive_value = np.finfo(np.float64).tiny + power_db = 10.0 * np.log10( + power_density + minimum_positive_value + ) + power_db -= np.max(power_db) + + figure, axes = plt.subplots(figsize=(12, 6)) + axes.plot(frequencies_hz / 1e3, power_db, linewidth=0.9) + axes.set_xlim(0, 20) + axes.set_ylim(-100, 5) + axes.set_title("Lab025. Спектр демодулированного монофонического звука") + axes.set_xlabel("Частота, кГц") + axes.set_ylabel("Относительная спектральная плотность, дБ") + axes.grid(True) + + figure.tight_layout() + figure.savefig(AUDIO_SPECTRUM_FILE_PATH, dpi=160) + plt.close(figure) + + +def calculate_audio_statistics( + pcm16_audio: np.ndarray, +) -> tuple[float, float, float]: + """ + Возвращает нормированные RMS, пик и процент предельных отсчётов. + """ + + normalized_audio = ( + pcm16_audio.astype(np.float64) + / np.iinfo(np.int16).max + ) + + rms_value = float( + np.sqrt(np.mean(normalized_audio ** 2)) + ) + peak_value = float(np.max(np.abs(normalized_audio))) + + clipped_sample_count = int( + np.count_nonzero( + (pcm16_audio == np.iinfo(np.int16).min) + | (pcm16_audio == np.iinfo(np.int16).max) + ) + ) + clipping_percentage = ( + 100.0 + * clipped_sample_count + / len(pcm16_audio) + ) + + return rms_value, peak_value, clipping_percentage + + +def save_report( + iq_sample_count: int, + iq_duration_seconds: float, + pcm16_audio: np.ndarray, + audio_rms: float, + audio_peak: float, + clipping_percentage: float, +) -> None: + """ + Создаёт текстовый отчёт с параметрами приёма и WAV-файла. + """ + + wav_duration_seconds = ( + len(pcm16_audio) / AUDIO_SAMPLE_RATE_HZ + ) + + report_lines = [ + "Lab025. Приём и программная WFM-демодуляция", + "", + f"URI Pluto+: {PLUTO_URI}", + f"Частота станции: {STATION_FREQUENCY_HZ} Гц", + f"RX LO: {RX_LO_FREQUENCY_HZ} Гц", + f"Цифровое смещение: +{LO_OFFSET_HZ} Гц", + f"Частота дискретизации RX: {SAMPLE_RATE_HZ} Гц", + f"Полоса RX: {RX_BANDWIDTH_HZ} Гц", + f"Количество рабочих буферов: {CAPTURE_BUFFER_COUNT}", + f"Количество IQ-сэмплов: {iq_sample_count}", + f"Длительность IQ-записи: {iq_duration_seconds:.6f} с", + ( + "Частота канального сигнала после децимации: " + f"{CHANNEL_SAMPLE_RATE_HZ} Гц" + ), + f"Длительность итогового WAV: {wav_duration_seconds:.6f} с", + f"Частота WAV: {AUDIO_SAMPLE_RATE_HZ} Гц", + "Тип PCM: signed PCM16, mono", + f"RMS итогового аудио: {audio_rms:.6f}", + f"Пиковая амплитуда: {audio_peak:.6f}", + f"Отсчёты на границе PCM16: {clipping_percentage:.6f} %", + "", + "Выходные файлы:", + f"WAV: {WAV_FILE_PATH}", + f"Радиоспектр: {RF_SPECTRUM_FILE_PATH}", + f"Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}", + f"Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}", + f"Отчёт: {REPORT_FILE_PATH}", + "", + "Использован только приёмный канал RX1.", + "Передатчики TX1 и TX2 не использовались.", + "Необработанные IQ-сэмплы на диск не сохранялись.", + ] + + REPORT_FILE_PATH.write_text( + "\n".join(report_lines), + encoding="utf-8", + ) + + +def main() -> None: + """ + Выполняет полный цикл приёма, WFM-демодуляции и сохранения WAV. + """ + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + + sdr = None + + try: + sdr = configure_receiver() + + print() + print("Параметры RX:") + print(f" URI: {PLUTO_URI}") + print( + f" Станция: " + f"{STATION_FREQUENCY_HZ / 1e6:.3f} МГц" + ) + print( + f" RX LO: " + f"{sdr.rx_lo / 1e6:.3f} МГц" + ) + print( + f" Частота дискретизации: " + f"{sdr.sample_rate / 1e6:.3f} Мвыб/с" + ) + print( + f" Полоса RX: " + f"{sdr.rx_rf_bandwidth / 1e6:.3f} МГц" + ) + print( + f" Режим усиления: " + f"{sdr.gain_control_mode_chan0}" + ) + print(f" Размер буфера: {sdr.rx_buffer_size}") + print(" Активный канал: RX1") + + samples = receive_samples(sdr) + + iq_sample_count = len(samples) + iq_duration_seconds = iq_sample_count / SAMPLE_RATE_HZ + + print() + print(f"Количество IQ-сэмплов: {iq_sample_count}") + print(f"Длительность записи: {iq_duration_seconds:.3f} с") + + # Удаляем остаточную комплексную постоянную составляющую. + samples = samples - np.mean(samples) + + print() + print("Цифровой перенос станции в центр полосы...") + centered_samples = shift_station_to_baseband( + samples=samples, + sample_rate_hz=SAMPLE_RATE_HZ, + frequency_shift_hz=LO_OFFSET_HZ, + ) + + print("Фильтрация WFM-канала и децимация до 240 кГц...") + channel_samples = extract_wfm_channel(centered_samples) + + print("FM-демодуляция...") + demodulated_samples = demodulate_fm(channel_samples) + + print("Звуковой low-pass фильтр 0–15 кГц...") + filtered_audio = lowpass_audio( + demodulated_samples=demodulated_samples, + sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ, + ) + + print("De-emphasis 50 мкс...") + deemphasized_audio = apply_deemphasis( + audio_samples=filtered_audio, + sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ, + time_constant_seconds=( + DEEMPHASIS_TIME_CONSTANT_SECONDS + ), + ) + + print("Преобразование звука в 48 кГц...") + output_audio = resample_audio_to_output_rate( + deemphasized_audio + ) + pcm16_audio = convert_audio_to_pcm16(output_audio) + + audio_rms, audio_peak, clipping_percentage = ( + calculate_audio_statistics(pcm16_audio) + ) + + print("Сохранение WAV...") + wavfile.write( + WAV_FILE_PATH, + AUDIO_SAMPLE_RATE_HZ, + pcm16_audio, + ) + + if not WAV_FILE_PATH.is_file(): + raise RuntimeError("Выходной WAV-файл не создан.") + + print("Сохранение графиков...") + create_rf_spectrum_graph(samples) + create_audio_waveform_graph(pcm16_audio) + create_audio_spectrum_graph(pcm16_audio) + + print("Создание текстового отчёта...") + save_report( + iq_sample_count=iq_sample_count, + iq_duration_seconds=iq_duration_seconds, + pcm16_audio=pcm16_audio, + audio_rms=audio_rms, + audio_peak=audio_peak, + clipping_percentage=clipping_percentage, + ) + + print() + print("Созданы файлы:") + print(f" WAV: {WAV_FILE_PATH}") + print(f" Радиоспектр: {RF_SPECTRUM_FILE_PATH}") + print(f" Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}") + print(f" Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}") + print(f" Отчёт: {REPORT_FILE_PATH}") + print() + print(f"RMS аудио: {audio_rms:.6f}") + print(f"Пиковая амплитуда: {audio_peak:.6f}") + print( + f"Отсчёты на границе PCM: " + f"{clipping_percentage:.6f} %" + ) + print() + print("Lab025 выполнена успешно.") + print() + print("Для прослушивания откройте:") + print(WAV_FILE_PATH) + + finally: + if sdr is not None: + destroy_buffer = getattr( + sdr, + "rx_destroy_buffer", + None, + ) + + if callable(destroy_buffer): + try: + destroy_buffer() + except Exception as cleanup_error: + print( + "Предупреждение: не удалось освободить " + f"RX-буфер: {cleanup_error}" + ) + + +if __name__ == "__main__": + main()