Public repo init

This commit is contained in:
Mark Qvist
2025-03-11 16:41:15 +01:00
commit 36246afe8c
64 changed files with 14604 additions and 0 deletions

10
.gitignore vendored Executable file
View File

@@ -0,0 +1,10 @@
*.DS_Store
*.pyc
testutils
build
dist
docs/build
lxst*.egg-info
examples/LXST
LXST/Utilities/LXST
RNS

403
LICENSE Normal file
View File

@@ -0,0 +1,403 @@
Attribution-NonCommercial-NoDerivatives 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-NoDerivatives 4.0
International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-NoDerivatives 4.0 International Public
License ("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
c. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
d. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
e. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
f. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
g. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
h. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce and reproduce, but not Share, Adapted Material
for NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material, You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
For the avoidance of doubt, You do not have permission under
this Public License to Share Adapted Material.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only and provided You do not Share Adapted Material;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

55
LXST/Call.py Normal file
View File

@@ -0,0 +1,55 @@
import RNS
from .Pipeline import Pipeline
from .Codecs import *
from .Sources import *
from .Sinks import *
from . import APP_NAME
class CallEndpoint():
def __init__(self, identity):
self.identity = identity
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "call", "endpoint")
self.destination.set_link_established_callback(self._incoming_call)
self.active_call = None
self.auto_answer = True
self.receive_pipeline = None
self.transmit_pipeline = None
self._incoming_call_callback = None
def announce(self):
if self.destination:
self.destination.announce()
@property
def incoming_call_callback(self):
return self._incoming_call_callback
@incoming_call_callback.setter
def incoming_call_callback(self, callback):
if callable(callback):
self._incoming_call_callback = callback
else:
raise TypeError(f"Invalid callback for {self}: Not callable")
def _incoming_call(self, link):
RNS.log(f"Incoming call on {self}", RNS.LOG_DEBUG)
if callable(self._incoming_call_callback):
self._incoming_call_callback(link)
def answer(self, call_link):
RNS.log(f"Answering call on {call_link}", RNS.LOG_DEBUG)
self.active_call = call_link
self.receive_pipeline = Pipeline(source=PacketSource(self),
codec=Opus(),
sink=LineSink())
self.transmit_pipeline = Pipeline(source=LineSource(target_frame_ms=target_frame_ms),
codec=Opus(),
sink=PacketSink(self))
def terminate(self):
self.receive_pipeline.stop()
self.transmit_pipeline.stop()
if self.active_call:
self.active_call.teardown()

62
LXST/Codecs/Codec.py Normal file
View File

@@ -0,0 +1,62 @@
import numpy as np
from .libs.pydub import AudioSegment
TYPE_MAP_FACTOR = np.iinfo("int16").max
class Codec():
preferred_samplerate = None
frame_quanta_ms = None
frame_max_ms = None
valid_frame_ms = None
source = None
sink = None
class CodecError(Exception):
pass
class Null(Codec):
def __init__(self):
pass
def encode(self, frame):
return frame
def decode(self, frame):
return frame
def resample_bytes(sample_bytes, bitdepth, channels, input_rate, output_rate, normalize=False):
sample_width = bitdepth//8
audio = AudioSegment(
sample_bytes,
frame_rate=input_rate,
sample_width=sample_width,
channels=channels)
if normalize:
audio = audio.apply_gain(-audio.max_dBFS)
resampled_audio = audio.set_frame_rate(output_rate)
resampled_bytes = resampled_audio.get_array_of_samples().tobytes()
# rate_factor = input_rate/output_rate
# input_samples = int(len(sample_bytes)/channels/sample_width)
# output_samples = int(len(resampled_bytes)/channels/sample_width)
# target_samples = int(input_samples/rate_factor)
# if output_samples < target_samples:
# print("Mismatch")
# add_samples = int(target_samples-output_samples)
# fill = resampled_bytes[-sample_width:]*add_samples
# resampled_bytes += fill
return resampled_bytes
def resample(input_samples, bitdepth, channels, input_rate, output_rate, normalize=False):
sample_width = bitdepth//8
input_samples = input_samples*TYPE_MAP_FACTOR
input_samples = input_samples.astype(np.int16)
resampled_bytes = resample_bytes(input_samples.tobytes(), bitdepth, channels, input_rate, output_rate, normalize)
output_samples = np.frombuffer(resampled_bytes, dtype=np.int16)/TYPE_MAP_FACTOR
output_samples = output_samples.reshape(output_samples.shape[0]//channels, channels)
output_samples = output_samples.astype(np.float32)
return output_samples

120
LXST/Codecs/Codec2.py Normal file
View File

@@ -0,0 +1,120 @@
import time
import math
import struct
import pycodec2
import numpy as np
from .Codec import Codec, CodecError, resample_bytes
# TODO: Remove debug
import RNS
class Codec2(Codec):
CODEC2_700C = 700
CODEC2_1200 = 1200
CODEC2_1300 = 1300
CODEC2_1400 = 1400
CODEC2_1600 = 1600
CODEC2_2400 = 2400
CODEC2_3200 = 3200
INPUT_RATE = 8000
OUTPUT_RATE = 8000
FRAME_QUANTA_MS = 40
TYPE_MAP_FACTOR = np.iinfo("int16").max
MODE_HEADERS = {CODEC2_700C: 0x00,
CODEC2_1200: 0x01,
CODEC2_1300: 0x02,
CODEC2_1400: 0x03,
CODEC2_1600: 0x04,
CODEC2_2400: 0x05,
CODEC2_3200: 0x06}
HEADER_MODES = {0x00: CODEC2_700C,
0x01: CODEC2_1200,
0x02: CODEC2_1300,
0x03: CODEC2_1400,
0x04: CODEC2_1600,
0x05: CODEC2_2400,
0x06: CODEC2_3200}
def __init__(self, mode=CODEC2_2400):
self.frame_quanta_ms = self.FRAME_QUANTA_MS
self.channels = 1
self.bitdepth = 16
self.c2 = None
self.output_samplerate = self.OUTPUT_RATE
self.set_mode(mode)
def set_mode(self, mode):
self.mode = mode
self.mode_header = self.MODE_HEADERS[self.mode].to_bytes()
self.c2 = pycodec2.Codec2(self.mode)
def encode(self, frame):
if frame.shape[1] == 0:
raise CodecError("Cannot encode frame with 0 channels")
elif frame.shape[1] > self.channels:
frame = frame[:, 1]
input_samples = frame*self.TYPE_MAP_FACTOR
input_samples = input_samples.astype(np.int16)
if self.source:
if self.source.samplerate != self.INPUT_RATE:
frame_bytes = input_samples.tobytes()
resampled_bytes = resample_bytes(frame_bytes, self.bitdepth, self.channels, self.source.samplerate, self.INPUT_RATE)
input_samples = np.frombuffer(resampled_bytes, dtype=np.int16)
SPF = self.c2.samples_per_frame()
N_FRAMES = math.floor(len(input_samples)/SPF)
input_frames = np.array(input_samples[0:N_FRAMES*SPF], dtype=np.int16)
encoded = b""
for pi in range(0, N_FRAMES):
pstart = pi*SPF
pend = (pi+1)*SPF
input_frame = input_frames[pstart:pend]
encoded_frame = self.c2.encode(input_frame)
encoded += encoded_frame
# TODO: Remove debug
# print(f"SPF : {SPF}")
# print(f"N_FRAMES : {N_FRAMES}")
return self.mode_header+encoded
def decode(self, frame_bytes):
frame_header = frame_bytes[0]
frame_bytes = frame_bytes[1:]
frame_mode = self.HEADER_MODES[frame_header]
if self.mode != frame_mode:
self.set_mode(frame_mode)
SPF = self.c2.samples_per_frame()
BPF = self.c2.bytes_per_frame()
STRUCT_FORMAT = f"{SPF}h"
N_FRAMES = math.floor(len(frame_bytes)/BPF)
# TODO: Remove debug
# print(f"BPF : {BPF}")
# print(f"N_FRAMES : {N_FRAMES}")
decoded = b""
for pi in range(0, N_FRAMES):
pstart = pi*BPF
pend = (pi+1)*BPF
encoded_frame = frame_bytes[pstart:pend]
decoded_frame = self.c2.decode(encoded_frame)
decoded += struct.pack(STRUCT_FORMAT, *decoded_frame)
if self.sink:
if self.sink.samplerate != self.OUTPUT_RATE:
decoded = resample_bytes(decoded, self.bitdepth, self.channels, self.OUTPUT_RATE, self.sink.samplerate)
decoded_samples = np.frombuffer(decoded, dtype="int16")/self.TYPE_MAP_FACTOR
frame_samples = np.zeros((len(decoded_samples), 1), dtype="float32")
frame_samples[:, 0] = decoded_samples
return frame_samples

179
LXST/Codecs/Opus.py Normal file
View File

@@ -0,0 +1,179 @@
import io
import RNS
import time
import math
import numpy as np
from .Codec import Codec, CodecError, resample_bytes
from .libs.pyogg import OpusEncoder, OpusDecoder
class Opus(Codec):
FRAME_QUANTA_MS = 2.5
FRAME_MAX_MS = 60
VALID_FRAME_MS = [2.5, 5, 10, 20, 40, 60]
TYPE_MAP_FACTOR = np.iinfo("int16").max
PROFILE_VOICE_LOW = 0x00
PROFILE_VOICE_MEDIUM = 0x01
PROFILE_VOICE_HIGH = 0x02
PROFILE_VOICE_MAX = 0x03
PROFILE_AUDIO_MIN = 0x04
PROFILE_AUDIO_LOW = 0x05
PROFILE_AUDIO_MEDIUM = 0x06
PROFILE_AUDIO_HIGH = 0x07
PROFILE_AUDIO_MAX = 0x08
def __init__(self, profile=PROFILE_VOICE_LOW):
self.frame_quanta_ms = self.FRAME_QUANTA_MS
self.frame_max_ms = self.FRAME_MAX_MS
self.valid_frame_ms = self.VALID_FRAME_MS
self.channels = 1
self.input_channels = 1
self.output_channels = 2
self.bitdepth = 16
self.opus_encoder = OpusEncoder()
self.opus_decoder = OpusDecoder()
self.encoder_configured = False
self.decoder_configured = False
self.bitrate_ceiling = 6000
self.output_bytes = 0
self.output_ms = 0
self.output_bitrate = 0
self.set_profile(profile)
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}")
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
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)
def encode(self, frame):
if frame.shape[1] == 0:
raise CodecError("Cannot encode frame with 0 channels")
elif frame.shape[1] > self.input_channels:
frame = frame[:, 0:self.input_channels]
elif frame.shape[1] < self.input_channels:
new_frame = np.zeros(shape=(frame.shape[0], self.input_channels))
for n in range(0, frame.shape[1]): new_frame[:, n] = frame[:, n]
for n in range(frame.shape[1], new_frame.shape[1]): new_frame[:, n] = frame[:, frame.shape[1]-1]
frame = new_frame
input_samples = frame*self.TYPE_MAP_FACTOR
input_samples = input_samples.astype(np.int16)
if self.source.samplerate != self.output_samplerate:
frame_bytes = input_samples.tobytes()
resampled_bytes = resample_bytes(frame_bytes, self.bitdepth, self.input_channels, self.source.samplerate, self.output_samplerate)
input_samples = np.frombuffer(resampled_bytes, dtype=np.int16)
input_samples = input_samples.reshape(len(input_samples)//self.input_channels, self.input_channels)
frame_duration_ms = (input_samples.shape[0]/self.output_samplerate)*1000
self.update_bitrate(frame_duration_ms)
if not self.encoder_configured:
self.input_channels = self.channels
self.opus_encoder.set_sampling_frequency(self.output_samplerate)
self.opus_encoder.set_channels(self.input_channels)
RNS.log(f"{self} encoder set to {self.input_channels} channels, {RNS.prettyfrequency(self.output_samplerate)}", RNS.LOG_DEBUG)
self.encoder_configured = True
input_bytes = input_samples.tobytes()
# TODO: Pad input bytes on partial frame
encoded_frame = self.opus_encoder.encode(input_bytes).tobytes()
self.output_bytes += len(encoded_frame)
self.output_ms += frame_duration_ms
self.output_bitrate = (self.output_bytes*8)/(self.output_ms/1000)
return encoded_frame
def decode(self, frame_bytes):
if not self.decoder_configured:
if self.sink and self.sink.channels: output_channels = self.sink.channels
else: output_channels = self.output_channels if self.output_channels > self.channels else self.channels
self.channels = output_channels
self.opus_decoder.set_channels(output_channels)
self.opus_decoder.set_sampling_frequency(self.sink.samplerate)
self.decoder_configured = True
RNS.log(f"{self} decoder set to {self.channels} channels, {RNS.prettyfrequency(self.sink.samplerate)}", RNS.LOG_DEBUG)
decoded_frame_bytes = self.opus_decoder.decode(memoryview(bytearray(frame_bytes)))
decoded_samples = np.frombuffer(decoded_frame_bytes, dtype="int16")/self.TYPE_MAP_FACTOR
frame_samples = decoded_samples.reshape(len(decoded_samples)//self.channels, self.channels)
return frame_samples

58
LXST/Codecs/Raw.py Normal file
View File

@@ -0,0 +1,58 @@
import RNS
import numpy as np
from .Codec import Codec
class Raw(Codec):
BITDEPTH_16 = 0x00
BITDEPTH_32 = 0x01
BITDEPTH_64 = 0x02
BITDEPTH_128 = 0x03
BITDEPTHS = ["float16", "float32", "float64", "float128"]
def __init__(self, channels=None, bitdepth=16):
if channels:
channels = min(max(channels, 1), 32)
self.bitdepth = bitdepth
self.channels = channels
if self.bitdepth >= 128:
self.dtype = self.BITDEPTHS[self.BITDEPTH_128]
self.header_bitdpeth = self.BITDEPTH_128
elif self.bitdepth >= 64:
self.dtype = self.BITDEPTHS[self.BITDEPTH_64]
self.header_bitdpeth = self.BITDEPTH_64
elif self.bitdepth >= 32:
self.dtype = self.BITDEPTHS[self.BITDEPTH_32]
self.header_bitdpeth = self.BITDEPTH_32
else:
self.dtype = self.BITDEPTHS[self.BITDEPTH_16]
self.header_bitdpeth = self.BITDEPTH_16
def encode(self, frame):
if self.channels == None:
self.channels = frame.shape[1]
RNS.log(f"{self} encoder set to {self.channels} channels", RNS.LOG_DEBUG)
if frame.shape[1] > self.channels:
frame = frame[:, range(0, self.channels)]
elif frame.shape[1] < self.channels:
new_frame = np.zeros(shape=(frame.shape[0], self.channels))
for n in range(0, frame.shape[1]): new_frame[:, n] = frame[:, n]
for n in range(frame.shape[1], new_frame.shape[1]): new_frame[:, n] = frame[:, frame.shape[1]-1]
frame = new_frame
frame_header = (self.header_bitdpeth << 6) | self.channels-1
frame_bytes = frame_header.to_bytes()+frame.astype(self.dtype).tobytes()
return frame_bytes
def decode(self, frame_bytes):
frame_header = frame_bytes[0]
frame_channels = (frame_header & 0b00111111)+1
frame_bitdepth = frame_header >> 6
frame_dtype = self.BITDEPTHS[frame_bitdepth]
frame_samples = np.frombuffer(frame_bytes[1:], dtype=frame_dtype)
frame_samples = frame_samples.reshape(len(frame_samples)//frame_channels, frame_channels)
if not self.channels: self.channels = frame_channels
return frame_samples

29
LXST/Codecs/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
from .Codec import CodecError as CodecError
from .Codec import Codec as Codec
from .Codec import Null as Null
from .Raw import Raw as Raw
from .Codec2 import Codec2 as Codec2
from .Opus import Opus as Opus
NULL = 0xFF
RAW = 0x00
OPUS = 0x01
CODEC2 = 0x02
def codec_header_byte(codec):
if codec == Raw:
return RAW.to_bytes()
elif codec == Opus:
return OPUS.to_bytes()
elif codec == Codec2:
return CODEC2.to_bytes()
raise TypeError(f"No header mapping for codec type {codec}")
def codec_type(header_byte):
if header_byte == RAW:
return Raw
elif header_byte == OPUS:
return Opus
elif header_byte == CODEC2:
return Codec2

View File

@@ -0,0 +1 @@
from .audio_segment import AudioSegment

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
import sys
import math
import array
from .utils import (
db_to_float,
ratio_to_db,
register_pydub_effect,
make_chunks,
audioop,
get_min_max_value
)
from .silence import split_on_silence
from .exceptions import TooManyMissingFrames, InvalidDuration
if sys.version_info >= (3, 0):
xrange = range
@register_pydub_effect
def apply_mono_filter_to_each_channel(seg, filter_fn):
n_channels = seg.channels
channel_segs = seg.split_to_mono()
channel_segs = [filter_fn(channel_seg) for channel_seg in channel_segs]
out_data = seg.get_array_of_samples()
for channel_i, channel_seg in enumerate(channel_segs):
for sample_i, sample in enumerate(channel_seg.get_array_of_samples()):
index = (sample_i * n_channels) + channel_i
out_data[index] = sample
return seg._spawn(out_data)
@register_pydub_effect
def normalize(seg, headroom=0.1):
"""
headroom is how close to the maximum volume to boost the signal up to (specified in dB)
"""
peak_sample_val = seg.max
# if the max is 0, this audio segment is silent, and can't be normalized
if peak_sample_val == 0:
return seg
target_peak = seg.max_possible_amplitude * db_to_float(-headroom)
needed_boost = ratio_to_db(target_peak / peak_sample_val)
return seg.apply_gain(needed_boost)
@register_pydub_effect
def speedup(seg, playback_speed=1.5, chunk_size=150, crossfade=25):
# we will keep audio in 150ms chunks since one waveform at 20Hz is 50ms long
# (20 Hz is the lowest frequency audible to humans)
# portion of AUDIO TO KEEP. if playback speed is 1.25 we keep 80% (0.8) and
# discard 20% (0.2)
atk = 1.0 / playback_speed
if playback_speed < 2.0:
# throwing out more than half the audio - keep 50ms chunks
ms_to_remove_per_chunk = int(chunk_size * (1 - atk) / atk)
else:
# throwing out less than half the audio - throw out 50ms chunks
ms_to_remove_per_chunk = int(chunk_size)
chunk_size = int(atk * chunk_size / (1 - atk))
# the crossfade cannot be longer than the amount of audio we're removing
crossfade = min(crossfade, ms_to_remove_per_chunk - 1)
# DEBUG
#print("chunk: {0}, rm: {1}".format(chunk_size, ms_to_remove_per_chunk))
chunks = make_chunks(seg, chunk_size + ms_to_remove_per_chunk)
if len(chunks) < 2:
raise Exception("Could not speed up AudioSegment, it was too short {2:0.2f}s for the current settings:\n{0}ms chunks at {1:0.1f}x speedup".format(
chunk_size, playback_speed, seg.duration_seconds))
# we'll actually truncate a bit less than we calculated to make up for the
# crossfade between chunks
ms_to_remove_per_chunk -= crossfade
# we don't want to truncate the last chunk since it is not guaranteed to be
# the full chunk length
last_chunk = chunks[-1]
chunks = [chunk[:-ms_to_remove_per_chunk] for chunk in chunks[:-1]]
out = chunks[0]
for chunk in chunks[1:]:
out = out.append(chunk, crossfade=crossfade)
out += last_chunk
return out
@register_pydub_effect
def strip_silence(seg, silence_len=1000, silence_thresh=-16, padding=100):
if padding > silence_len:
raise InvalidDuration("padding cannot be longer than silence_len")
chunks = split_on_silence(seg, silence_len, silence_thresh, padding)
crossfade = padding / 2
if not len(chunks):
return seg[0:0]
seg = chunks[0]
for chunk in chunks[1:]:
seg = seg.append(chunk, crossfade=crossfade)
return seg
@register_pydub_effect
def compress_dynamic_range(seg, threshold=-20.0, ratio=4.0, attack=5.0, release=50.0):
"""
Keyword Arguments:
threshold - default: -20.0
Threshold in dBFS. default of -20.0 means -20dB relative to the
maximum possible volume. 0dBFS is the maximum possible value so
all values for this argument sould be negative.
ratio - default: 4.0
Compression ratio. Audio louder than the threshold will be
reduced to 1/ratio the volume. A ratio of 4.0 is equivalent to
a setting of 4:1 in a pro-audio compressor like the Waves C1.
attack - default: 5.0
Attack in milliseconds. How long it should take for the compressor
to kick in once the audio has exceeded the threshold.
release - default: 50.0
Release in milliseconds. How long it should take for the compressor
to stop compressing after the audio has falled below the threshold.
For an overview of Dynamic Range Compression, and more detailed explanation
of the related terminology, see:
http://en.wikipedia.org/wiki/Dynamic_range_compression
"""
thresh_rms = seg.max_possible_amplitude * db_to_float(threshold)
look_frames = int(seg.frame_count(ms=attack))
def rms_at(frame_i):
return seg.get_sample_slice(frame_i - look_frames, frame_i).rms
def db_over_threshold(rms):
if rms == 0: return 0.0
db = ratio_to_db(rms / thresh_rms)
return max(db, 0)
output = []
# amount to reduce the volume of the audio by (in dB)
attenuation = 0.0
attack_frames = seg.frame_count(ms=attack)
release_frames = seg.frame_count(ms=release)
for i in xrange(int(seg.frame_count())):
rms_now = rms_at(i)
# with a ratio of 4.0 this means the volume will exceed the threshold by
# 1/4 the amount (of dB) that it would otherwise
max_attenuation = (1 - (1.0 / ratio)) * db_over_threshold(rms_now)
attenuation_inc = max_attenuation / attack_frames
attenuation_dec = max_attenuation / release_frames
if rms_now > thresh_rms and attenuation <= max_attenuation:
attenuation += attenuation_inc
attenuation = min(attenuation, max_attenuation)
else:
attenuation -= attenuation_dec
attenuation = max(attenuation, 0)
frame = seg.get_frame(i)
if attenuation != 0.0:
frame = audioop.mul(frame,
seg.sample_width,
db_to_float(-attenuation))
output.append(frame)
return seg._spawn(data=b''.join(output))
# Invert the phase of the signal.
@register_pydub_effect
def invert_phase(seg, channels=(1, 1)):
"""
channels- specifies which channel (left or right) to reverse the phase of.
Note that mono AudioSegments will become stereo.
"""
if channels == (1, 1):
inverted = audioop.mul(seg._data, seg.sample_width, -1.0)
return seg._spawn(data=inverted)
else:
if seg.channels == 2:
left, right = seg.split_to_mono()
else:
raise Exception("Can't implicitly convert an AudioSegment with " + str(seg.channels) + " channels to stereo.")
if channels == (1, 0):
left = left.invert_phase()
else:
right = right.invert_phase()
return seg.from_mono_audiosegments(left, right)
# High and low pass filters based on implementation found on Stack Overflow:
# http://stackoverflow.com/questions/13882038/implementing-simple-high-and-low-pass-filters-in-c
@register_pydub_effect
def low_pass_filter(seg, cutoff):
"""
cutoff - Frequency (in Hz) where higher frequency signal will begin to
be reduced by 6dB per octave (doubling in frequency) above this point
"""
RC = 1.0 / (cutoff * 2 * math.pi)
dt = 1.0 / seg.frame_rate
alpha = dt / (RC + dt)
original = seg.get_array_of_samples()
filteredArray = array.array(seg.array_type, original)
frame_count = int(seg.frame_count())
last_val = [0] * seg.channels
for i in range(seg.channels):
last_val[i] = filteredArray[i] = original[i]
for i in range(1, frame_count):
for j in range(seg.channels):
offset = (i * seg.channels) + j
last_val[j] = last_val[j] + (alpha * (original[offset] - last_val[j]))
filteredArray[offset] = int(last_val[j])
return seg._spawn(data=filteredArray)
@register_pydub_effect
def high_pass_filter(seg, cutoff):
"""
cutoff - Frequency (in Hz) where lower frequency signal will begin to
be reduced by 6dB per octave (doubling in frequency) below this point
"""
RC = 1.0 / (cutoff * 2 * math.pi)
dt = 1.0 / seg.frame_rate
alpha = RC / (RC + dt)
minval, maxval = get_min_max_value(seg.sample_width * 8)
original = seg.get_array_of_samples()
filteredArray = array.array(seg.array_type, original)
frame_count = int(seg.frame_count())
last_val = [0] * seg.channels
for i in range(seg.channels):
last_val[i] = filteredArray[i] = original[i]
for i in range(1, frame_count):
for j in range(seg.channels):
offset = (i * seg.channels) + j
offset_minus_1 = ((i-1) * seg.channels) + j
last_val[j] = alpha * (last_val[j] + original[offset] - original[offset_minus_1])
filteredArray[offset] = int(min(max(last_val[j], minval), maxval))
return seg._spawn(data=filteredArray)
@register_pydub_effect
def pan(seg, pan_amount):
"""
pan_amount should be between -1.0 (100% left) and +1.0 (100% right)
When pan_amount == 0.0 the left/right balance is not changed.
Panning does not alter the *perceived* loundness, but since loudness
is decreasing on one side, the other side needs to get louder to
compensate. When panned hard left, the left channel will be 3dB louder.
"""
if not -1.0 <= pan_amount <= 1.0:
raise ValueError("pan_amount should be between -1.0 (100% left) and +1.0 (100% right)")
max_boost_db = ratio_to_db(2.0)
boost_db = abs(pan_amount) * max_boost_db
boost_factor = db_to_float(boost_db)
reduce_factor = db_to_float(max_boost_db) - boost_factor
reduce_db = ratio_to_db(reduce_factor)
# Cut boost in half (max boost== 3dB) - in reality 2 speakers
# do not sum to a full 6 dB.
boost_db = boost_db / 2.0
if pan_amount < 0:
return seg.apply_gain_stereo(boost_db, reduce_db)
else:
return seg.apply_gain_stereo(reduce_db, boost_db)
@register_pydub_effect
def apply_gain_stereo(seg, left_gain=0.0, right_gain=0.0):
"""
left_gain - amount of gain to apply to the left channel (in dB)
right_gain - amount of gain to apply to the right channel (in dB)
note: mono audio segments will be converted to stereo
"""
if seg.channels == 1:
left = right = seg
elif seg.channels == 2:
left, right = seg.split_to_mono()
l_mult_factor = db_to_float(left_gain)
r_mult_factor = db_to_float(right_gain)
left_data = audioop.mul(left._data, left.sample_width, l_mult_factor)
left_data = audioop.tostereo(left_data, left.sample_width, 1, 0)
right_data = audioop.mul(right._data, right.sample_width, r_mult_factor)
right_data = audioop.tostereo(right_data, right.sample_width, 0, 1)
output = audioop.add(left_data, right_data, seg.sample_width)
return seg._spawn(data=output,
overrides={'channels': 2,
'frame_width': 2 * seg.sample_width})

View File

@@ -0,0 +1,32 @@
class PydubException(Exception):
"""
Base class for any Pydub exception
"""
class TooManyMissingFrames(PydubException):
pass
class InvalidDuration(PydubException):
pass
class InvalidTag(PydubException):
pass
class InvalidID3TagVersion(PydubException):
pass
class CouldntDecodeError(PydubException):
pass
class CouldntEncodeError(PydubException):
pass
class MissingAudioParameter(PydubException):
pass

View File

@@ -0,0 +1,142 @@
"""
Each generator will return float samples from -1.0 to 1.0, which can be
converted to actual audio with 8, 16, 24, or 32 bit depth using the
SiganlGenerator.to_audio_segment() method (on any of it's subclasses).
See Wikipedia's "waveform" page for info on some of the generators included
here: http://en.wikipedia.org/wiki/Waveform
"""
import math
import array
import itertools
import random
from .audio_segment import AudioSegment
from .utils import (
db_to_float,
get_frame_width,
get_array_type,
get_min_max_value
)
class SignalGenerator(object):
def __init__(self, sample_rate=44100, bit_depth=16):
self.sample_rate = sample_rate
self.bit_depth = bit_depth
def to_audio_segment(self, duration=1000.0, volume=0.0):
"""
Duration in milliseconds
(default: 1 second)
Volume in DB relative to maximum amplitude
(default 0.0 dBFS, which is the maximum value)
"""
minval, maxval = get_min_max_value(self.bit_depth)
sample_width = get_frame_width(self.bit_depth)
array_type = get_array_type(self.bit_depth)
gain = db_to_float(volume)
sample_count = int(self.sample_rate * (duration / 1000.0))
sample_data = (int(val * maxval * gain) for val in self.generate())
sample_data = itertools.islice(sample_data, 0, sample_count)
data = array.array(array_type, sample_data)
try:
data = data.tobytes()
except:
data = data.tostring()
return AudioSegment(data=data, metadata={
"channels": 1,
"sample_width": sample_width,
"frame_rate": self.sample_rate,
"frame_width": sample_width,
})
def generate(self):
raise NotImplementedError("SignalGenerator subclasses must implement the generate() method, and *should not* call the superclass implementation.")
class Sine(SignalGenerator):
def __init__(self, freq, **kwargs):
super(Sine, self).__init__(**kwargs)
self.freq = freq
def generate(self):
sine_of = (self.freq * 2 * math.pi) / self.sample_rate
sample_n = 0
while True:
yield math.sin(sine_of * sample_n)
sample_n += 1
class Pulse(SignalGenerator):
def __init__(self, freq, duty_cycle=0.5, **kwargs):
super(Pulse, self).__init__(**kwargs)
self.freq = freq
self.duty_cycle = duty_cycle
def generate(self):
sample_n = 0
# in samples
cycle_length = self.sample_rate / float(self.freq)
pulse_length = cycle_length * self.duty_cycle
while True:
if (sample_n % cycle_length) < pulse_length:
yield 1.0
else:
yield -1.0
sample_n += 1
class Square(Pulse):
def __init__(self, freq, **kwargs):
kwargs['duty_cycle'] = 0.5
super(Square, self).__init__(freq, **kwargs)
class Sawtooth(SignalGenerator):
def __init__(self, freq, duty_cycle=1.0, **kwargs):
super(Sawtooth, self).__init__(**kwargs)
self.freq = freq
self.duty_cycle = duty_cycle
def generate(self):
sample_n = 0
# in samples
cycle_length = self.sample_rate / float(self.freq)
midpoint = cycle_length * self.duty_cycle
ascend_length = midpoint
descend_length = cycle_length - ascend_length
while True:
cycle_position = sample_n % cycle_length
if cycle_position < midpoint:
yield (2 * cycle_position / ascend_length) - 1.0
else:
yield 1.0 - (2 * (cycle_position - midpoint) / descend_length)
sample_n += 1
class Triangle(Sawtooth):
def __init__(self, freq, **kwargs):
kwargs['duty_cycle'] = 0.5
super(Triangle, self).__init__(freq, **kwargs)
class WhiteNoise(SignalGenerator):
def generate(self):
while True:
yield (random.random() * 2) - 1.0

View File

@@ -0,0 +1,14 @@
"""
"""
import logging
converter_logger = logging.getLogger("pydub.converter")
def log_conversion(conversion_command):
converter_logger.debug("subprocess.call(%s)", repr(conversion_command))
def log_subprocess_output(output):
if output:
for line in output.rstrip().splitlines():
converter_logger.debug('subprocess output: %s', line.rstrip())

View File

@@ -0,0 +1,71 @@
"""
Support for playing AudioSegments. Pyaudio will be used if it's installed,
otherwise will fallback to ffplay. Pyaudio is a *much* nicer solution, but
is tricky to install. See my notes on installing pyaudio in a virtualenv (on
OSX 10.10): https://gist.github.com/jiaaro/9767512210a1d80a8a0d
"""
import subprocess
from tempfile import NamedTemporaryFile
from .utils import get_player_name, make_chunks
def _play_with_ffplay(seg):
PLAYER = get_player_name()
with NamedTemporaryFile("w+b", suffix=".wav") as f:
seg.export(f.name, "wav")
subprocess.call([PLAYER, "-nodisp", "-autoexit", "-hide_banner", f.name])
def _play_with_pyaudio(seg):
import pyaudio
p = pyaudio.PyAudio()
stream = p.open(format=p.get_format_from_width(seg.sample_width),
channels=seg.channels,
rate=seg.frame_rate,
output=True)
# Just in case there were any exceptions/interrupts, we release the resource
# So as not to raise OSError: Device Unavailable should play() be used again
try:
# break audio into half-second chunks (to allows keyboard interrupts)
for chunk in make_chunks(seg, 500):
stream.write(chunk._data)
finally:
stream.stop_stream()
stream.close()
p.terminate()
def _play_with_simpleaudio(seg):
import simpleaudio
return simpleaudio.play_buffer(
seg.raw_data,
num_channels=seg.channels,
bytes_per_sample=seg.sample_width,
sample_rate=seg.frame_rate
)
def play(audio_segment):
try:
playback = _play_with_simpleaudio(audio_segment)
try:
playback.wait_done()
except KeyboardInterrupt:
playback.stop()
except ImportError:
pass
else:
return
try:
_play_with_pyaudio(audio_segment)
return
except ImportError:
pass
else:
return
_play_with_ffplay(audio_segment)

View File

@@ -0,0 +1,591 @@
try:
from __builtin__ import max as builtin_max
from __builtin__ import min as builtin_min
except ImportError:
from builtins import max as builtin_max
from builtins import min as builtin_min
import math
import struct
try:
from fractions import gcd
except ImportError: # Python 3.9+
from math import gcd
from ctypes import create_string_buffer
class error(Exception):
pass
def _check_size(size):
if size != 1 and size != 2 and size != 4:
raise error("Size should be 1, 2 or 4")
def _check_params(length, size):
_check_size(size)
if length % size != 0:
raise error("not a whole number of frames")
def _sample_count(cp, size):
return len(cp) / size
def _get_samples(cp, size, signed=True):
for i in range(_sample_count(cp, size)):
yield _get_sample(cp, size, i, signed)
def _struct_format(size, signed):
if size == 1:
return "b" if signed else "B"
elif size == 2:
return "h" if signed else "H"
elif size == 4:
return "i" if signed else "I"
def _get_sample(cp, size, i, signed=True):
fmt = _struct_format(size, signed)
start = i * size
end = start + size
return struct.unpack_from(fmt, buffer(cp)[start:end])[0]
def _put_sample(cp, size, i, val, signed=True):
fmt = _struct_format(size, signed)
struct.pack_into(fmt, cp, i * size, val)
def _get_maxval(size, signed=True):
if signed and size == 1:
return 0x7f
elif size == 1:
return 0xff
elif signed and size == 2:
return 0x7fff
elif size == 2:
return 0xffff
elif signed and size == 4:
return 0x7fffffff
elif size == 4:
return 0xffffffff
def _get_minval(size, signed=True):
if not signed:
return 0
elif size == 1:
return -0x80
elif size == 2:
return -0x8000
elif size == 4:
return -0x80000000
def _get_clipfn(size, signed=True):
maxval = _get_maxval(size, signed)
minval = _get_minval(size, signed)
return lambda val: builtin_max(min(val, maxval), minval)
def _overflow(val, size, signed=True):
minval = _get_minval(size, signed)
maxval = _get_maxval(size, signed)
if minval <= val <= maxval:
return val
bits = size * 8
if signed:
offset = 2**(bits-1)
return ((val + offset) % (2**bits)) - offset
else:
return val % (2**bits)
def getsample(cp, size, i):
_check_params(len(cp), size)
if not (0 <= i < len(cp) / size):
raise error("Index out of range")
return _get_sample(cp, size, i)
def max(cp, size):
_check_params(len(cp), size)
if len(cp) == 0:
return 0
return builtin_max(abs(sample) for sample in _get_samples(cp, size))
def minmax(cp, size):
_check_params(len(cp), size)
max_sample, min_sample = 0, 0
for sample in _get_samples(cp, size):
max_sample = builtin_max(sample, max_sample)
min_sample = builtin_min(sample, min_sample)
return min_sample, max_sample
def avg(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
if sample_count == 0:
return 0
return sum(_get_samples(cp, size)) / sample_count
def rms(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
if sample_count == 0:
return 0
sum_squares = sum(sample**2 for sample in _get_samples(cp, size))
return int(math.sqrt(sum_squares / sample_count))
def _sum2(cp1, cp2, length):
size = 2
total = 0
for i in range(length):
total += getsample(cp1, size, i) * getsample(cp2, size, i)
return total
def findfit(cp1, cp2):
size = 2
if len(cp1) % 2 != 0 or len(cp2) % 2 != 0:
raise error("Strings should be even-sized")
if len(cp1) < len(cp2):
raise error("First sample should be longer")
len1 = _sample_count(cp1, size)
len2 = _sample_count(cp2, size)
sum_ri_2 = _sum2(cp2, cp2, len2)
sum_aij_2 = _sum2(cp1, cp1, len2)
sum_aij_ri = _sum2(cp1, cp2, len2)
result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2
best_result = result
best_i = 0
for i in range(1, len1 - len2 + 1):
aj_m1 = _get_sample(cp1, size, i - 1)
aj_lm1 = _get_sample(cp1, size, i + len2 - 1)
sum_aij_2 += aj_lm1**2 - aj_m1**2
sum_aij_ri = _sum2(buffer(cp1)[i*size:], cp2, len2)
result = (sum_ri_2 * sum_aij_2 - sum_aij_ri * sum_aij_ri) / sum_aij_2
if result < best_result:
best_result = result
best_i = i
factor = _sum2(buffer(cp1)[best_i*size:], cp2, len2) / sum_ri_2
return best_i, factor
def findfactor(cp1, cp2):
size = 2
if len(cp1) % 2 != 0:
raise error("Strings should be even-sized")
if len(cp1) != len(cp2):
raise error("Samples should be same size")
sample_count = _sample_count(cp1, size)
sum_ri_2 = _sum2(cp2, cp2, sample_count)
sum_aij_ri = _sum2(cp1, cp2, sample_count)
return sum_aij_ri / sum_ri_2
def findmax(cp, len2):
size = 2
sample_count = _sample_count(cp, size)
if len(cp) % 2 != 0:
raise error("Strings should be even-sized")
if len2 < 0 or sample_count < len2:
raise error("Input sample should be longer")
if sample_count == 0:
return 0
result = _sum2(cp, cp, len2)
best_result = result
best_i = 0
for i in range(1, sample_count - len2 + 1):
sample_leaving_window = getsample(cp, size, i - 1)
sample_entering_window = getsample(cp, size, i + len2 - 1)
result -= sample_leaving_window**2
result += sample_entering_window**2
if result > best_result:
best_result = result
best_i = i
return best_i
def avgpp(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
prevextremevalid = False
prevextreme = None
avg = 0
nextreme = 0
prevval = getsample(cp, size, 0)
val = getsample(cp, size, 1)
prevdiff = val - prevval
for i in range(1, sample_count):
val = getsample(cp, size, i)
diff = val - prevval
if diff * prevdiff < 0:
if prevextremevalid:
avg += abs(prevval - prevextreme)
nextreme += 1
prevextremevalid = True
prevextreme = prevval
prevval = val
if diff != 0:
prevdiff = diff
if nextreme == 0:
return 0
return avg / nextreme
def maxpp(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
prevextremevalid = False
prevextreme = None
max = 0
prevval = getsample(cp, size, 0)
val = getsample(cp, size, 1)
prevdiff = val - prevval
for i in range(1, sample_count):
val = getsample(cp, size, i)
diff = val - prevval
if diff * prevdiff < 0:
if prevextremevalid:
extremediff = abs(prevval - prevextreme)
if extremediff > max:
max = extremediff
prevextremevalid = True
prevextreme = prevval
prevval = val
if diff != 0:
prevdiff = diff
return max
def cross(cp, size):
_check_params(len(cp), size)
crossings = 0
last_sample = 0
for sample in _get_samples(cp, size):
if sample <= 0 < last_sample or sample >= 0 > last_sample:
crossings += 1
last_sample = sample
return crossings
def mul(cp, size, factor):
_check_params(len(cp), size)
clip = _get_clipfn(size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
sample = clip(int(sample * factor))
_put_sample(result, size, i, sample)
return result.raw
def tomono(cp, size, fac1, fac2):
_check_params(len(cp), size)
clip = _get_clipfn(size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp) / 2)
for i in range(0, sample_count, 2):
l_sample = getsample(cp, size, i)
r_sample = getsample(cp, size, i + 1)
sample = (l_sample * fac1) + (r_sample * fac2)
sample = clip(sample)
_put_sample(result, size, i / 2, sample)
return result.raw
def tostereo(cp, size, fac1, fac2):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp) * 2)
clip = _get_clipfn(size)
for i in range(sample_count):
sample = _get_sample(cp, size, i)
l_sample = clip(sample * fac1)
r_sample = clip(sample * fac2)
_put_sample(result, size, i * 2, l_sample)
_put_sample(result, size, i * 2 + 1, r_sample)
return result.raw
def add(cp1, cp2, size):
_check_params(len(cp1), size)
if len(cp1) != len(cp2):
raise error("Lengths should be the same")
clip = _get_clipfn(size)
sample_count = _sample_count(cp1, size)
result = create_string_buffer(len(cp1))
for i in range(sample_count):
sample1 = getsample(cp1, size, i)
sample2 = getsample(cp2, size, i)
sample = clip(sample1 + sample2)
_put_sample(result, size, i, sample)
return result.raw
def bias(cp, size, bias):
_check_params(len(cp), size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
sample = _overflow(sample + bias, size)
_put_sample(result, size, i, sample)
return result.raw
def reverse(cp, size):
_check_params(len(cp), size)
sample_count = _sample_count(cp, size)
result = create_string_buffer(len(cp))
for i, sample in enumerate(_get_samples(cp, size)):
_put_sample(result, size, sample_count - i - 1, sample)
return result.raw
def lin2lin(cp, size, size2):
_check_params(len(cp), size)
_check_size(size2)
if size == size2:
return cp
new_len = (len(cp) / size) * size2
result = create_string_buffer(new_len)
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
if size < size2:
sample = sample << (4 * size2 / size)
elif size > size2:
sample = sample >> (4 * size / size2)
sample = _overflow(sample, size2)
_put_sample(result, size2, i, sample)
return result.raw
def ratecv(cp, size, nchannels, inrate, outrate, state, weightA=1, weightB=0):
_check_params(len(cp), size)
if nchannels < 1:
raise error("# of channels should be >= 1")
bytes_per_frame = size * nchannels
frame_count = len(cp) / bytes_per_frame
if bytes_per_frame / nchannels != size:
raise OverflowError("width * nchannels too big for a C int")
if weightA < 1 or weightB < 0:
raise error("weightA should be >= 1, weightB should be >= 0")
if len(cp) % bytes_per_frame != 0:
raise error("not a whole number of frames")
if inrate <= 0 or outrate <= 0:
raise error("sampling rate not > 0")
d = gcd(inrate, outrate)
inrate /= d
outrate /= d
prev_i = [0] * nchannels
cur_i = [0] * nchannels
if state is None:
d = -outrate
else:
d, samps = state
if len(samps) != nchannels:
raise error("illegal state argument")
prev_i, cur_i = zip(*samps)
prev_i, cur_i = list(prev_i), list(cur_i)
q = frame_count / inrate
ceiling = (q + 1) * outrate
nbytes = ceiling * bytes_per_frame
result = create_string_buffer(nbytes)
samples = _get_samples(cp, size)
out_i = 0
while True:
while d < 0:
if frame_count == 0:
samps = zip(prev_i, cur_i)
retval = result.raw
# slice off extra bytes
trim_index = (out_i * bytes_per_frame) - len(retval)
retval = buffer(retval)[:trim_index]
return (retval, (d, tuple(samps)))
for chan in range(nchannels):
prev_i[chan] = cur_i[chan]
cur_i[chan] = samples.next()
cur_i[chan] = (
(weightA * cur_i[chan] + weightB * prev_i[chan])
/ (weightA + weightB)
)
frame_count -= 1
d += outrate
while d >= 0:
for chan in range(nchannels):
cur_o = (
(prev_i[chan] * d + cur_i[chan] * (outrate - d))
/ outrate
)
_put_sample(result, size, out_i, _overflow(cur_o, size))
out_i += 1
d -= inrate
def _sign(num):
return -1 if num < 0 else 0 if num == 0 else 1
def lin2ulaw(cp, size):
maxval = _get_maxval(size)
result = create_string_buffer(len(cp))
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
val = _sign(sample/maxval)*math.log(1+255*math.abs(sample/maxval))/math.log(1+255)
_put_sample(result, size, i, val)
return result
def ulaw2lin(cp, size):
maxval = _get_maxval(size)
result = create_string_buffer(len(cp))
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
val = (_sign(sample)*((1+255)**math.abs(sample)-1)/255) * maxval
_put_sample(result, size, i, val)
return result
def lin2alaw(cp, size):
maxval = _get_maxval(size)
result = create_string_buffer(len(cp))
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
val = None
if math.abs(sample/maxval) < 1/87.6:
val = _sign(sample/maxval)*87.6*math.abs(sample/maxval)/(1+math.log(87.6))
else:
val = _sign(sample/maxval)*(1+math.log(87.6*math.abs(sample/maxval)))/(1+math.log(87.6))
_put_sample(result, size, i, val)
return result
def alaw2lin(cp, size):
maxval = _get_maxval(size)
result = create_string_buffer(len(cp))
for i in range(_sample_count(cp, size)):
sample = _get_sample(cp, size, i)
val = None
if math.abs(sample) < 1/(1+math.log(87.6)):
val = _sign(sample) * math.abs(sample)*(1+math.log(87.6))/87.6 * maxval
else:
val = _sign(sample) * (math.e**(-1+math.abs(sample)*(1+math.log(87.6)))) / 87.6 * maxval
_put_sample(result, size, i, val)
return result
def lin2adpcm(cp, size, state):
raise NotImplementedError()
def adpcm2lin(cp, size, state):
raise NotImplementedError()

View File

@@ -0,0 +1,175 @@
"""
This module provides scipy versions of high_pass_filter, and low_pass_filter
as well as an additional band_pass_filter.
Of course, you will need to install scipy for these to work.
When this module is imported the high and low pass filters from this module
will be used when calling audio_segment.high_pass_filter() and
audio_segment.high_pass_filter() instead of the slower, less powerful versions
provided by pydub.effects.
"""
from scipy.signal import butter, sosfilt
from .utils import (register_pydub_effect,stereo_to_ms,ms_to_stereo)
def _mk_butter_filter(freq, type, order):
"""
Args:
freq: The cutoff frequency for highpass and lowpass filters. For
band filters, a list of [low_cutoff, high_cutoff]
type: "lowpass", "highpass", or "band"
order: nth order butterworth filter (default: 5th order). The
attenuation is -6dB/octave beyond the cutoff frequency (for 1st
order). A Higher order filter will have more attenuation, each level
adding an additional -6dB (so a 3rd order butterworth filter would
be -18dB/octave).
Returns:
function which can filter a mono audio segment
"""
def filter_fn(seg):
assert seg.channels == 1
nyq = 0.5 * seg.frame_rate
try:
freqs = [f / nyq for f in freq]
except TypeError:
freqs = freq / nyq
sos = butter(order, freqs, btype=type, output='sos')
y = sosfilt(sos, seg.get_array_of_samples())
return seg._spawn(y.astype(seg.array_type))
return filter_fn
@register_pydub_effect
def band_pass_filter(seg, low_cutoff_freq, high_cutoff_freq, order=5):
filter_fn = _mk_butter_filter([low_cutoff_freq, high_cutoff_freq], 'band', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def high_pass_filter(seg, cutoff_freq, order=5):
filter_fn = _mk_butter_filter(cutoff_freq, 'highpass', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def low_pass_filter(seg, cutoff_freq, order=5):
filter_fn = _mk_butter_filter(cutoff_freq, 'lowpass', order=order)
return seg.apply_mono_filter_to_each_channel(filter_fn)
@register_pydub_effect
def _eq(seg, focus_freq, bandwidth=100, mode="peak", gain_dB=0, order=2):
"""
Args:
focus_freq - middle frequency or known frequency of band (in Hz)
bandwidth - range of the equalizer band
mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf)
order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave)
Returns:
Equalized/Filtered AudioSegment
"""
filt_mode = ["peak", "low_shelf", "high_shelf"]
if mode not in filt_mode:
raise ValueError("Incorrect Mode Selection")
if gain_dB >= 0:
if mode == "peak":
sec = band_pass_filter(seg, focus_freq - bandwidth/2, focus_freq + bandwidth/2, order = order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if mode == "low_shelf":
sec = low_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if mode == "high_shelf":
sec = high_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 - gain_dB))
return seg
if gain_dB < 0:
if mode == "peak":
sec = high_pass_filter(seg, focus_freq - bandwidth/2, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
sec = low_pass_filter(seg, focus_freq + bandwidth/2, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
return seg
if mode == "low_shelf":
sec = high_pass_filter(seg, focus_freq, order=order)
seg = seg.overlay(sec - (3 + gain_dB)) + gain_dB
return seg
if mode=="high_shelf":
sec=low_pass_filter(seg, focus_freq, order=order)
seg=seg.overlay(sec - (3 + gain_dB)) +gain_dB
return seg
@register_pydub_effect
def eq(seg, focus_freq, bandwidth=100, channel_mode="L+R", filter_mode="peak", gain_dB=0, order=2):
"""
Args:
focus_freq - middle frequency or known frequency of band (in Hz)
bandwidth - range of the equalizer band
channel_mode - Select Channels to be affected by the filter.
L+R - Standard Stereo Filter
L - Only Left Channel is Filtered
R - Only Right Channel is Filtered
M+S - Blumlien Stereo Filter(Mid-Side)
M - Only Mid Channel is Filtered
S - Only Side Channel is Filtered
Mono Audio Segments are completely filtered.
filter_mode - Mode of Equalization(Peak/Notch(Bell Curve),High Shelf, Low Shelf)
order - Rolloff factor(1 - 6dB/Octave 2 - 12dB/Octave)
Returns:
Equalized/Filtered AudioSegment
"""
channel_modes = ["L+R", "M+S", "L", "R", "M", "S"]
if channel_mode not in channel_modes:
raise ValueError("Incorrect Channel Mode Selection")
if seg.channels == 1:
return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
if channel_mode == "L+R":
return _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
if channel_mode == "L":
seg = seg.split_to_mono()
seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]]
return AudioSegment.from_mono_audio_segements(seg[0], seg[1])
if channel_mode == "R":
seg = seg.split_to_mono()
seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)]
return AudioSegment.from_mono_audio_segements(seg[0], seg[1])
if channel_mode == "M+S":
seg = stereo_to_ms(seg)
seg = _eq(seg, focus_freq, bandwidth, filter_mode, gain_dB, order)
return ms_to_stereo(seg)
if channel_mode == "M":
seg = stereo_to_ms(seg).split_to_mono()
seg = [_eq(seg[0], focus_freq, bandwidth, filter_mode, gain_dB, order), seg[1]]
seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1])
return ms_to_stereo(seg)
if channel_mode == "S":
seg = stereo_to_ms(seg).split_to_mono()
seg = [seg[0], _eq(seg[1], focus_freq, bandwidth, filter_mode, gain_dB, order)]
seg = AudioSegment.from_mono_audio_segements(seg[0], seg[1])
return ms_to_stereo(seg)

View File

@@ -0,0 +1,182 @@
"""
Various functions for finding/manipulating silence in AudioSegments
"""
import itertools
from .utils import db_to_float
def detect_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1):
"""
Returns a list of all silent sections [start, end] in milliseconds of audio_segment.
Inverse of detect_nonsilent()
audio_segment - the segment to find silence in
min_silence_len - the minimum length for any silent section
silence_thresh - the upper bound for how quiet is silent in dFBS
seek_step - step size for interating over the segment in ms
"""
seg_len = len(audio_segment)
# you can't have a silent portion of a sound that is longer than the sound
if seg_len < min_silence_len:
return []
# convert silence threshold to a float value (so we can compare it to rms)
silence_thresh = db_to_float(silence_thresh) * audio_segment.max_possible_amplitude
# find silence and add start and end indicies to the to_cut list
silence_starts = []
# check successive (1 sec by default) chunk of sound for silence
# try a chunk at every "seek step" (or every chunk for a seek step == 1)
last_slice_start = seg_len - min_silence_len
slice_starts = range(0, last_slice_start + 1, seek_step)
# guarantee last_slice_start is included in the range
# to make sure the last portion of the audio is searched
if last_slice_start % seek_step:
slice_starts = itertools.chain(slice_starts, [last_slice_start])
for i in slice_starts:
audio_slice = audio_segment[i:i + min_silence_len]
if audio_slice.rms <= silence_thresh:
silence_starts.append(i)
# short circuit when there is no silence
if not silence_starts:
return []
# combine the silence we detected into ranges (start ms - end ms)
silent_ranges = []
prev_i = silence_starts.pop(0)
current_range_start = prev_i
for silence_start_i in silence_starts:
continuous = (silence_start_i == prev_i + seek_step)
# sometimes two small blips are enough for one particular slice to be
# non-silent, despite the silence all running together. Just combine
# the two overlapping silent ranges.
silence_has_gap = silence_start_i > (prev_i + min_silence_len)
if not continuous and silence_has_gap:
silent_ranges.append([current_range_start,
prev_i + min_silence_len])
current_range_start = silence_start_i
prev_i = silence_start_i
silent_ranges.append([current_range_start,
prev_i + min_silence_len])
return silent_ranges
def detect_nonsilent(audio_segment, min_silence_len=1000, silence_thresh=-16, seek_step=1):
"""
Returns a list of all nonsilent sections [start, end] in milliseconds of audio_segment.
Inverse of detect_silent()
audio_segment - the segment to find silence in
min_silence_len - the minimum length for any silent section
silence_thresh - the upper bound for how quiet is silent in dFBS
seek_step - step size for interating over the segment in ms
"""
silent_ranges = detect_silence(audio_segment, min_silence_len, silence_thresh, seek_step)
len_seg = len(audio_segment)
# if there is no silence, the whole thing is nonsilent
if not silent_ranges:
return [[0, len_seg]]
# short circuit when the whole audio segment is silent
if silent_ranges[0][0] == 0 and silent_ranges[0][1] == len_seg:
return []
prev_end_i = 0
nonsilent_ranges = []
for start_i, end_i in silent_ranges:
nonsilent_ranges.append([prev_end_i, start_i])
prev_end_i = end_i
if end_i != len_seg:
nonsilent_ranges.append([prev_end_i, len_seg])
if nonsilent_ranges[0] == [0, 0]:
nonsilent_ranges.pop(0)
return nonsilent_ranges
def split_on_silence(audio_segment, min_silence_len=1000, silence_thresh=-16, keep_silence=100,
seek_step=1):
"""
Returns list of audio segments from splitting audio_segment on silent sections
audio_segment - original pydub.AudioSegment() object
min_silence_len - (in ms) minimum length of a silence to be used for
a split. default: 1000ms
silence_thresh - (in dBFS) anything quieter than this will be
considered silence. default: -16dBFS
keep_silence - (in ms or True/False) leave some silence at the beginning
and end of the chunks. Keeps the sound from sounding like it
is abruptly cut off.
When the length of the silence is less than the keep_silence duration
it is split evenly between the preceding and following non-silent
segments.
If True is specified, all the silence is kept, if False none is kept.
default: 100ms
seek_step - step size for interating over the segment in ms
"""
# from the itertools documentation
def pairwise(iterable):
"s -> (s0,s1), (s1,s2), (s2, s3), ..."
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
if isinstance(keep_silence, bool):
keep_silence = len(audio_segment) if keep_silence else 0
output_ranges = [
[ start - keep_silence, end + keep_silence ]
for (start,end)
in detect_nonsilent(audio_segment, min_silence_len, silence_thresh, seek_step)
]
for range_i, range_ii in pairwise(output_ranges):
last_end = range_i[1]
next_start = range_ii[0]
if next_start < last_end:
range_i[1] = (last_end+next_start)//2
range_ii[0] = range_i[1]
return [
audio_segment[ max(start,0) : min(end,len(audio_segment)) ]
for start,end in output_ranges
]
def detect_leading_silence(sound, silence_threshold=-50.0, chunk_size=10):
"""
Returns the millisecond/index that the leading silence ends.
audio_segment - the segment to find silence in
silence_threshold - the upper bound for how quiet is silent in dFBS
chunk_size - chunk size for interating over the segment in ms
"""
trim_ms = 0 # ms
assert chunk_size > 0 # to avoid infinite loop
while sound[trim_ms:trim_ms+chunk_size].dBFS < silence_threshold and trim_ms < len(sound):
trim_ms += chunk_size
# if there is no end it should return the length of the segment
return min(trim_ms, len(sound))

View File

@@ -0,0 +1,434 @@
from __future__ import division
import json
import os
import re
import sys
from subprocess import Popen, PIPE
from math import log, ceil
from tempfile import TemporaryFile
from warnings import warn
from functools import wraps
try:
import audioop
except ImportError:
import pyaudioop as audioop
if sys.version_info >= (3, 0):
basestring = str
FRAME_WIDTHS = {
8: 1,
16: 2,
32: 4,
}
ARRAY_TYPES = {
8: "b",
16: "h",
32: "i",
}
ARRAY_RANGES = {
8: (-0x80, 0x7f),
16: (-0x8000, 0x7fff),
32: (-0x80000000, 0x7fffffff),
}
def get_frame_width(bit_depth):
return FRAME_WIDTHS[bit_depth]
def get_array_type(bit_depth, signed=True):
t = ARRAY_TYPES[bit_depth]
if not signed:
t = t.upper()
return t
def get_min_max_value(bit_depth):
return ARRAY_RANGES[bit_depth]
def _fd_or_path_or_tempfile(fd, mode='w+b', tempfile=True):
close_fd = False
if fd is None and tempfile:
fd = TemporaryFile(mode=mode)
close_fd = True
if isinstance(fd, basestring):
fd = open(fd, mode=mode)
close_fd = True
try:
if isinstance(fd, os.PathLike):
fd = open(fd, mode=mode)
close_fd = True
except AttributeError:
# module os has no attribute PathLike, so we're on python < 3.6.
# The protocol we're trying to support doesn't exist, so just pass.
pass
return fd, close_fd
def db_to_float(db, using_amplitude=True):
"""
Converts the input db to a float, which represents the equivalent
ratio in power.
"""
db = float(db)
if using_amplitude:
return 10 ** (db / 20)
else: # using power
return 10 ** (db / 10)
def ratio_to_db(ratio, val2=None, using_amplitude=True):
"""
Converts the input float to db, which represents the equivalent
to the ratio in power represented by the multiplier passed in.
"""
ratio = float(ratio)
# accept 2 values and use the ratio of val1 to val2
if val2 is not None:
ratio = ratio / val2
# special case for multiply-by-zero (convert to silence)
if ratio == 0:
return -float('inf')
if using_amplitude:
return 20 * log(ratio, 10)
else: # using power
return 10 * log(ratio, 10)
def register_pydub_effect(fn, name=None):
"""
decorator for adding pydub effects to the AudioSegment objects.
example use:
@register_pydub_effect
def normalize(audio_segment):
...
or you can specify a name:
@register_pydub_effect("normalize")
def normalize_audio_segment(audio_segment):
...
"""
if isinstance(fn, basestring):
name = fn
return lambda fn: register_pydub_effect(fn, name)
if name is None:
name = fn.__name__
from .audio_segment import AudioSegment
setattr(AudioSegment, name, fn)
return fn
def make_chunks(audio_segment, chunk_length):
"""
Breaks an AudioSegment into chunks that are <chunk_length> milliseconds
long.
if chunk_length is 50 then you'll get a list of 50 millisecond long audio
segments back (except the last one, which can be shorter)
"""
number_of_chunks = ceil(len(audio_segment) / float(chunk_length))
return [audio_segment[i * chunk_length:(i + 1) * chunk_length]
for i in range(int(number_of_chunks))]
def which(program):
"""
Mimics behavior of UNIX which command.
"""
# Add .exe program extension for windows support
if os.name == "nt" and not program.endswith(".exe"):
program += ".exe"
envdir_list = [os.curdir] + os.environ["PATH"].split(os.pathsep)
for envdir in envdir_list:
program_path = os.path.join(envdir, program)
if os.path.isfile(program_path) and os.access(program_path, os.X_OK):
return program_path
def get_encoder_name():
"""
Return enconder default application for system, either avconv or ffmpeg
"""
if which("avconv"):
return "avconv"
elif which("ffmpeg"):
return "ffmpeg"
else:
# should raise exception
warn("Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work", RuntimeWarning)
return "ffmpeg"
def get_player_name():
"""
Return enconder default application for system, either avconv or ffmpeg
"""
if which("avplay"):
return "avplay"
elif which("ffplay"):
return "ffplay"
else:
# should raise exception
warn("Couldn't find ffplay or avplay - defaulting to ffplay, but may not work", RuntimeWarning)
return "ffplay"
def get_prober_name():
"""
Return probe application, either avconv or ffmpeg
"""
if which("avprobe"):
return "avprobe"
elif which("ffprobe"):
return "ffprobe"
else:
# should raise exception
warn("Couldn't find ffprobe or avprobe - defaulting to ffprobe, but may not work", RuntimeWarning)
return "ffprobe"
def fsdecode(filename):
"""Wrapper for os.fsdecode which was introduced in python 3.2 ."""
if sys.version_info >= (3, 2):
PathLikeTypes = (basestring, bytes)
if sys.version_info >= (3, 6):
PathLikeTypes += (os.PathLike,)
if isinstance(filename, PathLikeTypes):
return os.fsdecode(filename)
else:
if isinstance(filename, bytes):
return filename.decode(sys.getfilesystemencoding())
if isinstance(filename, basestring):
return filename
raise TypeError("type {0} not accepted by fsdecode".format(type(filename)))
def get_extra_info(stderr):
"""
avprobe sometimes gives more information on stderr than
on the json output. The information has to be extracted
from stderr of the format of:
' Stream #0:0: Audio: flac, 88200 Hz, stereo, s32 (24 bit)'
or (macOS version):
' Stream #0:0: Audio: vorbis'
' 44100 Hz, stereo, fltp, 320 kb/s'
:type stderr: str
:rtype: list of dict
"""
extra_info = {}
re_stream = r'(?P<space_start> +)Stream #0[:\.](?P<stream_id>([0-9]+))(?P<content_0>.+)\n?(?! *Stream)((?P<space_end> +)(?P<content_1>.+))?'
for i in re.finditer(re_stream, stderr):
if i.group('space_end') is not None and len(i.group('space_start')) <= len(
i.group('space_end')):
content_line = ','.join([i.group('content_0'), i.group('content_1')])
else:
content_line = i.group('content_0')
tokens = [x.strip() for x in re.split('[:,]', content_line) if x]
extra_info[int(i.group('stream_id'))] = tokens
return extra_info
def mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "info",
"-show_format",
"-show_streams",
]
try:
command_args += [fsdecode(filepath)]
stdin_parameter = None
stdin_data = None
except TypeError:
if prober == 'ffprobe':
command_args += ["-read_ahead_limit", str(read_ahead_limit),
"cache:pipe:0"]
else:
command_args += ["-"]
stdin_parameter = PIPE
file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False)
file.seek(0)
stdin_data = file.read()
if close_file:
file.close()
command = [prober, '-of', 'json'] + command_args
res = Popen(command, stdin=stdin_parameter, stdout=PIPE, stderr=PIPE)
output, stderr = res.communicate(input=stdin_data)
output = output.decode("utf-8", 'ignore')
stderr = stderr.decode("utf-8", 'ignore')
info = json.loads(output)
if not info:
# If ffprobe didn't give any information, just return it
# (for example, because the file doesn't exist)
return info
extra_info = get_extra_info(stderr)
audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio']
if len(audio_streams) == 0:
return info
# We just operate on the first audio stream in case there are more
stream = audio_streams[0]
def set_property(stream, prop, value):
if prop not in stream or stream[prop] == 0:
stream[prop] = value
for token in extra_info[stream['index']]:
m = re.match(r'([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token)
m2 = re.match(r'([su]([0-9]{1,2})p?)( \(default\))?$', token)
if m:
set_property(stream, 'sample_fmt', m.group(1))
set_property(stream, 'bits_per_sample', int(m.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m.group(3)))
elif m2:
set_property(stream, 'sample_fmt', m2.group(1))
set_property(stream, 'bits_per_sample', int(m2.group(2)))
set_property(stream, 'bits_per_raw_sample', int(m2.group(2)))
elif re.match(r'(flt)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 32)
set_property(stream, 'bits_per_raw_sample', 32)
elif re.match(r'(dbl)p?( \(default\))?$', token):
set_property(stream, 'sample_fmt', token)
set_property(stream, 'bits_per_sample', 64)
set_property(stream, 'bits_per_raw_sample', 64)
return info
def mediainfo(filepath):
"""Return dictionary with media info(codec, duration, size, bitrate...) from filepath
"""
prober = get_prober_name()
command_args = [
"-v", "quiet",
"-show_format",
"-show_streams",
filepath
]
command = [prober, '-of', 'old'] + command_args
res = Popen(command, stdout=PIPE)
output = res.communicate()[0].decode("utf-8")
if res.returncode != 0:
command = [prober] + command_args
output = Popen(command, stdout=PIPE).communicate()[0].decode("utf-8")
rgx = re.compile(r"(?:(?P<inner_dict>.*?):)?(?P<key>.*?)\=(?P<value>.*?)$")
info = {}
if sys.platform == 'win32':
output = output.replace("\r", "")
for line in output.split("\n"):
# print(line)
mobj = rgx.match(line)
if mobj:
# print(mobj.groups())
inner_dict, key, value = mobj.groups()
if inner_dict:
try:
info[inner_dict]
except KeyError:
info[inner_dict] = {}
info[inner_dict][key] = value
else:
info[key] = value
return info
def cache_codecs(function):
cache = {}
@wraps(function)
def wrapper():
try:
return cache[0]
except:
cache[0] = function()
return cache[0]
return wrapper
@cache_codecs
def get_supported_codecs():
encoder = get_encoder_name()
command = [encoder, "-codecs"]
res = Popen(command, stdout=PIPE, stderr=PIPE)
output = res.communicate()[0].decode("utf-8")
if res.returncode != 0:
return []
if sys.platform == 'win32':
output = output.replace("\r", "")
rgx = re.compile(r"^([D.][E.][AVS.][I.][L.][S.]) (\w*) +(.*)")
decoders = set()
encoders = set()
for line in output.split('\n'):
match = rgx.match(line.strip())
if not match:
continue
flags, codec, name = match.groups()
if flags[0] == 'D':
decoders.add(codec)
if flags[1] == 'E':
encoders.add(codec)
return (decoders, encoders)
def get_supported_decoders():
return get_supported_codecs()[0]
def get_supported_encoders():
return get_supported_codecs()[1]
def stereo_to_ms(audio_segment):
'''
Left-Right -> Mid-Side
'''
channel = audio_segment.split_to_mono()
channel = [channel[0].overlay(channel[1]), channel[0].overlay(channel[1].invert_phase())]
return AudioSegment.from_mono_audiosegments(channel[0], channel[1])
def ms_to_stereo(audio_segment):
'''
Mid-Side -> Left-Right
'''
channel = audio_segment.split_to_mono()
channel = [channel[0].overlay(channel[1]) - 3, channel[0].overlay(channel[1].invert_phase()) - 3]
return AudioSegment.from_mono_audiosegments(channel[0], channel[1])

View File

@@ -0,0 +1,108 @@
import ctypes
from .pyogg_error import PyOggError
from .ogg import PYOGG_OGG_AVAIL
from .vorbis import PYOGG_VORBIS_AVAIL, PYOGG_VORBIS_FILE_AVAIL, PYOGG_VORBIS_ENC_AVAIL
from .opus import PYOGG_OPUS_AVAIL, PYOGG_OPUS_FILE_AVAIL, PYOGG_OPUS_ENC_AVAIL
from .flac import PYOGG_FLAC_AVAIL
#: PyOgg version number. Versions should comply with PEP440.
__version__ = '0.7'
if (PYOGG_OGG_AVAIL and PYOGG_VORBIS_AVAIL and PYOGG_VORBIS_FILE_AVAIL):
# VorbisFile
from .vorbis_file import VorbisFile
# VorbisFileStream
from .vorbis_file_stream import VorbisFileStream
else:
class VorbisFile: # type: ignore
def __init__(*args, **kw):
if not PYOGG_OGG_AVAIL:
raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
raise PyOggError("The Vorbis libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
class VorbisFileStream: # type: ignore
def __init__(*args, **kw):
if not PYOGG_OGG_AVAIL:
raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
raise PyOggError("The Vorbis libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if (PYOGG_OGG_AVAIL and PYOGG_OPUS_AVAIL and PYOGG_OPUS_FILE_AVAIL):
# OpusFile
from .opus_file import OpusFile
# OpusFileStream
from .opus_file_stream import OpusFileStream
else:
class OpusFile: # type: ignore
def __init__(*args, **kw):
if not PYOGG_OGG_AVAIL:
raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if not PYOGG_OPUS_AVAIL:
raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if not PYOGG_OPUS_FILE_AVAIL:
raise PyOggError("The OpusFile library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
raise PyOggError("Unknown initialisation error")
class OpusFileStream: # type: ignore
def __init__(*args, **kw):
if not PYOGG_OGG_AVAIL:
raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if not PYOGG_OPUS_AVAIL:
raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if not PYOGG_OPUS_FILE_AVAIL:
raise PyOggError("The OpusFile library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
raise PyOggError("Unknown initialisation error")
if PYOGG_OPUS_AVAIL:
# OpusEncoder
from .opus_encoder import OpusEncoder
# OpusBufferedEncoder
from .opus_buffered_encoder import OpusBufferedEncoder
# OpusDecoder
from .opus_decoder import OpusDecoder
else:
class OpusEncoder: # type: ignore
def __init__(*args, **kw):
raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
class OpusBufferedEncoder: # type: ignore
def __init__(*args, **kw):
raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
class OpusDecoder: # type: ignore
def __init__(*args, **kw):
raise PyOggError("The Opus library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if (PYOGG_OGG_AVAIL and PYOGG_OPUS_AVAIL):
# OggOpusWriter
from .ogg_opus_writer import OggOpusWriter
else:
class OggOpusWriter: # type: ignore
def __init__(*args, **kw):
if not PYOGG_OGG_AVAIL:
raise PyOggError("The Ogg library wasn't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
raise PyOggError("The Opus library was't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
if PYOGG_FLAC_AVAIL:
# FlacFile
from .flac_file import FlacFile
# FlacFileStream
from .flac_file_stream import FlacFileStream
else:
class FlacFile: # type: ignore
def __init__(*args, **kw):
raise PyOggError("The FLAC libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")
class FlacFileStream: # type: ignore
def __init__(*args, **kw):
raise PyOggError("The FLAC libraries weren't found or couldn't be loaded (maybe you're trying to use 64bit libraries with 32bit Python?)")

View File

@@ -0,0 +1,59 @@
from .pyogg_error import PyOggError
class AudioFile:
"""Abstract base class for audio files.
This class is a base class for audio files (such as Vorbis, Opus,
and FLAC). It should not be instatiated directly.
"""
def __init__(self):
raise PyOggError("AudioFile is an Abstract Base Class "+
"and should not be instantiated")
def as_array(self):
"""Returns the buffer as a NumPy array.
The shape of the returned array is in units of (number of
samples per channel, number of channels).
The data type is either 8-bit or 16-bit signed integers,
depending on bytes_per_sample.
The buffer is not copied, but rather the NumPy array
shares the memory with the buffer.
"""
# Assumes that self.buffer is a one-dimensional array of
# bytes and that channels are interleaved.
import numpy # type: ignore
assert self.buffer is not None
assert self.channels is not None
# The following code assumes that the bytes in the buffer
# represent 8-bit or 16-bit signed ints. Ensure the number of
# bytes per sample matches that assumption.
assert self.bytes_per_sample == 1 or self.bytes_per_sample == 2
# Create a dictionary mapping bytes per sample to numpy data
# types
dtype = {
1: numpy.int8,
2: numpy.int16
}
# Convert the ctypes buffer to a NumPy array
array = numpy.frombuffer(
self.buffer,
dtype=dtype[self.bytes_per_sample]
)
# Reshape the array
return array.reshape(
(len(self.buffer)
// self.bytes_per_sample
// self.channels,
self.channels)
)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import ctypes
from itertools import chain
from . import flac
from .audio_file import AudioFile
from .pyogg_error import PyOggError
def _to_char_p(string):
try:
return ctypes.c_char_p(string.encode("utf-8"))
except:
return ctypes.c_char_p(string)
def _resize_array(array, new_size):
return (array._type_*new_size).from_address(ctypes.addressof(array))
class FlacFile(AudioFile):
def write_callback(self, decoder, frame, buffer, client_data):
multi_channel_buf = _resize_array(buffer.contents, self.channels)
arr_size = frame.contents.header.blocksize
if frame.contents.header.channels >= 2:
arrays = []
for i in range(frame.contents.header.channels):
arr = ctypes.cast(multi_channel_buf[i], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents
arrays.append(arr[:])
arr = list(chain.from_iterable(zip(*arrays)))
self.buffer[self.buffer_pos : self.buffer_pos + len(arr)] = arr[:]
self.buffer_pos += len(arr)
else:
arr = ctypes.cast(multi_channel_buf[0], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents
self.buffer[self.buffer_pos : self.buffer_pos + arr_size] = arr[:]
self.buffer_pos += arr_size
return 0
def metadata_callback(self,decoder, metadata, client_data):
if not self.buffer:
self.total_samples = metadata.contents.data.stream_info.total_samples
self.channels = metadata.contents.data.stream_info.channels
Buffer = flac.FLAC__int16*(self.total_samples * self.channels)
self.buffer = Buffer()
self.frequency = metadata.contents.data.stream_info.sample_rate
def error_callback(self,decoder, status, client_data):
raise PyOggError("An error occured during the process of decoding. Status enum: {}".format(flac.FLAC__StreamDecoderErrorStatusEnum[status]))
def __init__(self, path):
self.decoder = flac.FLAC__stream_decoder_new()
self.client_data = ctypes.c_void_p()
#: Number of channels in audio file.
self.channels = None
#: Number of samples per second (per channel). For
# example, 44100.
self.frequency = None
self.total_samples = None
#: Raw PCM data from audio file.
self.buffer = None
self.buffer_pos = 0
write_callback_ = flac.FLAC__StreamDecoderWriteCallback(self.write_callback)
metadata_callback_ = flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback)
error_callback_ = flac.FLAC__StreamDecoderErrorCallback(self.error_callback)
init_status = flac.FLAC__stream_decoder_init_file(
self.decoder,
_to_char_p(path), # This will have an issue with Unicode filenames
write_callback_,
metadata_callback_,
error_callback_,
self.client_data
)
if init_status: # error
error = flac.FLAC__StreamDecoderInitStatusEnum[init_status]
raise PyOggError(
"An error occured when trying to open '{}': {}".format(path, error)
)
metadata_status = (flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder))
if not metadata_status: # error
raise PyOggError("An error occured when trying to decode the metadata of {}".format(path))
stream_status = (flac.FLAC__stream_decoder_process_until_end_of_stream(self.decoder))
if not stream_status: # error
raise PyOggError("An error occured when trying to decode the audio stream of {}".format(path))
flac.FLAC__stream_decoder_finish(self.decoder)
#: Length of buffer
self.buffer_length = len(self.buffer)
self.bytes_per_sample = ctypes.sizeof(flac.FLAC__int16) # See definition of Buffer in metadata_callback()
# Cast buffer to one-dimensional array of chars
CharBuffer = (
ctypes.c_byte *
(self.bytes_per_sample * len(self.buffer))
)
self.buffer = CharBuffer.from_buffer(self.buffer)
# FLAC audio is always signed. See
# https://xiph.org/flac/api/group__flac__stream__decoder.html#gaf98a4f9e2cac5747da6018c3dfc8dde1
self.signed = True

View File

@@ -0,0 +1,141 @@
import ctypes
from itertools import chain
from . import flac
from .pyogg_error import PyOggError
def _to_char_p(string):
try:
return ctypes.c_char_p(string.encode("utf-8"))
except:
return ctypes.c_char_p(string)
def _resize_array(array, new_size):
return (array._type_*new_size).from_address(ctypes.addressof(array))
class FlacFileStream:
def write_callback(self,decoder, frame, buffer, client_data):
multi_channel_buf = _resize_array(buffer.contents, self.channels)
arr_size = frame.contents.header.blocksize
if frame.contents.header.channels >= 2:
arrays = []
for i in range(frame.contents.header.channels):
arr = ctypes.cast(multi_channel_buf[i], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents
arrays.append(arr[:])
arr = list(chain.from_iterable(zip(*arrays)))
self.buffer = (flac.FLAC__int16*len(arr))(*arr)
self.bytes_written = len(arr) * 2
else:
arr = ctypes.cast(multi_channel_buf[0], ctypes.POINTER(flac.FLAC__int32*arr_size)).contents
self.buffer = (flac.FLAC__int16*len(arr))(*arr[:])
self.bytes_written = arr_size * 2
return 0
def metadata_callback(self,decoder, metadata, client_data):
self.total_samples = metadata.contents.data.stream_info.total_samples
self.channels = metadata.contents.data.stream_info.channels
self.frequency = metadata.contents.data.stream_info.sample_rate
def error_callback(self,decoder, status, client_data):
raise PyOggError("An error occured during the process of decoding. Status enum: {}".format(flac.FLAC__StreamDecoderErrorStatusEnum[status]))
def __init__(self, path):
self.decoder = flac.FLAC__stream_decoder_new()
self.client_data = ctypes.c_void_p()
#: Number of channels in audio file.
self.channels = None
#: Number of samples per second (per channel). For
# example, 44100.
self.frequency = None
self.total_samples = None
self.buffer = None
self.bytes_written = None
self.write_callback_ = flac.FLAC__StreamDecoderWriteCallback(self.write_callback)
self.metadata_callback_ = flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback)
self.error_callback_ = flac.FLAC__StreamDecoderErrorCallback(self.error_callback)
init_status = flac.FLAC__stream_decoder_init_file(self.decoder,
_to_char_p(path),
self.write_callback_,
self.metadata_callback_,
self.error_callback_,
self.client_data)
if init_status: # error
raise PyOggError("An error occured when trying to open '{}': {}".format(path, flac.FLAC__StreamDecoderInitStatusEnum[init_status]))
metadata_status = (flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder))
if not metadata_status: # error
raise PyOggError("An error occured when trying to decode the metadata of {}".format(path))
#: Bytes per sample
self.bytes_per_sample = 2
def get_buffer(self):
"""Returns the buffer.
Returns buffer (a bytes object) or None if all data has
been read from the file.
"""
# Attempt to read a single frame of audio
stream_status = (flac.FLAC__stream_decoder_process_single(self.decoder))
if not stream_status: # error
raise PyOggError("An error occured when trying to decode the audio stream of {}".format(path))
# Check if we encountered the end of the stream
if (flac.FLAC__stream_decoder_get_state(self.decoder) == 4): # end of stream
return None
buffer_as_bytes = bytes(self.buffer)
return buffer_as_bytes
def clean_up(self):
flac.FLAC__stream_decoder_finish(self.decoder)
def get_buffer_as_array(self):
"""Provides the buffer as a NumPy array.
Note that the underlying data type is 16-bit signed
integers.
Does not copy the underlying data, so the returned array
should either be processed or copied before the next call
to get_buffer() or get_buffer_as_array().
"""
import numpy # type: ignore
# Read the next samples from the stream
buf = self.get_buffer()
# Check if we've come to the end of the stream
if buf is None:
return None
# Convert the bytes buffer to a NumPy array
array = numpy.frombuffer(
buf,
dtype=numpy.int16
)
# Reshape the array
return array.reshape(
(len(buf)
// self.bytes_per_sample
// self.channels,
self.channels)
)

View File

@@ -0,0 +1,147 @@
import ctypes
import ctypes.util
import os
import sys
import platform
from typing import (
Optional,
Dict,
List
)
_here = os.path.dirname(__file__)
class ExternalLibraryError(Exception):
pass
architecture = platform.architecture()[0]
_windows_styles = ["{}", "lib{}", "lib{}_dynamic", "{}_dynamic"]
_other_styles = ["{}", "lib{}"]
if architecture == "32bit":
for arch_style in ["32bit", "32" "86", "win32", "x86", "_x86", "_32", "_win32", "_32bit"]:
for style in ["{}", "lib{}"]:
_windows_styles.append(style.format("{}"+arch_style))
elif architecture == "64bit":
for arch_style in ["64bit", "64" "86_64", "amd64", "win_amd64", "x86_64", "_x86_64", "_64", "_amd64", "_64bit"]:
for style in ["{}", "lib{}"]:
_windows_styles.append(style.format("{}"+arch_style))
run_tests = lambda lib, tests: [f(lib) for f in tests]
# Get the appropriate directory for the shared libraries depending
# on the current platform and architecture
platform_ = platform.system()
lib_dir = None
if platform_ == "Darwin":
lib_dir = "libs/macos"
elif platform_ == "Windows":
if architecture == "32bit":
lib_dir = "libs/win32"
elif architecture == "64bit":
lib_dir = "libs/win_amd64"
class Library:
@staticmethod
def load(names: Dict[str, str], paths: Optional[List[str]] = None, tests = []) -> Optional[ctypes.CDLL]:
lib = InternalLibrary.load(names, tests)
if lib is None:
lib = ExternalLibrary.load(names["external"], paths, tests)
return lib
class InternalLibrary:
@staticmethod
def load(names: Dict[str, str], tests) -> Optional[ctypes.CDLL]:
# If we do not have a library directory, give up immediately
if lib_dir is None:
return None
# Get the appropriate library filename given the platform
try:
name = names[platform_]
except KeyError:
return None
# Attempt to load the library from here
path = _here + "/" + lib_dir + "/" + name
try:
lib = ctypes.CDLL(path)
except OSError as e:
return None
# Check that the library passes the tests
if tests and all(run_tests(lib, tests)):
return lib
# Library failed tests
return None
# Cache of libraries that have already been loaded
_loaded_libraries: Dict[str, ctypes.CDLL] = {}
class ExternalLibrary:
@staticmethod
def load(name, paths = None, tests = []):
if name in _loaded_libraries:
return _loaded_libraries[name]
if sys.platform == "win32":
lib = ExternalLibrary.load_windows(name, paths, tests)
_loaded_libraries[name] = lib
return lib
else:
lib = ExternalLibrary.load_other(name, paths, tests)
_loaded_libraries[name] = lib
return lib
@staticmethod
def load_other(name, paths = None, tests = []):
os.environ["PATH"] += ";" + ";".join((os.getcwd(), _here))
if paths: os.environ["PATH"] += ";" + ";".join(paths)
for style in _other_styles:
candidate = style.format(name)
library = ctypes.util.find_library(candidate)
if library:
try:
lib = ctypes.CDLL(library)
if tests and all(run_tests(lib, tests)):
return lib
except:
pass
@staticmethod
def load_windows(name, paths = None, tests = []):
os.environ["PATH"] += ";" + ";".join((os.getcwd(), _here))
if paths: os.environ["PATH"] += ";" + ";".join(paths)
not_supported = [] # libraries that were found, but are not supported
for style in _windows_styles:
candidate = style.format(name)
library = ctypes.util.find_library(candidate)
if library:
try:
lib = ctypes.CDLL(library)
if tests and all(run_tests(lib, tests)):
return lib
not_supported.append(library)
except WindowsError:
pass
except OSError:
not_supported.append(library)
if not_supported:
raise ExternalLibraryError("library '{}' couldn't be loaded, because the following candidates were not supported:".format(name)
+ ("\n{}" * len(not_supported)).format(*not_supported))
raise ExternalLibraryError("library '{}' couldn't be loaded".format(name))

View File

@@ -0,0 +1,672 @@
############################################################
# Ogg license: #
############################################################
"""
Copyright (c) 2002, Xiph.org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import ctypes
from ctypes import c_int, c_int8, c_int16, c_int32, c_int64, c_uint, c_uint8, c_uint16, c_uint32, c_uint64, c_float, c_long, c_ulong, c_char, c_char_p, c_ubyte, c_longlong, c_ulonglong, c_size_t, c_void_p, c_double, POINTER, pointer, cast
import ctypes.util
import sys
from traceback import print_exc as _print_exc
import os
from .library_loader import Library, ExternalLibrary, ExternalLibraryError
def get_raw_libname(name):
name = os.path.splitext(name)[0].lower()
for x in "0123456789._- ":name=name.replace(x,"")
return name
# Define a function to convert strings to char-pointers. In Python 3
# all strings are Unicode, while in Python 2 they were ASCII-encoded.
# FIXME: Does PyOgg even support Python 2?
if sys.version_info.major > 2:
to_char_p = lambda s: s.encode('utf-8')
else:
to_char_p = lambda s: s
__here = os.getcwd()
libogg = None
try:
names = {
"Windows": "ogg.dll",
"Darwin": "libogg.0.dylib",
"external": "ogg"
}
libogg = Library.load(names, tests = [lambda lib: hasattr(lib, "oggpack_writeinit")])
except ExternalLibraryError:
pass
except:
_print_exc()
if libogg is not None:
PYOGG_OGG_AVAIL = True
else:
PYOGG_OGG_AVAIL = False
if PYOGG_OGG_AVAIL:
# Sanity check also satisfies mypy type checking
assert libogg is not None
# ctypes
c_ubyte_p = POINTER(c_ubyte)
c_uchar = c_ubyte
c_uchar_p = c_ubyte_p
c_float_p = POINTER(c_float)
c_float_p_p = POINTER(c_float_p)
c_float_p_p_p = POINTER(c_float_p_p)
c_char_p_p = POINTER(c_char_p)
c_int_p = POINTER(c_int)
c_long_p = POINTER(c_long)
# os_types
ogg_int16_t = c_int16
ogg_uint16_t = c_uint16
ogg_int32_t = c_int32
ogg_uint32_t = c_uint32
ogg_int64_t = c_int64
ogg_uint64_t = c_uint64
ogg_int64_t_p = POINTER(ogg_int64_t)
# ogg
class ogg_iovec_t(ctypes.Structure):
"""
Wrapper for:
typedef struct ogg_iovec_t;
"""
_fields_ = [("iov_base", c_void_p),
("iov_len", c_size_t)]
class oggpack_buffer(ctypes.Structure):
"""
Wrapper for:
typedef struct oggpack_buffer;
"""
_fields_ = [("endbyte", c_long),
("endbit", c_int),
("buffer", c_uchar_p),
("ptr", c_uchar_p),
("storage", c_long)]
class ogg_page(ctypes.Structure):
"""
Wrapper for:
typedef struct ogg_page;
"""
_fields_ = [("header", c_uchar_p),
("header_len", c_long),
("body", c_uchar_p),
("body_len", c_long)]
class ogg_stream_state(ctypes.Structure):
"""
Wrapper for:
typedef struct ogg_stream_state;
"""
_fields_ = [("body_data", c_uchar_p),
("body_storage", c_long),
("body_fill", c_long),
("body_returned", c_long),
("lacing_vals", c_int),
("granule_vals", ogg_int64_t),
("lacing_storage", c_long),
("lacing_fill", c_long),
("lacing_packet", c_long),
("lacing_returned", c_long),
("header", c_uchar*282),
("header_fill", c_int),
("e_o_s", c_int),
("b_o_s", c_int),
("serialno", c_long),
("pageno", c_long),
("packetno", ogg_int64_t),
("granulepos", ogg_int64_t)]
class ogg_packet(ctypes.Structure):
"""
Wrapper for:
typedef struct ogg_packet;
"""
_fields_ = [("packet", c_uchar_p),
("bytes", c_long),
("b_o_s", c_long),
("e_o_s", c_long),
("granulepos", ogg_int64_t),
("packetno", ogg_int64_t)]
def __str__(self):
bos = ""
if self.b_o_s:
bos = "beginning of stream, "
eos = ""
if self.e_o_s:
eos = "end of stream, "
# Converting the data will cause a seg-fault if the memory isn't valid
data = bytes(self.packet[0:self.bytes])
value = (
f"Ogg Packet <{hex(id(self))}>: " +
f"number {self.packetno}, " +
f"granule position {self.granulepos}, " +
bos + eos +
f"{self.bytes} bytes"
)
return value
class ogg_sync_state(ctypes.Structure):
"""
Wrapper for:
typedef struct ogg_sync_state;
"""
_fields_ = [("data", c_uchar_p),
("storage", c_int),
("fill", c_int),
("returned", c_int),
("unsynched", c_int),
("headerbytes", c_int),
("bodybytes", c_int)]
b_p = POINTER(oggpack_buffer)
oy_p = POINTER(ogg_sync_state)
op_p = POINTER(ogg_packet)
og_p = POINTER(ogg_page)
os_p = POINTER(ogg_stream_state)
iov_p = POINTER(ogg_iovec_t)
libogg.oggpack_writeinit.restype = None
libogg.oggpack_writeinit.argtypes = [b_p]
def oggpack_writeinit(b):
libogg.oggpack_writeinit(b)
try:
libogg.oggpack_writecheck.restype = c_int
libogg.oggpack_writecheck.argtypes = [b_p]
def oggpack_writecheck(b):
libogg.oggpack_writecheck(b)
except:
pass
libogg.oggpack_writetrunc.restype = None
libogg.oggpack_writetrunc.argtypes = [b_p, c_long]
def oggpack_writetrunc(b, bits):
libogg.oggpack_writetrunc(b, bits)
libogg.oggpack_writealign.restype = None
libogg.oggpack_writealign.argtypes = [b_p]
def oggpack_writealign(b):
libogg.oggpack_writealign(b)
libogg.oggpack_writecopy.restype = None
libogg.oggpack_writecopy.argtypes = [b_p, c_void_p, c_long]
def oggpack_writecopy(b, source, bits):
libogg.oggpack_writecopy(b, source, bits)
libogg.oggpack_reset.restype = None
libogg.oggpack_reset.argtypes = [b_p]
def oggpack_reset(b):
libogg.oggpack_reset(b)
libogg.oggpack_writeclear.restype = None
libogg.oggpack_writeclear.argtypes = [b_p]
def oggpack_writeclear(b):
libogg.oggpack_writeclear(b)
libogg.oggpack_readinit.restype = None
libogg.oggpack_readinit.argtypes = [b_p, c_uchar_p, c_int]
def oggpack_readinit(b, buf, bytes):
libogg.oggpack_readinit(b, buf, bytes)
libogg.oggpack_write.restype = None
libogg.oggpack_write.argtypes = [b_p, c_ulong, c_int]
def oggpack_write(b, value, bits):
libogg.oggpack_write(b, value, bits)
libogg.oggpack_look.restype = c_long
libogg.oggpack_look.argtypes = [b_p, c_int]
def oggpack_look(b, bits):
return libogg.oggpack_look(b, bits)
libogg.oggpack_look1.restype = c_long
libogg.oggpack_look1.argtypes = [b_p]
def oggpack_look1(b):
return libogg.oggpack_look1(b)
libogg.oggpack_adv.restype = None
libogg.oggpack_adv.argtypes = [b_p, c_int]
def oggpack_adv(b, bits):
libogg.oggpack_adv(b, bits)
libogg.oggpack_adv1.restype = None
libogg.oggpack_adv1.argtypes = [b_p]
def oggpack_adv1(b):
libogg.oggpack_adv1(b)
libogg.oggpack_read.restype = c_long
libogg.oggpack_read.argtypes = [b_p, c_int]
def oggpack_read(b, bits):
return libogg.oggpack_read(b, bits)
libogg.oggpack_read1.restype = c_long
libogg.oggpack_read1.argtypes = [b_p]
def oggpack_read1(b):
return libogg.oggpack_read1(b)
libogg.oggpack_bytes.restype = c_long
libogg.oggpack_bytes.argtypes = [b_p]
def oggpack_bytes(b):
return libogg.oggpack_bytes(b)
libogg.oggpack_bits.restype = c_long
libogg.oggpack_bits.argtypes = [b_p]
def oggpack_bits(b):
return libogg.oggpack_bits(b)
libogg.oggpack_get_buffer.restype = c_uchar_p
libogg.oggpack_get_buffer.argtypes = [b_p]
def oggpack_get_buffer(b):
return libogg.oggpack_get_buffer(b)
libogg.oggpackB_writeinit.restype = None
libogg.oggpackB_writeinit.argtypes = [b_p]
def oggpackB_writeinit(b):
libogg.oggpackB_writeinit(b)
try:
libogg.oggpackB_writecheck.restype = c_int
libogg.oggpackB_writecheck.argtypes = [b_p]
def oggpackB_writecheck(b):
return libogg.oggpackB_writecheck(b)
except:
pass
libogg.oggpackB_writetrunc.restype = None
libogg.oggpackB_writetrunc.argtypes = [b_p, c_long]
def oggpackB_writetrunc(b, bits):
libogg.oggpackB_writetrunc(b, bits)
libogg.oggpackB_writealign.restype = None
libogg.oggpackB_writealign.argtypes = [b_p]
def oggpackB_writealign(b):
libogg.oggpackB_writealign(b)
libogg.oggpackB_writecopy.restype = None
libogg.oggpackB_writecopy.argtypes = [b_p, c_void_p, c_long]
def oggpackB_writecopy(b, source, bits):
libogg.oggpackB_writecopy(b, source, bits)
libogg.oggpackB_reset.restype = None
libogg.oggpackB_reset.argtypes = [b_p]
def oggpackB_reset(b):
libogg.oggpackB_reset(b)
libogg.oggpackB_reset.restype = None
libogg.oggpackB_writeclear.argtypes = [b_p]
def oggpackB_reset(b):
libogg.oggpackB_reset(b)
libogg.oggpackB_readinit.restype = None
libogg.oggpackB_readinit.argtypes = [b_p, c_uchar_p, c_int]
def oggpackB_readinit(b, buf, bytes):
libogg.oggpackB_readinit(b, buf, bytes)
libogg.oggpackB_write.restype = None
libogg.oggpackB_write.argtypes = [b_p, c_ulong, c_int]
def oggpackB_write(b, value, bits):
libogg.oggpackB_write(b, value, bits)
libogg.oggpackB_look.restype = c_long
libogg.oggpackB_look.argtypes = [b_p, c_int]
def oggpackB_look(b, bits):
return libogg.oggpackB_look(b, bits)
libogg.oggpackB_look1.restype = c_long
libogg.oggpackB_look1.argtypes = [b_p]
def oggpackB_look1(b):
return libogg.oggpackB_look1(b)
libogg.oggpackB_adv.restype = None
libogg.oggpackB_adv.argtypes = [b_p, c_int]
def oggpackB_adv(b, bits):
libogg.oggpackB_adv(b, bits)
libogg.oggpackB_adv1.restype = None
libogg.oggpackB_adv1.argtypes = [b_p]
def oggpackB_adv1(b):
libogg.oggpackB_adv1(b)
libogg.oggpackB_read.restype = c_long
libogg.oggpackB_read.argtypes = [b_p, c_int]
def oggpackB_read(b, bits):
return libogg.oggpackB_read(b, bits)
libogg.oggpackB_read1.restype = c_long
libogg.oggpackB_read1.argtypes = [b_p]
def oggpackB_read1(b):
return libogg.oggpackB_read1(b)
libogg.oggpackB_bytes.restype = c_long
libogg.oggpackB_bytes.argtypes = [b_p]
def oggpackB_bytes(b):
return libogg.oggpackB_bytes(b)
libogg.oggpackB_bits.restype = c_long
libogg.oggpackB_bits.argtypes = [b_p]
def oggpackB_bits(b):
return libogg.oggpackB_bits(b)
libogg.oggpackB_get_buffer.restype = c_uchar_p
libogg.oggpackB_get_buffer.argtypes = [b_p]
def oggpackB_get_buffer(b):
return libogg.oggpackB_get_buffer(b)
libogg.ogg_stream_packetin.restype = c_int
libogg.ogg_stream_packetin.argtypes = [os_p, op_p]
def ogg_stream_packetin(os, op):
return libogg.ogg_stream_packetin(os, op)
try:
libogg.ogg_stream_iovecin.restype = c_int
libogg.ogg_stream_iovecin.argtypes = [os_p, iov_p, c_int, c_long, ogg_int64_t]
def ogg_stream_iovecin(os, iov, count, e_o_s, granulepos):
return libogg.ogg_stream_iovecin(os, iov, count, e_o_s, granulepos)
except:
pass
libogg.ogg_stream_pageout.restype = c_int
libogg.ogg_stream_pageout.argtypes = [os_p, og_p]
def ogg_stream_pageout(os, og):
return libogg.ogg_stream_pageout(os, og)
try:
libogg.ogg_stream_pageout_fill.restype = c_int
libogg.ogg_stream_pageout_fill.argtypes = [os_p, og_p, c_int]
def ogg_stream_pageout_fill(os, og, nfill):
return libogg.ogg_stream_pageout_fill(os, og, nfill)
except:
pass
libogg.ogg_stream_flush.restype = c_int
libogg.ogg_stream_flush.argtypes = [os_p, og_p]
def ogg_stream_flush(os, og):
return libogg.ogg_stream_flush(os, og)
try:
libogg.ogg_stream_flush_fill.restype = c_int
libogg.ogg_stream_flush_fill.argtypes = [os_p, og_p, c_int]
def ogg_stream_flush_fill(os, og, nfill):
return libogg.ogg_stream_flush_fill(os, og, nfill)
except:
pass
libogg.ogg_sync_init.restype = c_int
libogg.ogg_sync_init.argtypes = [oy_p]
def ogg_sync_init(oy):
return libogg.ogg_sync_init(oy)
libogg.ogg_sync_clear.restype = c_int
libogg.ogg_sync_clear.argtypes = [oy_p]
def ogg_sync_clear(oy):
return libogg.ogg_sync_clear(oy)
libogg.ogg_sync_reset.restype = c_int
libogg.ogg_sync_reset.argtypes = [oy_p]
def ogg_sync_reset(oy):
return libogg.ogg_sync_reset(oy)
libogg.ogg_sync_destroy.restype = c_int
libogg.ogg_sync_destroy.argtypes = [oy_p]
def ogg_sync_destroy(oy):
return libogg.ogg_sync_destroy(oy)
try:
libogg.ogg_sync_check.restype = c_int
libogg.ogg_sync_check.argtypes = [oy_p]
def ogg_sync_check(oy):
return libogg.ogg_sync_check(oy)
except:
pass
libogg.ogg_sync_buffer.restype = c_char_p
libogg.ogg_sync_buffer.argtypes = [oy_p, c_long]
def ogg_sync_buffer(oy, size):
return libogg.ogg_sync_buffer(oy, size)
libogg.ogg_sync_wrote.restype = c_int
libogg.ogg_sync_wrote.argtypes = [oy_p, c_long]
def ogg_sync_wrote(oy, bytes):
return libogg.ogg_sync_wrote(oy, bytes)
libogg.ogg_sync_pageseek.restype = c_int
libogg.ogg_sync_pageseek.argtypes = [oy_p, og_p]
def ogg_sync_pageseek(oy, og):
return libogg.ogg_sync_pageseek(oy, og)
libogg.ogg_sync_pageout.restype = c_long
libogg.ogg_sync_pageout.argtypes = [oy_p, og_p]
def ogg_sync_pageout(oy, og):
return libogg.ogg_sync_pageout(oy, og)
libogg.ogg_stream_pagein.restype = c_int
libogg.ogg_stream_pagein.argtypes = [os_p, og_p]
def ogg_stream_pagein(os, og):
return libogg.ogg_stream_pagein(oy, og)
libogg.ogg_stream_packetout.restype = c_int
libogg.ogg_stream_packetout.argtypes = [os_p, op_p]
def ogg_stream_packetout(os, op):
return libogg.ogg_stream_packetout(oy, op)
libogg.ogg_stream_packetpeek.restype = c_int
libogg.ogg_stream_packetpeek.argtypes = [os_p, op_p]
def ogg_stream_packetpeek(os, op):
return libogg.ogg_stream_packetpeek(os, op)
libogg.ogg_stream_init.restype = c_int
libogg.ogg_stream_init.argtypes = [os_p, c_int]
def ogg_stream_init(os, serialno):
return libogg.ogg_stream_init(os, serialno)
libogg.ogg_stream_clear.restype = c_int
libogg.ogg_stream_clear.argtypes = [os_p]
def ogg_stream_clear(os):
return libogg.ogg_stream_clear(os)
libogg.ogg_stream_reset.restype = c_int
libogg.ogg_stream_reset.argtypes = [os_p]
def ogg_stream_reset(os):
return libogg.ogg_stream_reset(os)
libogg.ogg_stream_reset_serialno.restype = c_int
libogg.ogg_stream_reset_serialno.argtypes = [os_p, c_int]
def ogg_stream_reset_serialno(os, serialno):
return libogg.ogg_stream_reset_serialno(os, serialno)
libogg.ogg_stream_destroy.restype = c_int
libogg.ogg_stream_destroy.argtypes = [os_p]
def ogg_stream_destroy(os):
return libogg.ogg_stream_destroy(os)
try:
libogg.ogg_stream_check.restype = c_int
libogg.ogg_stream_check.argtypes = [os_p]
def ogg_stream_check(os):
return libogg.ogg_stream_check(os)
except:
pass
libogg.ogg_stream_eos.restype = c_int
libogg.ogg_stream_eos.argtypes = [os_p]
def ogg_stream_eos(os):
return libogg.ogg_stream_eos(os)
libogg.ogg_page_checksum_set.restype = None
libogg.ogg_page_checksum_set.argtypes = [og_p]
def ogg_page_checksum_set(og):
libogg.ogg_page_checksum_set(og)
libogg.ogg_page_version.restype = c_int
libogg.ogg_page_version.argtypes = [og_p]
def ogg_page_version(og):
return libogg.ogg_page_version(og)
libogg.ogg_page_continued.restype = c_int
libogg.ogg_page_continued.argtypes = [og_p]
def ogg_page_continued(og):
return libogg.ogg_page_continued(og)
libogg.ogg_page_bos.restype = c_int
libogg.ogg_page_bos.argtypes = [og_p]
def ogg_page_bos(og):
return libogg.ogg_page_bos(og)
libogg.ogg_page_eos.restype = c_int
libogg.ogg_page_eos.argtypes = [og_p]
def ogg_page_eos(og):
return libogg.ogg_page_eos(og)
libogg.ogg_page_granulepos.restype = ogg_int64_t
libogg.ogg_page_granulepos.argtypes = [og_p]
def ogg_page_granulepos(og):
return libogg.ogg_page_granulepos(og)
libogg.ogg_page_serialno.restype = c_int
libogg.ogg_page_serialno.argtypes = [og_p]
def ogg_page_serialno(og):
return libogg.ogg_page_serialno(og)
libogg.ogg_page_pageno.restype = c_long
libogg.ogg_page_pageno.argtypes = [og_p]
def ogg_page_pageno(og):
return libogg.ogg_page_pageno(og)
libogg.ogg_page_packets.restype = c_int
libogg.ogg_page_packets.argtypes = [og_p]
def ogg_page_packets(og):
return libogg.ogg_page_packets(og)
libogg.ogg_packet_clear.restype = None
libogg.ogg_packet_clear.argtypes = [op_p]
def ogg_packet_clear(op):
libogg.ogg_packet_clear(op)

View File

@@ -0,0 +1,421 @@
import builtins
import copy
import ctypes
import random
import struct
from typing import (
Optional,
Union,
BinaryIO
)
from . import ogg
from . import opus
from .opus_buffered_encoder import OpusBufferedEncoder
#from .opus_encoder import OpusEncoder
from .pyogg_error import PyOggError
class OggOpusWriter():
"""Encodes PCM data into an OggOpus file."""
def __init__(self,
f: Union[BinaryIO, str],
encoder: OpusBufferedEncoder,
custom_pre_skip: Optional[int] = None) -> None:
"""Construct an OggOpusWriter.
f may be either a string giving the path to the file, or
an already-opened file handle.
If f is an already-opened file handle, then it is the
user's responsibility to close the file when they are
finished with it. The file should be opened for writing
in binary (not text) mode.
The encoder should be a
OpusBufferedEncoder and should be fully configured before the
first call to the `write()` method.
The Opus encoder requires an amount of "warm up" and when
stored in an Ogg container that warm up can be skipped. When
`custom_pre_skip` is None, the required amount of warm up
silence is automatically calculated and inserted. If a custom
(non-silent) pre-skip is desired, then `custom_pre_skip`
should be specified as the number of samples (per channel).
It is then the user's responsibility to pass the non-silent
pre-skip samples to `encode()`.
"""
# Store the Opus encoder
self._encoder = encoder
# Store the custom pre skip
self._custom_pre_skip = custom_pre_skip
# Create a new stream state with a random serial number
self._stream_state = self._create_stream_state()
# Create a packet (reused for each pass)
self._ogg_packet = ogg.ogg_packet()
self._packet_valid = False
# Create a page (reused for each pass)
self._ogg_page = ogg.ogg_page()
# Counter for the number of packets written into Ogg stream
self._count_packets = 0
# Counter for the number of samples encoded into Opus
# packets
self._count_samples = 0
# Flag to indicate if the headers have been written
self._headers_written = False
# Flag to indicate that the stream has been finished (the
# EOS bit was set in a final packet)
self._finished = False
# Reference to the current encoded packet (written only
# when we know if it the last)
self._current_encoded_packet: Optional[bytes] = None
# Open file if required. Given this may raise an exception,
# it should be the last step of initialisation.
self._i_opened_the_file = False
if isinstance(f, str):
self._file = builtins.open(f, 'wb')
self._i_opened_the_file = True
else:
# Assume it's already opened file
self._file = f
def __del__(self) -> None:
if not self._finished:
self.close()
#
# User visible methods
#
def write(self, pcm: memoryview) -> None:
"""Encode the PCM and write out the Ogg Opus stream.
Encoders the PCM using the provided encoder.
"""
# Check that the stream hasn't already been finished
if self._finished:
raise PyOggError(
"Stream has already ended. Perhaps close() was "+
"called too early?")
# If we haven't already written out the headers, do so
# now. Then, write a frame of silence to warm up the
# encoder.
if not self._headers_written:
pre_skip = self._write_headers(self._custom_pre_skip)
if self._custom_pre_skip is None:
self._write_silence(pre_skip)
# Call the internal method to encode the bytes
self._write_to_oggopus(pcm)
def _write_to_oggopus(self, pcm: memoryview, flush: bool = False) -> None:
assert self._encoder is not None
def handle_encoded_packet(encoded_packet: memoryview,
samples: int,
end_of_stream: bool) -> None:
# Cast memoryview to ctypes Array
Buffer = ctypes.c_ubyte * len(encoded_packet)
encoded_packet_ctypes = Buffer.from_buffer(encoded_packet)
# Obtain a pointer to the encoded packet
encoded_packet_ptr = ctypes.cast(
encoded_packet_ctypes,
ctypes.POINTER(ctypes.c_ubyte)
)
# Increase the count of the number of samples written
self._count_samples += samples
# Place data into the packet
self._ogg_packet.packet = encoded_packet_ptr
self._ogg_packet.bytes = len(encoded_packet)
self._ogg_packet.b_o_s = 0
self._ogg_packet.e_o_s = end_of_stream
self._ogg_packet.granulepos = self._count_samples
self._ogg_packet.packetno = self._count_packets
# Increase the counter of the number of packets
# in the stream
self._count_packets += 1
# Write the packet into the stream
self._write_packet()
# Encode the PCM data into an Opus packet
self._encoder.buffered_encode(
pcm,
flush=flush,
callback=handle_encoded_packet
)
def close(self) -> None:
# Check we haven't already closed this stream
if self._finished:
# We're attempting to close an already closed stream,
# do nothing more.
return
# Flush the underlying buffered encoder
self._write_to_oggopus(memoryview(bytearray(b"")), flush=True)
# The current packet must be the end of the stream, update
# the packet's details
self._ogg_packet.e_o_s = 1
# Write the packet to the stream
if self._packet_valid:
self._write_packet()
# Flush the stream of any unwritten pages
self._flush()
# Mark the stream as finished
self._finished = True
# Close the file if we opened it
if self._i_opened_the_file:
self._file.close()
self._i_opened_the_file = False
# Clean up the Ogg-related memory
ogg.ogg_stream_clear(self._stream_state)
# Clean up the reference to the encoded packet (as it must
# now have been written)
del self._current_encoded_packet
#
# Internal methods
#
def _create_random_serial_no(self) -> ctypes.c_int:
sizeof_c_int = ctypes.sizeof(ctypes.c_int)
min_int = -2**(sizeof_c_int*8-1)
max_int = 2**(sizeof_c_int*8-1)-1
serial_no = ctypes.c_int(random.randint(min_int, max_int))
return serial_no
def _create_stream_state(self) -> ogg.ogg_stream_state:
# Create a random serial number
serial_no = self._create_random_serial_no()
# Create an ogg_stream_state
ogg_stream_state = ogg.ogg_stream_state()
# Initialise the stream state
ogg.ogg_stream_init(
ctypes.pointer(ogg_stream_state),
serial_no
)
return ogg_stream_state
def _make_identification_header(self, pre_skip: int, input_sampling_rate: int = 0) -> bytes:
"""Make the OggOpus identification header.
An input_sampling rate may be set to zero to mean 'unspecified'.
Only channel mapping family 0 is currently supported.
This allows mono and stereo signals.
See https://tools.ietf.org/html/rfc7845#page-12 for more
details.
"""
signature = b"OpusHead"
version = 1
output_channels = self._encoder._channels
output_gain = 0
channel_mapping_family = 0
data = struct.pack(
"<BBHIHB",
version,
output_channels,
pre_skip,
input_sampling_rate,
output_gain,
channel_mapping_family
)
return signature+data
def _write_identification_header_packet(self, custom_pre_skip: int) -> int:
""" Returns pre-skip. """
if custom_pre_skip is not None:
# Use the user-specified amount of pre-skip
pre_skip = custom_pre_skip
else:
# Obtain the algorithmic delay of the Opus encoder. See
# https://tools.ietf.org/html/rfc7845#page-27
delay_samples = self._encoder.get_algorithmic_delay()
# Extra samples are recommended. See
# https://tools.ietf.org/html/rfc7845#page-27
extra_samples = 120
# We will just fill a whole frame with silence. Calculate
# the minimum frame length, which we'll use as the
# pre-skip.
frame_durations = [2.5, 5, 10, 20, 40, 60] # milliseconds
frame_lengths = [
x * self._encoder._samples_per_second // 1000
for x in frame_durations
]
for frame_length in frame_lengths:
if frame_length > delay_samples + extra_samples:
pre_skip = frame_length
break
# Create the identification header
id_header = self._make_identification_header(
pre_skip = pre_skip
)
# Specify the packet containing the identification header
self._ogg_packet.packet = ctypes.cast(id_header, ogg.c_uchar_p) # type: ignore
self._ogg_packet.bytes = len(id_header)
self._ogg_packet.b_o_s = 1
self._ogg_packet.e_o_s = 0
self._ogg_packet.granulepos = 0
self._ogg_packet.packetno = self._count_packets
self._count_packets += 1
# Write the identification header
result = ogg.ogg_stream_packetin(
self._stream_state,
self._ogg_packet
)
if result != 0:
raise PyOggError(
"Failed to write Opus identification header"
)
return pre_skip
def _make_comment_header(self):
"""Make the OggOpus comment header.
See https://tools.ietf.org/html/rfc7845#page-22 for more
details.
"""
signature = b"OpusTags"
vendor_string = b"ENCODER=PyOgg"
vendor_string_length = struct.pack("<I",len(vendor_string))
user_comments_length = struct.pack("<I",0)
return (
signature
+ vendor_string_length
+ vendor_string
+ user_comments_length
)
def _write_comment_header_packet(self):
# Specify the comment header
comment_header = self._make_comment_header()
# Specify the packet containing the identification header
self._ogg_packet.packet = ctypes.cast(comment_header, ogg.c_uchar_p)
self._ogg_packet.bytes = len(comment_header)
self._ogg_packet.b_o_s = 0
self._ogg_packet.e_o_s = 0
self._ogg_packet.granulepos = 0
self._ogg_packet.packetno = self._count_packets
self._count_packets += 1
# Write the header
result = ogg.ogg_stream_packetin(
self._stream_state,
self._ogg_packet
)
if result != 0:
raise PyOggError(
"Failed to write Opus comment header"
)
def _write_page(self):
""" Write page to file """
# Cast pointer to ctypes array, which can then be passed to
# write without issues.
HeaderBufferPtr = ctypes.POINTER(ctypes.c_ubyte * self._ogg_page.header_len)
header = HeaderBufferPtr(self._ogg_page.header.contents)[0]
self._file.write(header)
BodyBufferPtr = ctypes.POINTER(ctypes.c_ubyte * self._ogg_page.body_len)
body = BodyBufferPtr(self._ogg_page.body.contents)[0]
self._file.write(body)
def _flush(self):
""" Flush all pages to the file. """
while ogg.ogg_stream_flush(
ctypes.pointer(self._stream_state),
ctypes.pointer(self._ogg_page)) != 0:
self._write_page()
def _write_headers(self, custom_pre_skip):
""" Write the two Opus header packets."""
pre_skip = self._write_identification_header_packet(
custom_pre_skip
)
self._write_comment_header_packet()
# Store that the headers have been written
self._headers_written = True
# Write out pages to file to ensure that the headers are
# the only packets to appear on the first page. If this
# is not done, the file cannot be read by the library
# opusfile.
self._flush()
return pre_skip
def _write_packet(self):
# Place the packet into the stream
result = ogg.ogg_stream_packetin(
self._stream_state,
self._ogg_packet
)
# Check for errors
if result != 0:
raise PyOggError(
"Error while placing packet in Ogg stream"
)
# Write out pages to file
while ogg.ogg_stream_pageout(
ctypes.pointer(self._stream_state),
ctypes.pointer(self._ogg_page)) != 0:
self._write_page()
def _write_silence(self, samples):
""" Write a frame of silence. """
silence_length = (
samples
* self._encoder._channels
* ctypes.sizeof(opus.opus_int16)
)
silence_pcm = \
memoryview(bytearray(b"\x00" * silence_length))
self._write_to_oggopus(silence_pcm)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
import copy
import ctypes
from typing import Optional, ByteString, List, Tuple, Callable
import warnings
from . import opus
from .opus_encoder import OpusEncoder
from .pyogg_error import PyOggError
class OpusBufferedEncoder(OpusEncoder):
# TODO: This could be made more efficient. We don't need a
# deque. Instead, we need only sufficient PCM storage for one
# whole packet. We know the size of the packet thanks to
# set_frame_size().
def __init__(self) -> None:
super().__init__()
self._frame_size_ms: Optional[float] = None
self._frame_size_bytes: Optional[int] = None
# Buffer contains the bytes required for the next
# frame.
self._buffer: Optional[ctypes.Array] = None
# Location of the next free byte in the buffer
self._buffer_index = 0
def set_frame_size(self, frame_size: float) -> None:
""" Set the desired frame duration (in milliseconds).
Valid options are 2.5, 5, 10, 20, 40, or 60ms.
"""
# Ensure the frame size is valid. Compare frame size in
# units of 0.1ms to avoid floating point comparison
if int(frame_size*10) not in [25, 50, 100, 200, 400, 600]:
raise PyOggError(
"Frame size ({:f}) not one of ".format(frame_size)+
"the acceptable values"
)
self._frame_size_ms = frame_size
self._calc_frame_size()
def set_sampling_frequency(self, samples_per_second: int) -> None:
super().set_sampling_frequency(samples_per_second)
self._calc_frame_size()
def buffered_encode(self,
pcm_bytes: memoryview,
flush: bool = False,
callback: Callable[[memoryview,int,bool],None] = None
) -> List[Tuple[memoryview, int, bool]]:
"""Gets encoded packets and their number of samples.
This method returns a list, where each item in the list is
a tuple. The first item in the tuple is an Opus-encoded
frame stored as a bytes-object. The second item in the
tuple is the number of samples encoded (excluding
silence).
If `callback` is supplied then this method will instead
return an empty list but call the callback for every
Opus-encoded frame that would have been returned as a
list. This option has the desireable property of
eliminating the copying of the encoded packets, which is
required in order to form a list. The callback should
take two arguments, the encoded frame (a Python bytes
object) and the number of samples encoded per channel (an
int). The user must either process or copy the data as
the data may be overwritten once the callback terminates.
"""
# If there's no work to do return immediately
if len(pcm_bytes) == 0 and flush == False:
return [] # no work to do
# Sanity checks
if self._frame_size_ms is None:
raise PyOggError("Frame size must be set before encoding")
assert self._frame_size_bytes is not None
assert self._channels is not None
assert self._buffer is not None
assert self._buffer_index is not None
# Local variable initialisation
results = []
pcm_index = 0
pcm_len = len(pcm_bytes)
# 'Cast' memoryview of PCM to ctypes Array
Buffer = ctypes.c_ubyte * len(pcm_bytes)
try:
pcm_ctypes = Buffer.from_buffer(pcm_bytes)
except TypeError:
warnings.warn(
"Because PCM was read-only, an extra memory "+
"copy was required; consider storing PCM in "+
"writable memory (for example, bytearray "+
"rather than bytes)."
)
pcm_ctypes = Buffer.from_buffer(pcm_bytes)
# Either store the encoded packet to return at the end of the
# method or immediately call the callback with the encoded
# packet.
def store_or_callback(encoded_packet: memoryview,
samples: int,
end_of_stream: bool = False) -> None:
if callback is None:
# Store the result
results.append((
encoded_packet,
samples,
end_of_stream
))
else:
# Call the callback
callback(
encoded_packet,
samples,
end_of_stream
)
# Fill the remainder of the buffer with silence and encode it.
# The associated number of samples are only that of actual
# data, not the added silence.
def flush_buffer() -> None:
# Sanity checks to satisfy mypy
assert self._buffer_index is not None
assert self._channels is not None
assert self._buffer is not None
# If the buffer is already empty, we have no work to do
if self._buffer_index == 0:
return
# Store the number of samples currently in the buffer
samples = (
self._buffer_index
// self._channels
// ctypes.sizeof(opus.opus_int16)
)
# Fill the buffer with silence
ctypes.memset(
# destination
ctypes.byref(self._buffer, self._buffer_index),
# value
0,
# count
len(self._buffer) - self._buffer_index
)
# Encode the PCM
# As at 2020-11-05, mypy is unaware that ctype Arrays
# support the buffer protocol.
encoded_packet = self.encode(memoryview(self._buffer)) # type: ignore
# Either store the encoded packet or call the
# callback
store_or_callback(encoded_packet, samples, True)
# Copy the data remaining from the provided PCM into the
# buffer. Flush if required.
def copy_insufficient_data() -> None:
# Sanity checks to satisfy mypy
assert self._buffer is not None
# Calculate remaining data
remaining_data = len(pcm_bytes) - pcm_index
# Copy the data into the buffer.
ctypes.memmove(
# destination
ctypes.byref(self._buffer, self._buffer_index),
# source
ctypes.byref(pcm_ctypes, pcm_index),
# count
remaining_data
)
self._buffer_index += remaining_data
# If we've been asked to flush the buffer then do so
if flush:
flush_buffer()
# Loop through the provided PCM and the current buffer,
# encoding as we have full packets.
while True:
# There are two possibilities at this point: either we
# have previously unencoded data still in the buffer or we
# do not
if self._buffer_index == 0:
# We do not have unencoded data
# We are free to progress through the PCM that has
# been provided encoding frames without copying any
# bytes. Once there is insufficient data remaining
# for a complete frame, that data should be copied
# into the buffer and we have finished.
if pcm_len - pcm_index > self._frame_size_bytes:
# We have enough data remaining in the provided
# PCM to encode more than an entire frame without
# copying any data. Unfortunately, splicing a
# ctypes array copies the array. To avoid the
# copy we use memoryview see
# https://mattgwwalker.wordpress.com/2020/12/12/python-ctypes-slicing/
frame_data = memoryview(pcm_bytes)[
pcm_index:pcm_index+self._frame_size_bytes
]
# Update the PCM index
pcm_index += self._frame_size_bytes
# Store number of samples (per channel) of actual
# data
samples = (
len(frame_data)
// self._channels
// ctypes.sizeof(opus.opus_int16)
)
# Encode the PCM
encoded_packet = super().encode(frame_data)
# Either store the encoded packet or call the
# callback
store_or_callback(encoded_packet, samples)
else:
# We do not have enough data to fill a frame while
# still having data left over. Copy the data into
# the buffer.
copy_insufficient_data()
return results
else:
# We have unencoded data.
# Copy the provided PCM into the buffer (up until the
# buffer is full). If we can fill it, then we can
# encode the filled buffer and continue. If we can't
# fill it then we've finished.
data_required = len(self._buffer) - self._buffer_index
if pcm_len > data_required:
# We have sufficient data to fill the buffer and
# have data left over. Copy data into the buffer.
assert pcm_index == 0
remaining = len(self._buffer) - self._buffer_index
ctypes.memmove(
# destination
ctypes.byref(self._buffer, self._buffer_index),
# source
pcm_ctypes,
# count
remaining
)
pcm_index += remaining
self._buffer_index += remaining
assert self._buffer_index == len(self._buffer)
# Encode the PCM
encoded_packet = super().encode(
# Memoryviews of ctypes do work, even though
# mypy complains.
memoryview(self._buffer) # type: ignore
)
# Store number of samples (per channel) of actual
# data
samples = (
self._buffer_index
// self._channels
// ctypes.sizeof(opus.opus_int16)
)
# We've now processed the buffer
self._buffer_index = 0
# Either store the encoded packet or call the
# callback
store_or_callback(encoded_packet, samples)
else:
# We have insufficient data to fill the buffer
# while still having data left over. Copy the
# data into the buffer.
copy_insufficient_data()
return results
def _calc_frame_size(self):
"""Calculates the number of bytes in a frame.
If the frame size (in milliseconds) and the number of
samples per seconds have already been specified, then the
frame size in bytes is set. Otherwise, this method does
nothing.
The frame size is measured in bytes required to store the
sample.
"""
if (self._frame_size_ms is None
or self._samples_per_second is None):
return
self._frame_size_bytes = (
self._frame_size_ms
* self._samples_per_second
// 1000
* ctypes.sizeof(opus.opus_int16)
* self._channels
)
# Allocate space for the buffer
Buffer = ctypes.c_ubyte * self._frame_size_bytes
self._buffer = Buffer()
def _get_next_frame(self, add_silence=False):
"""Gets the next Opus-encoded frame.
Returns a tuple where the first item is the Opus-encoded
frame and the second item is the number of encoded samples
(per channel).
Returns None if insufficient data is available.
"""
next_frame = bytes()
samples = 0
# Ensure frame size has been specified
if self._frame_size_bytes is None:
raise PyOggError(
"Desired frame size hasn't been set. Perhaps "+
"encode() was called before set_frame_size() "+
"and set_sampling_frequency()?"
)
# Check if there's insufficient data in the buffer to fill
# a frame.
if self._frame_size_bytes > self._buffer_size:
if len(self._buffer) == 0:
# No data at all in buffer
return None
if add_silence:
# Get all remaining data
while len(self._buffer) != 0:
next_frame += self._buffer.popleft()
self._buffer_size = 0
# Store number of samples (per channel) of actual
# data
samples = (
len(next_frame)
// self._channels
// ctypes.sizeof(opus.opus_int16)
)
# Fill remainder of frame with silence
bytes_remaining = self._frame_size_bytes - len(next_frame)
next_frame += b'\x00' * bytes_remaining
return (next_frame, samples)
else:
# Insufficient data to fill a frame and we're not
# adding silence
return None
bytes_remaining = self._frame_size_bytes
while bytes_remaining > 0:
if len(self._buffer[0]) <= bytes_remaining:
# Take the whole first item
buffer_ = self._buffer.popleft()
next_frame += buffer_
bytes_remaining -= len(buffer_)
self._buffer_size -= len(buffer_)
else:
# Take only part of the buffer
# TODO: This could be more efficiently
# implemented. Rather than appending back the
# remaining data, we could just update an index
# saying where we were up to in regards to the
# first entry of the buffer.
buffer_ = self._buffer.popleft()
next_frame += buffer_[:bytes_remaining]
self._buffer_size -= bytes_remaining
# And put the unused part back into the buffer
self._buffer.appendleft(buffer_[bytes_remaining:])
bytes_remaining = 0
# Calculate number of samples (per channel)
samples = (
len(next_frame)
// self._channels
// ctypes.sizeof(opus.opus_int16)
)
return (next_frame, samples)

View File

@@ -0,0 +1,273 @@
import ctypes
from . import opus
from .pyogg_error import PyOggError
class OpusDecoder:
def __init__(self):
self._decoder = None
self._channels = None
self._samples_per_second = None
self._pcm_buffer = None
self._pcm_buffer_ptr = None
self._pcm_buffer_size_int = None
# TODO: Check if there is clean up that we need to do when
# closing a decoder.
#
# User visible methods
#
def set_channels(self, n):
"""Set the number of channels.
n must be either 1 or 2.
The decoder is capable of filling in either mono or
interleaved stereo pcm buffers.
"""
if self._decoder is None:
if n < 0 or n > 2:
raise PyOggError(
"Invalid number of channels in call to "+
"set_channels()"
)
self._channels = n
else:
raise PyOggError(
"Cannot change the number of channels after "+
"the decoder was created. Perhaps "+
"set_channels() was called after decode()?"
)
self._create_pcm_buffer()
def set_sampling_frequency(self, samples_per_second):
"""Set the number of samples (per channel) per second.
samples_per_second must be one of 8000, 12000, 16000,
24000, or 48000.
Internally Opus stores data at 48000 Hz, so that should be
the default value for Fs. However, the decoder can
efficiently decode to buffers at 8, 12, 16, and 24 kHz so
if for some reason the caller cannot use data at the full
sample rate, or knows the compressed data doesn't use the
full frequency range, it can request decoding at a reduced
rate.
"""
if self._decoder is None:
if samples_per_second in [8000, 12000, 16000, 24000, 48000]:
self._samples_per_second = samples_per_second
else:
raise PyOggError(
"Specified sampling frequency "+
"({:d}) ".format(samples_per_second)+
"was not one of the accepted values"
)
else:
raise PyOggError(
"Cannot change the sampling frequency after "+
"the decoder was created. Perhaps "+
"set_sampling_frequency() was called after decode()?"
)
self._create_pcm_buffer()
def decode(self, encoded_bytes: memoryview):
"""Decodes an Opus-encoded packet into PCM.
"""
# If we haven't already created a decoder, do so now
if self._decoder is None:
self._decoder = self._create_decoder()
# Create a ctypes array from the memoryview (without copying
# data)
Buffer = ctypes.c_char * len(encoded_bytes)
encoded_bytes_ctypes = Buffer.from_buffer(encoded_bytes)
# Create pointer to encoded bytes
encoded_bytes_ptr = ctypes.cast(
encoded_bytes_ctypes,
ctypes.POINTER(ctypes.c_ubyte)
)
# Store length of encoded bytes into int32
len_int32 = opus.opus_int32(
len(encoded_bytes)
)
# Check that we have a PCM buffer
if self._pcm_buffer is None:
raise PyOggError("PCM buffer was not configured.")
# Decode the encoded frame
result = opus.opus_decode(
self._decoder,
encoded_bytes_ptr,
len_int32,
self._pcm_buffer_ptr,
self._pcm_buffer_size_int,
0 # TODO: What's Forward Error Correction about?
)
# Check for any errors
if result < 0:
raise PyOggError(
"An error occurred while decoding an Opus-encoded "+
"packet: "+
opus.opus_strerror(result).decode("utf")
)
# Extract just the valid data as bytes
end_valid_data = (
result
* ctypes.sizeof(opus.opus_int16)
* self._channels
)
# Create memoryview of PCM buffer to avoid copying data during slice.
mv = memoryview(self._pcm_buffer)
# Cast memoryview to chars
mv = mv.cast('c')
# Slice memoryview to extract only valid data
mv = mv[:end_valid_data]
return mv
def decode_missing_packet(self, frame_duration):
""" Obtain PCM data despite missing a frame.
frame_duration is in milliseconds.
"""
# Consider frame duration in units of 0.1ms in order to
# avoid floating-point comparisons.
if int(frame_duration*10) not in [25, 50, 100, 200, 400, 600]:
raise PyOggError(
"Frame duration ({:f}) is not one of the accepted values".format(frame_duration)
)
# Calculate frame size
frame_size = int(
frame_duration
* self._samples_per_second
// 1000
)
# Store frame size as int
frame_size_int = ctypes.c_int(frame_size)
# Decode missing packet
result = opus.opus_decode(
self._decoder,
None,
0,
self._pcm_buffer_ptr,
frame_size_int,
0 # TODO: What is this Forward Error Correction about?
)
# Check for any errors
if result < 0:
raise PyOggError(
"An error occurred while decoding an Opus-encoded "+
"packet: "+
opus.opus_strerror(result).decode("utf")
)
# Extract just the valid data as bytes
end_valid_data = (
result
* ctypes.sizeof(opus.opus_int16)
* self._channels
)
return bytes(self._pcm_buffer)[:end_valid_data]
#
# Internal methods
#
def _create_pcm_buffer(self):
if (self._samples_per_second is None
or self._channels is None):
# We cannot define the buffer yet
return
# Create buffer to hold 120ms of samples. See "opus_decode()" at
# https://opus-codec.org/docs/opus_api-1.3.1/group__opus__decoder.html
max_duration = 120 # milliseconds
max_samples = max_duration * self._samples_per_second // 1000
PCMBuffer = opus.opus_int16 * (max_samples * self._channels)
self._pcm_buffer = PCMBuffer()
self._pcm_buffer_ptr = (
ctypes.cast(ctypes.pointer(self._pcm_buffer),
ctypes.POINTER(opus.opus_int16))
)
# Store samples per channel in an int
self._pcm_buffer_size_int = ctypes.c_int(max_samples)
def _create_decoder(self):
# To create a decoder, we must first allocate resources for it.
# We want Python to be responsible for the memory deallocation,
# and thus Python must be responsible for the initial memory
# allocation.
# Check that the sampling frequency has been defined
if self._samples_per_second is None:
raise PyOggError(
"The sampling frequency was not specified before "+
"attempting to create an Opus decoder. Perhaps "+
"decode() was called before set_sampling_frequency()?"
)
# The sampling frequency must be passed in as a 32-bit int
samples_per_second = opus.opus_int32(self._samples_per_second)
# Check that the number of channels has been defined
if self._channels is None:
raise PyOggError(
"The number of channels were not specified before "+
"attempting to create an Opus decoder. Perhaps "+
"decode() was called before set_channels()?"
)
# The number of channels must also be passed in as a 32-bit int
channels = opus.opus_int32(self._channels)
# Obtain the number of bytes of memory required for the decoder
size = opus.opus_decoder_get_size(channels);
# Allocate the required memory for the decoder
memory = ctypes.create_string_buffer(size)
# Cast the newly-allocated memory as a pointer to a decoder. We
# could also have used opus.od_p as the pointer type, but writing
# it out in full may be clearer.
decoder = ctypes.cast(memory, ctypes.POINTER(opus.OpusDecoder))
# Initialise the decoder
error = opus.opus_decoder_init(
decoder,
samples_per_second,
channels
);
# Check that there hasn't been an error when initialising the
# decoder
if error != opus.OPUS_OK:
raise PyOggError(
"An error occurred while creating the decoder: "+
opus.opus_strerror(error).decode("utf")
)
# Return our newly-created decoder
return decoder

View File

@@ -0,0 +1,358 @@
import ctypes
from typing import Optional, Union, ByteString
from . import opus
from .pyogg_error import PyOggError
class OpusEncoder:
"""Encodes PCM data into Opus frames."""
def __init__(self) -> None:
self._encoder: Optional[ctypes.pointer] = None
self._channels: Optional[int] = None
self._samples_per_second: Optional[int] = None
self._application: Optional[int] = None
self._max_bytes_per_frame: Optional[opus.opus_int32] = None
self._output_buffer: Optional[ctypes.Array] = None
self._output_buffer_ptr: Optional[ctypes.pointer] = None
# An output buffer of 4,000 bytes is recommended in
# https://opus-codec.org/docs/opus_api-1.3.1/group__opus__encoder.html
self.set_max_bytes_per_frame(4000)
#
# User visible methods
#
def set_channels(self, n: int) -> None:
"""Set the number of channels.
n must be either 1 or 2.
"""
if self._encoder is None:
if n < 0 or n > 2:
raise PyOggError(
"Invalid number of channels in call to "+
"set_channels()"
)
self._channels = n
else:
raise PyOggError(
"Cannot change the number of channels after "+
"the encoder was created. Perhaps "+
"set_channels() was called after encode()?"
)
def set_sampling_frequency(self, samples_per_second: int) -> None:
"""Set the number of samples (per channel) per second.
This must be one of 8000, 12000, 16000, 24000, or 48000.
Regardless of the sampling rate and number of channels
selected, the Opus encoder can switch to a lower audio
bandwidth or number of channels if the bitrate selected is
too low. This also means that it is safe to always use 48
kHz stereo input and let the encoder optimize the
encoding.
"""
if self._encoder is None:
if samples_per_second in [8000, 12000, 16000, 24000, 48000]:
self._samples_per_second = samples_per_second
else:
raise PyOggError(
"Specified sampling frequency "+
"({:d}) ".format(samples_per_second)+
"was not one of the accepted values"
)
else:
raise PyOggError(
"Cannot change the sampling frequency after "+
"the encoder was created. Perhaps "+
"set_sampling_frequency() was called after encode()?"
)
def set_application(self, application: str) -> None:
"""Set the encoding mode.
This must be one of 'voip', 'audio', or 'restricted_lowdelay'.
'voip': Gives best quality at a given bitrate for voice
signals. It enhances the input signal by high-pass
filtering and emphasizing formants and
harmonics. Optionally it includes in-band forward error
correction to protect against packet loss. Use this mode
for typical VoIP applications. Because of the enhancement,
even at high bitrates the output may sound different from
the input.
'audio': Gives best quality at a given bitrate for most
non-voice signals like music. Use this mode for music and
mixed (music/voice) content, broadcast, and applications
requiring less than 15 ms of coding delay.
'restricted_lowdelay': configures low-delay mode that
disables the speech-optimized mode in exchange for
slightly reduced delay. This mode can only be set on an
newly initialized encoder because it changes the codec
delay.
"""
if self._encoder is not None:
raise PyOggError(
"Cannot change the application after "+
"the encoder was created. Perhaps "+
"set_application() was called after encode()?"
)
if application == "voip":
self._application = opus.OPUS_APPLICATION_VOIP
elif application == "audio":
self._application = opus.OPUS_APPLICATION_AUDIO
elif application == "restricted_lowdelay":
self._application = opus.OPUS_APPLICATION_RESTRICTED_LOWDELAY
else:
raise PyOggError(
"The application specification '{:s}' ".format(application)+
"wasn't one of the accepted values."
)
def set_max_bytes_per_frame(self, max_bytes: int) -> None:
"""Set the maximum number of bytes in an encoded frame.
Size of the output payload. This may be used to impose an
upper limit on the instant bitrate, but should not be used
as the only bitrate control.
TODO: Use OPUS_SET_BITRATE to control the bitrate.
"""
self._max_bytes_per_frame = opus.opus_int32(max_bytes)
OutputBuffer = ctypes.c_ubyte * max_bytes
self._output_buffer = OutputBuffer()
self._output_buffer_ptr = (
ctypes.cast(ctypes.pointer(self._output_buffer),
ctypes.POINTER(ctypes.c_ubyte))
)
def encode(self, pcm: Union[bytes, bytearray, memoryview]) -> memoryview:
"""Encodes PCM data into an Opus frame.
`pcm` must be formatted as bytes-like, with each sample taking
two bytes (signed 16-bit integers; interleaved left, then
right channels if in stereo).
If `pcm` is not writeable, a copy of the array will be made.
"""
# If we haven't already created an encoder, do so now
if self._encoder is None:
self._encoder = self._create_encoder()
# Sanity checks also satisfy mypy type checking
assert self._channels is not None
assert self._samples_per_second is not None
assert self._output_buffer is not None
# Calculate the effective frame duration of the given PCM
# data. Calculate it in units of 0.1ms in order to avoid
# floating point comparisons.
bytes_per_sample = 2
frame_size = (
len(pcm) # bytes
// bytes_per_sample
// self._channels
)
frame_duration = (
(10*frame_size)
// (self._samples_per_second//1000)
)
# Check that we have a valid frame size
if int(frame_duration) not in [25, 50, 100, 200, 400, 600]:
raise PyOggError(
"The effective frame duration ({:.1f} ms) "
.format(frame_duration/10)+
"was not one of the acceptable values."
)
# Create a ctypes object sharing the memory of the PCM data
PcmCtypes = ctypes.c_ubyte * len(pcm)
try:
# Attempt to share the PCM memory
# Unfortunately, as at 2020-09-27, the type hinting for
# read-only and writeable buffer protocols was a
# work-in-progress. The following only works for writable
# cases, but the method's parameters include a read-only
# possibility (bytes), thus we ignore mypy's error.
pcm_ctypes = PcmCtypes.from_buffer(pcm) # type: ignore[arg-type]
except TypeError:
# The data must be copied if it's not writeable
pcm_ctypes = PcmCtypes.from_buffer_copy(pcm)
# Create a pointer to the PCM data
pcm_ptr = ctypes.cast(
pcm_ctypes,
ctypes.POINTER(opus.opus_int16)
)
# Create an int giving the frame size per channel
frame_size_int = ctypes.c_int(frame_size)
# Encode PCM
result = opus.opus_encode(
self._encoder,
pcm_ptr,
frame_size_int,
self._output_buffer_ptr,
self._max_bytes_per_frame
)
# Check for any errors
if result < 0:
raise PyOggError(
"An error occurred while encoding to Opus format: "+
opus.opus_strerror(result).decode("utf")
)
# Get memoryview of buffer so that the slice operation doesn't
# copy the data.
#
# Unfortunately, as at 2020-09-27, the type hints for
# memoryview do not include ctype arrays. This is because
# there is no currently accepted manner to label a class as
# supporting the buffer protocol. However, it's clearly a
# work in progress. For more information, see:
# * https://bugs.python.org/issue27501
# * https://github.com/python/typing/issues/593
# * https://github.com/python/typeshed/pull/4232
mv = memoryview(self._output_buffer) # type: ignore
# Cast the memoryview to char
mv = mv.cast('c')
# Slice just the valid data from the memoryview
valid_data_as_bytes = mv[:result]
# DEBUG
# Convert memoryview back to ctypes instance
Buffer = ctypes.c_ubyte * len(valid_data_as_bytes)
buf = Buffer.from_buffer( valid_data_as_bytes )
# Convert PCM back to pointer and dump 4,000-byte buffer
ptr = ctypes.cast(
buf,
ctypes.POINTER(ctypes.c_ubyte)
)
return valid_data_as_bytes
def get_algorithmic_delay(self):
"""Gets the total samples of delay added by the entire codec.
This can be queried by the encoder and then the provided
number of samples can be skipped on from the start of the
decoder's output to provide time aligned input and
output. From the perspective of a decoding application the
real data begins this many samples late.
The decoder contribution to this delay is identical for all
decoders, but the encoder portion of the delay may vary from
implementation to implementation, version to version, or even
depend on the encoder's initial configuration. Applications
needing delay compensation should call this method rather than
hard-coding a value.
"""
# If we haven't already created an encoder, do so now
if self._encoder is None:
self._encoder = self._create_encoder()
# Obtain the algorithmic delay of the Opus encoder. See
# https://tools.ietf.org/html/rfc7845#page-27
delay = opus.opus_int32()
result = opus.opus_encoder_ctl(
self._encoder,
opus.OPUS_GET_LOOKAHEAD_REQUEST,
ctypes.pointer(delay)
)
if result != opus.OPUS_OK:
raise PyOggError(
"Failed to obtain the algorithmic delay of "+
"the Opus encoder: "+
opus.opus_strerror(result).decode("utf")
)
delay_samples = delay.value
return delay_samples
#
# Internal methods
#
def _create_encoder(self) -> ctypes.pointer:
# To create an encoder, we must first allocate resources for it.
# We want Python to be responsible for the memory deallocation,
# and thus Python must be responsible for the initial memory
# allocation.
# Check that the application has been defined
if self._application is None:
raise PyOggError(
"The application was not specified before "+
"attempting to create an Opus encoder. Perhaps "+
"encode() was called before set_application()?"
)
application = self._application
# Check that the sampling frequency has been defined
if self._samples_per_second is None:
raise PyOggError(
"The sampling frequency was not specified before "+
"attempting to create an Opus encoder. Perhaps "+
"encode() was called before set_sampling_frequency()?"
)
# The frequency must be passed in as a 32-bit int
samples_per_second = opus.opus_int32(self._samples_per_second)
# Check that the number of channels has been defined
if self._channels is None:
raise PyOggError(
"The number of channels were not specified before "+
"attempting to create an Opus encoder. Perhaps "+
"encode() was called before set_channels()?"
)
channels = self._channels
# Obtain the number of bytes of memory required for the encoder
size = opus.opus_encoder_get_size(channels);
# Allocate the required memory for the encoder
memory = ctypes.create_string_buffer(size)
# Cast the newly-allocated memory as a pointer to an encoder. We
# could also have used opus.oe_p as the pointer type, but writing
# it out in full may be clearer.
encoder = ctypes.cast(memory, ctypes.POINTER(opus.OpusEncoder))
# Initialise the encoder
error = opus.opus_encoder_init(
encoder,
samples_per_second,
channels,
application
)
# Check that there hasn't been an error when initialising the
# encoder
if error != opus.OPUS_OK:
raise PyOggError(
"An error occurred while creating the encoder: "+
opus.opus_strerror(error).decode("utf")
)
# Return our newly-created encoder
return encoder

View File

@@ -0,0 +1,106 @@
import ctypes
from . import ogg
from . import opus
from .pyogg_error import PyOggError
from .audio_file import AudioFile
class OpusFile(AudioFile):
def __init__(self, path: str) -> None:
# Open the file
error = ctypes.c_int()
of = opus.op_open_file(
ogg.to_char_p(path),
ctypes.pointer(error)
)
# Check for errors
if error.value != 0:
raise PyOggError(
("File '{}' couldn't be opened or doesn't exist. "+
"Error code: {}").format(path, error.value)
)
# Extract the number of channels in the newly opened file
#: Number of channels in audio file.
self.channels = opus.op_channel_count(of, -1)
# Allocate sufficient memory to store the entire PCM
pcm_size = opus.op_pcm_total(of, -1)
Buf = opus.opus_int16*(pcm_size*self.channels)
buf = Buf()
# Create a pointer to the newly allocated memory. It
# seems we can only do pointer arithmetic on void
# pointers. See
# https://mattgwwalker.wordpress.com/2020/05/30/pointer-manipulation-in-python/
buf_ptr = ctypes.cast(
ctypes.pointer(buf),
ctypes.c_void_p
)
assert buf_ptr.value is not None # for mypy
buf_ptr_zero = buf_ptr.value
#: Bytes per sample
self.bytes_per_sample = ctypes.sizeof(opus.opus_int16)
# Read through the entire file, copying the PCM into the
# buffer
samples = 0
while True:
# Calculate remaining buffer size
remaining_buffer = (
len(buf) # int
- (buf_ptr.value
- buf_ptr_zero) // self.bytes_per_sample
)
# Convert buffer pointer to the desired type
ptr = ctypes.cast(
buf_ptr,
ctypes.POINTER(opus.opus_int16)
)
# Read the next section of PCM
ns = opus.op_read(
of,
ptr,
remaining_buffer,
ogg.c_int_p()
)
# Check for errors
if ns<0:
raise PyOggError(
"Error while reading OggOpus file. "+
"Error code: {}".format(ns)
)
# Increment the pointer
buf_ptr.value += (
ns
* self.bytes_per_sample
* self.channels
)
assert buf_ptr.value is not None # for mypy
samples += ns
# Check if we've finished
if ns==0:
break
# Close the open file
opus.op_free(of)
# Opus files are always stored at 48k samples per second
#: Number of samples per second (per channel). Always 48,000.
self.frequency = 48000
# Cast buffer to a one-dimensional array of chars
#: Raw PCM data from audio file.
CharBuffer = (
ctypes.c_byte
* (self.bytes_per_sample * self.channels * pcm_size)
)
self.buffer = CharBuffer.from_buffer(buf)

View File

@@ -0,0 +1,127 @@
import ctypes
from . import ogg
from . import opus
from .pyogg_error import PyOggError
class OpusFileStream:
def __init__(self, path):
"""Opens an OggOpus file as a stream.
path should be a string giving the filename of the file to
open. Unicode file names may not work correctly.
An exception will be raised if the file cannot be opened
correctly.
"""
error = ctypes.c_int()
self.of = opus.op_open_file(ogg.to_char_p(path), ctypes.pointer(error))
if error.value != 0:
self.of = None
raise PyOggError("file couldn't be opened or doesn't exist. Error code : {}".format(error.value))
#: Number of channels in audio file
self.channels = opus.op_channel_count(self.of, -1)
#: Total PCM Length
self.pcm_size = opus.op_pcm_total(self.of, -1)
#: Number of samples per second (per channel)
self.frequency = 48000
# The buffer size should be (per channel) large enough to
# hold 120ms (the largest possible Opus frame) at 48kHz.
# See https://opus-codec.org/docs/opusfile_api-0.7/group__stream__decoding.html#ga963c917749335e29bb2b698c1cb20a10
self.buffer_size = self.frequency // 1000 * 120 * self.channels
self.Buf = opus.opus_int16 * self.buffer_size
self._buf = self.Buf()
self.buffer_ptr = ctypes.cast(
ctypes.pointer(self._buf),
opus.opus_int16_p
)
#: Bytes per sample
self.bytes_per_sample = ctypes.sizeof(opus.opus_int16)
def __del__(self):
if self.of is not None:
opus.op_free(self.of)
def get_buffer(self):
"""Obtains the next frame of PCM samples.
Returns an array of signed 16-bit integers. If the file
is in stereo, the left and right channels are interleaved.
Returns None when all data has been read.
The array that is returned should be either processed or
copied before the next call to :meth:`~get_buffer` or
:meth:`~get_buffer_as_array` as the array's memory is reused for
each call.
"""
# Read the next frame
samples_read = opus.op_read(
self.of,
self.buffer_ptr,
self.buffer_size,
None
)
# Check for errors
if samples_read < 0:
raise PyOggError(
"Failed to read OpusFileStream. Error {:d}".format(samples_read)
)
# Check if we've reached the end of the stream
if samples_read == 0:
return None
# Cast the pointer to opus_int16 to an array of the
# correct size
result_ptr = ctypes.cast(
self.buffer_ptr,
ctypes.POINTER(opus.opus_int16 * (samples_read*self.channels))
)
# Convert the array to Python bytes
return bytes(result_ptr.contents)
def get_buffer_as_array(self):
"""Provides the buffer as a NumPy array.
Note that the underlying data type is 16-bit signed
integers.
Does not copy the underlying data, so the returned array
should either be processed or copied before the next call
to :meth:`~get_buffer` or :meth:`~get_buffer_as_array`.
"""
import numpy # type: ignore
# Read the next samples from the stream
buf = self.get_buffer()
# Check if we've come to the end of the stream
if buf is None:
return None
# Convert the bytes buffer to a NumPy array
array = numpy.frombuffer(
buf,
dtype=numpy.int16
)
# Reshape the array
return array.reshape(
(len(buf)
// self.bytes_per_sample
// self.channels,
self.channels)
)

View File

@@ -0,0 +1 @@
# Marker file for PEP 561. This package uses inline types.

View File

@@ -0,0 +1,2 @@
class PyOggError(Exception):
pass

View File

@@ -0,0 +1,855 @@
############################################################
# Vorbis license: #
############################################################
"""
Copyright (c) 2002-2015 Xiph.org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import ctypes
import ctypes.util
from traceback import print_exc as _print_exc
import os
OV_EXCLUDE_STATIC_CALLBACKS = False
__MINGW32__ = False
_WIN32 = False
from .ogg import *
from .library_loader import ExternalLibrary, ExternalLibraryError
__here = os.getcwd()
libvorbis = None
try:
names = {
"Windows": "libvorbis.dll",
"Darwin": "libvorbis.0.dylib",
"external": "vorbis"
}
libvorbis = Library.load(names, tests = [lambda lib: hasattr(lib, "vorbis_info_init")])
except ExternalLibraryError:
pass
except:
_print_exc()
libvorbisfile = None
try:
names = {
"Windows": "libvorbisfile.dll",
"Darwin": "libvorbisfile.3.dylib",
"external": "vorbisfile"
}
libvorbisfile = Library.load(names, tests = [lambda lib: hasattr(lib, "ov_clear")])
except ExternalLibraryError:
pass
except:
_print_exc()
libvorbisenc = None
# In some cases, libvorbis may also have the libvorbisenc functionality.
libvorbis_is_also_libvorbisenc = True
for f in ("vorbis_encode_ctl",
"vorbis_encode_init",
"vorbis_encode_init_vbr",
"vorbis_encode_setup_init",
"vorbis_encode_setup_managed",
"vorbis_encode_setup_vbr"):
if not hasattr(libvorbis, f):
libvorbis_is_also_libvorbisenc = False
break
if libvorbis_is_also_libvorbisenc:
libvorbisenc = libvorbis
else:
try:
names = {
"Windows": "libvorbisenc.dll",
"Darwin": "libvorbisenc.2.dylib",
"external": "vorbisenc"
}
libvorbisenc = Library.load(names, tests = [lambda lib: hasattr(lib, "vorbis_encode_init")])
except ExternalLibraryError:
pass
except:
_print_exc()
if libvorbis is None:
PYOGG_VORBIS_AVAIL = False
else:
PYOGG_VORBIS_AVAIL = True
if libvorbisfile is None:
PYOGG_VORBIS_FILE_AVAIL = False
else:
PYOGG_VORBIS_FILE_AVAIL = True
if libvorbisenc is None:
PYOGG_VORBIS_ENC_AVAIL = False
else:
PYOGG_VORBIS_ENC_AVAIL = True
# FIXME: What's the story with the lack of checking for PYOGG_VORBIS_ENC_AVAIL?
# We just seem to assume that it's available.
if PYOGG_OGG_AVAIL and PYOGG_VORBIS_AVAIL and PYOGG_VORBIS_FILE_AVAIL:
# Sanity check also satisfies mypy type checking
assert libogg is not None
assert libvorbis is not None
assert libvorbisfile is not None
# codecs
class vorbis_info(ctypes.Structure):
"""
Wrapper for:
typedef struct vorbis_info vorbis_info;
"""
_fields_ = [("version", c_int),
("channels", c_int),
("rate", c_long),
("bitrate_upper", c_long),
("bitrate_nominal", c_long),
("bitrate_lower", c_long),
("bitrate_window", c_long),
("codec_setup", c_void_p)]
class vorbis_dsp_state(ctypes.Structure):
"""
Wrapper for:
typedef struct vorbis_dsp_state vorbis_dsp_state;
"""
_fields_ = [("analysisp", c_int),
("vi", POINTER(vorbis_info)),
("pcm", c_float_p_p),
("pcmret", c_float_p_p),
("pcm_storage", c_int),
("pcm_current", c_int),
("pcm_returned", c_int),
("preextrapolate", c_int),
("eofflag", c_int),
("lW", c_long),
("W", c_long),
("nW", c_long),
("centerW", c_long),
("granulepos", ogg_int64_t),
("sequence", ogg_int64_t),
("glue_bits", ogg_int64_t),
("time_bits", ogg_int64_t),
("floor_bits", ogg_int64_t),
("res_bits", ogg_int64_t),
("backend_state", c_void_p)]
class alloc_chain(ctypes.Structure):
"""
Wrapper for:
typedef struct alloc_chain;
"""
pass
alloc_chain._fields_ = [("ptr", c_void_p),
("next", POINTER(alloc_chain))]
class vorbis_block(ctypes.Structure):
"""
Wrapper for:
typedef struct vorbis_block vorbis_block;
"""
_fields_ = [("pcm", c_float_p_p),
("opb", oggpack_buffer),
("lW", c_long),
("W", c_long),
("nW", c_long),
("pcmend", c_int),
("mode", c_int),
("eofflag", c_int),
("granulepos", ogg_int64_t),
("sequence", ogg_int64_t),
("vd", POINTER(vorbis_dsp_state)),
("localstore", c_void_p),
("localtop", c_long),
("localalloc", c_long),
("totaluse", c_long),
("reap", POINTER(alloc_chain)),
("glue_bits", c_long),
("time_bits", c_long),
("floor_bits", c_long),
("res_bits", c_long),
("internal", c_void_p)]
class vorbis_comment(ctypes.Structure):
"""
Wrapper for:
typedef struct vorbis_comment vorbis_comment;
"""
_fields_ = [("user_comments", c_char_p_p),
("comment_lengths", c_int_p),
("comments", c_int),
("vendor", c_char_p)]
vi_p = POINTER(vorbis_info)
vc_p = POINTER(vorbis_comment)
vd_p = POINTER(vorbis_dsp_state)
vb_p = POINTER(vorbis_block)
libvorbis.vorbis_info_init.restype = None
libvorbis.vorbis_info_init.argtypes = [vi_p]
def vorbis_info_init(vi):
libvorbis.vorbis_info_init(vi)
libvorbis.vorbis_info_clear.restype = None
libvorbis.vorbis_info_clear.argtypes = [vi_p]
def vorbis_info_clear(vi):
libvorbis.vorbis_info_clear(vi)
libvorbis.vorbis_info_blocksize.restype = c_int
libvorbis.vorbis_info_blocksize.argtypes = [vi_p, c_int]
def vorbis_info_blocksize(vi, zo):
return libvorbis.vorbis_info_blocksize(vi, zo)
libvorbis.vorbis_comment_init.restype = None
libvorbis.vorbis_comment_init.argtypes = [vc_p]
def vorbis_comment_init(vc):
libvorbis.vorbis_comment_init(vc)
libvorbis.vorbis_comment_add.restype = None
libvorbis.vorbis_comment_add.argtypes = [vc_p, c_char_p]
def vorbis_comment_add(vc, comment):
libvorbis.vorbis_comment_add(vc, comment)
libvorbis.vorbis_comment_add_tag.restype = None
libvorbis.vorbis_comment_add_tag.argtypes = [vc_p, c_char_p, c_char_p]
def vorbis_comment_add_tag(vc, tag, comment):
libvorbis.vorbis_comment_add_tag(vc, tag, comment)
libvorbis.vorbis_comment_query.restype = c_char_p
libvorbis.vorbis_comment_query.argtypes = [vc_p, c_char_p, c_int]
def vorbis_comment_query(vc, tag, count):
libvorbis.vorbis_comment_query(vc, tag, count)
libvorbis.vorbis_comment_query_count.restype = c_int
libvorbis.vorbis_comment_query_count.argtypes = [vc_p, c_char_p]
def vorbis_comment_query_count(vc, tag):
libvorbis.vorbis_comment_query_count(vc, tag)
libvorbis.vorbis_comment_clear.restype = None
libvorbis.vorbis_comment_clear.argtypes = [vc_p]
def vorbis_comment_clear(vc):
libvorbis.vorbis_comment_clear(vc)
libvorbis.vorbis_block_init.restype = c_int
libvorbis.vorbis_block_init.argtypes = [vd_p, vb_p]
def vorbis_block_init(v,vb):
return libvorbis.vorbis_block_init(v,vb)
libvorbis.vorbis_block_clear.restype = c_int
libvorbis.vorbis_block_clear.argtypes = [vb_p]
def vorbis_block_clear(vb):
return libvorbis.vorbis_block_clear(vb)
libvorbis.vorbis_dsp_clear.restype = None
libvorbis.vorbis_dsp_clear.argtypes = [vd_p]
def vorbis_dsp_clear(v):
return libvorbis.vorbis_dsp_clear(v)
libvorbis.vorbis_granule_time.restype = c_double
libvorbis.vorbis_granule_time.argtypes = [vd_p, ogg_int64_t]
def vorbis_granule_time(v, granulepos):
return libvorbis.vorbis_granule_time(v, granulepos)
libvorbis.vorbis_version_string.restype = c_char_p
libvorbis.vorbis_version_string.argtypes = []
def vorbis_version_string():
return libvorbis.vorbis_version_string()
libvorbis.vorbis_analysis_init.restype = c_int
libvorbis.vorbis_analysis_init.argtypes = [vd_p, vi_p]
def vorbis_analysis_init(v, vi):
return libvorbis.vorbis_analysis_init(v, vi)
libvorbis.vorbis_commentheader_out.restype = c_int
libvorbis.vorbis_commentheader_out.argtypes = [vc_p, op_p]
def vorbis_commentheader_out(vc, op):
return libvorbis.vorbis_commentheader_out(vc, op)
libvorbis.vorbis_analysis_headerout.restype = c_int
libvorbis.vorbis_analysis_headerout.argtypes = [vd_p, vc_p, op_p, op_p, op_p]
def vorbis_analysis_headerout(v,vc, op, op_comm, op_code):
return libvorbis.vorbis_analysis_headerout(v,vc, op, op_comm, op_code)
libvorbis.vorbis_analysis_buffer.restype = c_float_p_p
libvorbis.vorbis_analysis_buffer.argtypes = [vd_p, c_int]
def vorbis_analysis_buffer(v, vals):
return libvorbis.vorbis_analysis_buffer(v, vals)
libvorbis.vorbis_analysis_wrote.restype = c_int
libvorbis.vorbis_analysis_wrote.argtypes = [vd_p, c_int]
def vorbis_analysis_wrote(v, vals):
return libvorbis.vorbis_analysis_wrote(v, vals)
libvorbis.vorbis_analysis_blockout.restype = c_int
libvorbis.vorbis_analysis_blockout.argtypes = [vd_p, vb_p]
def vorbis_analysis_blockout(v, vb):
return libvorbis.vorbis_analysis_blockout(v, vb)
libvorbis.vorbis_analysis.restype = c_int
libvorbis.vorbis_analysis.argtypes = [vb_p, op_p]
def vorbis_analysis(vb, op):
return libvorbis.vorbis_analysis(vb, op)
libvorbis.vorbis_bitrate_addblock.restype = c_int
libvorbis.vorbis_bitrate_addblock.argtypes = [vb_p]
def vorbis_bitrate_addblock(vb):
return libvorbis.vorbis_bitrate_addblock(vb)
libvorbis.vorbis_bitrate_flushpacket.restype = c_int
libvorbis.vorbis_bitrate_flushpacket.argtypes = [vd_p, op_p]
def vorbis_bitrate_flushpacket(vd, op):
return libvorbis.vorbis_bitrate_flushpacket(vd, op)
libvorbis.vorbis_synthesis_idheader.restype = c_int
libvorbis.vorbis_synthesis_idheader.argtypes = [op_p]
def vorbis_synthesis_idheader(op):
return libvorbis.vorbis_synthesis_idheader(op)
libvorbis.vorbis_synthesis_headerin.restype = c_int
libvorbis.vorbis_synthesis_headerin.argtypes = [vi_p, vc_p, op_p]
def vorbis_synthesis_headerin(vi, vc, op):
return libvorbis.vorbis_synthesis_headerin(vi, vc, op)
libvorbis.vorbis_synthesis_init.restype = c_int
libvorbis.vorbis_synthesis_init.argtypes = [vd_p, vi_p]
def vorbis_synthesis_init(v,vi):
return libvorbis.vorbis_synthesis_init(v,vi)
libvorbis.vorbis_synthesis_restart.restype = c_int
libvorbis.vorbis_synthesis_restart.argtypes = [vd_p]
def vorbis_synthesis_restart(v):
return libvorbis.vorbis_synthesis_restart(v)
libvorbis.vorbis_synthesis.restype = c_int
libvorbis.vorbis_synthesis.argtypes = [vb_p, op_p]
def vorbis_synthesis(vb, op):
return libvorbis.vorbis_synthesis(vb, op)
libvorbis.vorbis_synthesis_trackonly.restype = c_int
libvorbis.vorbis_synthesis_trackonly.argtypes = [vb_p, op_p]
def vorbis_synthesis_trackonly(vb, op):
return libvorbis.vorbis_synthesis_trackonly(vb, op)
libvorbis.vorbis_synthesis_blockin.restype = c_int
libvorbis.vorbis_synthesis_blockin.argtypes = [vd_p, vb_p]
def vorbis_synthesis_blockin(v, vb):
return libvorbis.vorbis_synthesis_blockin(v, vb)
libvorbis.vorbis_synthesis_pcmout.restype = c_int
libvorbis.vorbis_synthesis_pcmout.argtypes = [vd_p, c_float_p_p_p]
def vorbis_synthesis_pcmout(v, pcm):
return libvorbis.vorbis_synthesis_pcmout(v, pcm)
libvorbis.vorbis_synthesis_lapout.restype = c_int
libvorbis.vorbis_synthesis_lapout.argtypes = [vd_p, c_float_p_p_p]
def vorbis_synthesis_lapout(v, pcm):
return libvorbis.vorbis_synthesis_lapout(v, pcm)
libvorbis.vorbis_synthesis_read.restype = c_int
libvorbis.vorbis_synthesis_read.argtypes = [vd_p, c_int]
def vorbis_synthesis_read(v, samples):
return libvorbis.vorbis_synthesis_read(v, samples)
libvorbis.vorbis_packet_blocksize.restype = c_long
libvorbis.vorbis_packet_blocksize.argtypes = [vi_p, op_p]
def vorbis_packet_blocksize(vi, op):
return libvorbis.vorbis_packet_blocksize(vi, op)
libvorbis.vorbis_synthesis_halfrate.restype = c_int
libvorbis.vorbis_synthesis_halfrate.argtypes = [vi_p, c_int]
def vorbis_synthesis_halfrate(v, flag):
return libvorbis.vorbis_synthesis_halfrate(v, flag)
libvorbis.vorbis_synthesis_halfrate_p.restype = c_int
libvorbis.vorbis_synthesis_halfrate_p.argtypes = [vi_p]
def vorbis_synthesis_halfrate_p(vi):
return libvorbis.vorbis_synthesis_halfrate_p(vi)
OV_FALSE = -1
OV_EOF = -2
OV_HOLE = -3
OV_EREAD = -128
OV_EFAULT = -129
OV_EIMPL =-130
OV_EINVAL =-131
OV_ENOTVORBIS =-132
OV_EBADHEADER =-133
OV_EVERSION =-134
OV_ENOTAUDIO =-135
OV_EBADPACKET =-136
OV_EBADLINK =-137
OV_ENOSEEK =-138
# end of codecs
# vorbisfile
read_func = ctypes.CFUNCTYPE(c_size_t,
c_void_p,
c_size_t,
c_size_t,
c_void_p)
seek_func = ctypes.CFUNCTYPE(c_int,
c_void_p,
ogg_int64_t,
c_int)
close_func = ctypes.CFUNCTYPE(c_int,
c_void_p)
tell_func = ctypes.CFUNCTYPE(c_long,
c_void_p)
class ov_callbacks(ctypes.Structure):
"""
Wrapper for:
typedef struct ov_callbacks;
"""
_fields_ = [("read_func", read_func),
("seek_func", seek_func),
("close_func", close_func),
("tell_func", tell_func)]
NOTOPEN = 0
PARTOPEN = 1
OPENED = 2
STREAMSET = 3
INITSET = 4
class OggVorbis_File(ctypes.Structure):
"""
Wrapper for:
typedef struct OggVorbis_File OggVorbis_File;
"""
_fields_ = [("datasource", c_void_p),
("seekable", c_int),
("offset", ogg_int64_t),
("end", ogg_int64_t),
("oy", ogg_sync_state),
("links", c_int),
("offsets", ogg_int64_t_p),
("dataoffsets", ogg_int64_t_p),
("serialnos", c_long_p),
("pcmlengths", ogg_int64_t_p),
("vi", vi_p),
("vc", vc_p),
("pcm_offset", ogg_int64_t),
("ready_state", c_int),
("current_serialno", c_long),
("current_link", c_int),
("bittrack", c_double),
("samptrack", c_double),
("os", ogg_stream_state),
("vd", vorbis_dsp_state),
("vb", vorbis_block),
("callbacks", ov_callbacks)]
vf_p = POINTER(OggVorbis_File)
libvorbisfile.ov_clear.restype = c_int
libvorbisfile.ov_clear.argtypes = [vf_p]
def ov_clear(vf):
return libvorbisfile.ov_clear(vf)
libvorbisfile.ov_fopen.restype = c_int
libvorbisfile.ov_fopen.argtypes = [c_char_p, vf_p]
def ov_fopen(path, vf):
return libvorbisfile.ov_fopen(to_char_p(path), vf)
libvorbisfile.ov_open_callbacks.restype = c_int
libvorbisfile.ov_open_callbacks.argtypes = [c_void_p, vf_p, c_char_p, c_long, ov_callbacks]
def ov_open_callbacks(datasource, vf, initial, ibytes, callbacks):
return libvorbisfile.ov_open_callbacks(datasource, vf, initial, ibytes, callbacks)
def ov_open(*args, **kw):
raise PyOggError("ov_open is not supported, please use ov_fopen instead")
def ov_test(*args, **kw):
raise PyOggError("ov_test is not supported")
libvorbisfile.ov_test_callbacks.restype = c_int
libvorbisfile.ov_test_callbacks.argtypes = [c_void_p, vf_p, c_char_p, c_long, ov_callbacks]
def ov_test_callbacks(datasource, vf, initial, ibytes, callbacks):
return libvorbisfile.ov_test_callbacks(datasource, vf, initial, ibytes, callbacks)
libvorbisfile.ov_test_open.restype = c_int
libvorbisfile.ov_test_open.argtypes = [vf_p]
def ov_test_open(vf):
return libvorbisfile.ov_test_open(vf)
libvorbisfile.ov_bitrate.restype = c_long
libvorbisfile.ov_bitrate.argtypes = [vf_p, c_int]
def ov_bitrate(vf, i):
return libvorbisfile.ov_bitrate(vf, i)
libvorbisfile.ov_bitrate_instant.restype = c_long
libvorbisfile.ov_bitrate_instant.argtypes = [vf_p]
def ov_bitrate_instant(vf):
return libvorbisfile.ov_bitrate_instant(vf)
libvorbisfile.ov_streams.restype = c_long
libvorbisfile.ov_streams.argtypes = [vf_p]
def ov_streams(vf):
return libvorbisfile.ov_streams(vf)
libvorbisfile.ov_seekable.restype = c_long
libvorbisfile.ov_seekable.argtypes = [vf_p]
def ov_seekable(vf):
return libvorbisfile.ov_seekable(vf)
libvorbisfile.ov_serialnumber.restype = c_long
libvorbisfile.ov_serialnumber.argtypes = [vf_p, c_int]
def ov_serialnumber(vf, i):
return libvorbisfile.ov_serialnumber(vf, i)
libvorbisfile.ov_raw_total.restype = ogg_int64_t
libvorbisfile.ov_raw_total.argtypes = [vf_p, c_int]
def ov_raw_total(vf, i):
return libvorbisfile.ov_raw_total(vf, i)
libvorbisfile.ov_pcm_total.restype = ogg_int64_t
libvorbisfile.ov_pcm_total.argtypes = [vf_p, c_int]
def ov_pcm_total(vf, i):
return libvorbisfile.ov_pcm_total(vf, i)
libvorbisfile.ov_time_total.restype = c_double
libvorbisfile.ov_time_total.argtypes = [vf_p, c_int]
def ov_time_total(vf, i):
return libvorbisfile.ov_time_total(vf, i)
libvorbisfile.ov_raw_seek.restype = c_int
libvorbisfile.ov_raw_seek.argtypes = [vf_p, ogg_int64_t]
def ov_raw_seek(vf, pos):
return libvorbisfile.ov_raw_seek(vf, pos)
libvorbisfile.ov_pcm_seek.restype = c_int
libvorbisfile.ov_pcm_seek.argtypes = [vf_p, ogg_int64_t]
def ov_pcm_seek(vf, pos):
return libvorbisfile.ov_pcm_seek(vf, pos)
libvorbisfile.ov_pcm_seek_page.restype = c_int
libvorbisfile.ov_pcm_seek_page.argtypes = [vf_p, ogg_int64_t]
def ov_pcm_seek_page(vf, pos):
return libvorbisfile.ov_pcm_seek_page(vf, pos)
libvorbisfile.ov_time_seek.restype = c_int
libvorbisfile.ov_time_seek.argtypes = [vf_p, c_double]
def ov_time_seek(vf, pos):
return libvorbisfile.ov_time_seek(vf, pos)
libvorbisfile.ov_time_seek_page.restype = c_int
libvorbisfile.ov_time_seek_page.argtypes = [vf_p, c_double]
def ov_time_seek_page(vf, pos):
return libvorbisfile.ov_time_seek_page(vf, pos)
libvorbisfile.ov_raw_seek_lap.restype = c_int
libvorbisfile.ov_raw_seek_lap.argtypes = [vf_p, ogg_int64_t]
def ov_raw_seek_lap(vf, pos):
return libvorbisfile.ov_raw_seek_lap(vf, pos)
libvorbisfile.ov_pcm_seek_lap.restype = c_int
libvorbisfile.ov_pcm_seek_lap.argtypes = [vf_p, ogg_int64_t]
def ov_pcm_seek_lap(vf, pos):
return libvorbisfile.ov_pcm_seek_lap(vf, pos)
libvorbisfile.ov_pcm_seek_page_lap.restype = c_int
libvorbisfile.ov_pcm_seek_page_lap.argtypes = [vf_p, ogg_int64_t]
def ov_pcm_seek_page_lap(vf, pos):
return libvorbisfile.ov_pcm_seek_page_lap(vf, pos)
libvorbisfile.ov_time_seek_lap.restype = c_int
libvorbisfile.ov_time_seek_lap.argtypes = [vf_p, c_double]
def ov_time_seek_lap(vf, pos):
return libvorbisfile.ov_time_seek_lap(vf, pos)
libvorbisfile.ov_time_seek_page_lap.restype = c_int
libvorbisfile.ov_time_seek_page_lap.argtypes = [vf_p, c_double]
def ov_time_seek_page_lap(vf, pos):
return libvorbisfile.ov_time_seek_page_lap(vf, pos)
libvorbisfile.ov_raw_tell.restype = ogg_int64_t
libvorbisfile.ov_raw_tell.argtypes = [vf_p]
def ov_raw_tell(vf):
return libvorbisfile.ov_raw_tell(vf)
libvorbisfile.ov_pcm_tell.restype = ogg_int64_t
libvorbisfile.ov_pcm_tell.argtypes = [vf_p]
def ov_pcm_tell(vf):
return libvorbisfile.ov_pcm_tell(vf)
libvorbisfile.ov_time_tell.restype = c_double
libvorbisfile.ov_time_tell.argtypes = [vf_p]
def ov_time_tell(vf):
return libvorbisfile.ov_time_tell(vf)
libvorbisfile.ov_info.restype = vi_p
libvorbisfile.ov_info.argtypes = [vf_p, c_int]
def ov_info(vf, link):
return libvorbisfile.ov_info(vf, link)
libvorbisfile.ov_comment.restype = vc_p
libvorbisfile.ov_comment.argtypes = [vf_p, c_int]
def ov_comment(vf, link):
return libvorbisfile.ov_comment(vf, link)
libvorbisfile.ov_read_float.restype = c_long
libvorbisfile.ov_read_float.argtypes = [vf_p, c_float_p_p_p, c_int, c_int_p]
def ov_read_float(vf, pcm_channels, samples, bitstream):
return libvorbisfile.ov_read_float(vf, pcm_channels, samples, bitstream)
filter_ = ctypes.CFUNCTYPE(None,
c_float_p_p,
c_long,
c_long,
c_void_p)
try:
libvorbisfile.ov_read_filter.restype = c_long
libvorbisfile.ov_read_filter.argtypes = [vf_p, c_char_p, c_int, c_int, c_int, c_int, c_int_p, filter_, c_void_p]
def ov_read_filter(vf, buffer, length, bigendianp, word, sgned, bitstream, filter_, filter_param):
return libvorbisfile.ov_read_filter(vf, buffer, length, bigendianp, word, sgned, bitstream, filter_, filter_param)
except:
pass
libvorbisfile.ov_read.restype = c_long
libvorbisfile.ov_read.argtypes = [vf_p, c_char_p, c_int, c_int, c_int, c_int, c_int_p]
def ov_read(vf, buffer, length, bigendianp, word, sgned, bitstream):
return libvorbisfile.ov_read(vf, buffer, length, bigendianp, word, sgned, bitstream)
libvorbisfile.ov_crosslap.restype = c_int
libvorbisfile.ov_crosslap.argtypes = [vf_p, vf_p]
def ov_crosslap(vf1, cf2):
return libvorbisfile.ov_crosslap(vf1, vf2)
libvorbisfile.ov_halfrate.restype = c_int
libvorbisfile.ov_halfrate.argtypes = [vf_p, c_int]
def ov_halfrate(vf, flag):
return libvorbisfile.ov_halfrate(vf, flag)
libvorbisfile.ov_halfrate_p.restype = c_int
libvorbisfile.ov_halfrate_p.argtypes = [vf_p]
def ov_halfrate_p(vf):
return libvorbisfile.ov_halfrate_p(vf)
# end of vorbisfile
try:
# vorbisenc
# Sanity check also satisfies mypy type checking
assert libvorbisenc is not None
libvorbisenc.vorbis_encode_init.restype = c_int
libvorbisenc.vorbis_encode_init.argtypes = [vi_p, c_long, c_long, c_long, c_long, c_long]
def vorbis_encode_init(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate):
return libvorbisenc.vorbis_encode_init(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate)
libvorbisenc.vorbis_encode_setup_managed.restype = c_int
libvorbisenc.vorbis_encode_setup_managed.argtypes = [vi_p, c_long, c_long, c_long, c_long, c_long]
def vorbis_encode_setup_managed(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate):
return libvorbisenc.vorbis_encode_setup_managed(vi, channels, rate, max_bitrate, nominal_bitrate, min_bitrate)
libvorbisenc.vorbis_encode_setup_vbr.restype = c_int
libvorbisenc.vorbis_encode_setup_vbr.argtypes = [vi_p, c_long, c_long, c_float]
def vorbis_encode_setup_vbr(vi, channels, rate, quality):
return libvorbisenc.vorbis_encode_setup_vbr(vi, channels, rate, quality)
libvorbisenc.vorbis_encode_init_vbr.restype = c_int
libvorbisenc.vorbis_encode_init_vbr.argtypes = [vi_p, c_long, c_long, c_float]
def vorbis_encode_init_vbr(vi, channels, rate, quality):
return libvorbisenc.vorbis_encode_init_vbr(vi, channels, rate, quality)
libvorbisenc.vorbis_encode_setup_init.restype = c_int
libvorbisenc.vorbis_encode_setup_init.argtypes = [vi_p]
def vorbis_encode_setup_init(vi):
return libvorbisenc.vorbis_encode_setup_init(vi)
libvorbisenc.vorbis_encode_ctl.restype = c_int
libvorbisenc.vorbis_encode_ctl.argtypes = [vi_p, c_int, c_void_p]
def vorbis_encode_ctl(vi, number, arg):
return libvorbisenc.vorbis_encode_ctl(vi, number, arg)
class ovectl_ratemanage_arg(ctypes.Structure):
_fields_ = [("management_active", c_int),
("bitrate_hard_min", c_long),
("bitrate_hard_max", c_long),
("bitrate_hard_window", c_double),
("bitrate_av_lo", c_long),
("bitrate_av_hi", c_long),
("bitrate_av_window", c_double),
("bitrate_av_window_center", c_double)]
class ovectl_ratemanage2_arg(ctypes.Structure):
_fields_ = [("management_active", c_int),
("bitrate_limit_min_kbps", c_long),
("bitrate_limit_max_kbps", c_long),
("bitrate_limit_reservoir_bits", c_long),
("bitrate_limit_reservoir_bias", c_double),
("bitrate_average_kbps", c_long),
("bitrate_average_damping", c_double)]
OV_ECTL_RATEMANAGE2_GET =0x14
OV_ECTL_RATEMANAGE2_SET =0x15
OV_ECTL_LOWPASS_GET =0x20
OV_ECTL_LOWPASS_SET =0x21
OV_ECTL_IBLOCK_GET =0x30
OV_ECTL_IBLOCK_SET =0x31
OV_ECTL_COUPLING_GET =0x40
OV_ECTL_COUPLING_SET =0x41
OV_ECTL_RATEMANAGE_GET =0x10
OV_ECTL_RATEMANAGE_SET =0x11
OV_ECTL_RATEMANAGE_AVG =0x12
OV_ECTL_RATEMANAGE_HARD =0x13
# end of vorbisenc
except:
pass

View File

@@ -0,0 +1,161 @@
import ctypes
from . import vorbis
from .audio_file import AudioFile
from .pyogg_error import PyOggError
# TODO: Issue #70: Vorbis files with multiple logical bitstreams could
# be supported by chaining VorbisFile instances (with say a 'next'
# attribute that points to the next VorbisFile that would contain the
# PCM for the next logical bitstream). A considerable constraint to
# implementing this was that examples files that demonstrated multiple
# logical bitstreams couldn't be found or created. Note that even
# Audacity doesn't handle multiple logical bitstreams (see
# https://wiki.audacityteam.org/wiki/OGG#Importing_multiple_stream_files).
# TODO: Issue #53: Unicode file names are not well supported.
# They may work in macOS and Linux, they don't work under Windows.
class VorbisFile(AudioFile):
def __init__(self,
path: str,
bytes_per_sample: int = 2,
signed:bool = True) -> None:
"""Load an OggVorbis File.
path specifies the location of the Vorbis file. Unicode
filenames may not work correctly under Windows.
bytes_per_sample specifies the word size of the PCM. It may
be either 1 or 2. Specifying one byte per sample will save
memory but will likely decrease the quality of the decoded
audio.
Only Vorbis files with a single logical bitstream are
supported.
"""
# Sanity check the number of bytes per sample
assert bytes_per_sample==1 or bytes_per_sample==2
# Sanity check that the vorbis library is available (for mypy)
assert vorbis.libvorbisfile is not None
#: Bytes per sample
self.bytes_per_sample = bytes_per_sample
#: Samples are signed (rather than unsigned)
self.signed = signed
# Create a Vorbis File structure
vf = vorbis.OggVorbis_File()
# Attempt to open the Vorbis file
error = vorbis.libvorbisfile.ov_fopen(
vorbis.to_char_p(path),
ctypes.byref(vf)
)
# Check for errors during opening
if error != 0:
raise PyOggError(
("File '{}' couldn't be opened or doesn't exist. "+
"Error code : {}").format(path, error)
)
# Extract info from the Vorbis file
info = vorbis.libvorbisfile.ov_info(
ctypes.byref(vf),
-1 # the current logical bitstream
)
#: Number of channels in audio file.
self.channels = info.contents.channels
#: Number of samples per second (per channel), 44100 for
# example.
self.frequency = info.contents.rate
# Extract the total number of PCM samples for the first
# logical bitstream
pcm_length_samples = vorbis.libvorbisfile.ov_pcm_total(
ctypes.byref(vf),
0 # to extract the length of the first logical bitstream
)
# Create a memory block to store the entire PCM
Buffer = (
ctypes.c_char
* (
pcm_length_samples
* self.bytes_per_sample
* self.channels
)
)
self.buffer = Buffer()
# Create a pointer to the newly allocated memory. It
# seems we can only do pointer arithmetic on void
# pointers. See
# https://mattgwwalker.wordpress.com/2020/05/30/pointer-manipulation-in-python/
buf_ptr = ctypes.cast(
ctypes.pointer(self.buffer),
ctypes.c_void_p
)
# Storage for the index of the logical bitstream
bitstream_previous = None
bitstream = ctypes.c_int()
# Set bytes remaining to read into PCM
read_size = len(self.buffer)
while True:
# Convert buffer pointer to the desired type
ptr = ctypes.cast(
buf_ptr,
ctypes.POINTER(ctypes.c_char)
)
# Attempt to decode PCM from the Vorbis file
result = vorbis.libvorbisfile.ov_read(
ctypes.byref(vf),
ptr,
read_size,
0, # Little endian
self.bytes_per_sample,
int(self.signed),
ctypes.byref(bitstream)
)
# Check for errors
if result < 0:
raise PyOggError(
"An error occurred decoding the Vorbis file: "+
f"Error code: {result}"
)
# Check that the bitstream hasn't changed as we only
# support Vorbis files with a single logical bitstream.
if bitstream_previous is None:
bitstream_previous = bitstream
else:
if bitstream_previous != bitstream:
raise PyOggError(
"PyOgg currently supports Vorbis files "+
"with only one logical stream"
)
# Check for end of file
if result == 0:
break
# Calculate the number of bytes remaining to read into PCM
read_size -= result
# Update the pointer into the buffer
buf_ptr.value += result
# Close the file and clean up memory
vorbis.libvorbisfile.ov_clear(ctypes.byref(vf))

View File

@@ -0,0 +1,110 @@
import ctypes
from . import vorbis
from .pyogg_error import PyOggError
class VorbisFileStream:
def __init__(self, path, buffer_size=8192):
self.exists = False
self._buffer_size = buffer_size
self.vf = vorbis.OggVorbis_File()
error = vorbis.ov_fopen(path, ctypes.byref(self.vf))
if error != 0:
raise PyOggError("file couldn't be opened or doesn't exist. Error code : {}".format(error))
info = vorbis.ov_info(ctypes.byref(self.vf), -1)
#: Number of channels in audio file.
self.channels = info.contents.channels
#: Number of samples per second (per channel). Always
# 48,000.
self.frequency = info.contents.rate
array = (ctypes.c_char*(self._buffer_size*self.channels))()
self.buffer_ = ctypes.cast(ctypes.pointer(array), ctypes.c_char_p)
self.bitstream = ctypes.c_int()
self.bitstream_pointer = ctypes.pointer(self.bitstream)
self.exists = True # TODO: is this the best place for this statement?
#: Bytes per sample
self.bytes_per_sample = 2 # TODO: Where is this defined?
def __del__(self):
if self.exists:
vorbis.ov_clear(ctypes.byref(self.vf))
self.exists = False
def clean_up(self):
vorbis.ov_clear(ctypes.byref(self.vf))
self.exists = False
def get_buffer(self):
"""get_buffer() -> bytesBuffer, bufferLength
Returns None when all data has been read from the file.
"""
if not self.exists:
return None
buffer = []
total_bytes_written = 0
while True:
new_bytes = vorbis.ov_read(ctypes.byref(self.vf), self.buffer_, self._buffer_size*self.channels - total_bytes_written, 0, 2, 1, self.bitstream_pointer)
array_ = ctypes.cast(self.buffer_, ctypes.POINTER(ctypes.c_char*(self._buffer_size*self.channels))).contents
buffer.append(array_.raw[:new_bytes])
total_bytes_written += new_bytes
if new_bytes == 0 or total_bytes_written >= self._buffer_size*self.channels:
break
out_buffer = b"".join(buffer)
if total_bytes_written == 0:
self.clean_up()
return(None)
return out_buffer
def get_buffer_as_array(self):
"""Provides the buffer as a NumPy array.
Note that the underlying data type is 16-bit signed
integers.
Does not copy the underlying data, so the returned array
should either be processed or copied before the next call
to get_buffer() or get_buffer_as_array().
"""
import numpy # type: ignore
# Read the next samples from the stream
buf = self.get_buffer()
# Check if we've come to the end of the stream
if buf is None:
return None
# Convert the bytes buffer to a NumPy array
array = numpy.frombuffer(
buf,
dtype=numpy.int16
)
# Reshape the array
return array.reshape(
(len(buf)
// self.bytes_per_sample
// self.channels,
self.channels)
)

2
LXST/Common.py Normal file
View File

@@ -0,0 +1,2 @@
def nop():
pass

135
LXST/Generators.py Normal file
View File

@@ -0,0 +1,135 @@
import os
import RNS
import math
import time
import threading
import numpy as np
from collections import deque
from .Codecs import Codec, CodecError
from .Sources import LocalSource
RNS.loglevel = RNS.LOG_DEBUG
class ToneSource(LocalSource):
DEFAULT_FRAME_MS = 80
DEFAULT_SAMPLERATE = 48000
DEFAULT_FREQUENCY = 400
EASE_TIME_MS = 20
def __init__(self, frequency=DEFAULT_FREQUENCY, gain=0.1, ease=True, ease_time_ms=EASE_TIME_MS,
target_frame_ms=DEFAULT_FRAME_MS, codec=None, sink=None, channels=1):
self.target_frame_ms = target_frame_ms
self.samplerate = self.DEFAULT_SAMPLERATE
self.channels = channels
self.bitdepth = 32
self.frequency = frequency
self._gain = gain
self.gain = self._gain
self.ease = ease
self.theta = 0
self.ease_gain = 0
self.ease_time_ms = ease_time_ms
self.ease_step = 0
self.gain_step = 0
self.easing_out = False
self.should_run = False
self.generate_thread = None
self.generate_lock = threading.Lock()
self._codec = None
self.codec = codec
self.sink = sink
@property
def codec(self):
return self._codec
@codec.setter
def codec(self, codec):
if codec == None:
self._codec = None
elif not issubclass(type(codec), Codec):
raise CodecError(f"Invalid codec specified for {self}")
else:
self._codec = codec
if self.codec.preferred_samplerate:
self.samplerate = self.codec.preferred_samplerate
if self.codec.frame_quanta_ms:
if self.target_frame_ms%self.codec.frame_quanta_ms != 0:
self.target_frame_ms = math.ceil(self.target_frame_ms/self.codec.frame_quanta_ms)*self.codec.frame_quanta_ms
RNS.log(f"{self} target frame time quantized to {self.target_frame_ms}ms due to codec frame quanta", RNS.LOG_DEBUG)
if self.codec.frame_max_ms:
if self.target_frame_ms > self.codec.frame_max_ms:
self.target_frame_ms = self.codec.frame_max_ms
RNS.log(f"{self} target frame time clamped to {self.target_frame_ms}ms due to codec frame limit", RNS.LOG_DEBUG)
if self.codec.valid_frame_ms:
if not self.target_frame_ms in self.codec.valid_frame_ms:
self.target_frame_ms = min(self.codec.valid_frame_ms, key=lambda t:abs(t-self.target_frame_ms))
RNS.log(f"{self} target frame time clamped to closest valid value of {self.target_frame_ms}ms ", RNS.LOG_DEBUG)
self.samples_per_frame = math.ceil((self.target_frame_ms/1000)*self.samplerate)
self.frame_time = self.samples_per_frame/self.samplerate
self.ease_step = 1/(self.samplerate*(self.ease_time_ms/1000))
self.gain_step = 0.02/(self.samplerate*(self.ease_time_ms/1000))
def start(self):
if not self.should_run:
RNS.log(f"{self} starting at {self.samples_per_frame} samples per frame, {self.channels} channels", RNS.LOG_DEBUG)
self.ease_gain = 0 if self.ease else 1
self.should_run = True
self.generate_thread = threading.Thread(target=self.__generate_job, daemon=True)
self.generate_thread.start()
def stop(self):
if not self.ease:
self.should_run = False
else:
self.easing_out = True
@property
def running(self):
return self.should_run and not self.easing_out
def __generate(self):
frame_samples = np.zeros((self.samples_per_frame, self.channels), dtype="float32")
step = (self.frequency * 2 * math.pi) / self.samplerate
for n in range(0, self.samples_per_frame):
self.theta += step
amplitude = math.sin(self.theta)*self._gain*self.ease_gain
for c in range(0, self.channels):
frame_samples[n, c] = amplitude
if self.gain > self._gain:
self._gain += self.gain_step
if self._gain > self.gain: self._gain = self.gain
if self.gain < self._gain:
self._gain -= self.gain_step
if self._gain < self.gain: self._gain = self.gain
if self.ease:
if self.ease_gain < 1.0 and not self.easing_out:
self.ease_gain += self.ease_step
if self.ease_gain > 1.0: self.ease_gain = 1.0
elif self.easing_out and self.ease_gain > 0.0:
self.ease_gain -= self.ease_step
if self.ease_gain <= 0.0:
self.ease_gain = 0.0
self.easing_out = False
self.should_run = False
return frame_samples
def __generate_job(self):
with self.generate_lock:
while self.should_run:
if self.codec and self.sink and self.sink.can_receive(from_source=self):
frame_samples = self.__generate()
self.last_samples = frame_samples
frame = self.codec.encode(frame_samples)
self.sink.handle_frame(frame, self)
time.sleep(self.frame_time*0.1)

149
LXST/Mixer.py Normal file
View File

@@ -0,0 +1,149 @@
import RNS
import LXST
import time
import math
import threading
import numpy as np
from collections import deque
from inspect import currentframe
from .Codecs import Codec, Raw
from .Codecs.Codec import resample
from .Sinks import LocalSink
from .Sources import LocalSource, Backend
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):
self.incoming_frames = {}
self.target_frame_ms = target_frame_ms
self.frame_time = self.target_frame_ms/1000
self.should_run = False
self.mixer_thread = None
self.mixer_lock = threading.Lock()
self.insert_lock = threading.Lock()
self.bitdepth = 32
self.channels = None
self.samplerate = None
self._sink = None
self._source = None
self._codec = None
if samplerate: self.samplerate = samplerate
if sink: self.sink = sink
if codec: self.codec = codec
def start(self):
if not self.should_run:
RNS.log(f"{self} starting", RNS.LOG_DEBUG)
self.should_run = True
self.mixer_thread = threading.Thread(target=self._mixer_job, daemon=True)
self.mixer_thread.start()
def stop(self):
self.should_run = False
def can_receive(self, from_source):
if not from_source in self.incoming_frames:
return True
elif len(self.incoming_frames[from_source]) < self.MAX_FRAMES:
return True
else:
return False
def handle_frame(self, frame, source, decoded=False):
with self.insert_lock:
if not source in self.incoming_frames:
self.incoming_frames[source] = deque(maxlen=self.MAX_FRAMES)
if not self.channels:
self.channels = source.channels
if not self.samplerate:
self.samplerate = source.samplerate
self.samples_per_frame = math.ceil((self.target_frame_ms/1000)*self.samplerate)
self.frame_time = self.samples_per_frame/self.samplerate
RNS.log(f"{self} samplerate set to {RNS.prettyfrequency(self.samplerate)}", RNS.LOG_DEBUG)
RNS.log(f"{self} frame time is {RNS.prettyshorttime(self.frame_time)}")
if not decoded: frame_samples = source.codec.decode(frame)
else: frame_samples = frame
# TODO: Add resampling for all source types
# if CODEC_OUTPUT_RATE != self.samplerate:
# frame_samples = resample(frame_samples, source.bitdepth, source.channels, CODEC_OUTPUT_RATE, self.samplerate)
self.incoming_frames[source].append(frame_samples)
def _mixer_job(self):
with self.mixer_lock:
while self.should_run:
if self.sink and self.sink.can_receive():
source_count = 0
mixed_frame = None
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
source_count += 1
if source_count > 0:
if self.codec: self.sink.handle_frame(self.codec.encode(mixed_frame), self)
else: self.sink.handle_frame(mixed_frame, self)
else:
time.sleep(self.frame_time*0.1)
else:
time.sleep(self.frame_time*0.1)
@property
def codec(self):
return self._codec
@codec.setter
def codec(self, codec):
if codec == None:
self._codec = None
elif not issubclass(type(codec), Codec):
raise CodecError(f"Invalid codec specified for {self}")
else:
self._codec = codec
if self.codec.preferred_samplerate:
self.samplerate = self.codec.preferred_samplerate
else:
self.samplerate = Backend.SAMPLERATE
if self.codec.frame_quanta_ms:
if self.target_frame_ms%self.codec.frame_quanta_ms != 0:
self.target_frame_ms = math.ceil(self.target_frame_ms/self.codec.frame_quanta_ms)*self.codec.frame_quanta_ms
RNS.log(f"{self} target frame time quantized to {self.target_frame_ms}ms due to codec frame quanta", RNS.LOG_DEBUG)
if self.codec.frame_max_ms:
if self.target_frame_ms > self.codec.frame_max_ms:
self.target_frame_ms = self.codec.frame_max_ms
RNS.log(f"{self} target frame time clamped to {self.target_frame_ms}ms due to codec frame limit", RNS.LOG_DEBUG)
if self.codec.valid_frame_ms:
if not self.target_frame_ms in self.codec.valid_frame_ms:
self.target_frame_ms = min(self.codec.valid_frame_ms, key=lambda t:abs(t-self.target_frame_ms))
RNS.log(f"{self} target frame time clamped to closest valid value of {self.target_frame_ms}ms ", RNS.LOG_DEBUG)
@property
def source(self):
return self._source
@source.setter
def source(self, source):
self._source = source
@property
def sink(self):
return self._sink
@sink.setter
def sink(self, sink):
self._sink = sink

149
LXST/Network.py Normal file
View File

@@ -0,0 +1,149 @@
import RNS
import time
import threading
from .Sinks import RemoteSink
from .Sources import RemoteSource
from .Codecs import Null, codec_header_byte, codec_type
from collections import deque
from RNS.vendor import umsgpack as mp
FIELD_SIGNALLING = 0x00
FIELD_FRAMES = 0x01
class SignallingReceiver():
def __init__(self, proxy=None):
# TODO: Add inband signalling scheduler
self.outgoing_signals = deque()
self.proxy = proxy
def handle_signalling_from(self, source):
source.set_packet_callback(self._packet)
def signalling_received(self, signals, source):
if self.proxy: self.proxy.signalling_received(signals, source)
def signal(self, signal, destination, immediate=True):
signalling_data = {FIELD_SIGNALLING:[signal]}
if immediate:
signalling_packet = RNS.Packet(destination, mp.packb(signalling_data), create_receipt=False)
signalling_packet.send()
else:
# TODO: Add inband signalling scheduler
pass
def _packet(self, data, packet, unpacked=None):
try:
if not unpacked: unpacked = mp.unpackb(data)
source = packet.link if hasattr(packet, "link") else None
if type(unpacked) == dict:
if FIELD_SIGNALLING in unpacked:
signalling = unpacked[FIELD_SIGNALLING]
if type(signalling) == list:
self.signalling_received(signalling, source)
else:
self.signalling_received([signalling], source)
except Exception as e:
RNS.log(f"{self} could not process incoming packet: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
class Packetizer(RemoteSink):
def __init__(self, destination, failure_callback=None):
self.destination = destination
self.should_run = False
self.source = None
self.transmit_failure = False
self.__failure_calback = failure_callback
def handle_frame(self, frame, source=None):
if type(self.destination) == RNS.Link and not self.destination.status == RNS.Link.ACTIVE:
return
# TODO: Add inband signalling scheduler
frame = codec_header_byte(type(self.source.codec))+frame
packet_data = {FIELD_FRAMES:frame}
frame_packet = RNS.Packet(self.destination, mp.packb(packet_data), create_receipt=False)
if frame_packet.send() == False:
self.transmit_failure = True
if callable(self.__failure_calback): self.__failure_calback()
# TODO: Remove testing
# if not hasattr(self, "frames"):
# self.frames = 0
# self.frame_bytes = 0
# self.total_bytes = 0
# self.total_bytes = 0
# self.overhead_bytes = 0
# self.frames += 1
# self.frame_bytes += len(frame)
# self.total_bytes += len(frame_packet.raw)
# self.overhead_bytes += len(frame_packet.raw)-len(frame)
# self.overhead_ratio = self.frame_bytes / self.total_bytes
# if not hasattr(self, "started"):
# self.started = time.time()
# rate = 0
# codec_rate = 0
# else:
# rate = (self.total_bytes*8)/(time.time()-self.started)
# codec_rate = (self.frame_bytes*8)/(time.time()-self.started)
# print(f"\rP={len(frame_packet.raw)}/{len(frame)}/{len(frame_packet.raw)-len(frame)} N={self.frames} E={round(self.overhead_ratio*100,0)}% O={RNS.prettysize(self.total_bytes)} F={RNS.prettysize(self.frame_bytes)} S={RNS.prettyspeed(rate)} C={RNS.prettyspeed(codec_rate)}", end=" ")
def start(self):
if not self.should_run:
RNS.log(f"{self} starting", RNS.LOG_DEBUG)
self.should_run = True
def stop(self):
self.should_run = False
class LinkSource(RemoteSource, SignallingReceiver):
def __init__(self, link, signalling_receiver, sink=None):
self.should_run = False
self.link = link
self.sink = sink
self.codec = Null()
self.pipeline = None
self.proxy = signalling_receiver
self.receive_lock = threading.Lock()
self.link.set_packet_callback(self._packet)
def _packet(self, data, packet):
with self.receive_lock:
try:
unpacked = mp.unpackb(data)
if type(unpacked) == dict:
if FIELD_FRAMES in unpacked:
frames = unpacked[FIELD_FRAMES]
if type(frames) != list: frames = [frames]
for frame in frames:
frame_codec = codec_type(frame[0])
if self.codec and self.sink:
if type(self.codec) != frame_codec:
RNS.log(f"Remote switched codec to {frame_codec}", RNS.LOG_DEBUG)
if self.pipeline: self.pipeline.codec = frame_codec()
else: self.codec = frame_codec(); self.codec.sink = self.sink
decoded_frame = self.codec.decode(frame[1:])
if self.codec.channels: self.channels = self.codec.channels
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 FIELD_SIGNALLING in unpacked:
super()._packet(data=None, packet=packet, unpacked=unpacked)
except Exception as e:
RNS.log(f"{self} could not process incoming packet: {e}", RNS.LOG_ERROR)
RNS.trace_exception(e)
def start(self):
if not self.should_run:
RNS.log(f"{self} starting", RNS.LOG_DEBUG)
self.should_run = True
def stop(self):
self.should_run = False

60
LXST/Pipeline.py Normal file
View File

@@ -0,0 +1,60 @@
from .Sources import *
from .Sinks import *
from .Codecs import *
from .Mixer import Mixer
from .Network import Packetizer
class PipelineError(Exception):
pass
class Pipeline():
def __init__(self, source, codec, sink, processor = None):
if not issubclass(type(source), Source): raise PipelineError("Audio pipeline initialised with invalid source")
if not issubclass(type(sink), Sink) : raise PipelineError("Audio pipeline initialised with invalid sink")
if not issubclass(type(codec), Codec) : raise PipelineError("Audio pipeline initialised with invalid codec")
self._codec = None
self.source = source
self.source.pipeline = self
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
@property
def codec(self):
if self.source:
return self.source.codec
else:
return None
@codec.setter
def codec(self, codec):
if not self._codec == codec:
self._codec = codec
self.source.codec = self._codec
self.source.codec.sink = self.sink
self.source.codec.source = self.source
@property
def sink(self):
if self.source:
return self.source.sink
else:
return None
@property
def running(self):
return self.source.should_run
def start(self):
if not self.running:
self.source.start()
def stop(self):
if self.running:
self.source.stop()

View File

@@ -0,0 +1,501 @@
import os
import RNS
import LXST
import time
import threading
from LXST import APP_NAME
from LXST import Mixer, Pipeline
from LXST.Codecs import Raw, Opus, Codec2, Null
from LXST.Sinks import LineSink
from LXST.Sources import LineSource, OpusFileSource
from LXST.Generators import ToneSource
from LXST.Network import SignallingReceiver, Packetizer, LinkSource
PRIMITIVE_NAME = "telephony"
class Signalling():
STATUS_BUSY = 0x00
STATUS_REJECTED = 0x01
STATUS_CALLING = 0x02
STATUS_AVAILABLE = 0x03
STATUS_RINGING = 0x04
STATUS_CONNECTING = 0x05
STATUS_ESTABLISHED = 0x06
AUTO_STATUS_CODES = [STATUS_CALLING, STATUS_AVAILABLE, STATUS_RINGING,
STATUS_CONNECTING, STATUS_ESTABLISHED]
class Telephone(SignallingReceiver):
RING_TIME = 60
WAIT_TIME = 70
DIAL_TONE_FREQUENCY = 382
DIAL_TONE_EASE_MS = 3.14159
JOB_INTERVAL = 5
ANNOUNCE_INTERVAL_MIN = 60*5
ANNOUNCE_INTERVAL = 60*60*3
ALLOW_ALL = 0xFF
ALLOW_NONE = 0xFE
def __init__(self, identity, ring_time=RING_TIME, wait_time=WAIT_TIME, auto_answer=None, allowed=ALLOW_ALL):
super().__init__()
self.identity = identity
self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME)
self.destination.set_proof_strategy(RNS.Destination.PROVE_NONE)
self.destination.set_link_established_callback(self.__incoming_link_established)
self.allowed = allowed
self.blocked = None
self.last_announce = 0
self.call_handler_lock = threading.Lock()
self.pipeline_lock = threading.Lock()
self.caller_pipeline_open_lock = threading.Lock()
self.links = {}
self.ring_time = ring_time
self.wait_time = wait_time
self.auto_answer = auto_answer
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.target_frame_time_ms = None
self.audio_output = None
self.audio_input = None
self.dial_tone = None
self.dial_tone_frequency = self.DIAL_TONE_FREQUENCY
self.dial_tone_ease_ms = self.DIAL_TONE_EASE_MS
self.transmit_codec = None
self.receive_codec = None
self.receive_mixer = None
self.transmit_mixer = None
self.receive_pipeline = None
self.transmit_pipeline = None
self.ringer_lock = threading.Lock()
self.ringer_output = None
self.ringer_pipeline = None
self.ringtone_path = None
self.speaker_device = None
self.microphone_device = None
self.ringer_device = None
threading.Thread(target=self.__jobs, daemon=True).start()
RNS.log(f"{self} listening on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_DEBUG)
def teardown(self):
self.hangup()
RNS.Transport.deregister_destination(self.destination)
self.destination = None
def announce(self):
self.destination.announce()
self.last_announce = time.time()
def set_allowed(self, allowed):
valid_allowed = [self.ALLOW_ALL, self.ALLOW_NONE]
if callable(allowed) or type(allowed) == list or allowed in valid_allowed: self.allowed = allowed
else: raise TypeError(f"Invalid type for allowed callers: {type(allowed)}")
def set_blocked(self, blocked):
if type(blocked) == list or blocked == None: self.blocked = blocked
else: raise TypeError(f"Invalid type for blocked callers: {type(blocked)}")
def set_announce_interval(self, announce_interval):
if not type(announce_interval) == int: raise TypeError(f"Invalid type for announce interval: {announce_interval}")
else:
if announce_interval < self.ANNOUNCE_INTERVAL_MIN: announce_interval = self.ANNOUNCE_INTERVAL_MIN
self.announce_interval = announce_interval
def set_ringing_callback(self, callback):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__ringing_callback = callback
def set_established_callback(self, callback):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__established_callback = callback
def set_ended_callback(self, callback):
if not callable(callback): raise TypeError(f"Invalid callback, {callback} is not callable")
self.__ended_callback = callback
def set_speaker(self, device):
self.speaker_device = device
RNS.log(f"{self} speaker device set to {device}", RNS.LOG_DEBUG)
def set_microphone(self, device):
self.microphone_device = device
RNS.log(f"{self} microphone device set to {device}", RNS.LOG_DEBUG)
def set_ringer(self, device):
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):
self.ringtone_path = ringtone_path
self.ringtone_gain = gain
RNS.log(f"{self} ringtone set to {self.ringtone_path}", RNS.LOG_DEBUG)
def __jobs(self):
while self.destination != None:
time.sleep(self.JOB_INTERVAL)
if time.time() > self.last_announce+self.ANNOUNCE_INTERVAL:
if self.destination != None: self.announce()
def __is_allowed(self, remote_identity):
identity_hash = remote_identity.hash
if type(self.blocked) == list and identity_hash in self.blocked: return False
elif self.allowed == self.ALLOW_ALL: return True
elif self.allowed == self.ALLOW_NONE: return False
elif type(self.allowed) == list: return identity_hash in self.allowed
elif callable(self.allowed): return self.allowed(identity_hash)
def __timeout_incoming_call_at(self, call, timeout):
def job():
while time.time()<timeout and self.active_call == call:
time.sleep(0.25)
if self.active_call == call and self.call_status < Signalling.STATUS_ESTABLISHED:
RNS.log(f"Ring timeout on call from {RNS.prettyhexrep(self.active_call.hash)}, hanging up", RNS.LOG_DEBUG)
self.active_call.ring_timeout = True
self.hangup()
threading.Thread(target=job, daemon=True).start()
def __timeout_outgoing_call_at(self, call, timeout):
def job():
while time.time()<timeout and self.active_call == call:
time.sleep(0.25)
if self.active_call == call and self.call_status < Signalling.STATUS_ESTABLISHED:
RNS.log(f"Timeout on outgoing call to {RNS.prettyhexrep(self.active_call.hash)}, hanging up", RNS.LOG_DEBUG)
self.hangup()
threading.Thread(target=job, daemon=True).start()
def __incoming_link_established(self, link):
link.is_incoming = True
link.is_outgoing = False
link.ring_timeout = False
with self.call_handler_lock:
if self.active_call or self.busy:
RNS.log(f"Incoming call, but line is already active, signalling busy", RNS.LOG_DEBUG)
self.signal(Signalling.STATUS_BUSY, link)
link.teardown()
else:
link.set_remote_identified_callback(self.__caller_identified)
link.set_link_closed_callback(self.__link_closed)
self.links[link.link_id] = link
self.signal(Signalling.STATUS_AVAILABLE, link)
def __caller_identified(self, link, identity):
with self.call_handler_lock:
if self.active_call or self.busy:
RNS.log(f"Caller identified as {RNS.prettyhexrep(identity.hash)}, but line is already active, signalling busy", RNS.LOG_DEBUG)
self.signal(Signalling.STATUS_BUSY, link)
link.teardown()
else:
if not self.__is_allowed(identity):
RNS.log(f"Identified caller {RNS.prettyhexrep(identity.hash)} was not allowed, signalling busy", RNS.LOG_DEBUG)
self.signal(Signalling.STATUS_BUSY, link)
link.teardown()
else:
RNS.log(f"Caller identified as {RNS.prettyhexrep(identity.hash)}, ringing", RNS.LOG_DEBUG)
self.active_call = link
self.__reset_dialling_pipelines()
self.signal(Signalling.STATUS_RINGING, self.active_call)
self.__activate_ring_tone()
if callable(self.__ringing_callback): self.__ringing_callback(identity)
if self.auto_answer:
def cb():
RNS.log(f"Auto-answering call from {RNS.prettyhexrep(identity.hash)} in {RNS.prettytime(self.auto_answer)}", RNS.LOG_DEBUG)
time.sleep(self.auto_answer)
self.answer(identity)
threading.Thread(target=cb, daemon=True).start()
else:
self.__timeout_incoming_call_at(self.active_call, time.time()+self.ring_time)
def __link_closed(self, link):
if link == self.active_call:
RNS.log(f"Remote for {RNS.prettyhexrep(link.get_remote_identity().hash)} hung up", RNS.LOG_DEBUG)
self.hangup()
def set_busy(self, busy):
self._external_busy = busy
@property
def busy(self):
if self.call_status != Signalling.STATUS_AVAILABLE:
return True
else:
return self._external_busy
def signal(self, signal, link):
if signal in Signalling.AUTO_STATUS_CODES: self.call_status = signal
super().signal(signal, link)
def answer(self, identity):
with self.call_handler_lock:
if self.active_call and self.active_call.get_remote_identity() == identity and self.call_status > Signalling.STATUS_RINGING:
RNS.log(f"Incoming call from {RNS.prettyhexrep(identity.hash)} already answered and active")
return False
elif not self.active_call:
RNS.log(f"Answering call failed, no active incoming call", RNS.LOG_ERROR)
return False
elif not self.active_call.get_remote_identity():
RNS.log(f"Answering call failed, active incoming call is not from {RNS.prettyhexrep(identity.hash)}", RNS.LOG_ERROR)
return False
else:
RNS.log(f"Answering call from {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG)
self.__open_pipelines(identity)
self.__start_pipelines()
RNS.log(f"Call setup complete for {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG)
if callable(self.__established_callback): self.__established_callback(self.active_call.get_remote_identity())
return True
def hangup(self):
if self.active_call:
with self.call_handler_lock:
terminating_call = self.active_call; self.active_call = None
remote_identity = terminating_call.get_remote_identity()
if terminating_call.is_incoming and self.call_status == Signalling.STATUS_RINGING:
if not terminating_call.ring_timeout and terminating_call.status == RNS.Link.ACTIVE:
self.signal(Signalling.STATUS_REJECTED, terminating_call)
if terminating_call.status == RNS.Link.ACTIVE: terminating_call.teardown()
self.__stop_pipelines()
self.receive_mixer = None
self.transmit_mixer = None
self.receive_pipeline = None
self.transmit_pipeline = None
self.audio_output = None
self.dial_tone = None
self.call_status = Signalling.STATUS_AVAILABLE
if remote_identity:
RNS.log(f"Call with {RNS.prettyhexrep(remote_identity.hash)} terminated", RNS.LOG_DEBUG)
else:
RNS.log(f"Outgoing call could not be connected, link establishment failed", RNS.LOG_DEBUG)
if callable(self.__ended_callback): self.__ended_callback(remote_identity)
def mute_receive(self):
pass
def mute_transmit(self):
pass
def select_call_codecs(self):
self.receive_codec = Null()
# self.transmit_codec = Codec2(mode=Codec2.CODEC2_700C)
# self.transmit_codec = Codec2(mode=Codec2.CODEC2_1600)
# self.transmit_codec = Codec2(mode=Codec2.CODEC2_3200)
# self.transmit_codec = Opus(profile=Opus.PROFILE_VOICE_LOW)
self.transmit_codec = Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
# self.transmit_codec = Opus(profile=Opus.PROFILE_VOICE_HIGH)
# self.transmit_codec = Opus(profile=Opus.PROFILE_VOICE_MAX)
# self.transmit_codec = Opus(profile=Opus.PROFILE_AUDIO_MIN)
# self.transmit_codec = Opus(profile=Opus.PROFILE_AUDIO_LOW)
# self.transmit_codec = Opus(profile=Opus.PROFILE_AUDIO_MEDIUM)
# self.transmit_codec = Opus(profile=Opus.PROFILE_AUDIO_HIGH)
# self.transmit_codec = Opus(profile=Opus.PROFILE_AUDIO_MAX)
# self.transmit_codec = Raw()
def select_call_frame_time(self):
self.target_frame_time_ms = 60
return self.target_frame_time_ms
def __reset_dialling_pipelines(self):
with self.pipeline_lock:
if self.audio_output: self.audio_output.stop()
if self.dial_tone: self.dial_tone.stop()
if self.receive_pipeline: self.receive_pipeline.stop()
if self.receive_mixer: self.receive_mixer.stop()
self.audio_output = None
self.dial_tone = None
self.receive_pipeline = None
self.receive_mixer = None
self.__prepare_dialling_pipelines()
def __prepare_dialling_pipelines(self):
self.select_call_frame_time()
self.select_call_codecs()
if self.audio_output == None: self.audio_output = LineSink(preferred_device=self.speaker_device)
if self.receive_mixer == None: self.receive_mixer = Mixer(target_frame_ms=self.target_frame_time_ms)
if self.dial_tone == None: self.dial_tone = ToneSource(frequency=self.dial_tone_frequency, gain=0.0, ease_time_ms=self.dial_tone_ease_ms, target_frame_ms=self.target_frame_time_ms, codec=Null(), sink=self.receive_mixer)
if self.receive_pipeline == None: self.receive_pipeline = Pipeline(source=self.receive_mixer, codec=Null(), sink=self.audio_output)
def __activate_ring_tone(self):
if self.ringtone_path != None and os.path.isfile(self.ringtone_path):
if not self.ringer_pipeline:
if not self.ringer_output: self.ringer_output = LineSink(preferred_device=self.ringer_device)
self.ringer_source = OpusFileSource(self.ringtone_path, loop=True, target_frame_ms=60)
self.ringer_pipeline = Pipeline(source=self.ringer_source, codec=Null(), sink=self.ringer_output)
def job():
with self.ringer_lock:
while self.active_call and self.active_call.is_incoming and self.call_status == Signalling.STATUS_RINGING:
if not self.ringer_pipeline.running: self.ringer_pipeline.start()
time.sleep(0.1)
self.ringer_source.stop()
threading.Thread(target=job, daemon=True).start()
def __play_busy_tone(self):
if self.audio_output == None or self.receive_mixer == None or self.dial_tone == None: self.__reset_dialling_pipelines()
with self.pipeline_lock:
window = 0.5; started = time.time()
while time.time()-started < 4.25:
elapsed = (time.time()-started)%window
if elapsed > 0.25: self.__enable_dial_tone()
else: self.__mute_dial_tone()
time.sleep(0.005)
time.sleep(0.5)
def __activate_dial_tone(self):
def job():
window = 7
started = time.time()
while self.active_call and self.active_call.is_outgoing and self.call_status == Signalling.STATUS_RINGING:
elapsed = (time.time()-started)%window
if elapsed > 0.05 and elapsed < 2.05: self.__enable_dial_tone()
else: self.__mute_dial_tone()
time.sleep(0.2)
threading.Thread(target=job, daemon=True).start()
def __enable_dial_tone(self):
if not self.receive_mixer.should_run: self.receive_mixer.start()
self.dial_tone.gain = 0.04
if not self.dial_tone.running: self.dial_tone.start()
def __mute_dial_tone(self):
if not self.receive_mixer.should_run: self.receive_mixer.start()
if self.dial_tone.running and self.dial_tone.gain != 0: self.dial_tone.gain = 0.0
if not self.dial_tone.running: self.dial_tone.start()
def __disable_dial_tone(self):
if self.dial_tone and self.dial_tone.running:
self.dial_tone.stop()
def __open_pipelines(self, identity):
with self.pipeline_lock:
if not self.active_call.get_remote_identity() == identity:
RNS.log("Identity mismatch while opening call pipelines, tearing down call", RNS.LOG_ERROR)
self.hangup()
else:
if not hasattr(self.active_call, "pipelines_opened"): self.active_call.pipelines_opened = False
if self.active_call.pipelines_opened: RNS.log(f"Pipelines already openened for call with {RNS.prettyhexrep(identity.hash)}", RNS.LOG_ERROR)
else:
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)
self.__prepare_dialling_pipelines()
self.transmit_mixer = Mixer(target_frame_ms=self.target_frame_time_ms)
self.audio_input = LineSource(preferred_device=self.microphone_device, target_frame_ms=self.target_frame_time_ms, codec=Raw(), sink=self.transmit_mixer)
# self.audio_input = OpusFileSource("/home/markqvist/Information/Source/LXST/docs/425.opus", loop=True, target_frame_ms=self.target_frame_time_ms, codec=Raw(), sink=self.transmit_mixer, timed=True)
self.transmit_pipeline = Pipeline(source=self.transmit_mixer,
codec=self.transmit_codec,
sink=Packetizer(self.active_call, failure_callback=self.__packetizer_failure))
self.active_call.audio_source = LinkSource(link=self.active_call, signalling_receiver=self, sink=self.receive_mixer)
self.signal(Signalling.STATUS_ESTABLISHED, self.active_call)
def __packetizer_failure(self):
RNS.log(f"Frame packetization failed, terminating call", RNS.LOG_ERROR)
self.hangup()
def __start_pipelines(self):
with self.pipeline_lock:
if self.receive_mixer: self.receive_mixer.start()
if self.transmit_mixer: self.transmit_mixer.start()
if self.audio_input: self.audio_input.start()
if self.transmit_pipeline: self.transmit_pipeline.start()
if not self.audio_input: RNS.log("No audio input was ready at call establishment", RNS.LOG_ERROR)
RNS.log(f"Audio pipelines started", RNS.LOG_DEBUG)
def __stop_pipelines(self):
with self.pipeline_lock:
if self.receive_mixer: self.receive_mixer.stop()
if self.transmit_mixer: self.transmit_mixer.stop()
if self.audio_input: self.audio_input.stop()
if self.receive_pipeline: self.receive_pipeline.stop()
if self.transmit_pipeline: self.transmit_pipeline.stop()
RNS.log(f"Audio pipelines stopped", RNS.LOG_DEBUG)
def call(self, identity):
with self.call_handler_lock:
if not self.active_call:
self.call_status = Signalling.STATUS_CALLING
outgoing_call_timeout = time.time()+self.wait_time
call_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME)
if not RNS.Transport.has_path(call_destination.hash):
RNS.log(f"No path known for call to {RNS.prettyhexrep(call_destination.hash)}, requesting path...", RNS.LOG_DEBUG)
RNS.Transport.request_path(call_destination.hash)
while not RNS.Transport.has_path(call_destination.hash) and time.time() < outgoing_call_timeout: time.sleep(0.2)
if not RNS.Transport.has_path(call_destination.hash) and time.time() >= outgoing_call_timeout:
self.hangup()
else:
RNS.log(f"Establishing link with {RNS.prettyhexrep(call_destination.hash)}...", RNS.LOG_DEBUG)
self.active_call = RNS.Link(call_destination,
established_callback=self.__outgoing_link_established,
closed_callback=self.__outgoing_link_closed)
self.active_call.is_incoming = False
self.active_call.is_outgoing = True
self.active_call.ring_timeout = False
self.__timeout_outgoing_call_at(self.active_call, outgoing_call_timeout)
def __outgoing_link_established(self, link):
RNS.log(f"Link established for call with {link.get_remote_identity()}", RNS.LOG_DEBUG)
link.set_link_closed_callback(self.__link_closed)
self.handle_signalling_from(link)
def __outgoing_link_closed(self, link):
pass
def signalling_received(self, signals, source):
for signal in signals:
if source != self.active_call:
RNS.log("Received signalling on non-active call, ignoring", RNS.LOG_DEBUG)
else:
if signal == Signalling.STATUS_BUSY:
RNS.log("Remote is busy, terminating", RNS.LOG_DEBUG)
self.__play_busy_tone()
self.__disable_dial_tone()
self.hangup()
elif signal == Signalling.STATUS_REJECTED:
RNS.log("Remote rejected call, terminating", RNS.LOG_DEBUG)
self.__play_busy_tone()
self.__disable_dial_tone()
self.hangup()
elif signal == Signalling.STATUS_AVAILABLE:
RNS.log("Line available, sending identification", RNS.LOG_DEBUG)
self.call_status = signal
source.identify(self.identity)
elif signal == Signalling.STATUS_RINGING:
RNS.log("Identification accepted, remote is now ringing", RNS.LOG_DEBUG)
self.call_status = signal
self.__prepare_dialling_pipelines()
if self.active_call and self.active_call.is_outgoing:
self.__activate_dial_tone()
elif signal == Signalling.STATUS_CONNECTING:
RNS.log("Call answered, remote is performing call setup, opening audio pipelines", RNS.LOG_DEBUG)
self.call_status = signal
with self.caller_pipeline_open_lock:
self.__reset_dialling_pipelines()
self.__open_pipelines(self.active_call.get_remote_identity())
elif signal == Signalling.STATUS_ESTABLISHED:
if self.active_call and self.active_call.is_outgoing:
RNS.log("Remote call setup completed, starting audio pipelines", RNS.LOG_DEBUG)
with self.caller_pipeline_open_lock:
self.__start_pipelines()
self.__disable_dial_tone()
RNS.log(f"Call setup complete for {RNS.prettyhexrep(self.active_call.get_remote_identity().hash)}", RNS.LOG_DEBUG)
self.call_status = signal
if callable(self.__established_callback): self.__established_callback(self.active_call.get_remote_identity())
def __str__(self):
return f"<lxst.telephony/{RNS.hexrep(self.identity.hash, delimit=False)}>"

View File

@@ -0,0 +1 @@
from .Telephony import Telephone

View File

@@ -0,0 +1,100 @@
import os
import time
import threading
from importlib.util import find_spec
if find_spec("smbus"): import smbus
else: raise OSError(f"No smbus module available, cannot use {os.path.basename(__file__)} driver")
class LCD():
DEFAULT_ADDR = 0x27
DEFAULT_I2C_CH = 1
COLS = 16
ROWS = 2
MODE_CHR = 0x01
MODE_CMD = 0x00
ROW_1 = 0x80
ROW_2 = 0xC0
BACKLIGHT_ON = 0x08
BACKLIGHT_OFF = 0x00
FLAG_ENABLE = 0b00000100
FLAG_RS = 0b00000001
T_PULSE = 0.5/1000
T_DELAY = 0.5/1000
CMD_INIT1 = 0x33
CMD_INIT2 = 0x32
CMD_CLEAR = 0x01
SHARED_BUS = None
def __init__(self, address=None):
if not LCD.SHARED_BUS: LCD.SHARED_BUS = smbus.SMBus(self.DEFAULT_I2C_CH)
self.address = address or self.DEFAULT_ADDR
self.bus = LCD.SHARED_BUS
self.row = LCD.ROW_1
self.backlight = LCD.BACKLIGHT_ON
self.__init_display()
def __init_display(self):
self.__send_command(LCD.CMD_INIT1)
self.__send_command(LCD.CMD_INIT2)
self.__send_command(0x28) # Data length, number of lines, font size
self.__send_command(0x0C) # Display on, cursor off, blink off
self.__send_command(LCD.CMD_CLEAR)
time.sleep(LCD.T_DELAY)
def __send_command(self, command):
byte = command & 0xF0 # Transmit MSBs
byte |= 0x04; self.__send_byte(byte); time.sleep(LCD.T_PULSE)
byte &= 0xFB; self.__send_byte(byte)
byte = (command & 0x0F) << 4 # Transmit LSBs
byte |= 0x04; self.__send_byte(byte); time.sleep(LCD.T_PULSE)
byte &= 0xFB; self.__send_byte(byte)
def __send_data(self, data):
byte = data & 0xF0 # Transmit MSBs
byte |= 0x05; self.__send_byte(byte); time.sleep(LCD.T_PULSE)
byte &= 0xFB; self.__send_byte(byte)
byte = (data & 0x0F) << 4 # Transmit LSBs
byte |= 0x05; self.__send_byte(byte); time.sleep(LCD.T_PULSE)
byte &= 0xFB; self.__send_byte(byte)
def __send_byte(self, byte):
self.bus.write_byte(self.address, byte | self.backlight)
def print(self, string, x=0, y=0):
string = string.ljust(LCD.COLS," ")
if x < 0: x = 0
if x > 15: x = 15
if y < 0: y = 0
if y > 1: y = 1
if self.is_sleeping: self.wake()
self.__send_command(0x80 + 0x40 * y + x) # Set cursor location
for i in range(LCD.COLS): self.__send_data(ord(string[i]))
def clear(self):
self.__init_display()
@property
def is_sleeping(self):
return self.backlight == LCD.BACKLIGHT_OFF
def sleep(self):
self.backlight = LCD.BACKLIGHT_OFF
self.__send_command(LCD.CMD_CLEAR)
def wake(self):
self.backlight = LCD.BACKLIGHT_ON
self.__init_display()
def close(self):
self.sleep()
self.bus.close()
self.bus = None
LCD.SHARED_BUS = None

View File

@@ -0,0 +1,130 @@
import os
import time
import threading
from importlib.util import find_spec
if find_spec("RPi"): import RPi.GPIO as GPIO
else: raise OSError(f"No GPIO module available, cannot use {os.path.basename(__file__)} driver")
class Event:
UP = 0x00
DOWN = 0x01
class Keypad():
ROWS = 4
COLS = 4
SCAN_INTERVAL_MS = 20
LOW = 0x00
HIGH = 0x01
DEFAULT_MAP = [["1", "2", "3", "A"],
["4", "5", "6", "B"],
["7", "8", "9", "C"],
["*", "0", "#", "D"]]
DEFAULT_ROWPINS = [21, 20, 16, 12]
DEFAULT_COLPINS = [26, 19, 13, 6]
DEFAULT_HOOKPIN = 5
HOOK_DEBOUNCE_MS = 150
def __init__(self, row_pins=None, col_pins=None, key_map=None, callback=None):
if not row_pins == None and (not type(row_pins) == list or len(row_pins) != 4):
raise ValueError("Invalid row pins specification")
if not col_pins == None and (not type(col_pins) == list or len(col_pins) != 4):
raise ValueError("Invalid row pins specification")
self.row_pins = row_pins or self.DEFAULT_ROWPINS
self.col_pins = col_pins or self.DEFAULT_COLPINS
self.scan_lock = threading.Lock()
self.callback = callback
self.hook_time = 0
self.hook_pin = None
self.on_hook = True
self.check_hook = False
self.should_run = False
self.ec = Event
self.set_key_map(key_map)
def enable_hook(self, pin=None):
if pin == None: pin = self.DEFAULT_HOOKPIN
self.hook_pin = pin
GPIO.setup(self.hook_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
self.key_states["hook"] = False
self.check_hook = True
def set_key_map(self, key_map):
self.key_map = key_map or self.DEFAULT_MAP
self.key_states = {}
for row in self.key_map:
for key in row: self.key_states[key] = False
def is_down(self, key):
if not key in self.key_states: return False
else:
return self.key_states[key]
def is_up(self, key):
if not key in self.key_states: return False
else:
return not self.key_states[key]
def __job(self):
while self.should_run:
self.__scan()
time.sleep(self.SCAN_INTERVAL_MS/1000)
def __handle(self, active_keys):
events = []
for key in self.key_states:
if self.key_states[key] == False:
if key in active_keys:
self.key_states[key] = True
events.append((key, Event.DOWN))
elif self.key_states[key] == True:
if not key in active_keys:
self.key_states[key] = False
events.append((key, Event.UP))
if callable(self.callback):
for event in events:
self.callback(self, event)
def __scan(self):
active_keys = []
for row in range(0, self.ROWS):
GPIO.setup(self.row_pins[row], GPIO.OUT)
GPIO.output(self.row_pins[row], GPIO.HIGH)
for col in range(0, self.COLS):
if GPIO.input(self.col_pins[col]):
active_keys.append(self.key_map[row][col])
GPIO.output(self.row_pins[row], GPIO.LOW)
GPIO.setup(self.row_pins[row], GPIO.IN, pull_up_down=GPIO.PUD_OFF)
if self.check_hook:
on_hook = GPIO.input(self.hook_pin) == GPIO.LOW
if on_hook:
active_keys.append("hook")
self.hook_time = time.time()
if self.key_states["hook"] == True and not on_hook:
if time.time()-self.hook_time < self.HOOK_DEBOUNCE_MS/1000:
active_keys.append("hook")
else:
self.hook_time = time.time()
if len(active_keys) >= 0 and len(active_keys) <= 4: self.__handle(active_keys)
def start(self):
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
for row_pin in self.row_pins: GPIO.setup(row_pin, GPIO.OUT)
for col_pin in self.col_pins: GPIO.setup(col_pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
self.should_run = True
threading.Thread(target=self.__job, daemon=True).start()
def stop(self):
self.should_run = False

0
LXST/Processing.py Normal file
View File

136
LXST/Sinks.py Normal file
View File

@@ -0,0 +1,136 @@
import RNS
import math
import time
import threading
from collections import deque
class LinuxBackend():
SAMPLERATE = 48000
def __init__(self, preferred_device=None, samplerate=SAMPLERATE):
import soundcard
self.samplerate = samplerate
self.soundcard = soundcard
if preferred_device:
try: self.device = self.soundcard.get_speaker(preferred_device)
except: self.device = soundcard.default_speaker()
else: self.device = soundcard.default_speaker()
RNS.log(f"Using output device {self.device}", RNS.LOG_DEBUG)
def flush(self):
self.recorder.flush()
def get_player(self, samples_per_frame=None):
return self.device.player(samplerate=self.samplerate, blocksize=samples_per_frame)
def get_backend():
if RNS.vendor.platformutils.is_linux():
return LinuxBackend
else:
return None
Backend = get_backend()
class Sink():
def handle_frame(self, frame, source):
pass
def can_receive(self, from_source=None):
return True
class RemoteSink(Sink):
pass
class LocalSink(Sink):
pass
class LineSink(LocalSink):
MAX_FRAMES = 6
AUTOSTART_MIN = 1
FRAME_TIMEOUT = 8
def __init__(self, preferred_device=None, autodigest=True):
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()
self.insert_lock = threading.Lock()
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.underrun_at = None
self.frame_timeout = self.FRAME_TIMEOUT
self.autodigest = autodigest
self.autostart_min = self.AUTOSTART_MIN
self.buffer_max_height = self.MAX_FRAMES-3
self.preferred_samplerate = Backend.SAMPLERATE
self.backend = Backend(preferred_device=self.preferred_device, samplerate=self.preferred_samplerate)
self.samplerate = self.backend.samplerate
self.channels = self.backend.device.channels
self.samples_per_frame = None
self.frame_time = None
self.output_latency = 0
self.max_latency = 0
def can_receive(self, from_source=None):
with self.insert_lock:
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.samples_per_frame = frame.shape[0]
self.frame_time = self.samples_per_frame*(1/self.backend.samplerate)
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):
self.should_run = False
def __digest_job(self):
with self.digest_lock:
with self.backend.get_player(samples_per_frame=self.samples_per_frame) as player:
while self.should_run:
frames_ready = len(self.frame_deque)
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
with self.insert_lock: frame = self.frame_deque.popleft()
if frame.shape[1] > self.channels: frame = frame[:, 0:self.channels]
player.play(frame)
if len(self.frame_deque) > self.buffer_max_height:
RNS.log(f"Buffer lag on {self} (height {len(self.frame_deque)}), dropping one frame", RNS.LOG_DEBUG)
self.frame_deque.popleft()
else:
if self.underrun_at == None:
# TODO: Remove debug
# RNS.log(f"Buffer underrun on {self}", RNS.LOG_DEBUG)
self.underrun_at = time.time()
else:
if time.time() > self.underrun_at+(self.frame_time*self.frame_timeout):
RNS.log(f"No frames available on {self}, stopping playback", RNS.LOG_DEBUG)
self.should_run = False
else:
time.sleep(self.frame_time*0.1)
class PacketSink(RemoteSink):
pass

270
LXST/Sources.py Normal file
View File

@@ -0,0 +1,270 @@
import os
import RNS
import math
import time
import threading
import numpy as np
from collections import deque
from .Sinks import LocalSink
from .Codecs import Codec, CodecError
from .Codecs.libs.pyogg import OpusFile
RNS.loglevel = RNS.LOG_DEBUG
class LinuxBackend():
SAMPLERATE = 48000
def __init__(self, preferred_device=None, samplerate=SAMPLERATE):
import soundcard
self.samplerate = samplerate
self.soundcard = soundcard
if preferred_device:
try: self.device = self.soundcard.get_microphone(preferred_device)
except: self.device = self.soundcard.default_microphone()
else: self.device = self.soundcard.default_microphone()
self.channels = self.device.channels
self.bitdepth = 32
RNS.log(f"Using input device {self.device}", RNS.LOG_DEBUG)
def flush(self):
self.recorder.flush()
def get_recorder(self, samples_per_frame):
return self.device.recorder(samplerate=self.SAMPLERATE, blocksize=samples_per_frame)
def get_backend():
if RNS.vendor.platformutils.is_linux():
return LinuxBackend
else:
return None
Backend = get_backend()
class Source():
pass
class LocalSource(Source):
pass
class RemoteSource(Source):
pass
class Loopback(LocalSource, LocalSink):
MAX_FRAMES = 128
def __init__(self, target_frame_ms=70, codec=None, sink=None):
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.should_run = False
self.loopback_thread = None
self.loopback_lock = threading.Lock()
self.codec = codec
self._sink = sink
self._source = None
def start(self):
if not self.should_run:
RNS.log(f"{self} starting", RNS.LOG_DEBUG)
self.should_run = True
def stop(self):
self.should_run = False
def can_receive(self, from_source=None):
if self._sink:
return self._sink.can_receive(from_source)
else:
return True
def handle_frame(self, frame, source):
with self.loopback_lock:
if self.codec and self.sink:
self.sink.handle_frame(self.codec.decode(frame), self)
@property
def source(self):
return self._source
@source.setter
def source(self, source):
self._source = source
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):
self.preferred_device = preferred_device
self.frame_deque = deque(maxlen=self.MAX_FRAMES)
self.target_frame_ms = target_frame_ms
self.samplerate = None
self.channels = None
self.bitdepth = None
self.should_run = False
self.ingest_thread = None
self.recording_lock = threading.Lock()
self._codec = None
self.codec = codec
self.sink = sink
@property
def codec(self):
return self._codec
@codec.setter
def codec(self, codec):
if codec == None:
self._codec = None
elif not issubclass(type(codec), Codec):
raise CodecError(f"Invalid codec specified for {self}")
else:
self._codec = codec
if self.codec.preferred_samplerate:
self.preferred_samplerate = self.codec.preferred_samplerate
else:
self.preferred_samplerate = Backend.SAMPLERATE
if self.codec.frame_quanta_ms:
if self.target_frame_ms%self.codec.frame_quanta_ms != 0:
self.target_frame_ms = math.ceil(self.target_frame_ms/self.codec.frame_quanta_ms)*self.codec.frame_quanta_ms
RNS.log(f"{self} target frame time quantized to {self.target_frame_ms}ms due to codec frame quanta", RNS.LOG_DEBUG)
if self.codec.frame_max_ms:
if self.target_frame_ms > self.codec.frame_max_ms:
self.target_frame_ms = self.codec.frame_max_ms
RNS.log(f"{self} target frame time clamped to {self.target_frame_ms}ms due to codec frame limit", RNS.LOG_DEBUG)
if self.codec.valid_frame_ms:
if not self.target_frame_ms in self.codec.valid_frame_ms:
self.target_frame_ms = min(self.codec.valid_frame_ms, key=lambda t:abs(t-self.target_frame_ms))
RNS.log(f"{self} target frame time clamped to closest valid value of {self.target_frame_ms}ms ", RNS.LOG_DEBUG)
self.backend = Backend(preferred_device=self.preferred_device, samplerate=self.preferred_samplerate)
self.samplerate = self.backend.samplerate
self.bitdepth = self.backend.bitdepth
self.channels = self.backend.channels
self.samples_per_frame = math.ceil((self.target_frame_ms/1000)*self.samplerate)
def start(self):
if not self.should_run:
RNS.log(f"{self} starting at {self.samples_per_frame} samples per frame, {self.channels} channels", RNS.LOG_DEBUG)
self.should_run = True
self.ingest_thread = threading.Thread(target=self.__ingest_job, daemon=True)
self.ingest_thread.start()
def stop(self):
self.should_run = False
def __ingest_job(self):
with self.recording_lock:
frame_samples = None
with self.backend.get_recorder(samples_per_frame=self.samples_per_frame) as recorder:
while self.should_run:
frame_samples = recorder.record(numframes=self.samples_per_frame)
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)
class OpusFileSource(LocalSource):
MAX_FRAMES = 128
DEFAULT_FRAME_MS = 70
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):
self.target_frame_ms = target_frame_ms
self.loop = loop
self.timed = timed
self.read_lock = threading.Lock()
self.should_run = False
self.ingest_thread = None
self.next_frame = None
self._codec = None
if file_path == None:
raise TypeError(f"{self} initialised with invalid file path: {file_path}")
elif os.path.isfile(file_path):
self.file = OpusFile(file_path)
self.samplerate = self.file.frequency
self.channels = self.file.channels
self.bitdepth = 16
self.samples = self.file.as_array()/self.TYPE_MAP_FACTOR
self.sample_count = self.samples.shape[0]
self.length_ms = (self.sample_count/self.samplerate)*1000
RNS.log(f"{self} loaded {RNS.prettytime(self.length_ms/1000)} of audio from {file_path}", RNS.LOG_DEBUG)
RNS.log(f"{self} samplerate is {RNS.prettyfrequency(self.samplerate)}, {self.channels} channels, {self.sample_count} samples in total", RNS.LOG_DEBUG)
else:
raise OSError(f"{self} file {file_path} not found")
self.codec = codec
self.sink = sink
@property
def codec(self):
return self._codec
@codec.setter
def codec(self, codec):
if codec == None:
self._codec = None
elif not issubclass(type(codec), Codec):
raise CodecError(f"Invalid codec specified for {self}")
else:
self._codec = codec
if self.codec.frame_quanta_ms:
if self.target_frame_ms%self.codec.frame_quanta_ms != 0:
self.target_frame_ms = math.ceil(self.target_frame_ms/self.codec.frame_quanta_ms)*self.codec.frame_quanta_ms
RNS.log(f"{self} target frame time quantized to {self.target_frame_ms}ms due to codec frame quanta", RNS.LOG_DEBUG)
if self.codec.frame_max_ms:
if self.target_frame_ms > self.codec.frame_max_ms:
self.target_frame_ms = self.codec.frame_max_ms
RNS.log(f"{self} target frame time clamped to {self.target_frame_ms}ms due to codec frame limit", RNS.LOG_DEBUG)
if self.codec.valid_frame_ms:
if not self.target_frame_ms in self.codec.valid_frame_ms:
self.target_frame_ms = min(self.codec.valid_frame_ms, key=lambda t:abs(t-self.target_frame_ms))
RNS.log(f"{self} target frame time clamped to closest valid value of {self.target_frame_ms}ms ", RNS.LOG_DEBUG)
self.samples_per_frame = math.ceil((self.target_frame_ms/1000)*self.samplerate)
self.frame_time = self.samples_per_frame/self.samplerate
RNS.log(f"{self} frame time is {RNS.prettyshorttime(self.frame_time)}", RNS.LOG_DEBUG)
def start(self):
if not self.should_run:
RNS.log(f"{self} starting at {self.samples_per_frame} samples per frame, {self.channels} channels", RNS.LOG_DEBUG)
self.should_run = True
self.ingest_thread = threading.Thread(target=self.__ingest_job, daemon=True)
self.ingest_thread.start()
def stop(self):
self.should_run = False
def __ingest_job(self):
with self.read_lock:
self.next_frame = time.time()
fi = 0; spf = self.samples_per_frame; sc = self.sample_count
while self.should_run:
if self.sink and self.sink.can_receive(from_source=self) and (not self.timed or time.time() >= self.next_frame):
self.next_frame = time.time()+self.frame_time
fi += 1
fs = (fi-1)*spf; fe = min(fi*spf, sc)
frame_samples = self.samples[fs:fe, :]
if len(frame_samples) < 1:
if self.loop:
RNS.log(f"{self} exhausted file samples, looping...", RNS.LOG_DEBUG)
fi = 0
else:
RNS.log(f"{self} exhausted file samples, stopping...", RNS.LOG_DEBUG)
self.should_run = False
else:
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)
else:
time.sleep(self.frame_time*0.1)
class PacketSource(RemoteSource):
pass

787
LXST/Utilities/rnphone.py Normal file
View File

@@ -0,0 +1,787 @@
#!/usr/bin/env python3
import RNS
import os
import sys
import time
import signal
import threading
import argparse
from LXST._version import __version__
from LXST.Primitives.Telephony import Telephone
from RNS.vendor.configobj import ConfigObj
class ReticulumTelephone():
STATE_AVAILABLE = 0x00
STATE_CONNECTING = 0x01
STATE_RINGING = 0x02
STATE_IN_CALL = 0x03
HW_SLEEP_TIMEOUT = 15
HW_STATE_IDLE = 0x00
HW_STATE_DIAL = 0x01
HW_STATE_SLEEP = 0xFF
KPD_NUMBERS = ["0","1","2","3","4","5","6","7","8","9"]
KPD_HEX_ALPHA = ["A","B","C","D","E","F"]
KPD_SYMBOLS = ["*","#"]
RING_TIME = 30
WAIT_TIME = 60
PATH_TIME = 10
def __init__(self, configdir, rnsconfigdir, verbosity = 0, service = False):
self.service = service
self.configdir = configdir
self.config = None
self.should_run = False
self.telephone = None
self.state = self.STATE_AVAILABLE
self.hw_state = self.HW_STATE_IDLE
self.hw_last_event = time.time()
self.hw_input = ""
self.direction = None
self.last_input = None
self.first_run = False
self.ringtone_path = None
self.speaker_device = None
self.microphone_device = None
self.ringer_device = None
self.keypad = None
self.display = None
self.allowed = Telephone.ALLOW_ALL
self.allow_phonebook = False
self.allowed_list = []
self.blocked_list = []
self.phonebook = {}
self.aliases = {}
self.names = {}
self.reload_config()
self.main_menu()
reticulum = RNS.Reticulum(configdir=rnsconfigdir, loglevel=3+verbosity)
self.telephone = Telephone(self.identity, ring_time=self.ring_time, wait_time=self.wait_time)
self.telephone.set_ringtone(self.ringtone_path)
self.telephone.set_ringing_callback(self.ringing)
self.telephone.set_established_callback(self.call_established)
self.telephone.set_ended_callback(self.call_ended)
self.telephone.set_speaker(self.speaker_device)
self.telephone.set_microphone(self.microphone_device)
self.telephone.set_ringer(self.ringer_device)
self.telephone.set_allowed(self.allowed)
self.telephone.set_blocked(self.blocked_list)
def create_default_config(self):
rnphone_config = ConfigObj(__default_rnphone_config__.splitlines())
rnphone_config.filename = self.configpath
rnphone_config.write()
def reload_config(self):
if self.service: RNS.log("Loading configuration...", RNS.LOG_DEBUG)
if self.configdir == None:
if os.path.isdir("/etc/rnphone") and os.path.isfile("/etc/rnphone/config"):
self.configdir = "/etc/rnphone"
elif os.path.isdir(RNS.Reticulum.userdir+"/.config/rnphone") and os.path.isfile(Reticulum.userdir+"/.config/rnphone/config"):
self.configdir = RNS.Reticulum.userdir+"/.config/rnphone"
else:
self.configdir = RNS.Reticulum.userdir+"/.rnphone"
self.configpath = self.configdir+"/config"
self.ignoredpath = self.configdir+"/ignored"
self.allowedpath = self.configdir+"/allowed"
self.identitypath = self.configdir+"/identity"
self.storagedir = self.configdir+"/storage"
self.ring_time = ReticulumTelephone.RING_TIME
self.wait_time = ReticulumTelephone.WAIT_TIME
self.path_time = ReticulumTelephone.PATH_TIME
if not os.path.isdir(self.storagedir):
os.makedirs(self.storagedir)
if not os.path.isfile(self.configpath):
self.create_default_config()
self.first_run = True
if os.path.isfile(self.configpath):
try:
self.config = ConfigObj(self.configpath)
except Exception as e:
RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR)
RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR)
RNS.panic()
# Generate or load primary identity
if os.path.isfile(self.identitypath):
try:
self.identity = RNS.Identity.from_file(self.identitypath)
if self.identity != None:
pass
else:
RNS.log("Could not load the Primary Identity from "+self.identitypath, RNS.LOG_ERROR)
exit(1)
except Exception as e:
RNS.log("Could not load the Primary Identity from "+self.identitypath, RNS.LOG_ERROR)
RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR)
exit(1)
else:
try:
print("No primary identity file found, creating new...")
self.identity = RNS.Identity()
self.identity.to_file(self.identitypath)
print("Created new Primary Identity %s" % (str(self.identity)))
except Exception as e:
RNS.log("Could not create and save a new Primary Identity", RNS.LOG_ERROR)
RNS.log("The contained exception was: %s" % (str(e)), RNS.LOG_ERROR)
exit(1)
self.apply_config()
def __is_allowed(self, identity_hash):
if identity_hash in self.allowed_list: return True
else: return False
def load_phonebook(self, phonebook):
if self.service: RNS.log("Loading phonebook...", RNS.LOG_DEBUG)
for name in phonebook:
alias = None
identity_hash = phonebook[name]
if type(identity_hash) == list:
components = identity_hash
identity_hash = components[0]
alias_input = components[1]
alias = ""
for c in alias_input:
if c in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]:
alias += c
if len(alias) == 0: alias = None
if len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
if identity_hash != RNS.hexrep(self.identity.hash, delimit=False):
try:
hash_bytes = bytes.fromhex(identity_hash)
self.phonebook[name] = identity_hash
self.names[identity_hash] = name
if alias: self.aliases[identity_hash] = alias
if self.allow_phonebook: self.allowed_list.append(hash_bytes)
except Exception as e:
RNS.log(f"Could not load phonebook entry for {name}: {e}", RNS.LOG_ERROR)
def apply_config(self):
if "telephone" in self.config:
config = self.config["telephone"]
if "ringtone" in config: self.ringtone_path = os.path.join(self.configdir, config["ringtone"])
if "speaker" in config: self.speaker_device = config["speaker"]
if "microphone" in config: self.microphone_device = config["microphone"]
if "ringer" in config: self.ringer_device = config["ringer"]
if "allowed_callers" in config:
allowed_callers = config["allowed_callers"]
if str(allowed_callers).lower() == "all": self.allowed = Telephone.ALLOW_ALL
elif str(allowed_callers).lower() == "none": self.allowed = Telephone.ALLOW_NONE
elif str(allowed_callers).lower() == "phonebook":
self.allow_phonebook = True
self.allowed = self.__is_allowed
elif type(config["allowed_callers"]) == list:
self.allowed = self.__is_allowed
for identity_hash in config["allowed_callers"]:
if len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
if identity_hash != RNS.hexrep(self.identity.hash, delimit=False):
try: hash_bytes = bytes.fromhex(identity_hash)
except Exception as e: RNS.log(f"Could not load allowed caller entry {identity_hash}: {e}", RNS.LOG_ERROR)
self.allowed_list.append(hash_bytes)
if "blocked_callers" in config:
blocked_callers = config["blocked_callers"]
if not type(blocked_callers) == list: blocked_callers = [blocked_callers]
if len(blocked_callers) > 0:
for identity_hash in blocked_callers:
if len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
if identity_hash != RNS.hexrep(self.identity.hash, delimit=False):
try: hash_bytes = bytes.fromhex(identity_hash)
except Exception as e: RNS.log(f"Could not load blocked caller entry {identity_hash}: {e}", RNS.LOG_ERROR)
self.blocked_list.append(hash_bytes)
if "phonebook" in self.config:
self.load_phonebook(self.config["phonebook"])
if "hardware" in self.config:
config = self.config["hardware"]
if "keypad" in config:
self.enable_keypad(config["keypad"].lower())
if "keypad_hook_pin" in config: self.enable_hook(pin = config.as_int("keypad_hook_pin"))
if "display" in config: self.enable_display(config["display"].lower())
self.last_dialled_identity_hash = None
def enable_keypad(self, driver):
if self.service: RNS.log(f"Starting keypad: {driver}", RNS.LOG_DEBUG)
if driver == "gpio_4x4":
from LXST.Primitives.hardware.keypad_gpio_4x4 import Keypad
self.keypad = Keypad(callback=self._keypad_event)
self.keypad.start()
else: raise OSError("Unknown keypad driver specified")
def enable_hook(self, pin=None):
if self.keypad: self.keypad.enable_hook(pin=pin)
def enable_display(self, driver):
if self.service: RNS.log(f"Starting display: {driver}", RNS.LOG_DEBUG)
if self.display == None:
if driver == "i2c_lcd1602":
from LXST.Primitives.hardware.display_i2c_lcd1602 import LCD
self.display = LCD()
else: raise OSError("Unknown display driver specified")
if self.display:
threading.Thread(target=self._display_job, daemon=True).start()
@property
def is_available(self):
return self.state == self.STATE_AVAILABLE
@property
def is_in_call(self):
return self.state == self.STATE_IN_CALL
@property
def is_ringing(self):
return self.state == self.STATE_RINGING
@property
def call_is_connecting(self):
return self.state == self.STATE_CONNECTING
@property
def hw_is_idle(self):
return self.hw_state == self.HW_STATE_IDLE
@property
def hw_is_dialing(self):
return self.hw_state == self.HW_STATE_DIAL
def start(self):
if not self.should_run:
signal.signal(signal.SIGINT, self.sigint_handler)
signal.signal(signal.SIGTERM, self.sigterm_handler)
self.telephone.announce()
self.should_run = True
self.run()
def stop(self):
self.should_run = False
def dial(self, identity_hash):
self.last_dialled_identity_hash = identity_hash
self.telephone.set_busy(True)
identity_hash = bytes.fromhex(identity_hash)
destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", identity_hash)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
if self.display: self.display.print("Finding path...", x=0, y=0)
def spincheck():
return RNS.Transport.has_path(destination_hash)
self.__spin(spincheck, "Requesting path for call to "+RNS.prettyhexrep(identity_hash), self.path_time)
if not spincheck():
print("Path request timed out")
if self.display:
self.display.print("Finding path", x=0, y=0)
self.display.print("timed out", x=0, y=1)
time.sleep(1.5)
self.became_available()
self.telephone.set_busy(False)
if RNS.Transport.has_path(destination_hash):
call_hops = RNS.Transport.hops_to(destination_hash)
cs = "" if call_hops == 1 else "s"
print(f"Connecting call over {call_hops} hop{cs}...")
if self.display:
call_hops_str = f"({call_hops}h{cs})"
call_str = "Calling"; ns = self.display.COLS-(len(call_str)+len(call_hops_str)); s = " "*ns
disp_str = f"{call_str}{s}{call_hops_str}"
self.display.print(disp_str, x=0, y=0)
identity = RNS.Identity.recall(destination_hash)
self.call(identity)
else:
self.became_available()
def redial(self, args=None):
if self.last_dialled_identity_hash: self.dial(self.last_dialled_identity_hash)
def call(self, remote_identity):
print(f"Calling {RNS.prettyhexrep(remote_identity.hash)}...")
self.state = self.STATE_CONNECTING
self.caller = remote_identity
self.direction = "to"
self.telephone.call(self.caller)
def ringing(self, remote_identity):
if self.hw_state == self.HW_STATE_SLEEP: self.hw_state = self.HW_STATE_IDLE
self.state = self.STATE_RINGING
self.caller = remote_identity
self.direction = "from" if self.direction == None else "to"
print(f"\n\nIncoming call from {RNS.prettyhexrep(self.caller.hash)}")
print(f"Hit enter to answer, {Terminal.BOLD}r{Terminal.END} to reject")
if self.display:
hash_str = RNS.hexrep(self.caller.hash, delimit=False)
if hash_str in self.aliases:
remote_alias = self.aliases[hash_str]
remote_name = self.names[hash_str]
self.display.print(remote_name, x=0, y=0)
self.display.print(f"({remote_alias})".rjust(self.display.COLS," "), x=0, y=1)
else:
self.display.print(hash_str[:16], x=0, y=0)
self.display.print(hash_str[16:], x=0, y=1)
def call_ended(self, remote_identity):
if self.is_in_call or self.is_ringing or self.call_is_connecting:
if self.is_in_call: print(f"Call with {RNS.prettyhexrep(self.caller.hash)} ended\n")
if self.is_ringing: print(f"Call {self.direction} {RNS.prettyhexrep(self.caller.hash)} was not answered\n")
if self.call_is_connecting: print(f"Call to {RNS.prettyhexrep(self.caller.hash)} could not be connected\n")
self.direction = None
self.state = self.STATE_AVAILABLE
self.became_available()
def call_established(self, remote_identity):
if self.call_is_connecting or self.is_ringing:
self.state = self.STATE_IN_CALL
print(f"Call established with {RNS.prettyhexrep(self.caller.hash)}")
self.display_call_status()
def display_call_status(self):
def job():
started = time.time()
erase_str = ""
while self.state == self.STATE_IN_CALL:
elapsed = round(time.time()-started)
time_string = RNS.prettytime(elapsed)
stat_string = f"In call for {time_string}, hit enter to hang up "
print(f"\r{stat_string}", end="")
erase_string = " "*len(stat_string)
sys.stdout.flush()
print(f"\r{erase_str}", end="")
if self.display:
self.display.print("Call connected", x=0, y=0)
self.display.print(f"{time_string}", x=0, y=1)
time.sleep(1.00)
else:
time.sleep(0.25)
print(f"\r{erase_str}> ", end="")
threading.Thread(target=job, daemon=True).start()
def became_available(self):
if not self.service:
if self.is_available and self.first_run:
hs = ""
if not hasattr(self, "first_prompt"): hs = " (or ? for help)"; self.first_prompt = True
print(f"Enter identity hash and hit enter to call{hs}\n", end="")
print("> ", end="")
sys.stdout.flush()
if self.display:
self.display.clear()
self.display.print("Telephone Ready", x=0, y=0)
self.display.print("", x=0, y=1)
if self.display or self.keypad:
self.hw_last_event = time.time()
self.hw_input = ""
self.hw_state = self.HW_STATE_IDLE
def print_identity(self, args):
print(f"Identity hash of this telephone: {RNS.prettyhexrep(self.identity.hash)}\n")
def print_destination(self, args):
print(f"Destination hash of this telephone: {RNS.prettyhexrep(self.telephone.destination.hash)}\n")
def phonebook_menu(self, args=None):
if len(self.phonebook) < 1:
print("\nNo entries in phonebook\n")
else:
def exit_menu(args=None):
print("Phonebook closed")
self.main_menu()
def dial_factory(identity_hash):
def x(args=None): self.dial(identity_hash)
return x
print("")
print(f"{Terminal.UNDERLINE}Phonebook{Terminal.END}")
self.active_menu = {}
maxaliaslen = 0
for identity_hash in self.aliases: maxaliaslen = max(maxaliaslen, len(self.aliases[identity_hash]))
maxlen = 0; maxnlen = max(maxaliaslen, len(str(len(self.phonebook)))); n = 0
for name in self.phonebook: maxlen = max(maxlen, len(name))
for name in self.phonebook:
n += 1; identity_hash = self.phonebook[name]
alias = n
if identity_hash in self.aliases:
alias = self.aliases[identity_hash]
spaces = maxlen-len(name); nspaces = maxnlen-len(str(alias)); s = " "
print(f" {Terminal.BOLD}{s*nspaces}{alias}{Terminal.END} {name}{s*spaces} : <{identity_hash}>")
self.active_menu[f"{alias}"] = dial_factory(identity_hash)
print(f" {Terminal.BOLD}b{Terminal.END}ack{s*(max(0, maxlen+maxnlen-2))}: Back to main menu\n")
self.active_menu["b"] = exit_menu
self.active_menu["back"] = exit_menu
self.active_menu["q"] = exit_menu
self.active_menu["quit"] = exit_menu
def main_menu(self, args=None):
def m_help(argv):
print("")
print(f"{Terminal.UNDERLINE}Available commands{Terminal.END}")
print(f" {Terminal.BOLD}p{Terminal.END}honebook : Open the phonebook")
print(f" {Terminal.BOLD}r{Terminal.END}edial : Call the last called identity again")
print(f" {Terminal.BOLD}i{Terminal.END}dentity : Display the identity hash of this telephone")
print(f" {Terminal.BOLD}d{Terminal.END}esthash : Display the destination hash of this telephone")
print(f" {Terminal.BOLD}a{Terminal.END}nnounce : Send an announce from this telephone")
print(f" {Terminal.BOLD}q{Terminal.END}uit : Exit the program")
print(f" {Terminal.BOLD}h{Terminal.END}elp : This help menu")
print("")
def m_quit(argv):
self.quit()
def m_announce(argv):
self.telephone.announce()
print(f"Announce sent")
self.active_menu = {"help": m_help,
"h": m_help,
"?": m_help,
"p": self.phonebook_menu,
"phonebook": self.phonebook_menu,
"r": self.redial,
"i": self.print_identity,
"identity": self.print_identity,
"d": self.print_destination,
"desthash": self.print_destination,
"a": m_announce,
"anounce": m_announce,
"redial": self.redial,
"exit": m_quit,
"quit": m_quit,
"q": m_quit}
def run(self):
if self.service:
print(f"Reticulum Telephone Service is ready")
print(f"Identity hash: {RNS.prettyhexrep(self.identity.hash)}")
else:
print(f"\n{Terminal.BOLD}Reticulum Telephone Utility is ready{Terminal.END}")
print(f" Identity hash: {RNS.prettyhexrep(self.identity.hash)}\n")
if self.service:
self.became_available()
while self.should_run:
time.sleep(0.5)
else:
while self.should_run:
if self.is_available:
if self.last_input and len(self.last_input) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
if self.is_available:
try:
self.dial(self.last_input)
except Exception as e:
print(f"Invalid identity hash: {e}\n")
RNS.trace_exception(e)
elif self.last_input and self.last_input.split(" ")[0] in self.active_menu:
self.active_menu[self.last_input.split(" ")[0]](self.last_input.split(" ")[1:])
self.became_available()
else:
self.became_available()
elif self.is_ringing:
if self.last_input == "":
print(f"Answering call from {RNS.prettyhexrep(self.caller.hash)}")
if not self.telephone.answer(self.caller):
print(f"Could not answer call from {RNS.prettyhexrep(self.caller.hash)}")
else:
print(f"Rejecting call from {RNS.prettyhexrep(self.caller.hash)}")
self.telephone.hangup()
elif self.is_in_call or self.call_is_connecting:
print(f"Hanging up call with {RNS.prettyhexrep(self.caller.hash)}")
self.telephone.hangup()
self.last_input = input()
def cleanup(self):
if self.display: self.display.close()
if self.keypad: self.keypad.stop()
def quit(self):
self.cleanup()
exit(0)
def __spin(self, until=None, msg=None, timeout=None):
i = 0
syms = "⢄⢂⢁⡁⡈⡐⡠"
if timeout != None:
timeout = time.time()+timeout
print(msg+" ", end=" ")
while (timeout == None or time.time()<timeout) and not until():
time.sleep(0.1)
print(("\b\b"+syms[i]+" "), end="")
sys.stdout.flush()
i = (i+1)%len(syms)
print("\r"+" "*len(msg)+" \r", end="")
if timeout != None and time.time() > timeout:
return False
else:
return True
def _display_job(self):
while self.display:
now = time.time()
if self.is_available and self.hw_is_idle and (self.telephone and not self.telephone.busy):
if now - self.hw_last_event >= self.HW_SLEEP_TIMEOUT:
self.hw_state = self.HW_STATE_SLEEP
self._sleep_display()
time.sleep(1)
def _sleep_display(self):
if self.display: self.display.sleep()
def _wake_display(self):
if self.display: self.display.wake()
def _update_display(self):
if self.display:
if self.hw_is_dialing:
if len(self.hw_input) == 0: lookup_name = "Enter number"
else: lookup_name = "Unknown"
for identity_hash in self.aliases:
alias = self.aliases[identity_hash]
if self.hw_input == alias: lookup_name = self.names[identity_hash]
self.display.print(f"{self.hw_input}", x=0, y=0)
self.display.print(f"{lookup_name}", x=0, y=1)
def _keypad_event(self, keypad, event):
self.hw_last_event = time.time()
if self.hw_state == self.HW_STATE_SLEEP:
self.hw_state = self.HW_STATE_IDLE
self._wake_display()
self.became_available()
if self.is_ringing:
answer_events = event[0] == "D" and event[1] == self.keypad.ec.DOWN
answer_events |= event[0] == "hook" and event[1] == self.keypad.ec.UP
if answer_events:
print(f"Answering call from {RNS.prettyhexrep(self.caller.hash)}")
if not self.telephone.answer(self.caller):
print(f"Could not answer call from {RNS.prettyhexrep(self.caller.hash)}")
elif event[0] == "C" and event[1] == self.keypad.ec.DOWN:
print(f"Rejecting call from {RNS.prettyhexrep(self.caller.hash)}")
self.telephone.hangup()
elif self.is_in_call or self.call_is_connecting:
hangup_events = event[0] == "D" and event[1] == self.keypad.ec.DOWN
hangup_events |= event[0] == "hook" and event[1] == self.keypad.ec.DOWN
if hangup_events:
print(f"Hanging up call with {RNS.prettyhexrep(self.caller.hash)}")
self.telephone.hangup()
elif self.is_available and self.hw_is_idle:
if event[0] == "A" and event[1] == self.keypad.ec.DOWN:
self.hw_input = ""; self.hw_state = self.HW_STATE_DIAL
self._update_display()
if event[0] in self.KPD_NUMBERS and event[1] == self.keypad.ec.DOWN:
self.hw_input += event[0]; self.hw_state = self.HW_STATE_DIAL
self._update_display()
elif self.is_available and self.hw_is_dialing:
dial_event = False
if event[1] == self.keypad.ec.DOWN:
if event[0] in self.KPD_NUMBERS: self.hw_input += event[0]
if event[0] == "A": self.became_available()
if event[0] == "B": self.hw_input = self.hw_input[:-1]
if event[0] == "C": self.hw_input = ""
if event[0] == "D": dial_event = True
if event[0] == "hook" and event[1] == self.keypad.ec.UP: dial_event = True
if dial_event:
for identity_hash in self.aliases:
alias = self.aliases[identity_hash]
if self.hw_input == alias:
self.hw_input = ""
self.hw_state = self.HW_STATE_IDLE
self.dial(identity_hash)
self._update_display()
def sigint_handler(self, signal, frame):
self.cleanup()
exit(0)
def sigterm_handler(self, signal, frame):
self.cleanup()
exit(0)
def main():
app = None
try:
parser = argparse.ArgumentParser(description="Reticulum Telephone Utility")
parser.add_argument("-l", "--list-devices", action="store_true", help="list available audio devices", default=False)
parser.add_argument("--config", action="store", default=None, help="path to config directory", type=str)
parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str)
parser.add_argument("-s", "--service", action="store_true", help="run as a service", default=False)
parser.add_argument("--systemd", action="store_true", help="display example systemd unit", default=False)
parser.add_argument("--version", action="version", version="rnprobe {version}".format(version=__version__))
parser.add_argument('-v', '--verbose', action='count', default=0)
args = parser.parse_args()
if args.list_devices:
import LXST
RNS.loglevel = 0
print("\nAvailable audio devices:")
for device in LXST.Sources.Backend().soundcard.all_speakers(): print(f" Output : {device}")
for device in LXST.Sinks.Backend().soundcard.all_microphones(): print(f" Input : {device}")
exit(0)
if args.systemd:
print("To install rnphone as a system service, paste the")
print("systemd unit configuration below into a new file at:\n")
print("/etc/systemd/system/rnphone.service\n")
print("Then enable the service at boot by running:\n\nsudo systemctl enable rnphone\n")
print("--- begin systemd unit snipped ---\n")
print(__systemd_unit__.replace("USERNAME", os.getlogin()))
print("--- end systemd unit snipped ---\n")
exit(0)
ReticulumTelephone(configdir = args.config,
rnsconfigdir = args.rnsconfig,
verbosity = args.verbose,
service = args.service).start()
except KeyboardInterrupt:
if app: app.quit()
print("")
exit()
__default_rnphone_config__ = """# This is an example rnphone config file.
# You should probably edit it to suit your
# intended usage.
[telephone]
# You can define the ringtone played when the
# phone is ringing. Must be in OPUS format, and
# located in the rnphone config directory.
ringtone = ringtone.opus
# You can define the preferred audio devices
# to use as the speaker output, ringer output
# and microphone input. The names do not have
# to be an exact match to your full soundcard
# device name, but will be fuzzy matched.
# You can list available device names with:
# rnphone -l
# speaker = device name
# microphone = device name
# ringer = device name
# You can configure who is allowed to call
# this telephone. This can be set to either
# "all", "none", "phonebook" or a list of
# identity hashes. See examples below.
# allowed_callers = all
# allowed_callers = none
# allowed_callers = phonebook
# allowed_callers = b8d80b1b7a9d3147880b366995422a45, fcfb80d4cd3aab7c8710541fb2317974
# It is also possible to block specific
# callers on a per-identity basis.
# blocked_callers = f3e8c3359b39d36f3baff0a616a73d3e, 5d2d14619dfa0ff06278c17347c14331
[phonebook]
# You can add entries to the phonebook for
# quick dialling by adding them here
# Mary = f3e8c3359b39d36f3baff0a616a73d3e
# Jake = b8d80b1b7a9d3147880b366995422a45
# Dean = 05d4c6697bb38e5458a3077571157bfa
# You can optionally specify a numerical
# alias for calling with a physical keypad
# Rudy = 5d2d14619dfa0ff06278c17347c14331, 241
# Josh = fcfb80d4cd3aab7c8710541fb2317974, 7907
[hardware]
# If the required hardware is connected, and
# the neccessary modules installed, you can
# enable various hardware components.
# keypad = gpio_4x4
# display = i2c_lcd1602
# If you have a keypad connected, you can
# also enable a GPIO pin for detecting
# on-hook/off-hook status
# keypad_hook_pin = 5
# You can configure a pin for muting the
# ringer amplifier, if available
# amp_mute_pin = 25
# amp_mute_level = high
"""
__systemd_unit__ = """# This systemd unit allows installing rnphone
# as a system service on Linux-based devices
[Unit]
Description=Reticulum Telephone Service
After=sound.target
[Service]
# Wait 30 seconds for WiFi and audio
# hardware to initialise.
ExecStartPre=/bin/sleep 30
Type=simple
Environment="DISPLAY=:0"
Environment="XAUTHORITY=/home/USERNAME/.Xauthority"
Environment="XDG_RUNTIME_DIR=/run/user/1000"
Restart=always
RestartSec=5
User=USERNAME
ExecStart=/home/USERNAME/.local/bin/rnphone --service -vvv
[Install]
WantedBy=graphical.target
"""
class Terminal():
UNDERLINE = "\033[4m"
BOLD = "\033[1m"
END = "\033[0m"
if __name__ == "__main__":
main()

7
LXST/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
APP_NAME = "lxst"
from .Pipeline import Pipeline
from .Mixer import Mixer
from .Sources import *
from .Generators import *
from .Primitives import *

1
LXST/_version.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.2.4"

30
Makefile Normal file
View File

@@ -0,0 +1,30 @@
all: release
clean:
@echo Cleaning...
-rm -r ./build
-rm -r ./dist
remove_symlinks:
@echo Removing symlinks for build...
-rm ./RNS
-rm ./LXST/Utilities/LXST
-rm ./examples/LXST
create_symlinks:
@echo Creating symlinks...
-ln -s ../Reticulum/RNS ./
-ln -s ../../LXST/ ./LXST/Utilities/LXST
-ln -s ../LXST/ ./examples/LXST
build_wheel:
python3 setup.py sdist bdist_wheel
release: remove_symlinks build_wheel create_symlinks
upload:
@echo Ready to publish release, hit enter to continue
@read VOID
@echo Uploading to PyPi...
twine upload dist/*
@echo Release published

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Lightweight Extensible Signal Transport
LXST is a simple and flexible real-time streaming format and delivery protocol that allows a wide variety of implementations, while using as little bandwidth as possible. It is built on top of [Reticulum](https://reticulum.network) and offers zero-conf stream routing, end-to-end encryption and Forward Secrecy, and can be transported over any kind of medium that Reticulum supports.
- Provides a variety of ready-to-use primitives, for easily creating applications such as:
- Telephony and live voice calls
- Two-way radio systems
- Direct peer-to-peer radio communications
- Trunked and routed real-time radio systems
- Media streaming
- Broadcast radio
- Public address systems
- Can handle real-time signal streams with end-to-end latencies below 10 milliseconds
- Supports encoding and decoding stream contents with a range of different codecs
- Raw and lossless streams with arbitrary sample rates
- Up to 32 channels
- Up to 128-bit sample precision
- Efficient, high-quality voice and audio with OPUS
- Many different built-in profiles, from ~4.5kbps to ~96kbps
- Profiles are pre-tuned for different applications, such as:
- Low-bandwidth voice
- Medium quality voice
- High quality, perceptually lossless voice
- Media content such as podcasts
- Perceptually lossless stereo music
- Ultra low-bandwidth voice communications with Codec2
- Provides intelligible voice between 700bps and 3200bps
- Can dynamically switch codecs mid-stream without stream re-initialization or frame loss
- Has in-band signalling support for call signalling, communications, metadata embedding, media and stream management
- Uses a fully staged signal pipelining, allowing arbitrary stream routing
- Provides built-in signal mixing support for any number of channels
## Transport Encryption
LXST uses encryption provided by [Reticulum](https://reticulum.network), and thus provides end-to-end encryption, guaranteed data integrity and authenticity, as well as forward secrecy by default.
## Project Status & License
This software is in a very early alpha state, and will change rapidly with ongoing development. Consider no APIs stable. Consider everything explosive.
While under early development, the project is kept under a `CC BY-NC-ND 4.0` license.
## Installation
If you want to try out LXST, you can install it with pip:
```bash
pip install lxst
```

BIN
docs/425.opus Normal file
View File

Binary file not shown.

140
docs/rns_audio_call_calc.py Normal file
View File

@@ -0,0 +1,140 @@
import os
import math
import RNS
import RNS.vendor.umsgpack as mp
def simulate(link_speed=2735, audio_slot_ms=400, codec_rate=1200,
signalling_bytes=14, method="msgpack"):
# Simulated on-air link speed
LINK_SPEED = link_speed
# Packing method, can be "msgpack" or "protobuf"
PACKING_METHOD = method
# The target audio slot time
TARGET_MS = audio_slot_ms
# Packets needed per second for half-duplex audio
PACKETS_PER_SECOND = 1000/TARGET_MS
# Effective audio encoder bitrate
CODEC_RATE = codec_rate
# Per-packet overhead on a established link is 19
# bytes, 3 for header and context, 16 for link ID
RNS_OVERHEAD = 19
# Physical-layer overhead. For RNode, this is 1
# byte per RNS packet.
PHY_OVERHEAD = 1
# Total transport overhead
TRANSPORT_OVERHEAD = PHY_OVERHEAD+RNS_OVERHEAD
# Calculate parameters
AUDIO_LEN = int(math.ceil(CODEC_RATE/(1000/TARGET_MS)/8))
PER_BYTE_LATENCY_MS = 1000/(LINK_SPEED/8)
# Pack the message with msgpack to get real-
# world packed message size
if PACKING_METHOD == "msgpack":
# Calculate msgpack overhead
PL_LEN = len(mp.packb([os.urandom(signalling_bytes), os.urandom(AUDIO_LEN)]))
PACKING_OVERHEAD = PL_LEN-AUDIO_LEN
elif PACKING_METHOD == "protobuf":
# For protobuf, assume the 8 bytes of stated overhead
PACKING_OVERHEAD = 8
PL_LEN = AUDIO_LEN+PACKING_OVERHEAD
else:
print("Unsupported packing method")
exit(1)
# Calculate required encrypted token blocks
BLOCKSIZE = 16
REQUIRED_BLOCKS = math.ceil((PL_LEN+1)/BLOCKSIZE)
ENCRYPTED_PAYLOAD_LEN = REQUIRED_BLOCKS*BLOCKSIZE
BLOCK_HEADROOM = (REQUIRED_BLOCKS*BLOCKSIZE) - PL_LEN - 1
# The complete on-air packet length
PACKET_LEN = PHY_OVERHEAD+RNS_OVERHEAD+ENCRYPTED_PAYLOAD_LEN
PACKET_LATENCY = round(PACKET_LEN*PER_BYTE_LATENCY_MS, 1)
# TODO: This should include any additional
# airtime consumption such as preamble and TX-tail.
PACKET_AIRTIME = PACKET_LEN*PER_BYTE_LATENCY_MS
AIRTIME_PCT = (PACKET_AIRTIME/TARGET_MS) * 100
# Maximum amount of concurrent full-duplex
# calls that can coexist on the same channel
CONCURRENT_CALLS = math.floor(100/AIRTIME_PCT)
# Calculate latencies
TRANSPORT_LATENCY = round((PHY_OVERHEAD+RNS_OVERHEAD)*PER_BYTE_LATENCY_MS, 1)
PAYLOAD_LATENCY = round(ENCRYPTED_PAYLOAD_LEN*PER_BYTE_LATENCY_MS, 1)
RAW_DATA_LATENCY = round(AUDIO_LEN*PER_BYTE_LATENCY_MS, 1)
PACKING_LATENCY = round(PACKING_OVERHEAD*PER_BYTE_LATENCY_MS, 1)
DATA_LATENCY = round(ENCRYPTED_PAYLOAD_LEN*PER_BYTE_LATENCY_MS, 1)
ENCRYPTION_LATENCY = round((ENCRYPTED_PAYLOAD_LEN-PL_LEN)*PER_BYTE_LATENCY_MS, 1)
if ENCRYPTED_PAYLOAD_LEN-PL_LEN == 1:
E_OPT_STR = "(optimal)"
else:
E_OPT_STR = "(sub-optimal)"
TOTAL_LATENCY = round(TARGET_MS+PACKET_LATENCY, 1)
print( "\n===== Simulation Parameters ===\n")
print(f" Packing method : {method}")
print(f" Sampling delay : {TARGET_MS}ms")
print(f" Codec bitrate : {CODEC_RATE} bps")
print(f" Audio data : {AUDIO_LEN} bytes")
print(f" Packing overhead : {PACKING_OVERHEAD} bytes")
print(f" Payload length : {PL_LEN} bytes")
print(f" AES blocks needed : {REQUIRED_BLOCKS}")
print(f" Encrypted payload : {ENCRYPTED_PAYLOAD_LEN} bytes")
print(f" Transport overhead : {TRANSPORT_OVERHEAD} bytes ({RNS_OVERHEAD} from RNS, {PHY_OVERHEAD} from PHY)")
print(f" On-air length : {PACKET_LEN} bytes")
print(f" Packet airtime : {round(PACKET_AIRTIME,2)}ms")
print(f" Transport bitrate : {RNS.prettyspeed((PACKET_LEN*8)/(TARGET_MS/1000))}")
print( "\n===== Results for "+RNS.prettyspeed(LINK_SPEED)+" Link Speed ===\n")
print(f" Final latency : {TOTAL_LATENCY}ms")
print(f" Recording latency : contributes {TARGET_MS}ms")
print(f" Packet transport : contributes {PACKET_LATENCY}ms")
print(f" Payload : contributes {PAYLOAD_LATENCY}ms")
print(f" Audio data : contributes {RAW_DATA_LATENCY}ms")
print(f" Packing format : contributes {PACKING_LATENCY}ms")
print(f" Encryption : contributes {ENCRYPTION_LATENCY}ms {E_OPT_STR}")
print(f" RNS+PHY overhead : contributes {TRANSPORT_LATENCY}ms")
print(f"")
print(f" Half-duplex airtime : {round(AIRTIME_PCT, 2)}% of link capacity")
print(f" Concurrent calls : {int(CONCURRENT_CALLS)}\n")
print(f" Full-duplex airtime : {round(AIRTIME_PCT*2, 2)}% of link capacity")
print(f" Concurrent calls : {int(CONCURRENT_CALLS/2)}")
if BLOCK_HEADROOM != 0:
print("")
print(f" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
print(f" Unaligned AES block! Each packet could fit")
print(f" {BLOCK_HEADROOM} bytes of additional audio data")
print(f" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
#print( "\n= With mspack =================")
#simulate(method="msgpack")
# simulate(link_speed=10980, audio_slot_ms=180, codec_rate=3200,
# signalling_bytes=18, method="msgpack")
# simulate(link_speed=4690, audio_slot_ms=480, codec_rate=1200,
# signalling_bytes=18, method="msgpack")
#simulate(link_speed=60e3, audio_slot_ms=270, codec_rate=12000,
# signalling_bytes=18, method="msgpack")
simulate(link_speed=9600, audio_slot_ms=300, codec_rate=3200,
signalling_bytes=2, method="msgpack")
#print("\n\n= With protobuf ===============")
#simulate(method="protobuf")

BIN
docs/speech.opus Normal file
View File

Binary file not shown.

BIN
docs/speech_stereo.opus Normal file
View File

Binary file not shown.

51
examples/mixer.py Normal file
View File

@@ -0,0 +1,51 @@
import RNS
import LXST
import sys
import time
RNS.loglevel = RNS.LOG_DEBUG
target_frame_ms = 20
pipelined_output = True
raw = LXST.Codecs.Raw()
# Pipelined mixer example
if pipelined_output:
opus = LXST.Codecs.Opus(profile=LXST.Codecs.Opus.PROFILE_AUDIO_HIGH)
codec2 = LXST.Codecs.Codec2(mode=LXST.Codecs.Codec2.CODEC2_3200)
line_sink = LXST.Sinks.LineSink()
mixer = LXST.Mixer(target_frame_ms=target_frame_ms)
loopback = LXST.Sources.Loopback()
codec = opus
file_source1 = LXST.Sources.OpusFileSource("./docs/speech_stereo.opus", codec=raw, sink=mixer, loop=True, target_frame_ms=target_frame_ms)
file_source2 = LXST.Sources.OpusFileSource("./docs/podcast.opus", codec=raw, sink=mixer, loop=True, target_frame_ms=target_frame_ms)
line_source = LXST.Sources.LineSource(target_frame_ms=target_frame_ms, codec=raw, sink=mixer)
input_pipeline = LXST.Pipeline(source=mixer, codec=codec, sink=loopback)
output_pipeline = LXST.Pipeline(source=loopback, codec=codec, sink=line_sink)
input_pipeline.start(); output_pipeline.start()
# Simple mixer example with output directly to sink
else:
line_sink = LXST.Sinks.LineSink()
mixer = LXST.Mixer(target_frame_ms=target_frame_ms, sink=line_sink)
file_source1 = LXST.Sources.OpusFileSource("./docs/speech_stereo.opus", codec=raw, sink=mixer, loop=True, target_frame_ms=target_frame_ms)
file_source2 = LXST.Sources.OpusFileSource("./docs/podcast.opus", codec=raw, sink=mixer, loop=True, target_frame_ms=target_frame_ms)
line_source = LXST.Sources.LineSource(target_frame_ms=target_frame_ms, codec=raw, sink=mixer)
mixer.start()
line_source.start()
print("Hit enter to add another source"); input()
file_source1.start()
print("Hit enter to add another source"); input()
file_source2.start()
print("Hit enter to stop all sources"); input()
file_source1.stop()
file_source2.stop()
line_source.stop()
time.sleep(0.5)

48
examples/pipelines.py Normal file
View File

@@ -0,0 +1,48 @@
import RNS
import LXST
import sys
import time
RNS.loglevel = RNS.LOG_DEBUG
if len(sys.argv) < 2:
print("No codec specified")
sys.exit(0)
else:
selected_codec = sys.argv[1]
if len(sys.argv) >= 4:
target_frame_ms = int(sys.argv[3])
else:
target_frame_ms = 40
if len(sys.argv) >= 3 and sys.argv[2].lower() == "file":
selected_source = LXST.Sources.OpusFileSource("./docs/speech_stereo.opus", loop=True, target_frame_ms=target_frame_ms)
# selected_source = LXST.Sources.OpusFileSource("./docs/music_stereo.opus", loop=True, target_frame_ms=target_frame_ms)
# selected_source = LXST.Sources.OpusFileSource("./docs/podcast.opus", loop=True, target_frame_ms=target_frame_ms)
else:
selected_source = LXST.Sources.LineSource(target_frame_ms=target_frame_ms)
line_sink = LXST.Sinks.LineSink()
loopback = LXST.Sources.Loopback()
if selected_codec.lower() == "raw":
raw = LXST.Codecs.Raw()
input_pipeline = LXST.Pipeline(source=selected_source, codec=raw, sink=loopback)
output_pipeline = LXST.Pipeline(source=loopback, codec=raw, sink=line_sink)
elif selected_codec.lower() == "codec2":
codec2 = LXST.Codecs.Codec2(mode=LXST.Codecs.Codec2.CODEC2_1600)
input_pipeline = LXST.Pipeline(source=selected_source, codec=codec2, sink=loopback)
output_pipeline = LXST.Pipeline(source=loopback, codec=codec2, sink=line_sink)
elif selected_codec.lower() == "opus":
opus = LXST.Codecs.Opus(profile=LXST.Codecs.Opus.PROFILE_VOICE_LOW)
input_pipeline = LXST.Pipeline(source=selected_source, codec=opus, sink=loopback)
output_pipeline = LXST.Pipeline(source=loopback, codec=opus, sink=line_sink)
else:
print("No valid codec selected")
sys.exit(0)
input_pipeline.start(); output_pipeline.start()
input()
input_pipeline.stop()
time.sleep(1)

View File

@@ -0,0 +1,15 @@
import RNS
import LXST
import sys
import time
RNS.loglevel = RNS.LOG_DEBUG
target_frame_ms = 40
tone = LXST.Generators.ToneSource(frequency=388, ease_time_ms=3.14159, target_frame_ms=target_frame_ms)
line_sink = LXST.Sinks.LineSink()
output_pipeline = LXST.Pipeline(source=tone, codec=LXST.Codecs.Null(), sink=line_sink)
output_pipeline.start(); input()
tone.stop()
time.sleep(1)

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
soundcard
numpy
pycodec2

42
setup.py Normal file
View File

@@ -0,0 +1,42 @@
import setuptools
with open("README.md", "r") as fh:
long_description = fh.read()
exec(open("LXST/_version.py", "r").read())
packages = setuptools.find_packages(exclude=[])
packages.append("LXST.Utilities")
packages.append("LXST.Primitives.hardware")
packages.append("LXST.Codecs.libs.pydub")
packages.append("LXST.Codecs.libs.pyogg")
print("Packages:")
print(packages)
setuptools.setup(
name="lxst",
version=__version__,
author="Mark Qvist",
author_email="mark@unsigned.io",
description="Lightweight Extensible Signal Transport for Reticulum",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://git.unsigned.io/markqvist/lxst",
packages=packages,
classifiers=[
"Programming Language :: Python :: 3",
"License :: Other/Proprietary License",
"Operating System :: OS Independent",
],
entry_points= {
'console_scripts': [
'rnphone=LXST.Utilities.rnphone:main',
]
},
install_requires=["rns>=0.9.2",
"soundcard",
"numpy",
"pycodec2",
"audioop-lts>=0.2.1;python_version>='3.13'"],
python_requires=">=3.7",
)