mirror of
https://github.com/markqvist/LXST.git
synced 2025-12-23 19:30:17 +00:00
Public repo init
This commit is contained in:
10
.gitignore
vendored
Executable file
10
.gitignore
vendored
Executable 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
403
LICENSE
Normal 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
55
LXST/Call.py
Normal 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
62
LXST/Codecs/Codec.py
Normal 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
120
LXST/Codecs/Codec2.py
Normal 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
179
LXST/Codecs/Opus.py
Normal 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
58
LXST/Codecs/Raw.py
Normal 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
29
LXST/Codecs/__init__.py
Normal 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
|
||||
1
LXST/Codecs/libs/pydub/__init__.py
Normal file
1
LXST/Codecs/libs/pydub/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .audio_segment import AudioSegment
|
||||
1399
LXST/Codecs/libs/pydub/audio_segment.py
Normal file
1399
LXST/Codecs/libs/pydub/audio_segment.py
Normal file
File diff suppressed because it is too large
Load Diff
341
LXST/Codecs/libs/pydub/effects.py
Normal file
341
LXST/Codecs/libs/pydub/effects.py
Normal 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})
|
||||
32
LXST/Codecs/libs/pydub/exceptions.py
Normal file
32
LXST/Codecs/libs/pydub/exceptions.py
Normal 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
|
||||
142
LXST/Codecs/libs/pydub/generators.py
Normal file
142
LXST/Codecs/libs/pydub/generators.py
Normal 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
|
||||
14
LXST/Codecs/libs/pydub/logging_utils.py
Normal file
14
LXST/Codecs/libs/pydub/logging_utils.py
Normal 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())
|
||||
71
LXST/Codecs/libs/pydub/playback.py
Normal file
71
LXST/Codecs/libs/pydub/playback.py
Normal 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)
|
||||
591
LXST/Codecs/libs/pydub/pyaudioop.py
Normal file
591
LXST/Codecs/libs/pydub/pyaudioop.py
Normal 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()
|
||||
175
LXST/Codecs/libs/pydub/scipy_effects.py
Normal file
175
LXST/Codecs/libs/pydub/scipy_effects.py
Normal 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)
|
||||
|
||||
|
||||
182
LXST/Codecs/libs/pydub/silence.py
Normal file
182
LXST/Codecs/libs/pydub/silence.py
Normal 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))
|
||||
|
||||
|
||||
434
LXST/Codecs/libs/pydub/utils.py
Normal file
434
LXST/Codecs/libs/pydub/utils.py
Normal 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])
|
||||
|
||||
108
LXST/Codecs/libs/pyogg/__init__.py
Normal file
108
LXST/Codecs/libs/pyogg/__init__.py
Normal 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?)")
|
||||
59
LXST/Codecs/libs/pyogg/audio_file.py
Normal file
59
LXST/Codecs/libs/pyogg/audio_file.py
Normal 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)
|
||||
)
|
||||
2061
LXST/Codecs/libs/pyogg/flac.py
Normal file
2061
LXST/Codecs/libs/pyogg/flac.py
Normal file
File diff suppressed because it is too large
Load Diff
114
LXST/Codecs/libs/pyogg/flac_file.py
Normal file
114
LXST/Codecs/libs/pyogg/flac_file.py
Normal 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
|
||||
141
LXST/Codecs/libs/pyogg/flac_file_stream.py
Normal file
141
LXST/Codecs/libs/pyogg/flac_file_stream.py
Normal 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)
|
||||
)
|
||||
147
LXST/Codecs/libs/pyogg/library_loader.py
Normal file
147
LXST/Codecs/libs/pyogg/library_loader.py
Normal 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))
|
||||
|
||||
|
||||
|
||||
|
||||
672
LXST/Codecs/libs/pyogg/ogg.py
Normal file
672
LXST/Codecs/libs/pyogg/ogg.py
Normal 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)
|
||||
421
LXST/Codecs/libs/pyogg/ogg_opus_writer.py
Normal file
421
LXST/Codecs/libs/pyogg/ogg_opus_writer.py
Normal 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)
|
||||
1377
LXST/Codecs/libs/pyogg/opus.py
Normal file
1377
LXST/Codecs/libs/pyogg/opus.py
Normal file
File diff suppressed because it is too large
Load Diff
407
LXST/Codecs/libs/pyogg/opus_buffered_encoder.py
Normal file
407
LXST/Codecs/libs/pyogg/opus_buffered_encoder.py
Normal 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)
|
||||
273
LXST/Codecs/libs/pyogg/opus_decoder.py
Normal file
273
LXST/Codecs/libs/pyogg/opus_decoder.py
Normal 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
|
||||
358
LXST/Codecs/libs/pyogg/opus_encoder.py
Normal file
358
LXST/Codecs/libs/pyogg/opus_encoder.py
Normal 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
|
||||
106
LXST/Codecs/libs/pyogg/opus_file.py
Normal file
106
LXST/Codecs/libs/pyogg/opus_file.py
Normal 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)
|
||||
127
LXST/Codecs/libs/pyogg/opus_file_stream.py
Normal file
127
LXST/Codecs/libs/pyogg/opus_file_stream.py
Normal 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)
|
||||
)
|
||||
1
LXST/Codecs/libs/pyogg/py.typed
Normal file
1
LXST/Codecs/libs/pyogg/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
# Marker file for PEP 561. This package uses inline types.
|
||||
2
LXST/Codecs/libs/pyogg/pyogg_error.py
Normal file
2
LXST/Codecs/libs/pyogg/pyogg_error.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class PyOggError(Exception):
|
||||
pass
|
||||
855
LXST/Codecs/libs/pyogg/vorbis.py
Normal file
855
LXST/Codecs/libs/pyogg/vorbis.py
Normal 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
|
||||
161
LXST/Codecs/libs/pyogg/vorbis_file.py
Normal file
161
LXST/Codecs/libs/pyogg/vorbis_file.py
Normal 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))
|
||||
110
LXST/Codecs/libs/pyogg/vorbis_file_stream.py
Normal file
110
LXST/Codecs/libs/pyogg/vorbis_file_stream.py
Normal 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
2
LXST/Common.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def nop():
|
||||
pass
|
||||
135
LXST/Generators.py
Normal file
135
LXST/Generators.py
Normal 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
149
LXST/Mixer.py
Normal 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
149
LXST/Network.py
Normal 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
60
LXST/Pipeline.py
Normal 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()
|
||||
501
LXST/Primitives/Telephony.py
Normal file
501
LXST/Primitives/Telephony.py
Normal 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)}>"
|
||||
1
LXST/Primitives/__init__.py
Normal file
1
LXST/Primitives/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .Telephony import Telephone
|
||||
100
LXST/Primitives/hardware/display_i2c_lcd1602.py
Normal file
100
LXST/Primitives/hardware/display_i2c_lcd1602.py
Normal 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
|
||||
130
LXST/Primitives/hardware/keypad_gpio_4x4.py
Normal file
130
LXST/Primitives/hardware/keypad_gpio_4x4.py
Normal 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
0
LXST/Processing.py
Normal file
136
LXST/Sinks.py
Normal file
136
LXST/Sinks.py
Normal 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
270
LXST/Sources.py
Normal 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
787
LXST/Utilities/rnphone.py
Normal 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
7
LXST/__init__.py
Normal 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
1
LXST/_version.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.2.4"
|
||||
30
Makefile
Normal file
30
Makefile
Normal 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
49
README.md
Normal 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
BIN
docs/425.opus
Normal file
Binary file not shown.
140
docs/rns_audio_call_calc.py
Normal file
140
docs/rns_audio_call_calc.py
Normal 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
BIN
docs/speech.opus
Normal file
Binary file not shown.
BIN
docs/speech_stereo.opus
Normal file
BIN
docs/speech_stereo.opus
Normal file
Binary file not shown.
51
examples/mixer.py
Normal file
51
examples/mixer.py
Normal 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
48
examples/pipelines.py
Normal 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)
|
||||
15
examples/tone_generator.py
Normal file
15
examples/tone_generator.py
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
soundcard
|
||||
numpy
|
||||
pycodec2
|
||||
42
setup.py
Normal file
42
setup.py
Normal 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",
|
||||
)
|
||||
Reference in New Issue
Block a user