Sync upstream

This commit is contained in:
Mark Qvist
2025-11-24 15:28:32 +01:00
parent 61a452c6df
commit 514b5e7ae6
15 changed files with 634 additions and 61 deletions

View File

@@ -57,7 +57,6 @@ class Codec2(Codec):
elif frame.shape[1] > self.channels:
frame = frame[:, 1]
input_samples = frame*self.TYPE_MAP_FACTOR
input_samples = input_samples.astype(np.int16)
@@ -88,7 +87,9 @@ class Codec2(Codec):
def decode(self, frame_bytes):
frame_header = frame_bytes[0]
frame_bytes = frame_bytes[1:]
frame_mode = self.HEADER_MODES[frame_header]
if frame_header in self.HEADER_MODES: frame_mode = self.HEADER_MODES[frame_header]
else: frame_mode = self.mode
if self.mode != frame_mode:
self.set_mode(frame_mode)

69
LXST/Filters.c Normal file
View File

@@ -0,0 +1,69 @@
#include <math.h>
void highpass_filter(float* input, float* output, int samples, int channels, float alpha, float* filter_states, float* last_inputs) {
int i, ch;
for (ch = 0; ch < channels; ch++) { float input_diff = input[ch] - last_inputs[ch]; output[ch] = alpha * (filter_states[ch] + input_diff); }
for (i = 1; i < samples; i++) { for (ch = 0; ch < channels; ch++) { int idx = i * channels + ch; float input_diff = input[idx] - input[idx - channels]; output[idx] = alpha * (output[idx - channels] + input_diff); } }
for (ch = 0; ch < channels; ch++) { int last_idx = (samples - 1) * channels + ch; filter_states[ch] = output[last_idx]; last_inputs[ch] = input[last_idx]; }
}
void lowpass_filter(float* input, float* output, int samples, int channels, float alpha, float* filter_states) {
int i, ch;
float one_minus_alpha = 1.0f - alpha;
for (ch = 0; ch < channels; ch++) { output[ch] = alpha * input[ch] + one_minus_alpha * filter_states[ch]; }
for (i = 1; i < samples; i++) {
for (ch = 0; ch < channels; ch++) { int idx = i * channels + ch; output[idx] = alpha * input[idx] + one_minus_alpha * output[idx - channels]; }
}
for (ch = 0; ch < channels; ch++) { int last_idx = (samples - 1) * channels + ch; filter_states[ch] = output[last_idx]; }
}
void agc_process(float* input, float* output, int samples, int channels, float target_linear, float max_gain_linear, float trigger_level,
float attack_coeff, float release_coeff, float hold_samples, float* current_gain_lin, int* hold_counter, int block_target) {
for (int i = 0; i < samples * channels; i++) { output[i] = input[i]; }
int num_blocks = block_target;
int block_size = samples / num_blocks;
if (block_size < 1) block_size = 1;
for (int block = 0; block < num_blocks; block++) {
int block_start = block*block_size;
int block_end = (block + 1)*block_size;
if (block == num_blocks - 1) { block_end = samples; }
if (block_end > samples) { block_end = samples; }
int block_samples = block_end - block_start;
if (block_samples <= 0) continue;
for (int ch = 0; ch < channels; ch++) {
float sum_squares = 0.0f;
for (int i = block_start; i < block_end; i++) { int idx = i * channels + ch; sum_squares += output[idx] * output[idx]; }
float rms = sqrtf(sum_squares / block_samples);
float target_gain;
if (rms > 1e-9f && rms > trigger_level) {
target_gain = target_linear / rms;
if (target_gain > max_gain_linear) { target_gain = max_gain_linear; }
} else { target_gain = current_gain_lin[ch]; }
if (target_gain < current_gain_lin[ch]) {
current_gain_lin[ch] = attack_coeff * target_gain + (1.0f - attack_coeff) * current_gain_lin[ch];
*hold_counter = (int)hold_samples;
} else {
if (*hold_counter > 0) { *hold_counter -= block_samples; }
else { current_gain_lin[ch] = release_coeff * target_gain + (1.0f - release_coeff) * current_gain_lin[ch]; }
}
for (int i = block_start; i < block_end; i++) { int idx = i * channels + ch; output[idx] *= current_gain_lin[ch]; }
}
}
float peak_limit = 0.75f;
for (int ch = 0; ch < channels; ch++) {
float peak = 0.0f;
for (int i = 0; i < samples; i++) { int idx = i * channels + ch; float abs_val = fabsf(output[idx]);
if (abs_val > peak) { peak = abs_val; } }
if (peak > peak_limit) { float scale = peak_limit / peak;
for (int i = 0; i < samples; i++) { int idx = i * channels + ch; output[idx] *= scale; } }
}
}

3
LXST/Filters.h Normal file
View File

@@ -0,0 +1,3 @@
void highpass_filter(float* input, float* output, int samples, int channels, float alpha, float* filter_states, float* last_inputs);
void lowpass_filter(float* input, float* output, int samples, int channels, float alpha, float* filter_states);
void agc_process(float* input, float* output, int samples, int channels, float target_linear, float max_gain_linear, float trigger_level, float attack_coeff, float release_coeff, float hold_samples, float* current_gain_lin, int* hold_counter, int block_target);

276
LXST/Filters.py Normal file
View File

@@ -0,0 +1,276 @@
from importlib.util import find_spec
import numpy as np
import time
import RNS
import os
USE_NATIVE_FILTERS = False
if not find_spec("cffi"):
RNS.log(f"Could not load CFFI module for filter acceleration, falling back to Python filters. This will be slow.", RNS.LOG_WARNING)
RNS.log(f"Make sure that the CFFI module is installed and available.", RNS.LOG_WARNING)
else:
try:
from cffi import FFI
import pathlib
c_src_path = pathlib.Path(__file__).parent.resolve()
ffi = FFI()
try:
filterlib_spec = find_spec("LXST.filterlib")
if not filterlib_spec or filterlib_spec.origin == None: raise ImportError("Could not locate pre-compiled LXST.filterlib module")
with open(os.path.join(c_src_path, "Filters.h"), "r") as f: ffi.cdef(f.read())
native_functions = ffi.dlopen(filterlib_spec.origin)
USE_NATIVE_FILTERS = True
except Exception as e:
RNS.log(f"Could not load pre-compiled LXST filters library. The contained exception was: {e}", RNS.LOG_WARNING)
RNS.log(f"Attempting to compile library from source...", RNS.LOG_WARNING)
if USE_NATIVE_FILTERS == False:
with open(os.path.join(c_src_path, "Filters.h"), "r") as f: ffi.cdef(f.read())
with open(os.path.join(c_src_path, "Filters.c"), "r") as f: c_src = f.read()
native_functions = ffi.verify(c_src)
USE_NATIVE_FILTERS = True
RNS.log(f"Successfully compiled and loaded filters library", RNS.LOG_WARNING)
except Exception as e:
RNS.log(f"Could not compile modules for filter acceleration, falling back to Python filters. This will be slow.", RNS.LOG_WARNING)
RNS.log(f"The contained exception was: {e}", RNS.LOG_WARNING)
USE_NATIVE_FILTERS = False
class Filter():
def handle_frame(self, frame):
raise NotImplementedError(f"The handle_frame method was not implemented on {self}")
class HighPass(Filter):
def __init__(self, cut):
super().__init__()
self.cut = cut
self._samplerate = None
self._channels = None
self._filter_states = None
self._last_inputs = None
self._alpha = None
def handle_frame(self, frame, samplerate):
if len(frame) == 0: return frame
if samplerate != self._samplerate:
self._samplerate = samplerate
dt = 1.0 / self._samplerate
rc = 1.0 / (2 * np.pi * self.cut)
self._alpha = rc / (rc + dt)
if len(frame.shape) == 1: frame_2d = frame.reshape(-1, 1)
else: frame_2d = frame
samples, channels = frame_2d.shape
if self._filter_states is None or self._channels != channels:
self._channels = channels
self._filter_states = np.zeros(self._channels, dtype=np.float32)
self._last_inputs = np.zeros(self._channels, dtype=np.float32)
if USE_NATIVE_FILTERS:
frame_2d = np.ascontiguousarray(frame_2d, dtype=np.float32)
output = np.empty_like(frame_2d, dtype=np.float32)
input_ptr = ffi.cast("float *", frame_2d.ctypes.data)
output_ptr = ffi.cast("float *", output.ctypes.data)
states_ptr = ffi.cast("float *", self._filter_states.ctypes.data)
last_inputs_ptr = ffi.cast("float *", self._last_inputs.ctypes.data)
native_functions.highpass_filter(input_ptr, output_ptr, samples, channels, float(self._alpha), states_ptr, last_inputs_ptr)
result = output.reshape(frame.shape)
return result
else:
output = np.empty_like(frame_2d)
input_diff_first = frame_2d[0] - self._last_inputs
output[0] = self._alpha * (self._filter_states + input_diff_first)
input_diff = np.empty_like(frame_2d)
input_diff[0] = input_diff_first
input_diff[1:] = frame_2d[1:] - frame_2d[:-1]
for i in range(1, samples):
output[i] = self._alpha * (output[i-1] + input_diff[i])
output = self._alpha * (output + input_diff)
self._filter_states = output[-1].copy()
self._last_inputs = frame_2d[-1].copy()
nframe = output.reshape(frame.shape)
return nframe
class LowPass(Filter):
def __init__(self, cut):
super().__init__()
self.cut = cut
self._samplerate = None
self._channels = None
self._filter_states = None
self._alpha = None
def handle_frame(self, frame, samplerate):
if len(frame) == 0: return frame
if samplerate != self._samplerate:
self._samplerate = samplerate
dt = 1.0 / self._samplerate
rc = 1.0 / (2 * np.pi * self.cut)
self._alpha = dt / (rc + dt)
if len(frame.shape) == 1: frame_2d = frame.reshape(-1, 1)
else: frame_2d = frame
samples, channels = frame_2d.shape
if self._filter_states is None or self._channels != channels:
self._channels = channels
self._filter_states = np.zeros(self._channels, dtype=np.float32)
if USE_NATIVE_FILTERS:
frame_2d = np.ascontiguousarray(frame_2d, dtype=np.float32)
output = np.empty_like(frame_2d, dtype=np.float32)
input_ptr = ffi.cast("float *", frame_2d.ctypes.data)
output_ptr = ffi.cast("float *", output.ctypes.data)
states_ptr = ffi.cast("float *", self._filter_states.ctypes.data)
native_functions.lowpass_filter(input_ptr, output_ptr, samples, channels, float(self._alpha), states_ptr)
return output.reshape(frame.shape)
else:
output = np.empty_like(frame_2d)
output[0] = self._alpha * frame_2d[0] + (1.0 - self._alpha) * self._filter_states
for i in range(1, samples):
output[i] = self._alpha * frame_2d[i] + (1.0 - self._alpha) * output[i-1]
self._filter_states = output[-1].copy()
return output.reshape(frame.shape)
class BandPass(Filter):
def __init__(self, low_cut, high_cut):
super().__init__()
if low_cut >= high_cut: raise ValueError("Low-cut frequency must be less than high-cut frequency")
self.low_cut = low_cut
self.high_cut = high_cut
self._high_pass = HighPass(self.low_cut)
self._low_pass = LowPass(self.high_cut)
def handle_frame(self, frame, samplerate):
# TODO: Remove debug
# st = time.time()
if len(frame) == 0: return frame
high_passed = self._high_pass.handle_frame(frame, samplerate)
band_passed = self._low_pass.handle_frame(high_passed, samplerate)
# RNS.log(f"Filter ran in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_DEBUG)
return band_passed
class AGC(Filter):
def __init__(self, target_level=-12.0, max_gain=12.0, attack_time=0.0001, release_time=0.002, hold_time=0.001):
super().__init__()
self.trigger_level = 0.003
self.target_level = target_level # In dBFS
self.max_gain_db = max_gain
self.attack_time = attack_time
self.release_time = release_time
self.hold_time = hold_time
self.target_linear = 10 ** (target_level / 10)
self.max_gain_linear = 10 ** (max_gain / 10)
self._samplerate = None
self._channels = None
self._current_gain_lin = 1.0
self._hold_counter = 0
self._block_target_s = 0.01
self._attack_coeff = None
self._release_coeff = None
self._hold_samples = None
def handle_frame(self, frame, samplerate):
# TODO: Remove debug
# st = time.time()
if len(frame) == 0: return frame
if len(frame.shape) == 1: frame_2d = frame.reshape(-1, 1)
else: frame_2d = frame
samples, channels = frame_2d.shape
if samplerate != self._samplerate:
self._samplerate = samplerate
self._block_target = int((samples/self._samplerate)/self._block_target_s)
self._calculate_coefficients()
if self._channels is None or self._channels != channels:
self._channels = channels
self._current_gain_lin = np.ones(channels, dtype=np.float32)
self._hold_counter = 0
if USE_NATIVE_FILTERS:
frame_2d = np.ascontiguousarray(frame_2d, dtype=np.float32)
output = np.empty_like(frame_2d, dtype=np.float32)
input_ptr = ffi.cast("float *", frame_2d.ctypes.data)
output_ptr = ffi.cast("float *", output.ctypes.data)
gain_ptr = ffi.cast("float *", self._current_gain_lin.ctypes.data)
hold_ptr = ffi.new("int *", self._hold_counter)
native_functions.agc_process(
input_ptr, output_ptr, samples, channels,
float(self.target_linear), float(self.max_gain_linear),
float(self.trigger_level),
float(self._attack_coeff), float(self._release_coeff),
float(self._hold_samples),
gain_ptr, hold_ptr, int(self._block_target)
)
self._hold_counter = hold_ptr[0]
result = output.reshape(frame.shape)
# TODO: Remove debug
# RNS.log(f"AGC ran in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_DEBUG)
return result
else:
output = np.empty_like(frame_2d)
block_size = max(1, samples // self._block_target)
for i in range(0, samples, block_size):
block_end = min(i + block_size, samples)
block = frame_2d[i:block_end]
block_samples = block_end - i
rms = np.sqrt(np.mean(block ** 2, axis=0))
target_gain = np.where(rms > 1e-9, self.target_linear / np.maximum(rms, 1e-9), self.max_gain_linear)
target_gain = np.minimum(target_gain, self.max_gain_linear)
smoothed_gain = np.empty_like(target_gain)
for ch in range(channels):
if (rms[0] < self.trigger_level): target_gain = self._current_gain_lin
if target_gain[ch] < self._current_gain_lin[ch]:
self._current_gain_lin[ch] = self._attack_coeff * target_gain[ch] + (1 - self._attack_coeff) * self._current_gain_lin[ch]
self._hold_counter = self._hold_samples # Reset hold counter
else:
if self._hold_counter > 0: self._hold_counter -= block_samples
else: self._current_gain_lin[ch] = self._release_coeff * target_gain[ch] + (1 - self._release_coeff) * self._current_gain_lin[ch]
smoothed_gain[ch] = self._current_gain_lin[ch]
output[i:block_end] = block * smoothed_gain[np.newaxis, :]
peak_limit = 0.75
current_peaks = np.max(np.abs(output), axis=0)
limit_gain = np.where(current_peaks > peak_limit, peak_limit / np.maximum(current_peaks, 1e-9), 1.0)
if np.any(limit_gain < 1.0): output *= limit_gain[np.newaxis, :]
nframe = output.reshape(frame.shape)
# TODO: Remove debug
# RNS.log(f"AGC ran in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_DEBUG)
return nframe
def _calculate_coefficients(self):
if self._samplerate:
self._attack_coeff = 1.0 - np.exp(-1.0 / (self.attack_time * self._samplerate))
self._release_coeff = 1.0 - np.exp(-1.0 / (self.release_time * self._samplerate))
self._hold_samples = int(self.hold_time * self._samplerate)
else:
self._attack_coeff = 0.1
self._release_coeff = 0.01
self._hold_samples = 1000

View File

@@ -15,7 +15,7 @@ class Mixer(LocalSource, LocalSink):
MAX_FRAMES = 8
TYPE_MAP_FACTOR = np.iinfo("int16").max
def __init__(self, target_frame_ms=40, samplerate=None, codec=None, sink=None):
def __init__(self, target_frame_ms=40, samplerate=None, codec=None, sink=None, gain=0.0):
self.incoming_frames = {}
self.target_frame_ms = target_frame_ms
self.frame_time = self.target_frame_ms/1000
@@ -23,6 +23,8 @@ class Mixer(LocalSource, LocalSink):
self.mixer_thread = None
self.mixer_lock = threading.Lock()
self.insert_lock = threading.Lock()
self.muted = False
self.gain = gain
self.bitdepth = 32
self.channels = None
self.samplerate = None
@@ -44,6 +46,18 @@ class Mixer(LocalSource, LocalSink):
def stop(self):
self.should_run = False
def set_gain(self, gain=None):
if gain == None: self.gain = 0.0
else: self.gain = float(gain)
def mute(self, mute=True):
if mute == True or mute == False: self.muted = mute
else: self.muted = False
def unmute(self, unmute=True):
if unmute == True or unmute == False: self.muted = unmute
else: self.muted = False
def set_source_max_frames(self, source, max_frames):
with self.insert_lock:
if not source in self.incoming_frames: self.incoming_frames[source] = deque(maxlen=max_frames)
@@ -78,6 +92,12 @@ class Mixer(LocalSource, LocalSink):
self.incoming_frames[source].append(frame_samples)
@property
def _mixing_gain(self):
if self.muted: return 0.0
elif self.gain == 0.0: return 1.0
else: return 10**(self.gain/10)
def _mixer_job(self):
with self.mixer_lock:
while self.should_run:
@@ -87,11 +107,16 @@ class Mixer(LocalSource, LocalSink):
for source in self.incoming_frames.copy():
if len(self.incoming_frames[source]) > 0:
next_frame = self.incoming_frames[source].popleft()
if source_count == 0: mixed_frame = next_frame
else: mixed_frame = mixed_frame + next_frame
if source_count == 0: mixed_frame = next_frame*self._mixing_gain
else: mixed_frame = mixed_frame + next_frame*self._mixing_gain
source_count += 1
if source_count > 0:
mixed_frame = np.clip(mixed_frame, -1.0, 1.0)
if RNS.loglevel >= RNS.LOG_DEBUG:
if mixed_frame.max() >= 1.0 or mixed_frame.min() <= -1.0:
RNS.log(f"Signal clipped on {self}", RNS.LOG_WARNING)
if self.codec: self.sink.handle_frame(self.codec.encode(mixed_frame), self)
else: self.sink.handle_frame(mixed_frame, self)
else:

View File

@@ -128,10 +128,8 @@ class LinkSource(RemoteSource, SignallingReceiver):
else:
decoded_frame = self.codec.decode(frame[1:])
if self.pipeline:
self.sink.handle_frame(decoded_frame, self)
else:
self.sink.handle_frame(decoded_frame, self, decoded=True)
if self.pipeline: self.sink.handle_frame(decoded_frame, self)
else: self.sink.handle_frame(decoded_frame, self, decoded=True)
if FIELD_SIGNALLING in unpacked:
super()._packet(data=None, packet=packet, unpacked=unpacked)

View File

@@ -11,6 +11,8 @@ from LXST.Sinks import LineSink
from LXST.Sources import LineSource, OpusFileSource
from LXST.Generators import ToneSource
from LXST.Network import SignallingReceiver, Packetizer, LinkSource
from LXST.Filters import BandPass, AGC
PRIMITIVE_NAME = "telephony"
@@ -112,6 +114,7 @@ class Signalling():
class Telephone(SignallingReceiver):
RING_TIME = 60
WAIT_TIME = 70
CONNECT_TIME = 5
DIAL_TONE_FREQUENCY = 382
DIAL_TONE_EASE_MS = 3.14159
JOB_INTERVAL = 5
@@ -120,7 +123,13 @@ class Telephone(SignallingReceiver):
ALLOW_ALL = 0xFF
ALLOW_NONE = 0xFE
def __init__(self, identity, ring_time=RING_TIME, wait_time=WAIT_TIME, auto_answer=None, allowed=ALLOW_ALL):
@staticmethod
def available_outputs(): return LXST.Sources.Backend().soundcard.all_speakers()
@staticmethod
def available_inputs(): return LXST.Sinks.Backend().soundcard.all_microphones()
def __init__(self, identity, ring_time=RING_TIME, wait_time=WAIT_TIME, auto_answer=None, allowed=ALLOW_ALL, receive_gain=0.0, transmit_gain=0.0):
super().__init__()
self.identity = identity
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME)
@@ -132,16 +141,22 @@ class Telephone(SignallingReceiver):
self.call_handler_lock = threading.Lock()
self.pipeline_lock = threading.Lock()
self.caller_pipeline_open_lock = threading.Lock()
self.establishment_timeout = self.CONNECT_TIME
self.links = {}
self.ring_time = ring_time
self.wait_time = wait_time
self.auto_answer = auto_answer
self.receive_gain = receive_gain
self.transmit_gain = transmit_gain
self.use_agc = True
self.active_call = None
self.call_status = Signalling.STATUS_AVAILABLE
self._external_busy = False
self.__ringing_callback = None
self.__established_callback = None
self.__ended_callback = None
self.__busy_callback = None
self.__rejected_callback = None
self.target_frame_time_ms = None
self.audio_output = None
self.audio_input = None
@@ -184,6 +199,9 @@ class Telephone(SignallingReceiver):
if type(blocked) == list or blocked == None: self.blocked = blocked
else: raise TypeError(f"Invalid type for blocked callers: {type(blocked)}")
def set_connect_timeout(self, timeout):
self.establishment_timeout = timeout
def set_announce_interval(self, announce_interval):
if not type(announce_interval) == int: raise TypeError(f"Invalid type for announce interval: {announce_interval}")
else:
@@ -202,6 +220,14 @@ class Telephone(SignallingReceiver):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__ended_callback = callback
def set_busy_callback(self, callback):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__busy_callback = callback
def set_rejected_callback(self, callback):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__rejected_callback = callback
def set_speaker(self, device):
self.speaker_device = device
RNS.log(f"{self} speaker device set to {device}", RNS.LOG_DEBUG)
@@ -214,11 +240,19 @@ class Telephone(SignallingReceiver):
self.ringer_device = device
RNS.log(f"{self} ringer device set to {device}", RNS.LOG_DEBUG)
def set_ringtone(self, ringtone_path, gain=1.0):
def set_ringtone(self, ringtone_path, gain=0.0):
self.ringtone_path = ringtone_path
self.ringtone_gain = gain
RNS.log(f"{self} ringtone set to {self.ringtone_path}", RNS.LOG_DEBUG)
def enable_agc(self, enable=True):
if enable == True: self.use_agc = True
else: self.use_agc = False
def disable_agc(self, disable=True):
if disable == True: self.use_agc = False
else: self.use_agc = True
def set_low_latency_output(self, enabled):
if enabled:
self.low_latency_output = True
@@ -243,9 +277,7 @@ class Telephone(SignallingReceiver):
def __timeout_incoming_call_at(self, call, timeout):
def job():
while time.time()<timeout and self.active_call == call:
time.sleep(0.25)
while time.time()<timeout and self.active_call == call: time.sleep(0.25)
if self.active_call == call and self.call_status < Signalling.STATUS_ESTABLISHED:
RNS.log(f"Ring timeout on call from {RNS.prettyhexrep(self.active_call.hash)}, hanging up", RNS.LOG_DEBUG)
self.active_call.ring_timeout = True
@@ -255,21 +287,29 @@ class Telephone(SignallingReceiver):
def __timeout_outgoing_call_at(self, call, timeout):
def job():
while time.time()<timeout and self.active_call == call:
time.sleep(0.25)
while time.time()<timeout and self.active_call == call: time.sleep(0.25)
if self.active_call == call and self.call_status < Signalling.STATUS_ESTABLISHED:
RNS.log(f"Timeout on outgoing call to {RNS.prettyhexrep(self.active_call.hash)}, hanging up", RNS.LOG_DEBUG)
self.hangup()
threading.Thread(target=job, daemon=True).start()
def __timeout_outgoing_establishment_at(self, call, timeout):
def job():
while time.time()<timeout and self.active_call == call: time.sleep(0.25)
if self.active_call == call and self.call_status < Signalling.STATUS_RINGING:
RNS.log(f"Timeout on outgoing connection establishment to {RNS.prettyhexrep(self.active_call.hash)}, hanging up", RNS.LOG_DEBUG)
self.hangup()
threading.Thread(target=job, daemon=True).start()
def __incoming_link_established(self, link):
link.is_incoming = True
link.is_outgoing = False
link.ring_timeout = False
link.answered = False
link.profile = None
link.is_incoming = True
link.is_outgoing = False
link.ring_timeout = False
link.answered = False
link.is_terminating = False
link.profile = None
with self.call_handler_lock:
if self.active_call or self.busy:
RNS.log(f"Incoming call, but line is already active, signalling busy", RNS.LOG_DEBUG)
@@ -314,7 +354,7 @@ class Telephone(SignallingReceiver):
def __link_closed(self, link):
if link == self.active_call:
RNS.log(f"Remote for {RNS.prettyhexrep(link.get_remote_identity().hash)} hung up", RNS.LOG_DEBUG)
self.hangup()
if not self.active_call.is_terminating: self.hangup()
def set_busy(self, busy):
self._external_busy = busy
@@ -356,7 +396,7 @@ class Telephone(SignallingReceiver):
if self.low_latency_output: self.audio_output.enable_low_latency()
return True
def hangup(self):
def hangup(self, reason=None):
if self.active_call:
with self.call_handler_lock:
terminating_call = self.active_call; self.active_call = None
@@ -375,31 +415,61 @@ class Telephone(SignallingReceiver):
self.audio_output = None
self.dial_tone = None
self.call_status = Signalling.STATUS_AVAILABLE
if remote_identity:
RNS.log(f"Call with {RNS.prettyhexrep(remote_identity.hash)} terminated", RNS.LOG_DEBUG)
else:
RNS.log(f"Outgoing call could not be connected, link establishment failed", RNS.LOG_DEBUG)
if remote_identity: RNS.log(f"Call with {RNS.prettyhexrep(remote_identity.hash)} terminated", RNS.LOG_DEBUG)
else: RNS.log(f"Outgoing call could not be connected, link establishment failed", RNS.LOG_DEBUG)
if callable(self.__ended_callback): self.__ended_callback(remote_identity)
if reason == None:
if callable(self.__ended_callback): self.__ended_callback(remote_identity)
elif reason == Signalling.STATUS_BUSY:
if callable(self.__busy_callback): self.__busy_callback(remote_identity)
elif callable(self.__ended_callback): self.__ended_callback(remote_identity)
elif reason == Signalling.STATUS_REJECTED:
if callable(self.__rejected_callback): self.__rejected_callback(remote_identity)
elif callable(self.__ended_callback): self.__ended_callback(remote_identity)
def mute_receive(self):
pass
def mute_receive(self, mute=True):
if self.receive_mixer: self.receive_mixer.mute(mute)
def mute_transmit(self):
pass
def unmute_receive(self, unmute=True):
if self.receive_mixer: self.receive_mixer.unmute(mute)
def select_call_profile(self, profile=None):
def mute_transmit(self, mute=True):
if self.transmit_mixer: self.transmit_mixer.mute(mute)
def unmute_transmit(self, unmute=True):
if self.transmit_mixer: self.transmit_mixer.unmute(unmute)
def set_receive_gain(self, gain=0.0):
self.receive_gain = float(gain)
if self.receive_mixer: self.receive_mixer.set_gain(self.receive_gain)
def set_transmit_gain(self, gain=0.0):
self.transmit_gain = float(gain)
if self.transmit_mixer: self.transmit_mixer.set_gain(self.transmit_gain)
def switch_profile(self, profile=None, from_signalling=False):
if self.active_call:
if self.active_call.profile == profile: return
else:
if self.call_status == Signalling.STATUS_ESTABLISHED:
self.active_call.profile = profile
self.transmit_codec = Profiles.get_codec(self.active_call.profile)
self.target_frame_time_ms = Profiles.get_frame_time(self.active_call.profile)
if not from_signalling: self.signal(Signalling.PREFERRED_PROFILE+self.active_call.profile, self.active_call)
self.__reconfigure_transmit_pipeline()
def __select_call_profile(self, profile=None):
if profile == None: profile = Profiles.DEFAULT_PROFILE
self.active_call.profile = profile
self.select_call_codecs(self.active_call.profile)
self.select_call_frame_time(self.active_call.profile)
self.__select_call_codecs(self.active_call.profile)
self.__select_call_frame_time(self.active_call.profile)
RNS.log(f"Selected call profile 0x{RNS.hexrep(profile, delimit=False)}", RNS.LOG_DEBUG)
def select_call_codecs(self, profile=None):
def __select_call_codecs(self, profile=None):
self.receive_codec = Null()
self.transmit_codec = Profiles.get_codec(profile)
def select_call_frame_time(self, profile=None):
def __select_call_frame_time(self, profile=None):
self.target_frame_time_ms = Profiles.get_frame_time(profile)
def __reset_dialling_pipelines(self):
@@ -415,9 +485,9 @@ class Telephone(SignallingReceiver):
self.__prepare_dialling_pipelines()
def __prepare_dialling_pipelines(self):
self.select_call_profile(self.active_call.profile)
self.__select_call_profile(self.active_call.profile)
if self.audio_output == None: self.audio_output = LineSink(preferred_device=self.speaker_device)
if self.receive_mixer == None: self.receive_mixer = Mixer(target_frame_ms=self.target_frame_time_ms)
if self.receive_mixer == None: self.receive_mixer = Mixer(target_frame_ms=self.target_frame_time_ms, gain=self.receive_gain)
if self.dial_tone == None: self.dial_tone = ToneSource(frequency=self.dial_tone_frequency, gain=0.0, ease_time_ms=self.dial_tone_ease_ms, target_frame_ms=self.target_frame_time_ms, codec=Null(), sink=self.receive_mixer)
if self.receive_pipeline == None: self.receive_pipeline = Pipeline(source=self.receive_mixer, codec=Null(), sink=self.audio_output)
@@ -473,6 +543,20 @@ class Telephone(SignallingReceiver):
if self.dial_tone and self.dial_tone.running:
self.dial_tone.stop()
def __reconfigure_transmit_pipeline(self):
if self.transmit_pipeline and self.call_status == Signalling.STATUS_ESTABLISHED:
self.audio_input.stop()
self.transmit_mixer.stop()
self.transmit_pipeline.stop()
self.transmit_mixer = Mixer(target_frame_ms=self.target_frame_time_ms, gain=self.transmit_gain)
self.audio_input = LineSource(preferred_device=self.microphone_device, target_frame_ms=self.target_frame_time_ms, codec=Raw(), sink=self.transmit_mixer, filters=self.active_call.filters)
self.transmit_pipeline = Pipeline(source=self.transmit_mixer,
codec=self.transmit_codec,
sink=self.active_call.packetizer)
self.transmit_mixer.start()
self.audio_input.start()
self.transmit_pipeline.start()
def __open_pipelines(self, identity):
with self.pipeline_lock:
if not self.active_call.get_remote_identity() == identity:
@@ -485,12 +569,16 @@ class Telephone(SignallingReceiver):
RNS.log(f"Opening audio pipelines for call with {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG)
if self.active_call.is_incoming: self.signal(Signalling.STATUS_CONNECTING, self.active_call)
if self.use_agc: self.active_call.filters = [BandPass(250, 8500), AGC()]
else: self.active_call.filters = [BandPass(250, 8500)]
self.__prepare_dialling_pipelines()
self.transmit_mixer = Mixer(target_frame_ms=self.target_frame_time_ms)
self.audio_input = LineSource(preferred_device=self.microphone_device, target_frame_ms=self.target_frame_time_ms, codec=Raw(), sink=self.transmit_mixer)
self.active_call.packetizer = Packetizer(self.active_call, failure_callback=self.__packetizer_failure)
self.transmit_mixer = Mixer(target_frame_ms=self.target_frame_time_ms, gain=self.transmit_gain)
self.audio_input = LineSource(preferred_device=self.microphone_device, target_frame_ms=self.target_frame_time_ms, codec=Raw(), sink=self.transmit_mixer, filters=self.active_call.filters)
self.transmit_pipeline = Pipeline(source=self.transmit_mixer,
codec=self.transmit_codec,
sink=Packetizer(self.active_call, failure_callback=self.__packetizer_failure))
sink=self.active_call.packetizer)
self.active_call.audio_source = LinkSource(link=self.active_call, signalling_receiver=self, sink=self.receive_mixer)
self.receive_mixer.set_source_max_frames(self.active_call.audio_source, 2)
@@ -524,6 +612,7 @@ class Telephone(SignallingReceiver):
if not self.active_call:
self.call_status = Signalling.STATUS_CALLING
outgoing_call_timeout = time.time()+self.wait_time
outgoing_establishment_timeout = time.time()+self.establishment_timeout
call_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME)
if not RNS.Transport.has_path(call_destination.hash):
RNS.log(f"No path known for call to {RNS.prettyhexrep(call_destination.hash)}, requesting path...", RNS.LOG_DEBUG)
@@ -537,11 +626,13 @@ class Telephone(SignallingReceiver):
established_callback=self.__outgoing_link_established,
closed_callback=self.__outgoing_link_closed)
self.active_call.is_incoming = False
self.active_call.is_outgoing = True
self.active_call.ring_timeout = False
self.active_call.profile = profile
self.active_call.is_incoming = False
self.active_call.is_outgoing = True
self.active_call.is_terminating = False
self.active_call.ring_timeout = False
self.active_call.profile = profile
self.__timeout_outgoing_call_at(self.active_call, outgoing_call_timeout)
self.__timeout_outgoing_establishment_at(self.active_call, outgoing_establishment_timeout)
def __outgoing_link_established(self, link):
RNS.log(f"Link established for call with {link.get_remote_identity()}", RNS.LOG_DEBUG)
@@ -559,14 +650,15 @@ class Telephone(SignallingReceiver):
return
elif signal == Signalling.STATUS_BUSY:
RNS.log("Remote is busy, terminating", RNS.LOG_DEBUG)
self.active_call.is_terminating = True
self.__play_busy_tone()
self.__disable_dial_tone()
self.hangup()
self.hangup(reason=Signalling.STATUS_BUSY)
elif signal == Signalling.STATUS_REJECTED:
RNS.log("Remote rejected call, terminating", RNS.LOG_DEBUG)
self.__play_busy_tone()
self.__disable_dial_tone()
self.hangup()
self.hangup(reason=Signalling.STATUS_REJECTED)
elif signal == Signalling.STATUS_AVAILABLE:
RNS.log("Line available, sending identification", RNS.LOG_DEBUG)
self.call_status = signal
@@ -594,8 +686,9 @@ class Telephone(SignallingReceiver):
if callable(self.__established_callback): self.__established_callback(self.active_call.get_remote_identity())
if self.low_latency_output: self.audio_output.enable_low_latency()
elif signal >= Signalling.PREFERRED_PROFILE:
self.active_call.profile = signal - Signalling.PREFERRED_PROFILE
self.select_call_profile(self.active_call.profile)
profile = signal - Signalling.PREFERRED_PROFILE
if self.active_call and self.call_status == Signalling.STATUS_ESTABLISHED: self.switch_profile(profile, from_signalling=True)
else: self.__select_call_profile(profile)
def __str__(self):
return f"<lxst.telephony/{RNS.hexrep(self.identity.hash, delimit=False)}>"

View File

@@ -152,7 +152,7 @@ class LineSource(LocalSource):
MAX_FRAMES = 128
DEFAULT_FRAME_MS = 80
def __init__(self, preferred_device=None, target_frame_ms=DEFAULT_FRAME_MS, codec=None, sink=None):
def __init__(self, preferred_device=None, target_frame_ms=DEFAULT_FRAME_MS, codec=None, sink=None, filters=None):
self.preferred_device = preferred_device
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.target_frame_ms = target_frame_ms
@@ -165,6 +165,11 @@ class LineSource(LocalSource):
self._codec = None
self.codec = codec
self.sink = sink
self.filters = None
if filters != None:
if type(filters) == list: self.filters = filters
else: self.filters = [filters]
@property
def codec(self): return self._codec
@@ -218,6 +223,8 @@ class LineSource(LocalSource):
with self.backend.get_recorder(samples_per_frame=backend_samples_per_frame) as recorder:
while self.should_run:
frame_samples = recorder.record(numframes=self.samples_per_frame)
if self.filters != None:
for f in self.filters: frame_samples = f.handle_frame(frame_samples, self.samplerate)
if self.codec:
frame = self.codec.encode(frame_samples)
if self.sink and self.sink.can_receive(from_source=self):

View File

@@ -1 +1 @@
__version__ = "0.4.1"
__version__ = "0.4.2"

View File

@@ -2,8 +2,9 @@ all: release
clean:
@echo Cleaning...
-rm -r ./build
-rm -r ./dist
-sudo rm -rf ./build
-rm -rf ./dist
-rm -r ./LXST/__pycache__
remove_symlinks:
@echo Removing symlinks for build...
@@ -18,7 +19,19 @@ create_symlinks:
-ln -s ../LXST/ ./examples/LXST
build_wheel:
cp ./lib/0.4.2/* ./LXST/
python3 setup.py sdist bdist_wheel
-(rm ./LXST/*.so)
-(rm ./LXST/*.dll)
-(rm ./LXST/*.dylib)
native_libs:
./march_build.sh
persist_libs:
-cp ./libs/dev/*.so ./libs/static/
-cp ./libs/dev/*.dll ./libs/static/
-cp ./libs/dev/*.dylib ./libs/static/
release: remove_symlinks build_wheel create_symlinks

26
build_wheels.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# This script is used to run the binary wheel builds
# inside docker containers for multi-arch compilation
set -e -x
yum install -y gcc gcc-c++ make codec2-devel codec2
PYTHON_VERSIONS=(
"cp311-cp311"
"cp312-cp312"
"cp313-cp313"
"cp314-cp314"
)
for PY_TAG in "${PYTHON_VERSIONS[@]}"; do
PYBIN="/opt/python/${PY_TAG}/bin"
if [ ! -d "$PYBIN" ]; then
echo "Python version not found: $PYBIN"
continue
fi
echo "Building with: $PYBIN"
"${PYBIN}/pip" install cffi
"${PYBIN}/pip" wheel /io/ -w wheelhouse/
done

23
examples/filters.py Normal file
View File

@@ -0,0 +1,23 @@
import RNS
import LXST
import sys
import time
from LXST.Filters import BandPass, AGC
target_frame_ms = 40
raw = LXST.Codecs.Raw()
filters = [BandPass(200, 8500), AGC()]
line_sink = LXST.Sinks.LineSink()
mixer = LXST.Mixer(target_frame_ms=target_frame_ms, sink=line_sink)
line_source = LXST.Sources.LineSource(target_frame_ms=target_frame_ms, codec=raw, sink=mixer, filters=filters)
mixer.start()
line_source.start()
print("Hit enter stop"); input()
line_source.stop()
time.sleep(0.5)

10
fetch_libs.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
TARGET_DIR="${1:-./lib/dev}"
mkdir -p "$TARGET_DIR"
echo "Copying native libraries to $TARGET_DIR..."
find ./build -name "filterlib*.*" -type f \( -name "*.so" -o -name "*.dll" -o -name "*.dylib" \) -exec cp {} "$TARGET_DIR/" \;
echo "Done. Copied files:"
ls -lh "$TARGET_DIR/"

12
march_build.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
docker run --rm -v $(pwd):/io quay.io/pypa/manylinux_2_34_x86_64 /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/musllinux_1_2_x86_64 /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/manylinux_2_31_armv7l /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/manylinux_2_34_aarch64 /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/manylinux_2_39_riscv64 /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/musllinux_1_2_aarch64 /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/musllinux_1_2_armv7l /io/build_wheels.sh
docker run --rm -v $(pwd):/io quay.io/pypa/musllinux_1_2_riscv64 /io/build_wheels.sh
./fetch_libs.sh

View File

@@ -1,10 +1,17 @@
import setuptools
from setuptools import setup, Extension
from setuptools.command.build_ext import build_ext
import os
import platform
with open("README.md", "r") as fh:
long_description = fh.read()
BUILD_EXTENSIONS = True
with open("README.md", "r") as fh: long_description = fh.read()
exec(open("LXST/_version.py", "r").read())
if BUILD_EXTENSIONS: extensions = [ Extension("LXST.filterlib", sources=["LXST/Filters.c"], include_dirs=["LXST"], language="c"), ]
else: extensions = []
packages = setuptools.find_packages(exclude=[])
packages.append("LXST.Utilities")
packages.append("LXST.Primitives.hardware")
@@ -16,6 +23,13 @@ package_data = {
"Codecs/libs/pyogg/libs/win_amd64/*",
"Codecs/libs/pyogg/libs/macos/*",
"Sounds/*",
],
"LXST": [
"Filters.h",
"Filters.c",
"filterlib*.so",
"filterlib*.dll",
"filterlib*.dylib",
]
}
@@ -30,6 +44,8 @@ setuptools.setup(
url="https://git.unsigned.io/markqvist/lxst",
packages=packages,
package_data=package_data,
ext_modules=extensions,
cmdclass={"build_ext": build_ext},
classifiers=[
"Programming Language :: Python :: 3",
"License :: Other/Proprietary License",
@@ -40,11 +56,12 @@ setuptools.setup(
'rnphone=LXST.Utilities.rnphone:main',
]
},
install_requires=["rns>=1.0.3",
"lxmf>=0.9.1",
install_requires=["rns>=1.0.4",
"lxmf>=0.9.3",
"soundcard>=0.4.5",
"numpy>=2.3.4",
"pycodec2>=4.1.0",
"audioop-lts>=0.2.1;python_version>='3.13'"],
python_requires=">=3.7",
"audioop-lts>=0.2.1;python_version>='3.13'",
"cffi>=1.17.1"],
python_requires=">=3.11",
)