diff --git a/LXST/Codecs/Opus.py b/LXST/Codecs/Opus.py index f85defc..96a85ea 100644 --- a/LXST/Codecs/Opus.py +++ b/LXST/Codecs/Opus.py @@ -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.input_channels = self.channels - self.output_samplerate = 8000 - self.opus_encoder.set_application("voip") - elif profile == self.PROFILE_VOICE_MEDIUM: - 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}") + self.channels = self.profile_channels(profile) + self.input_channels = self.channels + self.output_samplerate = self.profile_samplerate(profile) + self.opus_encoder.set_application(self.profile_application(profile)) + self.profile = profile 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) diff --git a/LXST/Pipeline.py b/LXST/Pipeline.py index db1920d..6fd377b 100644 --- a/LXST/Pipeline.py +++ b/LXST/Pipeline.py @@ -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): diff --git a/LXST/Primitives/Players.py b/LXST/Primitives/Players.py new file mode 100644 index 0000000..381b7da --- /dev/null +++ b/LXST/Primitives/Players.py @@ -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() \ No newline at end of file diff --git a/LXST/Primitives/Recorders.py b/LXST/Primitives/Recorders.py new file mode 100644 index 0000000..b4c8d20 --- /dev/null +++ b/LXST/Primitives/Recorders.py @@ -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() \ No newline at end of file diff --git a/LXST/Primitives/Telephony.py b/LXST/Primitives/Telephony.py index b57c72e..e0aceae 100644 --- a/LXST/Primitives/Telephony.py +++ b/LXST/Primitives/Telephony.py @@ -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() diff --git a/LXST/Sinks.py b/LXST/Sinks.py index a110a66..06bd364 100644 --- a/LXST/Sinks.py +++ b/LXST/Sinks.py @@ -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 \ No newline at end of file +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 diff --git a/LXST/Sources.py b/LXST/Sources.py index 4da546b..7e894f2 100644 --- a/LXST/Sources.py +++ b/LXST/Sources.py @@ -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 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): - self.sink.handle_frame(frame, self) + 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 \ No newline at end of file diff --git a/LXST/_version.py b/LXST/_version.py index f6b7e26..cd1ee63 100644 --- a/LXST/_version.py +++ b/LXST/_version.py @@ -1 +1 @@ -__version__ = "0.4.3" +__version__ = "0.4.4" diff --git a/examples/fileplayer.py b/examples/fileplayer.py new file mode 100644 index 0000000..c0724f9 --- /dev/null +++ b/examples/fileplayer.py @@ -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") \ No newline at end of file diff --git a/examples/filerecorder.py b/examples/filerecorder.py new file mode 100644 index 0000000..e4b4c4b --- /dev/null +++ b/examples/filerecorder.py @@ -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) \ No newline at end of file