mirror of
https://github.com/ratspeak/rsLXST
synced 2026-08-12 18:08:16 -04:00
Initial commit
This commit is contained in:
commit
2e0d1e733c
27 changed files with 16398 additions and 0 deletions
124
.github/workflows/ci.yml
vendored
Normal file
124
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Python LXST parity fixtures and the pinned upstream reference are kept in
|
||||
# the internal development tree, not in this published source release. The
|
||||
# corresponding tests skip themselves when this env var is set.
|
||||
SKIP_PYTHON_LXST_INTEROP: "1"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and Docs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: rsLXST
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/rsReticulum
|
||||
ref: main
|
||||
path: rsReticulum
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rsLXST -> target
|
||||
- name: Install Linux system deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config libopus0 libogg0
|
||||
- run: cargo fmt --all -- --check
|
||||
working-directory: rsLXST
|
||||
- run: cargo clippy --workspace -- -D warnings
|
||||
working-directory: rsLXST
|
||||
- run: cargo doc --workspace --no-deps
|
||||
working-directory: rsLXST
|
||||
env:
|
||||
RUSTDOCFLAGS: "-D warnings"
|
||||
|
||||
desktop-test:
|
||||
name: Test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: rsLXST
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/rsReticulum
|
||||
ref: main
|
||||
path: rsReticulum
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rsLXST -> target
|
||||
- name: Install Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config libopus0 libogg0
|
||||
- name: Install macOS Opus runtime
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install opus libogg
|
||||
- run: cargo test --workspace
|
||||
working-directory: rsLXST
|
||||
|
||||
mobile-check:
|
||||
name: Check (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-ios
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: rsLXST
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/rsReticulum
|
||||
ref: main
|
||||
path: rsReticulum
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rsLXST -> target
|
||||
- uses: nttld/setup-ndk@v1
|
||||
if: matrix.target == 'aarch64-linux-android'
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: r27d
|
||||
add-to-path: false
|
||||
- name: Check Android
|
||||
if: matrix.target == 'aarch64-linux-android'
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||
AR_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
|
||||
CC_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang
|
||||
CXX_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang++
|
||||
run: cargo check --workspace --target ${{ matrix.target }}
|
||||
working-directory: rsLXST
|
||||
- name: Check iOS
|
||||
if: matrix.target == 'aarch64-apple-ios'
|
||||
run: cargo check --workspace --target ${{ matrix.target }}
|
||||
working-directory: rsLXST
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
target/
|
||||
|
||||
# Runtime data and local identities
|
||||
.reticulum/
|
||||
.ratspeak/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.key
|
||||
identity
|
||||
storage/
|
||||
|
||||
# Python and tooling caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Editor and local environment files
|
||||
.env
|
||||
.env.*
|
||||
*.swp
|
||||
2243
Cargo.lock
generated
Normal file
2243
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/lxst-core",
|
||||
"crates/lxst-rns",
|
||||
"crates/lxst-telephony",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
rust-version = "1.85"
|
||||
|
||||
[workspace.dependencies]
|
||||
rmpv = "1"
|
||||
thiserror = "2"
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"] }
|
||||
half = "2"
|
||||
proptest = "1"
|
||||
opus-rs = "0.1.19"
|
||||
serial_test = "3"
|
||||
|
||||
rns-crypto = { path = "../rsReticulum/crates/rns-crypto" }
|
||||
rns-identity = { path = "../rsReticulum/crates/rns-identity" }
|
||||
rns-interface = { path = "../rsReticulum/crates/rns-interface" }
|
||||
rns-link = { path = "../rsReticulum/crates/rns-link" }
|
||||
rns-runtime = { path = "../rsReticulum/crates/rns-runtime" }
|
||||
rns-transport = { path = "../rsReticulum/crates/rns-transport" }
|
||||
rns-wire = { path = "../rsReticulum/crates/rns-wire" }
|
||||
|
||||
lxst-core = { path = "crates/lxst-core" }
|
||||
lxst-rns = { path = "crates/lxst-rns" }
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
226
README.md
Normal file
226
README.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<div align="center">
|
||||
|
||||
# rsLXST
|
||||
|
||||
**Rust LXST telephony and media streaming for Reticulum.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://github.com/markqvist/LXST)
|
||||
[](#feature-status)
|
||||
|
||||
[rsLXMF](https://github.com/ratspeak/rsLXMF) |
|
||||
[Ratspeak](https://github.com/ratspeak/Ratspeak) |
|
||||
[rsReticulum](https://github.com/ratspeak/rsReticulum) |
|
||||
[Reticulum Manual](https://reticulum.network/manual/)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
rsLXST is a Rust implementation of [LXST](https://github.com/markqvist/LXST), the Lightweight Extensible Signal
|
||||
Transport used for real-time voice calls and other media streams over Reticulum. This is not a
|
||||
fork of LXST; it is LXST written in a different language with interoperability
|
||||
as the primary focus. Python LXST remains the source-of-truth
|
||||
implementation, do not treat this repository as one.
|
||||
|
||||
The current rsLXST is experimental and incomplete. It provides LXST wire codecs,
|
||||
Reticulum link media packet boundaries, a telephony runtime, and Opus stream
|
||||
integration for applications (such as Ratspeak).
|
||||
|
||||
The first public target is interoperable Opus
|
||||
telephony, not complete feature parity with
|
||||
the reference implementation LXST.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Release Scope](#release-scope)
|
||||
- [Build It](#build-it)
|
||||
- [Test It](#test-it)
|
||||
- [Crate Layout](#crate-layout)
|
||||
- [Using Telephony](#using-telephony)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Release Scope
|
||||
|
||||
The experimental release is to cover basic voice calls, with several features still unsupported:
|
||||
|
||||
- `rnphone` parity and `rnphone-rs` usage.
|
||||
- Full Codec2 support.
|
||||
- Deeper audio support: microphone/source backends, filters, AGC, etc.
|
||||
- Broadcast, stream, and non-telephony LXST primitives.
|
||||
|
||||
Those are expected future work. They should not be implied by the first public
|
||||
Opus telephony release.
|
||||
|
||||
## Build It
|
||||
|
||||
The current development layout requires `rsReticulum` as a sibling checkout
|
||||
because rsLXST uses the Rust Reticulum crates directly:
|
||||
|
||||
```text
|
||||
ratspeak-src/
|
||||
|-- rsReticulum/
|
||||
`-- rsLXST/
|
||||
```
|
||||
|
||||
If you're starting fresh:
|
||||
|
||||
```bash
|
||||
mkdir ratspeak-src
|
||||
cd ratspeak-src
|
||||
git clone https://github.com/ratspeak/rsReticulum
|
||||
git clone https://github.com/ratspeak/rsLXST
|
||||
cd rsLXST
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Install Rust with `rustup`, then install Apple's command-line build tools:
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
Build the workspace:
|
||||
|
||||
```bash
|
||||
cd rsLXST
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Linux / Raspberry Pi
|
||||
|
||||
Install Rust with `rustup`, then install the usual build packages.
|
||||
|
||||
Debian, Ubuntu, and Raspberry Pi OS:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential pkg-config
|
||||
```
|
||||
|
||||
Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install gcc make pkgconf-pkg-config
|
||||
```
|
||||
|
||||
Arch:
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed base-devel pkgconf
|
||||
```
|
||||
|
||||
Build the workspace:
|
||||
|
||||
```bash
|
||||
cd rsLXST
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Install Rust with the MSVC toolchain. If Rust or Cargo asks for Visual Studio
|
||||
Build Tools, install the "Desktop development with C++" workload.
|
||||
|
||||
Build from PowerShell:
|
||||
|
||||
```powershell
|
||||
cd rsLXST
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Test It
|
||||
|
||||
Run the Rust-only test gate:
|
||||
|
||||
```bash
|
||||
SKIP_PYTHON_LXST_INTEROP=1 cargo test --workspace
|
||||
```
|
||||
|
||||
Run the local CI gate:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace -- -D warnings
|
||||
SKIP_PYTHON_LXST_INTEROP=1 cargo test --workspace
|
||||
```
|
||||
|
||||
The Rust-only gate covers wire codecs, telephony state, profile metadata,
|
||||
Opus stream boundaries, malformed-input handling, and the local service
|
||||
runtime.
|
||||
|
||||
Python LXST interop tests against the pinned upstream reference live in the development tree and are not
|
||||
included in this published release but I am happy to provide them on request.
|
||||
|
||||
## Crate Layout
|
||||
|
||||
| Crate | Purpose |
|
||||
| --- | --- |
|
||||
| `lxst-core` | LXST constants, telephony profiles, signalling values, codec IDs, MessagePack packets, Raw audio frames, Opus encode/decode state, stream packetization, synthetic sources, and jitter buffers. This crate has no Reticulum runtime dependency. |
|
||||
| `lxst-rns` | The Reticulum link-packet boundary for no-receipt LXST signalling and media over active links. It packs outbound LXST packets and decodes inbound link plaintext into typed LXST packet/frame events. |
|
||||
| `lxst-telephony` | The telephony runtime and service layer. It owns call state, caller policy, Reticulum destination registration, announce discovery, outgoing link establishment, typed control/event channels, Opus transmit/receive stream boundaries, timeout handling, and shutdown teardown. |
|
||||
|
||||
## Using Telephony
|
||||
|
||||
Applications normally use `lxst-telephony` through `TelephonyService`, not by
|
||||
manually translating Reticulum events. The service registers the local
|
||||
`lxst.telephony` destination, emits startup/periodic announces, owns the call
|
||||
runtime, and exposes typed control and event channels.
|
||||
|
||||
```rust
|
||||
use lxst_core::Profile;
|
||||
use lxst_telephony::{TelephonyControl, TelephonyService};
|
||||
use tokio::time::Duration;
|
||||
|
||||
let parts = TelephonyService::registered(transport_tx, &identity)?;
|
||||
let control_tx = parts.control_tx.clone();
|
||||
let mut event_rx = parts.event_rx;
|
||||
|
||||
tokio::spawn(parts.service.run());
|
||||
|
||||
control_tx
|
||||
.send(TelephonyControl::Call {
|
||||
remote_identity,
|
||||
profile: Some(Profile::QualityMedium),
|
||||
discovery_timeout: Duration::from_secs(8),
|
||||
})
|
||||
.await?;
|
||||
```
|
||||
|
||||
The service event stream is the app-facing state source. Use
|
||||
`TelephonyServiceEvent::Snapshot`, `IncomingCall`, `OutgoingCallStarted`,
|
||||
`CallTerminated`, stream lifecycle events, and media events instead of
|
||||
inferring call state from raw Reticulum traffic.
|
||||
|
||||
For Opus calls, applications supply and receive `RawAudioFrame` values through
|
||||
`StartOpusStream` and `StartOpusReceiveStream`. rsLXST enforces the negotiated
|
||||
LXST profile and reports profile changes, source/sink closure, frame drops, and
|
||||
call-end stream shutdown explicitly.
|
||||
|
||||
Applications still own platform integration:
|
||||
|
||||
- contact or peer lookup
|
||||
- UI and call controls
|
||||
- microphone/camera/speaker permissions
|
||||
- device selection
|
||||
- audio session lifecycle
|
||||
- capture/playback and resampling into `RawAudioFrame`
|
||||
- settings persistence
|
||||
- mobile foreground/background behavior
|
||||
|
||||
Ratspeak uses this boundary for its native voice-call feature.
|
||||
|
||||
## Contributing
|
||||
|
||||
If the issue or contribution belongs upstream as well, start there. Python LXST
|
||||
and Reticulum remain the reference implementations.
|
||||
|
||||
PRs are closed for now until I have time to catch up on everything.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the GNU Affero General
|
||||
Public License v3.0 or later. See [LICENSE](LICENSE).
|
||||
17
crates/lxst-core/Cargo.toml
Normal file
17
crates/lxst-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "lxst-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
half.workspace = true
|
||||
opus-rs.workspace = true
|
||||
rmpv.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hex.workspace = true
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
31
crates/lxst-core/src/lib.rs
Normal file
31
crates/lxst-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! Core LXST wire types.
|
||||
//!
|
||||
//! This crate owns the byte-level contract with Python LXST. It deliberately
|
||||
//! avoids audio and Reticulum runtime dependencies so packet/profile parity can
|
||||
//! be tested in isolation.
|
||||
|
||||
mod opus;
|
||||
mod profile;
|
||||
mod raw;
|
||||
mod stream;
|
||||
mod synthetic;
|
||||
mod telephony;
|
||||
mod wire;
|
||||
|
||||
pub use opus::{OpusCodecError, OpusDecoderState, OpusEncoderState};
|
||||
pub use profile::{AudioCodec, OpusApplication, OpusProfile, Profile, SignallingStatus};
|
||||
pub use raw::RawAudioFrame;
|
||||
pub use stream::{
|
||||
DropPolicy, FramePacketizer, FrameStreamEvent, FrameStreamState, JitterBuffer, JitterPush,
|
||||
JitterStats, StreamError,
|
||||
};
|
||||
pub use synthetic::{RawFrameCollector, SyntheticError, SyntheticSource, SyntheticSourceKind};
|
||||
pub use telephony::{CallRole, TelephonyAction, TelephonyCall};
|
||||
pub use wire::{
|
||||
Codec2Mode, CodecKind, Error, FIELD_FRAMES, FIELD_SIGNALLING, Frame, LxstPacket, RawBitDepth,
|
||||
RawFrameHeader, Signal,
|
||||
};
|
||||
|
||||
pub const APP_NAME: &str = "lxst";
|
||||
pub const TELEPHONY_PRIMITIVE_NAME: &str = "telephony";
|
||||
pub const TELEPHONY_DESTINATION_NAME: &str = "lxst.telephony";
|
||||
649
crates/lxst-core/src/opus.rs
Normal file
649
crates/lxst-core/src/opus.rs
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
use opus_rs::{Application, OpusDecoder, OpusEncoder};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AudioCodec, CodecKind, Frame, OpusApplication, OpusProfile, Profile, RawAudioFrame};
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum OpusCodecError {
|
||||
#[error("profile {0:?} does not use Opus")]
|
||||
NonOpusProfile(Profile),
|
||||
#[error("Opus frame channel count {actual} does not match profile channel count {expected}")]
|
||||
ChannelMismatch { expected: u8, actual: u8 },
|
||||
#[error("Opus frame sample count {actual} does not match profile sample count {expected}")]
|
||||
SampleFrameMismatch { expected: usize, actual: usize },
|
||||
#[error(
|
||||
"Opus frame duration is not supported by the current encoder: {sample_rate_hz} Hz / {sample_frames} samples"
|
||||
)]
|
||||
UnsupportedFrameDuration {
|
||||
sample_rate_hz: u32,
|
||||
sample_frames: usize,
|
||||
},
|
||||
#[error("invalid Opus frame codec {0:?}")]
|
||||
InvalidFrameCodec(CodecKind),
|
||||
#[error("Opus subframe payload length {0} exceeds the supported packet length encoding")]
|
||||
UnsupportedSubframePayloadLength(usize),
|
||||
#[error("Opus encoder returned an unsupported subpacket layout")]
|
||||
UnsupportedSubpacketLayout,
|
||||
#[error("malformed Opus packet: {0}")]
|
||||
MalformedPacket(&'static str),
|
||||
#[error("Opus codec error: {0}")]
|
||||
Codec(String),
|
||||
#[error("LXST wire error: {0}")]
|
||||
Wire(#[from] crate::Error),
|
||||
}
|
||||
|
||||
pub struct OpusEncoderState {
|
||||
profile: Profile,
|
||||
channels: u8,
|
||||
sample_frames: usize,
|
||||
subframe_count: usize,
|
||||
subframe_sample_frames: usize,
|
||||
encode_sample_frames: usize,
|
||||
encode_subframe_sample_frames: usize,
|
||||
max_payload_bytes: usize,
|
||||
encoder: OpusEncoder,
|
||||
}
|
||||
|
||||
impl OpusEncoderState {
|
||||
pub fn new(profile: Profile) -> Result<Self, OpusCodecError> {
|
||||
let opus_profile = match profile.audio_codec() {
|
||||
AudioCodec::Opus(profile) => profile,
|
||||
AudioCodec::Codec2(_) => return Err(OpusCodecError::NonOpusProfile(profile)),
|
||||
};
|
||||
let channels = opus_profile.channels();
|
||||
let sample_rate = opus_profile.sample_rate();
|
||||
let encode_sample_rate = encode_sample_rate(opus_profile);
|
||||
let sample_frames = profile.sample_frames_per_packet();
|
||||
let encode_sample_frames =
|
||||
scale_sample_frames(sample_frames, sample_rate, encode_sample_rate)?;
|
||||
let packet_layout = PacketLayout::new(encode_sample_rate, encode_sample_frames)?;
|
||||
let subframe_sample_frames = sample_frames
|
||||
.checked_div(packet_layout.subframe_count)
|
||||
.filter(|frames| frames * packet_layout.subframe_count == sample_frames)
|
||||
.ok_or(OpusCodecError::UnsupportedFrameDuration {
|
||||
sample_rate_hz: sample_rate,
|
||||
sample_frames,
|
||||
})?;
|
||||
let mut encoder = OpusEncoder::new(
|
||||
encode_sample_rate as i32,
|
||||
usize::from(channels),
|
||||
opus_application(opus_profile.application()),
|
||||
)
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
encoder.bitrate_bps = opus_profile.bitrate_ceiling() as i32;
|
||||
encoder.use_cbr = false;
|
||||
|
||||
Ok(Self {
|
||||
profile,
|
||||
channels,
|
||||
sample_frames,
|
||||
subframe_count: packet_layout.subframe_count,
|
||||
subframe_sample_frames,
|
||||
encode_sample_frames,
|
||||
encode_subframe_sample_frames: packet_layout.subframe_sample_frames,
|
||||
max_payload_bytes: opus_profile.max_bytes_per_frame_ms(profile.frame_time_ms()),
|
||||
encoder,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn profile(&self) -> Profile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
pub const fn channels(&self) -> u8 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub const fn sample_frames(&self) -> usize {
|
||||
self.sample_frames
|
||||
}
|
||||
|
||||
pub const fn subframe_count(&self) -> usize {
|
||||
self.subframe_count
|
||||
}
|
||||
|
||||
pub const fn subframe_sample_frames(&self) -> usize {
|
||||
self.subframe_sample_frames
|
||||
}
|
||||
|
||||
pub const fn max_payload_bytes(&self) -> usize {
|
||||
self.max_payload_bytes
|
||||
}
|
||||
|
||||
pub fn encode_frame(&mut self, frame: &RawAudioFrame) -> Result<Frame, OpusCodecError> {
|
||||
self.validate_frame_shape(frame)?;
|
||||
if self.subframe_count > 1 {
|
||||
return self.encode_multi_subframe_packet(frame);
|
||||
}
|
||||
|
||||
let resampled;
|
||||
let input = if self.encode_sample_frames == self.sample_frames {
|
||||
&frame.samples
|
||||
} else {
|
||||
resampled = resample_interleaved_linear(
|
||||
&frame.samples,
|
||||
self.sample_frames,
|
||||
self.encode_sample_frames,
|
||||
usize::from(self.channels),
|
||||
);
|
||||
&resampled
|
||||
};
|
||||
let mut encoded = vec![0u8; self.max_payload_bytes];
|
||||
let written = self
|
||||
.encoder
|
||||
.encode(input, self.encode_sample_frames, &mut encoded)
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
encoded.truncate(written);
|
||||
Ok(Frame::new(CodecKind::Opus, encoded))
|
||||
}
|
||||
|
||||
fn encode_multi_subframe_packet(
|
||||
&mut self,
|
||||
frame: &RawAudioFrame,
|
||||
) -> Result<Frame, OpusCodecError> {
|
||||
let channels = usize::from(self.channels);
|
||||
let budgets = self.subframe_payload_budgets()?;
|
||||
let mut subpackets = Vec::with_capacity(self.subframe_count);
|
||||
|
||||
for (subframe_index, payload_budget) in budgets.into_iter().enumerate() {
|
||||
let start = subframe_index * self.subframe_sample_frames * channels;
|
||||
let end = start + self.subframe_sample_frames * channels;
|
||||
let resampled;
|
||||
let input = if self.encode_subframe_sample_frames == self.subframe_sample_frames {
|
||||
&frame.samples[start..end]
|
||||
} else {
|
||||
resampled = resample_interleaved_linear(
|
||||
&frame.samples[start..end],
|
||||
self.subframe_sample_frames,
|
||||
self.encode_subframe_sample_frames,
|
||||
channels,
|
||||
);
|
||||
&resampled
|
||||
};
|
||||
let mut encoded = vec![0u8; payload_budget + 1];
|
||||
let written = self
|
||||
.encoder
|
||||
.encode(input, self.encode_subframe_sample_frames, &mut encoded)
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
encoded.truncate(written);
|
||||
if encoded.first().is_none_or(|toc| toc & 0x03 != 0) {
|
||||
return Err(OpusCodecError::UnsupportedSubpacketLayout);
|
||||
}
|
||||
subpackets.push(encoded);
|
||||
}
|
||||
|
||||
let toc = (subpackets[0][0] & !0x03) | 0x03;
|
||||
let mut payload = Vec::with_capacity(self.max_payload_bytes);
|
||||
payload.push(toc);
|
||||
payload.push(0x80 | (self.subframe_count as u8));
|
||||
for subpacket in subpackets.iter().take(self.subframe_count - 1) {
|
||||
push_subframe_payload_len(&mut payload, subpacket.len() - 1)?;
|
||||
}
|
||||
for subpacket in subpackets {
|
||||
payload.extend_from_slice(&subpacket[1..]);
|
||||
}
|
||||
|
||||
debug_assert!(payload.len() <= self.max_payload_bytes);
|
||||
Ok(Frame::new(CodecKind::Opus, payload))
|
||||
}
|
||||
|
||||
fn subframe_payload_budgets(&self) -> Result<Vec<usize>, OpusCodecError> {
|
||||
let header_bytes = 2 + self.subframe_count - 1;
|
||||
let payload_budget = self
|
||||
.max_payload_bytes
|
||||
.checked_sub(header_bytes)
|
||||
.ok_or(OpusCodecError::UnsupportedSubpacketLayout)?;
|
||||
let base = payload_budget / self.subframe_count;
|
||||
let extra = payload_budget % self.subframe_count;
|
||||
Ok((0..self.subframe_count)
|
||||
.map(|index| base + usize::from(index < extra))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_frame_shape(&self, frame: &RawAudioFrame) -> Result<(), OpusCodecError> {
|
||||
if frame.channels != self.channels {
|
||||
return Err(OpusCodecError::ChannelMismatch {
|
||||
expected: self.channels,
|
||||
actual: frame.channels,
|
||||
});
|
||||
}
|
||||
if frame.sample_frames() != self.sample_frames {
|
||||
return Err(OpusCodecError::SampleFrameMismatch {
|
||||
expected: self.sample_frames,
|
||||
actual: frame.sample_frames(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpusDecoderState {
|
||||
profile: Profile,
|
||||
channels: u8,
|
||||
sample_frames: usize,
|
||||
subframe_count: usize,
|
||||
subframe_sample_frames: usize,
|
||||
decoder: OpusDecoder,
|
||||
}
|
||||
|
||||
impl OpusDecoderState {
|
||||
pub fn new(profile: Profile) -> Result<Self, OpusCodecError> {
|
||||
let opus_profile = match profile.audio_codec() {
|
||||
AudioCodec::Opus(profile) => profile,
|
||||
AudioCodec::Codec2(_) => return Err(OpusCodecError::NonOpusProfile(profile)),
|
||||
};
|
||||
let channels = opus_profile.channels();
|
||||
let sample_rate = opus_profile.sample_rate();
|
||||
let sample_frames = profile.sample_frames_per_packet();
|
||||
let packet_layout = PacketLayout::new(sample_rate, sample_frames)?;
|
||||
let decoder = OpusDecoder::new(sample_rate as i32, usize::from(channels))
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
profile,
|
||||
channels,
|
||||
sample_frames,
|
||||
subframe_count: packet_layout.subframe_count,
|
||||
subframe_sample_frames: packet_layout.subframe_sample_frames,
|
||||
decoder,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn profile(&self) -> Profile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
pub const fn subframe_count(&self) -> usize {
|
||||
self.subframe_count
|
||||
}
|
||||
|
||||
pub const fn subframe_sample_frames(&self) -> usize {
|
||||
self.subframe_sample_frames
|
||||
}
|
||||
|
||||
pub fn decode_frame(&mut self, frame: &Frame) -> Result<RawAudioFrame, OpusCodecError> {
|
||||
if frame.codec != CodecKind::Opus {
|
||||
return Err(OpusCodecError::InvalidFrameCodec(frame.codec));
|
||||
}
|
||||
if self.subframe_count > 1 && frame.payload.first().is_some_and(|toc| toc & 0x03 == 0x03) {
|
||||
return self.decode_multi_subframe_packet(frame);
|
||||
}
|
||||
|
||||
self.decode_direct_packet(frame)
|
||||
}
|
||||
|
||||
fn decode_direct_packet(&mut self, frame: &Frame) -> Result<RawAudioFrame, OpusCodecError> {
|
||||
let mut samples = vec![0.0f32; self.sample_frames * usize::from(self.channels)];
|
||||
let decoded = self
|
||||
.decoder
|
||||
.decode(&frame.payload, self.sample_frames, &mut samples)
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
samples.truncate(decoded * usize::from(self.channels));
|
||||
Ok(RawAudioFrame::new(self.channels, samples)?)
|
||||
}
|
||||
|
||||
fn decode_multi_subframe_packet(
|
||||
&mut self,
|
||||
frame: &Frame,
|
||||
) -> Result<RawAudioFrame, OpusCodecError> {
|
||||
let subpayloads = parse_code3_subframe_payloads(&frame.payload, self.subframe_count)?;
|
||||
let channels = usize::from(self.channels);
|
||||
let mut samples = vec![0.0f32; self.sample_frames * channels];
|
||||
let toc = frame.payload[0] & !0x03;
|
||||
|
||||
for (index, subpayload) in subpayloads.iter().enumerate() {
|
||||
let start = index * self.subframe_sample_frames * channels;
|
||||
let end = start + self.subframe_sample_frames * channels;
|
||||
let mut subpacket = Vec::with_capacity(subpayload.len() + 1);
|
||||
subpacket.push(toc);
|
||||
subpacket.extend_from_slice(subpayload);
|
||||
self.decoder
|
||||
.decode(
|
||||
&subpacket,
|
||||
self.subframe_sample_frames,
|
||||
&mut samples[start..end],
|
||||
)
|
||||
.map_err(|err| OpusCodecError::Codec(err.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(RawAudioFrame::new(self.channels, samples)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PacketLayout {
|
||||
subframe_count: usize,
|
||||
subframe_sample_frames: usize,
|
||||
}
|
||||
|
||||
impl PacketLayout {
|
||||
fn new(sample_rate_hz: u32, sample_frames: usize) -> Result<Self, OpusCodecError> {
|
||||
if supports_direct_frame(sample_rate_hz, sample_frames) {
|
||||
return Ok(Self {
|
||||
subframe_count: 1,
|
||||
subframe_sample_frames: sample_frames,
|
||||
});
|
||||
}
|
||||
|
||||
let subframe_sample_frames = (sample_rate_hz as usize) / 50;
|
||||
if sample_frames != 0
|
||||
&& subframe_sample_frames != 0
|
||||
&& sample_frames % subframe_sample_frames == 0
|
||||
&& supports_direct_frame(sample_rate_hz, subframe_sample_frames)
|
||||
{
|
||||
return Ok(Self {
|
||||
subframe_count: sample_frames / subframe_sample_frames,
|
||||
subframe_sample_frames,
|
||||
});
|
||||
}
|
||||
|
||||
Err(OpusCodecError::UnsupportedFrameDuration {
|
||||
sample_rate_hz,
|
||||
sample_frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn opus_application(application: OpusApplication) -> Application {
|
||||
match application {
|
||||
OpusApplication::Voip => Application::Voip,
|
||||
OpusApplication::Audio => Application::Audio,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_sample_rate(profile: OpusProfile) -> u32 {
|
||||
match profile {
|
||||
// Python LXST uses libopus with an output byte ceiling but no fixed
|
||||
// bitrate or bandwidth CTLs. At Medium's 8 kbps ceiling, libopus is
|
||||
// free to pick lower voice bandwidth; opus-rs otherwise forces
|
||||
// superwideband/hybrid from the 24 kHz API rate, which is poor for
|
||||
// speech at this budget.
|
||||
OpusProfile::VoiceMedium => 16_000,
|
||||
_ => profile.sample_rate(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_sample_frames(
|
||||
sample_frames: usize,
|
||||
source_sample_rate: u32,
|
||||
encode_sample_rate: u32,
|
||||
) -> Result<usize, OpusCodecError> {
|
||||
let numerator = sample_frames
|
||||
.checked_mul(encode_sample_rate as usize)
|
||||
.ok_or(OpusCodecError::UnsupportedFrameDuration {
|
||||
sample_rate_hz: encode_sample_rate,
|
||||
sample_frames,
|
||||
})?;
|
||||
let denominator = source_sample_rate as usize;
|
||||
if numerator % denominator != 0 {
|
||||
return Err(OpusCodecError::UnsupportedFrameDuration {
|
||||
sample_rate_hz: encode_sample_rate,
|
||||
sample_frames,
|
||||
});
|
||||
}
|
||||
Ok(numerator / denominator)
|
||||
}
|
||||
|
||||
fn supports_direct_frame(sample_rate_hz: u32, sample_frames: usize) -> bool {
|
||||
sample_frames != 0 && (sample_rate_hz as usize) % sample_frames == 0
|
||||
}
|
||||
|
||||
fn resample_interleaved_linear(
|
||||
input: &[f32],
|
||||
input_frames: usize,
|
||||
output_frames: usize,
|
||||
channels: usize,
|
||||
) -> Vec<f32> {
|
||||
if input_frames == output_frames {
|
||||
return input.to_vec();
|
||||
}
|
||||
let mut output = vec![0.0f32; output_frames * channels];
|
||||
if input_frames == 0 || output_frames == 0 || channels == 0 {
|
||||
return output;
|
||||
}
|
||||
if input_frames == 1 {
|
||||
for frame in 0..output_frames {
|
||||
let out = frame * channels;
|
||||
output[out..out + channels].copy_from_slice(&input[..channels]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
let scale = input_frames as f64 / output_frames as f64;
|
||||
let max_input_index = input_frames - 1;
|
||||
for out_frame in 0..output_frames {
|
||||
let src = ((out_frame as f64 + 0.5) * scale - 0.5).clamp(0.0, max_input_index as f64);
|
||||
let left = src.floor() as usize;
|
||||
let right = (left + 1).min(max_input_index);
|
||||
let fraction = (src - left as f64) as f32;
|
||||
let out = out_frame * channels;
|
||||
let left_offset = left * channels;
|
||||
let right_offset = right * channels;
|
||||
for channel in 0..channels {
|
||||
let a = input[left_offset + channel];
|
||||
let b = input[right_offset + channel];
|
||||
output[out + channel] = a + (b - a) * fraction;
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn push_subframe_payload_len(output: &mut Vec<u8>, len: usize) -> Result<(), OpusCodecError> {
|
||||
if len < 252 {
|
||||
output.push(len as u8);
|
||||
Ok(())
|
||||
} else if len <= 1275 {
|
||||
let first = 252 + (len % 4);
|
||||
output.push(first as u8);
|
||||
output.push(((len - first) / 4) as u8);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(OpusCodecError::UnsupportedSubframePayloadLength(len))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_code3_subframe_payloads(
|
||||
packet: &[u8],
|
||||
expected_count: usize,
|
||||
) -> Result<Vec<&[u8]>, OpusCodecError> {
|
||||
if packet.len() < 2 {
|
||||
return Err(OpusCodecError::MalformedPacket(
|
||||
"code 3 packet is too short",
|
||||
));
|
||||
}
|
||||
|
||||
let count_byte = packet[1];
|
||||
let frame_count = usize::from(count_byte & 0x3F);
|
||||
if frame_count != expected_count {
|
||||
return Err(OpusCodecError::MalformedPacket(
|
||||
"code 3 frame count does not match the active profile",
|
||||
));
|
||||
}
|
||||
if frame_count == 0 {
|
||||
return Err(OpusCodecError::MalformedPacket(
|
||||
"code 3 frame count is zero",
|
||||
));
|
||||
}
|
||||
|
||||
let vbr = count_byte & 0x80 != 0;
|
||||
let padding = count_byte & 0x40 != 0;
|
||||
let mut cursor = 2;
|
||||
let mut payload_end = packet.len();
|
||||
|
||||
if padding {
|
||||
let mut pad_len = 0usize;
|
||||
loop {
|
||||
if cursor >= packet.len() {
|
||||
return Err(OpusCodecError::MalformedPacket("padding exceeds packet"));
|
||||
}
|
||||
let byte = usize::from(packet[cursor]);
|
||||
cursor += 1;
|
||||
if byte == 255 {
|
||||
pad_len += 254;
|
||||
} else {
|
||||
pad_len += byte;
|
||||
break;
|
||||
}
|
||||
}
|
||||
payload_end = packet
|
||||
.len()
|
||||
.checked_sub(pad_len)
|
||||
.ok_or(OpusCodecError::MalformedPacket("padding exceeds packet"))?;
|
||||
if cursor > payload_end {
|
||||
return Err(OpusCodecError::MalformedPacket("padding exceeds payload"));
|
||||
}
|
||||
}
|
||||
|
||||
if vbr {
|
||||
let mut lengths = Vec::with_capacity(frame_count);
|
||||
for _ in 0..frame_count - 1 {
|
||||
let (len, consumed) = read_subframe_payload_len(&packet[cursor..payload_end])?;
|
||||
cursor += consumed;
|
||||
lengths.push(len);
|
||||
}
|
||||
|
||||
let declared_payload_bytes = lengths.iter().sum::<usize>();
|
||||
let remaining = payload_end
|
||||
.checked_sub(cursor)
|
||||
.ok_or(OpusCodecError::MalformedPacket(
|
||||
"payload cursor exceeds packet",
|
||||
))?;
|
||||
if declared_payload_bytes > remaining {
|
||||
return Err(OpusCodecError::MalformedPacket(
|
||||
"declared frame lengths exceed packet payload",
|
||||
));
|
||||
}
|
||||
lengths.push(remaining - declared_payload_bytes);
|
||||
|
||||
let mut payloads = Vec::with_capacity(frame_count);
|
||||
let mut payload_cursor = cursor;
|
||||
for len in lengths {
|
||||
let next = payload_cursor + len;
|
||||
payloads.push(&packet[payload_cursor..next]);
|
||||
payload_cursor = next;
|
||||
}
|
||||
Ok(payloads)
|
||||
} else {
|
||||
let compressed = &packet[cursor..payload_end];
|
||||
if compressed.len() % frame_count != 0 {
|
||||
return Err(OpusCodecError::MalformedPacket(
|
||||
"CBR code 3 payload is not evenly divisible",
|
||||
));
|
||||
}
|
||||
let frame_len = compressed.len() / frame_count;
|
||||
Ok(compressed.chunks(frame_len).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_subframe_payload_len(input: &[u8]) -> Result<(usize, usize), OpusCodecError> {
|
||||
let first = *input
|
||||
.first()
|
||||
.ok_or(OpusCodecError::MalformedPacket("missing frame length"))?;
|
||||
if first < 252 {
|
||||
Ok((usize::from(first), 1))
|
||||
} else {
|
||||
let second = *input
|
||||
.get(1)
|
||||
.ok_or(OpusCodecError::MalformedPacket("truncated frame length"))?;
|
||||
Ok((usize::from(first) + 4 * usize::from(second), 2))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SyntheticSourceKind;
|
||||
|
||||
fn source_for(profile: Profile) -> crate::SyntheticSource {
|
||||
crate::SyntheticSource::new(
|
||||
profile.channels(),
|
||||
profile.sample_rate_hz(),
|
||||
profile.sample_frames_per_packet(),
|
||||
SyntheticSourceKind::Sine {
|
||||
frequency_hz: 440.0,
|
||||
amplitude: 0.25,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_encoder_rejects_codec2_profiles() {
|
||||
assert!(matches!(
|
||||
OpusEncoderState::new(Profile::BandwidthLow),
|
||||
Err(OpusCodecError::NonOpusProfile(Profile::BandwidthLow))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_profile_encoder_caps_payload_to_python_budget() {
|
||||
let profile = Profile::LatencyLow;
|
||||
let mut encoder = OpusEncoderState::new(profile).unwrap();
|
||||
let frame = source_for(profile).next_raw_frame().unwrap();
|
||||
|
||||
let encoded = encoder.encode_frame(&frame).unwrap();
|
||||
assert_eq!(encoded.codec, CodecKind::Opus);
|
||||
assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_roundtrip_decodes_profile_shaped_pcm() {
|
||||
let profile = Profile::LatencyLow;
|
||||
let mut source = source_for(profile);
|
||||
let frame = source.next_raw_frame().unwrap();
|
||||
let mut encoder = OpusEncoderState::new(profile).unwrap();
|
||||
let mut decoder = OpusDecoderState::new(profile).unwrap();
|
||||
|
||||
let encoded = encoder.encode_frame(&frame).unwrap();
|
||||
let decoded = decoder.decode_frame(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.channels, profile.channels());
|
||||
assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet());
|
||||
assert_eq!(decoder.profile(), profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_quality_profiles_encode_sixty_ms_as_three_subframes() {
|
||||
for profile in [
|
||||
Profile::QualityMedium,
|
||||
Profile::QualityHigh,
|
||||
Profile::QualityMax,
|
||||
] {
|
||||
let mut source = source_for(profile);
|
||||
let frame = source.next_raw_frame().unwrap();
|
||||
let mut encoder = OpusEncoderState::new(profile).unwrap();
|
||||
let mut decoder = OpusDecoderState::new(profile).unwrap();
|
||||
|
||||
assert_eq!(encoder.subframe_count(), 3);
|
||||
assert_eq!(decoder.subframe_count(), 3);
|
||||
assert_eq!(
|
||||
encoder.subframe_sample_frames() * 3,
|
||||
encoder.sample_frames()
|
||||
);
|
||||
|
||||
let encoded = encoder.encode_frame(&frame).unwrap();
|
||||
assert_eq!(encoded.codec, CodecKind::Opus);
|
||||
assert_eq!(encoded.payload[0] & 0x03, 0x03);
|
||||
assert_eq!(encoded.payload[1] & 0x3F, 3);
|
||||
assert_ne!(encoded.payload[1] & 0x80, 0);
|
||||
assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap());
|
||||
|
||||
let decoded = decoder.decode_frame(&encoded).unwrap();
|
||||
assert_eq!(decoded.channels, profile.channels());
|
||||
assert_eq!(decoded.sample_frames(), profile.sample_frames_per_packet());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_medium_uses_wideband_silk_at_low_bitrate() {
|
||||
let profile = Profile::QualityMedium;
|
||||
let mut source = source_for(profile);
|
||||
let frame = source.next_raw_frame().unwrap();
|
||||
let mut encoder = OpusEncoderState::new(profile).unwrap();
|
||||
|
||||
let encoded = encoder.encode_frame(&frame).unwrap();
|
||||
|
||||
assert_eq!(encoded.payload[0] & !0x03, 0x48);
|
||||
assert!(encoded.payload.len() <= profile.opus_payload_ceiling_bytes().unwrap());
|
||||
}
|
||||
}
|
||||
356
crates/lxst-core/src/profile.rs
Normal file
356
crates/lxst-core/src/profile.rs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
use crate::wire::{Codec2Mode, CodecKind};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u32)]
|
||||
pub enum SignallingStatus {
|
||||
Busy = 0x00,
|
||||
Rejected = 0x01,
|
||||
Calling = 0x02,
|
||||
Available = 0x03,
|
||||
Ringing = 0x04,
|
||||
Connecting = 0x05,
|
||||
Established = 0x06,
|
||||
}
|
||||
|
||||
impl SignallingStatus {
|
||||
pub const AUTO_STATUS_CODES: [Self; 5] = [
|
||||
Self::Calling,
|
||||
Self::Available,
|
||||
Self::Ringing,
|
||||
Self::Connecting,
|
||||
Self::Established,
|
||||
];
|
||||
|
||||
pub const fn wire_value(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
pub const fn from_wire(value: u32) -> Option<Self> {
|
||||
match value {
|
||||
0x00 => Some(Self::Busy),
|
||||
0x01 => Some(Self::Rejected),
|
||||
0x02 => Some(Self::Calling),
|
||||
0x03 => Some(Self::Available),
|
||||
0x04 => Some(Self::Ringing),
|
||||
0x05 => Some(Self::Connecting),
|
||||
0x06 => Some(Self::Established),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_auto_status(self) -> bool {
|
||||
Self::AUTO_STATUS_CODES.contains(&self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u32)]
|
||||
pub enum Profile {
|
||||
BandwidthUltraLow = 0x10,
|
||||
BandwidthVeryLow = 0x20,
|
||||
BandwidthLow = 0x30,
|
||||
QualityMedium = 0x40,
|
||||
QualityHigh = 0x50,
|
||||
QualityMax = 0x60,
|
||||
LatencyUltraLow = 0x70,
|
||||
LatencyLow = 0x80,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub const DEFAULT: Self = Self::QualityMedium;
|
||||
|
||||
pub const ORDER: [Self; 8] = [
|
||||
Self::BandwidthUltraLow,
|
||||
Self::BandwidthVeryLow,
|
||||
Self::BandwidthLow,
|
||||
Self::QualityMedium,
|
||||
Self::QualityHigh,
|
||||
Self::QualityMax,
|
||||
Self::LatencyLow,
|
||||
Self::LatencyUltraLow,
|
||||
];
|
||||
|
||||
pub const fn wire_value(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
pub const fn from_wire(value: u32) -> Option<Self> {
|
||||
match value {
|
||||
0x10 => Some(Self::BandwidthUltraLow),
|
||||
0x20 => Some(Self::BandwidthVeryLow),
|
||||
0x30 => Some(Self::BandwidthLow),
|
||||
0x40 => Some(Self::QualityMedium),
|
||||
0x50 => Some(Self::QualityHigh),
|
||||
0x60 => Some(Self::QualityMax),
|
||||
0x70 => Some(Self::LatencyUltraLow),
|
||||
0x80 => Some(Self::LatencyLow),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::BandwidthUltraLow => "Ultra Low Bandwidth",
|
||||
Self::BandwidthVeryLow => "Very Low Bandwidth",
|
||||
Self::BandwidthLow => "Low Bandwidth",
|
||||
Self::QualityMedium => "Medium Quality",
|
||||
Self::QualityHigh => "High Quality",
|
||||
Self::QualityMax => "Super High Quality",
|
||||
Self::LatencyLow => "Low Latency",
|
||||
Self::LatencyUltraLow => "Ultra Low Latency",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn abbreviation(self) -> &'static str {
|
||||
match self {
|
||||
Self::BandwidthUltraLow => "ULBW",
|
||||
Self::BandwidthVeryLow => "VLBW",
|
||||
Self::BandwidthLow => "LBW",
|
||||
Self::QualityMedium => "MQ",
|
||||
Self::QualityHigh => "HQ",
|
||||
Self::QualityMax => "SHQ",
|
||||
Self::LatencyLow => "LL",
|
||||
Self::LatencyUltraLow => "ULL",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn frame_time_ms(self) -> u16 {
|
||||
match self {
|
||||
Self::BandwidthUltraLow => 400,
|
||||
Self::BandwidthVeryLow => 320,
|
||||
Self::BandwidthLow => 200,
|
||||
Self::QualityMedium => 60,
|
||||
Self::QualityHigh => 60,
|
||||
Self::QualityMax => 60,
|
||||
Self::LatencyLow => 20,
|
||||
Self::LatencyUltraLow => 10,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn audio_codec(self) -> AudioCodec {
|
||||
match self {
|
||||
Self::BandwidthUltraLow => AudioCodec::Codec2(Codec2Mode::Mode700C),
|
||||
Self::BandwidthVeryLow => AudioCodec::Codec2(Codec2Mode::Mode1600),
|
||||
Self::BandwidthLow => AudioCodec::Codec2(Codec2Mode::Mode3200),
|
||||
Self::QualityMedium => AudioCodec::Opus(OpusProfile::VoiceMedium),
|
||||
Self::QualityHigh => AudioCodec::Opus(OpusProfile::VoiceHigh),
|
||||
Self::QualityMax => AudioCodec::Opus(OpusProfile::VoiceMax),
|
||||
Self::LatencyLow => AudioCodec::Opus(OpusProfile::VoiceMedium),
|
||||
Self::LatencyUltraLow => AudioCodec::Opus(OpusProfile::VoiceMedium),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn channels(self) -> u8 {
|
||||
self.audio_codec().channels()
|
||||
}
|
||||
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
self.audio_codec().sample_rate_hz()
|
||||
}
|
||||
|
||||
pub const fn sample_frames_per_packet(self) -> usize {
|
||||
((self.sample_rate_hz() as usize) * (self.frame_time_ms() as usize)) / 1000
|
||||
}
|
||||
|
||||
pub const fn opus_payload_ceiling_bytes(self) -> Option<usize> {
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Opus(profile) => Some(profile.max_bytes_per_frame_ms(self.frame_time_ms())),
|
||||
AudioCodec::Codec2(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
let index = Self::ORDER
|
||||
.iter()
|
||||
.position(|candidate| *candidate == self)
|
||||
.unwrap_or(0);
|
||||
Self::ORDER[(index + 1) % Self::ORDER.len()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AudioCodec {
|
||||
Opus(OpusProfile),
|
||||
Codec2(Codec2Mode),
|
||||
}
|
||||
|
||||
impl AudioCodec {
|
||||
pub const fn codec_kind(self) -> CodecKind {
|
||||
match self {
|
||||
Self::Opus(_) => CodecKind::Opus,
|
||||
Self::Codec2(_) => CodecKind::Codec2,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn channels(self) -> u8 {
|
||||
match self {
|
||||
Self::Opus(profile) => profile.channels(),
|
||||
Self::Codec2(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus(profile) => profile.sample_rate(),
|
||||
Self::Codec2(_) => 8_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum OpusProfile {
|
||||
VoiceLow = 0x00,
|
||||
VoiceMedium = 0x01,
|
||||
VoiceHigh = 0x02,
|
||||
VoiceMax = 0x03,
|
||||
AudioMin = 0x04,
|
||||
AudioLow = 0x05,
|
||||
AudioMedium = 0x06,
|
||||
AudioHigh = 0x07,
|
||||
AudioMax = 0x08,
|
||||
}
|
||||
|
||||
impl OpusProfile {
|
||||
pub const VALID_FRAME_MS: [f32; 6] = [2.5, 5.0, 10.0, 20.0, 40.0, 60.0];
|
||||
pub const FRAME_QUANTA_MS: f32 = 2.5;
|
||||
pub const FRAME_MAX_MS: f32 = 60.0;
|
||||
|
||||
pub const fn channels(self) -> u8 {
|
||||
match self {
|
||||
Self::VoiceLow | Self::VoiceMedium | Self::VoiceHigh => 1,
|
||||
Self::VoiceMax => 2,
|
||||
Self::AudioMin | Self::AudioLow => 1,
|
||||
Self::AudioMedium | Self::AudioHigh | Self::AudioMax => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn sample_rate(self) -> u32 {
|
||||
match self {
|
||||
Self::VoiceLow => 8_000,
|
||||
Self::VoiceMedium => 24_000,
|
||||
Self::VoiceHigh | Self::VoiceMax => 48_000,
|
||||
Self::AudioMin => 8_000,
|
||||
Self::AudioLow => 12_000,
|
||||
Self::AudioMedium => 24_000,
|
||||
Self::AudioHigh | Self::AudioMax => 48_000,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn application(self) -> OpusApplication {
|
||||
match self {
|
||||
Self::VoiceLow | Self::VoiceMedium | Self::VoiceHigh | Self::VoiceMax => {
|
||||
OpusApplication::Voip
|
||||
}
|
||||
Self::AudioMin
|
||||
| Self::AudioLow
|
||||
| Self::AudioMedium
|
||||
| Self::AudioHigh
|
||||
| Self::AudioMax => OpusApplication::Audio,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn bitrate_ceiling(self) -> u32 {
|
||||
match self {
|
||||
Self::VoiceLow => 6_000,
|
||||
Self::VoiceMedium => 8_000,
|
||||
Self::VoiceHigh => 16_000,
|
||||
Self::VoiceMax => 32_000,
|
||||
Self::AudioMin => 8_000,
|
||||
Self::AudioLow => 14_000,
|
||||
Self::AudioMedium => 28_000,
|
||||
Self::AudioHigh => 56_000,
|
||||
Self::AudioMax => 128_000,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn max_bytes_per_frame_ms(self, frame_duration_ms: u16) -> usize {
|
||||
let numerator = (self.bitrate_ceiling() as usize) * (frame_duration_ms as usize);
|
||||
numerator.div_ceil(8_000)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum OpusApplication {
|
||||
Voip,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profile_order_matches_python_next_profile_order() {
|
||||
assert_eq!(Profile::QualityMax.next(), Profile::LatencyLow);
|
||||
assert_eq!(Profile::LatencyLow.next(), Profile::LatencyUltraLow);
|
||||
assert_eq!(Profile::LatencyUltraLow.next(), Profile::BandwidthUltraLow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telephony_profile_mapping_matches_python_reference() {
|
||||
assert_eq!(
|
||||
Profile::BandwidthUltraLow.audio_codec(),
|
||||
AudioCodec::Codec2(Codec2Mode::Mode700C)
|
||||
);
|
||||
assert_eq!(
|
||||
Profile::BandwidthVeryLow.audio_codec(),
|
||||
AudioCodec::Codec2(Codec2Mode::Mode1600)
|
||||
);
|
||||
assert_eq!(
|
||||
Profile::BandwidthLow.audio_codec(),
|
||||
AudioCodec::Codec2(Codec2Mode::Mode3200)
|
||||
);
|
||||
assert_eq!(
|
||||
Profile::LatencyUltraLow.audio_codec(),
|
||||
AudioCodec::Opus(OpusProfile::VoiceMedium)
|
||||
);
|
||||
assert_eq!(Profile::LatencyUltraLow.frame_time_ms(), 10);
|
||||
assert_eq!(Profile::LatencyLow.frame_time_ms(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telephony_profile_audio_budgets_match_python_tables() {
|
||||
assert_eq!(Profile::BandwidthUltraLow.channels(), 1);
|
||||
assert_eq!(Profile::BandwidthUltraLow.sample_rate_hz(), 8_000);
|
||||
assert_eq!(Profile::BandwidthUltraLow.sample_frames_per_packet(), 3_200);
|
||||
assert_eq!(
|
||||
Profile::BandwidthUltraLow.opus_payload_ceiling_bytes(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(Profile::QualityMedium.channels(), 1);
|
||||
assert_eq!(Profile::QualityMedium.sample_rate_hz(), 24_000);
|
||||
assert_eq!(Profile::QualityMedium.sample_frames_per_packet(), 1_440);
|
||||
assert_eq!(
|
||||
Profile::QualityMedium.opus_payload_ceiling_bytes(),
|
||||
Some(60)
|
||||
);
|
||||
|
||||
assert_eq!(Profile::QualityHigh.channels(), 1);
|
||||
assert_eq!(Profile::QualityHigh.sample_rate_hz(), 48_000);
|
||||
assert_eq!(Profile::QualityHigh.sample_frames_per_packet(), 2_880);
|
||||
assert_eq!(Profile::QualityHigh.opus_payload_ceiling_bytes(), Some(120));
|
||||
|
||||
assert_eq!(Profile::QualityMax.channels(), 2);
|
||||
assert_eq!(Profile::QualityMax.sample_rate_hz(), 48_000);
|
||||
assert_eq!(Profile::QualityMax.sample_frames_per_packet(), 2_880);
|
||||
assert_eq!(Profile::QualityMax.opus_payload_ceiling_bytes(), Some(240));
|
||||
|
||||
assert_eq!(Profile::LatencyLow.sample_frames_per_packet(), 480);
|
||||
assert_eq!(Profile::LatencyLow.opus_payload_ceiling_bytes(), Some(20));
|
||||
assert_eq!(Profile::LatencyUltraLow.sample_frames_per_packet(), 240);
|
||||
assert_eq!(
|
||||
Profile::LatencyUltraLow.opus_payload_ceiling_bytes(),
|
||||
Some(10)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_max_bytes_per_frame_matches_python_formula() {
|
||||
assert_eq!(OpusProfile::VoiceMedium.max_bytes_per_frame_ms(60), 60);
|
||||
assert_eq!(OpusProfile::VoiceHigh.max_bytes_per_frame_ms(60), 120);
|
||||
assert_eq!(OpusProfile::VoiceMax.max_bytes_per_frame_ms(60), 240);
|
||||
assert_eq!(OpusProfile::AudioMax.max_bytes_per_frame_ms(60), 960);
|
||||
}
|
||||
}
|
||||
204
crates/lxst-core/src/raw.rs
Normal file
204
crates/lxst-core/src/raw.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
use half::f16;
|
||||
|
||||
use crate::{CodecKind, Error, Frame, RawBitDepth, RawFrameHeader};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RawAudioFrame {
|
||||
pub channels: u8,
|
||||
pub samples: Vec<f32>,
|
||||
}
|
||||
|
||||
impl RawAudioFrame {
|
||||
pub fn new(channels: u8, samples: impl Into<Vec<f32>>) -> Result<Self, Error> {
|
||||
let samples = samples.into();
|
||||
validate_sample_count(channels, samples.len())?;
|
||||
Ok(Self { channels, samples })
|
||||
}
|
||||
|
||||
pub fn sample_frames(&self) -> usize {
|
||||
self.samples.len() / usize::from(self.channels)
|
||||
}
|
||||
|
||||
pub fn from_frame(frame: &Frame) -> Result<Self, Error> {
|
||||
if frame.codec != CodecKind::Raw {
|
||||
return Err(Error::InvalidRawFrameCodec(frame.codec));
|
||||
}
|
||||
|
||||
Self::from_payload(&frame.payload)
|
||||
}
|
||||
|
||||
pub fn to_frame(&self, bit_depth: RawBitDepth) -> Result<Frame, Error> {
|
||||
Ok(Frame::new(CodecKind::Raw, self.to_payload(bit_depth)?))
|
||||
}
|
||||
|
||||
pub fn from_payload(payload: &[u8]) -> Result<Self, Error> {
|
||||
let Some((&header_byte, sample_bytes)) = payload.split_first() else {
|
||||
return Err(Error::EmptyRawPayload);
|
||||
};
|
||||
|
||||
let header = RawFrameHeader::parse(header_byte)?;
|
||||
let samples = decode_samples(sample_bytes, header.bit_depth)?;
|
||||
validate_sample_count(header.channels, samples.len())?;
|
||||
|
||||
Ok(Self {
|
||||
channels: header.channels,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_payload(&self, bit_depth: RawBitDepth) -> Result<Vec<u8>, Error> {
|
||||
validate_sample_count(self.channels, self.samples.len())?;
|
||||
let header = RawFrameHeader::new(self.channels, bit_depth)?;
|
||||
let mut out = Vec::with_capacity(1 + self.samples.len() * bit_depth.bytes_per_sample());
|
||||
out.push(header.encode());
|
||||
encode_samples(&self.samples, bit_depth, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sample_count(channels: u8, samples: usize) -> Result<(), Error> {
|
||||
RawFrameHeader::new(channels, RawBitDepth::Float16)?;
|
||||
if samples % usize::from(channels) != 0 {
|
||||
Err(Error::InvalidRawSampleCount { samples, channels })
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_samples(bytes: &[u8], bit_depth: RawBitDepth) -> Result<Vec<f32>, Error> {
|
||||
let bytes_per_sample = bit_depth.bytes_per_sample();
|
||||
if bytes.len() % bytes_per_sample != 0 {
|
||||
return Err(Error::InvalidRawSampleBytes { bytes_per_sample });
|
||||
}
|
||||
|
||||
let mut samples = Vec::with_capacity(bytes.len() / bytes_per_sample);
|
||||
match bit_depth {
|
||||
RawBitDepth::Float16 => {
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
samples.push(f16::from_le_bytes([chunk[0], chunk[1]]).to_f32());
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float32 => {
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
samples.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float64 => {
|
||||
for chunk in bytes.chunks_exact(8) {
|
||||
samples.push(f64::from_le_bytes([
|
||||
chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
|
||||
]) as f32);
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float128 => {
|
||||
for chunk in bytes.chunks_exact(16) {
|
||||
samples.push(decode_float128_lossy(chunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(samples)
|
||||
}
|
||||
|
||||
fn encode_samples(samples: &[f32], bit_depth: RawBitDepth, out: &mut Vec<u8>) {
|
||||
match bit_depth {
|
||||
RawBitDepth::Float16 => {
|
||||
for sample in samples {
|
||||
out.extend_from_slice(&f16::from_f32(*sample).to_le_bytes());
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float32 => {
|
||||
for sample in samples {
|
||||
out.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float64 => {
|
||||
for sample in samples {
|
||||
out.extend_from_slice(&f64::from(*sample).to_le_bytes());
|
||||
}
|
||||
}
|
||||
RawBitDepth::Float128 => {
|
||||
for sample in samples {
|
||||
encode_float128_from_f32(*sample, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float128_lossy(bytes: &[u8]) -> f32 {
|
||||
// Python/Numpy names this dtype "float128", but on common little-endian
|
||||
// platforms it may be backed by an 80-bit extended value padded to 16 bytes.
|
||||
// We preserve finite zero exactly and otherwise use the leading f64 lane as
|
||||
// a conservative lossy fallback until a platform-specific long-double codec
|
||||
// is introduced.
|
||||
if bytes.iter().all(|byte| *byte == 0) {
|
||||
0.0
|
||||
} else {
|
||||
f64::from_le_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]) as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_float128_from_f32(sample: f32, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&f64::from(sample).to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 8]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raw_audio_frame_encodes_python_default_float16_payload() {
|
||||
let raw = RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap();
|
||||
let payload = raw.to_payload(RawBitDepth::Float16).unwrap();
|
||||
assert_eq!(
|
||||
payload,
|
||||
vec![0x01, 0x00, 0x00, 0x00, 0x38, 0x00, 0xB4, 0x00, 0x3C]
|
||||
);
|
||||
assert_eq!(RawAudioFrame::from_payload(&payload).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_audio_frame_encodes_float32_and_float64() {
|
||||
let raw = RawAudioFrame::new(1, vec![0.25, -1.5]).unwrap();
|
||||
|
||||
let f32_payload = raw.to_payload(RawBitDepth::Float32).unwrap();
|
||||
assert_eq!(f32_payload[0], 0x40);
|
||||
assert_eq!(RawAudioFrame::from_payload(&f32_payload).unwrap(), raw);
|
||||
|
||||
let f64_payload = raw.to_payload(RawBitDepth::Float64).unwrap();
|
||||
assert_eq!(f64_payload[0], 0x80);
|
||||
assert_eq!(RawAudioFrame::from_payload(&f64_payload).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_audio_frame_rejects_misaligned_payloads() {
|
||||
assert_eq!(
|
||||
RawAudioFrame::from_payload(&[]),
|
||||
Err(Error::EmptyRawPayload)
|
||||
);
|
||||
assert_eq!(
|
||||
RawAudioFrame::from_payload(&[0x40, 0x00]),
|
||||
Err(Error::InvalidRawSampleBytes {
|
||||
bytes_per_sample: 4,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
RawAudioFrame::new(2, vec![0.0]),
|
||||
Err(Error::InvalidRawSampleCount {
|
||||
samples: 1,
|
||||
channels: 2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_audio_frame_converts_to_and_from_lxst_frame() {
|
||||
let raw = RawAudioFrame::new(1, vec![0.0, 1.0]).unwrap();
|
||||
let frame = raw.to_frame(RawBitDepth::Float16).unwrap();
|
||||
assert_eq!(frame.codec, CodecKind::Raw);
|
||||
assert_eq!(RawAudioFrame::from_frame(&frame).unwrap(), raw);
|
||||
}
|
||||
}
|
||||
339
crates/lxst-core/src/stream.rs
Normal file
339
crates/lxst-core/src/stream.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{CodecKind, Frame, LxstPacket};
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum StreamError {
|
||||
#[error("frame packetizer batch size must be greater than zero")]
|
||||
InvalidBatchSize,
|
||||
#[error("jitter buffer capacity must be greater than zero")]
|
||||
InvalidJitterCapacity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FramePacketizer {
|
||||
frames_per_packet: usize,
|
||||
}
|
||||
|
||||
impl FramePacketizer {
|
||||
pub const PYTHON_COMPATIBLE: Self = Self {
|
||||
frames_per_packet: 1,
|
||||
};
|
||||
|
||||
pub fn new(frames_per_packet: usize) -> Result<Self, StreamError> {
|
||||
if frames_per_packet == 0 {
|
||||
Err(StreamError::InvalidBatchSize)
|
||||
} else {
|
||||
Ok(Self { frames_per_packet })
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn frames_per_packet(self) -> usize {
|
||||
self.frames_per_packet
|
||||
}
|
||||
|
||||
pub fn packetize_one(self, frame: Frame) -> LxstPacket {
|
||||
LxstPacket::frame(frame)
|
||||
}
|
||||
|
||||
pub fn packetize(self, frames: impl IntoIterator<Item = Frame>) -> Vec<LxstPacket> {
|
||||
let mut packets = Vec::new();
|
||||
let mut batch = Vec::with_capacity(self.frames_per_packet);
|
||||
|
||||
for frame in frames {
|
||||
batch.push(frame);
|
||||
if batch.len() == self.frames_per_packet {
|
||||
packets.push(packet_from_batch(&mut batch));
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
packets.push(packet_from_batch(&mut batch));
|
||||
}
|
||||
|
||||
packets
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_from_batch(batch: &mut Vec<Frame>) -> LxstPacket {
|
||||
if batch.len() == 1 {
|
||||
LxstPacket::frame(batch.pop().expect("batch has one frame"))
|
||||
} else {
|
||||
LxstPacket {
|
||||
signals: Vec::new(),
|
||||
frames: std::mem::take(batch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FrameStreamEvent {
|
||||
CodecChanged {
|
||||
from: Option<CodecKind>,
|
||||
to: CodecKind,
|
||||
},
|
||||
Frame(Frame),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FrameStreamState {
|
||||
current_codec: Option<CodecKind>,
|
||||
}
|
||||
|
||||
impl FrameStreamState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub const fn current_codec(&self) -> Option<CodecKind> {
|
||||
self.current_codec
|
||||
}
|
||||
|
||||
pub fn accept_packet(&mut self, packet: LxstPacket) -> Vec<FrameStreamEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
for frame in packet.frames {
|
||||
if self.current_codec != Some(frame.codec) {
|
||||
let from = self.current_codec;
|
||||
self.current_codec = Some(frame.codec);
|
||||
events.push(FrameStreamEvent::CodecChanged {
|
||||
from,
|
||||
to: frame.codec,
|
||||
});
|
||||
}
|
||||
events.push(FrameStreamEvent::Frame(frame));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DropPolicy {
|
||||
DropNewest,
|
||||
DropOldest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct JitterStats {
|
||||
pub pushed: u64,
|
||||
pub popped: u64,
|
||||
pub dropped_oldest: u64,
|
||||
pub dropped_newest: u64,
|
||||
pub underruns: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JitterPush<T> {
|
||||
Accepted,
|
||||
DroppedIncoming(T),
|
||||
DroppedOldest(T),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JitterBuffer<T> {
|
||||
capacity: usize,
|
||||
drop_policy: DropPolicy,
|
||||
queue: VecDeque<T>,
|
||||
stats: JitterStats,
|
||||
}
|
||||
|
||||
impl<T> JitterBuffer<T> {
|
||||
pub fn new(capacity: usize, drop_policy: DropPolicy) -> Result<Self, StreamError> {
|
||||
if capacity == 0 {
|
||||
Err(StreamError::InvalidJitterCapacity)
|
||||
} else {
|
||||
Ok(Self {
|
||||
capacity,
|
||||
drop_policy,
|
||||
queue: VecDeque::with_capacity(capacity),
|
||||
stats: JitterStats::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn capacity(&self) -> usize {
|
||||
self.capacity
|
||||
}
|
||||
|
||||
pub const fn drop_policy(&self) -> DropPolicy {
|
||||
self.drop_policy
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
pub const fn stats(&self) -> JitterStats {
|
||||
self.stats
|
||||
}
|
||||
|
||||
pub fn push(&mut self, item: T) -> JitterPush<T> {
|
||||
self.stats.pushed += 1;
|
||||
if self.queue.len() < self.capacity {
|
||||
self.queue.push_back(item);
|
||||
return JitterPush::Accepted;
|
||||
}
|
||||
|
||||
match self.drop_policy {
|
||||
DropPolicy::DropNewest => {
|
||||
self.stats.dropped_newest += 1;
|
||||
JitterPush::DroppedIncoming(item)
|
||||
}
|
||||
DropPolicy::DropOldest => {
|
||||
self.stats.dropped_oldest += 1;
|
||||
let dropped = self
|
||||
.queue
|
||||
.pop_front()
|
||||
.expect("full jitter buffer has oldest item");
|
||||
self.queue.push_back(item);
|
||||
JitterPush::DroppedOldest(dropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<T> {
|
||||
let item = self.queue.pop_front();
|
||||
if item.is_some() {
|
||||
self.stats.popped += 1;
|
||||
} else {
|
||||
self.stats.underruns += 1;
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw_frame(byte: u8) -> Frame {
|
||||
Frame::new(CodecKind::Raw, [0x00, byte])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_compatible_packetizer_sends_one_frame_per_packet() {
|
||||
let frames = vec![raw_frame(1), raw_frame(2)];
|
||||
let packets = FramePacketizer::PYTHON_COMPATIBLE.packetize(frames.clone());
|
||||
|
||||
assert_eq!(packets.len(), 2);
|
||||
assert_eq!(packets[0].frames, vec![frames[0].clone()]);
|
||||
assert_eq!(packets[1].frames, vec![frames[1].clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_packetizer_groups_frames_without_reordering() {
|
||||
let frames = vec![raw_frame(1), raw_frame(2), raw_frame(3)];
|
||||
let packetizer = FramePacketizer::new(2).unwrap();
|
||||
let packets = packetizer.packetize(frames.clone());
|
||||
|
||||
assert_eq!(packetizer.frames_per_packet(), 2);
|
||||
assert_eq!(packets.len(), 2);
|
||||
assert_eq!(
|
||||
packets[0].frames,
|
||||
vec![frames[0].clone(), frames[1].clone()]
|
||||
);
|
||||
assert_eq!(packets[1].frames, vec![frames[2].clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_stream_state_emits_codec_changes_before_frames() {
|
||||
let mut state = FrameStreamState::new();
|
||||
let packet = LxstPacket {
|
||||
signals: Vec::new(),
|
||||
frames: vec![
|
||||
Frame::new(CodecKind::Raw, [0x00]),
|
||||
Frame::new(CodecKind::Raw, [0x01]),
|
||||
Frame::new(CodecKind::Opus, [0xF8]),
|
||||
],
|
||||
};
|
||||
|
||||
let events = state.accept_packet(packet);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
FrameStreamEvent::CodecChanged {
|
||||
from: None,
|
||||
to: CodecKind::Raw,
|
||||
},
|
||||
FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x00])),
|
||||
FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x01])),
|
||||
FrameStreamEvent::CodecChanged {
|
||||
from: Some(CodecKind::Raw),
|
||||
to: CodecKind::Opus,
|
||||
},
|
||||
FrameStreamEvent::Frame(Frame::new(CodecKind::Opus, [0xF8])),
|
||||
]
|
||||
);
|
||||
assert_eq!(state.current_codec(), Some(CodecKind::Opus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_buffer_drop_newest_keeps_existing_latency_window() {
|
||||
let mut buffer = JitterBuffer::new(2, DropPolicy::DropNewest).unwrap();
|
||||
assert_eq!(buffer.push(1), JitterPush::Accepted);
|
||||
assert_eq!(buffer.push(2), JitterPush::Accepted);
|
||||
assert_eq!(buffer.push(3), JitterPush::DroppedIncoming(3));
|
||||
assert_eq!(buffer.pop(), Some(1));
|
||||
assert_eq!(buffer.pop(), Some(2));
|
||||
assert_eq!(
|
||||
buffer.stats(),
|
||||
JitterStats {
|
||||
pushed: 3,
|
||||
popped: 2,
|
||||
dropped_oldest: 0,
|
||||
dropped_newest: 1,
|
||||
underruns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_buffer_drop_oldest_preserves_latest_audio() {
|
||||
let mut buffer = JitterBuffer::new(2, DropPolicy::DropOldest).unwrap();
|
||||
assert_eq!(buffer.push(1), JitterPush::Accepted);
|
||||
assert_eq!(buffer.push(2), JitterPush::Accepted);
|
||||
assert_eq!(buffer.push(3), JitterPush::DroppedOldest(1));
|
||||
assert_eq!(buffer.pop(), Some(2));
|
||||
assert_eq!(buffer.pop(), Some(3));
|
||||
assert_eq!(
|
||||
buffer.stats(),
|
||||
JitterStats {
|
||||
pushed: 3,
|
||||
popped: 2,
|
||||
dropped_oldest: 1,
|
||||
dropped_newest: 0,
|
||||
underruns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_buffer_tracks_playback_underruns() {
|
||||
let mut buffer = JitterBuffer::new(2, DropPolicy::DropOldest).unwrap();
|
||||
assert_eq!(buffer.pop(), None);
|
||||
assert_eq!(buffer.push(1), JitterPush::Accepted);
|
||||
assert_eq!(buffer.pop(), Some(1));
|
||||
assert_eq!(buffer.pop(), None);
|
||||
assert_eq!(
|
||||
buffer.stats(),
|
||||
JitterStats {
|
||||
pushed: 1,
|
||||
popped: 1,
|
||||
dropped_oldest: 0,
|
||||
dropped_newest: 0,
|
||||
underruns: 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
249
crates/lxst-core/src/synthetic.rs
Normal file
249
crates/lxst-core/src/synthetic.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use crate::{Error as WireError, RawAudioFrame, RawFrameHeader};
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum SyntheticError {
|
||||
#[error("synthetic source sample rate must be greater than zero")]
|
||||
InvalidSampleRate,
|
||||
#[error("synthetic source frame sample count must be greater than zero")]
|
||||
InvalidFrameSamples,
|
||||
#[error("synthetic source parameter must be finite")]
|
||||
NonFiniteParameter,
|
||||
#[error("raw frame error: {0}")]
|
||||
Raw(#[from] WireError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SyntheticSourceKind {
|
||||
Silence,
|
||||
Ramp { start: f32, step: f32 },
|
||||
Sine { frequency_hz: f32, amplitude: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SyntheticSource {
|
||||
channels: u8,
|
||||
sample_rate_hz: u32,
|
||||
frame_samples: usize,
|
||||
kind: SyntheticSourceKind,
|
||||
cursor_samples: u64,
|
||||
}
|
||||
|
||||
impl SyntheticSource {
|
||||
pub fn new(
|
||||
channels: u8,
|
||||
sample_rate_hz: u32,
|
||||
frame_samples: usize,
|
||||
kind: SyntheticSourceKind,
|
||||
) -> Result<Self, SyntheticError> {
|
||||
RawFrameHeader::new(channels, crate::RawBitDepth::Float16)?;
|
||||
if sample_rate_hz == 0 {
|
||||
return Err(SyntheticError::InvalidSampleRate);
|
||||
}
|
||||
if frame_samples == 0 {
|
||||
return Err(SyntheticError::InvalidFrameSamples);
|
||||
}
|
||||
validate_kind(kind)?;
|
||||
|
||||
Ok(Self {
|
||||
channels,
|
||||
sample_rate_hz,
|
||||
frame_samples,
|
||||
kind,
|
||||
cursor_samples: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn channels(&self) -> u8 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
pub const fn sample_rate_hz(&self) -> u32 {
|
||||
self.sample_rate_hz
|
||||
}
|
||||
|
||||
pub const fn frame_samples(&self) -> usize {
|
||||
self.frame_samples
|
||||
}
|
||||
|
||||
pub const fn cursor_samples(&self) -> u64 {
|
||||
self.cursor_samples
|
||||
}
|
||||
|
||||
pub fn next_raw_frame(&mut self) -> Result<RawAudioFrame, SyntheticError> {
|
||||
let mut samples = Vec::with_capacity(self.frame_samples * usize::from(self.channels));
|
||||
|
||||
for frame_index in 0..self.frame_samples {
|
||||
let absolute_sample = self.cursor_samples + frame_index as u64;
|
||||
let base = self.sample_at(absolute_sample);
|
||||
for channel in 0..self.channels {
|
||||
samples.push(channel_sample(base, channel));
|
||||
}
|
||||
}
|
||||
|
||||
self.cursor_samples += self.frame_samples as u64;
|
||||
Ok(RawAudioFrame::new(self.channels, samples)?)
|
||||
}
|
||||
|
||||
fn sample_at(&self, absolute_sample: u64) -> f32 {
|
||||
match self.kind {
|
||||
SyntheticSourceKind::Silence => 0.0,
|
||||
SyntheticSourceKind::Ramp { start, step } => start + step * absolute_sample as f32,
|
||||
SyntheticSourceKind::Sine {
|
||||
frequency_hz,
|
||||
amplitude,
|
||||
} => {
|
||||
let t = absolute_sample as f32 / self.sample_rate_hz as f32;
|
||||
amplitude * (std::f32::consts::TAU * frequency_hz * t).sin()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_sample(base: f32, channel: u8) -> f32 {
|
||||
if channel == 0 {
|
||||
base
|
||||
} else {
|
||||
// Deterministic but small channel separation for fixture validation.
|
||||
base + f32::from(channel) * 0.001
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_kind(kind: SyntheticSourceKind) -> Result<(), SyntheticError> {
|
||||
match kind {
|
||||
SyntheticSourceKind::Silence => Ok(()),
|
||||
SyntheticSourceKind::Ramp { start, step } => {
|
||||
if start.is_finite() && step.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SyntheticError::NonFiniteParameter)
|
||||
}
|
||||
}
|
||||
SyntheticSourceKind::Sine {
|
||||
frequency_hz,
|
||||
amplitude,
|
||||
} => {
|
||||
if frequency_hz.is_finite() && amplitude.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SyntheticError::NonFiniteParameter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct RawFrameCollector {
|
||||
frames: Vec<RawAudioFrame>,
|
||||
sample_frames: usize,
|
||||
}
|
||||
|
||||
impl RawFrameCollector {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, frame: RawAudioFrame) {
|
||||
self.sample_frames += frame.sample_frames();
|
||||
self.frames.push(frame);
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> &[RawAudioFrame] {
|
||||
&self.frames
|
||||
}
|
||||
|
||||
pub const fn sample_frames(&self) -> usize {
|
||||
self.sample_frames
|
||||
}
|
||||
|
||||
pub fn into_frames(self) -> Vec<RawAudioFrame> {
|
||||
self.frames
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ramp_source_generates_deterministic_interleaved_channels() {
|
||||
let mut source = SyntheticSource::new(
|
||||
2,
|
||||
48_000,
|
||||
3,
|
||||
SyntheticSourceKind::Ramp {
|
||||
start: 0.0,
|
||||
step: 0.5,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let first = source.next_raw_frame().unwrap();
|
||||
let second = source.next_raw_frame().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
first,
|
||||
RawAudioFrame::new(2, vec![0.0, 0.001, 0.5, 0.501, 1.0, 1.001]).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
second,
|
||||
RawAudioFrame::new(2, vec![1.5, 1.501, 2.0, 2.001, 2.5, 2.501]).unwrap()
|
||||
);
|
||||
assert_eq!(source.cursor_samples(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sine_source_generates_expected_quarter_wave_samples() {
|
||||
let mut source = SyntheticSource::new(
|
||||
1,
|
||||
4,
|
||||
5,
|
||||
SyntheticSourceKind::Sine {
|
||||
frequency_hz: 1.0,
|
||||
amplitude: 1.0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let frame = source.next_raw_frame().unwrap();
|
||||
let expected = [0.0, 1.0, 0.0, -1.0, 0.0];
|
||||
for (sample, expected) in frame.samples.iter().zip(expected) {
|
||||
assert!((sample - expected).abs() < 0.000_001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_tracks_frames_and_sample_count() {
|
||||
let mut collector = RawFrameCollector::new();
|
||||
collector.push(RawAudioFrame::new(1, vec![0.0, 1.0]).unwrap());
|
||||
collector.push(RawAudioFrame::new(2, vec![0.0, 0.1, 0.2, 0.3]).unwrap());
|
||||
|
||||
assert_eq!(collector.frames().len(), 2);
|
||||
assert_eq!(collector.sample_frames(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_source_parameters_fail_explicitly() {
|
||||
assert_eq!(
|
||||
SyntheticSource::new(1, 0, 1, SyntheticSourceKind::Silence),
|
||||
Err(SyntheticError::InvalidSampleRate)
|
||||
);
|
||||
assert_eq!(
|
||||
SyntheticSource::new(1, 48_000, 0, SyntheticSourceKind::Silence),
|
||||
Err(SyntheticError::InvalidFrameSamples)
|
||||
);
|
||||
assert_eq!(
|
||||
SyntheticSource::new(
|
||||
1,
|
||||
48_000,
|
||||
1,
|
||||
SyntheticSourceKind::Ramp {
|
||||
start: f32::NAN,
|
||||
step: 1.0,
|
||||
},
|
||||
),
|
||||
Err(SyntheticError::NonFiniteParameter)
|
||||
);
|
||||
}
|
||||
}
|
||||
378
crates/lxst-core/src/telephony.rs
Normal file
378
crates/lxst-core/src/telephony.rs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
use crate::{Profile, Signal, SignallingStatus};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CallRole {
|
||||
Incoming,
|
||||
Outgoing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TelephonyAction {
|
||||
SendSignal(Signal),
|
||||
IdentifyLocalIdentity,
|
||||
SelectProfile(Profile),
|
||||
PrepareDialingPipelines,
|
||||
ResetDialingPipelines,
|
||||
OpenAudioPipelines,
|
||||
StartAudioPipelines,
|
||||
StartDialTone,
|
||||
Terminate(Option<SignallingStatus>),
|
||||
TeardownLink,
|
||||
RingIncomingCall,
|
||||
SwitchProfile(Profile),
|
||||
IgnoreSignal(Signal),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TelephonyCall {
|
||||
role: CallRole,
|
||||
status: SignallingStatus,
|
||||
profile: Option<Profile>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl TelephonyCall {
|
||||
pub fn outgoing(profile: Option<Profile>) -> Self {
|
||||
Self {
|
||||
role: CallRole::Outgoing,
|
||||
status: SignallingStatus::Calling,
|
||||
profile,
|
||||
answered: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn incoming() -> Self {
|
||||
Self {
|
||||
role: CallRole::Incoming,
|
||||
status: SignallingStatus::Available,
|
||||
profile: None,
|
||||
answered: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn role(&self) -> CallRole {
|
||||
self.role
|
||||
}
|
||||
|
||||
pub const fn status(&self) -> SignallingStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub const fn profile(&self) -> Option<Profile> {
|
||||
self.profile
|
||||
}
|
||||
|
||||
pub const fn answered(&self) -> bool {
|
||||
self.answered
|
||||
}
|
||||
|
||||
pub fn incoming_link_established(line_busy: bool) -> Vec<TelephonyAction> {
|
||||
if line_busy {
|
||||
vec![
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)),
|
||||
TelephonyAction::TeardownLink,
|
||||
]
|
||||
} else {
|
||||
vec![TelephonyAction::SendSignal(Signal::from(
|
||||
SignallingStatus::Available,
|
||||
))]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caller_identified(&mut self, line_busy: bool, allowed: bool) -> Vec<TelephonyAction> {
|
||||
if line_busy || !allowed {
|
||||
return vec![
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)),
|
||||
TelephonyAction::TeardownLink,
|
||||
];
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
actions.push(TelephonyAction::ResetDialingPipelines);
|
||||
self.push_status_signal(SignallingStatus::Ringing, &mut actions);
|
||||
actions.push(TelephonyAction::RingIncomingCall);
|
||||
actions
|
||||
}
|
||||
|
||||
pub fn answer(&mut self) -> Vec<TelephonyAction> {
|
||||
if self.role != CallRole::Incoming || self.status != SignallingStatus::Ringing {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
self.answered = true;
|
||||
self.ensure_profile(&mut actions);
|
||||
self.push_status_signal(SignallingStatus::Connecting, &mut actions);
|
||||
actions.push(TelephonyAction::OpenAudioPipelines);
|
||||
self.push_status_signal(SignallingStatus::Established, &mut actions);
|
||||
actions.push(TelephonyAction::StartAudioPipelines);
|
||||
actions
|
||||
}
|
||||
|
||||
pub fn receive_signal(&mut self, signal: Signal) -> Vec<TelephonyAction> {
|
||||
if self.role == CallRole::Incoming && !self.answered && matches!(signal, Signal::Status(_))
|
||||
{
|
||||
return vec![TelephonyAction::IgnoreSignal(signal)];
|
||||
}
|
||||
|
||||
match signal {
|
||||
Signal::Status(SignallingStatus::Busy) => {
|
||||
vec![TelephonyAction::Terminate(Some(SignallingStatus::Busy))]
|
||||
}
|
||||
Signal::Status(SignallingStatus::Rejected) => {
|
||||
vec![TelephonyAction::Terminate(Some(SignallingStatus::Rejected))]
|
||||
}
|
||||
Signal::Status(SignallingStatus::Calling) => {
|
||||
vec![TelephonyAction::IgnoreSignal(signal)]
|
||||
}
|
||||
Signal::Status(SignallingStatus::Available) => {
|
||||
self.status = SignallingStatus::Available;
|
||||
vec![TelephonyAction::IdentifyLocalIdentity]
|
||||
}
|
||||
Signal::Status(SignallingStatus::Ringing) => {
|
||||
let mut actions = Vec::new();
|
||||
self.status = SignallingStatus::Ringing;
|
||||
self.ensure_profile(&mut actions);
|
||||
actions.push(TelephonyAction::PrepareDialingPipelines);
|
||||
if let Some(profile) = self.profile {
|
||||
actions.push(TelephonyAction::SendSignal(Signal::from(profile)));
|
||||
}
|
||||
actions.push(TelephonyAction::StartDialTone);
|
||||
actions
|
||||
}
|
||||
Signal::Status(SignallingStatus::Connecting) => {
|
||||
self.status = SignallingStatus::Connecting;
|
||||
vec![
|
||||
TelephonyAction::ResetDialingPipelines,
|
||||
TelephonyAction::OpenAudioPipelines,
|
||||
]
|
||||
}
|
||||
Signal::Status(SignallingStatus::Established) => {
|
||||
self.status = SignallingStatus::Established;
|
||||
vec![TelephonyAction::StartAudioPipelines]
|
||||
}
|
||||
Signal::PreferredProfile(profile) => {
|
||||
if self.profile == Some(profile) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.profile = Some(profile);
|
||||
if self.status == SignallingStatus::Established {
|
||||
vec![TelephonyAction::SwitchProfile(profile)]
|
||||
} else {
|
||||
vec![TelephonyAction::SelectProfile(profile)]
|
||||
}
|
||||
}
|
||||
Signal::Raw(_) => vec![TelephonyAction::IgnoreSignal(signal)],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn switch_profile(&mut self, profile: Profile) -> Vec<TelephonyAction> {
|
||||
if self.profile == Some(profile) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.profile = Some(profile);
|
||||
if self.status == SignallingStatus::Established {
|
||||
vec![
|
||||
TelephonyAction::SendSignal(Signal::from(profile)),
|
||||
TelephonyAction::SwitchProfile(profile),
|
||||
]
|
||||
} else {
|
||||
vec![TelephonyAction::SelectProfile(profile)]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hangup(&mut self, ring_timeout: bool) -> Vec<TelephonyAction> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
if self.role == CallRole::Incoming
|
||||
&& self.status == SignallingStatus::Ringing
|
||||
&& !ring_timeout
|
||||
{
|
||||
actions.push(TelephonyAction::SendSignal(Signal::from(
|
||||
SignallingStatus::Rejected,
|
||||
)));
|
||||
}
|
||||
|
||||
actions.push(TelephonyAction::TeardownLink);
|
||||
self.status = SignallingStatus::Available;
|
||||
self.answered = false;
|
||||
actions
|
||||
}
|
||||
|
||||
fn ensure_profile(&mut self, actions: &mut Vec<TelephonyAction>) {
|
||||
let profile = self.profile.unwrap_or(Profile::DEFAULT);
|
||||
self.profile = Some(profile);
|
||||
actions.push(TelephonyAction::SelectProfile(profile));
|
||||
}
|
||||
|
||||
fn push_status_signal(&mut self, status: SignallingStatus, actions: &mut Vec<TelephonyAction>) {
|
||||
if status.is_auto_status() {
|
||||
self.status = status;
|
||||
}
|
||||
actions.push(TelephonyAction::SendSignal(Signal::from(status)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn incoming_link_establishment_matches_python_busy_branch() {
|
||||
assert_eq!(
|
||||
TelephonyCall::incoming_link_established(false),
|
||||
vec![TelephonyAction::SendSignal(Signal::from(
|
||||
SignallingStatus::Available
|
||||
))]
|
||||
);
|
||||
assert_eq!(
|
||||
TelephonyCall::incoming_link_established(true),
|
||||
vec![
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Busy)),
|
||||
TelephonyAction::TeardownLink,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_identified_then_answered_sequence_matches_python() {
|
||||
let mut call = TelephonyCall::incoming();
|
||||
let ringing = call.caller_identified(false, true);
|
||||
assert_eq!(call.status(), SignallingStatus::Ringing);
|
||||
assert_eq!(
|
||||
ringing,
|
||||
vec![
|
||||
TelephonyAction::ResetDialingPipelines,
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Ringing)),
|
||||
TelephonyAction::RingIncomingCall,
|
||||
]
|
||||
);
|
||||
|
||||
let answer = call.answer();
|
||||
assert_eq!(call.status(), SignallingStatus::Established);
|
||||
assert!(call.answered());
|
||||
assert_eq!(call.profile(), Some(Profile::DEFAULT));
|
||||
assert_eq!(
|
||||
answer,
|
||||
vec![
|
||||
TelephonyAction::SelectProfile(Profile::DEFAULT),
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Connecting)),
|
||||
TelephonyAction::OpenAudioPipelines,
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Established)),
|
||||
TelephonyAction::StartAudioPipelines,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_sequence_identifies_profiles_opens_and_starts_audio() {
|
||||
let mut call = TelephonyCall::outgoing(None);
|
||||
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(SignallingStatus::Available)),
|
||||
vec![TelephonyAction::IdentifyLocalIdentity]
|
||||
);
|
||||
|
||||
let ringing = call.receive_signal(Signal::from(SignallingStatus::Ringing));
|
||||
assert_eq!(call.profile(), Some(Profile::DEFAULT));
|
||||
assert_eq!(
|
||||
ringing,
|
||||
vec![
|
||||
TelephonyAction::SelectProfile(Profile::DEFAULT),
|
||||
TelephonyAction::PrepareDialingPipelines,
|
||||
TelephonyAction::SendSignal(Signal::from(Profile::DEFAULT)),
|
||||
TelephonyAction::StartDialTone,
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(SignallingStatus::Connecting)),
|
||||
vec![
|
||||
TelephonyAction::ResetDialingPipelines,
|
||||
TelephonyAction::OpenAudioPipelines,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(SignallingStatus::Established)),
|
||||
vec![TelephonyAction::StartAudioPipelines]
|
||||
);
|
||||
assert_eq!(call.status(), SignallingStatus::Established);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_signals_select_or_switch_profile_by_call_status() {
|
||||
let mut call = TelephonyCall::outgoing(None);
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(Profile::LatencyLow)),
|
||||
vec![TelephonyAction::SelectProfile(Profile::LatencyLow)]
|
||||
);
|
||||
|
||||
call.receive_signal(Signal::from(SignallingStatus::Established));
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(Profile::LatencyUltraLow)),
|
||||
vec![TelephonyAction::SwitchProfile(Profile::LatencyUltraLow)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_profile_signals_do_not_reconfigure_audio() {
|
||||
let mut call = TelephonyCall::outgoing(Some(Profile::QualityHigh));
|
||||
call.receive_signal(Signal::from(SignallingStatus::Established));
|
||||
|
||||
assert!(
|
||||
call.receive_signal(Signal::from(Profile::QualityHigh))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_profile_switch_signals_remote_and_reconfigures_established_call() {
|
||||
let mut call = TelephonyCall::outgoing(Some(Profile::QualityMedium));
|
||||
call.receive_signal(Signal::from(SignallingStatus::Established));
|
||||
|
||||
assert_eq!(
|
||||
call.switch_profile(Profile::QualityHigh),
|
||||
vec![
|
||||
TelephonyAction::SendSignal(Signal::from(Profile::QualityHigh)),
|
||||
TelephonyAction::SwitchProfile(Profile::QualityHigh),
|
||||
]
|
||||
);
|
||||
assert_eq!(call.profile(), Some(Profile::QualityHigh));
|
||||
assert!(call.switch_profile(Profile::QualityHigh).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_call_ignores_status_signals_before_answer() {
|
||||
let mut call = TelephonyCall::incoming();
|
||||
call.caller_identified(false, true);
|
||||
|
||||
assert_eq!(
|
||||
call.receive_signal(Signal::from(SignallingStatus::Established)),
|
||||
vec![TelephonyAction::IgnoreSignal(Signal::from(
|
||||
SignallingStatus::Established
|
||||
))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_hangup_sends_rejected_while_ringing_unless_timeout() {
|
||||
let mut call = TelephonyCall::incoming();
|
||||
call.caller_identified(false, true);
|
||||
assert_eq!(
|
||||
call.hangup(false),
|
||||
vec![
|
||||
TelephonyAction::SendSignal(Signal::from(SignallingStatus::Rejected)),
|
||||
TelephonyAction::TeardownLink,
|
||||
]
|
||||
);
|
||||
|
||||
let mut timeout_call = TelephonyCall::incoming();
|
||||
timeout_call.caller_identified(false, true);
|
||||
assert_eq!(
|
||||
timeout_call.hangup(true),
|
||||
vec![TelephonyAction::TeardownLink]
|
||||
);
|
||||
}
|
||||
}
|
||||
485
crates/lxst-core/src/wire.rs
Normal file
485
crates/lxst-core/src/wire.rs
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
use rmpv::Value;
|
||||
use rmpv::decode::read_value;
|
||||
use rmpv::encode::write_value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::profile::{Profile, SignallingStatus};
|
||||
|
||||
pub const FIELD_SIGNALLING: u8 = 0x00;
|
||||
pub const FIELD_FRAMES: u8 = 0x01;
|
||||
|
||||
const PREFERRED_PROFILE_BASE: u32 = 0xFF;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("msgpack decode error: {0}")]
|
||||
Decode(String),
|
||||
#[error("msgpack encode error: {0}")]
|
||||
Encode(String),
|
||||
#[error("LXST packet root must be a msgpack map")]
|
||||
RootNotMap,
|
||||
#[error("LXST field key must be a non-negative integer")]
|
||||
InvalidFieldKey,
|
||||
#[error("LXST field {field:#04x} has invalid value type")]
|
||||
InvalidFieldType { field: u8 },
|
||||
#[error("LXST signal value must be a non-negative integer")]
|
||||
InvalidSignal,
|
||||
#[error("LXST frame must contain a codec header byte")]
|
||||
EmptyFrame,
|
||||
#[error("unknown LXST codec id {0:#04x}")]
|
||||
UnknownCodec(u8),
|
||||
#[error("LXST codec {0:?} is not transmittable as a media frame")]
|
||||
NonTransmittableCodec(CodecKind),
|
||||
#[error("expected raw codec frame, got {0:?}")]
|
||||
InvalidRawFrameCodec(CodecKind),
|
||||
#[error("invalid raw channel count {0}; expected 1..=64")]
|
||||
InvalidRawChannels(u8),
|
||||
#[error("unknown raw bit-depth header {0}")]
|
||||
UnknownRawBitDepth(u8),
|
||||
#[error("raw payload is empty; expected one header byte plus sample data")]
|
||||
EmptyRawPayload,
|
||||
#[error("raw payload sample bytes are not aligned to {bytes_per_sample}-byte samples")]
|
||||
InvalidRawSampleBytes { bytes_per_sample: usize },
|
||||
#[error("raw sample count {samples} is not divisible by channel count {channels}")]
|
||||
InvalidRawSampleCount { samples: usize, channels: u8 },
|
||||
#[error("unknown Codec2 mode header {0:#04x}")]
|
||||
UnknownCodec2Mode(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum CodecKind {
|
||||
Raw = 0x00,
|
||||
Opus = 0x01,
|
||||
Codec2 = 0x02,
|
||||
Null = 0xFF,
|
||||
}
|
||||
|
||||
impl CodecKind {
|
||||
pub const fn wire_id(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_wire(id: u8) -> Result<Self, Error> {
|
||||
match id {
|
||||
0x00 => Ok(Self::Raw),
|
||||
0x01 => Ok(Self::Opus),
|
||||
0x02 => Ok(Self::Codec2),
|
||||
0xFF => Ok(Self::Null),
|
||||
other => Err(Error::UnknownCodec(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_transmittable(self) -> bool {
|
||||
!matches!(self, Self::Null)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Signal {
|
||||
Status(SignallingStatus),
|
||||
PreferredProfile(Profile),
|
||||
Raw(u32),
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
pub const fn wire_value(self) -> u32 {
|
||||
match self {
|
||||
Self::Status(status) => status.wire_value(),
|
||||
Self::PreferredProfile(profile) => PREFERRED_PROFILE_BASE + profile.wire_value(),
|
||||
Self::Raw(value) => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn from_wire(value: u32) -> Self {
|
||||
if let Some(status) = SignallingStatus::from_wire(value) {
|
||||
Self::Status(status)
|
||||
} else if value >= PREFERRED_PROFILE_BASE {
|
||||
let profile_value = value - PREFERRED_PROFILE_BASE;
|
||||
if let Some(profile) = Profile::from_wire(profile_value) {
|
||||
Self::PreferredProfile(profile)
|
||||
} else {
|
||||
Self::Raw(value)
|
||||
}
|
||||
} else {
|
||||
Self::Raw(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SignallingStatus> for Signal {
|
||||
fn from(value: SignallingStatus) -> Self {
|
||||
Self::Status(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Profile> for Signal {
|
||||
fn from(value: Profile) -> Self {
|
||||
Self::PreferredProfile(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub codec: CodecKind,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(codec: CodecKind, payload: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
codec,
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire_bytes(bytes: &[u8]) -> Result<Self, Error> {
|
||||
let Some((&codec_id, payload)) = bytes.split_first() else {
|
||||
return Err(Error::EmptyFrame);
|
||||
};
|
||||
|
||||
let codec = CodecKind::from_wire(codec_id)?;
|
||||
if !codec.is_transmittable() {
|
||||
return Err(Error::NonTransmittableCodec(codec));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
codec,
|
||||
payload: payload.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_wire_bytes(&self) -> Result<Vec<u8>, Error> {
|
||||
if !self.codec.is_transmittable() {
|
||||
return Err(Error::NonTransmittableCodec(self.codec));
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(1 + self.payload.len());
|
||||
bytes.push(self.codec.wire_id());
|
||||
bytes.extend_from_slice(&self.payload);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct LxstPacket {
|
||||
pub signals: Vec<Signal>,
|
||||
pub frames: Vec<Frame>,
|
||||
}
|
||||
|
||||
impl LxstPacket {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn signalling(signals: impl IntoIterator<Item = Signal>) -> Self {
|
||||
Self {
|
||||
signals: signals.into_iter().collect(),
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame(frame: Frame) -> Self {
|
||||
Self {
|
||||
signals: Vec::new(),
|
||||
frames: vec![frame],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, Error> {
|
||||
let mut map = Vec::with_capacity(2);
|
||||
|
||||
if !self.signals.is_empty() {
|
||||
let signals = self
|
||||
.signals
|
||||
.iter()
|
||||
.map(|signal| Value::from(signal.wire_value() as u64))
|
||||
.collect();
|
||||
map.push((Value::from(FIELD_SIGNALLING as u64), Value::Array(signals)));
|
||||
}
|
||||
|
||||
if !self.frames.is_empty() {
|
||||
let value = if self.frames.len() == 1 {
|
||||
Value::Binary(self.frames[0].to_wire_bytes()?)
|
||||
} else {
|
||||
Value::Array(
|
||||
self.frames
|
||||
.iter()
|
||||
.map(|frame| frame.to_wire_bytes().map(Value::Binary))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
)
|
||||
};
|
||||
map.push((Value::from(FIELD_FRAMES as u64), value));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
write_value(&mut out, &Value::Map(map)).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
|
||||
let value = read_value(&mut &bytes[..]).map_err(|e| Error::Decode(e.to_string()))?;
|
||||
Self::from_value(value)
|
||||
}
|
||||
|
||||
fn from_value(value: Value) -> Result<Self, Error> {
|
||||
let Value::Map(entries) = value else {
|
||||
return Err(Error::RootNotMap);
|
||||
};
|
||||
|
||||
let mut packet = Self::new();
|
||||
for (key, value) in entries {
|
||||
let Some(field) = integer_value(&key).and_then(|v| u8::try_from(v).ok()) else {
|
||||
return Err(Error::InvalidFieldKey);
|
||||
};
|
||||
|
||||
match field {
|
||||
FIELD_SIGNALLING => {
|
||||
packet.signals.extend(parse_signals(value)?);
|
||||
}
|
||||
FIELD_FRAMES => {
|
||||
packet.frames.extend(parse_frames(value)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_signals(value: Value) -> Result<Vec<Signal>, Error> {
|
||||
match value {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(parse_signal)
|
||||
.collect::<Result<Vec<_>, _>>(),
|
||||
other => Ok(vec![parse_signal(&other)?]),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_signal(value: &Value) -> Result<Signal, Error> {
|
||||
let Some(raw) = integer_value(value).and_then(|v| u32::try_from(v).ok()) else {
|
||||
return Err(Error::InvalidSignal);
|
||||
};
|
||||
Ok(Signal::from_wire(raw))
|
||||
}
|
||||
|
||||
fn parse_frames(value: Value) -> Result<Vec<Frame>, Error> {
|
||||
match value {
|
||||
Value::Binary(bytes) => Ok(vec![Frame::from_wire_bytes(&bytes)?]),
|
||||
Value::Array(values) => values
|
||||
.into_iter()
|
||||
.map(|value| match value {
|
||||
Value::Binary(bytes) => Frame::from_wire_bytes(&bytes),
|
||||
_ => Err(Error::InvalidFieldType {
|
||||
field: FIELD_FRAMES,
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(Error::InvalidFieldType {
|
||||
field: FIELD_FRAMES,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn integer_value(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|v| u64::try_from(v).ok()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum RawBitDepth {
|
||||
Float16 = 0x00,
|
||||
Float32 = 0x01,
|
||||
Float64 = 0x02,
|
||||
Float128 = 0x03,
|
||||
}
|
||||
|
||||
impl RawBitDepth {
|
||||
pub const fn from_header_bits(bits: u8) -> Result<Self, Error> {
|
||||
match bits {
|
||||
0x00 => Ok(Self::Float16),
|
||||
0x01 => Ok(Self::Float32),
|
||||
0x02 => Ok(Self::Float64),
|
||||
0x03 => Ok(Self::Float128),
|
||||
other => Err(Error::UnknownRawBitDepth(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn bits(self) -> u16 {
|
||||
match self {
|
||||
Self::Float16 => 16,
|
||||
Self::Float32 => 32,
|
||||
Self::Float64 => 64,
|
||||
Self::Float128 => 128,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn bytes_per_sample(self) -> usize {
|
||||
(self.bits() as usize) / 8
|
||||
}
|
||||
|
||||
pub const fn numpy_dtype(self) -> &'static str {
|
||||
match self {
|
||||
Self::Float16 => "float16",
|
||||
Self::Float32 => "float32",
|
||||
Self::Float64 => "float64",
|
||||
Self::Float128 => "float128",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RawFrameHeader {
|
||||
pub channels: u8,
|
||||
pub bit_depth: RawBitDepth,
|
||||
}
|
||||
|
||||
impl RawFrameHeader {
|
||||
pub const fn new(channels: u8, bit_depth: RawBitDepth) -> Result<Self, Error> {
|
||||
if channels == 0 || channels > 64 {
|
||||
Err(Error::InvalidRawChannels(channels))
|
||||
} else {
|
||||
Ok(Self {
|
||||
channels,
|
||||
bit_depth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(byte: u8) -> Result<Self, Error> {
|
||||
let channels = (byte & 0b0011_1111) + 1;
|
||||
let bit_depth = RawBitDepth::from_header_bits(byte >> 6)?;
|
||||
Self::new(channels, bit_depth)
|
||||
}
|
||||
|
||||
pub const fn encode(self) -> u8 {
|
||||
((self.bit_depth as u8) << 6) | (self.channels - 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum Codec2Mode {
|
||||
Mode700C = 0x00,
|
||||
Mode1200 = 0x01,
|
||||
Mode1300 = 0x02,
|
||||
Mode1400 = 0x03,
|
||||
Mode1600 = 0x04,
|
||||
Mode2400 = 0x05,
|
||||
Mode3200 = 0x06,
|
||||
}
|
||||
|
||||
impl Codec2Mode {
|
||||
pub const fn from_header(byte: u8) -> Result<Self, Error> {
|
||||
match byte {
|
||||
0x00 => Ok(Self::Mode700C),
|
||||
0x01 => Ok(Self::Mode1200),
|
||||
0x02 => Ok(Self::Mode1300),
|
||||
0x03 => Ok(Self::Mode1400),
|
||||
0x04 => Ok(Self::Mode1600),
|
||||
0x05 => Ok(Self::Mode2400),
|
||||
0x06 => Ok(Self::Mode3200),
|
||||
other => Err(Error::UnknownCodec2Mode(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn header(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn bitrate(self) -> u16 {
|
||||
match self {
|
||||
Self::Mode700C => 700,
|
||||
Self::Mode1200 => 1200,
|
||||
Self::Mode1300 => 1300,
|
||||
Self::Mode1400 => 1400,
|
||||
Self::Mode1600 => 1600,
|
||||
Self::Mode2400 => 2400,
|
||||
Self::Mode3200 => 3200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codec_header_mapping_matches_python() {
|
||||
assert_eq!(CodecKind::Raw.wire_id(), 0x00);
|
||||
assert_eq!(CodecKind::Opus.wire_id(), 0x01);
|
||||
assert_eq!(CodecKind::Codec2.wire_id(), 0x02);
|
||||
assert_eq!(CodecKind::Null.wire_id(), 0xFF);
|
||||
assert_eq!(CodecKind::from_wire(0x02), Ok(CodecKind::Codec2));
|
||||
assert_eq!(CodecKind::from_wire(0x03), Err(Error::UnknownCodec(0x03)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_header_roundtrip() {
|
||||
let header = RawFrameHeader::new(2, RawBitDepth::Float32).unwrap();
|
||||
assert_eq!(header.encode(), 0x41);
|
||||
assert_eq!(RawFrameHeader::parse(0x41), Ok(header));
|
||||
|
||||
let max = RawFrameHeader::new(64, RawBitDepth::Float128).unwrap();
|
||||
assert_eq!(RawFrameHeader::parse(max.encode()), Ok(max));
|
||||
assert_eq!(
|
||||
RawFrameHeader::new(0, RawBitDepth::Float16),
|
||||
Err(Error::InvalidRawChannels(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec2_mode_headers_match_python() {
|
||||
assert_eq!(Codec2Mode::Mode700C.header(), 0x00);
|
||||
assert_eq!(Codec2Mode::Mode1600.header(), 0x04);
|
||||
assert_eq!(Codec2Mode::Mode3200.header(), 0x06);
|
||||
assert_eq!(Codec2Mode::Mode700C.bitrate(), 700);
|
||||
assert_eq!(
|
||||
Codec2Mode::from_header(0x07),
|
||||
Err(Error::UnknownCodec2Mode(0x07))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_roundtrip() {
|
||||
let frame = Frame::new(CodecKind::Raw, [0x41, 0xAA, 0xBB]);
|
||||
let wire = frame.to_wire_bytes().unwrap();
|
||||
assert_eq!(wire, vec![0x00, 0x41, 0xAA, 0xBB]);
|
||||
assert_eq!(Frame::from_wire_bytes(&wire), Ok(frame));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_codec_is_known_but_not_transmittable() {
|
||||
assert_eq!(CodecKind::from_wire(0xFF), Ok(CodecKind::Null));
|
||||
assert_eq!(
|
||||
Frame::from_wire_bytes(&[0xFF]),
|
||||
Err(Error::NonTransmittableCodec(CodecKind::Null))
|
||||
);
|
||||
assert_eq!(
|
||||
LxstPacket::frame(Frame::new(CodecKind::Null, [])).encode(),
|
||||
Err(Error::NonTransmittableCodec(CodecKind::Null))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_profile_base_matches_python() {
|
||||
let signal = Signal::from(Profile::QualityMedium);
|
||||
assert_eq!(signal.wire_value(), 0xFF + 0x40);
|
||||
assert_eq!(
|
||||
Signal::from_wire(0xFF + 0x70),
|
||||
Signal::PreferredProfile(Profile::LatencyUltraLow)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_encodes_single_frame_as_binary_field() {
|
||||
let packet = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00]));
|
||||
let encoded = packet.encode().unwrap();
|
||||
assert_eq!(encoded, vec![0x81, 0x01, 0xC4, 0x02, 0x00, 0x00]);
|
||||
assert_eq!(LxstPacket::decode(&encoded), Ok(packet));
|
||||
}
|
||||
}
|
||||
128
crates/lxst-core/tests/malformed_wire.rs
Normal file
128
crates/lxst-core/tests/malformed_wire.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use lxst_core::{
|
||||
CodecKind, Error, FIELD_FRAMES, FIELD_SIGNALLING, Frame, LxstPacket, RawAudioFrame, RawBitDepth,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rmpv::Value;
|
||||
|
||||
fn encode_value(value: Value) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
rmpv::encode::write_value(&mut out, &value).expect("encode msgpack value");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_msgpack_shapes_fail_deterministically() {
|
||||
let cases = [
|
||||
(
|
||||
Value::Array(vec![]),
|
||||
Error::RootNotMap,
|
||||
"root array is not an LXST packet",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(Value::from(-1), Value::from(0))]),
|
||||
Error::InvalidFieldKey,
|
||||
"negative field key",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(Value::from(999), Value::from(0))]),
|
||||
Error::InvalidFieldKey,
|
||||
"oversized field key",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(
|
||||
Value::from(FIELD_SIGNALLING as u64),
|
||||
Value::String("bad".into()),
|
||||
)]),
|
||||
Error::InvalidSignal,
|
||||
"non-integer signal",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(
|
||||
Value::from(FIELD_FRAMES as u64),
|
||||
Value::Array(vec![Value::from(1)]),
|
||||
)]),
|
||||
Error::InvalidFieldType {
|
||||
field: FIELD_FRAMES,
|
||||
},
|
||||
"non-bytes frame in list",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(
|
||||
Value::from(FIELD_FRAMES as u64),
|
||||
Value::Binary(vec![]),
|
||||
)]),
|
||||
Error::EmptyFrame,
|
||||
"empty media frame",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(
|
||||
Value::from(FIELD_FRAMES as u64),
|
||||
Value::Binary(vec![0x03]),
|
||||
)]),
|
||||
Error::UnknownCodec(0x03),
|
||||
"unknown media codec",
|
||||
),
|
||||
(
|
||||
Value::Map(vec![(
|
||||
Value::from(FIELD_FRAMES as u64),
|
||||
Value::Binary(vec![0xFF]),
|
||||
)]),
|
||||
Error::NonTransmittableCodec(CodecKind::Null),
|
||||
"null media codec is not transmittable",
|
||||
),
|
||||
];
|
||||
|
||||
for (value, expected, name) in cases {
|
||||
assert_eq!(
|
||||
LxstPacket::decode(&encode_value(value)),
|
||||
Err(expected),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_tolerates_trailing_bytes_like_python_umsgpack() {
|
||||
let mut encoded = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00, 0x00]))
|
||||
.encode()
|
||||
.unwrap();
|
||||
encoded.extend_from_slice(b"trailing");
|
||||
|
||||
let decoded = LxstPacket::decode(&encoded).unwrap();
|
||||
assert_eq!(decoded.frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_payload_errors_are_explicit() {
|
||||
assert_eq!(
|
||||
RawAudioFrame::from_payload(&[0x40, 0x00]),
|
||||
Err(Error::InvalidRawSampleBytes {
|
||||
bytes_per_sample: 4,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
RawAudioFrame::new(2, vec![0.0]),
|
||||
Err(Error::InvalidRawSampleCount {
|
||||
samples: 1,
|
||||
channels: 2,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
RawAudioFrame::new(1, vec![0.0])
|
||||
.unwrap()
|
||||
.to_frame(RawBitDepth::Float16)
|
||||
.unwrap()
|
||||
.codec,
|
||||
CodecKind::Raw
|
||||
);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let _ = LxstPacket::decode(&bytes);
|
||||
});
|
||||
prop_assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
161
crates/lxst-core/tests/python_raw_parity.rs
Normal file
161
crates/lxst-core/tests/python_raw_parity.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use lxst_core::{RawAudioFrame, RawBitDepth};
|
||||
use serde_json::Value;
|
||||
|
||||
const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP";
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crate is under rsLXST/crates/lxst-core")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn fixture_script() -> PathBuf {
|
||||
repo_root().join("tools/fixtures/lxst_raw_fixtures.py")
|
||||
}
|
||||
|
||||
fn should_skip() -> bool {
|
||||
std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false)
|
||||
}
|
||||
|
||||
fn python_fixtures() -> Vec<Value> {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Raw parity");
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg(fixture_script())
|
||||
.output()
|
||||
.expect("spawn Python Raw fixture generator");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python Raw fixture generator failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
serde_json::from_slice(&output.stdout).expect("fixture JSON")
|
||||
}
|
||||
|
||||
fn decode_with_python(payload_hex: &str) -> Value {
|
||||
let output = Command::new("python3")
|
||||
.arg(fixture_script())
|
||||
.arg("--decode-hex")
|
||||
.arg(payload_hex)
|
||||
.output()
|
||||
.expect("spawn Python Raw fixture decoder");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python Raw fixture decoder failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
serde_json::from_slice(&output.stdout).expect("decode JSON")
|
||||
}
|
||||
|
||||
fn bit_depth(value: &Value) -> RawBitDepth {
|
||||
match value["bitdepth_header"].as_u64().expect("bitdepth header") {
|
||||
0 => RawBitDepth::Float16,
|
||||
1 => RawBitDepth::Float32,
|
||||
2 => RawBitDepth::Float64,
|
||||
3 => RawBitDepth::Float128,
|
||||
other => panic!("unknown bitdepth header {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn samples(value: &Value) -> Vec<f32> {
|
||||
value["samples"]
|
||||
.as_array()
|
||||
.expect("samples")
|
||||
.iter()
|
||||
.map(|sample| sample.as_f64().expect("sample") as f32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_samples_close(actual: &[f32], expected: &[f32], name: &str) {
|
||||
assert_eq!(actual.len(), expected.len(), "{name}");
|
||||
for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
let delta = (actual - expected).abs();
|
||||
assert!(
|
||||
delta <= 0.000_976_562_5,
|
||||
"{name} sample {index}: actual {actual} expected {expected} delta {delta}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_decodes_python_raw_payloads() {
|
||||
for fixture in python_fixtures() {
|
||||
let name = fixture["name"].as_str().expect("fixture name");
|
||||
let payload_hex = fixture["payload_hex"].as_str().expect("payload hex");
|
||||
let payload = hex::decode(payload_hex).expect("payload hex decodes");
|
||||
let raw = RawAudioFrame::from_payload(&payload)
|
||||
.unwrap_or_else(|e| panic!("failed to decode fixture {name}: {e}"));
|
||||
let depth = bit_depth(&fixture);
|
||||
let expected_samples = samples(&fixture);
|
||||
|
||||
assert_eq!(
|
||||
raw.channels,
|
||||
fixture["channels"].as_u64().expect("channels") as u8,
|
||||
"{name}",
|
||||
);
|
||||
assert_eq!(
|
||||
raw.sample_frames(),
|
||||
fixture["sample_frames"].as_u64().expect("sample frames") as usize,
|
||||
"{name}",
|
||||
);
|
||||
assert_samples_close(&raw.samples, &expected_samples, name);
|
||||
assert_eq!(
|
||||
hex::encode(raw.to_payload(depth).unwrap()),
|
||||
payload_hex,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_decodes_rust_raw_payloads() {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Raw parity");
|
||||
return;
|
||||
}
|
||||
|
||||
let cases = [
|
||||
(
|
||||
RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap(),
|
||||
RawBitDepth::Float16,
|
||||
),
|
||||
(
|
||||
RawAudioFrame::new(1, vec![0.25, -1.5, 2.0]).unwrap(),
|
||||
RawBitDepth::Float32,
|
||||
),
|
||||
(
|
||||
RawAudioFrame::new(3, vec![0.0, 0.125, -0.5, 1.0, -1.0, 0.25]).unwrap(),
|
||||
RawBitDepth::Float64,
|
||||
),
|
||||
];
|
||||
|
||||
for (raw, depth) in cases {
|
||||
let payload_hex = hex::encode(raw.to_payload(depth).expect("encode Raw payload"));
|
||||
let decoded = decode_with_python(&payload_hex);
|
||||
|
||||
assert_eq!(
|
||||
decoded["channels"].as_u64().expect("channels") as u8,
|
||||
raw.channels
|
||||
);
|
||||
assert_eq!(
|
||||
decoded["sample_frames"].as_u64().expect("sample frames") as usize,
|
||||
raw.sample_frames(),
|
||||
);
|
||||
assert_eq!(bit_depth(&decoded), depth);
|
||||
assert_samples_close(&samples(&decoded), &raw.samples, &payload_hex);
|
||||
}
|
||||
}
|
||||
150
crates/lxst-core/tests/python_wire_parity.rs
Normal file
150
crates/lxst-core/tests/python_wire_parity.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use lxst_core::{CodecKind, Frame, LxstPacket, Profile, Signal, SignallingStatus};
|
||||
use serde_json::Value;
|
||||
|
||||
const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP";
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crate is under rsLXST/crates/lxst-core")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn fixture_script() -> PathBuf {
|
||||
repo_root().join("tools/fixtures/lxst_wire_fixtures.py")
|
||||
}
|
||||
|
||||
fn should_skip() -> bool {
|
||||
std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false)
|
||||
}
|
||||
|
||||
fn python_fixtures() -> Vec<Value> {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST wire parity");
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg(fixture_script())
|
||||
.output()
|
||||
.expect("spawn Python fixture generator");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python fixture generator failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
serde_json::from_slice(&output.stdout).expect("fixture JSON")
|
||||
}
|
||||
|
||||
fn decode_with_python(packet_hex: &str) -> Value {
|
||||
let output = Command::new("python3")
|
||||
.arg(fixture_script())
|
||||
.arg("--decode-hex")
|
||||
.arg(packet_hex)
|
||||
.output()
|
||||
.expect("spawn Python fixture decoder");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python fixture decoder failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
serde_json::from_slice(&output.stdout).expect("decode JSON")
|
||||
}
|
||||
|
||||
fn signal_values(packet: &LxstPacket) -> Vec<u32> {
|
||||
packet.signals.iter().map(|s| s.wire_value()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_decodes_python_lxst_packets() {
|
||||
for fixture in python_fixtures() {
|
||||
let name = fixture["name"].as_str().expect("fixture name");
|
||||
let packet_hex = fixture["packet_hex"].as_str().expect("packet hex");
|
||||
let packet_bytes = hex::decode(packet_hex).expect("packet hex decodes");
|
||||
let packet = LxstPacket::decode(&packet_bytes)
|
||||
.unwrap_or_else(|e| panic!("failed to decode fixture {name}: {e}"));
|
||||
|
||||
let expected_signals: Vec<u32> = fixture["signals"]
|
||||
.as_array()
|
||||
.expect("signals array")
|
||||
.iter()
|
||||
.map(|v| v.as_u64().expect("signal int") as u32)
|
||||
.collect();
|
||||
assert_eq!(signal_values(&packet), expected_signals, "{name}");
|
||||
|
||||
let expected_frames = fixture["frames"].as_array().expect("frames array");
|
||||
assert_eq!(packet.frames.len(), expected_frames.len(), "{name}");
|
||||
|
||||
for (frame, expected) in packet.frames.iter().zip(expected_frames) {
|
||||
let codec = expected["codec"].as_u64().expect("codec") as u8;
|
||||
assert_eq!(frame.codec.wire_id(), codec, "{name}");
|
||||
let payload = expected["payload_hex"].as_str().expect("payload hex");
|
||||
assert_eq!(hex::encode(&frame.payload), payload, "{name}");
|
||||
}
|
||||
|
||||
if fixture["canonical"].as_bool().unwrap_or(false) {
|
||||
assert_eq!(hex::encode(packet.encode().unwrap()), packet_hex, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_decodes_rust_lxst_packets() {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST wire parity");
|
||||
return;
|
||||
}
|
||||
|
||||
let packets = [
|
||||
LxstPacket::signalling([
|
||||
Signal::from(SignallingStatus::Available),
|
||||
Signal::from(Profile::QualityMedium),
|
||||
]),
|
||||
LxstPacket::frame(Frame::new(CodecKind::Raw, [0x41, 0x01, 0x02, 0x03, 0x04])),
|
||||
LxstPacket {
|
||||
signals: vec![
|
||||
Signal::from(SignallingStatus::Established),
|
||||
Signal::from(Profile::LatencyUltraLow),
|
||||
],
|
||||
frames: vec![
|
||||
Frame::new(CodecKind::Raw, [0x41, 0x01, 0x02, 0x03, 0x04]),
|
||||
Frame::new(CodecKind::Codec2, [0x04, 0xAA, 0xBB]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for packet in packets {
|
||||
let packet_hex = hex::encode(packet.encode().expect("encode packet"));
|
||||
let decoded = decode_with_python(&packet_hex);
|
||||
let signals: Vec<u32> = decoded["signals"]
|
||||
.as_array()
|
||||
.expect("signals")
|
||||
.iter()
|
||||
.map(|v| v.as_u64().expect("signal") as u32)
|
||||
.collect();
|
||||
assert_eq!(signals, signal_values(&packet));
|
||||
|
||||
let frames = decoded["frames"].as_array().expect("frames");
|
||||
assert_eq!(frames.len(), packet.frames.len());
|
||||
for (frame, expected) in packet.frames.iter().zip(frames) {
|
||||
assert_eq!(
|
||||
expected["codec"].as_u64().expect("codec") as u8,
|
||||
frame.codec.wire_id()
|
||||
);
|
||||
assert_eq!(
|
||||
expected["payload_hex"].as_str().expect("payload"),
|
||||
hex::encode(&frame.payload)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
crates/lxst-core/tests/reference_snapshot.rs
Normal file
82
crates/lxst-core/tests/reference_snapshot.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP";
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crate is under rsLXST/crates/lxst-core")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_lxst_reference_snapshot_is_available() {
|
||||
if std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false) {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST reference snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
let script = repo_root().join("tools/reference/lxst_reference_snapshot.py");
|
||||
let output = Command::new("python3")
|
||||
.arg(script)
|
||||
.output()
|
||||
.expect("spawn Python LXST reference snapshot");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python LXST reference snapshot failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let snapshot: Value = serde_json::from_slice(&output.stdout).expect("snapshot JSON");
|
||||
let lock_path = repo_root().join("tools/reference/lxst_reference_lock.json");
|
||||
let locked: Value =
|
||||
serde_json::from_slice(&fs::read(lock_path).expect("read LXST reference lock"))
|
||||
.expect("reference lock JSON");
|
||||
|
||||
assert_eq!(
|
||||
snapshot["remote"].as_str(),
|
||||
Some("https://github.com/markqvist/LXST.git")
|
||||
);
|
||||
assert_eq!(snapshot["dirty"].as_bool(), Some(false));
|
||||
assert_eq!(snapshot["missing_files"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(
|
||||
snapshot["remote"], locked["remote"],
|
||||
"LXST upstream remote changed"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot["commit"], locked["commit"],
|
||||
"LXST source-of-truth commit changed; review upstream diff and update tools/reference/lxst_reference_lock.json intentionally"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot["package_version"], locked["package_version"],
|
||||
"LXST package version changed"
|
||||
);
|
||||
|
||||
let files = snapshot["files"].as_object().expect("files object");
|
||||
let locked_files = locked["files"].as_object().expect("locked files object");
|
||||
for rel in [
|
||||
"LXST/_version.py",
|
||||
"LXST/Network.py",
|
||||
"LXST/Primitives/Telephony.py",
|
||||
"LXST/Codecs/__init__.py",
|
||||
"LXST/Codecs/Raw.py",
|
||||
"LXST/Codecs/Opus.py",
|
||||
"LXST/Codecs/Codec2.py",
|
||||
] {
|
||||
let entry = files.get(rel).unwrap_or_else(|| panic!("missing {rel}"));
|
||||
let locked_entry = locked_files
|
||||
.get(rel)
|
||||
.unwrap_or_else(|| panic!("missing locked {rel}"));
|
||||
let sha = entry["sha256"].as_str().expect("sha256");
|
||||
assert_eq!(sha.len(), 64, "{rel}");
|
||||
assert!(entry["bytes"].as_u64().unwrap_or(0) > 0, "{rel}");
|
||||
assert_eq!(entry, locked_entry, "LXST reference file changed: {rel}");
|
||||
}
|
||||
}
|
||||
18
crates/lxst-rns/Cargo.toml
Normal file
18
crates/lxst-rns/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "lxst-rns"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes.workspace = true
|
||||
lxst-core.workspace = true
|
||||
rns-link.workspace = true
|
||||
rns-transport.workspace = true
|
||||
rns-wire.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rns-crypto.workspace = true
|
||||
659
crates/lxst-rns/src/lib.rs
Normal file
659
crates/lxst-rns/src/lib.rs
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
//! Reticulum transport boundary for LXST.
|
||||
//!
|
||||
//! This crate is intentionally small: it turns already-encoded LXST packets into
|
||||
//! Reticulum link data packets and leaves call state, audio, and codec work to
|
||||
//! higher layers.
|
||||
|
||||
use bytes::Bytes;
|
||||
use lxst_core::{
|
||||
DropPolicy, Frame, FramePacketizer, FrameStreamEvent, FrameStreamState, JitterBuffer,
|
||||
JitterPush, JitterStats, LxstPacket, OpusDecoderState, RawAudioFrame, RawBitDepth,
|
||||
};
|
||||
use rns_link::link::{Link, LinkState};
|
||||
use rns_transport::messages::{OutboundRequest, TransportMessage};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("LXST core error: {0}")]
|
||||
Lxst(#[from] lxst_core::Error),
|
||||
#[error("LXST stream error: {0}")]
|
||||
Stream(#[from] lxst_core::StreamError),
|
||||
#[error("LXST Opus codec error: {0}")]
|
||||
Opus(#[from] lxst_core::OpusCodecError),
|
||||
#[error("Reticulum link is not active: {0:?}")]
|
||||
LinkNotActive(LinkState),
|
||||
#[error("LXST payload length {payload_len} exceeds link MDU {mdu}")]
|
||||
PayloadExceedsMdu { payload_len: usize, mdu: usize },
|
||||
#[error("Reticulum link encryption failed: {0}")]
|
||||
LinkEncrypt(String),
|
||||
#[error("Reticulum outbound queue is closed")]
|
||||
TransportClosed,
|
||||
#[error("Reticulum outbound queue is full")]
|
||||
TransportFull,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackedLinkPacket {
|
||||
pub raw: Bytes,
|
||||
pub packet_hash: [u8; 32],
|
||||
pub destination_hash: [u8; 16],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InboundLxstPacket {
|
||||
pub link_id: [u8; 16],
|
||||
pub packet: LxstPacket,
|
||||
pub frame_events: Vec<FrameStreamEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MediaIngressResult {
|
||||
pub inbound: InboundLxstPacket,
|
||||
pub jitter_pushes: Vec<JitterPush<Frame>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LxstLinkIngress {
|
||||
frame_stream: FrameStreamState,
|
||||
}
|
||||
|
||||
impl LxstLinkIngress {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub const fn current_codec(&self) -> Option<lxst_core::CodecKind> {
|
||||
self.frame_stream.current_codec()
|
||||
}
|
||||
|
||||
/// Decode decrypted plaintext from `LinkManager::set_link_packet_channel`.
|
||||
pub fn accept_plaintext(
|
||||
&mut self,
|
||||
link_id: [u8; 16],
|
||||
payload: &[u8],
|
||||
) -> Result<InboundLxstPacket, Error> {
|
||||
let packet = LxstPacket::decode(payload)?;
|
||||
let frame_events = self.frame_stream.accept_packet(packet.clone());
|
||||
|
||||
Ok(InboundLxstPacket {
|
||||
link_id,
|
||||
packet,
|
||||
frame_events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct LxstMediaEgress {
|
||||
packetizer: FramePacketizer,
|
||||
}
|
||||
|
||||
impl LxstMediaEgress {
|
||||
pub const PYTHON_COMPATIBLE: Self = Self {
|
||||
packetizer: FramePacketizer::PYTHON_COMPATIBLE,
|
||||
};
|
||||
|
||||
pub fn new(frames_per_packet: usize) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
packetizer: FramePacketizer::new(frames_per_packet)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn packetizer(&self) -> FramePacketizer {
|
||||
self.packetizer
|
||||
}
|
||||
|
||||
pub fn pack_frames(
|
||||
self,
|
||||
link: &Link,
|
||||
frames: impl IntoIterator<Item = Frame>,
|
||||
) -> Result<Vec<PackedLinkPacket>, Error> {
|
||||
self.packetizer
|
||||
.packetize(frames)
|
||||
.iter()
|
||||
.map(|packet| pack_lxst_link_packet(link, packet))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pack_raw_frames(
|
||||
self,
|
||||
link: &Link,
|
||||
bit_depth: RawBitDepth,
|
||||
frames: impl IntoIterator<Item = RawAudioFrame>,
|
||||
) -> Result<Vec<PackedLinkPacket>, Error> {
|
||||
let frames = frames
|
||||
.into_iter()
|
||||
.map(|frame| frame.to_frame(bit_depth))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
self.pack_frames(link, frames)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LxstMediaIngress {
|
||||
link_ingress: LxstLinkIngress,
|
||||
jitter: JitterBuffer<Frame>,
|
||||
}
|
||||
|
||||
impl LxstMediaIngress {
|
||||
pub fn new(jitter_capacity: usize, drop_policy: DropPolicy) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
link_ingress: LxstLinkIngress::new(),
|
||||
jitter: JitterBuffer::new(jitter_capacity, drop_policy)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn current_codec(&self) -> Option<lxst_core::CodecKind> {
|
||||
self.link_ingress.current_codec()
|
||||
}
|
||||
|
||||
pub const fn jitter_stats(&self) -> JitterStats {
|
||||
self.jitter.stats()
|
||||
}
|
||||
|
||||
pub fn jitter_len(&self) -> usize {
|
||||
self.jitter.len()
|
||||
}
|
||||
|
||||
pub fn accept_plaintext(
|
||||
&mut self,
|
||||
link_id: [u8; 16],
|
||||
payload: &[u8],
|
||||
) -> Result<MediaIngressResult, Error> {
|
||||
let inbound = self.link_ingress.accept_plaintext(link_id, payload)?;
|
||||
let mut jitter_pushes = Vec::new();
|
||||
|
||||
for event in &inbound.frame_events {
|
||||
if let FrameStreamEvent::Frame(frame) = event {
|
||||
jitter_pushes.push(self.jitter.push(frame.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MediaIngressResult {
|
||||
inbound,
|
||||
jitter_pushes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pop_frame(&mut self) -> Option<Frame> {
|
||||
self.jitter.pop()
|
||||
}
|
||||
|
||||
pub fn pop_raw_frame(&mut self) -> Result<Option<RawAudioFrame>, Error> {
|
||||
self.pop_frame()
|
||||
.map(|frame| RawAudioFrame::from_frame(&frame).map_err(Error::from))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn pop_opus_frame(
|
||||
&mut self,
|
||||
decoder: &mut OpusDecoderState,
|
||||
) -> Result<Option<RawAudioFrame>, Error> {
|
||||
self.pop_frame()
|
||||
.map(|frame| decoder.decode_frame(&frame).map_err(Error::from))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode and encrypt an LXST packet for transmission over an active Reticulum
|
||||
/// link as a no-receipt data packet.
|
||||
pub fn pack_lxst_link_packet(link: &Link, packet: &LxstPacket) -> Result<PackedLinkPacket, Error> {
|
||||
let payload = packet.encode()?;
|
||||
pack_link_payload(link, &payload)
|
||||
}
|
||||
|
||||
/// Encrypt application payload bytes and wrap them in a Reticulum LINK/DATA
|
||||
/// packet with context NONE, matching Python `RNS.Packet(link, data,
|
||||
/// create_receipt=False)`.
|
||||
pub fn pack_link_payload(link: &Link, payload: &[u8]) -> Result<PackedLinkPacket, Error> {
|
||||
if link.state != LinkState::Active {
|
||||
return Err(Error::LinkNotActive(link.state));
|
||||
}
|
||||
|
||||
if payload.len() > link.mdu {
|
||||
return Err(Error::PayloadExceedsMdu {
|
||||
payload_len: payload.len(),
|
||||
mdu: link.mdu,
|
||||
});
|
||||
}
|
||||
|
||||
let encrypted = link
|
||||
.encrypt(payload)
|
||||
.map_err(|e| Error::LinkEncrypt(e.to_string()))?;
|
||||
let header = rns_wire::header::PacketHeader {
|
||||
flags: rns_wire::flags::PacketFlags {
|
||||
header_type: rns_wire::flags::HeaderType::Header1,
|
||||
context_flag: false,
|
||||
transport_type: rns_wire::flags::TransportType::Broadcast,
|
||||
destination_type: rns_wire::flags::DestinationType::Link,
|
||||
packet_type: rns_wire::flags::PacketType::Data,
|
||||
},
|
||||
hops: 0,
|
||||
transport_id: None,
|
||||
destination_hash: link.link_id,
|
||||
context: rns_wire::context::PacketContext::None,
|
||||
};
|
||||
|
||||
let mut raw = header.pack();
|
||||
raw.extend_from_slice(&encrypted);
|
||||
let packet_hash = rns_wire::hash::packet_hash(&raw, rns_wire::flags::HeaderType::Header1);
|
||||
|
||||
Ok(PackedLinkPacket {
|
||||
raw: Bytes::from(raw),
|
||||
packet_hash,
|
||||
destination_hash: link.link_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pack and queue an already-encoded LXST payload for Reticulum transmission.
|
||||
pub fn queue_link_payload(
|
||||
transport_tx: &mpsc::Sender<TransportMessage>,
|
||||
link: &Link,
|
||||
payload: &[u8],
|
||||
) -> Result<PackedLinkPacket, Error> {
|
||||
let packet = pack_link_payload(link, payload)?;
|
||||
queue_packed_link_packet(transport_tx, packet)
|
||||
}
|
||||
|
||||
/// Pack and queue a structured LXST packet for Reticulum transmission.
|
||||
pub fn queue_lxst_link_packet(
|
||||
transport_tx: &mpsc::Sender<TransportMessage>,
|
||||
link: &Link,
|
||||
packet: &LxstPacket,
|
||||
) -> Result<PackedLinkPacket, Error> {
|
||||
let packet = pack_lxst_link_packet(link, packet)?;
|
||||
queue_packed_link_packet(transport_tx, packet)
|
||||
}
|
||||
|
||||
fn queue_packed_link_packet(
|
||||
transport_tx: &mpsc::Sender<TransportMessage>,
|
||||
packet: PackedLinkPacket,
|
||||
) -> Result<PackedLinkPacket, Error> {
|
||||
transport_tx
|
||||
.try_send(TransportMessage::Outbound(OutboundRequest {
|
||||
raw: packet.raw.clone(),
|
||||
destination_hash: packet.destination_hash,
|
||||
}))
|
||||
.map_err(|e| match e {
|
||||
mpsc::error::TrySendError::Full(_) => Error::TransportFull,
|
||||
mpsc::error::TrySendError::Closed(_) => Error::TransportClosed,
|
||||
})?;
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lxst_core::{
|
||||
CodecKind, DropPolicy, Frame, FrameStreamEvent, OpusDecoderState, OpusEncoderState,
|
||||
Profile, Signal, SignallingStatus, SyntheticSource, SyntheticSourceKind,
|
||||
};
|
||||
use rns_crypto::ed25519::Ed25519PrivateKey;
|
||||
|
||||
fn active_link_pair() -> (Link, Link) {
|
||||
let dest_hash = [0xAA; 16];
|
||||
let identity_key = Ed25519PrivateKey::generate();
|
||||
let identity_pub = identity_key.public_key();
|
||||
|
||||
let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1);
|
||||
let (mut responder, proof_data) =
|
||||
Link::new_responder(&request_data, &identity_key, dest_hash, 1).unwrap();
|
||||
|
||||
let rtt_data = initiator
|
||||
.validate_proof(&proof_data, &identity_pub, &identity_pub.to_bytes())
|
||||
.unwrap();
|
||||
responder.receive_rtt_packet(&rtt_data).unwrap();
|
||||
|
||||
(initiator, responder)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_lxst_packet_decrypts_on_peer_link() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let lxst = LxstPacket::signalling([
|
||||
Signal::from(SignallingStatus::Available),
|
||||
Signal::from(Profile::QualityMedium),
|
||||
]);
|
||||
|
||||
let packet = pack_lxst_link_packet(&initiator, &lxst).unwrap();
|
||||
let (header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
header.flags.destination_type,
|
||||
rns_wire::flags::DestinationType::Link
|
||||
);
|
||||
assert_eq!(header.flags.packet_type, rns_wire::flags::PacketType::Data);
|
||||
assert_eq!(header.context, rns_wire::context::PacketContext::None);
|
||||
assert_eq!(header.destination_hash, initiator.link_id);
|
||||
|
||||
let decrypted = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
assert_eq!(LxstPacket::decode(&decrypted).unwrap(), lxst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_sends_outbound_transport_message() {
|
||||
let (initiator, _responder) = active_link_pair();
|
||||
let payload = LxstPacket::frame(Frame::new(CodecKind::Raw, [0x00, 0x11, 0x22]))
|
||||
.encode()
|
||||
.unwrap();
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
|
||||
let packet = queue_link_payload(&tx, &initiator, &payload).unwrap();
|
||||
let sent = rx.try_recv().unwrap();
|
||||
let TransportMessage::Outbound(outbound) = sent else {
|
||||
panic!("expected outbound transport message");
|
||||
};
|
||||
|
||||
assert_eq!(outbound.raw, packet.raw);
|
||||
assert_eq!(outbound.destination_hash, initiator.link_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_links_do_not_pack_media_packets() {
|
||||
let (link, _request_data) = Link::new_initiator([0xBB; 16], 1);
|
||||
let packet = LxstPacket::signalling([Signal::from(SignallingStatus::Calling)]);
|
||||
|
||||
assert!(matches!(
|
||||
pack_lxst_link_packet(&link, &packet),
|
||||
Err(Error::LinkNotActive(LinkState::Pending))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payloads_must_fit_link_mdu() {
|
||||
let (initiator, _responder) = active_link_pair();
|
||||
let payload = vec![0u8; initiator.mdu + 1];
|
||||
|
||||
assert!(matches!(
|
||||
pack_link_payload(&initiator, &payload),
|
||||
Err(Error::PayloadExceedsMdu { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_decodes_decrypted_link_payloads_and_tracks_codecs() {
|
||||
let link_id = [0x44; 16];
|
||||
let mut ingress = LxstLinkIngress::new();
|
||||
let packet = LxstPacket {
|
||||
signals: vec![Signal::from(SignallingStatus::Established)],
|
||||
frames: vec![
|
||||
Frame::new(CodecKind::Raw, [0x00, 0x11]),
|
||||
Frame::new(CodecKind::Opus, [0xF8, 0xFF, 0xFE]),
|
||||
],
|
||||
};
|
||||
|
||||
let inbound = ingress
|
||||
.accept_plaintext(link_id, &packet.encode().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inbound.link_id, link_id);
|
||||
assert_eq!(inbound.packet.signals, packet.signals);
|
||||
assert_eq!(
|
||||
inbound.frame_events,
|
||||
vec![
|
||||
FrameStreamEvent::CodecChanged {
|
||||
from: None,
|
||||
to: CodecKind::Raw,
|
||||
},
|
||||
FrameStreamEvent::Frame(Frame::new(CodecKind::Raw, [0x00, 0x11])),
|
||||
FrameStreamEvent::CodecChanged {
|
||||
from: Some(CodecKind::Raw),
|
||||
to: CodecKind::Opus,
|
||||
},
|
||||
FrameStreamEvent::Frame(Frame::new(CodecKind::Opus, [0xF8, 0xFF, 0xFE])),
|
||||
]
|
||||
);
|
||||
assert_eq!(ingress.current_codec(), Some(CodecKind::Opus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_rejects_malformed_plaintext() {
|
||||
let mut ingress = LxstLinkIngress::new();
|
||||
assert!(matches!(
|
||||
ingress.accept_plaintext([0x55; 16], b"not msgpack"),
|
||||
Err(Error::Lxst(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_egress_and_ingress_roundtrip_raw_frames_over_link_crypto() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let raw_frames = vec![
|
||||
RawAudioFrame::new(2, vec![0.0, 0.5, -0.25, 1.0]).unwrap(),
|
||||
RawAudioFrame::new(2, vec![0.125, -0.125, 0.75, -0.75]).unwrap(),
|
||||
];
|
||||
|
||||
let packed = LxstMediaEgress::PYTHON_COMPATIBLE
|
||||
.pack_raw_frames(&initiator, RawBitDepth::Float16, raw_frames.clone())
|
||||
.unwrap();
|
||||
assert_eq!(packed.len(), raw_frames.len());
|
||||
|
||||
let mut ingress = LxstMediaIngress::new(4, DropPolicy::DropOldest).unwrap();
|
||||
for packet in packed {
|
||||
let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw)
|
||||
.expect("packed Reticulum header");
|
||||
let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
let result = ingress
|
||||
.accept_plaintext(responder.link_id, &plaintext)
|
||||
.expect("accept LXST payload");
|
||||
assert_eq!(result.jitter_pushes, vec![JitterPush::Accepted]);
|
||||
}
|
||||
|
||||
assert_eq!(ingress.current_codec(), Some(CodecKind::Raw));
|
||||
assert_eq!(ingress.jitter_len(), raw_frames.len());
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[0].clone())
|
||||
);
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[1].clone())
|
||||
);
|
||||
assert_eq!(ingress.pop_raw_frame().unwrap(), None);
|
||||
assert_eq!(
|
||||
ingress.jitter_stats(),
|
||||
JitterStats {
|
||||
pushed: 2,
|
||||
popped: 2,
|
||||
dropped_oldest: 0,
|
||||
dropped_newest: 0,
|
||||
underruns: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_egress_can_batch_frames_for_rust_peers() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let raw_frames = vec![
|
||||
RawAudioFrame::new(1, vec![0.0]).unwrap(),
|
||||
RawAudioFrame::new(1, vec![0.5]).unwrap(),
|
||||
RawAudioFrame::new(1, vec![1.0]).unwrap(),
|
||||
];
|
||||
|
||||
let packed = LxstMediaEgress::new(2)
|
||||
.unwrap()
|
||||
.pack_raw_frames(&initiator, RawBitDepth::Float32, raw_frames.clone())
|
||||
.unwrap();
|
||||
assert_eq!(packed.len(), 2);
|
||||
|
||||
let mut ingress = LxstMediaIngress::new(8, DropPolicy::DropNewest).unwrap();
|
||||
for packet in packed {
|
||||
let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw)
|
||||
.expect("packed Reticulum header");
|
||||
let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
ingress
|
||||
.accept_plaintext(responder.link_id, &plaintext)
|
||||
.expect("accept LXST payload");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[0].clone())
|
||||
);
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[1].clone())
|
||||
);
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[2].clone())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_ingress_jitter_policy_drops_oldest_under_pressure() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let raw_frames = vec![
|
||||
RawAudioFrame::new(1, vec![0.0]).unwrap(),
|
||||
RawAudioFrame::new(1, vec![1.0]).unwrap(),
|
||||
];
|
||||
let packed = LxstMediaEgress::PYTHON_COMPATIBLE
|
||||
.pack_raw_frames(&initiator, RawBitDepth::Float16, raw_frames.clone())
|
||||
.unwrap();
|
||||
let mut ingress = LxstMediaIngress::new(1, DropPolicy::DropOldest).unwrap();
|
||||
|
||||
let mut push_results = Vec::new();
|
||||
for packet in packed {
|
||||
let (_header, data_offset) = rns_wire::header::PacketHeader::unpack(&packet.raw)
|
||||
.expect("packed Reticulum header");
|
||||
let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
let result = ingress
|
||||
.accept_plaintext(responder.link_id, &plaintext)
|
||||
.unwrap();
|
||||
push_results.extend(result.jitter_pushes);
|
||||
}
|
||||
|
||||
assert!(matches!(push_results[0], JitterPush::Accepted));
|
||||
assert!(matches!(push_results[1], JitterPush::DroppedOldest(_)));
|
||||
assert_eq!(
|
||||
ingress.pop_raw_frame().unwrap(),
|
||||
Some(raw_frames[1].clone())
|
||||
);
|
||||
assert_eq!(
|
||||
ingress.jitter_stats(),
|
||||
JitterStats {
|
||||
pushed: 2,
|
||||
popped: 1,
|
||||
dropped_oldest: 1,
|
||||
dropped_newest: 0,
|
||||
underruns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_raw_source_survives_link_media_flow() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let mut source = SyntheticSource::new(
|
||||
2,
|
||||
48_000,
|
||||
4,
|
||||
SyntheticSourceKind::Ramp {
|
||||
start: -0.5,
|
||||
step: 0.125,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let expected = (0..12)
|
||||
.map(|_| source.next_raw_frame().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let packed = LxstMediaEgress::PYTHON_COMPATIBLE
|
||||
.pack_raw_frames(&initiator, RawBitDepth::Float32, expected.clone())
|
||||
.unwrap();
|
||||
let mut ingress = LxstMediaIngress::new(16, DropPolicy::DropOldest).unwrap();
|
||||
|
||||
for packet in packed {
|
||||
let (_header, data_offset) =
|
||||
rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap();
|
||||
let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
ingress
|
||||
.accept_plaintext(responder.link_id, &plaintext)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut actual = Vec::new();
|
||||
while let Some(frame) = ingress.pop_raw_frame().unwrap() {
|
||||
actual.push(frame);
|
||||
}
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
ingress.jitter_stats(),
|
||||
JitterStats {
|
||||
pushed: 12,
|
||||
popped: 12,
|
||||
dropped_oldest: 0,
|
||||
dropped_newest: 0,
|
||||
underruns: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sustained_opus_source_survives_link_media_flow() {
|
||||
let (initiator, responder) = active_link_pair();
|
||||
let profile = Profile::QualityHigh;
|
||||
let mut source = SyntheticSource::new(
|
||||
profile.channels(),
|
||||
profile.sample_rate_hz(),
|
||||
profile.sample_frames_per_packet(),
|
||||
SyntheticSourceKind::Sine {
|
||||
frequency_hz: 440.0,
|
||||
amplitude: 0.25,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let raw_frames = (0..12)
|
||||
.map(|_| source.next_raw_frame().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let mut encoder = OpusEncoderState::new(profile).unwrap();
|
||||
let opus_frames = raw_frames
|
||||
.iter()
|
||||
.map(|frame| encoder.encode_frame(frame).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let packed = LxstMediaEgress::PYTHON_COMPATIBLE
|
||||
.pack_frames(&initiator, opus_frames)
|
||||
.unwrap();
|
||||
assert_eq!(packed.len(), raw_frames.len());
|
||||
|
||||
let mut ingress = LxstMediaIngress::new(16, DropPolicy::DropOldest).unwrap();
|
||||
for packet in packed {
|
||||
let (_header, data_offset) =
|
||||
rns_wire::header::PacketHeader::unpack(&packet.raw).unwrap();
|
||||
let plaintext = responder.decrypt(&packet.raw[data_offset..]).unwrap();
|
||||
let result = ingress
|
||||
.accept_plaintext(responder.link_id, &plaintext)
|
||||
.unwrap();
|
||||
assert_eq!(result.jitter_pushes, vec![JitterPush::Accepted]);
|
||||
}
|
||||
|
||||
let mut decoder = OpusDecoderState::new(profile).unwrap();
|
||||
let mut decoded_frames = Vec::new();
|
||||
while let Some(frame) = ingress.pop_opus_frame(&mut decoder).unwrap() {
|
||||
decoded_frames.push(frame);
|
||||
}
|
||||
|
||||
assert_eq!(decoded_frames.len(), raw_frames.len());
|
||||
assert_eq!(ingress.current_codec(), Some(CodecKind::Opus));
|
||||
for frame in decoded_frames {
|
||||
assert_eq!(frame.channels, profile.channels());
|
||||
assert_eq!(frame.sample_frames(), profile.sample_frames_per_packet());
|
||||
}
|
||||
assert_eq!(
|
||||
ingress.jitter_stats(),
|
||||
JitterStats {
|
||||
pushed: 12,
|
||||
popped: 12,
|
||||
dropped_oldest: 0,
|
||||
dropped_newest: 0,
|
||||
underruns: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
25
crates/lxst-telephony/Cargo.toml
Normal file
25
crates/lxst-telephony/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "lxst-telephony"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes.workspace = true
|
||||
lxst-core.workspace = true
|
||||
lxst-rns.workspace = true
|
||||
rns-crypto.workspace = true
|
||||
rns-identity.workspace = true
|
||||
rns-link.workspace = true
|
||||
rns-runtime.workspace = true
|
||||
rns-transport.workspace = true
|
||||
rns-wire.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hex.workspace = true
|
||||
rns-interface.workspace = true
|
||||
serial_test.workspace = true
|
||||
serde_json.workspace = true
|
||||
2863
crates/lxst-telephony/src/lib.rs
Normal file
2863
crates/lxst-telephony/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
3147
crates/lxst-telephony/src/tests.rs
Normal file
3147
crates/lxst-telephony/src/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
69
crates/lxst-telephony/tests/python_destination_parity.rs
Normal file
69
crates/lxst-telephony/tests/python_destination_parity.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use lxst_core::TELEPHONY_DESTINATION_NAME;
|
||||
use lxst_telephony::telephony_destination_hash;
|
||||
use serde_json::Value;
|
||||
|
||||
const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP";
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crate is under rsLXST/crates/lxst-telephony")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn fixture_script() -> PathBuf {
|
||||
repo_root().join("tools/fixtures/lxst_destination_fixtures.py")
|
||||
}
|
||||
|
||||
fn should_skip() -> bool {
|
||||
std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_destination_hash_matches_python_rns_lxst_telephony() {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python RNS destination parity");
|
||||
return;
|
||||
}
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg(fixture_script())
|
||||
.output()
|
||||
.expect("spawn Python destination fixture generator");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python destination fixture generator failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let fixture: Value = serde_json::from_slice(&output.stdout).expect("fixture JSON");
|
||||
assert_eq!(
|
||||
fixture["expanded_name"].as_str().expect("expanded name"),
|
||||
TELEPHONY_DESTINATION_NAME
|
||||
);
|
||||
assert_eq!(
|
||||
fixture["destination_hash"]
|
||||
.as_str()
|
||||
.expect("destination hash"),
|
||||
fixture["hash_from_name"].as_str().expect("hash from name")
|
||||
);
|
||||
|
||||
let identity_hash = hex::decode(fixture["identity_hash"].as_str().expect("identity hash"))
|
||||
.expect("identity hash hex");
|
||||
let identity_hash: [u8; 16] = identity_hash
|
||||
.try_into()
|
||||
.expect("Python RNS identity hashes are 16 bytes");
|
||||
|
||||
assert_eq!(
|
||||
hex::encode(telephony_destination_hash(&identity_hash)),
|
||||
fixture["destination_hash"]
|
||||
.as_str()
|
||||
.expect("destination hash")
|
||||
);
|
||||
}
|
||||
110
crates/lxst-telephony/tests/python_telephone_helper.rs
Normal file
110
crates/lxst-telephony/tests/python_telephone_helper.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use lxst_telephony::telephony_destination_hash;
|
||||
use serde_json::Value;
|
||||
|
||||
const SKIP_ENV: &str = "SKIP_PYTHON_LXST_INTEROP";
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crate is under rsLXST/crates/lxst-telephony")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn helper_script() -> PathBuf {
|
||||
repo_root().join("tools/interop/lxst_telephone_helper.py")
|
||||
}
|
||||
|
||||
fn temp_storage(name: &str) -> PathBuf {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock is after Unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("{name}-{}-{now}", std::process::id()))
|
||||
}
|
||||
|
||||
fn should_skip() -> bool {
|
||||
std::env::var(SKIP_ENV).map(|v| v == "1").unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_telephone_helper_loads_reference_and_creates_destination() {
|
||||
if should_skip() {
|
||||
eprintln!("{SKIP_ENV}=1 -> skipping Python LXST Telephone helper self-test");
|
||||
return;
|
||||
}
|
||||
|
||||
let storage = temp_storage("rs-lxst-python-telephone-helper");
|
||||
let output = Command::new("python3")
|
||||
.arg(helper_script())
|
||||
.arg("--mode")
|
||||
.arg("self-test")
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage)
|
||||
.output()
|
||||
.expect("spawn Python LXST Telephone helper");
|
||||
|
||||
let _ = fs::remove_dir_all(&storage);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Python LXST Telephone helper failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let events: Vec<Value> = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).expect("helper JSON line"))
|
||||
.collect();
|
||||
|
||||
let ready = events
|
||||
.iter()
|
||||
.find(|event| event["event"] == "READY")
|
||||
.expect("READY event");
|
||||
assert_eq!(ready["app_name"], "lxst");
|
||||
assert_eq!(ready["primitive_name"], "telephony");
|
||||
assert_eq!(ready["lxst_version"], "0.4.5");
|
||||
assert_eq!(
|
||||
ready["lxst_root"].as_str(),
|
||||
Some("/Users/Games/Desktop/main/upstream/LXST")
|
||||
);
|
||||
assert!(ready["codec2_stubbed"].is_boolean());
|
||||
assert_eq!(ready["headless_audio"], true);
|
||||
assert_eq!(ready["native_filters_disabled"], true);
|
||||
|
||||
let identity_hash = hex::decode(ready["identity_hash"].as_str().expect("identity hash"))
|
||||
.expect("identity hash hex");
|
||||
let identity_hash: [u8; 16] = identity_hash
|
||||
.try_into()
|
||||
.expect("Python RNS identity hashes are 16 bytes");
|
||||
assert_eq!(
|
||||
hex::encode(telephony_destination_hash(&identity_hash)),
|
||||
ready["destination_hash"]
|
||||
.as_str()
|
||||
.expect("destination hash")
|
||||
);
|
||||
assert_eq!(
|
||||
ready["expanded_name"].as_str().expect("expanded name"),
|
||||
format!("lxst.telephony.{}", hex::encode(identity_hash))
|
||||
);
|
||||
|
||||
let snapshot = events
|
||||
.iter()
|
||||
.find(|event| event["event"] == "SNAPSHOT")
|
||||
.expect("SNAPSHOT event");
|
||||
assert_eq!(snapshot["active_call"], Value::Null);
|
||||
assert_eq!(snapshot["busy"], false);
|
||||
assert_eq!(snapshot["call_status"], 3);
|
||||
assert_eq!(snapshot["link_count"], 0);
|
||||
|
||||
assert!(
|
||||
events.iter().any(|event| event["event"] == "STOPPED"),
|
||||
"helper did not stop cleanly"
|
||||
);
|
||||
}
|
||||
2965
crates/lxst-telephony/tests/python_telephone_live_interop.rs
Normal file
2965
crates/lxst-telephony/tests/python_telephone_live_interop.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue