diff --git a/LXST/Codecs/Codec2.py b/LXST/Codecs/Codec2.py index b371343..ab37ebe 100644 --- a/LXST/Codecs/Codec2.py +++ b/LXST/Codecs/Codec2.py @@ -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) diff --git a/LXST/Filters.c b/LXST/Filters.c new file mode 100644 index 0000000..607a8e0 --- /dev/null +++ b/LXST/Filters.c @@ -0,0 +1,69 @@ +#include + +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; } } + } +} \ No newline at end of file diff --git a/LXST/Filters.h b/LXST/Filters.h new file mode 100644 index 0000000..cefeb6c --- /dev/null +++ b/LXST/Filters.h @@ -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); \ No newline at end of file diff --git a/LXST/Filters.py b/LXST/Filters.py new file mode 100644 index 0000000..4414093 --- /dev/null +++ b/LXST/Filters.py @@ -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 \ No newline at end of file diff --git a/LXST/Mixer.py b/LXST/Mixer.py index de9677b..2a4cda5 100644 --- a/LXST/Mixer.py +++ b/LXST/Mixer.py @@ -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: diff --git a/LXST/Network.py b/LXST/Network.py index 5c0f2a2..a0b4468 100644 --- a/LXST/Network.py +++ b/LXST/Network.py @@ -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) diff --git a/LXST/Primitives/Telephony.py b/LXST/Primitives/Telephony.py index 008f044..217fe9f 100644 --- a/LXST/Primitives/Telephony.py +++ b/LXST/Primitives/Telephony.py @@ -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()= 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"" \ No newline at end of file diff --git a/LXST/Sources.py b/LXST/Sources.py index 163e1f9..d10aaea 100644 --- a/LXST/Sources.py +++ b/LXST/Sources.py @@ -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): diff --git a/LXST/_version.py b/LXST/_version.py index 3d26edf..df12433 100644 --- a/LXST/_version.py +++ b/LXST/_version.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/Makefile b/Makefile index b4b0a82..52298c4 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/build_wheels.sh b/build_wheels.sh new file mode 100755 index 0000000..48df71d --- /dev/null +++ b/build_wheels.sh @@ -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 \ No newline at end of file diff --git a/examples/filters.py b/examples/filters.py new file mode 100644 index 0000000..05768cd --- /dev/null +++ b/examples/filters.py @@ -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) \ No newline at end of file diff --git a/fetch_libs.sh b/fetch_libs.sh new file mode 100755 index 0000000..aef8f95 --- /dev/null +++ b/fetch_libs.sh @@ -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/" \ No newline at end of file diff --git a/march_build.sh b/march_build.sh new file mode 100755 index 0000000..d8ed8f4 --- /dev/null +++ b/march_build.sh @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py index f87ac21..faeb282 100644 --- a/setup.py +++ b/setup.py @@ -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", )