Sync upstream

This commit is contained in:
Mark Qvist 2025-11-27 02:17:32 +01:00
commit 68c2cf489c
10 changed files with 405 additions and 96 deletions

View file

@ -40,85 +40,73 @@ class Opus(Codec):
self.output_bitrate = 0 self.output_bitrate = 0
self.set_profile(profile) self.set_profile(profile)
@staticmethod
def profile_channels(profile):
if profile == Opus.PROFILE_VOICE_LOW: return 1
elif profile == Opus.PROFILE_VOICE_MEDIUM: return 1
elif profile == Opus.PROFILE_VOICE_HIGH: return 1
elif profile == Opus.PROFILE_VOICE_MAX: return 2
elif profile == Opus.PROFILE_AUDIO_MIN: return 1
elif profile == Opus.PROFILE_AUDIO_LOW: return 1
elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 2
elif profile == Opus.PROFILE_AUDIO_HIGH: return 2
elif profile == Opus.PROFILE_AUDIO_MAX: return 2
else: raise CodecError(f"Unsupported profile")
@staticmethod
def profile_samplerate(profile):
if profile == Opus.PROFILE_VOICE_LOW: return 8000
elif profile == Opus.PROFILE_VOICE_MEDIUM: return 24000
elif profile == Opus.PROFILE_VOICE_HIGH: return 48000
elif profile == Opus.PROFILE_VOICE_MAX: return 48000
elif profile == Opus.PROFILE_AUDIO_MIN: return 8000
elif profile == Opus.PROFILE_AUDIO_LOW: return 12000
elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 24000
elif profile == Opus.PROFILE_AUDIO_HIGH: return 48000
elif profile == Opus.PROFILE_AUDIO_MAX: return 48000
else: raise CodecError(f"Unsupported profile")
@staticmethod
def profile_application(profile):
if profile == Opus.PROFILE_VOICE_LOW: return "voip"
elif profile == Opus.PROFILE_VOICE_MEDIUM: return "voip"
elif profile == Opus.PROFILE_VOICE_HIGH: return "voip"
elif profile == Opus.PROFILE_VOICE_MAX: return "voip"
elif profile == Opus.PROFILE_AUDIO_MIN: return "audio"
elif profile == Opus.PROFILE_AUDIO_LOW: return "audio"
elif profile == Opus.PROFILE_AUDIO_MEDIUM: return "audio"
elif profile == Opus.PROFILE_AUDIO_HIGH: return "audio"
elif profile == Opus.PROFILE_AUDIO_MAX: return "audio"
else: raise CodecError(f"Unsupported profile")
@staticmethod
def profile_bitrate_ceiling(profile):
if profile == Opus.PROFILE_VOICE_LOW: return 6000
elif profile == Opus.PROFILE_VOICE_MEDIUM: return 8000
elif profile == Opus.PROFILE_VOICE_HIGH: return 16000
elif profile == Opus.PROFILE_VOICE_MAX: return 32000
elif profile == Opus.PROFILE_AUDIO_MIN: return 8000
elif profile == Opus.PROFILE_AUDIO_LOW: return 14000
elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 28000
elif profile == Opus.PROFILE_AUDIO_HIGH: return 56000
elif profile == Opus.PROFILE_AUDIO_MAX: return 128000
else: raise CodecError(f"Unsupported profile")
@staticmethod
def max_bytes_per_frame(bitrate_ceiling, frame_duration_ms):
return math.ceil((bitrate_ceiling/8)*(frame_duration_ms/1000))
def set_profile(self, profile): def set_profile(self, profile):
if profile == self.PROFILE_VOICE_LOW: self.channels = self.profile_channels(profile)
self.profile = profile
self.channels = 1
self.input_channels = self.channels self.input_channels = self.channels
self.output_samplerate = 8000 self.output_samplerate = self.profile_samplerate(profile)
self.opus_encoder.set_application("voip") self.opus_encoder.set_application(self.profile_application(profile))
elif profile == self.PROFILE_VOICE_MEDIUM:
self.profile = profile self.profile = profile
self.channels = 1
self.input_channels = self.channels
self.output_samplerate = 24000
self.opus_encoder.set_application("voip")
elif profile == self.PROFILE_VOICE_HIGH:
self.profile = profile
self.channels = 1
self.input_channels = self.channels
self.output_samplerate = 48000
self.opus_encoder.set_application("voip")
elif profile == self.PROFILE_VOICE_MAX:
self.profile = profile
self.channels = 2
self.input_channels = self.channels
self.output_samplerate = 48000
self.opus_encoder.set_application("voip")
elif profile == self.PROFILE_AUDIO_MIN:
self.profile = profile
self.channels = 1
self.input_channels = self.channels
self.output_samplerate = 8000
self.opus_encoder.set_application("audio")
elif profile == self.PROFILE_AUDIO_LOW:
self.profile = profile
self.channels = 1
self.input_channels = self.channels
self.output_samplerate = 12000
self.opus_encoder.set_application("audio")
elif profile == self.PROFILE_AUDIO_MEDIUM:
self.profile = profile
self.channels = 2
self.input_channels = self.channels
self.output_samplerate = 24000
self.opus_encoder.set_application("audio")
elif profile == self.PROFILE_AUDIO_HIGH:
self.profile = profile
self.channels = 2
self.input_channels = self.channels
self.output_samplerate = 48000
self.opus_encoder.set_application("audio")
elif profile == self.PROFILE_AUDIO_MAX:
self.profile = profile
self.channels = 2
self.input_channels = self.channels
self.output_samplerate = 48000
self.opus_encoder.set_application("audio")
else:
raise CodecError(f"Unsupported profile configured for {self}")
def update_bitrate(self, frame_duration_ms): def update_bitrate(self, frame_duration_ms):
if self.profile == self.PROFILE_VOICE_LOW: self.bitrate_ceiling = self.profile_bitrate_ceiling(self.profile)
self.bitrate_ceiling = 6000 max_bytes_per_frame = self.max_bytes_per_frame(self.bitrate_ceiling, frame_duration_ms)
elif self.profile == self.PROFILE_VOICE_MEDIUM:
self.bitrate_ceiling = 8000
elif self.profile == self.PROFILE_VOICE_HIGH:
self.bitrate_ceiling = 16000
elif self.profile == self.PROFILE_VOICE_MAX:
self.bitrate_ceiling = 32000
elif self.profile == self.PROFILE_AUDIO_MIN:
self.bitrate_ceiling = 8000
elif self.profile == self.PROFILE_AUDIO_LOW:
self.bitrate_ceiling = 14000
elif self.profile == self.PROFILE_AUDIO_MEDIUM:
self.bitrate_ceiling = 28000
elif self.profile == self.PROFILE_AUDIO_HIGH:
self.bitrate_ceiling = 56000
elif self.profile == self.PROFILE_AUDIO_MAX:
self.bitrate_ceiling = 128000
max_bytes_per_frame = math.ceil((self.bitrate_ceiling/8)*(frame_duration_ms/1000))
configured_bitrate = (max_bytes_per_frame*8)/(frame_duration_ms/1000) configured_bitrate = (max_bytes_per_frame*8)/(frame_duration_ms/1000)
self.opus_encoder.set_max_bytes_per_frame(max_bytes_per_frame) self.opus_encoder.set_max_bytes_per_frame(max_bytes_per_frame)

View file

@ -18,12 +18,10 @@ class Pipeline():
self.source.sink = sink self.source.sink = sink
self.codec = codec self.codec = codec
if isinstance(sink, Loopback): if isinstance(sink, Loopback): sink.samplerate = source.samplerate
sink.samplerate = source.samplerate if isinstance(source, Loopback): source._sink = sink
if isinstance(source, Loopback): if isinstance(sink, Packetizer): sink.source = source
source._sink = sink if isinstance(sink, OpusFileSink): sink.source = source
if isinstance(sink, Packetizer):
sink.source = source
@property @property
def codec(self): def codec(self):

View file

@ -0,0 +1,71 @@
import LXST
import time
import threading
import os
from LXST.Sinks import LineSink
from LXST.Sources import OpusFileSource
class FilePlayer():
def __init__(self, path=None, device=None, loop=False):
self._file_path = path
self._playback_device = None
self.__finished_callback = None
self.__loop = loop
self.__source = None
self.__sink = LineSink(self._playback_device)
self.__raw = LXST.Codecs.Raw()
self.__loopback = LXST.Sources.Loopback()
self.__output_pipeline = LXST.Pipeline(source=self.__loopback, codec=self.__raw, sink=self.__sink)
self.__input_pipeline = None
if path: self.set_source(self._file_path)
@property
def running(self):
if not self.__source: return False
else: return self.__source.should_run
@property
def playing(self): return self.running
@property
def finished_callback(self): return self.__finished_callback
@finished_callback.setter
def finished_callback(self, callback):
if callback == None: self.__finished_callback = None
elif not callable(callback): raise TypeError("Provided callback is not callable")
else: self.__finished_callback = callback
def __callback_job(self):
if self.__finished_callback:
time.sleep(0.2)
while self.running: time.sleep(0.1)
self.__finished_callback(self)
def set_source(self, path=None):
if not path: return
else:
if not os.path.isfile(path): raise OSError(f"File not found: {path}")
else:
self.__source = OpusFileSource(path, loop=self.__loop)
self.__input_pipeline = LXST.Pipeline(source=self.__source, codec=self.__raw, sink=self.__loopback)
def loop(self, loop=True):
if loop == True: self.__loop = True
else: self.__loop = False
if self.__source: self.__source.loop = self.__loop
def start(self):
if not self.running and self.__source:
self.__input_pipeline.start()
self.__output_pipeline.start()
if self.__finished_callback:
threading.Thread(target=self.__callback_job, daemon=True).start()
def stop(self):
if self.running and self.__source:
self.__input_pipeline.stop()
self.__output_pipeline.stop()
def play(self): self.start()

View file

@ -0,0 +1,53 @@
import RNS
import LXST
import time
import os
from LXST.Sources import LineSource
from LXST.Sinks import OpusFileSink
from LXST.Filters import BandPass, AGC
class FileRecorder():
def __init__(self, path=None, device=None, profile=LXST.Codecs.Opus.PROFILE_AUDIO_MAX,
gain=0.0, ease_in=0.125, skip=0.075, filters=[BandPass(25, 24000)]):
self._file_path = path
self._record_device = device
self.__profile = profile
self.__source = None
self.__sink = OpusFileSink(path=self._file_path, profile=profile)
self.__null = LXST.Codecs.Null()
self.__filters = filters
self.__ease_in = ease_in
self.__skip = skip
self.__gain = gain
self.set_source(device)
@property
def running(self):
if not self.__source: return False
else: return self.__source.should_run
@property
def recording(self): return self.running
def set_source(self, device=None):
self._record_device = device
self.__source = LineSource(preferred_device=self._record_device, target_frame_ms=20, codec=self.__null, sink=self.__sink,
gain=self.__gain, ease_in=self.__ease_in, skip=self.__skip, filters=self.__filters)
self.__sink.source = self.__source
def set_output_path(self, path):
self._file_path = path
self.__sink.__output_path = path
def start(self):
if self.__source:
self.__source.start()
def stop(self):
if self.__source:
self.__source.stop()
while self.__sink.frames_waiting: time.sleep(0.1)
self.__sink.stop()
def record(self):
self.start()

View file

@ -604,7 +604,7 @@ class Telephone(SignallingReceiver):
RNS.log(f"Opening audio pipelines for call with {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG) 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.active_call.is_incoming: self.signal(Signalling.STATUS_CONNECTING, self.active_call)
if self.use_agc: self.active_call.filters = [BandPass(250, 8500), AGC()] if self.use_agc: self.active_call.filters = [BandPass(250, 8500), AGC(target_level=-15.0)]
else: self.active_call.filters = [BandPass(250, 8500)] else: self.active_call.filters = [BandPass(250, 8500)]
self.__prepare_dialling_pipelines() self.__prepare_dialling_pipelines()

View file

@ -2,7 +2,10 @@ import RNS
import math import math
import time import time
import threading import threading
import numpy as np
from collections import deque from collections import deque
from LXST.Codecs import Opus
from LXST.Codecs.Codec import resample_bytes, resample
class LinuxBackend(): class LinuxBackend():
SAMPLERATE = 48000 SAMPLERATE = 48000
@ -119,7 +122,6 @@ class LineSink(LocalSink):
def __init__(self, preferred_device=None, autodigest=True, low_latency=False): def __init__(self, preferred_device=None, autodigest=True, low_latency=False):
self.preferred_device = preferred_device self.preferred_device = preferred_device
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.should_run = False self.should_run = False
self.digest_thread = None self.digest_thread = None
self.digest_lock = threading.Lock() self.digest_lock = threading.Lock()
@ -215,4 +217,131 @@ class LineSink(LocalSink):
self.backend.release_player() self.backend.release_player()
class PacketSink(RemoteSink): pass class OpusFileSink(LocalSink):
MAX_FRAMES = 64
AUTOSTART_MIN = 1
FINALIZE_TIMEOUT = 2
TYPE_MAP_FACTOR = np.iinfo("int16").max
def __init__(self, path=None, autodigest=True, profile=Opus.PROFILE_AUDIO_MAX):
self.should_run = False
self.digest_thread = None
self.digest_lock = threading.Lock()
self.insert_lock = threading.Lock()
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.autodigest = autodigest
self.autostart_min = self.AUTOSTART_MIN
self.buffer_max_height = self.MAX_FRAMES
self.profile = profile
self.bitdepth = 32
self.samplerate = None
self.output_samplerate = Opus.profile_samplerate(self.profile)
self.channels = Opus.profile_channels(self.profile)
self.application = Opus.profile_application(self.profile)
self.bitrate_ceiling = Opus.profile_bitrate_ceiling(self.profile)
self.max_bytes_per_frame = None
self.samples_per_frame = None
self.frame_time = None
self.output_latency = 0
self.max_latency = 0
self.underrun_at = None
self.samples_written = 0
self.__recording_stopped = False
self.__finalized = False
self.__opus_writer = None
self.__encoder = None
self.__output_path = path
@property
def frames_waiting(self): return len(self.frame_deque)
def can_receive(self, from_source=None):
with self.insert_lock:
if self.__recording_stopped: return False
if len(self.frame_deque) < self.buffer_max_height: return True
else: return False
def handle_frame(self, frame, source=None):
with self.insert_lock:
self.frame_deque.append(frame)
if self.samples_per_frame == None:
self.samplerate = source.samplerate
self.samples_per_frame = frame.shape[0]
self.frame_time = self.samples_per_frame*(1/self.samplerate)
if not self.channels: self.channels = frame.shape[1]
RNS.log(f"{self} starting at {self.samples_per_frame} samples per frame, {self.channels} channels", RNS.LOG_DEBUG)
if self.autodigest and not self.should_run:
if len(self.frame_deque) >= self.autostart_min: self.start()
def start(self):
if not self.should_run:
self.should_run = True
self.digest_thread = threading.Thread(target=self.__digest_job, daemon=True)
self.digest_thread.start()
def stop(self):
if self.should_run:
self.__recording_stopped = True
timeout = time.time()+self.FINALIZE_TIMEOUT
while len(self.frame_deque) > 0 and time.time() < timeout: time.sleep(0.05)
self.should_run = False
while self.__finalized == False: time.sleep(0.05)
self.__opus_writer.close()
self.__opus_writer = None
def __digest_job(self):
with self.digest_lock:
from .Codecs.libs.pyogg import OpusBufferedEncoder
from .Codecs.libs.pyogg import OggOpusWriter
if not self.__output_path: raise ValueError("No recording file path configured")
self.max_bytes_per_frame = Opus.max_bytes_per_frame(self.bitrate_ceiling, self.frame_time*1000)
self.__encoder = OpusBufferedEncoder()
self.__encoder.set_application(self.application)
self.__encoder.set_sampling_frequency(self.samplerate)
self.__encoder.set_channels(self.channels)
self.__encoder.set_frame_size(int(self.frame_time*1000))
self.__encoder.set_max_bytes_per_frame(self.max_bytes_per_frame)
self.__opus_writer = OggOpusWriter(self.__output_path, self.__encoder)
final_silence_frames = 10
while self.should_run or final_silence_frames > 0:
frames_ready = len(self.frame_deque) or (self.should_run == False and final_silence_frames)
if frames_ready:
self.output_latency = len(self.frame_deque)*self.frame_time
self.max_latency = self.buffer_max_height*self.frame_time
self.underrun_at = None
if self.should_run:
with self.insert_lock: frame = self.frame_deque.popleft()
else:
final_silence_frames -= 1
frame = np.zeros((self.samples_per_frame, self.channels), dtype="float32")
if frame.shape[1] > self.channels: frame = frame[:, 0:self.channels]
elif frame.shape[1] < self.channels:
for i in range(self.channels - frame.shape[1]):
frame = np.hstack([frame, frame[:, -1:]])
if frame.shape[0] < self.samples_per_frame:
RNS.log("Insufficient frame data, padding with silence", RNS.LOG_DEBUG)
silence_frame = np.zeros((self.samples_per_frame-frame.shape[0], frame.shape[1]), dtype=frame.dtype)
frame = np.vstack([frame, silence_frame])
self.samples_written += frame.shape[0]
if self.samplerate != 48000:
frame = resample(frame, self.bitdepth, self.channels, self.samplerate, self.output_samplerate)
input_samples = frame*self.TYPE_MAP_FACTOR
input_samples = input_samples.astype(np.int16)
self.__opus_writer.write(bytearray(input_samples.tobytes()))
if len(self.frame_deque) > self.buffer_max_height: RNS.log(f"Buffer lag on {self} (height {len(self.frame_deque)})", RNS.LOG_DEBUG)
else:
if self.underrun_at == None: self.underrun_at = time.time()
else: time.sleep(self.frame_time*0.1)
self.__finalized = True

View file

@ -160,7 +160,10 @@ class LineSource(LocalSource):
MAX_FRAMES = 128 MAX_FRAMES = 128
DEFAULT_FRAME_MS = 80 DEFAULT_FRAME_MS = 80
def __init__(self, preferred_device=None, target_frame_ms=DEFAULT_FRAME_MS, codec=None, sink=None, filters=None): @staticmethod
def linear_gain(gain_db): return 10**(gain_db/10)
def __init__(self, preferred_device=None, target_frame_ms=DEFAULT_FRAME_MS, codec=None, sink=None, filters=None, gain=0.0, ease_in=0.0, skip=0.0):
self.preferred_device = preferred_device self.preferred_device = preferred_device
self.frame_deque = deque(maxlen=self.MAX_FRAMES) self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.target_frame_ms = target_frame_ms self.target_frame_ms = target_frame_ms
@ -174,11 +177,18 @@ class LineSource(LocalSource):
self.codec = codec self.codec = codec
self.sink = sink self.sink = sink
self.filters = None self.filters = None
self.ease_in = ease_in
self.gain = gain
self.__skip = skip
self.__gain = self.linear_gain(self.gain)
self.__target_gain = self.__gain
if filters != None: if filters != None:
if type(filters) == list: self.filters = filters if type(filters) == list: self.filters = filters
else: self.filters = [filters] else: self.filters = [filters]
if self.ease_in != 0.0: self.__gain = 0.0
@property @property
def codec(self): return self._codec def codec(self): return self._codec
@ -229,18 +239,34 @@ class LineSource(LocalSource):
else: backend_samples_per_frame = None else: backend_samples_per_frame = None
with self.backend.get_recorder(samples_per_frame=backend_samples_per_frame) as recorder: with self.backend.get_recorder(samples_per_frame=backend_samples_per_frame) as recorder:
started = time.time()
ease_in_completed = True if self.ease_in <= 0.0 else False
skip_completed = True if self.__skip <= 0.0 else False
while self.should_run: while self.should_run:
frame_samples = recorder.record(numframes=self.samples_per_frame) frame_samples = recorder.record(numframes=self.samples_per_frame)
if not skip_completed:
if time.time()-started > self.__skip:
skip_completed = True
started = time.time()
else:
if self.filters != None: if self.filters != None:
for f in self.filters: frame_samples = f.handle_frame(frame_samples, self.samplerate) for f in self.filters: frame_samples = f.handle_frame(frame_samples, self.samplerate)
if self.__gain != 1.0: frame_samples *= self.__gain
if self.codec: if self.codec:
frame = self.codec.encode(frame_samples) frame = self.codec.encode(frame_samples)
if self.sink and self.sink.can_receive(from_source=self): if self.sink and self.sink.can_receive(from_source=self):
self.sink.handle_frame(frame, self) self.sink.handle_frame(frame, self)
if not ease_in_completed:
d = time.time()-started
self.__gain = (d/self.ease_in)*self.__target_gain
if self.__gain >= self.__target_gain:
self.__gain = self.__target_gain
ease_in_completed = True
class OpusFileSource(LocalSource): class OpusFileSource(LocalSource):
MAX_FRAMES = 128 MAX_FRAMES = 128
DEFAULT_FRAME_MS = 70 DEFAULT_FRAME_MS = 100
TYPE_MAP_FACTOR = np.iinfo("int16").max TYPE_MAP_FACTOR = np.iinfo("int16").max
def __init__(self, file_path, target_frame_ms=DEFAULT_FRAME_MS, loop=False, codec=None, sink=None, timed=False): def __init__(self, file_path, target_frame_ms=DEFAULT_FRAME_MS, loop=False, codec=None, sink=None, timed=False):
@ -332,5 +358,3 @@ class OpusFileSource(LocalSource):
self.sink.handle_frame(frame, self) self.sink.handle_frame(frame, self)
else: else:
time.sleep(self.frame_time*0.1) time.sleep(self.frame_time*0.1)
class PacketSource(RemoteSource): pass

View file

@ -1 +1 @@
__version__ = "0.4.3" __version__ = "0.4.4"

22
examples/fileplayer.py Normal file
View file

@ -0,0 +1,22 @@
import sys
import time
import select
from LXST.Primitives.Players import FilePlayer
loop = False
if loop:
player = FilePlayer("./docs/speech_stereo.opus", loop=True)
player.start()
while player.running:
i, o, e = select.select([sys.stdin], [], [], 1.0)
if (i): player.stop()
else:
player = FilePlayer("./docs/speech_stereo.opus")
player.start()
while player.running: time.sleep(0.1)
print("Playback finished")

24
examples/filerecorder.py Normal file
View file

@ -0,0 +1,24 @@
import sys
import time
import select
from LXST.Primitives.Recorders import FileRecorder
filename = "recording.opus"
# With default profile (maximum quality)
recorder = FileRecorder(filename)
# Or, with specific profile
# from LXST.Codecs import Opus
# recorder = FileRecorder(filename, profile=Opus.PROFILE_VOICE_MEDIUM)
recorder.start()
print("Recording started")
try: input()
except KeyboardInterrupt: pass
recorder.stop()
print(f"Recording saved to {filename}")
time.sleep(0.2)