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.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):
if profile == self.PROFILE_VOICE_LOW:
self.profile = profile
self.channels = 1
self.channels = self.profile_channels(profile)
self.input_channels = self.channels
self.output_samplerate = 8000
self.opus_encoder.set_application("voip")
elif profile == self.PROFILE_VOICE_MEDIUM:
self.output_samplerate = self.profile_samplerate(profile)
self.opus_encoder.set_application(self.profile_application(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):
if self.profile == self.PROFILE_VOICE_LOW:
self.bitrate_ceiling = 6000
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
self.bitrate_ceiling = self.profile_bitrate_ceiling(self.profile)
max_bytes_per_frame = self.max_bytes_per_frame(self.bitrate_ceiling, frame_duration_ms)
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)
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.codec = codec
if isinstance(sink, Loopback):
sink.samplerate = source.samplerate
if isinstance(source, Loopback):
source._sink = sink
if isinstance(sink, Packetizer):
sink.source = source
if isinstance(sink, Loopback): sink.samplerate = source.samplerate
if isinstance(source, Loopback): source._sink = sink
if isinstance(sink, Packetizer): sink.source = source
if isinstance(sink, OpusFileSink): sink.source = source
@property
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)
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)]
self.__prepare_dialling_pipelines()

View file

@ -2,7 +2,10 @@ import RNS
import math
import time
import threading
import numpy as np
from collections import deque
from LXST.Codecs import Opus
from LXST.Codecs.Codec import resample_bytes, resample
class LinuxBackend():
SAMPLERATE = 48000
@ -119,7 +122,6 @@ class LineSink(LocalSink):
def __init__(self, preferred_device=None, autodigest=True, low_latency=False):
self.preferred_device = preferred_device
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.should_run = False
self.digest_thread = None
self.digest_lock = threading.Lock()
@ -215,4 +217,131 @@ class LineSink(LocalSink):
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
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.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.target_frame_ms = target_frame_ms
@ -174,11 +177,18 @@ class LineSource(LocalSource):
self.codec = codec
self.sink = sink
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 type(filters) == list: self.filters = filters
else: self.filters = [filters]
if self.ease_in != 0.0: self.__gain = 0.0
@property
def codec(self): return self._codec
@ -229,18 +239,34 @@ class LineSource(LocalSource):
else: backend_samples_per_frame = None
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:
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:
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:
frame = self.codec.encode(frame_samples)
if self.sink and self.sink.can_receive(from_source=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):
MAX_FRAMES = 128
DEFAULT_FRAME_MS = 70
DEFAULT_FRAME_MS = 100
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):
@ -332,5 +358,3 @@ class OpusFileSource(LocalSource):
self.sink.handle_frame(frame, self)
else:
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)