855 lines
38 KiB
Python
855 lines
38 KiB
Python
# DDS-генератор на аудио-ШИМ (RP2040) — CircuitPython
|
||
#
|
||
# Вариант 5 генератора для лабораторных работ. Совместим по системе команд
|
||
# с вариантами 1–4 (F, SF, W, D, M, A, SWEEP, STOP, B, O, N, FN, FP, I,
|
||
# CAL, HELP), но выход сделан не через PIO и не через программный ШИМ,
|
||
# а через штатный звуковой модуль CircuitPython audiopwmio.
|
||
#
|
||
# ЧЕРНОВИК (AI-сгенерировано, 2026-10-28). Требует проверки преподавателем
|
||
# на реальном стенде: частоты и разрядность посчитаны по исходникам ядра,
|
||
# но реальный размах и уровень несущей надо проверить осциллографом.
|
||
#
|
||
# Дополнительные библиотеки НЕ нужны: audiopwmio и audiocore входят в
|
||
# прошивку CircuitPython для RP2040.
|
||
#
|
||
# ──────────────── ПОЧЕМУ НЕ analogio (важно) ────────────────
|
||
#
|
||
# В CircuitPython модуль analogio содержит ровно два класса:
|
||
#
|
||
# analogio.AnalogIn — вход АЦП (им и меряем, см. команду A);
|
||
# analogio.AnalogOut — выход ЦАП.
|
||
#
|
||
# AnalogOut работает только там, где есть НАСТОЯЩИЙ ЦАП. У RP2040 его нет,
|
||
# поэтому в документации прямо написано: «Not available on Nordic, RP2040,
|
||
# Spresense, as there is no on-chip DAC». То есть аналогового выхода на
|
||
# Pico через analogio не получить — ни в каком виде.
|
||
#
|
||
# Вывод «звука через ШИМ» в CircuitPython делает другой модуль — audiopwmio:
|
||
#
|
||
# audiopwmio.PWMAudioOut(pin) — канал вывода (ШИМ-слайс);
|
||
# audiocore.RawSample(буфер, ...) — проигрываемый сэмпл.
|
||
#
|
||
# Именно его и использует этот вариант.
|
||
#
|
||
# ──────────────── КАК ЭТО РАБОТАЕТ ────────────────
|
||
#
|
||
# На RP2040 внутри audiopwmio:
|
||
#
|
||
# * занимается один ШИМ-слайс (GP15 — это слайс 7, канал B);
|
||
# * TOP регистра скважности = 1023, поэтому несущая ШИМ:
|
||
#
|
||
# f_несущей = 125 МГц / 1024 = 122,07 кГц (фиксирована ядром)
|
||
#
|
||
# * скважность (10 бит из 16-битного сэмпла) обновляется НЕ программой,
|
||
# а DMA по таймеру темпа. Таймер настраивается дробным делителем
|
||
# 125 МГц * X / Y, поэтому частота сэмплирования задаётся очень точно:
|
||
#
|
||
# sample_rate = f_вых * m, где m = N * O — сэмплов на период
|
||
#
|
||
# * после RC-фильтра низких частот остаётся среднее значение скважности,
|
||
# то есть наш сигнал. Несущие 122 кГц фильтр давит, сигнал пропускает.
|
||
#
|
||
# Отличие от варианта 1 и 2 (программный ШИМ) — в том, что цикл Python в
|
||
# формировании сигнала НЕ участвует вообще. Отличие от вариантов 3 и 4
|
||
# (PIO) — в том, что вместо PIO-машины работает штатный аудио-тракт ядра.
|
||
#
|
||
# ──────────────── ЧТО ЭТО ДАЁТ И ЧЕГО НЕ ДАЁТ ────────────────
|
||
#
|
||
# Плюсы:
|
||
# * вывод идёт в фоне (DMA), CPU свободен — команды принимаются на ходу,
|
||
# АЦП можно читать ОДНОВРЕМЕННО с выводом сигнала;
|
||
# * несущая 122 кГц фильтруется тем же RC, что и в остальных вариантах
|
||
# (1к/47н, f_c ~ 3,4 кГц);
|
||
# * не нужны adafruit_pioasm и rp2pio — только прошивка.
|
||
#
|
||
# Минусы (важно для лаборатории):
|
||
# * это ЗВУКОВОЙ тракт: частота сэмплирования ограничена сверху ~48 кГц,
|
||
# потому что она должна быть заметно ниже несущей 122 кГц. Значит
|
||
# верхняя частота сигнала:
|
||
#
|
||
# f_вых_max = sample_rate_max / m
|
||
#
|
||
# при m = 16 это 3 кГц, при m = 48 — 1 кГц, при m = 256 — 188 Гц.
|
||
# Выше 3–4 кГц этот вариант не годится, там нужны PIO (варианты 3–4)
|
||
# или программный ШИМ (варианты 1–2).
|
||
# * разрядность скважности 10 бит — команда B больше 10 не имеет смысла
|
||
# (ядро всё равно оставит 10 старших бит).
|
||
# * при малом m форма ступенчатая: 16 точек на период — это грубо.
|
||
#
|
||
# ──────────────── ПИНЫ ────────────────
|
||
#
|
||
# GP15 — выход (ШИМ в режиме Q, аудио-ШИМ в режиме S);
|
||
# GP16 — синхроимпульс (вне зоны, которую занимает аудио);
|
||
# GP26 — вход ADC0 (A0), до 3,3 В, больше — только через делитель.
|
||
#
|
||
# ──────────────── КОМАНДЫ ────────────────
|
||
#
|
||
# F <Гц> — частота (1..200000, но см. предел выше)
|
||
# SF <Гц> — то же, явно для синуса
|
||
# W Q | W S — режим: Q — прямоугольник (pwmio), S — звук (audiopwmio)
|
||
# D <0..255> — скважность прямоугольника в режиме Q
|
||
# M <0..127> — амплитуда (127 = полная шкала)
|
||
# A — измерить размах на ADC0 -> "A <мВ>"
|
||
# SWEEP f0 f1 n ms — свип: "P <Гц> <мВ>" на точку, затем "DONE"
|
||
# STOP — прервать свип
|
||
# B <1..10> — разрядность скважности (10 — предел audiopwmio)
|
||
# O <1..64> — множитель сэмплов: m = N*O
|
||
# N <16..4096> — «полезных» сэмплов на период
|
||
# FN <имя> — форма: sin, sine2, triangle, saw, square
|
||
# FP <выражение> — своя функция от t (0..1)
|
||
# CAL <Гц> — автокалибровка по осциллографу (поправка частоты)
|
||
# CLC <n> — НЕ ПРИМЕНИМА (нет PIO); принимается для совместимости
|
||
# I — настройки, пределы частоты, фактическая частота
|
||
#
|
||
# ВНИМАНИЕ: ADC0 (GP26) — вход 0..3,3 В. Сигнал больше 3,3 В подавать
|
||
# только через делитель. Схему без питания к ADC не подключать.
|
||
|
||
import array
|
||
import math
|
||
import sys
|
||
import time
|
||
|
||
import board
|
||
import digitalio
|
||
import pwmio
|
||
|
||
import audiocore
|
||
import audiopwmio
|
||
|
||
try:
|
||
import analogbufio
|
||
except ImportError: # pragma: no cover
|
||
analogbufio = None
|
||
print("# analogbufio недоступен: команды A и SWEEP работают по-простому")
|
||
|
||
|
||
# ─────────────────────────── константы ───────────────────────────
|
||
|
||
PIN_OUT = board.GP15 # выход: PWM в режиме Q, аудио-ШИМ в режиме S
|
||
PIN_TRIG = board.GP16 # синхроимпульс
|
||
PIN_ADC = board.GP26 # ADC0 — вход измерения
|
||
|
||
SYS_CLK = 125_000_000 # тактовая RP2040, Гц
|
||
|
||
# Внутри audiopwmio на RP2040: BITS_PER_SAMPLE = 10, TOP = 2^10 - 1 = 1023.
|
||
# Отсюда фиксированная несущая ШИМ и предельная разрядность 10 бит.
|
||
PWM_BITS = 10
|
||
PWM_CARRIER_HZ = SYS_CLK / (1 << PWM_BITS) # 122 070,3 Гц
|
||
|
||
# Темп сэмплирования задаёт DMA-таймер с делителем до 65536, значит снизу
|
||
# частота ограничена ~1907 Гц (та же цифра, что предел clkdiv у PIO).
|
||
SR_MIN = SYS_CLK / 65536 # ≈ 1907 Гц
|
||
# Сверху ограничиваем «по-звуковому»: сэмплы должны быть заметно реже
|
||
# несущей, иначе ШИМ не успевает их отыграть и на выходе будет мусор.
|
||
SR_MAX = 48000
|
||
|
||
ADC_FULL_MV = 3300 # опорное напряжение ADC, мВ
|
||
|
||
# Буфер сэмплов: 2 байта на сэмпл ("H"), держим не больше 64 КБ.
|
||
MAX_BUFFER_BYTES = 64 * 1024
|
||
|
||
# Пределы для команд N и O (те же, что в вариантах 3–4, для совместимости).
|
||
N_MIN, N_MAX = 16, 4096
|
||
O_MIN, O_MAX = 1, 64
|
||
|
||
|
||
def _default_wave(t):
|
||
"""Форма по умолчанию: синус. t — доля периода [0,1)."""
|
||
return math.sin(2.0 * math.pi * t)
|
||
|
||
|
||
# ─────────────────────────── класс AudioPWMDDS ───────────────────────────
|
||
|
||
class AudioPWMDDS:
|
||
"""DDS на audiopwmio: буфер сэмплов играет DMA, CPU свободен."""
|
||
|
||
def __init__(self, bits=8, oversample=1, n=32, freq=1000, level=127):
|
||
self.bits = int(bits)
|
||
self.oversample = int(oversample)
|
||
self.n = int(n)
|
||
self.freq = int(freq)
|
||
self.level = int(level)
|
||
self.wave = _default_wave
|
||
self.wave_name = "sin"
|
||
self.corr = 1.0 # поправка частоты после CAL
|
||
self.audio = None # создаётся лениво, в ensure()
|
||
self._sample = None # RawSample — держим ссылку!
|
||
self._buf = None # array('H') — буфер должен жить
|
||
|
||
# ─── параметры ───
|
||
|
||
def samples_per_period(self):
|
||
"""m — сколько сэмплов приходится на один период сигнала."""
|
||
return self.n * self.oversample
|
||
|
||
def sample_rate_wanted(self):
|
||
"""Желаемая частота сэмплирования, Гц (до ограничения)."""
|
||
return self.freq * self.samples_per_period() * self.corr
|
||
|
||
def sample_rate_used(self):
|
||
"""Частота сэмплирования, которая реально будет выставлена."""
|
||
sr = int(round(self.sample_rate_wanted()))
|
||
if sr < int(SR_MIN) + 1:
|
||
sr = int(SR_MIN) + 1
|
||
if sr > SR_MAX:
|
||
sr = SR_MAX
|
||
return sr
|
||
|
||
def f_out_actual(self):
|
||
"""Фактическая частота сигнала при текущем sample_rate."""
|
||
return self.sample_rate_used() / float(self.samples_per_period())
|
||
|
||
def f_out_max(self):
|
||
return SR_MAX / float(self.samples_per_period())
|
||
|
||
def f_out_min(self):
|
||
return (int(SR_MIN) + 1) / float(self.samples_per_period())
|
||
|
||
def needs_warning(self):
|
||
"""True, если запрошенная частота не влезает в звуковой тракт."""
|
||
sr = self.sample_rate_wanted()
|
||
return sr > SR_MAX or sr < SR_MIN
|
||
|
||
def suggest_reduction(self):
|
||
"""Подсказка, что менять, если частота не достижима."""
|
||
m = self.samples_per_period()
|
||
if self.sample_rate_wanted() > SR_MAX:
|
||
ok = int(SR_MAX / float(self.freq * self.corr)) if self.freq else 0
|
||
return ("уменьшите N*O до %d (сейчас m=%d) или снизьте частоту "
|
||
"до %.1f Гц" % (max(N_MIN, ok), m, self.f_out_max()))
|
||
return ("увеличьте N*O (сейчас m=%d): минимум %.1f Гц"
|
||
% (m, self.f_out_min()))
|
||
|
||
def set_frequency(self, hz):
|
||
self.freq = max(1, min(200000, int(hz)))
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def set_bits(self, b):
|
||
b = int(b)
|
||
if b < 1:
|
||
raise ValueError("B должно быть 1..%d" % PWM_BITS)
|
||
if b > PWM_BITS:
|
||
raise ValueError("для audiopwmio максимум B=%d (ядро оставляет "
|
||
"10 старших бит)" % PWM_BITS)
|
||
self.bits = b
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def set_oversample(self, o):
|
||
o = int(o)
|
||
if o < O_MIN or o > O_MAX:
|
||
raise ValueError("O должно быть %d..%d" % (O_MIN, O_MAX))
|
||
self._check_buffer(self.n * o) # проверить ДО присваивания
|
||
self.oversample = o
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def set_n(self, n):
|
||
n = int(n)
|
||
if n < N_MIN or n > N_MAX:
|
||
raise ValueError("N должно быть %d..%d" % (N_MIN, N_MAX))
|
||
self._check_buffer(n * self.oversample)
|
||
self.n = n
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def set_amplitude(self, level):
|
||
self.level = max(0, min(127, int(level)))
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def set_function(self, fn, name="func"):
|
||
self.wave = fn
|
||
self.wave_name = name
|
||
if self.audio is not None:
|
||
self._reload()
|
||
|
||
def reset_function(self):
|
||
self.set_function(_default_wave, "sin")
|
||
|
||
def slew_warning(self):
|
||
"""В этом варианте нет дельта-сигмы, поэтому ограничения наклона нет.
|
||
Метод оставлен для совместимости с вызовами из main()."""
|
||
return None
|
||
|
||
# ─── буфер и воспроизведение ───
|
||
|
||
def _check_buffer(self, m=None):
|
||
"""Проверить, что буфер для m сэмплов влезает в память."""
|
||
if m is None:
|
||
m = self.samples_per_period()
|
||
if m * 2 > MAX_BUFFER_BYTES:
|
||
raise ValueError("буфер %d байт > %d — уменьшите N или O"
|
||
% (m * 2, MAX_BUFFER_BYTES))
|
||
|
||
def _build(self):
|
||
"""Заполнить буфер: m сэмплов одного периода, 16 бит, 0..65535.
|
||
|
||
Квантование по B бит делается ЗДЕСЬ, а не в железе: уровень
|
||
округляется до 2^B значений скважности и растягивается на всю
|
||
16-битную шкалу. Так команда B честно показывает, сколько
|
||
ступенек увидит осциллограф.
|
||
"""
|
||
m = self.samples_per_period()
|
||
self._check_buffer(m)
|
||
|
||
levels = (1 << self.bits) - 1 # 1, 3, 7, ... 1023
|
||
amp = self.level / 127.0
|
||
|
||
buf = array.array("H", [0]) * m
|
||
for i in range(m):
|
||
u = self.wave(i / float(m)) * amp
|
||
if u > 1.0:
|
||
u = 1.0
|
||
elif u < -1.0:
|
||
u = -1.0
|
||
# 0.5 + 0.5*u -> 0..1; округляем до B-битной ступеньки
|
||
level = int(round((0.5 + 0.5 * u) * levels))
|
||
buf[i] = level * 65535 // levels
|
||
self._buf = buf
|
||
return m
|
||
|
||
def _reload(self):
|
||
"""Пересобрать буфер и перезапустить воспроизведение."""
|
||
self._build()
|
||
sr = self.sample_rate_used()
|
||
self._sample = audiocore.RawSample(self._buf,
|
||
channel_count=1,
|
||
sample_rate=sr)
|
||
if self.audio is not None:
|
||
self.audio.play(self._sample, loop=True)
|
||
|
||
def ensure(self):
|
||
"""Создать канал вывода и запустить сигнал (лениво)."""
|
||
if self.audio is None:
|
||
self.audio = audiopwmio.PWMAudioOut(PIN_OUT,
|
||
quiescent_value=0x8000)
|
||
self._reload()
|
||
return self
|
||
|
||
def release(self):
|
||
"""Остановить и освободить ШИМ-слайс (нужно для режима Q)."""
|
||
if self.audio is not None:
|
||
try:
|
||
self.audio.stop()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.audio.deinit()
|
||
except Exception:
|
||
pass
|
||
self.audio = None
|
||
self._sample = None
|
||
self._buf = None
|
||
|
||
def deinit(self):
|
||
self.release()
|
||
|
||
def calibrate(self, measured_hz):
|
||
"""CAL <измеренная_Гц>: подстроить частоту сэмплирования.
|
||
|
||
Ошибка тут может быть только от кварца (доли промилле), но команда
|
||
оставлена для единообразия с вариантами 3–4: вводим измеренное,
|
||
получаем поправку corr и пересчёт.
|
||
"""
|
||
if self.needs_warning():
|
||
raise ValueError("сначала задайте достижимую частоту: сейчас "
|
||
"запрошено %d Гц при пределе %.1f Гц"
|
||
% (self.freq, self.f_out_max()))
|
||
measured = float(measured_hz)
|
||
if measured <= 0:
|
||
raise ValueError("измеренная частота должна быть > 0")
|
||
old = self.corr
|
||
self.corr = self.corr * self.freq / measured
|
||
self.corr = max(0.5, min(2.0, self.corr))
|
||
if self.audio is not None:
|
||
self._reload()
|
||
return old, self.corr
|
||
|
||
def info(self):
|
||
m = self.samples_per_period()
|
||
return ("аудио-ШИМ: f=%d Гц, форма=%s, B=%d, N=%d, O=%d, m=%d, "
|
||
"Fs=%d Гц, несущая=%.1f кГц, f_ном=%.2f Гц, "
|
||
"поправка=%.6f, f_min=%.1f, f_max=%.1f Гц"
|
||
% (self.freq, self.wave_name, self.bits, self.n,
|
||
self.oversample, m, self.sample_rate_used(),
|
||
PWM_CARRIER_HZ / 1000.0, self.f_out_actual(), self.corr,
|
||
self.f_out_min(), self.f_out_max()))
|
||
|
||
|
||
# ───────────────────── прямоугольник на PWM ─────────────────────
|
||
#
|
||
# Режим Q — обычный аппаратный ШИМ на том же GP15. Одновременно с
|
||
# аудио-ШИМ он работать не может: audiopwmio занимает весь слайс.
|
||
# Поэтому режимы включаются по очереди (ensure/release), как в варианте 3.
|
||
|
||
class SquarePWM:
|
||
def __init__(self, pin=PIN_OUT):
|
||
self.pwm = pwmio.PWMOut(pin, frequency=1000,
|
||
duty_cycle=0, variable_frequency=True)
|
||
|
||
def set_frequency(self, f):
|
||
self.pwm.frequency = max(1, min(200000, int(f)))
|
||
|
||
def set_duty(self, d):
|
||
d = max(0, min(255, int(d)))
|
||
self.pwm.duty_cycle = d * 65535 // 255
|
||
|
||
def off(self):
|
||
self.pwm.duty_cycle = 0
|
||
|
||
def deinit(self):
|
||
try:
|
||
self.pwm.deinit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ─────────────────────────── АЦП ───────────────────────────
|
||
|
||
def measure_amplitude_mv(window_ms=50, rate=100000):
|
||
"""Размах (пик-пик) на ADC0 в милливольтах.
|
||
|
||
Чтение через analogbufio (аппаратный ADC + DMA). Если библиотеки нет
|
||
или DMA занят звуком — откатываемся на analogio.AnalogIn.
|
||
|
||
ВАЖНО: воспроизведение при этом не останавливается — audiopwmio играет
|
||
на своём DMA-канале, ADC работает независимо.
|
||
"""
|
||
if analogbufio is not None:
|
||
length = int(rate * window_ms / 1000)
|
||
length = max(64, min(8192, length))
|
||
buf = array.array("H", [0]) * length
|
||
adc = None
|
||
try:
|
||
adc = analogbufio.BufferedIn(PIN_ADC, sample_rate=rate)
|
||
adc.readinto(buf, loop=False)
|
||
return (max(buf) - min(buf)) * ADC_FULL_MV // 65535
|
||
except Exception:
|
||
pass # нет DMA-канала или частоты — ниже
|
||
finally:
|
||
if adc is not None:
|
||
try:
|
||
adc.deinit()
|
||
except Exception:
|
||
pass
|
||
|
||
import analogio
|
||
adc = analogio.AnalogIn(PIN_ADC)
|
||
vmin, vmax = 65535, 0
|
||
t0 = time.monotonic()
|
||
try:
|
||
while (time.monotonic() - t0) * 1000 < window_ms:
|
||
v = adc.value
|
||
if v < vmin:
|
||
vmin = v
|
||
if v > vmax:
|
||
vmax = v
|
||
finally:
|
||
adc.deinit()
|
||
return (vmax - vmin) * ADC_FULL_MV // 65535
|
||
|
||
|
||
# ─────────────────────────── свип ───────────────────────────
|
||
|
||
trig = None
|
||
|
||
|
||
def ensure_trig():
|
||
"""Создать синхроимпульс при первом использовании (лениво!)."""
|
||
global trig
|
||
if trig is None:
|
||
trig = digitalio.DigitalInOut(PIN_TRIG)
|
||
trig.direction = digitalio.Direction.OUTPUT
|
||
trig.value = False
|
||
return trig
|
||
|
||
|
||
def trigger_pulse():
|
||
t = ensure_trig()
|
||
t.value = True
|
||
time.sleep(0.00005)
|
||
t.value = False
|
||
|
||
|
||
def run_sweep(dds, square, mode, f0, f1, count, dwell_ms):
|
||
"""Логарифмический свип. Печатает 'P <Гц> <мВ>', в конце 'DONE'."""
|
||
count = max(2, count)
|
||
if f1 <= f0:
|
||
f1 = f0 * 10.0
|
||
ratio = (f1 / f0) ** (1.0 / (count - 1))
|
||
f = float(f0)
|
||
try:
|
||
for _ in range(count):
|
||
if mode == "S":
|
||
dds.set_frequency(int(f))
|
||
else:
|
||
square.set_frequency(int(f))
|
||
trigger_pulse()
|
||
time.sleep(dwell_ms / 1000.0)
|
||
mv = measure_amplitude_mv()
|
||
sys.stdout.write("P %d %d\n" % (int(f), mv))
|
||
f *= ratio
|
||
except KeyboardInterrupt:
|
||
sys.stdout.write("DONE\n")
|
||
raise
|
||
sys.stdout.write("DONE\n")
|
||
|
||
|
||
# ───────── предустановленные формы сигнала (для команды FN) ─────────
|
||
|
||
def _wave_sin(t):
|
||
return math.sin(2.0 * math.pi * t)
|
||
|
||
|
||
def _wave_sine2(t):
|
||
return (0.3 * math.sin(2.0 * math.pi * t) +
|
||
0.5 * math.sin(4.0 * math.pi * t))
|
||
|
||
|
||
def _wave_triangle(t):
|
||
return 4.0 * abs(t - 0.5) - 1.0
|
||
|
||
|
||
def _wave_saw(t):
|
||
return 2.0 * t - 1.0
|
||
|
||
|
||
def _wave_square(t):
|
||
return 1.0 if t < 0.5 else -1.0
|
||
|
||
|
||
WAVES = {
|
||
"sin": _wave_sin,
|
||
"sine2": _wave_sine2,
|
||
"triangle": _wave_triangle,
|
||
"saw": _wave_saw,
|
||
"square": _wave_square,
|
||
}
|
||
|
||
# Разрешённые имена для команд FP/FN: только математика, без встроенных.
|
||
_EXPR_NAMES = {
|
||
"sin": math.sin, "cos": math.cos, "tan": math.tan,
|
||
"pi": math.pi, "abs": abs,
|
||
}
|
||
|
||
EXPR_LEN_MAX = 120
|
||
|
||
|
||
def _eval_expr(expr, t):
|
||
"""Вычислить выражение от t. Отдельная функция, не lambda: на плате
|
||
разбор lambda в командах не срабатывал (см. вариант 3)."""
|
||
env = dict(_EXPR_NAMES)
|
||
env["t"] = t
|
||
return eval(expr, {"__builtins__": {}}, env)
|
||
|
||
|
||
def _expr_ok(expr):
|
||
"""Проверить выражение до использования. None, если всё в порядке."""
|
||
if len(expr) > EXPR_LEN_MAX:
|
||
return "выражение слишком длинное (лимит %d)" % EXPR_LEN_MAX
|
||
for t in (0.0, 0.125, 0.333, 0.5, 0.75):
|
||
try:
|
||
v = _eval_expr(expr, t)
|
||
except Exception as e:
|
||
return "%s (при t=%.3f)" % (e, t)
|
||
if v != v: # NaN
|
||
return "не числовой результат (NaN)"
|
||
if abs(v) > 1e6:
|
||
return "слишком большое значение (%g) — форма вне [-1,1]" % v
|
||
return None
|
||
|
||
|
||
def _make_expr_wave(expr):
|
||
def wave(t):
|
||
return _eval_expr(expr, t)
|
||
return wave
|
||
|
||
|
||
# ─────────────────────────── подробный хелп ───────────────────────────
|
||
|
||
def print_help():
|
||
lines = [
|
||
"# ─────── DDS-генератор на аудио-ШИМ (audiopwmio): команды ───────",
|
||
"# Обозначения: <..> — обязательный аргумент.",
|
||
"#",
|
||
"# РЕЖИМ И ЧАСТОТА",
|
||
"# W Q — режим прямоугольника (аппаратный ШИМ на GP15)",
|
||
"# W S — режим звука (audiopwmio, GP15)",
|
||
"# F <Гц> — частота, в режиме Q — прямоугольника,",
|
||
"# в S — сигнала. пример: F 440",
|
||
"# SF <Гц> — частота СИГНАЛА (то же, явно)",
|
||
"#",
|
||
"# ФОРМА СИГНАЛА",
|
||
"# D <0..255> — скважность прямоугольника (128 = 50 %)",
|
||
"# M <0..127> — амплитуда (127 = полная шкала)",
|
||
"# FN <имя> — форма: sin, sine2, triangle, saw, square",
|
||
"# FP <выражение> — своя функция от t (0..1), например:",
|
||
"# FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t)",
|
||
"#",
|
||
"# ИЗМЕРЕНИЕ И СВИП (работает ОДНОВРЕМЕННО с выводом)",
|
||
"# A — размах на ADC0 (GP26), мВ -> 'A <мВ>'",
|
||
"# SWEEP <f0> <f1> <n> <dwell_ms>",
|
||
"# логарифмический свип; печатает 'P <Гц> <мВ>',",
|
||
"# в конце 'DONE'. пример: SWEEP 20 3000 20 150",
|
||
"# STOP — прервать свип",
|
||
"#",
|
||
"# ПАРАМЕТРЫ СИНТЕЗА",
|
||
"# B <1..10> — разрядность скважности. 10 — предел ядра",
|
||
"# (внутри audiopwmio берутся 10 старших бит)",
|
||
"# O <1..64> — множитель сэмплов, m = N * O",
|
||
"# N <16..4096> — «полезных» сэмплов на период",
|
||
"# I — настройки, пределы, f_min/f_max, поправка",
|
||
"# CAL <Гц> — автокалибровка: подайте частоту, измерьте",
|
||
"# осциллографом, введите измеренное (CAL 440)",
|
||
"# CLC <n> — НЕ ПРИМЕНИМА: PIO здесь нет; принимается",
|
||
"# только чтобы не ломать общий сценарий команд",
|
||
"#",
|
||
"# ГЛАВНОЕ ОГРАНИЧЕНИЕ (в чём отличие от вариантов 3–4)",
|
||
"# Частота сэмплирования не больше 48000 Гц, поэтому",
|
||
"# f_max = 48000 / m.",
|
||
"# При m=16 это 3 кГц, при m=48 — 1 кГц, при m=256 — 188 Гц.",
|
||
"# Выше 3–4 кГц этот вариант не работает — берите PIO.",
|
||
"# Команда I показывает f_min и f_max для текущих N и O.",
|
||
"#",
|
||
"# КАРТА ПИНОВ",
|
||
"# GP15 — выход (ШИМ в Q, аудио-ШИМ в S)",
|
||
"# GP16 — синхроимпульс",
|
||
"# GP26 (A0) — вход ADC, до 3,3 В (через делитель)",
|
||
"#",
|
||
"# ЧЕМ ЭТО ОТЛИЧАЕТСЯ ОТ analogio",
|
||
"# analogio умеет только AnalogIn (вход) и AnalogOut (ЦАП).",
|
||
"# На RP2040 ЦАПА НЕТ, поэтому AnalogOut недоступен, и «звук через",
|
||
"# ШИМ» делается модулем audiopwmio — он и используется здесь.",
|
||
"#",
|
||
"# ФИЛЬТР НА ВЫХОДЕ (тот же, что в остальных вариантах)",
|
||
"# GP15 --[R1]--+--[R2]--+--> во вход схемы",
|
||
"# | |",
|
||
"# [C1] [C2] R1=R2=1к, C1=C2=47н",
|
||
"# | | (f_c ~ 3.4 кГц)",
|
||
"# GND GND",
|
||
"# Несущая ШИМ 122 кГц давится этим фильтром примерно на 60 дБ.",
|
||
"#",
|
||
"# СПРАВКА",
|
||
"# HELP | ? — этот текст",
|
||
]
|
||
for ln in lines:
|
||
sys.stdout.write(ln + "\n")
|
||
|
||
|
||
# ─────────────────────────── main ───────────────────────────
|
||
|
||
def main():
|
||
print("# DDS на аудио-ШИМ (RP2040 / CircuitPython).")
|
||
print("# Вывод: audiopwmio, вход: analogio/analogbufio.")
|
||
print("# Подробный хелп: HELP или ?")
|
||
|
||
dds = None
|
||
square = None
|
||
try:
|
||
dds = AudioPWMDDS(bits=8, oversample=1, n=32, freq=1000, level=127)
|
||
square = SquarePWM()
|
||
square.set_frequency(1000)
|
||
square.set_duty(128)
|
||
print("# " + dds.info())
|
||
print(" (режим Q: PWM на GP15; режим S: audiopwmio на GP15)")
|
||
except Exception as e:
|
||
print("! ОШИБКА инициализации:", repr(e))
|
||
if dds is not None:
|
||
dds.deinit()
|
||
if square is not None:
|
||
square.deinit()
|
||
return
|
||
|
||
mode = "Q"
|
||
saved_square_hz = 1000
|
||
|
||
try:
|
||
while True:
|
||
line = sys.stdin.readline()
|
||
if not line:
|
||
break # EOF: порт закрыт
|
||
parts = line.split()
|
||
if not parts:
|
||
continue
|
||
cmd = parts[0].upper()
|
||
|
||
if cmd in ("HELP", "?"):
|
||
print_help()
|
||
|
||
elif cmd == "F" and len(parts) > 1:
|
||
hz = int(float(parts[1]))
|
||
if mode == "S":
|
||
dds.set_frequency(hz)
|
||
print("OK F %d (сигнал)" % dds.freq)
|
||
if dds.needs_warning():
|
||
print("! ВНИМАНИЕ: частота недостижима — %s"
|
||
% dds.suggest_reduction())
|
||
print("! Сейчас реально: %.1f Гц" % dds.f_out_actual())
|
||
else:
|
||
square.set_frequency(hz)
|
||
print("OK F %d (прямоугольник)" % hz)
|
||
|
||
elif cmd == "SF" and len(parts) > 1:
|
||
dds.set_frequency(int(float(parts[1])))
|
||
print("OK SF %d" % dds.freq)
|
||
if dds.needs_warning():
|
||
print("! ВНИМАНИЕ: частота недостижима — %s"
|
||
% dds.suggest_reduction())
|
||
print("! Сейчас реально: %.1f Гц" % dds.f_out_actual())
|
||
|
||
elif cmd == "W" and len(parts) > 1:
|
||
want = "S" if parts[1].upper().startswith("S") else "Q"
|
||
if want == mode:
|
||
print("OK W %s (уже)" % mode)
|
||
else:
|
||
try:
|
||
if want == "S":
|
||
# запомнить частоту прямоугольника, погасить его,
|
||
# освободить слайс и запустить аудио-ШИМ
|
||
saved_square_hz = square.pwm.frequency
|
||
square.off()
|
||
square.deinit()
|
||
square = None
|
||
dds.ensure()
|
||
mode = "S"
|
||
print("OK W S (audiopwmio: %s)" % dds.info())
|
||
else:
|
||
# остановить аудио, вернуть ШИМ на тот же GP15
|
||
dds.release()
|
||
square = SquarePWM()
|
||
square.set_frequency(saved_square_hz)
|
||
square.set_duty(128)
|
||
mode = "Q"
|
||
print("OK W Q (PWM на GP15, %d Гц)"
|
||
% saved_square_hz)
|
||
except Exception as e:
|
||
print("ERR W %s:" % want, repr(e))
|
||
|
||
elif cmd == "D" and len(parts) > 1:
|
||
if mode == "Q" and square is not None:
|
||
square.set_duty(int(parts[1]))
|
||
print("OK D %s" % parts[1])
|
||
|
||
elif cmd == "M" and len(parts) > 1:
|
||
dds.set_amplitude(int(parts[1]))
|
||
print("OK M %d" % dds.level)
|
||
|
||
elif cmd == "A":
|
||
print("A %d" % measure_amplitude_mv())
|
||
|
||
elif cmd == "SWEEP" and len(parts) > 4:
|
||
run_sweep(dds, square, mode,
|
||
float(parts[1]), float(parts[2]),
|
||
int(parts[3]), int(parts[4]))
|
||
|
||
elif cmd == "STOP":
|
||
print("OK STOP (если свип идёт — надёжнее Ctrl-C)")
|
||
|
||
elif cmd == "B" and len(parts) > 1:
|
||
try:
|
||
dds.set_bits(int(parts[1]))
|
||
print("OK B %d" % dds.bits)
|
||
except Exception as e:
|
||
print("ERR B:", repr(e))
|
||
|
||
elif cmd == "O" and len(parts) > 1:
|
||
try:
|
||
dds.set_oversample(int(parts[1]))
|
||
print("OK O %d" % dds.oversample)
|
||
except Exception as e:
|
||
print("ERR O:", repr(e))
|
||
|
||
elif cmd == "N" and len(parts) > 1:
|
||
try:
|
||
dds.set_n(int(parts[1]))
|
||
print("OK N %d" % dds.n)
|
||
except Exception as e:
|
||
print("ERR N:", repr(e))
|
||
|
||
elif cmd == "FN" and len(parts) > 1:
|
||
name = parts[1].lower()
|
||
if name in WAVES:
|
||
try:
|
||
dds.set_function(WAVES[name], name=name)
|
||
print("OK FN %s" % name)
|
||
except Exception as e:
|
||
print("ERR FN:", repr(e))
|
||
else:
|
||
print("ERR FN: неизвестная форма. Доступны: %s"
|
||
% ", ".join(sorted(WAVES)))
|
||
|
||
elif cmd == "FP" and len(parts) > 1:
|
||
expr = line.strip()[len("FP"):].strip()
|
||
if expr:
|
||
err = _expr_ok(expr)
|
||
if err:
|
||
print("ERR FP: %s" % err)
|
||
else:
|
||
try:
|
||
dds.set_function(_make_expr_wave(expr),
|
||
name="FP:" + expr)
|
||
print("OK FP %s" % expr)
|
||
except Exception as e:
|
||
print("ERR FP:", repr(e))
|
||
dds.reset_function()
|
||
else:
|
||
print("ERR FP: укажите выражение, например "
|
||
"FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t)")
|
||
|
||
elif cmd == "CLC" and len(parts) > 1:
|
||
# Команда есть в вариантах 3–4 (тактов PIO на сэмпл).
|
||
# Здесь PIO нет: частоту задаёт DMA-таймер, подстраивать
|
||
# нечего. Принимаем, чтобы общий сценарий не падал.
|
||
print("OK CLC %s (не применима: здесь нет PIO; для поправки "
|
||
"частоты — CAL)" % parts[1])
|
||
|
||
elif cmd == "CAL" and len(parts) > 1:
|
||
try:
|
||
was, now = dds.calibrate(float(parts[1]))
|
||
print("OK CAL: поправка %.6f -> %.6f (Fs=%d Гц, "
|
||
"физическая частота должна стать %d Гц)"
|
||
% (was, now, dds.sample_rate_used(), dds.freq))
|
||
except Exception as e:
|
||
print("ERR CAL:", repr(e))
|
||
|
||
elif cmd == "I":
|
||
print("# " + dds.info())
|
||
|
||
else:
|
||
print("ERR unknown command: %s" % cmd)
|
||
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
dds.deinit()
|
||
if square is not None:
|
||
square.deinit()
|
||
if trig is not None:
|
||
try:
|
||
trig.deinit()
|
||
except Exception:
|
||
pass
|
||
print("stopped")
|
||
|
||
|
||
# ─────────────── точка входа ───────────────
|
||
#
|
||
# Как и в других вариантах, запускаем безусловно: в CircuitPython проверка
|
||
# `if __name__ == "__main__":` при автозапуске code.py ненадёжна.
|
||
|
||
_START_GENERATOR = True
|
||
|
||
if _START_GENERATOR:
|
||
try:
|
||
main()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
except Exception as _e:
|
||
try:
|
||
import sys as _sys
|
||
_sys.print_exception(_e)
|
||
except Exception:
|
||
print("! ОШИБКА:", repr(_e))
|