This commit is contained in:
xssfox 2023-12-26 16:26:52 +11:00
commit bfa7f3f6d9
16 changed files with 1478 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
venv
__pycache__
*.so
*.o
*.c
.vscode
TODO
*.raw
*.s16
*.data
.DS_Store

10
README.md Normal file
View file

@ -0,0 +1,10 @@
Work in progress
Untested instructions:
1. Update `include_dirs` and `library_dirs` in `freedvtnc2/freedv_build.py` to point to codec2
2. Create venv / activate
3. cd into freedvtnc2
3. `pip install -r requirements.txt`
3. run `python freedv_build.py`
4. cd back to the main project dir
5. run `python -m freedvtnc2 --help`

BIN
c01.raw Normal file

Binary file not shown.

0
freedvtnc2/__init__.py Normal file
View file

132
freedvtnc2/__main__.py Normal file
View file

@ -0,0 +1,132 @@
from .modem import FreeDVRX, FreeDVTX, Modems, Packet
from . import audio
from .shell import FreeDVShell
import logging
import configargparse
from . import tnc
import time
from . import rigctl
import readline
import sys,struct,fcntl,termios
logging.basicConfig()
def blank_current_readline():
# Next line said to be reasonably portable for various Unixes
(rows,cols) = struct.unpack('hh', fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ,'1234'))
text_len = len(readline.get_line_buffer())+2
# ANSI escape sequences (All VT100 except ESC[0G)
sys.stdout.write('\x1b[2K') # Clear current line
sys.stdout.write('\x1b[1A\x1b[2K'*(text_len//cols)) # Move cursor up and clear line
sys.stdout.write('\x1b[0G') # Move to start of line
if __name__ == '__main__':
p = configargparse.ArgParser(default_config_files=['/etc/freedvtnc2.conf', '~/.freedvtnc2.conf'])
p.add('-c', '-config', required=False, is_config_file=True, help='config file path')
p.add('--no-cli', action='store_true', env_var="FREEDVTNC2_CLI")
p.add('--list-audio-devices', action='store_true', default=False)
p.add('--log-level', type=str, default="INFO", env_var="FREEDVTNC2_LOG_LEVEL", choices=logging._nameToLevel.keys())
p.add('--input-device', type=str, default=None, env_var="FREEDVTNC2_INPUT_DEVICE")
p.add('--output-device', type=str, default=None, env_var="FREEDVTNC2_OUTPUT_DEVICE")
p.add('--mode', type=str, choices=[x.name for x in Modems], default=Modems.DATAC1.name, help="The TX mode for the modem. The modem will receive all modes at once")
p.add('--pts', default=False, action='store_true', env_var="FREEDVTNC2_PTS", help="Disables TCP and instead creates a PTS 'fake serial' interface")
p.add('--kiss-tcp-port', default=8001, type=int, env_var="FREEDVTNC2_KISS_TCP_PORT")
p.add('--kiss-tcp-address', default="127.0.0.1", type=str, env_var="FREEDVTNC2_KISS_TCP_ADDRESS")
p.add('--rigctld-port', type=int, default=4532, env_var="FREEDVTNC2_RIGTCTLD_PORT", help="TCP port for rigctld - set to 0 to disable rigctld support")
p.add('--rigctld-host', type=str, default="localhost", env_var="FREEDVTNC2_RIGTCTLD_HOST", help="Host for rigctld")
p.add('--callsign', type=str, env_var="FREEDVTNC2_CALLSIGN", help="Currently only used for chat")
options = p.parse_args()
logger = logging.getLogger()
logger.setLevel(level=options.log_level)
logging.debug("Starting")
if options.list_audio_devices:
print(
audio.devices
)
else:
modem_tx = FreeDVTX(modem={x.name:x for x in Modems}[options.mode])
def tx(data):
logging.debug(f"Sending {str(data)}")
output_device.write(modem_tx.write(data))
def rx(data: Packet):
logging.debug(f"Received {str(data.header)} - {str(data.data)}")
if data.header == 255:
tnc_interface.tx(data.data)
elif data.header == 254: # Chat interface
call, message = data.data.split(b"\xff")
# this is all hack to make the input line when receiving a message not clobber the input
# ignoring debug messages - this is the only place where we have this issues - if we add more threaded output
# we should move this into a dedicated function
if not options.no_cli:
blank_current_readline()
print(f"<{call.decode()}> {message.decode()}")
if readline.get_line_buffer()[-1:] == "\n": # the readline buffer doesn't get cleared on libedit - I haven't tested this on gnureadline
sys.stdout.write(shell.prompt)
else:
sys.stdout.write(shell.prompt + readline.get_line_buffer())
sys.stdout.flush()
else:
print(f"\n<{call.decode()}> {message.decode()}")
if options.pts:
tnc_interface = tnc.KissInterface(tx)
else:
tnc_interface = tnc.KissTCPInterface(tx, port=options.kiss_tcp_port, address=options.kiss_tcp_address)
modem_rx = FreeDVRX(callback=rx)
input_device_name_or_id = options.input_device
output_device_name_or_id = options.output_device
try:
input_device_name_or_id = int(input_device_name_or_id)
output_device_name_or_id = int(output_device_name_or_id)
except:
pass
if options.rigctld_port != 0:
rig = rigctl.Rigctld(hostname=options.rigctld_host, port=options.rigctld_port)
ptt_trigger = rig.ptt_enable
ptt_release = rig.ptt_disable
else:
ptt_trigger = None
ptt_release = None
input_device = audio.InputDevice(modem_rx.write, modem_rx.sample_rate, name_or_id=input_device_name_or_id)
output_device = audio.OutputDevice(modem_rx.sample_rate, name_or_id=output_device_name_or_id, ptt_release=ptt_release, ptt_trigger=ptt_trigger)
try:
if not options.no_cli:
if 'libedit' in readline.__doc__: # macos hack
readline.parse_and_bind ("bind ^I rl_complete")
shell = FreeDVShell()
shell.modem_rx = modem_rx
shell.modem_tx = modem_tx
shell.input_device = input_device
shell.output_device = output_device
if options.callsign:
shell.callsign = options.callsign
shell.cmdloop()
else:
while 1:
time.sleep(0.1)
except KeyboardInterrupt:
input_device.close()
output_device.close()

250
freedvtnc2/audio.py Normal file
View file

@ -0,0 +1,250 @@
import pyaudio
from dataclasses import dataclass
from tabulate import tabulate
import logging
import audioop as pyaudioop
import time
from threading import Lock
#from pydub import pyaudioop
p = pyaudio.PyAudio()
FORMAT = pyaudio.paInt16
class AudioDevices:
"""
Gets info of all audio devices
"""
devices = []
def __init__(self):
for x in range(p.get_device_count()):
device_info = p.get_device_info_by_index(x)
self.devices.append(AudioDevice(
input_channels = device_info['maxInputChannels'],
output_channels = device_info['maxOutputChannels'],
sample_rate = int(device_info['defaultSampleRate']),
name = device_info['name'],
id = x
))
def __str__(self):
rows = [
["Id","Name","In", "Out", "SampleRate"]
]
for device in self.devices:
rows.append(
[
device.id,
device.name,
device.input_channels,
device.output_channels,
device.sample_rate
]
)
return tabulate(rows, tablefmt="plain", headers="firstrow")
@dataclass
class AudioDevice:
"""
Information about an audio device
"""
input_channels: int
output_channels: int
sample_rate: int
name: str
id: int
devices = AudioDevices()
default_input_device = devices.devices[p.get_default_input_device_info()['index']]
default_output_device = devices.devices[p.get_default_output_device_info()['index']]
class InputDevice():
"""
Handles receiving audio from an input device
Sample rate is the expected modem sample rate
"""
rate_state = None # used for sample rate conversions
def __init__(self, callback, sample_rate, name_or_id=None):
self.sample_rate = sample_rate
self.callback = callback
self.bit_depth = pyaudio.get_sample_size(FORMAT)
if name_or_id:
try:
self.device = next(
device for device in devices.devices
if device.name == name_or_id or
device.id == name_or_id
)
except StopIteration:
raise ValueError(f"Could not find audio device {name_or_id}")
else:
self.device = default_input_device
logging.debug(f"Opening {self.device.name} for input")
if self.device.input_channels > 2:
raise NotImplementedError("Inputs with greater than 2 channels not supported")
if self.device.input_channels == 2:
logging.warning("Stereo input detected - Only the left channel will be used")
if self.device.sample_rate < sample_rate:
logging.critical(f"Input audio device sample rate {self.device.sample_rate} is less than modems sample rate {sample_rate} - this will cause problems")
self.stream = p.open(format=FORMAT,
channels=self.device.input_channels,
rate=self.device.sample_rate,
output=False,
input=True,
stream_callback=self.pa_callback,
input_device_index=self.device.id,
frames_per_buffer=4096
)
def close(self):
self.stream.close()
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def pa_callback(self, in_data, frame_count, time_info, status_flag):
if self.device.input_channels == 2:
in_data = pyaudioop.tomono(
in_data,
pyaudio.get_sample_size(FORMAT),
1,
0
)
if self.device.sample_rate != self.sample_rate:
(in_data, self.rate_state) = pyaudioop.ratecv(
in_data,
pyaudio.get_sample_size(FORMAT),
1,
self.device.sample_rate,
self.sample_rate,
self.rate_state
)
self.callback(in_data)
return (None, pyaudio.paContinue)
class OutputDevice():
"""
Handles sending audio from an output device
Sample rate is the expected modem sample rate
"""
rate_state = None # used for sample rate conversions
buffer = bytearray()
output_buffer_lock = Lock()
def __init__(self, sample_rate, name_or_id=None, ptt_trigger=None, ptt_release=None):
self.sample_rate = sample_rate
self.bit_depth = pyaudio.get_sample_size(FORMAT)
if name_or_id:
try:
self.device = next(
device for device in devices.devices
if device.name == name_or_id or
device.id == name_or_id
)
except StopIteration:
raise ValueError(f"Could not find audio device {name_or_id}")
else:
self.device = default_output_device
logging.debug(f"Opening {self.device.name} for output")
if self.device.input_channels > 2:
raise NotImplementedError("Inputs with greater than 2 channels not supported")
if self.device.sample_rate < sample_rate:
logging.critical(f"Output audio device sample rate {self.device.sample_rate} is less than modems sample rate {sample_rate} - this will cause problems")
self.stream = p.open(format=FORMAT,
channels=self.device.output_channels,
rate=self.device.sample_rate,
output=True,
input=False,
output_device_index=self.device.id,
stream_callback=self.pa_callback,
frames_per_buffer=4096
)
logging.debug("started output debug")
self.ptt_trigger = ptt_trigger
self.ptt_release = ptt_release
self.ptt = False
def write(self, data: bytes):
if self.device.sample_rate != self.sample_rate:
(data, self.rate_state) = pyaudioop.ratecv(
data,
pyaudio.get_sample_size(FORMAT),
1,
self.sample_rate,
self.device.sample_rate,
self.rate_state,
)
if self.device.output_channels == 2:
data = pyaudioop.tostereo(
data,
pyaudio.get_sample_size(FORMAT),
1,
1
)
with self.output_buffer_lock:
self.buffer += data
logging.debug("wrote to output buffer")
def pa_callback(self, in_data, frame_count, time_info, status):
buffer_size = frame_count * pyaudio.get_sample_size(FORMAT) * self.device.output_channels
output = bytearray(buffer_size)
ptt = False
with self.output_buffer_lock:
chunk_size = min(len(self.buffer), buffer_size)
output[:chunk_size] = self.buffer[:chunk_size]
if self.buffer:
ptt = True
del self.buffer[:chunk_size]
if self.ptt != ptt:
if ptt and self.ptt_trigger:
logging.debug("Triggering PTT")
self.ptt_trigger()
elif ptt == False and self.ptt_release:
logging.debug("Releasing PTT")
self.ptt_release()
self.ptt = ptt
return (bytes(output), pyaudio.paContinue)
def clear(self):
with self.output_buffer_lock:
self.buffer = bytearray()
return
def close(self):
self.stream.close()

266
freedvtnc2/freedv_build.py Normal file
View file

@ -0,0 +1,266 @@
from cffi import FFI
ffibuilder = FFI()
# cdef() expects a single string declaring the C types, functions and
# globals needed to use the shared object. It must be in valid C syntax.
ffibuilder.cdef("""
typedef struct {
float real;
float imag;
} COMP;
// available speech modes
#define FREEDV_MODE_1600 0
#define FREEDV_MODE_2400A 3
#define FREEDV_MODE_2400B 4
#define FREEDV_MODE_800XA 5
#define FREEDV_MODE_700C 6
#define FREEDV_MODE_700D 7
#define FREEDV_MODE_700E 13
#define FREEDV_MODE_2020 8
#define FREEDV_MODE_2020B 16
// available data modes
#define FREEDV_MODE_FSK_LDPC 9
#define FREEDV_MODE_DATAC1 10
#define FREEDV_MODE_DATAC3 12
#define FREEDV_MODE_DATAC0 14
#define FREEDV_MODE_DATAC4 18
#define FREEDV_MODE_DATAC13 19
// Sample rates used
#define FREEDV_FS_8000 8000
#define FREEDV_FS_16000 16000
// peak (complex) sample value from Tx modulator
#define FREEDV_PEAK 16384
// Return code flags for freedv_get_rx_status() function
#define FREEDV_RX_TRIAL_SYNC 0x1 // demodulator has trial sync
#define FREEDV_RX_SYNC 0x2 // demodulator has sync
#define FREEDV_RX_BITS 0x4 // data bits have been returned
#define FREEDV_RX_BIT_ERRORS \
0x8 // FEC may not have corrected all bit errors (not all parity checks OK)
// optional operator control of OFDM modem state machine
#define FREEDV_SYNC_UNSYNC \
0 // force sync state machine to lose sync, and search for new sync
#define FREEDV_SYNC_AUTO 1 // falls out of sync automatically
#define FREEDV_SYNC_MANUAL 2 // fall out of sync only under operator control
#define FREEDV_VARICODE_MAX_BITS 12 // max bits for each ASCII character
// These macros allow us to disable unwanted modes at compile tine, for example
// to save memory on embedded systems or the remove need to link other
// libraries. By default we enable all modes. Disable during compile time e.g
// -DFREEDV_MODE_1600_EN=0 will enable all but FreeDV 1600. Or the other way
// round -DFREEDV_MODE_EN_DEFAULT=0 -DFREEDV_MODE_1600_EN=1 will enable only
// FreeDV 1600
// struct that hold state information for one freedv instance
struct freedv;
// Some modes allow extra configuration parameters
struct freedv_advanced {
int interleave_frames; // now unused but remains to prevent breaking API for
// legacy apps
// parameters for FREEDV_MODE_FSK_LDPC
int M; // 2 or 4 FSK
int Rs; // Symbol rate Hz
int Fs; // Sample rate Hz
int first_tone; // Freq of first tone Hz
int tone_spacing; // Spacing between tones Hz
char *codename; // LDPC codename, from codes listed in ldpc_codes.c
};
// Called when text message char is decoded
typedef void (*freedv_callback_rx)(void *, char);
// Called when new text message char is needed
typedef char (*freedv_callback_tx)(void *);
typedef void (*freedv_calback_error_pattern)(void *error_pattern_callback_state,
short error_pattern[],
int sz_error_pattern);
// Protocol bits are packed MSB-first
// Called when a frame containing protocol data is decoded
typedef void (*freedv_callback_protorx)(void *, char *);
// Called when a frame containing protocol data is to be sent
typedef void (*freedv_callback_prototx)(void *, char *);
// Data packet callbacks
// Called when a packet has been received
typedef void (*freedv_callback_datarx)(void *, unsigned char *packet,
size_t size);
// Called when a new packet can be send
typedef void (*freedv_callback_datatx)(void *, unsigned char *packet,
size_t *size);
/*---------------------------------------------------------------------------*\
FreeDV API functions
\*---------------------------------------------------------------------------*/
// open, close ----------------------------------------------------------------
struct freedv *freedv_open_advanced(int mode, struct freedv_advanced *adv);
struct freedv *freedv_open(int mode);
void freedv_close(struct freedv *freedv);
// Transmit -------------------------------------------------------------------
void freedv_tx(struct freedv *freedv, short mod_out[], short speech_in[]);
void freedv_comptx(struct freedv *freedv, COMP mod_out[], short speech_in[]);
void freedv_datatx(struct freedv *f, short mod_out[]);
int freedv_data_ntxframes(struct freedv *freedv);
void freedv_rawdatatx(struct freedv *f, short mod_out[],
unsigned char *packed_payload_bits);
void freedv_rawdatacomptx(struct freedv *f, COMP mod_out[],
unsigned char *packed_payload_bits);
int freedv_rawdatapreambletx(struct freedv *f, short mod_out[]);
int freedv_rawdatapreamblecomptx(struct freedv *f, COMP mod_out[]);
int freedv_rawdatapostambletx(struct freedv *f, short mod_out[]);
int freedv_rawdatapostamblecomptx(struct freedv *f, COMP mod_out[]);
// Receive -------------------------------------------------------------------
int freedv_nin(struct freedv *freedv);
int freedv_rx(struct freedv *freedv, short speech_out[], short demod_in[]);
int freedv_shortrx(struct freedv *freedv, short speech_out[], short demod_in[],
float gain);
int freedv_floatrx(struct freedv *freedv, short speech_out[], float demod_in[]);
int freedv_comprx(struct freedv *freedv, short speech_out[], COMP demod_in[]);
int freedv_rawdatarx(struct freedv *freedv, unsigned char *packed_payload_bits,
short demod_in[]);
int freedv_rawdatacomprx(struct freedv *freedv,
unsigned char *packed_payload_bits, COMP demod_in[]);
// Helper functions
// -------------------------------------------------------------------
int freedv_codec_frames_from_rawdata(struct freedv *freedv,
unsigned char *codec_frames,
unsigned char *rawdata);
int freedv_rawdata_from_codec_frames(struct freedv *freedv,
unsigned char *rawdata,
unsigned char *codec_frames);
unsigned short freedv_gen_crc16(unsigned char *bytes, int nbytes);
void freedv_pack(unsigned char *bytes, unsigned char *bits, int nbits);
void freedv_unpack(unsigned char *bits, unsigned char *bytes, int nbits);
unsigned short freedv_crc16_unpacked(unsigned char *bits, int nbits);
int freedv_check_crc16_unpacked(unsigned char *unpacked_bits, int nbits);
// Set parameters ------------------------------------------------------------
void freedv_set_callback_txt(struct freedv *freedv, freedv_callback_rx rx,
freedv_callback_tx tx, void *callback_state);
void freedv_set_callback_protocol(struct freedv *freedv,
freedv_callback_protorx rx,
freedv_callback_prototx tx,
void *callback_state);
void freedv_set_callback_data(struct freedv *freedv,
freedv_callback_datarx datarx,
freedv_callback_datatx datatx,
void *callback_state);
void freedv_set_test_frames(struct freedv *freedv, int test_frames);
void freedv_set_test_frames_diversity(struct freedv *freedv,
int test_frames_diversity);
void freedv_set_squelch_en(struct freedv *freedv, bool squelch_en);
void freedv_set_snr_squelch_thresh(struct freedv *freedv,
float snr_squelch_thresh);
void freedv_set_clip(struct freedv *freedv, bool val);
void freedv_set_total_bit_errors(struct freedv *freedv, int val);
void freedv_set_total_bits(struct freedv *freedv, int val);
void freedv_set_total_bit_errors_coded(struct freedv *freedv, int val);
void freedv_set_total_bits_coded(struct freedv *freedv, int val);
void freedv_set_total_packets(struct freedv *freedv, int val);
void freedv_set_total_packet_errors(struct freedv *freedv, int val);
void freedv_set_callback_error_pattern(struct freedv *freedv,
freedv_calback_error_pattern cb,
void *state);
void freedv_set_varicode_code_num(struct freedv *freedv, int val);
void freedv_set_data_header(struct freedv *freedv, unsigned char *header);
void freedv_set_carrier_ampl(struct freedv *freedv, int c, float ampl);
void freedv_set_sync(struct freedv *freedv, int sync_cmd);
void freedv_set_verbose(struct freedv *freedv, int verbosity);
void freedv_set_tx_bpf(struct freedv *freedv, int val);
void freedv_set_tx_amp(struct freedv *freedv, float amp);
void freedv_set_ext_vco(struct freedv *f, int val);
void freedv_set_phase_est_bandwidth_mode(struct freedv *f, int val);
void freedv_set_eq(struct freedv *f, bool val);
void freedv_set_frames_per_burst(struct freedv *f, int framesperburst);
void freedv_passthrough_gain(struct freedv *f, float g);
int freedv_set_tuning_range(struct freedv *freedv, float val_fmin,
float val_fmax);
// Get parameters
// -------------------------------------------------------------------------
struct MODEM_STATS;
int freedv_get_version(void);
char *freedv_get_hash(void);
int freedv_get_mode(struct freedv *freedv);
void freedv_get_modem_stats(struct freedv *freedv, int *sync, float *snr_est);
void freedv_get_modem_extended_stats(struct freedv *freedv,
struct MODEM_STATS *stats);
int freedv_get_test_frames(struct freedv *freedv);
int freedv_get_speech_sample_rate(struct freedv *freedv);
int freedv_get_n_speech_samples(struct freedv *freedv);
int freedv_get_n_max_speech_samples(struct freedv *freedv);
int freedv_get_modem_sample_rate(struct freedv *freedv);
int freedv_get_modem_symbol_rate(struct freedv *freedv);
int freedv_get_n_max_modem_samples(struct freedv *freedv);
int freedv_get_n_nom_modem_samples(struct freedv *freedv);
int freedv_get_n_tx_modem_samples(struct freedv *freedv);
int freedv_get_n_tx_preamble_modem_samples(struct freedv *freedv);
int freedv_get_n_tx_postamble_modem_samples(struct freedv *freedv);
// bit error rate stats
int freedv_get_total_bits(struct freedv *freedv);
int freedv_get_total_bit_errors(struct freedv *freedv);
int freedv_get_total_bits_coded(struct freedv *freedv);
int freedv_get_total_bit_errors_coded(struct freedv *freedv);
int freedv_get_total_packets(struct freedv *freedv);
int freedv_get_total_packet_errors(struct freedv *freedv);
int freedv_get_rx_status(struct freedv *freedv);
void freedv_get_fsk_S_and_N(struct freedv *freedv, float *S, float *N);
int freedv_get_sync(struct freedv *freedv);
int freedv_get_sync_interleaver(struct freedv *freedv);
// access to speech codec states
struct FSK *freedv_get_fsk(struct freedv *f);
struct CODEC2 *freedv_get_codec2(struct freedv *freedv);
int freedv_get_bits_per_codec_frame(struct freedv *freedv);
int freedv_get_bits_per_modem_frame(struct freedv *freedv);
int freedv_get_sz_error_pattern(struct freedv *freedv);
int freedv_get_protocol_bits(struct freedv *freedv);
""")
# set_source() gives the name of the python extension module to
# produce, and some C source code as a string. This C code needs
# to make the declarated functions, types and globals available,
# so it is often just the "#include".
ffibuilder.set_source("_freedv_cffi",
"""
#include "freedv_api.h" // the C header of the library
""",
libraries=['codec2'],
include_dirs = [ "/Users/mwheeler/src/codec2/src/"],
library_dirs = ["/Users/mwheeler/src/codec2/build_linux/src/"]
) # library name, for the linker
if __name__ == "__main__":
ffibuilder.compile(verbose=True)

270
freedvtnc2/modem.py Normal file
View file

@ -0,0 +1,270 @@
from ._freedv_cffi import ffi, lib
from typing import Callable
from dataclasses import dataclass
from enum import Enum
import logging
class Modems(Enum):
"""
Supported modems and friendly names for them
"""
DATAC1 = lib.FREEDV_MODE_DATAC1
DATAC3 = lib.FREEDV_MODE_DATAC3
DATAC4 = lib.FREEDV_MODE_DATAC4
@dataclass
class FreeDVFrame:
"""
Receive data from the FreeDV modem with meta data
"""
data: bytes
sync: int
snr: float
modem: Modems
class Modem():
def __init__(self, modem: Modems, callback: Callable[[FreeDVFrame],None]|None=None):
self.modem = lib.freedv_open(modem.value)
self.modem_name = modem.name
self.buffer = bytearray()
self.callback = callback
lib.freedv_set_frames_per_burst(self.modem, 1)
@property
def nin(self) -> int:
"""
Number of bytes that the modem is expecting to process (freedv api is number of shorts - we use bytes to make things easier)
"""
return lib.freedv_nin(self.modem) * ffi.sizeof("short")
@property
def bytes_per_frame(self) -> int:
"""
Max number of bytes returned for each frame of audio sent. Used to build buffers.
"""
return lib.freedv_get_bits_per_modem_frame(self.modem)//8
@property
def snr(self) -> float:
"""
Receivers SNR reported by the modem
"""
sync = ffi.new("int *")
snr = ffi.new("float *")
lib.freedv_get_modem_stats(self.modem,sync,snr)
return snr[0]
@property
def sync(self) -> float:
"""
Modems sync status.
"""
sync = ffi.new("int *")
snr = ffi.new("float *")
lib.freedv_get_modem_stats(self.modem,sync,snr)
return sync[0]
@property
def sample_rate(self) -> int:
"""
Sample rate the modem is running at
"""
return lib.freedv_get_modem_sample_rate(self.modem)
def write(self, data: bytes) -> None:
"""
Feed in audio bytes.
"""
# add data to our internal buffer
self.buffer += data
# if we have enough data run the demodulator
while (nin := self.nin) <= len(self.buffer):
# setup the memory location where audio samples will be read from
to_modem = ffi.from_buffer("short[]", self.buffer[:nin] )
# remove the loaded samples from the buffer
del self.buffer[:nin]
# setup a location to put the results
from_modem = ffi.new("unsigned char packed_payload_bits[]", bytes(self.bytes_per_frame))
# run the demodulator
bytes_returned = lib.freedv_rawdatarx(self.modem,from_modem,to_modem)
# check if we get returned bytes
if bytes_returned and self.callback:
# if we do, create a freedvframe object and return the data
self.callback( # we should change this to do depacketization
FreeDVFrame(
data = bytes(from_modem)[:bytes_returned-2], # Remove the CRC
sync = self.sync,
snr = self.snr,
modem = self.modem_name
)
)
def crc(self, data: bytes) -> bytes:
data_in = ffi.from_buffer(f"unsigned char[{self.bytes_per_frame - 2}]", data)
return lib.freedv_gen_crc16(data_in, self.bytes_per_frame - 2).to_bytes(2, byteorder="big")
def modulate(self, data: bytes, header_byte=b'\xff') -> bytes:
"""
Modulates bytes into audio samples (also bytes)
"""
"""
Our packet format is. Packet must be less than 32768
## TODO build tests for packets exactly the max length
Short packet
0xff [2 byte short for length of data in bytes] [data]
Long packet
[sequence number 0-127] [data]
"""
# Convert to byte array as it will be easier to slice
data = bytearray(data)
chunks = []
pop_packet_length = self.bytes_per_frame - 2 - 3 # first iteration we use 3 bytes for the header
while data:
chunks.append(data[:pop_packet_length])
del data[:pop_packet_length]
pop_packet_length = self.bytes_per_frame - 2 - 1 # next iterations only use 1 byte for sequence
frames = []
# first frame includes header
frame=bytearray(self.bytes_per_frame)
# header
frame[0:3] = header_byte + sum([len(x) for x in chunks]).to_bytes(2)
# data
frame[3:3+len(chunks[0])] = chunks[0]
# crc
frame[-2:] = self.crc(bytes(frame)[:-2])
frames.append(frame)
for seq, next_chunk in enumerate(chunks[1:]):
frame=bytearray(self.bytes_per_frame)
# header
frame[0] = seq
frame[1:1+len(next_chunk)] = next_chunk
# crc
frame[-2:] = self.crc(bytes(frame)[:-2])
frames.append(frame)
output = bytes()
for frame in frames:
from_modem = ffi.new(f"short mod_out[{lib.freedv_get_n_tx_modem_samples(self.modem)}]")
# preamble
samples = lib.freedv_rawdatapreambletx(self.modem, from_modem)
output += ffi.buffer(from_modem)[:(samples*ffi.sizeof("short"))]
to_modem = ffi.from_buffer("unsigned char *", frame)
# setup a location to put the results
lib.freedv_rawdatatx(self.modem, from_modem, to_modem)
output += ffi.buffer(from_modem)[:]
#postamble
samples=lib.freedv_rawdatapostambletx(self.modem, from_modem)
output += ffi.buffer(from_modem)[:(samples*ffi.sizeof("short"))]
# add an extra bit of silence to clear out buffers
output += bytes(lib.freedv_get_n_nom_modem_samples(self.modem)*ffi.sizeof("short")*2)
return output
@dataclass
class Packet():
data: bytes
header: int
class FreeDVRX():
def __init__(self, callback: Callable[[bytes],None]):
self.callback = callback
# we RX all the modems at once
self.modems = [Modem(x, callback=self.rx) for x in Modems]
# set sample rate so that the audio processor can perform the required sampling conversion
if len(set([x.sample_rate for x in self.modems])) != 1:
raise NotImplemented("Not all modems are running the same sample rate - We can't handle this right now")
else:
self.sample_rate = self.modems[0].sample_rate
# data for packet rx
self.remaining_bytes = None
self.next_seq_number = None
self.partial_data = None
def write(self, data: bytes) -> None:
"""
Accepts bytes of data that will be read by tge modem and demodulated
"""
for modem in self.modems:
modem.write(data)
def rx(self, data_frame: FreeDVFrame):
logging.debug(f"Received data. snr:{data_frame.snr}")
data = bytearray(data_frame.data)
header = data.pop(0)
if header > 200: # start of packet
self.remaining_bytes = int.from_bytes(data[0:2])
del data[0:2]
logging.debug(f"Found packet start - Expecting {self.remaining_bytes} bytes")
self.next_seq_number = 0
self.partial_data=b''
self.header = header
elif self.next_seq_number != None: # should be a seq number
if self.next_seq_number != header:
logging.debug(f"Missing data - header seq expected {self.next_seq_number}, got {header}")
self.next_seq_number = None
self.remaining_bytes = None
return
else:
logging.debug(f"Received frame {header}")
self.next_seq_number += 1
else:
logging.debug(f"Not expecting data - got {header}")
self.next_seq_number = None
self.remaining_bytes = None
return
self.partial_data += data[:self.remaining_bytes]
self.remaining_bytes -= len(data[:self.remaining_bytes])
logging.debug(f"Seq: {header} Remaining data: {self.remaining_bytes}")
if self.remaining_bytes == 0:
self.next_seq_number = None
self.remaining_bytes = None
self.callback(Packet(header=self.header, data=self.partial_data))
def set_mode(self, modem: Modem):
self.modem = Modem(modem=modem)
class FreeDVTX():
def __init__(self, modem: Modem = Modems.DATAC1):
self.modem = Modem(modem=modem)
def set_mode(self, modem: Modem):
self.modem = Modem(modem=modem)
def write(self, data: bytes, header_byte=b'\xff'):
return self.modem.modulate(data, header_byte)

172
freedvtnc2/poetry.lock generated Normal file
View file

@ -0,0 +1,172 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]]
name = "cffi"
version = "1.16.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
{file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
{file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
{file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
{file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
{file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
{file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
{file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
{file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
{file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
{file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
{file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
{file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
{file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
{file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
{file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
{file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
{file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
{file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
{file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
{file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
{file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
{file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
{file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
{file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
{file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
{file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
]
[package.dependencies]
pycparser = "*"
[[package]]
name = "configargparse"
version = "1.7"
description = "A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables."
optional = false
python-versions = ">=3.5"
files = [
{file = "ConfigArgParse-1.7-py3-none-any.whl", hash = "sha256:d249da6591465c6c26df64a9f73d2536e743be2f244eb3ebe61114af2f94f86b"},
{file = "ConfigArgParse-1.7.tar.gz", hash = "sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1"},
]
[package.extras]
test = ["PyYAML", "mock", "pytest"]
yaml = ["PyYAML"]
[[package]]
name = "kissfix"
version = "7.0.11"
description = "Python KISS Module."
optional = false
python-versions = "*"
files = [
{file = "kissfix-7.0.11-py3-none-any.whl", hash = "sha256:aa2a90d19549eba1ad08adb83cca7b9d147ec805918c208cfbb85dc834e2eeb8"},
{file = "kissfix-7.0.11.tar.gz", hash = "sha256:6d17a27c152a06be0329c701c2f32e1f5bdbc2c1a8361ae1af4c92e4d406baa8"},
]
[package.dependencies]
pyserial = ">=3.4"
[[package]]
name = "pyaudio"
version = "0.2.14"
description = "Cross-platform audio I/O with PortAudio"
optional = false
python-versions = "*"
files = [
{file = "PyAudio-0.2.14-cp310-cp310-win32.whl", hash = "sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61"},
{file = "PyAudio-0.2.14-cp310-cp310-win_amd64.whl", hash = "sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83"},
{file = "PyAudio-0.2.14-cp311-cp311-win32.whl", hash = "sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289"},
{file = "PyAudio-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903"},
{file = "PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b"},
{file = "PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3"},
{file = "PyAudio-0.2.14-cp38-cp38-win32.whl", hash = "sha256:858caf35b05c26d8fc62f1efa2e8f53d5fa1a01164842bd622f70ddc41f55000"},
{file = "PyAudio-0.2.14-cp38-cp38-win_amd64.whl", hash = "sha256:2dac0d6d675fe7e181ba88f2de88d321059b69abd52e3f4934a8878e03a7a074"},
{file = "PyAudio-0.2.14-cp39-cp39-win32.whl", hash = "sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9"},
{file = "PyAudio-0.2.14-cp39-cp39-win_amd64.whl", hash = "sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57"},
{file = "PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87"},
]
[package.extras]
test = ["numpy"]
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
[[package]]
name = "pydub"
version = "0.25.1"
description = "Manipulate audio with an simple and easy high level interface"
optional = false
python-versions = "*"
files = [
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
]
[[package]]
name = "pyserial"
version = "3.5"
description = "Python Serial Port Extension"
optional = false
python-versions = "*"
files = [
{file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"},
{file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"},
]
[package.extras]
cp2110 = ["hidapi"]
[[package]]
name = "tabulate"
version = "0.9.0"
description = "Pretty-print tabular data"
optional = false
python-versions = ">=3.7"
files = [
{file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"},
{file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"},
]
[package.extras]
widechars = ["wcwidth"]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "0c76f5a37399d9d07cc95a4fee91abffff6ea8efbd181335dc1e685678534acb"

20
freedvtnc2/pyproject.toml Normal file
View file

@ -0,0 +1,20 @@
[tool.poetry]
name = "freedvtnc2"
version = "0.1.0"
description = ""
authors = ["xssfox <xss@sprocketfox.io>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
cffi = "^1.16.0"
configargparse = "^1.7"
pyaudio = "^0.2.14"
tabulate = "^0.9.0"
pydub = "^0.25.1"
kissfix = "^7.0.11"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,83 @@
cffi==1.16.0 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc \
--hash=sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a \
--hash=sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417 \
--hash=sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab \
--hash=sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520 \
--hash=sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36 \
--hash=sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743 \
--hash=sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8 \
--hash=sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed \
--hash=sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684 \
--hash=sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56 \
--hash=sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324 \
--hash=sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d \
--hash=sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235 \
--hash=sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e \
--hash=sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088 \
--hash=sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000 \
--hash=sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7 \
--hash=sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e \
--hash=sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673 \
--hash=sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c \
--hash=sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe \
--hash=sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2 \
--hash=sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098 \
--hash=sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8 \
--hash=sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a \
--hash=sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0 \
--hash=sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b \
--hash=sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896 \
--hash=sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e \
--hash=sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9 \
--hash=sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2 \
--hash=sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b \
--hash=sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6 \
--hash=sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404 \
--hash=sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f \
--hash=sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0 \
--hash=sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4 \
--hash=sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc \
--hash=sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936 \
--hash=sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba \
--hash=sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872 \
--hash=sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb \
--hash=sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614 \
--hash=sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1 \
--hash=sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d \
--hash=sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969 \
--hash=sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b \
--hash=sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4 \
--hash=sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627 \
--hash=sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956 \
--hash=sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357
configargparse==1.7 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:d249da6591465c6c26df64a9f73d2536e743be2f244eb3ebe61114af2f94f86b \
--hash=sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1
kissfix==7.0.11 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:6d17a27c152a06be0329c701c2f32e1f5bdbc2c1a8361ae1af4c92e4d406baa8 \
--hash=sha256:aa2a90d19549eba1ad08adb83cca7b9d147ec805918c208cfbb85dc834e2eeb8
pyaudio==0.2.14 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57 \
--hash=sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61 \
--hash=sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3 \
--hash=sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83 \
--hash=sha256:2dac0d6d675fe7e181ba88f2de88d321059b69abd52e3f4934a8878e03a7a074 \
--hash=sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289 \
--hash=sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b \
--hash=sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87 \
--hash=sha256:858caf35b05c26d8fc62f1efa2e8f53d5fa1a01164842bd622f70ddc41f55000 \
--hash=sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903 \
--hash=sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9
pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \
--hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206
pydub==0.25.1 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6 \
--hash=sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f
pyserial==3.5 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
--hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
tabulate==0.9.0 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \
--hash=sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f

44
freedvtnc2/rigctl.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
import socket
import logging
# rigctl - https://github.com/darksidelemm/rotctld-web-gui/blob/master/rotatorgui.py#L35
class Rigctld():
""" rigctld (hamlib) communication class """
# Note: This is a massive hack.
def __init__(self, hostname="localhost", port=4532, poll_rate=5, timeout=5):
""" Open a connection to rigctld, and test it for validity """
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(timeout)
self.hostname = hostname
self.port = port
self.connect()
logging.debug(f"Rigctl intialized")
def connect(self):
""" Connect to rigctld instance """
self.sock.connect((self.hostname,self.port))
def close(self):
self.sock.close()
def send_command(self, command):
""" Send a command to the connected rigctld instance,
and return the return value.
"""
self.sock.sendall(command+b'\n')
try:
return self.sock.recv(1024)
except:
return None
def ptt_enable(self):
logging.debug(f"PTT enabled")
self.send_command(b"T 1")
def ptt_disable(self):
logging.debug(f"PTT disabled")
self.send_command(b"T 0")

92
freedvtnc2/shell.py Normal file
View file

@ -0,0 +1,92 @@
import logging
import cmd
from .modem import Modems, lib, ffi
import readline
import rlcompleter
import code
import sys
import time
from . import audio
import pydub.generators
class FreeDVShell(cmd.Cmd):
intro = "FreeDVTNC2 Shell - type help or ? to list commands\n"
prompt = "(freedvtnc2) "
callsign = None
def do_log_level(self, arg):
"Set the log level"
logger = logging.getLogger()
logger.setLevel(level=arg.upper())
def do_test_ptt(self, arg):
"Turns on PTT for 2 seconds"
print("Starting TX")
sin_wave = pydub.generators.Sine(
440,
sample_rate=self.modem_tx.modem.sample_rate,
bit_depth=16,
).to_audio_segment(2000)
sin_wave.set_channels(1)
self.output_device.write(sin_wave.raw_data)
print("Stopping TX")
def do_mode(self, arg):
arg = arg.upper()
if arg not in [x.name for x in Modems]:
print(f"Mode must be {', '.join([x.name for x in Modems])}")
else:
modem = {x.name:x for x in Modems}[arg]
self.modem_rx.set_mode(modem)
self.modem_tx.set_mode(modem)
print(f"Set mode {arg}")
def help_mode(self):
print(f"Change TX Mode: mode [{', '.join([x.name for x in Modems])}]")
def do_clear(self, arg):
"Clears TX queues"
self.output_device.clear()
print("TX buffer cleared")
def do_list_audio_devices(self, arg):
"Lists audio device parameters"
print(audio.devices)
def do_send_string(self, arg):
"Sends string over the modem"
self.output_device.write(self.modem_tx.write(arg.encode()))
def do_msg(self, arg):
"Send a message"
if not self.callsign:
self.callsign = input("Your callsign:")
data = self.callsign.encode() + b"\xff" + arg.encode()
self.output_device.write(self.modem_tx.write(data, header_byte=b"\xfe"))
def do_debug(self, arg):
"Open the debug shell"
def console_exit():
raise SystemExit
variables = globals().copy()
variables.update(locals())
variables['exit'] = console_exit
sys.ps1 = "(freedvtnc2)>>> "
sys.ps2 = "(freedvtnc2)... "
readline.set_completer(rlcompleter.Completer(variables).complete)
shell = code.InteractiveConsole(variables)
try:
shell.interact(banner="freedvtnc2 debug console")
except SystemExit:
pass
def emptyline(self):
pass

15
freedvtnc2/test_audio.py Normal file
View file

@ -0,0 +1,15 @@
import logging
logging.basicConfig(level=logging.DEBUG)
import unittest
from unittest.mock import Mock, call
from . import audio
import time
class TestAudio(unittest.TestCase):
def test_audio_list_devices(self):
audio.devices # just test that this function doesn't error - we can probably mock out pyaudio for proper tests
callback = Mock()
with audio.InputDevice(callback, 8000):
time.sleep(0.1) # enough time to give an audio sample
callback.assert_called()

36
freedvtnc2/test_modem.py Normal file
View file

@ -0,0 +1,36 @@
import logging
logging.basicConfig(level=logging.DEBUG)
import unittest
from unittest.mock import Mock, call
from . import modem
class TestModem(unittest.TestCase):
def testMultiRX(self):
callback = Mock()
rx = modem.FreeDVRX(callback)
for freedv_modem in rx.modems:
freedv_modem.callback = callback
with open("c01.raw","rb") as f:
while chunk := f.read(100):
rx.write(chunk) # spoon feed in data to make sure buffering is working
self.assertEqual(callback.call_count, 10)
for mocked_call in callback.call_args_list:
self.assertEqual(len(mocked_call[0]),1)
self.assertIsInstance(mocked_call[0][0], modem.FreeDVFrame)
self.assertIsInstance(mocked_call[0][0].snr, float)
self.assertIsInstance(mocked_call[0][0].sync, int)
self.assertEqual(mocked_call[0][0].modem, 'DATAC1')
def testTX(self):
tx = modem.FreeDVTX()
callback = Mock()
rx = modem.FreeDVRX(callback)
tx_output = tx.write(b'test')
tx_output += tx.write(b'test'*200)
rx.write(tx_output)
self.assertEqual(callback.call_args_list[0][0][0],modem.Packet(data=b'test',header=255))
self.assertEqual(callback.call_args_list[1][0][0],modem.Packet(data=b'test'*200,header=255))
if __name__ == '__main__':
unittest.main()

77
freedvtnc2/tnc.py Normal file
View file

@ -0,0 +1,77 @@
import kissfix
import os, pty, tty, termios
import threading
import logging
import sys, traceback
import fcntl
# This deals with encoding and decoding KISS frames
class KissInterface():
def __init__(self, callback):
self.k = kissfix.SerialKISS('/dev/ptmx', 9600)
self.k.start()
# Override the serial interface with our own PTY file descriptor
self.control, self.user_port = pty.openpty()
self.ttyname = os.ttyname(self.user_port)
self.k.interface.fd = self.control # we need to override the the serial port with the fd from pty
tty.setraw(self.control, termios.TCSANOW) # this makes the tty act more like a serial port
# change flags to be non blocking so that buffer full doesn't cause issues
flags = fcntl.fcntl(self.control, fcntl.F_GETFL)
flags |= os.O_NONBLOCK
fcntl.fcntl(self.control, fcntl.F_SETFL, flags)
self.rx_thread = KissThread(callback, self.k)
self.rx_thread.setDaemon(True)
self.rx_thread.start()
def tx(self, bytes_in: bytes):
frame = kissfix.FEND + b'\00' + kissfix.escape_special_codes(bytes_in) + kissfix.FEND
try:
os.write(self.control, frame)
except BlockingIOError:
logging.error("PTY interface buffer is full. The connected application may have crashed or isn't reading fast enough. Data loss is likely. Alternatively you aren't using the PTY interface and should have used --no-pty. Clearing the buffer now so we can keep going")
blocking = os.get_blocking(self.user_port) # remember what the state was before
os.set_blocking(self.user_port, False)
try:
while 1:
os.read(self.user_port,32) # read off the buffer until we've cleared it
except BlockingIOError:
pass
os.set_blocking(self.user_port, blocking) # restore the state after
class KissTCPInterface():
def __init__(self, callback, port=8001, address="127.0.0.1"):
self.k = kissfix.TCPServerKISS(address, port)
self.k.start()
self.rx_thread = KissThread(callback, self.k)
self.rx_thread.setDaemon(True)
self.rx_thread.start()
def tx(self, bytes_in: bytes):
try:
frame = kissfix.FEND + b'\00' + kissfix.escape_special_codes(bytes_in) + kissfix.FEND
self.k._write_handler(frame)
except:
traceback.print_exc(file=sys.stderr)
logging.info("Issue send frame to TCP TNC - Client not connected?")
pass # so many things can go wrong here
class KissThread(threading.Thread):
def __init__(self,callback, interface):
threading.Thread.__init__(self)
self.callback = callback
self._running = True
self.interface = interface
def run(self):
while self._running == True:
# check TNC port
for frame in self.interface.read(readmode=False):
self.callback(bytes(frame[1:])) #we strip the first two byte which is TNC port number.
def terminate(self):
self._running = False