mirror of
https://github.com/RFnexus/FreeDVInterface.git
synced 2025-12-22 08:17:08 +00:00
943 lines
36 KiB
Python
943 lines
36 KiB
Python
import time
|
|
import threading
|
|
import queue
|
|
import numpy as np
|
|
import pyaudio
|
|
from ctypes import *
|
|
import platform
|
|
import math
|
|
from threading import Lock
|
|
import subprocess
|
|
import RNS
|
|
from RNS.Interfaces.Interface import Interface
|
|
|
|
MODE_DATAC1 = 10
|
|
MODE_DATAC3 = 12
|
|
MODE_DATAC4 = 18
|
|
|
|
|
|
class FreeDVData:
|
|
def __init__(self, mode):
|
|
system = platform.system()
|
|
libname = None
|
|
self.debug = False
|
|
|
|
if system == 'Windows':
|
|
libname = 'libcodec2.dll'
|
|
elif system == 'Linux':
|
|
libname = 'libcodec2.so'
|
|
elif system == 'Darwin':
|
|
libname = 'libcodec2.dylib'
|
|
|
|
lib_paths = [
|
|
libname,
|
|
f'./lib/{libname}',
|
|
f'~/.reticulum/interfaces/lib/{libname}',
|
|
f'/usr/local/lib/{libname}',
|
|
f'/usr/lib/{libname}',
|
|
]
|
|
|
|
loaded = False
|
|
for path in lib_paths:
|
|
try:
|
|
self.c_lib = CDLL(path)
|
|
loaded = True
|
|
break
|
|
except:
|
|
continue
|
|
|
|
if not loaded:
|
|
raise Exception(f"Could not load FreeDV library. Tried paths: {lib_paths}")
|
|
|
|
self.mode = mode
|
|
|
|
self.c_lib.freedv_open.restype = POINTER(c_ubyte)
|
|
self.freedv = self.c_lib.freedv_open(mode)
|
|
|
|
self.c_lib.freedv_get_n_max_modem_samples.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_n_max_modem_samples.restype = c_int
|
|
|
|
self.c_lib.freedv_get_n_tx_modem_samples.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_n_tx_modem_samples.restype = c_size_t
|
|
|
|
self.c_lib.freedv_get_n_tx_preamble_modem_samples.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_n_tx_preamble_modem_samples.restype = c_size_t
|
|
|
|
self.c_lib.freedv_get_n_tx_postamble_modem_samples.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_n_tx_postamble_modem_samples.restype = c_size_t
|
|
|
|
self.c_lib.freedv_get_bits_per_modem_frame.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_bits_per_modem_frame.restype = c_size_t
|
|
|
|
self.c_lib.freedv_nin.argtypes = [c_void_p]
|
|
self.c_lib.freedv_nin.restype = c_int
|
|
|
|
self.c_lib.freedv_rawdatarx.argtypes = [c_void_p, c_void_p, c_void_p]
|
|
self.c_lib.freedv_rawdatarx.restype = c_int
|
|
|
|
self.c_lib.freedv_rawdatapreambletx.argtypes = [c_void_p, c_void_p]
|
|
self.c_lib.freedv_rawdatapreambletx.restype = c_int
|
|
|
|
self.c_lib.freedv_rawdatatx.argtypes = [c_void_p, c_void_p, c_void_p]
|
|
self.c_lib.freedv_rawdatatx.restype = c_void_p
|
|
|
|
self.c_lib.freedv_rawdatapostambletx.argtypes = [c_void_p, c_void_p]
|
|
self.c_lib.freedv_rawdatapostambletx.restype = c_int
|
|
|
|
self.c_lib.freedv_gen_crc16.argtypes = [c_void_p, c_size_t]
|
|
self.c_lib.freedv_gen_crc16.restype = c_uint16
|
|
|
|
self.c_lib.freedv_set_frames_per_burst.argtypes = [c_void_p, c_int]
|
|
self.c_lib.freedv_set_frames_per_burst.restype = c_void_p
|
|
|
|
self.c_lib.freedv_set_verbose.argtypes = [c_void_p, c_int]
|
|
self.c_lib.freedv_set_verbose.restype = c_void_p
|
|
|
|
self.c_lib.freedv_close.argtypes = [c_void_p]
|
|
self.c_lib.freedv_close.restype = c_void_p
|
|
|
|
self.c_lib.freedv_get_sync.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_sync.restype = c_int
|
|
|
|
self.c_lib.freedv_get_rx_status.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_rx_status.restype = c_int
|
|
|
|
self.c_lib.freedv_get_modem_sample_rate.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_modem_sample_rate.restype = c_int
|
|
|
|
self.c_lib.freedv_get_n_nom_modem_samples.argtypes = [c_void_p]
|
|
self.c_lib.freedv_get_n_nom_modem_samples.restype = c_int
|
|
|
|
self.c_lib.freedv_get_modem_stats.argtypes = [c_void_p, POINTER(c_int), POINTER(c_float)]
|
|
self.c_lib.freedv_get_modem_stats.restype = None
|
|
|
|
self.bytes_per_modem_frame = self.c_lib.freedv_get_bits_per_modem_frame(self.freedv) // 8
|
|
self.payload_bytes_per_modem_frame = self.bytes_per_modem_frame - 2
|
|
self.n_mod_out = self.c_lib.freedv_get_n_tx_modem_samples(self.freedv)
|
|
self.n_tx_modem_samples = self.c_lib.freedv_get_n_tx_modem_samples(self.freedv)
|
|
self.n_tx_preamble_modem_samples = self.c_lib.freedv_get_n_tx_preamble_modem_samples(self.freedv)
|
|
self.n_tx_postamble_modem_samples = self.c_lib.freedv_get_n_tx_postamble_modem_samples(self.freedv)
|
|
|
|
self.c_lib.freedv_set_frames_per_burst(self.freedv, 1)
|
|
self.c_lib.freedv_set_verbose(self.freedv, 0)
|
|
|
|
self.nin = self.get_freedv_rx_nin()
|
|
|
|
def tx_burst(self, data_in):
|
|
num_frames = math.ceil(len(data_in) / self.payload_bytes_per_modem_frame)
|
|
|
|
if self.debug:
|
|
RNS.log(f"FreeDV modem TX: {len(data_in)} bytes in {num_frames} frames", RNS.LOG_EXTREME)
|
|
|
|
mod_out = create_string_buffer(self.n_tx_modem_samples * 2)
|
|
mod_out_preamble = create_string_buffer(self.n_tx_preamble_modem_samples * 2)
|
|
mod_out_postamble = create_string_buffer(self.n_tx_postamble_modem_samples * 2)
|
|
|
|
# Preamble
|
|
self.c_lib.freedv_rawdatapreambletx(self.freedv, mod_out_preamble)
|
|
txbuffer = bytes(mod_out_preamble)
|
|
|
|
# Create data frames
|
|
for i in range(num_frames):
|
|
# Main data buffer
|
|
buffer = bytearray(self.payload_bytes_per_modem_frame)
|
|
data_chunk = data_in[i * self.payload_bytes_per_modem_frame:(i + 1) * self.payload_bytes_per_modem_frame]
|
|
buffer[:len(data_chunk)] = data_chunk
|
|
|
|
# Add CRC16
|
|
crc16 = c_ushort(self.c_lib.freedv_gen_crc16(bytes(buffer), self.payload_bytes_per_modem_frame))
|
|
crc16 = crc16.value.to_bytes(2, byteorder='big')
|
|
buffer += crc16
|
|
|
|
data = (c_ubyte * self.bytes_per_modem_frame).from_buffer_copy(buffer)
|
|
self.c_lib.freedv_rawdatatx(self.freedv, mod_out, data)
|
|
txbuffer += bytes(mod_out)
|
|
|
|
self.c_lib.freedv_rawdatapostambletx(self.freedv, mod_out_postamble)
|
|
txbuffer += mod_out_postamble
|
|
|
|
s_multiplier = 2
|
|
|
|
silence_samples = int((100 / 1000) * 8000) * s_multiplier
|
|
txbuffer += bytes(silence_samples)
|
|
|
|
# extra_silence = self.c_lib.freedv_get_n_nom_modem_samples(self.freedv) * 2 * 2
|
|
# txbuffer += bytes(extra_silence)
|
|
|
|
return txbuffer
|
|
|
|
def get_freedv_rx_nin(self):
|
|
return self.c_lib.freedv_nin(self.freedv)
|
|
|
|
def get_modem_sample_rate(self):
|
|
return self.c_lib.freedv_get_modem_sample_rate(self.freedv)
|
|
|
|
def get_n_nom_modem_samples(self):
|
|
return self.c_lib.freedv_get_n_nom_modem_samples(self.freedv)
|
|
|
|
def get_sync(self):
|
|
sync = c_int()
|
|
snr = c_float()
|
|
self.c_lib.freedv_get_modem_stats(self.freedv, byref(sync), byref(snr))
|
|
return sync.value
|
|
|
|
def get_rx_status(self):
|
|
return self.c_lib.freedv_get_rx_status(self.freedv)
|
|
|
|
def rx(self, demod_in):
|
|
bytes_out = create_string_buffer(self.bytes_per_modem_frame)
|
|
nbytes_out = self.c_lib.freedv_rawdatarx(self.freedv, bytes_out, demod_in)
|
|
self.nin = self.get_freedv_rx_nin()
|
|
|
|
# get FreeDV modem stats
|
|
sync = c_int()
|
|
snr = c_float()
|
|
self.c_lib.freedv_get_modem_stats(self.freedv, byref(sync), byref(snr))
|
|
|
|
return nbytes_out, bytes_out[:nbytes_out], sync.value, snr.value
|
|
|
|
def close(self):
|
|
self.c_lib.freedv_close(self.freedv)
|
|
|
|
|
|
class AudioBuffer:
|
|
def __init__(self, size):
|
|
self.size = size
|
|
self.buffer = np.zeros(size, dtype=np.int16)
|
|
self.nbuffer = 0
|
|
self.mutex = Lock()
|
|
|
|
def push(self, samples):
|
|
self.mutex.acquire()
|
|
try:
|
|
space_available = self.size - self.nbuffer
|
|
if len(samples) > space_available:
|
|
samples_to_drop = len(samples) - space_available
|
|
self.buffer[:-samples_to_drop] = self.buffer[samples_to_drop:]
|
|
self.nbuffer -= samples_to_drop
|
|
|
|
self.buffer[self.nbuffer: self.nbuffer + len(samples)] = samples
|
|
self.nbuffer += len(samples)
|
|
finally:
|
|
self.mutex.release()
|
|
|
|
def pop(self, size):
|
|
self.mutex.acquire()
|
|
try:
|
|
if size > self.nbuffer:
|
|
size = self.nbuffer
|
|
self.nbuffer -= size
|
|
self.buffer[: self.nbuffer] = self.buffer[size: size + self.nbuffer]
|
|
finally:
|
|
self.mutex.release()
|
|
|
|
def get_samples(self, size):
|
|
self.mutex.acquire()
|
|
try:
|
|
if self.nbuffer >= size:
|
|
return self.buffer[:size].copy()
|
|
return None
|
|
finally:
|
|
self.mutex.release()
|
|
|
|
|
|
class FreeDVInterface(Interface):
|
|
DEFAULT_IFAC_SIZE = 8
|
|
|
|
def __init__(self, owner, configuration):
|
|
super().__init__()
|
|
|
|
# Parse configuration
|
|
ifconf = Interface.get_config_obj(configuration)
|
|
self.name = ifconf["name"]
|
|
|
|
self.debug = ifconf.get("debug", "false").lower() == "true"
|
|
|
|
# Get configuration parameters
|
|
self.input_device_config = ifconf.get("input_device", 0)
|
|
self.output_device_config = ifconf.get("output_device", 0)
|
|
|
|
self.input_device = self.get_device_index(self.input_device_config, is_input=True)
|
|
self.output_device = self.get_device_index(self.output_device_config, is_input=False)
|
|
|
|
self.freedv_mode_str = ifconf["freedv_mode"].lower() if "freedv_mode" in ifconf else "datac1"
|
|
self.tx_volume = float(ifconf.get("tx_volume", 100)) / 100.0
|
|
|
|
# PTT configuration
|
|
self.ptt_type = ifconf.get("ptt_type", "none").lower() # none, serial, hamlib, vox
|
|
self.ptt_enabled = self.ptt_type != "none"
|
|
|
|
# serial PTT settings
|
|
self.ptt_port = ifconf.get("ptt_port", None)
|
|
self.ptt_serial_type = ifconf.get("ptt_serial_type", "DTR").upper()
|
|
|
|
# Hamlib settings
|
|
self.hamlib_model = ifconf.get("hamlib_model", "1") # 1 = dummy/test
|
|
self.hamlib_device = ifconf.get("hamlib_device", "/dev/ttyUSB0")
|
|
self.hamlib_rigctl = ifconf.get("hamlib_rigctl", "rigctl") # path to rigctl
|
|
self.hamlib_speed = ifconf.get("hamlib_speed", "19200")
|
|
self.hamlib_data_bits = ifconf.get("hamlib_data_bits", "8")
|
|
self.hamlib_stop_bits = ifconf.get("hamlib_stop_bits", "1")
|
|
self.hamlib_parity = ifconf.get("hamlib_parity", "N").upper()
|
|
self.hamlib_extra_args = ifconf.get("hamlib_extra_args", "")
|
|
self.hamlib_network = ifconf.get("hamlib_network", "false").lower() == "true"
|
|
self.hamlib_host = ifconf.get("hamlib_host", "localhost")
|
|
self.hamlib_port = ifconf.get("hamlib_port", "4532")
|
|
|
|
# PTT timing
|
|
self.ptt_on_delay = float(ifconf.get("ptt_on_delay", "0.1")) # Delay after PTT on
|
|
self.ptt_off_delay = float(ifconf.get("ptt_off_delay", "0.1")) # Delay before PTT off
|
|
self.ptt_tail_delay = float(ifconf.get("ptt_tail_delay", "0.1")) # Delay after PTT off
|
|
self.vox_delay = float(ifconf.get("vox_delay", "0.5")) # Extra delay for VOX activation
|
|
self.vox_tone = ifconf.get("vox_tone", "false").lower() == "true" # Send tone for VOX activation
|
|
|
|
# Set FreeDV mode
|
|
if self.freedv_mode_str == "datac3":
|
|
self.freedv_mode = MODE_DATAC3
|
|
self.HW_MTU = 508 # temp
|
|
self.bitrate = 250
|
|
raise NotImplementedError
|
|
|
|
elif self.freedv_mode_str == "datac4":
|
|
self.freedv_mode = MODE_DATAC4
|
|
self.HW_MTU = 508 # temp
|
|
self.bitrate = 125
|
|
raise NotImplementedError
|
|
|
|
else:
|
|
self.freedv_mode = MODE_DATAC1
|
|
self.HW_MTU = 508 # temp
|
|
self.bitrate = 980
|
|
|
|
# Initialize properties
|
|
self.online = False
|
|
self.owner = owner
|
|
self.audio_frames_per_buffer = 256
|
|
self.is_transmitting = False
|
|
|
|
# Channel state for CSMA
|
|
self.channel_busy = False
|
|
self.last_sync_time = 0
|
|
self.sync_lock = Lock()
|
|
self.signal_threshold = float(ifconf.get("signal_threshold", "0.35")) # 0-1 normalized
|
|
self.recent_signal_level = 0.0
|
|
|
|
# CSMA settings
|
|
self.csma_enabled = ifconf.get("csma_enabled", "true").lower() == "true"
|
|
self.csma_wait_time = float(ifconf.get("csma_wait_time", "2.0")) # wait time after channel busy
|
|
self.channel_busy_timeout = float(
|
|
ifconf.get("channel_busy_timeout", "0.5")) # time after last activity before channel is clear
|
|
self.signal_threshold = float(ifconf.get("signal_threshold", "0.1")) # audio level threshold
|
|
|
|
self.tx_queue = queue.Queue(maxsize=100)
|
|
self.rx_queue = queue.Queue()
|
|
|
|
# audio buffers
|
|
self.rx_audio_buffer = AudioBuffer(self.audio_frames_per_buffer * 10000)
|
|
self.tx_audio_buffer = AudioBuffer(self.audio_frames_per_buffer * 10000)
|
|
|
|
self.running = True
|
|
self.tx_thread = None
|
|
self.rx_thread = None
|
|
self.audio_thread = None
|
|
self.monitor_thread = None
|
|
|
|
# PTT handling
|
|
self.ptt_serial = None
|
|
if self.ptt_type == "serial" and self.ptt_port:
|
|
try:
|
|
import serial
|
|
self.ptt_serial = serial.Serial(self.ptt_port)
|
|
if self.debug:
|
|
RNS.log(f"Serial PTT enabled on {self.ptt_port}", RNS.LOG_DEBUG)
|
|
except Exception as e:
|
|
RNS.log(f"Could not open PTT port {self.ptt_port}: {e}", RNS.LOG_ERROR)
|
|
self.ptt_enabled = False
|
|
elif self.ptt_type == "hamlib":
|
|
# Check if rigctl exists
|
|
try:
|
|
subprocess.run([self.hamlib_rigctl, "-V"], capture_output=True, timeout=2)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
RNS.log(f"rigctl not found or not working: {e}", RNS.LOG_ERROR)
|
|
self.ptt_enabled = False
|
|
return
|
|
|
|
# Test hamlib connection
|
|
if self.test_hamlib():
|
|
RNS.log(f"Hamlib PTT enabled - Model: {self.hamlib_model}, Device: {self.hamlib_device}", RNS.LOG_INFO)
|
|
else:
|
|
RNS.log("Hamlib PTT test failed - check configuration", RNS.LOG_ERROR)
|
|
self.ptt_enabled = False
|
|
elif self.ptt_type == "vox":
|
|
RNS.log(f"VOX PTT enabled with {self.vox_delay}s activation delay", RNS.LOG_INFO)
|
|
|
|
# start our threads
|
|
try:
|
|
self.freedv = FreeDVData(self.freedv_mode)
|
|
self.init_audio()
|
|
self.start_threads()
|
|
self.online = True
|
|
|
|
self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True)
|
|
self.monitor_thread.start()
|
|
|
|
RNS.log(f"FreeDV Interface [{self.name}] is online using {self.freedv_mode_str.upper()}", RNS.LOG_INFO)
|
|
RNS.log(f" MTU: {self.HW_MTU} bytes, Bitrate: ~{self.bitrate} bps", RNS.LOG_INFO)
|
|
if self.debug:
|
|
RNS.log(f" Mode: {self.freedv}, Payload/frame: {self.freedv.payload_bytes_per_modem_frame} bytes",
|
|
RNS.LOG_DEBUG)
|
|
|
|
if self.csma_enabled:
|
|
RNS.log(f" CSMA: Enabled (wait={self.csma_wait_time}s, timeout={self.channel_busy_timeout}s)",
|
|
RNS.LOG_INFO)
|
|
else:
|
|
RNS.log(f" CSMA: Disabled", RNS.LOG_INFO)
|
|
|
|
if self.ptt_enabled:
|
|
if self.ptt_type == "hamlib":
|
|
if self.hamlib_network:
|
|
RNS.log(f" PTT: Hamlib network mode ({self.hamlib_host}:{self.hamlib_port})", RNS.LOG_INFO)
|
|
else:
|
|
RNS.log(f" PTT: Hamlib model {self.hamlib_model} on {self.hamlib_device}", RNS.LOG_INFO)
|
|
elif self.ptt_type == "serial":
|
|
RNS.log(f" PTT: Serial {self.ptt_serial_type} on {self.ptt_port}", RNS.LOG_INFO)
|
|
elif self.ptt_type == "vox":
|
|
RNS.log(f" PTT: VOX with {self.vox_delay}s delay", RNS.LOG_INFO)
|
|
except Exception as e:
|
|
RNS.log(f"Could not initialize FreeDV interface [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
raise e
|
|
|
|
def get_device_index(self, device_config, is_input=True):
|
|
if isinstance(device_config, int):
|
|
return device_config
|
|
|
|
try:
|
|
return int(device_config)
|
|
except ValueError:
|
|
pass
|
|
|
|
p = pyaudio.PyAudio()
|
|
device_index = None
|
|
|
|
search_name = device_config.lower().strip()
|
|
|
|
for i in range(p.get_device_count()):
|
|
info = p.get_device_info_by_index(i)
|
|
device_name = info['name'].lower().strip()
|
|
|
|
if is_input and info['maxInputChannels'] == 0:
|
|
continue
|
|
if not is_input and info['maxOutputChannels'] == 0:
|
|
continue
|
|
|
|
if device_name == search_name:
|
|
device_index = i
|
|
break
|
|
|
|
# partial match
|
|
if search_name in device_name:
|
|
device_index = i
|
|
|
|
p.terminate()
|
|
|
|
if device_index is None:
|
|
RNS.log(f"Could not find audio device '{device_config}' for {'input' if is_input else 'output'}",
|
|
RNS.LOG_ERROR)
|
|
RNS.log("Available devices:", RNS.LOG_ERROR)
|
|
p = pyaudio.PyAudio()
|
|
for i in range(p.get_device_count()):
|
|
info = p.get_device_info_by_index(i)
|
|
if (is_input and info['maxInputChannels'] > 0) or (not is_input and info['maxOutputChannels'] > 0):
|
|
RNS.log(f" {i}: {info['name']}", RNS.LOG_ERROR)
|
|
p.terminate()
|
|
raise ValueError(f"Audio device '{device_config}' not found")
|
|
|
|
if self.debug:
|
|
p = pyaudio.PyAudio()
|
|
info = p.get_device_info_by_index(device_index)
|
|
RNS.log(
|
|
f"Found {'input' if is_input else 'output'} device '{device_config}' at index {device_index}: {info['name']}",
|
|
RNS.LOG_DEBUG)
|
|
p.terminate()
|
|
|
|
return device_index
|
|
|
|
def init_audio(self):
|
|
self.p = pyaudio.PyAudio()
|
|
|
|
if self.debug:
|
|
RNS.log(f"Initializing audio for FreeDV [{self.name}]", RNS.LOG_DEBUG)
|
|
input_info = self.p.get_device_info_by_index(self.input_device)
|
|
output_info = self.p.get_device_info_by_index(self.output_device)
|
|
RNS.log(f"Input device: {self.input_device} ({input_info['name']})", RNS.LOG_DEBUG)
|
|
RNS.log(f"Output device: {self.output_device} ({output_info['name']})", RNS.LOG_DEBUG)
|
|
|
|
try:
|
|
# Open audio stream
|
|
self.stream = self.p.open(
|
|
rate=8000,
|
|
channels=1,
|
|
format=pyaudio.paInt16,
|
|
frames_per_buffer=self.audio_frames_per_buffer,
|
|
input=True,
|
|
output=True,
|
|
input_device_index=self.input_device,
|
|
output_device_index=self.output_device,
|
|
stream_callback=self.audio_callback
|
|
)
|
|
|
|
self.stream.start_stream()
|
|
|
|
if not self.stream.is_active():
|
|
raise Exception("Audio stream failed to start - check configuration")
|
|
|
|
if self.debug:
|
|
RNS.log(f"Audio stream started [{self.name}]", RNS.LOG_DEBUG)
|
|
|
|
except Exception as e:
|
|
RNS.log(f"Failed audio | [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
raise
|
|
|
|
def audio_callback(self, in_data, frame_count, time_info, status):
|
|
try:
|
|
# always capture input audio
|
|
samples_int16 = np.frombuffer(in_data, dtype=np.int16)
|
|
self.rx_audio_buffer.push(samples_int16)
|
|
|
|
# check if we have TX audio to send
|
|
if self.tx_audio_buffer.nbuffer >= frame_count:
|
|
tx_samples = self.tx_audio_buffer.buffer[:frame_count].copy()
|
|
self.tx_audio_buffer.pop(frame_count)
|
|
|
|
# TODO
|
|
tx_samples = (tx_samples * self.tx_volume).astype(np.int16)
|
|
return tx_samples.tobytes(), pyaudio.paContinue
|
|
|
|
return b'\x00' * (frame_count * 2), pyaudio.paContinue
|
|
|
|
except Exception as e:
|
|
RNS.log(f"Audio callback error [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
return b'\x00' * (frame_count * 2), pyaudio.paContinue
|
|
|
|
def start_threads(self):
|
|
"""Start processing threads"""
|
|
self.tx_thread = threading.Thread(target=self.tx_loop, daemon=True)
|
|
self.rx_thread = threading.Thread(target=self.rx_loop, daemon=True)
|
|
|
|
self.tx_thread.start()
|
|
self.rx_thread.start()
|
|
|
|
def build_hamlib_cmd(self):
|
|
cmd = [self.hamlib_rigctl]
|
|
|
|
if self.hamlib_network:
|
|
# network mode
|
|
cmd.extend(["-m", "2"]) # 2 = NET rigctl
|
|
cmd.extend(["-r", f"{self.hamlib_host}:{self.hamlib_port}"])
|
|
else:
|
|
# direct serial mode
|
|
cmd.extend(["-m", self.hamlib_model])
|
|
cmd.extend(["-r", self.hamlib_device])
|
|
cmd.extend(["-s", self.hamlib_speed])
|
|
cmd.extend(["-C", f"data_bits={self.hamlib_data_bits}"])
|
|
cmd.extend(["-C", f"stop_bits={self.hamlib_stop_bits}"])
|
|
cmd.extend(["-C", f"serial_parity={self.hamlib_parity}"])
|
|
|
|
if self.hamlib_extra_args:
|
|
cmd.extend(self.hamlib_extra_args.split())
|
|
|
|
return cmd
|
|
|
|
def test_hamlib(self):
|
|
try:
|
|
cmd = self.build_hamlib_cmd()
|
|
cmd.append("f")
|
|
|
|
if self.debug:
|
|
RNS.log(f"Testing hamlib with command: {' '.join(cmd)}", RNS.LOG_DEBUG)
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
|
|
if self.debug and result.returncode != 0:
|
|
RNS.log(f"Hamlib test output: {result.stderr}", RNS.LOG_DEBUG)
|
|
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
if self.debug:
|
|
RNS.log(f"Hamlib test failed: {e}", RNS.LOG_ERROR)
|
|
return False
|
|
|
|
def ptt_on(self):
|
|
if not self.ptt_enabled:
|
|
return
|
|
|
|
try:
|
|
if self.ptt_type == "vox":
|
|
# For VOX, just add extra delay for VOX circuit to activate
|
|
time.sleep(self.vox_delay)
|
|
elif self.ptt_type == "serial" and self.ptt_serial:
|
|
if self.ptt_serial_type == "DTR":
|
|
self.ptt_serial.dtr = True
|
|
elif self.ptt_serial_type == "RTS":
|
|
self.ptt_serial.rts = True
|
|
time.sleep(self.ptt_on_delay)
|
|
elif self.ptt_type == "hamlib":
|
|
cmd = self.build_hamlib_cmd()
|
|
cmd.extend(["T", "1"])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
|
|
|
|
if result.returncode != 0 and self.debug:
|
|
RNS.log(f"Hamlib PTT ON failed: {result.stderr}", RNS.LOG_ERROR)
|
|
|
|
time.sleep(self.ptt_on_delay)
|
|
|
|
if self.debug:
|
|
RNS.log(f"Hamlib PTT ON", RNS.LOG_DEBUG)
|
|
except Exception as e:
|
|
RNS.log(f"PTT control error: {e}", RNS.LOG_ERROR)
|
|
|
|
def ptt_off(self):
|
|
if not self.ptt_enabled:
|
|
return
|
|
|
|
if self.ptt_type == "vox":
|
|
#
|
|
return
|
|
|
|
try:
|
|
time.sleep(self.ptt_off_delay) # PTT delay before off
|
|
|
|
if self.ptt_type == "serial" and self.ptt_serial:
|
|
if self.ptt_serial_type == "DTR":
|
|
self.ptt_serial.dtr = False
|
|
elif self.ptt_serial_type == "RTS":
|
|
self.ptt_serial.rts = False
|
|
elif self.ptt_type == "hamlib":
|
|
cmd = self.build_hamlib_cmd()
|
|
cmd.extend(["T", "0"]) # PTT off command
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
|
|
|
|
if result.returncode != 0 and self.debug:
|
|
RNS.log(f"Hamlib PTT OFF failed: {result.stderr}", RNS.LOG_ERROR)
|
|
|
|
if self.debug:
|
|
RNS.log(f"Hamlib PTT OFF", RNS.LOG_DEBUG)
|
|
except Exception as e:
|
|
RNS.log(f"PTT control error: {e}", RNS.LOG_ERROR)
|
|
|
|
def monitor_loop(self):
|
|
last_log = 0
|
|
while self.running:
|
|
try:
|
|
now = time.time()
|
|
if now - last_log > 30 and self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] status: TX queue={self.tx_queue.qsize()}, " +
|
|
f"RX buffer={self.rx_audio_buffer.nbuffer}, TX buffer={self.tx_audio_buffer.nbuffer}, " +
|
|
f"Transmitting={self.is_transmitting}, Channel busy={self.channel_busy}", RNS.LOG_DEBUG)
|
|
last_log = now
|
|
|
|
# check if our audio stream is still active
|
|
if hasattr(self, 'stream') and self.stream and not self.stream.is_active():
|
|
RNS.log(f"Audio stream died on FreeDV [{self.name}], attempting restart", RNS.LOG_ERROR)
|
|
self.online = False
|
|
break
|
|
|
|
time.sleep(1)
|
|
except Exception as e:
|
|
RNS.log(f"Monitor error in FreeDV [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
|
|
def update_channel_state(self, has_sync, signal_level=None):
|
|
with self.sync_lock:
|
|
if signal_level is not None:
|
|
self.recent_signal_level = signal_level
|
|
|
|
# channel is busy if we have sync OR signal level is above threshold
|
|
if has_sync or (self.recent_signal_level > self.signal_threshold):
|
|
self.channel_busy = True
|
|
self.last_sync_time = time.time()
|
|
if self.debug and (has_sync or self.recent_signal_level > self.signal_threshold):
|
|
reason = "sync detected" if has_sync else f"signal level {self.recent_signal_level:.3f}"
|
|
RNS.log(f"FreeDV [{self.name}] channel busy - {reason}", RNS.LOG_DEBUG)
|
|
else:
|
|
# channel is considered free if no sync/signal above threshold for timeout period
|
|
if time.time() - self.last_sync_time > self.channel_busy_timeout:
|
|
if self.channel_busy and self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] channel now clear", RNS.LOG_DEBUG)
|
|
self.channel_busy = False
|
|
|
|
def is_channel_clear(self):
|
|
with self.sync_lock:
|
|
# also check if RX buffer has significant data (someone might be transmitting)
|
|
rx_buffer_busy = self.rx_audio_buffer.nbuffer > (self.audio_frames_per_buffer * 10)
|
|
|
|
if rx_buffer_busy and self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] channel busy - RX buffer has {self.rx_audio_buffer.nbuffer} samples",
|
|
RNS.LOG_DEBUG)
|
|
|
|
return not self.channel_busy and not rx_buffer_busy
|
|
|
|
def wait_for_clear_channel(self):
|
|
if not self.csma_enabled:
|
|
return True
|
|
|
|
# check if channel is already clear
|
|
if self.is_channel_clear():
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] channel clear, transmitting", RNS.LOG_DEBUG)
|
|
return True
|
|
|
|
# Channel is busy, wait for it to clear
|
|
RNS.log(f"FreeDV [{self.name}] channel busy, waiting...", RNS.LOG_DEBUG)
|
|
wait_start = time.time()
|
|
last_log = wait_start
|
|
|
|
while not self.is_channel_clear():
|
|
time.sleep(0.1) # Check every 100ms
|
|
|
|
# log every 5 seconds while waiting
|
|
if time.time() - last_log > 5.0:
|
|
wait_time = time.time() - wait_start
|
|
RNS.log(f"FreeDV [{self.name}] still waiting for clear channel ({wait_time:.1f}s)", RNS.LOG_DEBUG)
|
|
last_log = time.time()
|
|
|
|
time.sleep(self.csma_wait_time)
|
|
|
|
# check one more time that channel is still clear
|
|
if not self.is_channel_clear():
|
|
return self.wait_for_clear_channel()
|
|
|
|
total_wait = time.time() - wait_start
|
|
if total_wait > 0.5:
|
|
RNS.log(f"channel clear after {total_wait:.1f}s, transmitting", RNS.LOG_DEBUG)
|
|
|
|
return True
|
|
|
|
def tx_loop(self):
|
|
while self.running:
|
|
try:
|
|
packet = self.tx_queue.get(timeout=0.1)
|
|
|
|
self.wait_for_clear_channel()
|
|
self.is_transmitting = True
|
|
|
|
max_payload = self.freedv.payload_bytes_per_modem_frame
|
|
if len(packet) > max_payload:
|
|
RNS.log(f"FreeDV [{self.name}] packet too large: {len(packet)} > {max_payload}",
|
|
RNS.LOG_ERROR)
|
|
self.is_transmitting = False
|
|
continue
|
|
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] TX: {len(packet)} bytes",
|
|
RNS.LOG_DEBUG)
|
|
|
|
self.ptt_on()
|
|
|
|
# For VOX with tone enabled send a short tone to trigger VOX
|
|
if self.ptt_type == "vox" and self.vox_tone:
|
|
# 100ms of 1kHz tone at 8kHz sample rate
|
|
tone_duration = 0.1
|
|
sample_rate = 8000
|
|
frequency = 1000
|
|
t = np.linspace(0, tone_duration, int(sample_rate * tone_duration))
|
|
tone = (np.sin(2 * np.pi * frequency * t) * 32767 * 0.3).astype(np.int16)
|
|
self.tx_audio_buffer.push(tone)
|
|
time.sleep(tone_duration + 0.1)
|
|
|
|
tx_audio = self.freedv.tx_burst(packet)
|
|
|
|
self.tx_audio_buffer.nbuffer = 0
|
|
self.tx_audio_buffer.push(np.frombuffer(tx_audio, dtype=np.int16))
|
|
|
|
# calculate expected time
|
|
samples_to_tx = len(tx_audio) // 2 # 16-bit samples
|
|
expected_time = samples_to_tx / 8000.0 # 8kHz sample rate
|
|
|
|
time.sleep(expected_time + 0.5)
|
|
|
|
# clear any remaining samples
|
|
self.tx_audio_buffer.nbuffer = 0
|
|
|
|
# disable PTT
|
|
self.ptt_off()
|
|
|
|
# add PTT tail delay
|
|
time.sleep(self.ptt_tail_delay)
|
|
|
|
# clear transmitting flag
|
|
self.is_transmitting = False
|
|
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] transmission complete", RNS.LOG_DEBUG)
|
|
|
|
except queue.Empty:
|
|
continue
|
|
except Exception as e:
|
|
self.is_transmitting = False
|
|
RNS.log(f"TX error in FreeDV interface [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
import traceback
|
|
RNS.log(traceback.format_exc(), RNS.LOG_ERROR)
|
|
|
|
def rx_loop(self):
|
|
while self.running:
|
|
try:
|
|
# get our samples for demodulation
|
|
nin = self.freedv.nin
|
|
samples = self.rx_audio_buffer.get_samples(nin)
|
|
|
|
if samples is not None:
|
|
self.rx_audio_buffer.pop(nin)
|
|
|
|
signal_level = np.sqrt(np.mean(samples.astype(float) ** 2)) / 32768.0
|
|
|
|
nbytes_out, rx_bytes, sync_state, snr_value = self.freedv.rx(samples.tobytes())
|
|
|
|
self.update_channel_state(sync_state > 0, signal_level)
|
|
|
|
if nbytes_out > 0:
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] raw RX: {nbytes_out} bytes", RNS.LOG_DEBUG)
|
|
|
|
# For FreeDV, we get the full frame including CRC
|
|
# The CRC is the last 2 bytes. but FreeDV already validates it
|
|
# If were here, the CRC was good, so we can use the payload
|
|
|
|
if len(rx_bytes) >= 2:
|
|
# remove the CRC (last 2 bytes)
|
|
payload = rx_bytes[:-2]
|
|
|
|
actual_length = len(payload)
|
|
while actual_length > 0 and payload[actual_length - 1] == 0:
|
|
actual_length -= 1
|
|
|
|
if actual_length > 0:
|
|
packet = payload[:actual_length]
|
|
self.process_incoming(bytes(packet))
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] processed {len(packet)} byte packet",
|
|
RNS.LOG_DEBUG)
|
|
elif self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] received empty/padding-only frame",
|
|
RNS.LOG_DEBUG)
|
|
else:
|
|
if self.debug:
|
|
RNS.log(f"FreeDV",
|
|
RNS.LOG_DEBUG)
|
|
|
|
else:
|
|
# no samples available, sleep a bit and update channel state
|
|
time.sleep(0.01)
|
|
self.update_channel_state(False, self.recent_signal_level * 0.95)
|
|
|
|
except Exception as e:
|
|
RNS.log(f"RX error in FreeDV interface [{self.name}]: {e}", RNS.LOG_ERROR)
|
|
|
|
def process_incoming(self, data):
|
|
self.rxb += len(data)
|
|
if self.debug:
|
|
RNS.log(f"FreeDV [{self.name}] received {len(data)} byte packet (total: {self.rxb} bytes)", RNS.LOG_DEBUG)
|
|
self.owner.inbound(data, self)
|
|
|
|
def process_outgoing(self, data):
|
|
if self.online:
|
|
max_payload = self.freedv.payload_bytes_per_modem_frame
|
|
|
|
if len(data) > max_payload:
|
|
return
|
|
|
|
try:
|
|
# Add packet to queue - it will be sent when channel is clear
|
|
self.tx_queue.put(data, timeout=1.0)
|
|
self.txb += len(data)
|
|
|
|
if self.csma_enabled and self.channel_busy and self.debug:
|
|
RNS.log(
|
|
f"FreeDV [{self.name}] queued packet #{self.tx_queue.qsize()}, will send when channel is clear",
|
|
RNS.LOG_DEBUG)
|
|
except queue.Full:
|
|
RNS.log(f"TX queue full on FreeDV interface [{self.name}]", RNS.LOG_WARNING)
|
|
|
|
def should_ingress_limit(self):
|
|
return False
|
|
|
|
def get_hash(self):
|
|
import hashlib
|
|
return hashlib.sha256(f"FreeDVInterface.{self.name}".encode()).digest()
|
|
|
|
def close(self):
|
|
self.online = False
|
|
self.running = False
|
|
|
|
# Ckear any pending transmissions
|
|
self.is_transmitting = False
|
|
|
|
if self.tx_thread:
|
|
self.tx_thread.join(timeout=2)
|
|
if self.rx_thread:
|
|
self.rx_thread.join(timeout=2)
|
|
if hasattr(self, 'monitor_thread') and self.monitor_thread:
|
|
self.monitor_thread.join(timeout=2)
|
|
|
|
if hasattr(self, 'stream') and self.stream:
|
|
try:
|
|
if self.stream.is_active():
|
|
self.stream.stop_stream()
|
|
self.stream.close()
|
|
except:
|
|
pass
|
|
|
|
if hasattr(self, 'p'):
|
|
self.p.terminate()
|
|
|
|
if hasattr(self, 'freedv'):
|
|
self.freedv.close()
|
|
|
|
if self.ptt_serial:
|
|
self.ptt_serial.close()
|
|
|
|
if self.debug:
|
|
RNS.log(f"FreeDV Interface [{self.name}] closed", RNS.LOG_DEBUG)
|
|
|
|
|
|
# Register
|
|
interface_class = FreeDVInterface
|
|
|
|
# Helper script to list audio devices and hamlib radios when run directly
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
print("FreeDV Interface for Reticulum")
|
|
print("=" * 60)
|
|
|
|
print("\nAvailable audio devices:")
|
|
print("-" * 50)
|
|
p = pyaudio.PyAudio()
|
|
for i in range(p.get_device_count()):
|
|
info = p.get_device_info_by_index(i)
|
|
print(f"Device {i}: {info['name']}")
|
|
print(f" Channels: {info['maxInputChannels']} in, {info['maxOutputChannels']} out")
|
|
print(f" Sample Rate: {info['defaultSampleRate']}")
|
|
p.terminate()
|
|
|
|
try:
|
|
result = subprocess.run(["rigctl", "--list"], capture_output=True, text=True)
|
|
except:
|
|
print("rigctl not found - install hamlib for radio control (view README for install instructions)")
|
|
|
|
print("\nConfiguration examples:")
|
|
print("-" * 50)
|
|
print("""
|
|
|
|
[[FreeDV with IC-7300]]
|
|
type = FreeDVInterface
|
|
enabled = yes
|
|
input_device = default
|
|
output_device = default
|
|
freedv_mode = datac1
|
|
ptt_type = hamlib
|
|
hamlib_model = 3073 # run rigctl -l to list devices
|
|
hamlib_device = /dev/ttyUSB0
|
|
hamlib_speed = 19200
|
|
""") |