mirror of
https://github.com/markqvist/LXST.git
synced 2026-04-27 14:20:39 +00:00
Sync upstream
This commit is contained in:
parent
7d5f390a90
commit
68c2cf489c
10 changed files with 405 additions and 96 deletions
|
|
@ -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.input_channels = self.channels
|
||||||
self.channels = 1
|
self.output_samplerate = self.profile_samplerate(profile)
|
||||||
self.input_channels = self.channels
|
self.opus_encoder.set_application(self.profile_application(profile))
|
||||||
self.output_samplerate = 8000
|
self.profile = profile
|
||||||
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}")
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
71
LXST/Primitives/Players.py
Normal file
71
LXST/Primitives/Players.py
Normal 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()
|
||||||
53
LXST/Primitives/Recorders.py
Normal file
53
LXST/Primitives/Recorders.py
Normal 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()
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
133
LXST/Sinks.py
133
LXST/Sinks.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 self.filters != None:
|
if not skip_completed:
|
||||||
for f in self.filters: frame_samples = f.handle_frame(frame_samples, self.samplerate)
|
if time.time()-started > self.__skip:
|
||||||
if self.codec:
|
skip_completed = True
|
||||||
frame = self.codec.encode(frame_samples)
|
started = time.time()
|
||||||
if self.sink and self.sink.can_receive(from_source=self):
|
else:
|
||||||
self.sink.handle_frame(frame, self)
|
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):
|
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
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.4.3"
|
__version__ = "0.4.4"
|
||||||
|
|
|
||||||
22
examples/fileplayer.py
Normal file
22
examples/fileplayer.py
Normal 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
24
examples/filerecorder.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue