Initial release: real-time voice changer in pure Rust
Phase-vocoder pitch/formant shifting, 12 presets, effect chain, noise gate, WAV recording, full-screen TUI (arrow/mouse control) and a built-in PipeWire/PulseAudio virtual mic (vois.rs). GPL-3.0-or-later.
This commit is contained in:
commit
b0d685a110
25 changed files with 6243 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
1613
Cargo.lock
generated
Normal file
1613
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "vois"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Real-time voice changer written in pure Rust"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
cpal = "0.15"
|
||||||
|
crossterm = "0.28"
|
||||||
|
hound = "3"
|
||||||
|
ratatui = "0.28"
|
||||||
|
ringbuf = "0.4"
|
||||||
|
rustfft = "6"
|
||||||
|
signal-hook = "0.3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
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 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. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU 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 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 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 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 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 General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
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 GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
213
README.md
Normal file
213
README.md
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# vois.rs
|
||||||
|
|
||||||
|
Real-time voice changer for your mic: pitch/formant shifting, 12 effect presets and a built-in virtual microphone — written in pure Rust.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[English](#english) | [Русский](#russian)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a name="english"></a>
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
vois.rs turns your microphone into a voice changer in real time: shift pitch and formants with a phase-vocoder DSP chain, apply game/voice-change style presets (Robot, 8-bit, Demon, Girl, Alien, Ghost...), and expose the processed voice as a **virtual microphone** that Discord, Zoom, OBS and games can use — no external tools required.
|
||||||
|
|
||||||
|
> **Heads up:** this project was hacked together very quickly on a "vibe" and may contain bugs, rough edges and half-finished bits. Use at your own risk and report issues!
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Real-time pitch shift | Phase-vocoder, −12…+12 semitones, formants preserved |
|
||||||
|
| Formant shift | Change the timbre (girl / deep / bright) independently of pitch |
|
||||||
|
| 12 presets | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg |
|
||||||
|
| Effects | Vocoder, bitcrush, distortion, reverb, chorus, ring-mod, bandpass, noise, compressor, noise gate |
|
||||||
|
| Virtual microphone | Built-in `vois.rs` mic (PipeWire/PulseAudio) — no VB-CABLE needed on Linux |
|
||||||
|
| TUI | Full-screen terminal UI with arrow-key + mouse control and live meters |
|
||||||
|
| WAV recording | Record the processed audio with the `R` hotkey or `--record` |
|
||||||
|
| Test tone | `--tone 220` lets you hear what a preset does without a mic |
|
||||||
|
| Cross-platform | Linux (ALSA/PipeWire), macOS (CoreAudio), Windows (WASAPI) |
|
||||||
|
|
||||||
|
### Presets
|
||||||
|
|
||||||
|
| Preset | Pitch | Formant | Effects |
|
||||||
|
|--------|-------|---------|---------|
|
||||||
|
| Clean | 0 st | 1.00× | passthrough (A/B compare) |
|
||||||
|
| Girl / Anime | +5 st | 1.25× | — |
|
||||||
|
| Boy | +3 st | 1.12× | — |
|
||||||
|
| Manly / Deep | −4 st | 0.78× | compressor |
|
||||||
|
| Demon | −9 st | 0.85× | distortion + reverb |
|
||||||
|
| Robot | 0 st | 1.00× | channel vocoder + bitcrush |
|
||||||
|
| 8-bit | 0 st | 1.00× | bitcrush (5-bit, decimate ×4, auto-level) |
|
||||||
|
| Alien | −2 st | 1.15× | ring-mod + chorus |
|
||||||
|
| Radio | 0 st | 1.00× | bandpass + noise + compressor |
|
||||||
|
| Megaphone | 0 st | 1.00× | distortion + narrow EQ + compressor |
|
||||||
|
| Ghost | −3 st | 1.05× | big reverb + chorus |
|
||||||
|
| Cyborg | +2 st | 1.00× | vocoder + bitcrush |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
./target/release/vois # virtual mic is ON by default
|
||||||
|
./target/release/vois -p "Girl / Anime" # pick a preset
|
||||||
|
./target/release/vois --no-virtual-mic # just output to your speakers
|
||||||
|
./target/release/vois --tone 220 # hear presets without a mic
|
||||||
|
./target/release/vois --list # list audio devices
|
||||||
|
./target/release/vois --list-presets # list presets
|
||||||
|
```
|
||||||
|
|
||||||
|
In Discord / Zoom / OBS select **`vois.rs`** as your microphone.
|
||||||
|
|
||||||
|
### TUI controls
|
||||||
|
|
||||||
|
| Key / Mouse | Action |
|
||||||
|
|-------------|--------|
|
||||||
|
| `↑` / `↓` / click | switch preset (live preview) |
|
||||||
|
| `←` / `→` | change pitch (main) / change setting (settings) |
|
||||||
|
| `S` / `Tab` | open settings screen |
|
||||||
|
| `H` | help |
|
||||||
|
| `Q` / `Ctrl+C` | quit |
|
||||||
|
|
||||||
|
Settings screen: `↑↓` select, `←→` / `Enter` change, `Esc` back — preset, pitch, formant, gate threshold (dB), gain (dB), mute, LIVE/PASSTHROUGH mode, record.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `cpal` — audio capture/playback (ALSA / WASAPI / CoreAudio)
|
||||||
|
- `rustfft` — phase-vocoder FFT
|
||||||
|
- `ratatui` + `crossterm` — terminal UI
|
||||||
|
- `ringbuf` — lock-free sample buffers
|
||||||
|
- `hound` — WAV recording
|
||||||
|
- Linux virtual mic requires `pactl` (PipeWire/PulseAudio)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. `cargo build --release`
|
||||||
|
2. Run `./target/release/vois`
|
||||||
|
3. Select **`vois.rs`** as your microphone in your voice app
|
||||||
|
|
||||||
|
### Roadmap / ideas
|
||||||
|
|
||||||
|
- Windows/macOS virtual mic (VB-CABLE / BlackHole) as output device
|
||||||
|
- Latency meter and lower-latency FFT modes
|
||||||
|
- Pitch auto-correction / karaoke-style smoothing
|
||||||
|
- More presets and user-defined chains
|
||||||
|
- Web UI / tray icon
|
||||||
|
- Playback of a soundboard through the virtual mic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a name="russian"></a>
|
||||||
|
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
### Обзор
|
||||||
|
|
||||||
|
vois.rs превращает твой микрофон в войс-ченджер в реальном времени: сдвиг высоты и формант на фазовом вокодере, 12 пресетов в стиле войс-модов (Robot, 8-bit, Demon, Girl, Alien, Ghost...) и **виртуальный микрофон**, который видят Discord, Zoom, OBS и игры — без внешних программ.
|
||||||
|
|
||||||
|
> **Важно:** проект был написан очень быстро, «на вайбе», поэтому могут быть баги, недоделки и шероховатости. Пользуйся на свой страх и риск, баги репорть!
|
||||||
|
|
||||||
|
### Возможности
|
||||||
|
|
||||||
|
| Возможность | Описание |
|
||||||
|
|-------------|----------|
|
||||||
|
| Сдвиг высоты | Фазовый вокодер, −12…+12 полутонов, форманты сохраняются |
|
||||||
|
| Сдвиг формант | Меняет тембр (девушка / глубокий / звонкий) независимо от высоты |
|
||||||
|
| 12 пресетов | Clean, Girl/Anime, Boy, Manly/Deep, Demon, Robot, 8-bit, Alien, Radio, Megaphone, Ghost, Cyborg |
|
||||||
|
| Эффекты | Вокодер, биткраш, дисторшн, реверб, хорус, кольцевая модуляция, полосовой фильтр, шум, компрессор, шумоподавитель |
|
||||||
|
| Виртуальный микрофон | Встроенный `vois.rs` (PipeWire/PulseAudio) — на Linux не нужен VB-CABLE |
|
||||||
|
| TUI | Полноэкранный интерфейс с управлением стрелками и мышью, живые уровни |
|
||||||
|
| Запись в WAV | Клавиша `R` или `--record` |
|
||||||
|
| Тестовый тон | `--tone 220` — послушать пресет без микрофона |
|
||||||
|
| Кроссплатформенность | Linux (ALSA/PipeWire), macOS (CoreAudio), Windows (WASAPI) |
|
||||||
|
|
||||||
|
### Пресеты
|
||||||
|
|
||||||
|
| Пресет | Высота | Форманты | Эффекты |
|
||||||
|
|--------|--------|----------|---------|
|
||||||
|
| Clean | 0 пт | 1.00× | passthrough (сравнение A/B) |
|
||||||
|
| Girl / Anime | +5 пт | 1.25× | — |
|
||||||
|
| Boy | +3 пт | 1.12× | — |
|
||||||
|
| Manly / Deep | −4 пт | 0.78× | компрессор |
|
||||||
|
| Demon | −9 пт | 0.85× | дисторшн + реверб |
|
||||||
|
| Robot | 0 пт | 1.00× | вокодер + биткраш |
|
||||||
|
| 8-bit | 0 пт | 1.00× | биткраш (5 бит, децимация ×4, автолевел) |
|
||||||
|
| Alien | −2 пт | 1.15× | кольцевая модуляция + хорус |
|
||||||
|
| Radio | 0 пт | 1.00× | полосовой фильтр + шум + компрессор |
|
||||||
|
| Megaphone | 0 пт | 1.00× | дисторшн + узкий EQ + компрессор |
|
||||||
|
| Ghost | −3 пт | 1.05× | большой реверб + хорус |
|
||||||
|
| Cyborg | +2 пт | 1.00× | вокодер + биткраш |
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
./target/release/vois # виртуальный микрофон включён по умолчанию
|
||||||
|
./target/release/vois -p "Girl / Anime" # выбрать пресет
|
||||||
|
./target/release/vois --no-virtual-mic # просто вывод в колонки/наушники
|
||||||
|
./target/release/vois --tone 220 # послушать пресеты без микрофона
|
||||||
|
./target/release/vois --list # список устройств
|
||||||
|
./target/release/vois --list-presets # список пресетов
|
||||||
|
```
|
||||||
|
|
||||||
|
В Discord / Zoom / OBS выбери микрофон **`vois.rs`**.
|
||||||
|
|
||||||
|
### Управление в TUI
|
||||||
|
|
||||||
|
| Клавиша / мышь | Действие |
|
||||||
|
|----------------|----------|
|
||||||
|
| `↑` / `↓` / клик | переключение пресета (живой предпросмотр) |
|
||||||
|
| `←` / `→` | pitch (главный экран) / изменение параметра (настройки) |
|
||||||
|
| `S` / `Tab` | экран настроек |
|
||||||
|
| `H` | справка |
|
||||||
|
| `Q` / `Ctrl+C` | выход |
|
||||||
|
|
||||||
|
Экран настроек: `↑↓` выбор, `←→` / `Enter` изменение, `Esc` назад — пресет, pitch, formant, порог шумоподавителя (дБ), усиление (дБ), mute, режим LIVE/PASSTHROUGH, запись.
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
|
||||||
|
- `cpal` — захват/вывод звука (ALSA / WASAPI / CoreAudio)
|
||||||
|
- `rustfft` — FFT для фазового вокодера
|
||||||
|
- `ratatui` + `crossterm` — терминальный интерфейс
|
||||||
|
- `ringbuf` — lock-free буферы сэмплов
|
||||||
|
- `hound` — запись WAV
|
||||||
|
- Виртуальный микрофон на Linux требует `pactl` (PipeWire/PulseAudio)
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
1. `cargo build --release`
|
||||||
|
2. Запусти `./target/release/vois`
|
||||||
|
3. В голосовом приложении выбери микрофон **`vois.rs`**
|
||||||
|
|
||||||
|
### Планы / идеи
|
||||||
|
|
||||||
|
- Виртуальный микрофон на Windows/macOS (VB-CABLE / BlackHole) как устройство вывода
|
||||||
|
- Замер задержки и режимы FFT с меньшей латентностью
|
||||||
|
- Автокоррекция высоты / сглаживание в стиле караоке
|
||||||
|
- Больше пресетов и пользовательские цепочки эффектов
|
||||||
|
- Web-UI / трей-иконка
|
||||||
|
- Звуковая плата (soundboard) через виртуальный микрофон
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
- [Releases](../../releases)
|
||||||
|
- [Issues](../../issues)
|
||||||
|
- [License](LICENSE)
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
GNU General Public License v3.0 (or later)
|
||||||
217
examples/latency_probe.rs
Normal file
217
examples/latency_probe.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
//! Latency + throughput probe for the vocoder DSP.
|
||||||
|
//!
|
||||||
|
//! This is a dev-only example, not part of the app. `vois` is a binary-only
|
||||||
|
//! crate (DSP lives behind private `mod dsp` in `main.rs`), so we pull in the
|
||||||
|
//! real DSP sources with `#[path]` and compile them into this example. No new
|
||||||
|
//! dependencies are needed: `rustfft` and `anyhow` are already in Cargo.toml.
|
||||||
|
|
||||||
|
// The example only exercises `Processor` + `PitchShifter`; the rest of the
|
||||||
|
// worker/Control surface compiled in from `dsp::chain` is intentionally unused
|
||||||
|
// here, so silence dead-code warnings for this example target only.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Minimal stand-in for `crate::audio::record` so `dsp::chain` compiles here.
|
||||||
|
mod audio {
|
||||||
|
pub mod record {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
pub fn spawn_writer(
|
||||||
|
_path: String,
|
||||||
|
_fs: u32,
|
||||||
|
) -> (mpsc::Sender<Vec<f32>>, thread::JoinHandle<()>) {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let handle = thread::spawn(move || while rx.recv().is_ok() {});
|
||||||
|
(tx, handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[path = "../src/dsp/mod.rs"]
|
||||||
|
mod dsp;
|
||||||
|
|
||||||
|
use dsp::chain::{Control, Processor};
|
||||||
|
use dsp::pitch::PitchShifter;
|
||||||
|
use dsp::stats::Stats;
|
||||||
|
|
||||||
|
const FS: u32 = 48000;
|
||||||
|
|
||||||
|
/// Harmonic-rich saw (like the unit tests use): narrow pitch lines + broad
|
||||||
|
/// envelope, closer to a voice than a pure sine.
|
||||||
|
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|i| {
|
||||||
|
let t = freq * i as f32 / fs as f32;
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=12 {
|
||||||
|
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed `input` through the processor in the same fixed-size blocks the DSP
|
||||||
|
/// worker uses, collecting the output.
|
||||||
|
fn feed_blocks(p: &mut Processor, input: &[f32], block: usize) -> Vec<f32> {
|
||||||
|
let mut out_all = Vec::with_capacity(input.len());
|
||||||
|
for chunk in input.chunks(block) {
|
||||||
|
let mut b = chunk.to_vec();
|
||||||
|
b.resize(block, 0.0);
|
||||||
|
p.process_block(&mut b);
|
||||||
|
out_all.extend_from_slice(&b[..chunk.len()]);
|
||||||
|
}
|
||||||
|
out_all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU time needed to process one second of audio (in ms), measured over 10 s
|
||||||
|
/// of a 220 Hz saw fed in `block`-sized chunks. Preset "Girl / Anime" (pitch
|
||||||
|
/// +5 st, formant x1.25) exercises the heaviest vocoder path.
|
||||||
|
fn throughput(block: usize) -> f64 {
|
||||||
|
let control = Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 1,
|
||||||
|
..Control::default()
|
||||||
|
}));
|
||||||
|
let mut p = Processor::new(FS, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
let audio = saw(220.0, FS, 10 * FS as usize);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let out = feed_blocks(&mut p, &audio, block);
|
||||||
|
let dt = t0.elapsed();
|
||||||
|
assert_eq!(out.len(), audio.len());
|
||||||
|
dt.as_secs_f64() / 10.0 * 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pitch-shifter group delay: feed a sharp transient, report the first output
|
||||||
|
/// sample above `thresh_frac` of the output peak. A unit impulse at sample 0
|
||||||
|
/// is annihilated by the Hann window (w[0] == 0 exactly), so the transient is
|
||||||
|
/// placed mid-frame where the window is at its peak.
|
||||||
|
fn group_delay(n: usize, hop: usize, impulse_at: usize, thresh_frac: f32) -> Option<(usize, f32)> {
|
||||||
|
let mut sh = PitchShifter::new(n, hop, FS);
|
||||||
|
sh.pitch_ratio = 1.0;
|
||||||
|
let mut input = vec![0.0f32; 2 * n];
|
||||||
|
input[impulse_at] = 1.0;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(&input, &mut out);
|
||||||
|
let peak = out.iter().fold(0.0f32, |a, &v| a.max(v.abs()));
|
||||||
|
let thr = peak * thresh_frac;
|
||||||
|
out.iter().position(|&v| v.abs() > thr).map(|i| (i, peak))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Practical onset delay through the whole chain (what a user would hear):
|
||||||
|
/// feed a sine burst that starts after a quiet lead-in and measure the gap
|
||||||
|
/// between the input onset and the first output sample above a threshold.
|
||||||
|
fn chain_onset_delay(block: usize, n: usize) -> Option<usize> {
|
||||||
|
let control = Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 0,
|
||||||
|
pitch_delta: 5.0,
|
||||||
|
..Control::default()
|
||||||
|
}));
|
||||||
|
let mut p = Processor::new(FS, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
|
||||||
|
let lead = 2 * n;
|
||||||
|
let burst_len = 2048;
|
||||||
|
let mut input = vec![0.0f32; lead + burst_len];
|
||||||
|
for (i, s) in input.iter_mut().enumerate().skip(lead) {
|
||||||
|
let t = 220.0 * (i - lead) as f32 / FS as f32;
|
||||||
|
*s = (2.0 * std::f32::consts::PI * t).sin();
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = feed_blocks(&mut p, &input, block);
|
||||||
|
let peak = out[lead..].iter().fold(0.0f32, |a, &v| a.max(v.abs()));
|
||||||
|
let thr = peak * 1e-2;
|
||||||
|
out.iter().position(|&v| v.abs() > thr).map(|i| i - lead)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity check: feeding the same audio at block=1024 vs block=256 must
|
||||||
|
/// produce (nearly) identical output — the block-size change only reduces
|
||||||
|
/// buffering latency, not audio content.
|
||||||
|
fn block_size_identity() {
|
||||||
|
let saw2 = saw(220.0, FS, 2 * FS as usize);
|
||||||
|
let mk = || {
|
||||||
|
Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 1,
|
||||||
|
..Control::default()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let mut a = Processor::new(FS, mk(), Arc::new(Mutex::new(Stats::default())));
|
||||||
|
let mut b = Processor::new(FS, mk(), Arc::new(Mutex::new(Stats::default())));
|
||||||
|
let oa = feed_blocks(&mut a, &saw2, 1024);
|
||||||
|
let ob = feed_blocks(&mut b, &saw2, 256);
|
||||||
|
let max_diff = oa
|
||||||
|
.iter()
|
||||||
|
.zip(ob.iter())
|
||||||
|
.map(|(x, y)| (x - y).abs())
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
println!("block 1024 vs 256 output: max |diff| = {max_diff:.3e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ms(samples: usize) -> f64 {
|
||||||
|
samples as f64 / FS as f64 * 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let audio_seconds = 10.0;
|
||||||
|
let (n, hop) = (1024usize, 256usize);
|
||||||
|
|
||||||
|
println!("=== vois DSP latency probe (fs = {FS} Hz) ===");
|
||||||
|
|
||||||
|
// a) Throughput.
|
||||||
|
for block in [1024usize, 256usize] {
|
||||||
|
let cpu_ms = throughput(block);
|
||||||
|
println!(
|
||||||
|
"throughput block={block:>4}: {cpu_ms:7.2} ms CPU per 1s audio \
|
||||||
|
({:5.1}% of one core, {:.1}x realtime)",
|
||||||
|
cpu_ms / 10.0,
|
||||||
|
audio_seconds / (cpu_ms / 1000.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// b) Pitch-shifter group delay (impulse mid-frame, ratio = 1.0).
|
||||||
|
let (gd, peak) = group_delay(n, hop, n / 2, 0.1).expect("no impulse response found");
|
||||||
|
println!(
|
||||||
|
"shifter group delay (n={n}, hop={hop}, impulse@{}, >10% of peak {:.2e}): \
|
||||||
|
{gd} samples = {:.2} ms",
|
||||||
|
n / 2,
|
||||||
|
peak,
|
||||||
|
ms(gd)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Frame-fill latency: the shifter needs `n` samples before it can emit its
|
||||||
|
// first hop, i.e. output lags input by n - hop in the stream.
|
||||||
|
println!(
|
||||||
|
" streaming frame-fill latency n - hop = {} samples = {:.2} ms",
|
||||||
|
n - hop,
|
||||||
|
ms(n - hop)
|
||||||
|
);
|
||||||
|
|
||||||
|
// c) End-to-end estimate = measured group delay + fixed prime + block
|
||||||
|
// buffering, for the current settings.
|
||||||
|
let prime = n - hop;
|
||||||
|
for block in [1024usize, 256usize] {
|
||||||
|
let total = gd + prime + block;
|
||||||
|
println!(
|
||||||
|
"end-to-end (block={block:>4}): group delay {gd} + prime {prime} + buffering {block} \
|
||||||
|
= {total} samples = {ms:.2} ms",
|
||||||
|
ms = ms(total)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain onset delay is the number users would actually feel.
|
||||||
|
for block in [1024usize, 256usize] {
|
||||||
|
if let Some(d) = chain_onset_delay(block, n) {
|
||||||
|
println!(
|
||||||
|
"chain onset delay (block={block:>4}): {d} samples = {:.2} ms after burst start",
|
||||||
|
ms(d)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
block_size_identity();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
31
scripts/setup_virtual_mic.sh
Normal file
31
scripts/setup_virtual_mic.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create the "vois" null sink and expose its monitor as a virtual mic.
|
||||||
|
# The default sink is left untouched: vois routes only its own playback
|
||||||
|
# stream to the "vois" sink, so other apps keep playing to your real output.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SINK_NAME="vois.rs"
|
||||||
|
SINK_DESC="vois.rs virtual mic"
|
||||||
|
REMAP_NAME="vois.rs"
|
||||||
|
|
||||||
|
# Idempotency check: field 2 of `pactl list short <x>` is the object name.
|
||||||
|
if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then
|
||||||
|
pactl load-module module-null-sink \
|
||||||
|
sink_name="${SINK_NAME}" \
|
||||||
|
sink_properties="device.description=${SINK_DESC}"
|
||||||
|
echo "created sink: ${SINK_NAME}"
|
||||||
|
else
|
||||||
|
echo "sink already present: ${SINK_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pactl list short sources | awk -v n="${REMAP_NAME}" '$2==n' | grep -q .; then
|
||||||
|
pactl load-module module-remap-source \
|
||||||
|
source_name="${REMAP_NAME}" \
|
||||||
|
master="${SINK_NAME}.monitor"
|
||||||
|
echo "created remapped source: ${REMAP_NAME}"
|
||||||
|
else
|
||||||
|
echo "remapped source already present: ${REMAP_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "virtual mic source name(s): ${SINK_NAME}.monitor and ${REMAP_NAME}"
|
||||||
|
echo 'use "vois.rs" (or "vois.rs.monitor") as your microphone in apps'
|
||||||
69
src/audio/capture.rs
Normal file
69
src/audio/capture.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use cpal::{
|
||||||
|
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
|
||||||
|
SupportedStreamConfig,
|
||||||
|
};
|
||||||
|
use ringbuf::traits::Producer;
|
||||||
|
|
||||||
|
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||||
|
match name {
|
||||||
|
Some(name) => host
|
||||||
|
.input_devices()
|
||||||
|
.context("failed to enumerate input devices")?
|
||||||
|
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||||
|
.with_context(|| format!("input device {name:?} not found")),
|
||||||
|
None => host
|
||||||
|
.default_input_device()
|
||||||
|
.context("no default input device found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the input stream. Captured samples are converted to `f32` (interleaved
|
||||||
|
/// frames) and pushed into `tx`.
|
||||||
|
pub fn start<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
|
||||||
|
where
|
||||||
|
P: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let config: StreamConfig = supported.config();
|
||||||
|
match supported.sample_format() {
|
||||||
|
SampleFormat::F32 => build::<f32, P>(device, &config, tx),
|
||||||
|
SampleFormat::F64 => build::<f64, P>(device, &config, tx),
|
||||||
|
SampleFormat::I8 => build::<i8, P>(device, &config, tx),
|
||||||
|
SampleFormat::I16 => build::<i16, P>(device, &config, tx),
|
||||||
|
SampleFormat::I32 => build::<i32, P>(device, &config, tx),
|
||||||
|
SampleFormat::I64 => build::<i64, P>(device, &config, tx),
|
||||||
|
SampleFormat::U8 => build::<u8, P>(device, &config, tx),
|
||||||
|
SampleFormat::U16 => build::<u16, P>(device, &config, tx),
|
||||||
|
SampleFormat::U32 => build::<u32, P>(device, &config, tx),
|
||||||
|
SampleFormat::U64 => build::<u64, P>(device, &config, tx),
|
||||||
|
format => anyhow::bail!("unsupported input sample format: {format:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
|
||||||
|
where
|
||||||
|
T: SizedSample,
|
||||||
|
f32: FromSample<T>,
|
||||||
|
P: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
let mut frame = vec![0f32; channels];
|
||||||
|
let err_fn = |err| eprintln!("capture stream error: {err}");
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||||
|
for chunk in data.chunks(channels) {
|
||||||
|
for (slot, sample) in frame.iter_mut().zip(chunk) {
|
||||||
|
*slot = f32::from_sample(*sample);
|
||||||
|
}
|
||||||
|
let _ = tx.push_slice(&frame);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
|
||||||
|
}
|
||||||
18
src/audio/devices.rs
Normal file
18
src/audio/devices.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
|
||||||
|
pub fn print_all() -> Result<()> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
println!("Input devices:");
|
||||||
|
for device in host.input_devices()? {
|
||||||
|
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Output devices:");
|
||||||
|
for device in host.output_devices()? {
|
||||||
|
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
191
src/audio/mod.rs
Normal file
191
src/audio/mod.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
pub mod capture;
|
||||||
|
pub mod devices;
|
||||||
|
pub mod playback;
|
||||||
|
pub mod record;
|
||||||
|
pub mod virtual_mic;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||||
|
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
|
||||||
|
use ringbuf::traits::Split;
|
||||||
|
use ringbuf::HeapRb;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::dsp;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
/// Higher = better quality for our purposes.
|
||||||
|
fn format_rank(format: cpal::SampleFormat) -> u8 {
|
||||||
|
match format {
|
||||||
|
cpal::SampleFormat::F32 => 10,
|
||||||
|
cpal::SampleFormat::I16 => 9,
|
||||||
|
cpal::SampleFormat::I32 => 8,
|
||||||
|
cpal::SampleFormat::F64 => 7,
|
||||||
|
cpal::SampleFormat::U16 => 6,
|
||||||
|
cpal::SampleFormat::I8 => 5,
|
||||||
|
cpal::SampleFormat::U8 => 4,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best supported config with the exact sample rate (same channel
|
||||||
|
/// count as `channels`). This is how we force both devices onto one sample
|
||||||
|
/// rate while preferring a high-quality sample format.
|
||||||
|
fn pick_config(
|
||||||
|
ranges: impl Iterator<Item = SupportedStreamConfigRange>,
|
||||||
|
channels: u16,
|
||||||
|
rate: SampleRate,
|
||||||
|
) -> Option<SupportedStreamConfig> {
|
||||||
|
ranges
|
||||||
|
.filter(|r| r.channels() == channels && r.try_with_sample_rate(rate).is_some())
|
||||||
|
.max_by_key(|r| format_rank(r.sample_format()))
|
||||||
|
.map(|r| r.with_sample_rate(rate))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
|
||||||
|
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
|
||||||
|
|
||||||
|
let in_supported = input_device
|
||||||
|
.default_input_config()
|
||||||
|
.context("failed to read default input config")?;
|
||||||
|
|
||||||
|
let rate = cfg
|
||||||
|
.sample_rate
|
||||||
|
.map(SampleRate)
|
||||||
|
.unwrap_or_else(|| in_supported.sample_rate());
|
||||||
|
|
||||||
|
let in_supported = if cfg.sample_rate.is_some() {
|
||||||
|
pick_config(
|
||||||
|
input_device.supported_input_configs()?,
|
||||||
|
in_supported.channels(),
|
||||||
|
rate,
|
||||||
|
)
|
||||||
|
.with_context(|| format!("input device does not support sample rate {} Hz", rate.0))?
|
||||||
|
} else {
|
||||||
|
in_supported
|
||||||
|
};
|
||||||
|
|
||||||
|
let out_default = output_device
|
||||||
|
.default_output_config()
|
||||||
|
.context("failed to read default output config")?;
|
||||||
|
let out_supported = pick_config(
|
||||||
|
output_device.supported_output_configs()?,
|
||||||
|
out_default.channels(),
|
||||||
|
rate,
|
||||||
|
)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"output device does not support sample rate {} Hz; try `--sample-rate`",
|
||||||
|
rate.0
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let in_cfg: StreamConfig = in_supported.config();
|
||||||
|
let out_cfg: StreamConfig = out_supported.config();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"input : {} @ {} Hz, {:?}, {} ch",
|
||||||
|
input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||||
|
in_cfg.sample_rate.0,
|
||||||
|
in_supported.sample_format(),
|
||||||
|
in_cfg.channels,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"output : {} @ {} Hz, {:?}, {} ch",
|
||||||
|
output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||||
|
out_cfg.sample_rate.0,
|
||||||
|
out_supported.sample_format(),
|
||||||
|
out_cfg.channels,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ring_cap = (rate.0 as usize * 2).max(8192);
|
||||||
|
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
|
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
|
|
||||||
|
// Virtual mic: keep `vois.rs` as the default sink for the whole run so the
|
||||||
|
// app's output lands in the virtual mic. A helper thread parks other
|
||||||
|
// apps' streams back on the real sink so their audio doesn't leak into the
|
||||||
|
// mic. The default sink is restored on exit.
|
||||||
|
let prev_sink = if cfg.virtual_mic {
|
||||||
|
virtual_mic::VirtualMic::setup()?;
|
||||||
|
virtual_mic::VirtualMic::get_default_sink()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if prev_sink.is_some() {
|
||||||
|
virtual_mic::VirtualMic::set_default_sink("vois.rs");
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
|
||||||
|
let output_stream = playback::start(&output_device, &out_supported, out_cons)?;
|
||||||
|
|
||||||
|
let in_ch = in_cfg.channels as usize;
|
||||||
|
let out_ch = out_cfg.channels as usize;
|
||||||
|
|
||||||
|
let control = Arc::new(Mutex::new(cfg.to_control()));
|
||||||
|
let stats = Arc::new(Mutex::new(dsp::stats::Stats::default()));
|
||||||
|
let worker_control = Arc::clone(&control);
|
||||||
|
let worker_stats = Arc::clone(&stats);
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
dsp::chain::run_worker(
|
||||||
|
in_cons,
|
||||||
|
out_prod,
|
||||||
|
in_ch,
|
||||||
|
out_ch,
|
||||||
|
rate.0,
|
||||||
|
worker_control,
|
||||||
|
worker_stats,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
input_stream
|
||||||
|
.play()
|
||||||
|
.context("failed to start input stream")?;
|
||||||
|
output_stream
|
||||||
|
.play()
|
||||||
|
.context("failed to start output stream")?;
|
||||||
|
let cleanup = if let Some(prev) = prev_sink {
|
||||||
|
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let handle = virtual_mic::VirtualMic::keep_others_off(Arc::clone(&stop), prev.clone());
|
||||||
|
let _sig = virtual_mic::VirtualMic::restore_on_signal(prev.clone());
|
||||||
|
Some((stop, handle, prev))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = ui::SessionInfo {
|
||||||
|
input: input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||||
|
output: output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||||
|
sample_rate: rate.0,
|
||||||
|
in_ch,
|
||||||
|
out_ch,
|
||||||
|
format_in: format!("{:?}", in_supported.sample_format()),
|
||||||
|
format_out: format!("{:?}", out_supported.sample_format()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the streams alive while the UI blocks.
|
||||||
|
let _streams = (input_stream, output_stream);
|
||||||
|
|
||||||
|
ui::tui::run(control, stats, info);
|
||||||
|
|
||||||
|
if let Some((stop, handle, prev)) = cleanup {
|
||||||
|
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let _ = handle.join();
|
||||||
|
virtual_mic::VirtualMic::set_default_sink(&prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("waiting for DSP worker to finish...");
|
||||||
|
let _ = worker.join();
|
||||||
|
|
||||||
|
// cpal's ALSA teardown can panic on PipeWire when the stream drop races
|
||||||
|
// with the worker thread closing its self-pipe. The process is about to
|
||||||
|
// exit anyway, so leak the streams instead of dropping them.
|
||||||
|
std::mem::forget(_streams);
|
||||||
|
println!("done.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
75
src/audio/playback.rs
Normal file
75
src/audio/playback.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use cpal::{
|
||||||
|
Device, FromSample, Host, SampleFormat, SizedSample, Stream, StreamConfig,
|
||||||
|
SupportedStreamConfig,
|
||||||
|
};
|
||||||
|
use ringbuf::traits::Consumer;
|
||||||
|
|
||||||
|
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||||
|
match name {
|
||||||
|
Some(name) => host
|
||||||
|
.output_devices()
|
||||||
|
.context("failed to enumerate output devices")?
|
||||||
|
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||||
|
.with_context(|| format!("output device {name:?} not found")),
|
||||||
|
None => host
|
||||||
|
.default_output_device()
|
||||||
|
.context("no default output device found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
|
||||||
|
/// with silence when the DSP worker has nothing ready yet.
|
||||||
|
pub fn start<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
|
||||||
|
where
|
||||||
|
C: Consumer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let config: StreamConfig = supported.config();
|
||||||
|
match supported.sample_format() {
|
||||||
|
SampleFormat::F32 => build::<f32, C>(device, &config, rx),
|
||||||
|
SampleFormat::F64 => build::<f64, C>(device, &config, rx),
|
||||||
|
SampleFormat::I8 => build::<i8, C>(device, &config, rx),
|
||||||
|
SampleFormat::I16 => build::<i16, C>(device, &config, rx),
|
||||||
|
SampleFormat::I32 => build::<i32, C>(device, &config, rx),
|
||||||
|
SampleFormat::I64 => build::<i64, C>(device, &config, rx),
|
||||||
|
SampleFormat::U8 => build::<u8, C>(device, &config, rx),
|
||||||
|
SampleFormat::U16 => build::<u16, C>(device, &config, rx),
|
||||||
|
SampleFormat::U32 => build::<u32, C>(device, &config, rx),
|
||||||
|
SampleFormat::U64 => build::<u64, C>(device, &config, rx),
|
||||||
|
format => anyhow::bail!("unsupported output sample format: {format:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
|
||||||
|
where
|
||||||
|
T: SizedSample + FromSample<f32>,
|
||||||
|
C: Consumer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut scratch = vec![0f32; 2048];
|
||||||
|
let err_fn = |err| eprintln!("playback stream error: {err}");
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut written = 0usize;
|
||||||
|
while written < data.len() {
|
||||||
|
let want = (data.len() - written).min(scratch.len());
|
||||||
|
let n = rx.pop_slice(&mut scratch[..want]);
|
||||||
|
if n == 0 {
|
||||||
|
data[written..].fill(T::from_sample(0.0f32));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let out = &mut data[written..written + n];
|
||||||
|
for (out_sample, in_sample) in out.iter_mut().zip(&scratch[..n]) {
|
||||||
|
*out_sample = T::from_sample(*in_sample);
|
||||||
|
}
|
||||||
|
written += n;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
|
||||||
|
}
|
||||||
40
src/audio/record.rs
Normal file
40
src/audio/record.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
//! WAV recording of the processed mono stream via a dedicated writer thread.
|
||||||
|
|
||||||
|
use hound::{SampleFormat, WavSpec, WavWriter};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::thread;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
pub fn spawn_writer(path: String, sample_rate: u32) -> (Sender<Vec<f32>>, JoinHandle<()>) {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<f32>>();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let spec = WavSpec {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
sample_format: SampleFormat::Int,
|
||||||
|
};
|
||||||
|
let mut writer = match WavWriter::create(&path, spec) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("record: cannot create {path}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while let Ok(block) = rx.recv() {
|
||||||
|
for &s in &block {
|
||||||
|
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
|
if writer.write_sample(v).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = writer.finalize() {
|
||||||
|
eprintln!("record: {e}");
|
||||||
|
} else {
|
||||||
|
println!("recorded to {path}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(tx, handle)
|
||||||
|
}
|
||||||
197
src/audio/virtual_mic.rs
Normal file
197
src/audio/virtual_mic.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// Virtual microphone setup for PipeWire/PulseAudio.
|
||||||
|
//
|
||||||
|
// Creates a "vois.rs" null sink and remaps its monitor source to "vois.rs".
|
||||||
|
// The app does NOT hijack the default sink permanently: it briefly points the
|
||||||
|
// default sink at `vois.rs` while its own output stream is created (so the
|
||||||
|
// processed voice lands in the virtual mic) and restores it right after.
|
||||||
|
// Other apps' streams are not moved, so their audio never leaks into the mic.
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub struct VirtualMic;
|
||||||
|
|
||||||
|
impl VirtualMic {
|
||||||
|
pub fn setup() -> Result<Option<String>> {
|
||||||
|
let pactl = "pactl";
|
||||||
|
|
||||||
|
let sinks = Command::new(pactl)
|
||||||
|
.args(["list", "short", "sinks"])
|
||||||
|
.output()
|
||||||
|
.context("failed to run `pactl list short sinks`")?;
|
||||||
|
if !sinks.status.success() {
|
||||||
|
bail!("`pactl list short sinks` failed");
|
||||||
|
}
|
||||||
|
let sinks_out = String::from_utf8_lossy(&sinks.stdout);
|
||||||
|
|
||||||
|
if !has_entry(&sinks_out, "vois.rs") {
|
||||||
|
let out = Command::new(pactl)
|
||||||
|
.args([
|
||||||
|
"load-module",
|
||||||
|
"module-null-sink",
|
||||||
|
"sink_name=vois.rs",
|
||||||
|
"sink_properties=device.description=vois virtual mic",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("failed to create the vois.rs null sink")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"`pactl load-module module-null-sink sink_name=vois.rs` failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources = Command::new(pactl)
|
||||||
|
.args(["list", "short", "sources"])
|
||||||
|
.output()
|
||||||
|
.context("failed to run `pactl list short sources`")?;
|
||||||
|
if !sources.status.success() {
|
||||||
|
bail!("`pactl list short sources` failed");
|
||||||
|
}
|
||||||
|
let sources_out = String::from_utf8_lossy(&sources.stdout);
|
||||||
|
|
||||||
|
if !has_entry(&sources_out, "vois.rs") {
|
||||||
|
let out = Command::new(pactl)
|
||||||
|
.args([
|
||||||
|
"load-module",
|
||||||
|
"module-remap-source",
|
||||||
|
"source_name=vois.rs",
|
||||||
|
"master=vois.rs.monitor",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("failed to create the vois.rs remap source")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"`pactl load-module module-remap-source source_name=vois.rs` failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some("vois.rs".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current default sink name, if any.
|
||||||
|
pub fn get_default_sink() -> Option<String> {
|
||||||
|
let out = Command::new("pactl")
|
||||||
|
.arg("get-default-sink")
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_default_sink(name: &str) {
|
||||||
|
let _ = Command::new("pactl")
|
||||||
|
.args(["set-default-sink", name])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep every sink-input that is NOT this app's (client name "vois") away
|
||||||
|
/// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`.
|
||||||
|
///
|
||||||
|
/// WirePlumber routes ALSA streams to the default sink, so while the app
|
||||||
|
/// runs with `vois.rs` as default, other apps' audio would leak into the
|
||||||
|
/// virtual mic. Moving their streams explicitly sticks, so this thread
|
||||||
|
/// parks them on the real output.
|
||||||
|
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
||||||
|
thread::spawn(move || {
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let target_sink_id = sink_id_named("vois.rs");
|
||||||
|
let prev_id = sink_id_named(&prev_sink);
|
||||||
|
if let (Some(lv), Some(pv)) = (target_sink_id, prev_id) {
|
||||||
|
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
|
||||||
|
for (id, sink, client) in &inputs {
|
||||||
|
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
|
||||||
|
if sink == &lv && !ours {
|
||||||
|
let _ = Command::new("pactl")
|
||||||
|
.args(["move-sink-input", id, &pv])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the default sink if the process is killed (SIGINT/SIGTERM),
|
||||||
|
/// so the user's audio isn't left pointing at the silent null sink.
|
||||||
|
pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut signals = match signal_hook::iterator::Signals::new([
|
||||||
|
signal_hook::consts::signal::SIGINT,
|
||||||
|
signal_hook::consts::signal::SIGTERM,
|
||||||
|
]) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if let Some(_sig) = signals.forever().next() {
|
||||||
|
Self::set_default_sink(&prev);
|
||||||
|
std::process::exit(130);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sink_id_named(name: &str) -> Option<String> {
|
||||||
|
let out = Command::new("pactl")
|
||||||
|
.args(["list", "short", "sinks"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if fields.len() >= 2 && fields[1] == name {
|
||||||
|
return Some(fields[0].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_names() -> Result<std::collections::HashMap<String, String>> {
|
||||||
|
let out = Command::new("pactl")
|
||||||
|
.args(["list", "short", "clients"])
|
||||||
|
.output()
|
||||||
|
.context("failed to run `pactl list short clients`")?;
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if fields.len() >= 3 {
|
||||||
|
map.insert(fields[0].to_string(), fields[2].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
|
||||||
|
let out = Command::new("pactl")
|
||||||
|
.args(["list", "short", "sink-inputs"])
|
||||||
|
.output()
|
||||||
|
.context("failed to run `pactl list short sink-inputs`")?;
|
||||||
|
let mut inputs = Vec::new();
|
||||||
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if fields.len() >= 3 {
|
||||||
|
inputs.push((
|
||||||
|
fields[0].to_string(),
|
||||||
|
fields[1].to_string(),
|
||||||
|
fields[2].to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_entry(list: &str, name: &str) -> bool {
|
||||||
|
list.lines()
|
||||||
|
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
|
||||||
|
}
|
||||||
64
src/config.rs
Normal file
64
src/config.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use crate::dsp::chain::Control;
|
||||||
|
use crate::dsp::presets::PRESETS;
|
||||||
|
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub input: Option<String>,
|
||||||
|
pub output: Option<String>,
|
||||||
|
pub sample_rate: Option<u32>,
|
||||||
|
pub preset: String,
|
||||||
|
pub record: Option<String>,
|
||||||
|
pub pitch: f32,
|
||||||
|
pub formant: f32,
|
||||||
|
pub gate: f32,
|
||||||
|
pub gain: f32,
|
||||||
|
pub run_seconds: Option<u32>,
|
||||||
|
pub tone: f32,
|
||||||
|
pub virtual_mic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_cli(cli: &crate::Cli) -> Self {
|
||||||
|
Self {
|
||||||
|
input: cli.input.clone(),
|
||||||
|
output: cli.output.clone(),
|
||||||
|
sample_rate: cli.sample_rate,
|
||||||
|
preset: cli.preset.clone(),
|
||||||
|
record: cli.record.clone(),
|
||||||
|
pitch: cli.pitch,
|
||||||
|
formant: cli.formant,
|
||||||
|
gate: cli.gate,
|
||||||
|
gain: cli.gain,
|
||||||
|
run_seconds: cli.run_seconds,
|
||||||
|
tone: cli.tone,
|
||||||
|
virtual_mic: !cli.no_virtual_mic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the preset name into an index, defaulting to "Clean" on error.
|
||||||
|
fn preset_idx(&self) -> usize {
|
||||||
|
crate::dsp::presets::Preset::find(&self.preset).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_control(&self) -> Control {
|
||||||
|
Control {
|
||||||
|
preset_idx: self.preset_idx(),
|
||||||
|
pitch_delta: self.pitch,
|
||||||
|
formant_delta: self.formant,
|
||||||
|
gate_threshold_db: self.gate,
|
||||||
|
gain: self.gain,
|
||||||
|
record: self.record.is_some(),
|
||||||
|
record_path: self.record.clone(),
|
||||||
|
run_seconds: self.run_seconds,
|
||||||
|
tone_hz: self.tone,
|
||||||
|
..Control::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset_names() -> String {
|
||||||
|
PRESETS
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
430
src/dsp/chain.rs
Normal file
430
src/dsp/chain.rs
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
use crate::audio::record;
|
||||||
|
use crate::dsp::effects::Effect;
|
||||||
|
use crate::dsp::formant::ratio_from_semitones;
|
||||||
|
use crate::dsp::gate::NoiseGate;
|
||||||
|
use crate::dsp::pitch::PitchShifter;
|
||||||
|
use crate::dsp::presets::{build_effects, PRESETS};
|
||||||
|
use crate::dsp::stats::Stats;
|
||||||
|
use ringbuf::traits::{Consumer, Producer};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Processing mode, switchable at runtime (A/B compare).
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum Mode {
|
||||||
|
/// Normal processing through the DSP chain.
|
||||||
|
Live,
|
||||||
|
/// Raw input straight to output (bypass) for A/B comparison.
|
||||||
|
Passthrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
*self = match self {
|
||||||
|
Mode::Live => Mode::Passthrough,
|
||||||
|
Mode::Passthrough => Mode::Live,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-adjustable state, shared between the UI and the DSP worker.
|
||||||
|
pub struct Control {
|
||||||
|
pub preset_idx: usize,
|
||||||
|
/// Manual pitch adjustment added to the preset value (semitones).
|
||||||
|
pub pitch_delta: f32,
|
||||||
|
/// Manual formant adjustment added to the preset value (semitones).
|
||||||
|
pub formant_delta: f32,
|
||||||
|
pub gate_threshold_db: f32,
|
||||||
|
pub gain: f32,
|
||||||
|
pub mute: bool,
|
||||||
|
pub record: bool,
|
||||||
|
pub record_path: Option<String>,
|
||||||
|
pub run_seconds: Option<u32>,
|
||||||
|
/// Test tone frequency (Hz) mixed into the input; 0 = off.
|
||||||
|
pub tone_hz: f32,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Control {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
preset_idx: 0,
|
||||||
|
pitch_delta: 0.0,
|
||||||
|
formant_delta: 0.0,
|
||||||
|
gate_threshold_db: -100.0,
|
||||||
|
gain: 1.0,
|
||||||
|
mute: false,
|
||||||
|
record: false,
|
||||||
|
record_path: None,
|
||||||
|
run_seconds: None,
|
||||||
|
tone_hz: 0.0,
|
||||||
|
mode: Mode::Live,
|
||||||
|
quit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Processor {
|
||||||
|
fs: u32,
|
||||||
|
control: Arc<Mutex<Control>>,
|
||||||
|
stats: Arc<Mutex<Stats>>,
|
||||||
|
shifter: Option<PitchShifter>,
|
||||||
|
shift_pending: Vec<f32>,
|
||||||
|
gate: NoiseGate,
|
||||||
|
effects: Vec<Box<dyn Effect>>,
|
||||||
|
last_preset: usize,
|
||||||
|
recorder_tx: Option<Sender<Vec<f32>>>,
|
||||||
|
recorder_handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Processor {
|
||||||
|
pub fn new(fs: u32, control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
control,
|
||||||
|
stats,
|
||||||
|
shifter: None,
|
||||||
|
shift_pending: Vec::new(),
|
||||||
|
gate: NoiseGate::new(-60.0),
|
||||||
|
effects: Vec::new(),
|
||||||
|
last_preset: usize::MAX,
|
||||||
|
recorder_tx: None,
|
||||||
|
recorder_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording and wait for the WAV writer to finalize.
|
||||||
|
fn stop_recording(&mut self) {
|
||||||
|
self.recorder_tx = None;
|
||||||
|
if let Some(handle) = self.recorder_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one fixed-size mono block through the whole chain.
|
||||||
|
pub fn process_block(&mut self, mono: &mut [f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
// Input level (pre-processing).
|
||||||
|
let mut in_sum = 0.0;
|
||||||
|
for &s in mono.iter() {
|
||||||
|
in_sum += s * s;
|
||||||
|
}
|
||||||
|
let in_rms = (in_sum / mono.len() as f32).sqrt();
|
||||||
|
|
||||||
|
let (preset_idx, pitch, formant, gate_db, gain, mute, record, mode, record_path) = {
|
||||||
|
let ctrl = match self.control.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
let preset = &PRESETS[ctrl.preset_idx.min(PRESETS.len() - 1)];
|
||||||
|
(
|
||||||
|
ctrl.preset_idx.min(PRESETS.len() - 1),
|
||||||
|
preset.pitch_semitones + ctrl.pitch_delta,
|
||||||
|
preset.formant_ratio * ratio_from_semitones(ctrl.formant_delta),
|
||||||
|
ctrl.gate_threshold_db,
|
||||||
|
ctrl.gain,
|
||||||
|
ctrl.mute,
|
||||||
|
ctrl.record,
|
||||||
|
ctrl.mode,
|
||||||
|
ctrl.record_path.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if preset_idx != self.last_preset {
|
||||||
|
self.effects = build_effects(PRESETS[preset_idx].effects, self.fs);
|
||||||
|
self.last_preset = preset_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shifter_active = (pitch - 0.0).abs() > 0.05 || (formant - 1.0).abs() > 0.01;
|
||||||
|
|
||||||
|
if mute {
|
||||||
|
mono.fill(0.0);
|
||||||
|
} else if mode == Mode::Passthrough {
|
||||||
|
// A/B compare: raw input, only gain.
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s *= gain;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.gate.set_threshold_db(gate_db);
|
||||||
|
self.gate.process(mono);
|
||||||
|
|
||||||
|
if shifter_active {
|
||||||
|
if self.shifter.is_none() {
|
||||||
|
self.shifter = Some(PitchShifter::new(1024, 256, self.fs));
|
||||||
|
// Prime the extraction buffer with the vocoder's fixed latency
|
||||||
|
// (n - hop) so every input block yields a full output block.
|
||||||
|
self.shift_pending = vec![0.0; 1024 - 256];
|
||||||
|
}
|
||||||
|
let shifter = self.shifter.as_mut().unwrap();
|
||||||
|
shifter.pitch_ratio = 2f32.powf(pitch / 12.0);
|
||||||
|
shifter.formant_ratio = formant;
|
||||||
|
let mut out = Vec::with_capacity(mono.len());
|
||||||
|
shifter.process(mono, &mut out);
|
||||||
|
|
||||||
|
// The vocoder is a streaming delay line: per call it may emit a
|
||||||
|
// different number of samples than it consumed. Keep the excess
|
||||||
|
// in `shift_pending` and pull exactly `mono.len()` samples out,
|
||||||
|
// so the rest of the chain sees constant block sizes.
|
||||||
|
self.shift_pending.extend(out);
|
||||||
|
let take = mono.len().min(self.shift_pending.len());
|
||||||
|
mono[..take].copy_from_slice(&self.shift_pending[..take]);
|
||||||
|
mono[take..].fill(0.0);
|
||||||
|
self.shift_pending.drain(..take);
|
||||||
|
}
|
||||||
|
|
||||||
|
for effect in &mut self.effects {
|
||||||
|
effect.process(mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s *= gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if record {
|
||||||
|
if self.recorder_tx.is_none() {
|
||||||
|
if let Some(path) = record_path {
|
||||||
|
let (tx, handle) = record::spawn_writer(path, self.fs);
|
||||||
|
self.recorder_tx = Some(tx);
|
||||||
|
self.recorder_handle = Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tx) = &self.recorder_tx {
|
||||||
|
let _ = tx.send(mono.to_vec());
|
||||||
|
}
|
||||||
|
} else if self.recorder_tx.is_some() {
|
||||||
|
self.stop_recording();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output level + runtime stats.
|
||||||
|
let mut out_sum = 0.0;
|
||||||
|
for &s in mono.iter() {
|
||||||
|
out_sum += s * s;
|
||||||
|
}
|
||||||
|
let out_rms = (out_sum / mono.len() as f32).sqrt();
|
||||||
|
let elapsed = t0.elapsed().as_secs_f32();
|
||||||
|
let block_dur = mono.len() as f32 / self.fs as f32;
|
||||||
|
let cpu = if block_dur > 0.0 {
|
||||||
|
elapsed / block_dur * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut st = match self.stats.lock() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
st.input_rms = st.input_rms * 0.85 + in_rms * 0.15;
|
||||||
|
st.output_rms = st.output_rms * 0.85 + out_rms * 0.15;
|
||||||
|
st.peak_in = st.peak_in.max(in_rms);
|
||||||
|
st.peak_out = st.peak_out.max(out_rms);
|
||||||
|
st.blocks += 1;
|
||||||
|
st.samples += mono.len() as u64;
|
||||||
|
st.cpu_pct = st.cpu_pct * 0.9 + cpu * 0.1;
|
||||||
|
// DSP latency estimate: block buffering + vocoder frame-fill when active.
|
||||||
|
let latency_samples = if shifter_active { 768 } else { 0 } + 256;
|
||||||
|
st.dsp_latency_ms = latency_samples as f32 / self.fs as f32 * 1000.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DSP worker thread: pulls captured audio, runs the chain, pushes the result
|
||||||
|
/// to the output.
|
||||||
|
pub fn run_worker<I, O>(
|
||||||
|
mut input: I,
|
||||||
|
mut output: O,
|
||||||
|
in_ch: usize,
|
||||||
|
out_ch: usize,
|
||||||
|
fs: u32,
|
||||||
|
control: Arc<Mutex<Control>>,
|
||||||
|
stats: Arc<Mutex<Stats>>,
|
||||||
|
) where
|
||||||
|
I: Consumer<Item = f32> + Send + 'static,
|
||||||
|
O: Producer<Item = f32> + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut processor = Processor::new(fs, control, stats);
|
||||||
|
let mut scratch = vec![0f32; 8192];
|
||||||
|
let mut acc = Vec::with_capacity(8192);
|
||||||
|
let mut block = vec![0f32; 256];
|
||||||
|
let mut out = Vec::with_capacity(8192);
|
||||||
|
let mut total_blocks: u64 = 0;
|
||||||
|
let mut total_samples: u64 = 0;
|
||||||
|
let mut tone_phase: f32 = 0.0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if processor.control.lock().map(|c| c.quit).unwrap_or(false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = input.pop_slice(&mut scratch);
|
||||||
|
if n == 0 {
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total_samples += n as u64;
|
||||||
|
|
||||||
|
let frames = n / in_ch.max(1);
|
||||||
|
for f in 0..frames {
|
||||||
|
let mut mix = 0.0;
|
||||||
|
for c in 0..in_ch {
|
||||||
|
mix += scratch[f * in_ch + c];
|
||||||
|
}
|
||||||
|
acc.push(mix / in_ch as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional test tone injected before the DSP chain.
|
||||||
|
let tone_hz = processor.control.lock().map(|c| c.tone_hz).unwrap_or(0.0);
|
||||||
|
if tone_hz > 0.0 {
|
||||||
|
let start = acc.len().saturating_sub(frames);
|
||||||
|
for v in acc.iter_mut().skip(start) {
|
||||||
|
*v += 0.5 * (2.0 * std::f32::consts::PI * tone_phase).sin();
|
||||||
|
tone_phase += tone_hz / fs as f32;
|
||||||
|
if tone_phase >= 1.0 {
|
||||||
|
tone_phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in fixed-size blocks (multiple of the vocoder hop) so the
|
||||||
|
// pitch shifter always yields a full block. Smaller blocks cut the
|
||||||
|
// worst-case buffering latency; the shifter stream is unchanged.
|
||||||
|
while acc.len() >= 256 {
|
||||||
|
block.copy_from_slice(&acc[..256]);
|
||||||
|
acc.drain(..256);
|
||||||
|
total_blocks += 1;
|
||||||
|
|
||||||
|
processor.process_block(&mut block);
|
||||||
|
|
||||||
|
out.clear();
|
||||||
|
for v in &block {
|
||||||
|
for _ in 0..out_ch {
|
||||||
|
out.push(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = output.push_slice(&out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("dsp: processed {total_blocks} blocks / {total_samples} samples");
|
||||||
|
processor.stop_recording();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rms(x: &[f32]) -> f32 {
|
||||||
|
let s: f32 = x.iter().map(|v| v * v).sum();
|
||||||
|
(s / x.len().max(1) as f32).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
let n = 4096;
|
||||||
|
if x.len() < n {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(n);
|
||||||
|
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let bins = n / 2;
|
||||||
|
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
||||||
|
let mut best = (0usize, 0.0f32);
|
||||||
|
for b in 20..(bins / 4) {
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=4 {
|
||||||
|
if b * h < bins {
|
||||||
|
s += mags[b * h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s > best.1 {
|
||||||
|
best = (b, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs as f32 * best.0 as f32 / n as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preset_control(idx: usize) -> Arc<Mutex<Control>> {
|
||||||
|
Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: idx,
|
||||||
|
..Control::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn girl_preset_produces_energy_on_sine() {
|
||||||
|
// Clean preset + manual pitch delta = pitch-only (no formant shift).
|
||||||
|
let control = Arc::new(Mutex::new(Control {
|
||||||
|
preset_idx: 0,
|
||||||
|
pitch_delta: 5.0,
|
||||||
|
..Control::default()
|
||||||
|
}));
|
||||||
|
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
// Harmonic-rich tone (closer to voice than a pure sine).
|
||||||
|
let input: Vec<f32> = (0..96000)
|
||||||
|
.map(|i| {
|
||||||
|
let t = 220.0 * i as f32 / 48000.0;
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=12 {
|
||||||
|
s += (2.0 * std::f32::consts::PI * h as f32 * t).sin() / h as f32;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let out = feed_blocks(&mut p, &input);
|
||||||
|
let r = rms(&out[4000..]);
|
||||||
|
assert!(r.is_finite() && r > 0.05, "girl sine rms too low: {r}");
|
||||||
|
|
||||||
|
// Pitch must actually shift: 220 Hz * 2^(5/12) ~= 293.7 Hz.
|
||||||
|
let est = dominant_hz(&out[20000..96000], 48000);
|
||||||
|
assert!(
|
||||||
|
(est - 293.7).abs() < 12.0,
|
||||||
|
"girl pitch expected ~293.7 Hz, got {est:.1} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn girl_preset_produces_energy_on_noise() {
|
||||||
|
let control = preset_control(1);
|
||||||
|
let mut p = Processor::new(48000, control, Arc::new(Mutex::new(Stats::default())));
|
||||||
|
let mut state = 0x12345678u32;
|
||||||
|
let mut input = Vec::new();
|
||||||
|
for _ in 0..96000 {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
input.push(state as f32 / u32::MAX as f32 * 2.0 - 1.0);
|
||||||
|
}
|
||||||
|
// Scale to ~mic-hiss level.
|
||||||
|
for v in input.iter_mut() {
|
||||||
|
*v *= 0.01;
|
||||||
|
}
|
||||||
|
let out = feed_blocks(&mut p, &input);
|
||||||
|
let r = rms(&out[4000..]);
|
||||||
|
assert!(r.is_finite() && r > 0.0005, "girl noise rms too low: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed input through the processor in the same fixed-size blocks the
|
||||||
|
/// real worker uses, collecting the output.
|
||||||
|
fn feed_blocks(p: &mut Processor, input: &[f32]) -> Vec<f32> {
|
||||||
|
let mut out_all = Vec::with_capacity(input.len());
|
||||||
|
for chunk in input.chunks(256) {
|
||||||
|
let mut block = chunk.to_vec();
|
||||||
|
block.resize(256, 0.0);
|
||||||
|
p.process_block(&mut block);
|
||||||
|
out_all.extend_from_slice(&block[..chunk.len()]);
|
||||||
|
}
|
||||||
|
out_all
|
||||||
|
}
|
||||||
|
}
|
||||||
605
src/dsp/effects.rs
Normal file
605
src/dsp/effects.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
//! Effects chain: small processing units applied to the mono stream after
|
||||||
|
//! pitch/formant shifting. Each effect mutates the block in place.
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
pub trait Effect: Send {
|
||||||
|
fn process(&mut self, mono: &mut [f32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Distortion (waveshaper)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Distortion {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Distortion {
|
||||||
|
pub fn new(drive: f32, output: f32) -> Self {
|
||||||
|
Self { drive, output }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Distortion {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s = (*s * self.drive).tanh() * self.output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bitcrusher (bit depth reduction + sample rate reduction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Bitcrush {
|
||||||
|
bits: u8,
|
||||||
|
sample_rate_div: u32,
|
||||||
|
/// Max boost applied to quiet input (auto-level normalization).
|
||||||
|
max_gain: f32,
|
||||||
|
/// Target peak level after normalization.
|
||||||
|
target: f32,
|
||||||
|
gain_smooth: f32,
|
||||||
|
hold: f32,
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bitcrush {
|
||||||
|
/// `max_gain`/`target` normalize each block before quantization so the
|
||||||
|
/// effect works on quiet mics without clipping loud ones.
|
||||||
|
pub fn new(bits: u8, sample_rate_div: u32, max_gain: f32, target: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
bits: bits.max(2),
|
||||||
|
sample_rate_div: sample_rate_div.max(1),
|
||||||
|
max_gain: max_gain.max(1.0),
|
||||||
|
target: target.clamp(0.1, 1.0),
|
||||||
|
gain_smooth: 1.0,
|
||||||
|
hold: 0.0,
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Bitcrush {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let levels = (1i32 << (self.bits - 1)) as f32;
|
||||||
|
|
||||||
|
// Auto-level: bring the block peak up to `target` (with smoothing).
|
||||||
|
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
|
||||||
|
let gain = if peak > 1e-6 {
|
||||||
|
(self.target / peak).clamp(1.0, self.max_gain)
|
||||||
|
} else {
|
||||||
|
self.max_gain
|
||||||
|
};
|
||||||
|
self.gain_smooth += (gain - self.gain_smooth) * 0.3;
|
||||||
|
let g = self.gain_smooth;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
self.count = (self.count + 1) % self.sample_rate_div;
|
||||||
|
if self.count == 0 {
|
||||||
|
self.hold = *s;
|
||||||
|
}
|
||||||
|
let q = (self.hold * g * levels).round();
|
||||||
|
*s = (q / levels).clamp(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Freeverb-style reverb
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Comb {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
filter: f32,
|
||||||
|
feedback: f32,
|
||||||
|
damp1: f32,
|
||||||
|
damp2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Comb {
|
||||||
|
fn new(len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0.0; len],
|
||||||
|
idx: 0,
|
||||||
|
filter: 0.0,
|
||||||
|
feedback: 0.84,
|
||||||
|
damp1: 0.5,
|
||||||
|
damp2: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let output = self.buf[self.idx];
|
||||||
|
self.filter = output * self.damp2 + self.filter * self.damp1;
|
||||||
|
self.buf[self.idx] = input + self.filter * self.feedback;
|
||||||
|
self.idx += 1;
|
||||||
|
if self.idx >= self.buf.len() {
|
||||||
|
self.idx = 0;
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Allpass {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
feedback: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Allpass {
|
||||||
|
fn new(len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0.0; len],
|
||||||
|
idx: 0,
|
||||||
|
feedback: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn process(&mut self, input: f32) -> f32 {
|
||||||
|
let bufout = self.buf[self.idx];
|
||||||
|
self.buf[self.idx] = input + bufout * self.feedback;
|
||||||
|
self.idx += 1;
|
||||||
|
if self.idx >= self.buf.len() {
|
||||||
|
self.idx = 0;
|
||||||
|
}
|
||||||
|
bufout - input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Reverb {
|
||||||
|
combs: Vec<Comb>,
|
||||||
|
allpasses: Vec<Allpass>,
|
||||||
|
wet: f32,
|
||||||
|
room: f32,
|
||||||
|
damp: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reverb {
|
||||||
|
pub fn new(sample_rate: u32, room: f32, damp: f32, wet: f32) -> Self {
|
||||||
|
let scale = sample_rate as f32 / 44100.0;
|
||||||
|
let comb_lens = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
|
||||||
|
let allpass_lens = [556, 441, 341, 225];
|
||||||
|
|
||||||
|
let combs: Vec<Comb> = comb_lens
|
||||||
|
.iter()
|
||||||
|
.map(|&l| Comb::new((l as f32 * scale) as usize))
|
||||||
|
.collect();
|
||||||
|
let allpasses: Vec<Allpass> = allpass_lens
|
||||||
|
.iter()
|
||||||
|
.map(|&l| Allpass::new((l as f32 * scale) as usize))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut reverb = Self {
|
||||||
|
combs,
|
||||||
|
allpasses,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
room: room.clamp(0.0, 1.0),
|
||||||
|
damp: damp.clamp(0.0, 1.0),
|
||||||
|
};
|
||||||
|
reverb.apply_params();
|
||||||
|
reverb
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_params(&mut self) {
|
||||||
|
// Map room size 0..1 to feedback 0..~0.98.
|
||||||
|
let feedback = self.room * 0.88 + 0.10;
|
||||||
|
for comb in &mut self.combs {
|
||||||
|
comb.feedback = feedback;
|
||||||
|
comb.damp1 = self.damp;
|
||||||
|
comb.damp2 = 1.0 - self.damp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Reverb {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let input = *s;
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for comb in &mut self.combs {
|
||||||
|
acc += comb.process(input);
|
||||||
|
}
|
||||||
|
acc /= self.combs.len() as f32;
|
||||||
|
for ap in &mut self.allpasses {
|
||||||
|
acc = ap.process(acc);
|
||||||
|
}
|
||||||
|
*s = input * dry + acc * wet * 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chorus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Chorus {
|
||||||
|
delay: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
cap: usize,
|
||||||
|
depth: f32,
|
||||||
|
rate: f32,
|
||||||
|
wet: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chorus {
|
||||||
|
pub fn new(sample_rate: u32, depth_ms: f32, rate_hz: f32, wet: f32) -> Self {
|
||||||
|
let depth = depth_ms / 1000.0 * sample_rate as f32;
|
||||||
|
let cap = (sample_rate as f32 * 0.1) as usize + (depth * 4.0) as usize;
|
||||||
|
Self {
|
||||||
|
delay: vec![0.0; cap],
|
||||||
|
idx: 0,
|
||||||
|
cap,
|
||||||
|
depth: depth.max(1.0),
|
||||||
|
rate: rate_hz,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn read(&self, offset: f32) -> f32 {
|
||||||
|
let mut pos = self.idx as f32 - offset;
|
||||||
|
if pos < 0.0 {
|
||||||
|
pos += self.cap as f32;
|
||||||
|
}
|
||||||
|
let i = pos.floor() as usize;
|
||||||
|
let j = (i + 1) % self.cap;
|
||||||
|
let t = pos - i as f32;
|
||||||
|
self.delay[i] * (1.0 - t) + self.delay[j] * t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Chorus {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let depth = self.depth;
|
||||||
|
let rate = self.rate;
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
let cap = self.cap as f32;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
self.delay[self.idx] = *s;
|
||||||
|
let lfo = (2.0 * PI * self.phase).sin();
|
||||||
|
|
||||||
|
// Two slightly dephased voices.
|
||||||
|
let d1 = depth * (0.35 + 0.35 * lfo);
|
||||||
|
let d2 = depth * (0.35 - 0.35 * lfo);
|
||||||
|
let v1 = self.read(d1);
|
||||||
|
let v2 = self.read(d2);
|
||||||
|
|
||||||
|
*s = *s * dry + (v1 + v2) * 0.5 * wet;
|
||||||
|
|
||||||
|
self.idx += 1;
|
||||||
|
if self.idx as f32 >= cap {
|
||||||
|
self.idx = 0;
|
||||||
|
}
|
||||||
|
self.phase += rate / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ring modulator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct RingMod {
|
||||||
|
freq: f32,
|
||||||
|
depth: f32,
|
||||||
|
phase: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingMod {
|
||||||
|
pub fn new(sample_rate: u32, freq_hz: f32, depth: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
freq: freq_hz,
|
||||||
|
depth: depth.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for RingMod {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let depth = self.depth;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let m = 1.0 - depth + depth * (2.0 * PI * self.phase).sin();
|
||||||
|
*s *= m;
|
||||||
|
self.phase += self.freq / self.fs;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Biquad filters (RBJ cookbook) for bandpass / narrow EQ
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Biquad {
|
||||||
|
b0: f32,
|
||||||
|
b1: f32,
|
||||||
|
b2: f32,
|
||||||
|
a1: f32,
|
||||||
|
a2: f32,
|
||||||
|
z1: f32,
|
||||||
|
z2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biquad {
|
||||||
|
/// Constant-skirt bandpass.
|
||||||
|
pub fn bandpass(sample_rate: u32, f0: f32, q: f32) -> Self {
|
||||||
|
let w0 = 2.0 * PI * f0 / sample_rate as f32;
|
||||||
|
let alpha = w0.sin() / (2.0 * q);
|
||||||
|
let b0 = alpha;
|
||||||
|
let b1 = 0.0;
|
||||||
|
let b2 = -alpha;
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
let a1 = -2.0 * w0.cos();
|
||||||
|
let a2 = 1.0 - alpha;
|
||||||
|
Self::normalized(b0, b1, b2, a1, a2, a0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32, a0: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
b0: b0 / a0,
|
||||||
|
b1: b1 / a0,
|
||||||
|
b2: b2 / a0,
|
||||||
|
a1: a1 / a0,
|
||||||
|
a2: a2 / a0,
|
||||||
|
z1: 0.0,
|
||||||
|
z2: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn tick(&mut self, x: f32) -> f32 {
|
||||||
|
let y = self.b0 * x + self.z1;
|
||||||
|
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
||||||
|
self.z2 = self.b2 * x - self.a2 * y;
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Bandpass {
|
||||||
|
filter: Biquad,
|
||||||
|
mix: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bandpass {
|
||||||
|
pub fn new(sample_rate: u32, low_hz: f32, high_hz: f32, mix: f32) -> Self {
|
||||||
|
let f0 = (low_hz * high_hz).sqrt();
|
||||||
|
let q = f0 / (high_hz - low_hz).max(1.0);
|
||||||
|
Self {
|
||||||
|
filter: Biquad::bandpass(sample_rate, f0, q),
|
||||||
|
mix: mix.clamp(0.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Bandpass {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let mix = self.mix;
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let y = self.filter.tick(*s);
|
||||||
|
*s = *s * (1.0 - mix) + y * mix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// White noise generator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Noise {
|
||||||
|
gain: f32,
|
||||||
|
state: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Noise {
|
||||||
|
pub fn new(gain: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
gain,
|
||||||
|
state: 0x9E3779B9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn next(&mut self) -> f32 {
|
||||||
|
let mut x = self.state;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 17;
|
||||||
|
x ^= x << 5;
|
||||||
|
self.state = x;
|
||||||
|
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Noise {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
*s += self.next() * self.gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compressor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Compressor {
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
makeup_db: f32,
|
||||||
|
env: f32,
|
||||||
|
gain: f32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compressor {
|
||||||
|
pub fn new(sample_rate: u32, threshold_db: f32, ratio: f32, makeup_db: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
threshold_db,
|
||||||
|
ratio: ratio.max(1.0),
|
||||||
|
makeup_db,
|
||||||
|
env: 0.0,
|
||||||
|
gain: 1.0,
|
||||||
|
fs: sample_rate as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for Compressor {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let threshold = 10f32.powf(self.threshold_db / 20.0);
|
||||||
|
let makeup = 10f32.powf(self.makeup_db / 20.0);
|
||||||
|
let release = 1.0 / (self.fs * 0.1).max(1.0); // ~100 ms
|
||||||
|
let attack = 1.0 / (self.fs * 0.002).max(1.0); // ~2 ms
|
||||||
|
let inv_ratio = 1.0 - 1.0 / self.ratio;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let peak = s.abs();
|
||||||
|
self.env *= 1.0 - release;
|
||||||
|
if peak > self.env {
|
||||||
|
self.env = self.env + (peak - self.env) * attack;
|
||||||
|
}
|
||||||
|
|
||||||
|
let over_db = 20.0 * (self.env / threshold.max(1e-9)).log10();
|
||||||
|
let target_gain = if over_db > 0.0 {
|
||||||
|
let g_db = -over_db * inv_ratio;
|
||||||
|
10f32.powf(g_db / 20.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
self.gain += (target_gain - self.gain) * attack;
|
||||||
|
*s *= self.gain * makeup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Channel vocoder (classic robot voice)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
|
pub enum CarrierKind {
|
||||||
|
Noise,
|
||||||
|
Saw,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChannelVocoder {
|
||||||
|
bands: Vec<VocoderBand>,
|
||||||
|
carrier: CarrierKind,
|
||||||
|
bands_count: usize,
|
||||||
|
wet: f32,
|
||||||
|
phase: f32,
|
||||||
|
noise_state: u32,
|
||||||
|
fs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VocoderBand {
|
||||||
|
analysis: Biquad,
|
||||||
|
synthesis: Biquad,
|
||||||
|
env: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelVocoder {
|
||||||
|
pub fn new(sample_rate: u32, bands_count: usize, carrier: CarrierKind, wet: f32) -> Self {
|
||||||
|
let fs = sample_rate as f32;
|
||||||
|
let low: f32 = 150.0;
|
||||||
|
let high: f32 = 4500.0;
|
||||||
|
let n = bands_count.max(2);
|
||||||
|
let mut bands = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let f = low * (high / low).powf(i as f32 / (n - 1) as f32);
|
||||||
|
let f0: f32 = f;
|
||||||
|
let q = 8.0;
|
||||||
|
let analysis = Biquad::bandpass(sample_rate, f0, q);
|
||||||
|
let synthesis = Biquad::bandpass(sample_rate, f0, q);
|
||||||
|
bands.push(VocoderBand {
|
||||||
|
analysis,
|
||||||
|
synthesis,
|
||||||
|
env: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
bands,
|
||||||
|
carrier,
|
||||||
|
bands_count: n,
|
||||||
|
wet: wet.clamp(0.0, 1.0),
|
||||||
|
phase: 0.0,
|
||||||
|
noise_state: 0x2545F491,
|
||||||
|
fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn carrier_sample(&mut self) -> f32 {
|
||||||
|
match self.carrier {
|
||||||
|
CarrierKind::Noise => {
|
||||||
|
let mut x = self.noise_state;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 17;
|
||||||
|
x ^= x << 5;
|
||||||
|
self.noise_state = x;
|
||||||
|
x as f32 / u32::MAX as f32 * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
CarrierKind::Saw => {
|
||||||
|
let s = self.phase * 2.0 - 1.0;
|
||||||
|
self.phase += 1.0 / self.fs * 120.0;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effect for ChannelVocoder {
|
||||||
|
fn process(&mut self, mono: &mut [f32]) {
|
||||||
|
let wet = self.wet;
|
||||||
|
let dry = 1.0 - wet;
|
||||||
|
let attack = 0.06;
|
||||||
|
let release = 0.0015;
|
||||||
|
let inv_n = 1.0 / self.bands_count as f32;
|
||||||
|
|
||||||
|
for s in mono.iter_mut() {
|
||||||
|
let input = *s;
|
||||||
|
let carrier = self.carrier_sample();
|
||||||
|
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for band in &mut self.bands {
|
||||||
|
let a = band.analysis.tick(input);
|
||||||
|
let a_env = a.abs();
|
||||||
|
if a_env > band.env {
|
||||||
|
band.env += (a_env - band.env) * attack;
|
||||||
|
} else {
|
||||||
|
band.env += (a_env - band.env) * release;
|
||||||
|
}
|
||||||
|
acc += band.synthesis.tick(carrier) * band.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = input * dry + acc * wet * inv_n * 4.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/dsp/formant.rs
Normal file
12
src/dsp/formant.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//! Formant shifting helpers.
|
||||||
|
//!
|
||||||
|
//! Formant shifting (changing the vocal-tract resonances without changing
|
||||||
|
//! pitch) is performed by the phase vocoder in [`crate::dsp::pitch`] via its
|
||||||
|
//! `formant_ratio` parameter. This module holds the tiny helper used to convert
|
||||||
|
//! a manual formant adjustment in semitones into the ratio the vocoder expects.
|
||||||
|
|
||||||
|
/// Convert a formant adjustment in semitones into a ratio factor
|
||||||
|
/// (1.0 = unchanged, >1 = higher/brighter).
|
||||||
|
pub fn ratio_from_semitones(semitones: f32) -> f32 {
|
||||||
|
2f32.powf(semitones / 12.0)
|
||||||
|
}
|
||||||
64
src/dsp/gate.rs
Normal file
64
src/dsp/gate.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
//! Noise gate: suppresses quiet segments below the threshold with a short
|
||||||
|
//! attack / longer release and a 6 dB hysteresis to avoid chatter.
|
||||||
|
|
||||||
|
/// Convert dB to linear amplitude.
|
||||||
|
pub fn db_to_lin(db: f32) -> f32 {
|
||||||
|
10f32.powf(db / 20.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NoiseGate {
|
||||||
|
pub threshold_db: f32,
|
||||||
|
level: f32,
|
||||||
|
gain: f32,
|
||||||
|
open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoiseGate {
|
||||||
|
pub fn new(threshold_db: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
threshold_db,
|
||||||
|
level: 0.0,
|
||||||
|
gain: 0.0,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_threshold_db(&mut self, db: f32) {
|
||||||
|
self.threshold_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(&mut self, block: &mut [f32]) {
|
||||||
|
if block.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for &s in block.iter() {
|
||||||
|
sum += s * s;
|
||||||
|
}
|
||||||
|
let rms = (sum / block.len() as f32).sqrt();
|
||||||
|
|
||||||
|
// Envelope follower: fast attack, slow release.
|
||||||
|
if rms > self.level {
|
||||||
|
self.level += (rms - self.level) * 0.5;
|
||||||
|
} else {
|
||||||
|
self.level += (rms - self.level) * 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
let threshold = db_to_lin(self.threshold_db);
|
||||||
|
|
||||||
|
// Hysteresis: open above threshold, close 6 dB below it.
|
||||||
|
if self.level > threshold {
|
||||||
|
self.open = true;
|
||||||
|
} else if self.level < threshold * 0.5 {
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = if self.open { 1.0 } else { 0.0 };
|
||||||
|
for s in block.iter_mut() {
|
||||||
|
// Per-sample smoothing so the gate never clicks.
|
||||||
|
self.gain += (target - self.gain) * 0.15;
|
||||||
|
*s *= self.gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/dsp/mod.rs
Normal file
7
src/dsp/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod chain;
|
||||||
|
pub mod effects;
|
||||||
|
pub mod formant;
|
||||||
|
pub mod gate;
|
||||||
|
pub mod pitch;
|
||||||
|
pub mod presets;
|
||||||
|
pub mod stats;
|
||||||
416
src/dsp/pitch.rs
Normal file
416
src/dsp/pitch.rs
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
//! Real-time pitch shifting via phase vocoder.
|
||||||
|
//!
|
||||||
|
//! The phase vocoder resamples the spectral *residual* (fine structure) to
|
||||||
|
//! change pitch while keeping the spectral envelope (formants) intact, and
|
||||||
|
//! independently resamples the envelope to change formants. This gives us a
|
||||||
|
//! single FFT-based stage that drives both the pitch and formant controls.
|
||||||
|
//!
|
||||||
|
//! Latency is one FFT frame (default 1024 samples ≈ 21 ms @ 48 kHz).
|
||||||
|
//! Hann window with 75% overlap (hop = N/4) is a valid COLA window, so the
|
||||||
|
//! overlap-add reconstruction is lossless in the bypass case.
|
||||||
|
|
||||||
|
use rustfft::num_complex::Complex;
|
||||||
|
use rustfft::{Fft, FftPlanner};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Linear interpolation over `arr` at fractional position `pos`.
|
||||||
|
fn interpolate(arr: &[f32], pos: f32) -> f32 {
|
||||||
|
let max = arr.len() as f32 - 1.0;
|
||||||
|
let pos = pos.clamp(0.0, max);
|
||||||
|
let i = pos.floor() as usize;
|
||||||
|
let j = (i + 1).min(arr.len() - 1);
|
||||||
|
let t = pos - i as f32;
|
||||||
|
arr[i] * (1.0 - t) + arr[j] * t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a phase difference into (-PI, PI].
|
||||||
|
fn princarg(x: f32) -> f32 {
|
||||||
|
x - 2.0 * std::f32::consts::PI * (x / (2.0 * std::f32::consts::PI)).round()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PitchShifter {
|
||||||
|
n: usize,
|
||||||
|
hop: usize,
|
||||||
|
|
||||||
|
window: Vec<f32>,
|
||||||
|
norm: f32,
|
||||||
|
|
||||||
|
fft: Arc<dyn Fft<f32>>,
|
||||||
|
ifft: Arc<dyn Fft<f32>>,
|
||||||
|
env_ifft: Arc<dyn Fft<f32>>,
|
||||||
|
env_fft: Arc<dyn Fft<f32>>,
|
||||||
|
|
||||||
|
in_buf: Vec<f32>,
|
||||||
|
out_buf: Vec<f32>,
|
||||||
|
|
||||||
|
spec: Vec<Complex<f32>>,
|
||||||
|
mag: Vec<f32>,
|
||||||
|
phase: Vec<f32>,
|
||||||
|
logmag: Vec<f32>,
|
||||||
|
env: Vec<f32>,
|
||||||
|
cep: Vec<Complex<f32>>,
|
||||||
|
prev_phase: Vec<f32>,
|
||||||
|
out_phase: Vec<f32>,
|
||||||
|
|
||||||
|
/// Quefrency bins kept by the cepstral lifter (spectral envelope cutoff).
|
||||||
|
lifter_len: usize,
|
||||||
|
|
||||||
|
/// Pitch factor: output pitch = input pitch * ratio (1.0 = unchanged).
|
||||||
|
pub pitch_ratio: f32,
|
||||||
|
/// Formant factor: output formants = input formants * ratio (1.0 = unchanged).
|
||||||
|
pub formant_ratio: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PitchShifter {
|
||||||
|
pub fn new(fft_size: usize, hop: usize, sample_rate: u32) -> Self {
|
||||||
|
debug_assert!(fft_size.is_multiple_of(hop));
|
||||||
|
let frames = fft_size / hop;
|
||||||
|
|
||||||
|
let mut window = vec![0.0; fft_size];
|
||||||
|
for (i, w) in window.iter_mut().enumerate() {
|
||||||
|
*w =
|
||||||
|
0.5 - 0.5 * (2.0 * std::f32::consts::PI * i as f32 / (fft_size as f32 - 1.0)).cos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady-state OLA normalization: every output sample is covered by
|
||||||
|
// `frames` overlapping windows; sum of w^2 over those offsets.
|
||||||
|
let mut norm = 0.0;
|
||||||
|
for i in 0..frames {
|
||||||
|
let idx = i * hop;
|
||||||
|
norm += window[idx] * window[idx];
|
||||||
|
}
|
||||||
|
debug_assert!(norm > 0.0);
|
||||||
|
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(fft_size);
|
||||||
|
let ifft = planner.plan_fft_inverse(fft_size);
|
||||||
|
let env_ifft = planner.plan_fft_inverse(fft_size);
|
||||||
|
let env_fft = planner.plan_fft_forward(fft_size);
|
||||||
|
|
||||||
|
let _ = sample_rate;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
n: fft_size,
|
||||||
|
hop,
|
||||||
|
window,
|
||||||
|
norm,
|
||||||
|
fft,
|
||||||
|
ifft,
|
||||||
|
env_ifft,
|
||||||
|
env_fft,
|
||||||
|
in_buf: Vec::new(),
|
||||||
|
out_buf: vec![0.0; fft_size],
|
||||||
|
spec: vec![Complex::new(0.0, 0.0); fft_size],
|
||||||
|
mag: vec![0.0; fft_size / 2 + 1],
|
||||||
|
phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
logmag: vec![0.0; fft_size / 2 + 1],
|
||||||
|
|
||||||
|
env: vec![0.0; fft_size / 2 + 1],
|
||||||
|
cep: vec![Complex::new(0.0, 0.0); fft_size],
|
||||||
|
prev_phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
out_phase: vec![0.0; fft_size / 2 + 1],
|
||||||
|
lifter_len: fft_size / 8,
|
||||||
|
pitch_ratio: 1.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream `input` through the shifter, appending the same number of
|
||||||
|
/// processed samples to `output` (length is preserved).
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut Vec<f32>) {
|
||||||
|
for &sample in input {
|
||||||
|
self.in_buf.push(sample);
|
||||||
|
if self.in_buf.len() == self.n {
|
||||||
|
self.process_frame();
|
||||||
|
self.in_buf.drain(..self.hop);
|
||||||
|
output.extend_from_slice(&self.out_buf[..self.hop]);
|
||||||
|
self.out_buf.drain(..self.hop);
|
||||||
|
self.out_buf.resize(self.n, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_frame(&mut self) {
|
||||||
|
let n2 = self.n / 2;
|
||||||
|
|
||||||
|
// Analysis: window + forward FFT.
|
||||||
|
for (i, c) in self.spec.iter_mut().enumerate() {
|
||||||
|
*c = Complex::new(self.in_buf[i] * self.window[i], 0.0);
|
||||||
|
}
|
||||||
|
self.fft.process(&mut self.spec);
|
||||||
|
|
||||||
|
for i in 0..=n2 {
|
||||||
|
let c = self.spec[i];
|
||||||
|
let m = c.norm();
|
||||||
|
self.mag[i] = m;
|
||||||
|
self.phase[i] = c.arg();
|
||||||
|
self.logmag[i] = (m + 1e-12).ln();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cepstral spectral-envelope estimation (used for formant control).
|
||||||
|
self.compute_envelope();
|
||||||
|
|
||||||
|
let pr = self.pitch_ratio;
|
||||||
|
let fr = self.formant_ratio;
|
||||||
|
let p_active = (pr - 1.0).abs() > 1e-4;
|
||||||
|
let f_active = (fr - 1.0).abs() > 1e-4;
|
||||||
|
|
||||||
|
if p_active || f_active {
|
||||||
|
// Phase propagation (Laroche & Dolson): track the instantaneous
|
||||||
|
// frequency of each input bin and advance the output phase so the
|
||||||
|
// reconstructed components stay coherent.
|
||||||
|
let hop = self.hop as f32;
|
||||||
|
let nf = self.n as f32;
|
||||||
|
let mut new_mag = vec![0.0; n2 + 1];
|
||||||
|
let mut new_phase = vec![0.0; n2 + 1];
|
||||||
|
for (b, nm) in new_mag.iter_mut().enumerate() {
|
||||||
|
let bf = b as f32;
|
||||||
|
|
||||||
|
// Source input bin for output bin b under the pitch shift.
|
||||||
|
let src = if p_active { bf / pr } else { bf };
|
||||||
|
let base = interpolate(&self.mag, src);
|
||||||
|
|
||||||
|
// Formants: re-weight the envelope so its shape is shifted by
|
||||||
|
// `fr` independent of pitch. After the pitch resample the
|
||||||
|
// envelope sits at E[bf/pr]; we move it to E[bf/fr].
|
||||||
|
if f_active {
|
||||||
|
let num = interpolate(&self.env, bf / fr);
|
||||||
|
let den = interpolate(&self.env, bf / pr);
|
||||||
|
let ratio = (num / den.max(1e-9)).clamp(0.3, 3.3);
|
||||||
|
*nm = base * ratio;
|
||||||
|
} else {
|
||||||
|
*nm = base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantaneous frequency of the source bin (rad per frame).
|
||||||
|
let si = src.round().clamp(0.0, n2 as f32) as usize;
|
||||||
|
let omega_src = 2.0 * std::f32::consts::PI * si as f32 / nf;
|
||||||
|
let diff = princarg(self.phase[si] - self.prev_phase[si] - omega_src * hop);
|
||||||
|
let inst = omega_src + diff / hop;
|
||||||
|
|
||||||
|
// Output phase advances by the (pitch-shifted) source frequency.
|
||||||
|
let advance = if p_active {
|
||||||
|
inst * hop * pr
|
||||||
|
} else {
|
||||||
|
inst * hop
|
||||||
|
};
|
||||||
|
self.out_phase[b] += advance;
|
||||||
|
new_phase[b] = self.out_phase[b];
|
||||||
|
|
||||||
|
self.prev_phase[si] = self.phase[si];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the conjugate-symmetric spectrum.
|
||||||
|
for (b, (&nm, &ph)) in new_mag.iter().zip(new_phase.iter()).enumerate() {
|
||||||
|
self.spec[b] = Complex::from_polar(nm, ph);
|
||||||
|
if b > 0 && b < n2 {
|
||||||
|
self.spec[self.n - b] = Complex::from_polar(nm, -ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesis: inverse FFT + window + overlap-add.
|
||||||
|
self.ifft.process(&mut self.spec);
|
||||||
|
let scale = 1.0 / (self.n as f32 * self.norm);
|
||||||
|
for i in 0..self.n {
|
||||||
|
let y = self.spec[i].re * self.window[i] * scale;
|
||||||
|
self.out_buf[i] += y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spectral envelope via homomorphic (cepstral) filtering.
|
||||||
|
fn compute_envelope(&mut self) {
|
||||||
|
let n2 = self.n / 2;
|
||||||
|
|
||||||
|
// Conjugate-symmetric spectrum of log-magnitude -> real cepstrum.
|
||||||
|
for i in 0..=n2 {
|
||||||
|
let l = self.logmag[i];
|
||||||
|
self.cep[i] = Complex::new(l, 0.0);
|
||||||
|
if i > 0 && i < n2 {
|
||||||
|
self.cep[self.n - i] = Complex::new(l, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.env_ifft.process(&mut self.cep);
|
||||||
|
let inv = 1.0 / self.n as f32;
|
||||||
|
|
||||||
|
// Lifter: keep low quefrencies (smooth envelope), zero the rest.
|
||||||
|
for (i, c) in self.cep.iter_mut().enumerate() {
|
||||||
|
let keep = if i <= self.lifter_len { 1.0 } else { 0.0 };
|
||||||
|
c.re *= keep * inv;
|
||||||
|
c.im = 0.0;
|
||||||
|
}
|
||||||
|
self.env_fft.process(&mut self.cep);
|
||||||
|
|
||||||
|
for i in 0..=n2 {
|
||||||
|
self.env[i] = self.cep[i].re.exp().max(1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sine(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / fs as f32).sin())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Harmonic-rich tone (sawtooth): narrow pitch lines + broad envelope,
|
||||||
|
/// closer to a real voice than a pure sine.
|
||||||
|
fn saw(freq: f32, fs: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|i| {
|
||||||
|
let t = freq * i as f32 / fs as f32;
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=12 {
|
||||||
|
s += ((2.0 * std::f32::consts::PI * h as f32 * t).sin()) / h as f32;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(x: &[f32]) -> f32 {
|
||||||
|
let s: f32 = x.iter().map(|v| v * v).sum();
|
||||||
|
(s / x.len().max(1) as f32).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_hz(x: &[f32], fs: u32) -> f32 {
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
let n = 4096;
|
||||||
|
if x.len() < n {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(n);
|
||||||
|
let mut buf: Vec<Complex<f32>> = x[..n].iter().map(|&s| Complex::new(s, 0.0)).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let bins = n / 2;
|
||||||
|
let mags: Vec<f32> = (0..bins).map(|b| buf[b].norm()).collect();
|
||||||
|
// Harmonic product sum: find the fundamental whose harmonics add up.
|
||||||
|
let mut best = (0usize, 0.0f32);
|
||||||
|
for b in 20..(bins / 4) {
|
||||||
|
let mut s = 0.0;
|
||||||
|
for h in 1..=4 {
|
||||||
|
if b * h < bins {
|
||||||
|
s += mags[b * h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s > best.1 {
|
||||||
|
best = (b, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs as f32 * best.0 as f32 / n as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bypass_preserves_signal() {
|
||||||
|
let mut sh = PitchShifter::new(1024, 256, 48000);
|
||||||
|
let input = saw(220.0, 48000, 48000);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(&input, &mut out);
|
||||||
|
assert!(out.len() >= 47000, "out.len={}", out.len());
|
||||||
|
assert!(rms(&out[1000..]).is_finite());
|
||||||
|
assert!(
|
||||||
|
rms(&out[1000..]) > 0.05,
|
||||||
|
"bypass rms too low: {}",
|
||||||
|
rms(&out[1000..])
|
||||||
|
);
|
||||||
|
|
||||||
|
use rustfft::FftPlanner;
|
||||||
|
let n = 4096;
|
||||||
|
let seg = &out[2000..2000 + n];
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(n);
|
||||||
|
let mut buf: Vec<Complex<f32>> = seg.iter().map(|&s| Complex::new(s, 0.0)).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let mut peaks: Vec<(usize, f32)> = (0..n / 2).map(|b| (b, buf[b].norm())).collect();
|
||||||
|
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
let top: Vec<String> = peaks
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|(b, m)| format!("{:.0}Hz({m:.0})", b * 48000 / n))
|
||||||
|
.collect();
|
||||||
|
eprintln!("bypass peaks: {}", top.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pitch_shift_produces_energy() {
|
||||||
|
let mut sh = PitchShifter::new(1024, 256, 48000);
|
||||||
|
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = saw(220.0, 48000, 48000);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(&input, &mut out);
|
||||||
|
assert!(
|
||||||
|
out.iter().take(20000).all(|v| v.is_finite()),
|
||||||
|
"non-finite output"
|
||||||
|
);
|
||||||
|
let r = rms(&out[1000..20000]);
|
||||||
|
assert!(r > 0.05, "pitch-shifted rms too low: {r}");
|
||||||
|
|
||||||
|
let est = dominant_hz(&out[1000..20000], 48000);
|
||||||
|
assert!(
|
||||||
|
(est - 293.7).abs() < 8.0,
|
||||||
|
"expected ~293.7 Hz for 220*2^(5/12), got {est:.1} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_feed_matches_chain() {
|
||||||
|
// Mimics dsp::chain's block-by-block feeding with a pending buffer.
|
||||||
|
let mut sh = PitchShifter::new(1024, 256, 48000);
|
||||||
|
sh.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = sine(220.0, 48000, 48000);
|
||||||
|
let block = 2560;
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut out_all = Vec::new();
|
||||||
|
for chunk in input.chunks(block) {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
sh.process(chunk, &mut out);
|
||||||
|
pending.extend(out);
|
||||||
|
let take = chunk.len().min(pending.len());
|
||||||
|
let mut mono = vec![0.0; chunk.len()];
|
||||||
|
mono[..take].copy_from_slice(&pending[..take]);
|
||||||
|
out_all.extend(mono);
|
||||||
|
pending.drain(..take);
|
||||||
|
}
|
||||||
|
assert!(out_all.iter().all(|v| v.is_finite()), "non-finite");
|
||||||
|
let r = rms(&out_all[4000..40000]);
|
||||||
|
assert!(r > 0.05, "block-feed rms too low: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_shot_vs_block_fed() {
|
||||||
|
let mut a = PitchShifter::new(1024, 256, 48000);
|
||||||
|
a.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let input = saw(220.0, 48000, 48000);
|
||||||
|
let mut out_a = Vec::new();
|
||||||
|
a.process(&input, &mut out_a);
|
||||||
|
|
||||||
|
// Block-fed WITHOUT prime/pending, just concatenating shifter output.
|
||||||
|
let mut b = PitchShifter::new(1024, 256, 48000);
|
||||||
|
b.pitch_ratio = 2f32.powf(5.0 / 12.0);
|
||||||
|
let mut out_b = Vec::new();
|
||||||
|
for chunk in input.chunks(1024) {
|
||||||
|
b.process(chunk, &mut out_b);
|
||||||
|
}
|
||||||
|
let n = out_a.len().min(out_b.len());
|
||||||
|
assert!(n > 10000, "n={n}");
|
||||||
|
let mut max_diff = 0.0f32;
|
||||||
|
let mut idx = 0usize;
|
||||||
|
for i in 3000..n {
|
||||||
|
let d = (out_a[i] - out_b[i]).abs();
|
||||||
|
if d > max_diff {
|
||||||
|
max_diff = d;
|
||||||
|
idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("max diff {max_diff} at {idx} of {n}");
|
||||||
|
assert!(
|
||||||
|
max_diff < 1e-3,
|
||||||
|
"block-fed differs from one-shot: max_diff={max_diff}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/dsp/presets.rs
Normal file
298
src/dsp/presets.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
//! Preset voice settings: pitch, formant and an effect chain.
|
||||||
|
|
||||||
|
use crate::dsp::effects::{self, CarrierKind, Effect};
|
||||||
|
|
||||||
|
pub struct Preset {
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Pitch shift in semitones (positive = higher).
|
||||||
|
pub pitch_semitones: f32,
|
||||||
|
/// Formant ratio (1.0 = unchanged, >1 = higher/brighter).
|
||||||
|
pub formant_ratio: f32,
|
||||||
|
/// Effects applied in order after pitch/formant.
|
||||||
|
pub effects: &'static [EffectSpec],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum EffectSpec {
|
||||||
|
Distortion {
|
||||||
|
drive: f32,
|
||||||
|
output: f32,
|
||||||
|
},
|
||||||
|
Bitcrush {
|
||||||
|
bits: u8,
|
||||||
|
sample_rate_div: u32,
|
||||||
|
max_gain: f32,
|
||||||
|
target: f32,
|
||||||
|
},
|
||||||
|
Reverb {
|
||||||
|
room: f32,
|
||||||
|
damp: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
Chorus {
|
||||||
|
depth_ms: f32,
|
||||||
|
rate_hz: f32,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
RingMod {
|
||||||
|
freq_hz: f32,
|
||||||
|
depth: f32,
|
||||||
|
},
|
||||||
|
Bandpass {
|
||||||
|
low_hz: f32,
|
||||||
|
high_hz: f32,
|
||||||
|
mix: f32,
|
||||||
|
},
|
||||||
|
Noise {
|
||||||
|
gain: f32,
|
||||||
|
},
|
||||||
|
Compressor {
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
makeup_db: f32,
|
||||||
|
},
|
||||||
|
Vocoder {
|
||||||
|
bands: usize,
|
||||||
|
carrier: CarrierKind,
|
||||||
|
wet: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const PRESETS: &[Preset] = &[
|
||||||
|
Preset {
|
||||||
|
name: "Clean",
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Girl / Anime",
|
||||||
|
pitch_semitones: 5.0,
|
||||||
|
formant_ratio: 1.25,
|
||||||
|
effects: &[],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Boy",
|
||||||
|
pitch_semitones: 3.0,
|
||||||
|
formant_ratio: 1.12,
|
||||||
|
effects: &[],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Manly / Deep",
|
||||||
|
pitch_semitones: -4.0,
|
||||||
|
formant_ratio: 0.78,
|
||||||
|
effects: &[EffectSpec::Compressor {
|
||||||
|
threshold_db: -20.0,
|
||||||
|
ratio: 3.0,
|
||||||
|
makeup_db: 2.0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Demon",
|
||||||
|
pitch_semitones: -9.0,
|
||||||
|
formant_ratio: 0.85,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Distortion {
|
||||||
|
drive: 6.0,
|
||||||
|
output: 0.7,
|
||||||
|
},
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.6,
|
||||||
|
damp: 0.4,
|
||||||
|
wet: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Robot",
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands: 16,
|
||||||
|
carrier: CarrierKind::Noise,
|
||||||
|
wet: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits: 8,
|
||||||
|
sample_rate_div: 2,
|
||||||
|
max_gain: 8.0,
|
||||||
|
target: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "8-bit",
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[EffectSpec::Bitcrush {
|
||||||
|
bits: 5,
|
||||||
|
sample_rate_div: 4,
|
||||||
|
max_gain: 32.0,
|
||||||
|
target: 0.6,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Alien",
|
||||||
|
pitch_semitones: -2.0,
|
||||||
|
formant_ratio: 1.15,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::RingMod {
|
||||||
|
freq_hz: 55.0,
|
||||||
|
depth: 0.5,
|
||||||
|
},
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms: 6.0,
|
||||||
|
rate_hz: 0.5,
|
||||||
|
wet: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Radio",
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 300.0,
|
||||||
|
high_hz: 3000.0,
|
||||||
|
mix: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Noise { gain: 0.04 },
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -24.0,
|
||||||
|
ratio: 4.0,
|
||||||
|
makeup_db: 6.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Megaphone",
|
||||||
|
pitch_semitones: 0.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Distortion {
|
||||||
|
drive: 2.5,
|
||||||
|
output: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz: 800.0,
|
||||||
|
high_hz: 2600.0,
|
||||||
|
mix: 1.0,
|
||||||
|
},
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db: -18.0,
|
||||||
|
ratio: 5.0,
|
||||||
|
makeup_db: 4.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Ghost",
|
||||||
|
pitch_semitones: -3.0,
|
||||||
|
formant_ratio: 1.05,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Reverb {
|
||||||
|
room: 0.95,
|
||||||
|
damp: 0.2,
|
||||||
|
wet: 0.8,
|
||||||
|
},
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms: 8.0,
|
||||||
|
rate_hz: 0.4,
|
||||||
|
wet: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "Cyborg",
|
||||||
|
pitch_semitones: 2.0,
|
||||||
|
formant_ratio: 1.0,
|
||||||
|
effects: &[
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands: 16,
|
||||||
|
carrier: CarrierKind::Saw,
|
||||||
|
wet: 0.9,
|
||||||
|
},
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits: 8,
|
||||||
|
sample_rate_div: 2,
|
||||||
|
max_gain: 8.0,
|
||||||
|
target: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
pub fn find(name: &str) -> Option<usize> {
|
||||||
|
PRESETS.iter().position(|p| {
|
||||||
|
p.name.eq_ignore_ascii_case(name)
|
||||||
|
|| p.name
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim().eq_ignore_ascii_case(name))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_effects(specs: &[EffectSpec], sample_rate: u32) -> Vec<Box<dyn Effect>> {
|
||||||
|
specs.iter().map(|s| build_effect(s, sample_rate)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_effect(spec: &EffectSpec, sample_rate: u32) -> Box<dyn Effect> {
|
||||||
|
match spec {
|
||||||
|
EffectSpec::Distortion { drive, output } => {
|
||||||
|
Box::new(effects::Distortion::new(*drive, *output))
|
||||||
|
}
|
||||||
|
EffectSpec::Bitcrush {
|
||||||
|
bits,
|
||||||
|
sample_rate_div,
|
||||||
|
max_gain,
|
||||||
|
target,
|
||||||
|
} => Box::new(effects::Bitcrush::new(
|
||||||
|
*bits,
|
||||||
|
*sample_rate_div,
|
||||||
|
*max_gain,
|
||||||
|
*target,
|
||||||
|
)),
|
||||||
|
EffectSpec::Reverb { room, damp, wet } => {
|
||||||
|
Box::new(effects::Reverb::new(sample_rate, *room, *damp, *wet))
|
||||||
|
}
|
||||||
|
EffectSpec::Chorus {
|
||||||
|
depth_ms,
|
||||||
|
rate_hz,
|
||||||
|
wet,
|
||||||
|
} => Box::new(effects::Chorus::new(sample_rate, *depth_ms, *rate_hz, *wet)),
|
||||||
|
EffectSpec::RingMod { freq_hz, depth } => {
|
||||||
|
Box::new(effects::RingMod::new(sample_rate, *freq_hz, *depth))
|
||||||
|
}
|
||||||
|
EffectSpec::Bandpass {
|
||||||
|
low_hz,
|
||||||
|
high_hz,
|
||||||
|
mix,
|
||||||
|
} => Box::new(effects::Bandpass::new(sample_rate, *low_hz, *high_hz, *mix)),
|
||||||
|
EffectSpec::Noise { gain } => Box::new(effects::Noise::new(*gain)),
|
||||||
|
EffectSpec::Compressor {
|
||||||
|
threshold_db,
|
||||||
|
ratio,
|
||||||
|
makeup_db,
|
||||||
|
} => Box::new(effects::Compressor::new(
|
||||||
|
sample_rate,
|
||||||
|
*threshold_db,
|
||||||
|
*ratio,
|
||||||
|
*makeup_db,
|
||||||
|
)),
|
||||||
|
EffectSpec::Vocoder {
|
||||||
|
bands,
|
||||||
|
carrier,
|
||||||
|
wet,
|
||||||
|
} => Box::new(effects::ChannelVocoder::new(
|
||||||
|
sample_rate,
|
||||||
|
*bands,
|
||||||
|
*carrier,
|
||||||
|
*wet,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/dsp/stats.rs
Normal file
35
src/dsp/stats.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//! Runtime statistics produced by the DSP worker and shown in the TUI.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub struct Stats {
|
||||||
|
/// Smoothed input level (RMS, linear).
|
||||||
|
pub input_rms: f32,
|
||||||
|
/// Smoothed output level (RMS, linear).
|
||||||
|
pub output_rms: f32,
|
||||||
|
pub peak_in: f32,
|
||||||
|
pub peak_out: f32,
|
||||||
|
pub blocks: u64,
|
||||||
|
pub samples: u64,
|
||||||
|
/// Smoothed DSP load in percent of one core.
|
||||||
|
pub cpu_pct: f32,
|
||||||
|
/// Estimated DSP latency in milliseconds.
|
||||||
|
pub dsp_latency_ms: f32,
|
||||||
|
pub started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Stats {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
input_rms: 0.0,
|
||||||
|
output_rms: 0.0,
|
||||||
|
peak_in: 0.0,
|
||||||
|
peak_out: 0.0,
|
||||||
|
blocks: 0,
|
||||||
|
samples: 0,
|
||||||
|
cpu_pct: 0.0,
|
||||||
|
dsp_latency_ms: 0.0,
|
||||||
|
started: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/main.rs
Normal file
113
src/main.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
mod audio;
|
||||||
|
mod config;
|
||||||
|
mod dsp;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "vois",
|
||||||
|
version,
|
||||||
|
about = "Real-time voice changer written in pure Rust"
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(short = 'i', long, help = "Input (microphone) device name")]
|
||||||
|
pub input: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short = 'o', long, help = "Output device name")]
|
||||||
|
pub output: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 's',
|
||||||
|
long,
|
||||||
|
help = "Sample rate in Hz (must be supported by both devices)"
|
||||||
|
)]
|
||||||
|
pub sample_rate: Option<u32>,
|
||||||
|
|
||||||
|
#[arg(long, help = "List available audio devices and exit")]
|
||||||
|
pub list: bool,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'p',
|
||||||
|
long,
|
||||||
|
default_value = "Clean",
|
||||||
|
help = "Starting preset name"
|
||||||
|
)]
|
||||||
|
pub preset: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
help = "Record processed audio to a WAV file (also toggle with R in the UI)"
|
||||||
|
)]
|
||||||
|
pub record: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 0.0, help = "Initial pitch shift in semitones")]
|
||||||
|
pub pitch: f32,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value_t = 0.0,
|
||||||
|
help = "Initial formant shift in semitones"
|
||||||
|
)]
|
||||||
|
pub formant: f32,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value_t = -100.0,
|
||||||
|
help = "Noise gate threshold in dB (default -100 = off)"
|
||||||
|
)]
|
||||||
|
pub gate: f32,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 1.0, help = "Output gain (1.0 = unity)")]
|
||||||
|
pub gain: f32,
|
||||||
|
|
||||||
|
#[arg(long, help = "Exit automatically after this many seconds")]
|
||||||
|
pub run_seconds: Option<u32>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value_t = 0.0,
|
||||||
|
help = "Mix a test tone (Hz) into the input; 0 = off. Useful to hear presets without a mic"
|
||||||
|
)]
|
||||||
|
pub tone: f32,
|
||||||
|
|
||||||
|
#[arg(long, help = "List available presets and exit")]
|
||||||
|
pub list_presets: bool,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
help = "Disable the built-in virtual mic (vois.rs); on by default"
|
||||||
|
)]
|
||||||
|
pub no_virtual_mic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.list {
|
||||||
|
audio::devices::print_all()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.list_presets {
|
||||||
|
println!("Available presets: {}", config::AppConfig::preset_names());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cli.no_virtual_mic {
|
||||||
|
match audio::virtual_mic::VirtualMic::setup() {
|
||||||
|
Ok(Some(src)) => {
|
||||||
|
println!(
|
||||||
|
"virtual mic ready: use \"{src}\" (or \"vois.rs.monitor\") as your microphone"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(None) => println!("virtual mic setup failed: no source reported"),
|
||||||
|
Err(e) => println!("warning: virtual mic setup failed: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = config::AppConfig::from_cli(&cli);
|
||||||
|
audio::run(&cfg)
|
||||||
|
}
|
||||||
12
src/ui/mod.rs
Normal file
12
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
pub mod tui;
|
||||||
|
|
||||||
|
/// Static information about the audio session, shown in the TUI.
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub input: String,
|
||||||
|
pub output: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub in_ch: usize,
|
||||||
|
pub out_ch: usize,
|
||||||
|
pub format_in: String,
|
||||||
|
pub format_out: String,
|
||||||
|
}
|
||||||
826
src/ui/tui.rs
Normal file
826
src/ui/tui.rs
Normal file
|
|
@ -0,0 +1,826 @@
|
||||||
|
//! Full-screen interactive TUI built on ratatui: splash, dashboard with
|
||||||
|
//! presets / parameters / levels / stats, and a settings screen with arrow-key
|
||||||
|
//! and mouse control.
|
||||||
|
|
||||||
|
use crate::dsp::chain::{Control, Mode};
|
||||||
|
use crate::dsp::presets::PRESETS;
|
||||||
|
use crate::dsp::stats::Stats;
|
||||||
|
use crate::ui::SessionInfo;
|
||||||
|
use crossterm::event::{
|
||||||
|
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||||
|
};
|
||||||
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
||||||
|
use ratatui::{Frame, Terminal};
|
||||||
|
use std::io::{stdout, IsTerminal, Write};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const LOGO: [&str; 7] = [
|
||||||
|
" ██╗ ██╗ ██████╗ ██╗███████╗ ",
|
||||||
|
" ██║ ██║██╔═══██╗██║██╔════╝ ",
|
||||||
|
" ██║ ██║██║ ██║██║███████╗ ",
|
||||||
|
" ╚██╗ ██╔╝██║ ██║██║╚════██║ ",
|
||||||
|
" ╚████╔╝ ╚██████╔╝██║███████║ ",
|
||||||
|
" ╚═══╝ ╚═════╝ ╚═╝╚══════╝ ",
|
||||||
|
" real-time voice changer ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SETTING_INFO: [&str; 8] = [
|
||||||
|
"voice character, previewed live",
|
||||||
|
"pitch: higher = girl/kid, lower = deep",
|
||||||
|
"formant: brightness / timbre of the voice",
|
||||||
|
"noise gate: audio below this dB is cut",
|
||||||
|
"output loudness, in dB",
|
||||||
|
"silence the output",
|
||||||
|
"LIVE = processed, PASSTHROUGH = raw input (A/B)",
|
||||||
|
"write processed audio to a WAV file",
|
||||||
|
];
|
||||||
|
|
||||||
|
const HELP_TEXT: &str = "\
|
||||||
|
KEY / MOUSE ACTION
|
||||||
|
───────────────────────────────────────────────
|
||||||
|
↑ ↓ (or mouse) select preset / settings row
|
||||||
|
← → change value
|
||||||
|
Enter toggle / apply
|
||||||
|
S / Tab toggle SETTINGS screen
|
||||||
|
Esc back to main screen
|
||||||
|
H toggle this help screen
|
||||||
|
Q / Ctrl+C quit
|
||||||
|
0 - 9 jump to preset
|
||||||
|
M mute R record
|
||||||
|
B A/B passthrough mode
|
||||||
|
";
|
||||||
|
|
||||||
|
const METER_FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
fn db(rms: f32) -> f32 {
|
||||||
|
if rms <= 0.0 {
|
||||||
|
METER_FLOOR_DB
|
||||||
|
} else {
|
||||||
|
(20.0 * rms.log10()).clamp(METER_FLOOR_DB, 6.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meter_ratio(db: f32) -> f64 {
|
||||||
|
((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meter_style(level_db: f32) -> Style {
|
||||||
|
if level_db >= -6.0 {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if level_db >= -20.0 {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum View {
|
||||||
|
Main,
|
||||||
|
Settings,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
control: Arc<Mutex<Control>>,
|
||||||
|
stats: Arc<Mutex<Stats>>,
|
||||||
|
info: SessionInfo,
|
||||||
|
view: View,
|
||||||
|
settings_cursor: usize,
|
||||||
|
splash_until: Instant,
|
||||||
|
started: Instant,
|
||||||
|
running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// (preset_idx, preset_name, pitch_st, formant_ratio, gate_db, gain_lin,
|
||||||
|
/// mute, record, tone, mode)
|
||||||
|
fn snapshot(&self) -> (usize, String, f32, f32, f32, f32, bool, bool, bool, Mode) {
|
||||||
|
self.control
|
||||||
|
.lock()
|
||||||
|
.map(|c| {
|
||||||
|
let preset = &PRESETS[c.preset_idx.min(PRESETS.len() - 1)];
|
||||||
|
(
|
||||||
|
c.preset_idx.min(PRESETS.len() - 1),
|
||||||
|
preset.name.to_string(),
|
||||||
|
preset.pitch_semitones + c.pitch_delta,
|
||||||
|
preset.formant_ratio * 2f32.powf(c.formant_delta / 12.0),
|
||||||
|
c.gate_threshold_db,
|
||||||
|
c.gain,
|
||||||
|
c.mute,
|
||||||
|
c.record,
|
||||||
|
c.tone_hz > 0.0,
|
||||||
|
c.mode,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((
|
||||||
|
0,
|
||||||
|
"Clean".into(),
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
-100.0,
|
||||||
|
1.0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Mode::Live,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_value(&self, idx: usize) -> String {
|
||||||
|
let (_, _name, pitch, formant, gate, gain, mute, record, _tone, mode) = self.snapshot();
|
||||||
|
match idx {
|
||||||
|
0 => PRESETS[self
|
||||||
|
.control
|
||||||
|
.lock()
|
||||||
|
.map(|c| c.preset_idx.min(PRESETS.len() - 1))
|
||||||
|
.unwrap_or(0)]
|
||||||
|
.name
|
||||||
|
.to_string(),
|
||||||
|
1 => format!("{pitch:+.1} st"),
|
||||||
|
2 => format!("{formant:.2}×"),
|
||||||
|
3 => {
|
||||||
|
if gate <= -90.0 {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{gate:.0} dB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => format!("{:+.1} dB", 20.0 * gain.max(1e-6).log10()),
|
||||||
|
5 => if mute { "ON" } else { "off" }.to_string(),
|
||||||
|
6 => match mode {
|
||||||
|
Mode::Live => "LIVE".to_string(),
|
||||||
|
Mode::Passthrough => "PASSTHROUGH".to_string(),
|
||||||
|
},
|
||||||
|
7 => if record { "recording" } else { "off" }.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust a settings row. `step` is negative/positive; toggles ignore it.
|
||||||
|
fn adjust(&self, idx: usize, step: i32) {
|
||||||
|
self.with_ctrl(|c| match idx {
|
||||||
|
0 => {
|
||||||
|
let n = PRESETS.len() as i32;
|
||||||
|
c.preset_idx = ((c.preset_idx as i32 + step).rem_euclid(n)) as usize;
|
||||||
|
}
|
||||||
|
1 => c.pitch_delta += step as f32,
|
||||||
|
2 => c.formant_delta += step as f32,
|
||||||
|
3 => {
|
||||||
|
c.gate_threshold_db =
|
||||||
|
(c.gate_threshold_db + step as f32 * 2.0).clamp(-100.0, -20.0);
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
let db = 20.0 * c.gain.max(1e-6).log10() + step as f32;
|
||||||
|
c.gain = 10f32.powf(db / 20.0).clamp(0.0316, 4.0); // -30 .. +12 dB
|
||||||
|
}
|
||||||
|
5 => c.mute = !c.mute,
|
||||||
|
6 => c.mode.toggle(),
|
||||||
|
7 => c.record = !c.record,
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
let quit = key.code == KeyCode::Char('q')
|
||||||
|
|| (ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('q')));
|
||||||
|
|
||||||
|
// Any key dismisses the splash.
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
self.splash_until = Instant::now();
|
||||||
|
if quit {
|
||||||
|
self.set_quit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if quit {
|
||||||
|
self.set_quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.view {
|
||||||
|
View::Help => {
|
||||||
|
if key.code == KeyCode::Esc
|
||||||
|
|| key.code == KeyCode::Char('h')
|
||||||
|
|| key.code == KeyCode::Char('H')
|
||||||
|
{
|
||||||
|
self.view = View::Main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Main => self.on_key_main(key),
|
||||||
|
View::Settings => self.on_key_settings(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_main(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => self
|
||||||
|
.with_ctrl(|c| c.preset_idx = (c.preset_idx + PRESETS.len() - 1) % PRESETS.len()),
|
||||||
|
KeyCode::Down => self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % PRESETS.len()),
|
||||||
|
KeyCode::Left => self.adjust(1, -1),
|
||||||
|
KeyCode::Right => self.adjust(1, 1),
|
||||||
|
KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => self.view = View::Settings,
|
||||||
|
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
||||||
|
KeyCode::Char('p') | KeyCode::Char('P') => {
|
||||||
|
self.with_ctrl(|c| c.preset_idx = (c.preset_idx + 1) % PRESETS.len())
|
||||||
|
}
|
||||||
|
KeyCode::Char(d) if d.is_ascii_digit() => self.with_ctrl(|c| {
|
||||||
|
let n = d.to_digit(10).unwrap() as usize;
|
||||||
|
c.preset_idx = if n == 0 {
|
||||||
|
PRESETS.len() - 1
|
||||||
|
} else {
|
||||||
|
(n - 1).min(PRESETS.len() - 1)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
KeyCode::Char('[') => self.adjust(1, -1),
|
||||||
|
KeyCode::Char(']') => self.adjust(1, 1),
|
||||||
|
KeyCode::Char('{') => self.adjust(2, -1),
|
||||||
|
KeyCode::Char('}') => self.adjust(2, 1),
|
||||||
|
KeyCode::Char('g') | KeyCode::Char('G') => self.adjust(3, -1),
|
||||||
|
KeyCode::Char('b') | KeyCode::Char('B') => self.adjust(6, 1),
|
||||||
|
KeyCode::Char('m') | KeyCode::Char('M') => self.adjust(5, 1),
|
||||||
|
KeyCode::Char('r') | KeyCode::Char('R') => self.adjust(7, 1),
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => self.adjust(4, 1),
|
||||||
|
KeyCode::Char('-') => self.adjust(4, -1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_key_settings(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => self.settings_cursor = self.settings_cursor.saturating_sub(1),
|
||||||
|
KeyCode::Down => self.settings_cursor = (self.settings_cursor + 1).min(7),
|
||||||
|
KeyCode::Left => self.adjust(self.settings_cursor, -1),
|
||||||
|
KeyCode::Right => self.adjust(self.settings_cursor, 1),
|
||||||
|
KeyCode::Enter => self.adjust(self.settings_cursor, 1),
|
||||||
|
KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Tab => {
|
||||||
|
self.view = View::Main
|
||||||
|
}
|
||||||
|
KeyCode::Char('h') | KeyCode::Char('H') => self.view = View::Help,
|
||||||
|
KeyCode::Char('q') => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_mouse(&mut self, mouse: MouseEvent) {
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
self.splash_until = Instant::now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
|
||||||
|
let area = self.terminal_size();
|
||||||
|
match self.view {
|
||||||
|
View::Main => {
|
||||||
|
let rects = main_layout(area);
|
||||||
|
let presets_rect = rects.0;
|
||||||
|
if mouse.row >= presets_rect.y
|
||||||
|
&& mouse.row < presets_rect.y + presets_rect.height
|
||||||
|
{
|
||||||
|
let idx = (mouse.row - presets_rect.y) as usize;
|
||||||
|
if idx < PRESETS.len() {
|
||||||
|
self.with_ctrl(|c| c.preset_idx = idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Settings => {
|
||||||
|
let rects = settings_layout(area);
|
||||||
|
let list_rect = rects.0;
|
||||||
|
if mouse.row >= list_rect.y && mouse.row < list_rect.y + list_rect.height {
|
||||||
|
let idx = (mouse.row - list_rect.y) as usize;
|
||||||
|
if idx < 8 {
|
||||||
|
self.settings_cursor = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
View::Help => self.view = View::Main,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_size(&self) -> Rect {
|
||||||
|
crossterm::terminal::size()
|
||||||
|
.map(|(w, h)| Rect::new(0, 0, w, h))
|
||||||
|
.unwrap_or(Rect::new(0, 0, 100, 30))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_quit(&mut self) {
|
||||||
|
self.with_ctrl(|c| c.quit = true);
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_ctrl(&self, f: impl FnOnce(&mut Control)) {
|
||||||
|
if let Ok(mut c) = self.control.lock() {
|
||||||
|
f(&mut c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, f: &mut Frame) {
|
||||||
|
if Instant::now() < self.splash_until {
|
||||||
|
self.render_splash(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.view {
|
||||||
|
View::Main => self.render_main(f),
|
||||||
|
View::Settings => self.render_settings(f),
|
||||||
|
View::Help => self.render_help(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_splash(&self, f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
for (i, l) in LOGO.iter().enumerate() {
|
||||||
|
let color = match i {
|
||||||
|
0 | 1 => Color::Cyan,
|
||||||
|
2 | 3 => Color::Magenta,
|
||||||
|
4 | 5 => Color::Blue,
|
||||||
|
_ => Color::Gray,
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(
|
||||||
|
l.to_string(),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(
|
||||||
|
" press any key, click, or use the arrow keys ...",
|
||||||
|
));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
" audio is already running",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
|
||||||
|
// Center in the full area, but never taller than the terminal.
|
||||||
|
let rect = centered_rect(70, 55, area);
|
||||||
|
f.render_widget(para, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_help(&self, f: &mut Frame) {
|
||||||
|
let para = Paragraph::new(HELP_TEXT)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.block(block(" vois · help "));
|
||||||
|
f.render_widget(para, f.area());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(&self, f: &mut Frame, area: Rect) {
|
||||||
|
let (_, _name, _p, _f, _g, _gain, mute, record, tone, mode) = self.snapshot();
|
||||||
|
let mode_txt = match mode {
|
||||||
|
Mode::Live => "LIVE",
|
||||||
|
Mode::Passthrough => "A/B PASSTHROUGH",
|
||||||
|
};
|
||||||
|
let rec_txt = if record { "● REC" } else { "" };
|
||||||
|
let tone_txt = if tone { "● TONE" } else { "" };
|
||||||
|
let mute_txt = if mute { "MUTED" } else { "" };
|
||||||
|
let header = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" VOIS ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
"real-time voice changer",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Gray)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(
|
||||||
|
mode_txt,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
rec_txt,
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(tone_txt, Style::default().fg(Color::Blue)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
mute_txt,
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(Paragraph::new(header), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_footer(&self, f: &mut Frame, area: Rect, hint: &str) {
|
||||||
|
let footer = Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray)));
|
||||||
|
f.render_widget(Paragraph::new(footer), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_main(&mut self, f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let rects = main_layout(area);
|
||||||
|
let (head, body, foot) = (rects.0, rects.1, rects.2);
|
||||||
|
|
||||||
|
self.render_header(f, head);
|
||||||
|
|
||||||
|
let (preset_idx, _name, pitch, formant, gate, gain, mute, _record, _tone, _mode) =
|
||||||
|
self.snapshot();
|
||||||
|
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(38),
|
||||||
|
Constraint::Percentage(28),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
])
|
||||||
|
.split(body);
|
||||||
|
|
||||||
|
// Presets (navigable, live preview).
|
||||||
|
let mut plines = Vec::new();
|
||||||
|
for (i, p) in PRESETS.iter().enumerate() {
|
||||||
|
let selected = i == preset_idx;
|
||||||
|
let label = format!(
|
||||||
|
"{}{} {}",
|
||||||
|
if selected { "▶" } else { " " },
|
||||||
|
if selected { "" } else { " " },
|
||||||
|
p.name
|
||||||
|
);
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
plines.push(Line::styled(label, style));
|
||||||
|
}
|
||||||
|
let para = Paragraph::new(plines).block(block(" presets · ↑↓ to switch "));
|
||||||
|
f.render_widget(para, cols[0]);
|
||||||
|
|
||||||
|
// Parameters + info.
|
||||||
|
let gate_txt = if gate <= -90.0 {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{gate:.0} dB")
|
||||||
|
};
|
||||||
|
let gain_db = 20.0 * gain.max(1e-6).log10();
|
||||||
|
let param_lines = vec![
|
||||||
|
p_row("preset", &_name, Color::Yellow, "voice character"),
|
||||||
|
p_row(
|
||||||
|
"pitch",
|
||||||
|
&format!("{pitch:+.1} st"),
|
||||||
|
Color::Cyan,
|
||||||
|
"higher=girl, lower=deep",
|
||||||
|
),
|
||||||
|
p_row(
|
||||||
|
"formant",
|
||||||
|
&format!("{formant:.2}×"),
|
||||||
|
Color::Magenta,
|
||||||
|
"brightness/timbre",
|
||||||
|
),
|
||||||
|
p_row("gate", &gate_txt, Color::Blue, "noise below this dB is cut"),
|
||||||
|
p_row(
|
||||||
|
"gain",
|
||||||
|
&format!("{gain_db:+.1} dB"),
|
||||||
|
Color::Green,
|
||||||
|
"output loudness",
|
||||||
|
),
|
||||||
|
p_row(
|
||||||
|
"mute",
|
||||||
|
if mute { "ON" } else { "off" },
|
||||||
|
if mute { Color::Red } else { Color::DarkGray },
|
||||||
|
"silence output",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let para = Paragraph::new(param_lines).block(block(" parameters · info "));
|
||||||
|
f.render_widget(para, cols[1]);
|
||||||
|
|
||||||
|
self.render_stats(f, cols[2]);
|
||||||
|
|
||||||
|
self.render_footer(
|
||||||
|
f,
|
||||||
|
foot,
|
||||||
|
" ↑↓ preset | ←→ pitch | S settings | H help | Q quit ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_settings(&mut self, f: &mut Frame) {
|
||||||
|
let area = f.area();
|
||||||
|
let rects = settings_layout(area);
|
||||||
|
let (head, list_rect, foot) = (rects.0, rects.1, rects.2);
|
||||||
|
|
||||||
|
self.render_header(f, head);
|
||||||
|
|
||||||
|
let labels = [
|
||||||
|
"Preset",
|
||||||
|
"Pitch",
|
||||||
|
"Formant",
|
||||||
|
"Gate (min)",
|
||||||
|
"Gain",
|
||||||
|
"Mute",
|
||||||
|
"Mode",
|
||||||
|
"Record",
|
||||||
|
];
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
let value = self.setting_value(i);
|
||||||
|
let selected = i == self.settings_cursor;
|
||||||
|
let style = if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
let info_style = Style::default().fg(Color::DarkGray);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{}{} {:<12}",
|
||||||
|
if selected { "▶" } else { " " },
|
||||||
|
if selected { "" } else { " " },
|
||||||
|
label
|
||||||
|
),
|
||||||
|
style,
|
||||||
|
),
|
||||||
|
Span::styled(format!("{:<16}", value), style),
|
||||||
|
Span::styled(SETTING_INFO[i], info_style),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::styled(
|
||||||
|
" use ↑ ↓ to select, ← → (or Enter) to change, Esc to go back",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
let para = Paragraph::new(lines).block(block(" settings "));
|
||||||
|
f.render_widget(para, list_rect);
|
||||||
|
|
||||||
|
self.render_footer(
|
||||||
|
f,
|
||||||
|
foot,
|
||||||
|
" ↑↓ select | ←→ change | Esc back | H help | Q quit ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats(&self, f: &mut Frame, area: Rect) {
|
||||||
|
let s = self
|
||||||
|
.stats
|
||||||
|
.lock()
|
||||||
|
.map(|s| {
|
||||||
|
(
|
||||||
|
s.input_rms,
|
||||||
|
s.output_rms,
|
||||||
|
s.peak_in,
|
||||||
|
s.peak_out,
|
||||||
|
s.blocks,
|
||||||
|
s.samples,
|
||||||
|
s.cpu_pct,
|
||||||
|
s.dsp_latency_ms,
|
||||||
|
s.started,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((0.0, 0.0, 0.0, 0.0, 0, 0, 0.0, 0.0, Instant::now()));
|
||||||
|
let (in_rms, out_rms, peak_in, peak_out, blocks, samples, cpu, latency, started) = s;
|
||||||
|
|
||||||
|
let in_db = db(in_rms);
|
||||||
|
let out_db = db(out_rms);
|
||||||
|
let peak_in_db = db(peak_in);
|
||||||
|
let peak_out_db = db(peak_out);
|
||||||
|
|
||||||
|
let uptime = started.elapsed().as_secs();
|
||||||
|
let up = format!(
|
||||||
|
"{:02}:{:02}:{:02}",
|
||||||
|
uptime / 3600,
|
||||||
|
(uptime / 60) % 60,
|
||||||
|
uptime % 60
|
||||||
|
);
|
||||||
|
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||||
|
let hz = samples as f32 / secs;
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" peak in ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("{peak_in_db:+.0} dB"), meter_style(peak_in_db)),
|
||||||
|
Span::styled(" peak out ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!("{peak_out_db:+.0} dB"), meter_style(peak_out_db)),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" dsp load ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{cpu:.1} %"),
|
||||||
|
if cpu > 50.0 {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("latency", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(" {latency:.0} ms")),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" rate ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} Hz {} ch → {} ch ({} → {})",
|
||||||
|
self.info.sample_rate,
|
||||||
|
self.info.in_ch,
|
||||||
|
self.info.out_ch,
|
||||||
|
self.info.format_in,
|
||||||
|
self.info.format_out
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" uptime ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(up),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" blocks ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(format!("{blocks} ({hz:.0} blk/s)")),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" in: {}", self.info.input),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" out: {}", self.info.output),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Meters on top of the column.
|
||||||
|
let vert = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let in_gauge = Gauge::default()
|
||||||
|
.ratio(meter_ratio(in_db))
|
||||||
|
.gauge_style(meter_style(in_db));
|
||||||
|
let out_gauge = Gauge::default()
|
||||||
|
.ratio(meter_ratio(out_db))
|
||||||
|
.gauge_style(meter_style(out_db));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(" in "),
|
||||||
|
Span::styled(format!("{in_db:+.0} dB"), meter_style(in_db)),
|
||||||
|
])),
|
||||||
|
vert[0],
|
||||||
|
);
|
||||||
|
f.render_widget(in_gauge, vert[1]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(" out "),
|
||||||
|
Span::styled(format!("{out_db:+.0} dB"), meter_style(out_db)),
|
||||||
|
])),
|
||||||
|
vert[2],
|
||||||
|
);
|
||||||
|
f.render_widget(out_gauge, vert[3]);
|
||||||
|
f.render_widget(Paragraph::new(lines), vert[4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout rects for the main screen: (header, body, footer).
|
||||||
|
fn main_layout(area: Rect) -> (Rect, Rect, Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], chunks[1], chunks[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout rects for the settings screen: (header, list, footer).
|
||||||
|
fn settings_layout(area: Rect) -> (Rect, Rect, Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
(chunks[0], chunks[1], chunks[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn p_row(label: &str, value: &str, color: Color, info: &str) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {label:<8}"),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
value.to_string(),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(format!(" ({info})"), Style::default().fg(Color::DarkGray)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(title: &str) -> Block<'static> {
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Span::styled(
|
||||||
|
title.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let height = ((area.height as u32 * percent_y as u32 / 100) as u16).max(12);
|
||||||
|
let top = area.y + (area.height.saturating_sub(height) / 2);
|
||||||
|
let rect = Rect::new(area.x, top, area.width, height);
|
||||||
|
let width = ((rect.width as u32 * percent_x as u32 / 100) as u16).max(30);
|
||||||
|
let x = rect.x + (rect.width.saturating_sub(width) / 2);
|
||||||
|
Rect::new(x, rect.y, width, rect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: SessionInfo) {
|
||||||
|
let run_seconds = control.lock().ok().and_then(|c| c.run_seconds).unwrap_or(0);
|
||||||
|
|
||||||
|
if !std::io::stdin().is_terminal() {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let done = control
|
||||||
|
.lock()
|
||||||
|
.map(|c| {
|
||||||
|
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if done {
|
||||||
|
if let Ok(mut c) = control.lock() {
|
||||||
|
c.quit = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_raw_mode().expect("failed to enable raw mode");
|
||||||
|
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
|
||||||
|
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
disable_raw_mode().ok();
|
||||||
|
eprintln!("tui init failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = terminal.clear();
|
||||||
|
|
||||||
|
let mut app = App {
|
||||||
|
control,
|
||||||
|
stats,
|
||||||
|
info,
|
||||||
|
view: View::Main,
|
||||||
|
settings_cursor: 0,
|
||||||
|
splash_until: Instant::now() + Duration::from_millis(1500),
|
||||||
|
started: Instant::now(),
|
||||||
|
running: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
while app.running {
|
||||||
|
let _ = terminal.draw(|f| app.render(f));
|
||||||
|
let _ = stdout().flush();
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
|
||||||
|
if let Ok(ev) = event::read() {
|
||||||
|
match ev {
|
||||||
|
Event::Key(key) => app.on_key(key),
|
||||||
|
Event::Mouse(m) => app.on_mouse(m),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if run_seconds > 0 && app.started.elapsed().as_secs() >= run_seconds as u64 {
|
||||||
|
app.set_quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = terminal.show_cursor();
|
||||||
|
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
|
||||||
|
disable_raw_mode().ok();
|
||||||
|
println!("shutting down...");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue