Initial commit

This commit is contained in:
2026-01-18 15:39:46 +01:00
commit 587f2bd7e7
106 changed files with 14918 additions and 0 deletions

8
.cargo/config.toml Normal file
View File

@@ -0,0 +1,8 @@
[target.'cfg(target_arch = "x86_64")']
rustflags = ["-C", "target-cpu=native"]
[target.'cfg(target_arch = "aarch64")']
rustflags = ["-C", "target-cpu=native"]
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]

58
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
working-directory: website
- name: Build
run: pnpm build
working-directory: website
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/build
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Rust
/target
*.rs.bk
# Node
node_modules/
# Lock files (except Cargo.lock for binary reproducibility)
pnpm-lock.yaml
# SvelteKit
.svelte-kit/
website/build/
# macOS
.DS_Store
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.*
!.env.example
# Logs
*.log

78
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,78 @@
# Contributing to Doux
Contributions are welcome. There are many ways to contribute beyond code:
- **Bug reports**: Open an issue describing the problem and steps to reproduce.
- **Feature requests**: Suggest new features or improvements.
- **Documentation**: Fix typos, clarify explanations, or add examples.
- **Testing**: Try Doux on different platforms, report issues.
- **Tutorials**: Write guides or share example sessions.
- **Community support**: Help others in issues and discussions.
## Prerequisites
Before you start, ensure you have:
- **Rust** (stable toolchain) - [rustup.rs](https://rustup.rs/)
- **Node.js** (v18+) and **pnpm** - [pnpm.io](https://pnpm.io/) (for website development)
## Quick start
```sh
# Build the audio engine
cargo build
cargo clippy
# Website development
cd website && pnpm install && pnpm dev
# Build WASM module
./build-wasm.sh
```
## Project structure
- `src/` - Audio engine (Rust)
- `website/` - Documentation and playground (SvelteKit)
## Code contributions
1. Fork the repository
2. Create a branch for your changes
3. Make your changes
4. Run `cargo clippy` and fix any warnings
5. Submit a pull request with a clear description of your changes
Please explain the reasoning behind your changes in the pull request. Document what problem you're solving and how your solution works. This helps reviewers understand your intent and speeds up the review process.
### Rust
- Run `cargo clippy` before submitting.
- Avoid cloning to satisfy the borrow checker - find a better solution.
### TypeScript/Svelte
- Use pnpm (not npm or yarn).
- Run `pnpm check` for type checking.
## Code of conduct
This project follows the [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating, you agree to uphold its standards. We are committed to providing a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, experience level, nationality, appearance, race, religion, or sexual identity.
**Expected behavior:**
- Demonstrate empathy and kindness
- Respect differing viewpoints and experiences
- Accept constructive feedback gracefully
- Focus on what's best for the community
**Unacceptable behavior:**
- Harassment, trolling, or personal attacks
- Sexualized language or unwanted advances
- Publishing others' private information
- Any conduct inappropriate in a professional setting
Report violations to the project maintainers. All complaints will be reviewed promptly and confidentially.
## License
By contributing, you agree that your contributions will be licensed under AGPLv3.

2671
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

50
Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[workspace]
members = [".", "doux-sova"]
[package]
name = "doux"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "doux"
path = "src/main.rs"
required-features = ["native"]
[[bin]]
name = "doux-repl"
path = "src/repl.rs"
required-features = ["native"]
[features]
default = ["native"]
native = ["dep:clap", "dep:cpal", "dep:rosc", "dep:symphonia", "dep:rustyline"]
[dependencies]
clap = { version = "4", optional = true, features = ["derive"] }
cpal = { version = "0.15", optional = true }
rosc = { version = "0.10", optional = true }
symphonia = { version = "0.5", optional = true, default-features = false, features = ["wav", "pcm", "mp3", "ogg", "flac", "aac"] }
rustyline = { version = "14", optional = true }
mi-plaits-dsp = { git = "https://github.com/sourcebox/mi-plaits-dsp-rs", rev = "dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4" }
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
[profile.release-with-debug]
inherits = "release"
debug = true
strip = false
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 2

662
LICENSE Normal file
View File

@@ -0,0 +1,662 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

11
README.md Normal file
View File

@@ -0,0 +1,11 @@
# doux
A software synthesizer engine for live coding, written in Rust. Ported from [Dough](https://dough.strudel.cc/) by Felix Roos and al. Documentation and live playground can be found on this page: [doux](https://doux.livecoding.fr).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
This project is licensed under the [GNU Affero General Public License v3.0](LICENSE).

13
build-wasm.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "Building WASM..."
cargo build --target wasm32-unknown-unknown --release --no-default-features
echo "Copying to website/static/..."
cp target/wasm32-unknown-unknown/release/doux.wasm website/static/doux.wasm
echo "Verifying..."
ls -la website/static/doux.wasm
echo ""
echo "Done! WASM updated in website/static/"

14
doux-sova/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "doux-sova"
version = "0.1.0"
edition = "2021"
[dependencies]
doux = { path = "..", features = ["native"] }
crossbeam-channel = "0.5"
cpal = "0.15"
serde = { version = "1", features = ["derive"] }
[dependencies.sova_core]
package = "core"
git = "https://github.com/sova-org/Sova"

143
doux-sova/README.md Normal file
View File

@@ -0,0 +1,143 @@
# doux-sova
Integration layer connecting Doux audio engine to Sova's AudioEngineProxy.
## Quick Start with DouxManager
```rust
use doux_sova::{DouxManager, DouxConfig, audio};
// 1. List available devices (for UI)
let devices = audio::list_output_devices();
for dev in &devices {
let default_marker = if dev.is_default { " [default]" } else { "" };
println!("{}: {} (max {} ch){}", dev.index, dev.name, dev.max_channels, default_marker);
}
// 2. Create configuration
let config = DouxConfig::default()
.with_output_device("Built-in Output")
.with_channels(2)
.with_buffer_size(512)
.with_sample_path("/path/to/samples");
// 3. Create and start manager
let mut manager = DouxManager::new(config)?;
let proxy = manager.start(clock.micros())?;
// 4. Register with Sova
device_map.connect_audio_engine("doux", proxy)?;
// 5. Control during runtime
manager.hush(); // Release all voices
manager.panic(); // Immediately stop all voices
manager.add_sample_path(path); // Add more samples
manager.rescan_samples(); // Rescan all sample directories
let state = manager.state(); // Get AudioEngineState (voices, cpu, etc)
let scope = manager.scope_capture(); // Get oscilloscope buffer
// 6. Stop or restart
manager.stop(); // Stop audio streams
let new_config = DouxConfig::default().with_channels(4);
let new_proxy = manager.restart(new_config, clock.micros())?;
```
## Low-Level API
For direct engine access without lifecycle management:
```rust
use std::sync::{Arc, Mutex};
use doux::Engine;
use doux_sova::create_integration;
// 1. Create the doux engine
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
// 2. Get initial sync time from Sova's clock
let initial_time = clock.micros();
// 3. Create integration - returns thread handle and proxy
let (handle, proxy) = create_integration(engine.clone(), initial_time);
// 4. Register proxy with Sova's device map
device_map.connect_audio_engine("doux", proxy)?;
// 5. Start your audio output loop using the engine
// (see doux/src/main.rs for cpal example)
```
## Architecture
```
Sova Scheduler
AudioEngineProxy::send(AudioEnginePayload)
▼ (crossbeam channel)
SovaReceiver thread
│ converts HashMap → command string
Engine::evaluate("/s/sine/freq/440/gain/0.5/...")
```
## Time Synchronization
Pass `clock.micros()` at engine startup. Sova's timetags (microseconds) are converted to engine time (seconds) relative to this initial value. Events with timetags go to Doux's scheduler for sample-accurate playback.
## DouxConfig Methods
| Method | Description |
|--------|-------------|
| `with_output_device(name)` | Set output device by name |
| `with_input_device(name)` | Set input device by name |
| `with_channels(n)` | Set number of output channels |
| `with_buffer_size(n)` | Set audio buffer size in frames |
| `with_sample_path(path)` | Add a single sample directory |
| `with_sample_paths(paths)` | Add multiple sample directories |
## DouxManager Methods
| Method | Description |
|--------|-------------|
| `start(time)` | Start audio streams, returns proxy |
| `stop()` | Stop audio streams |
| `restart(config, time)` | Restart with new configuration |
| `hush()` | Release all voices gracefully |
| `panic()` | Immediately stop all voices |
| `add_sample_path(path)` | Add sample directory |
| `rescan_samples()` | Rescan all sample directories |
| `clear_samples()` | Clear sample pool |
| `state()` | Returns `AudioEngineState` |
| `is_running()` | Check if streams are active |
| `sample_rate()` | Get current sample rate |
| `channels()` | Get number of channels |
| `config()` | Get current configuration |
| `engine_handle()` | Access engine for telemetry |
| `scope_capture()` | Get oscilloscope buffer |
## Re-exported Types
### AudioEngineState
```rust
pub struct AudioEngineState {
pub active_voices: u32,
pub cpu_percent: f32,
pub output_level: f32,
}
```
### DouxError
Error type for manager operations (device not found, stream errors, etc).
### ScopeCapture
Buffer containing oscilloscope data from `scope_capture()`.
## Parameters
All Sova Dirt parameters map directly to Doux event fields via `Event::parse()`. No manual mapping required - add new parameters to Doux and they work automatically.

55
doux-sova/src/convert.rs Normal file
View File

@@ -0,0 +1,55 @@
//! Payload conversion from Sova to Doux command format.
//!
//! Converts Sova's `AudioEnginePayload` (HashMap of parameters) into
//! Doux's slash-separated command strings (e.g., `/sound/sine/freq/440`).
use std::collections::HashMap;
use sova_core::clock::SyncTime;
use sova_core::vm::variable::VariableValue;
use crate::time::TimeConverter;
/// Converts a Sova payload to a Doux command string.
///
/// The resulting string has the format `/key/value/key/value/...`.
/// If a timetag is present, it's converted to engine time and prepended.
pub fn payload_to_command(
args: &HashMap<String, VariableValue>,
timetag: Option<SyncTime>,
time_converter: &TimeConverter,
) -> String {
let mut parts = Vec::new();
if let Some(tt) = timetag {
let engine_time = time_converter.sync_to_engine_time(tt);
parts.push("time".to_string());
parts.push(engine_time.to_string());
}
for (key, value) in args {
parts.push(key.clone());
parts.push(value_to_string(value));
}
format!("/{}", parts.join("/"))
}
/// Converts a Sova variable value to a string for Doux.
fn value_to_string(v: &VariableValue) -> String {
match v {
VariableValue::Integer(i) => i.to_string(),
VariableValue::Float(f) => f.to_string(),
VariableValue::Decimal(sign, num, den) => {
let f = (*num as f64) / (*den as f64);
if *sign < 0 {
format!("-{f}")
} else {
f.to_string()
}
}
VariableValue::Str(s) => s.clone(),
VariableValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
_ => String::new(),
}
}

38
doux-sova/src/lib.rs Normal file
View File

@@ -0,0 +1,38 @@
mod convert;
pub mod manager;
mod receiver;
pub mod scope;
mod time;
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use crossbeam_channel::unbounded;
use doux::Engine;
use sova_core::clock::SyncTime;
use sova_core::protocol::audio_engine_proxy::AudioEngineProxy;
use receiver::SovaReceiver;
use time::TimeConverter;
// Re-exports for convenience
pub use doux::audio;
pub use doux::config::DouxConfig;
pub use doux::error::DouxError;
pub use manager::{AudioEngineState, DouxManager};
pub use scope::ScopeCapture;
/// Creates a Sova integration for an existing engine.
///
/// This is the low-level API. For most use cases, prefer `DouxManager`
/// which handles the full engine lifecycle.
pub fn create_integration(
engine: Arc<Mutex<Engine>>,
initial_sync_time: SyncTime,
) -> (JoinHandle<()>, AudioEngineProxy) {
let (tx, rx) = unbounded();
let time_converter = TimeConverter::new(initial_sync_time);
let receiver = SovaReceiver::new(engine, rx, time_converter);
let handle = thread::spawn(move || receiver.run());
(handle, AudioEngineProxy::new(tx))
}

447
doux-sova/src/manager.rs Normal file
View File

@@ -0,0 +1,447 @@
//! DouxManager - Lifecycle management for the Doux audio engine with Sova integration.
//!
//! Provides a high-level API for managing the complete audio engine lifecycle,
//! including device selection, stream creation, and Sova scheduler integration.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::{Device, Stream, SupportedStreamConfig};
use crossbeam_channel::Sender;
use serde::{Deserialize, Serialize};
use doux::audio::{
default_input_device, default_output_device, find_input_device, find_output_device,
max_output_channels,
};
use doux::config::DouxConfig;
use doux::error::DouxError;
use doux::Engine;
use sova_core::clock::SyncTime;
use sova_core::protocol::audio_engine_proxy::{AudioEnginePayload, AudioEngineProxy};
use crate::receiver::SovaReceiver;
use crate::scope::ScopeCapture;
use crate::time::TimeConverter;
/// Snapshot of the audio engine state for external visibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioEngineState {
/// Whether audio streams are currently running.
pub running: bool,
/// Name of the output device, or None if using system default.
pub device: Option<String>,
/// Sample rate in Hz.
pub sample_rate: f32,
/// Number of output channels.
pub channels: usize,
/// Requested buffer size in samples, if explicitly set.
pub buffer_size: Option<u32>,
/// Number of currently playing voices.
pub active_voices: usize,
/// Configured sample directory paths.
pub sample_paths: Vec<PathBuf>,
/// Last error message, if any.
pub error: Option<String>,
/// CPU load as a fraction (0.0 to 1.0+).
pub cpu_load: f32,
/// Peak number of voices seen since reset.
pub peak_voices: usize,
/// Maximum allowed voices.
pub max_voices: usize,
/// Number of events in the schedule queue.
pub schedule_depth: usize,
/// Total memory used by sample pool in megabytes.
pub sample_pool_mb: f32,
}
impl Default for AudioEngineState {
fn default() -> Self {
Self {
running: false,
device: None,
sample_rate: 0.0,
channels: 0,
buffer_size: None,
active_voices: 0,
sample_paths: Vec::new(),
error: None,
cpu_load: 0.0,
peak_voices: 0,
max_voices: doux::types::MAX_VOICES,
schedule_depth: 0,
sample_pool_mb: 0.0,
}
}
}
/// Manages the Doux audio engine lifecycle with Sova integration.
///
/// Handles creating, starting, stopping, and restarting the audio engine
/// with different configurations.
pub struct DouxManager {
/// The audio synthesis engine, shared with the audio callback thread.
engine: Arc<Mutex<Engine>>,
/// Current configuration (device, channels, sample paths).
config: DouxConfig,
/// Actual sample rate from the audio device.
sample_rate: f32,
/// Actual channel count (may be clamped to device maximum).
actual_channels: usize,
/// Handle to the CPAL output stream, None when stopped.
output_stream: Option<Stream>,
/// Handle to the CPAL input stream, None when stopped or no input.
input_stream: Option<Stream>,
/// Handle to the Sova receiver thread.
receiver_handle: Option<JoinHandle<()>>,
/// Sender end of the channel to the receiver, dropped to signal shutdown.
proxy_sender: Option<Sender<AudioEnginePayload>>,
/// Scope capture for oscilloscope display.
scope: Option<Arc<ScopeCapture>>,
}
/// Resolves the output device from config, returning an error if not found.
fn resolve_output_device(config: &DouxConfig) -> Result<Device, DouxError> {
match &config.output_device {
Some(spec) => {
find_output_device(spec).ok_or_else(|| DouxError::DeviceNotFound(spec.clone()))
}
None => default_output_device().ok_or(DouxError::NoDefaultDevice),
}
}
/// Gets the device configuration and extracts the sample rate.
fn get_device_config(device: &Device) -> Result<(SupportedStreamConfig, f32), DouxError> {
let config = device
.default_output_config()
.map_err(|e| DouxError::DeviceConfigError(e.to_string()))?;
let sample_rate = config.sample_rate().0 as f32;
Ok((config, sample_rate))
}
/// Computes the actual channel count, clamped to the device maximum.
fn compute_channels(device: &Device, requested: u16) -> usize {
let max_ch = max_output_channels(device);
(requested as usize).min(max_ch as usize)
}
impl DouxManager {
/// Creates a new DouxManager with the given configuration.
///
/// This resolves the audio device and creates the engine, but does not
/// start the audio streams. Call `start()` to begin audio processing.
pub fn new(config: DouxConfig) -> Result<Self, DouxError> {
let output_device = resolve_output_device(&config)?;
let (_, sample_rate) = get_device_config(&output_device)?;
let actual_channels = compute_channels(&output_device, config.channels);
// Create engine
let mut engine = Engine::new_with_channels(sample_rate, actual_channels);
// Load sample directories
for path in &config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
engine.sample_index.extend(index);
}
Ok(Self {
engine: Arc::new(Mutex::new(engine)),
config,
sample_rate,
actual_channels,
output_stream: None,
input_stream: None,
receiver_handle: None,
proxy_sender: None,
scope: None,
})
}
/// Starts the audio streams and returns an AudioEngineProxy for Sova.
///
/// The proxy can be registered with Sova's device map to receive events.
pub fn start(&mut self, initial_sync_time: SyncTime) -> Result<AudioEngineProxy, DouxError> {
let output_device = resolve_output_device(&self.config)?;
let (device_config, _) = get_device_config(&output_device)?;
let stream_config = cpal::StreamConfig {
channels: self.actual_channels as u16,
sample_rate: device_config.sample_rate(),
buffer_size: self
.config
.buffer_size
.map(cpal::BufferSize::Fixed)
.unwrap_or(cpal::BufferSize::Default),
};
// Ring buffer for live audio input
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(8192)));
// Set up input stream if configured
let input_device = match &self.config.input_device {
Some(spec) => find_input_device(spec),
None => default_input_device(),
};
self.input_stream = input_device.and_then(|input_dev| {
let input_config = input_dev.default_input_config().ok()?;
let buf = Arc::clone(&input_buffer);
let stream = input_dev
.build_input_stream(
&input_config.into(),
move |data: &[f32], _| {
let mut b = buf.lock().unwrap();
for &sample in data {
b.push_back(sample);
if b.len() > 8192 {
b.pop_front();
}
}
},
|err| eprintln!("input stream error: {err}"),
None,
)
.ok()?;
stream.play().ok()?;
Some(stream)
});
// Create scope capture for oscilloscope
let scope = Arc::new(ScopeCapture::new());
let scope_clone = Arc::clone(&scope);
// Build output stream
let engine_clone = Arc::clone(&self.engine);
let input_buf_clone = Arc::clone(&input_buffer);
let live_scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(vec![0.0; 1024]));
let live_scratch_clone = Arc::clone(&live_scratch);
let sample_rate = self.sample_rate;
let output_channels = self.actual_channels;
let output_stream = output_device
.build_output_stream(
&stream_config,
move |data: &mut [f32], _| {
let mut buf = input_buf_clone.lock().unwrap();
let mut scratch = live_scratch_clone.lock().unwrap();
if scratch.len() < data.len() {
scratch.resize(data.len(), 0.0);
}
for sample in scratch[..data.len()].iter_mut() {
*sample = buf.pop_front().unwrap_or(0.0);
}
drop(buf);
let mut engine = engine_clone.lock().unwrap();
// Set buffer time budget for CPU load measurement
let buffer_samples = data.len() / output_channels;
let buffer_time_ns = (buffer_samples as f64 / sample_rate as f64 * 1e9) as u64;
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &scratch[..data.len()]);
// Capture samples for oscilloscope (zero-allocation path)
for chunk in data.chunks(output_channels) {
if output_channels >= 2 {
scope_clone.push_stereo(chunk[0], chunk[1]);
} else {
scope_clone.push_mono(chunk[0]);
}
}
},
|err| eprintln!("output stream error: {err}"),
None,
)
.map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?;
output_stream
.play()
.map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?;
self.output_stream = Some(output_stream);
self.scope = Some(scope);
// Create Sova integration
let (tx, rx) = crossbeam_channel::unbounded();
let time_converter = TimeConverter::new(initial_sync_time);
let receiver = SovaReceiver::new(Arc::clone(&self.engine), rx, time_converter);
let handle = std::thread::spawn(move || receiver.run());
self.receiver_handle = Some(handle);
self.proxy_sender = Some(tx.clone());
Ok(AudioEngineProxy::new(tx))
}
/// Stops all audio streams and the Sova receiver.
pub fn stop(&mut self) {
// Drop streams to stop audio
self.output_stream = None;
self.input_stream = None;
self.scope = None;
// Drop sender to signal receiver to stop
self.proxy_sender = None;
// Wait for receiver thread to finish (it will exit when channel closes)
if let Some(handle) = self.receiver_handle.take() {
let _ = handle.join();
}
}
/// Restarts the engine with a new configuration.
///
/// Stops the current engine, creates a new one with the new config,
/// and returns a new AudioEngineProxy.
pub fn restart(
&mut self,
config: DouxConfig,
initial_sync_time: SyncTime,
) -> Result<AudioEngineProxy, DouxError> {
self.stop();
let output_device = resolve_output_device(&config)?;
let (_, sample_rate) = get_device_config(&output_device)?;
let actual_channels = compute_channels(&output_device, config.channels);
// Create new engine
let mut engine = Engine::new_with_channels(sample_rate, actual_channels);
for path in &config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
engine.sample_index.extend(index);
}
self.engine = Arc::new(Mutex::new(engine));
self.config = config;
self.sample_rate = sample_rate;
self.actual_channels = actual_channels;
self.start(initial_sync_time)
}
/// Returns the actual sample rate being used.
pub fn sample_rate(&self) -> f32 {
self.sample_rate
}
/// Returns the actual number of output channels.
pub fn channels(&self) -> usize {
self.actual_channels
}
/// Returns the current configuration.
pub fn config(&self) -> &DouxConfig {
&self.config
}
/// Returns whether audio streams are running.
pub fn is_running(&self) -> bool {
self.output_stream.is_some()
}
/// Returns a snapshot of the current audio engine state.
pub fn state(&self) -> AudioEngineState {
use std::sync::atomic::Ordering;
let (active_voices, cpu_load, peak_voices, schedule_depth, sample_pool_mb) = self
.engine
.lock()
.map(|e| {
(
e.active_voices,
e.metrics.load.get_load(),
e.metrics.peak_voices.load(Ordering::Relaxed) as usize,
e.metrics.schedule_depth.load(Ordering::Relaxed) as usize,
e.metrics.sample_pool_mb(),
)
})
.unwrap_or((0, 0.0, 0, 0, 0.0));
AudioEngineState {
running: self.is_running(),
device: self
.config
.output_device
.clone()
.or_else(|| Some("System Default".to_string())),
sample_rate: self.sample_rate,
channels: self.actual_channels,
buffer_size: self.config.buffer_size,
active_voices,
sample_paths: self.config.sample_paths.clone(),
error: None,
cpu_load,
peak_voices,
max_voices: doux::types::MAX_VOICES,
schedule_depth,
sample_pool_mb,
}
}
/// Adds a sample directory and scans it.
pub fn add_sample_path(&mut self, path: std::path::PathBuf) {
let index = doux::loader::scan_samples_dir(&path);
if let Ok(mut engine) = self.engine.lock() {
engine.sample_index.extend(index);
}
self.config.sample_paths.push(path);
}
/// Rescans all configured sample directories.
pub fn rescan_samples(&mut self) {
if let Ok(mut engine) = self.engine.lock() {
engine.sample_index.clear();
for path in &self.config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
engine.sample_index.extend(index);
}
}
}
/// Clears all loaded samples.
pub fn clear_samples(&mut self) {
if let Ok(mut engine) = self.engine.lock() {
engine.sample_index.clear();
engine.samples.clear();
engine.sample_pool = doux::sample::SamplePool::new();
}
}
/// Sends a hush command to release all voices.
pub fn hush(&self) {
if let Ok(mut engine) = self.engine.lock() {
engine.hush();
}
}
/// Sends a panic command to immediately stop all voices.
pub fn panic(&self) {
if let Ok(mut engine) = self.engine.lock() {
engine.panic();
}
}
/// Returns a handle to the engine for telemetry access.
///
/// This allows external code to read engine metrics without holding
/// the entire DouxManager (which is not Send due to cpal::Stream).
pub fn engine_handle(&self) -> Arc<Mutex<Engine>> {
Arc::clone(&self.engine)
}
/// Returns the scope capture for oscilloscope display.
///
/// Returns None if the audio engine is not running.
pub fn scope_capture(&self) -> Option<Arc<ScopeCapture>> {
self.scope.clone()
}
}
impl Drop for DouxManager {
fn drop(&mut self) {
self.stop();
}
}

54
doux-sova/src/receiver.rs Normal file
View File

@@ -0,0 +1,54 @@
//! Sova event receiver thread.
//!
//! Listens for events from Sova's scheduler via a crossbeam channel and
//! forwards them to the Doux engine as command strings.
use std::sync::{Arc, Mutex};
use crossbeam_channel::Receiver;
use doux::Engine;
use sova_core::protocol::audio_engine_proxy::AudioEnginePayload;
use crate::convert::payload_to_command;
use crate::time::TimeConverter;
/// Receives events from Sova and forwards them to the Doux engine.
///
/// Runs in a dedicated thread, blocking on channel receive. Exits when
/// the sender is dropped (channel closed).
pub struct SovaReceiver {
/// Shared reference to the audio engine.
engine: Arc<Mutex<Engine>>,
/// Channel receiving events from Sova's scheduler.
rx: Receiver<AudioEnginePayload>,
/// Converts Sova timestamps to engine time.
time_converter: TimeConverter,
}
impl SovaReceiver {
/// Creates a new receiver with the given engine, channel, and time converter.
pub fn new(
engine: Arc<Mutex<Engine>>,
rx: Receiver<AudioEnginePayload>,
time_converter: TimeConverter,
) -> Self {
Self {
engine,
rx,
time_converter,
}
}
/// Runs the receiver loop until the channel is closed.
///
/// Each received payload is converted to a Doux command string
/// and evaluated by the engine.
pub fn run(self) {
while let Ok(payload) = self.rx.recv() {
let cmd = payload_to_command(&payload.args, payload.timetag, &self.time_converter);
if let Ok(mut engine) = self.engine.lock() {
engine.evaluate(&cmd);
}
}
}
}

107
doux-sova/src/scope.rs Normal file
View File

@@ -0,0 +1,107 @@
//! Lock-free oscilloscope capture for the audio engine.
use std::sync::atomic::{AtomicUsize, Ordering};
const BUFFER_SIZE: usize = 1600;
/// Lock-free triple-buffer for audio oscilloscope capture.
///
/// Uses three buffers: one being written by the audio thread, one ready for
/// reading, and one in transition. This allows lock-free concurrent access
/// from the audio callback (writer) and UI thread (reader).
pub struct ScopeCapture {
buffers: [Box<[f32; BUFFER_SIZE]>; 3],
write_idx: AtomicUsize,
write_buffer: AtomicUsize,
read_buffer: AtomicUsize,
}
// SAFETY: All mutable access is through atomic operations or single-writer guarantee.
// The write methods are only called from one audio callback thread at a time.
unsafe impl Send for ScopeCapture {}
// SAFETY: Concurrent read/write is safe due to triple-buffering design.
// Writer and reader operate on different buffers, synchronized via atomics.
unsafe impl Sync for ScopeCapture {}
impl ScopeCapture {
/// Creates a new scope capture with zeroed buffers.
pub fn new() -> Self {
Self {
buffers: [
Box::new([0.0; BUFFER_SIZE]),
Box::new([0.0; BUFFER_SIZE]),
Box::new([0.0; BUFFER_SIZE]),
],
write_idx: AtomicUsize::new(0),
write_buffer: AtomicUsize::new(0),
read_buffer: AtomicUsize::new(2),
}
}
/// Pushes a stereo sample pair, converting to mono for display.
#[inline]
pub fn push_stereo(&self, left: f32, right: f32) {
let mono = (left + right) * 0.5;
self.push_mono(mono);
}
/// Pushes a mono sample to the write buffer.
#[inline]
pub fn push_mono(&self, sample: f32) {
let buf_idx = self.write_buffer.load(Ordering::Relaxed);
let write_pos = self.write_idx.load(Ordering::Relaxed);
let buf_ptr = self.buffers[buf_idx].as_ptr() as *mut f32;
// SAFETY: write_pos is always < BUFFER_SIZE, and only one writer exists.
unsafe {
*buf_ptr.add(write_pos) = sample;
}
let next_pos = write_pos + 1;
if next_pos >= BUFFER_SIZE {
let next_buf = (buf_idx + 1) % 3;
self.read_buffer.store(buf_idx, Ordering::Release);
self.write_buffer.store(next_buf, Ordering::Relaxed);
self.write_idx.store(0, Ordering::Relaxed);
} else {
self.write_idx.store(next_pos, Ordering::Relaxed);
}
}
/// Returns peak (min, max) pairs for waveform display.
pub fn read_peaks(&self, num_peaks: usize) -> Vec<(f32, f32)> {
if num_peaks == 0 {
return Vec::new();
}
let buf_idx = self.read_buffer.load(Ordering::Acquire);
let buf = &self.buffers[buf_idx];
let window = (BUFFER_SIZE / num_peaks).max(1);
buf.chunks(window)
.take(num_peaks)
.map(|chunk| {
chunk
.iter()
.fold((f32::MAX, f32::MIN), |(min, max), &s| (min.min(s), max.max(s)))
})
.collect()
}
/// Returns a copy of the current read buffer samples.
pub fn read_samples(&self) -> Vec<f32> {
let buf_idx = self.read_buffer.load(Ordering::Acquire);
self.buffers[buf_idx].to_vec()
}
/// Returns the buffer size in samples.
pub const fn buffer_size() -> usize {
BUFFER_SIZE
}
}
impl Default for ScopeCapture {
fn default() -> Self {
Self::new()
}
}

34
doux-sova/src/time.rs Normal file
View File

@@ -0,0 +1,34 @@
//! Time synchronization between Sova and Doux.
//!
//! Sova uses microsecond timestamps (SyncTime) from its internal clock.
//! Doux uses seconds relative to engine start. This module converts between them.
use sova_core::clock::SyncTime;
/// Converts Sova timestamps to Doux engine time.
///
/// Stores the initial sync time (engine start) and computes relative
/// offsets in seconds for incoming events.
pub struct TimeConverter {
/// Microsecond timestamp when the engine was started.
engine_start_micros: SyncTime,
}
impl TimeConverter {
/// Creates a converter with the given initial sync time.
///
/// Pass `clock.micros()` at engine startup.
pub fn new(initial_sync_time: SyncTime) -> Self {
Self {
engine_start_micros: initial_sync_time,
}
}
/// Converts a Sova timetag to engine time in seconds.
///
/// Returns the number of seconds since engine start.
pub fn sync_to_engine_time(&self, timetag: SyncTime) -> f64 {
let delta = timetag.saturating_sub(self.engine_start_micros);
(delta as f64) / 1_000_000.0
}
}

10
package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "doux",
"type": "module",
"private": true,
"scripts": {
"dev": "cd website && pnpm dev",
"build": "cd website && pnpm build",
"preview": "cd website && pnpm preview"
}
}

141
src/audio.rs Normal file
View File

@@ -0,0 +1,141 @@
//! Audio device enumeration and stream creation utilities.
//!
//! Provides functions to list available audio devices and create audio streams
//! with specific configurations.
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{Device, Host, SupportedStreamConfig};
/// Information about an available audio device.
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
pub name: String,
pub index: usize,
pub max_channels: u16,
pub is_default: bool,
}
/// Returns the default CPAL host for the current platform.
pub fn default_host() -> Host {
cpal::default_host()
}
/// Lists all available output audio devices.
pub fn list_output_devices() -> Vec<AudioDeviceInfo> {
let host = default_host();
let default_name = host
.default_output_device()
.and_then(|d| d.name().ok());
let Ok(devices) = host.output_devices() else {
return Vec::new();
};
devices
.enumerate()
.filter_map(|(index, device)| {
let name = device.name().ok()?;
let max_channels = device
.supported_output_configs()
.ok()?
.map(|c| c.channels())
.max()
.unwrap_or(2);
let is_default = Some(&name) == default_name.as_ref();
Some(AudioDeviceInfo {
name,
index,
max_channels,
is_default,
})
})
.collect()
}
/// Lists all available input audio devices.
pub fn list_input_devices() -> Vec<AudioDeviceInfo> {
let host = default_host();
let default_name = host
.default_input_device()
.and_then(|d| d.name().ok());
let Ok(devices) = host.input_devices() else {
return Vec::new();
};
devices
.enumerate()
.filter_map(|(index, device)| {
let name = device.name().ok()?;
let max_channels = device
.supported_input_configs()
.ok()?
.map(|c| c.channels())
.max()
.unwrap_or(2);
let is_default = Some(&name) == default_name.as_ref();
Some(AudioDeviceInfo {
name,
index,
max_channels,
is_default,
})
})
.collect()
}
/// Finds an output device by index or partial name match.
///
/// If `spec` parses as a number, returns the device at that index.
/// Otherwise, performs a case-insensitive substring match on device names.
pub fn find_output_device(spec: &str) -> Option<Device> {
let host = default_host();
let devices = host.output_devices().ok()?;
find_device_impl(devices, spec)
}
/// Finds an input device by index or partial name match.
pub fn find_input_device(spec: &str) -> Option<Device> {
let host = default_host();
let devices = host.input_devices().ok()?;
find_device_impl(devices, spec)
}
fn find_device_impl<I>(devices: I, spec: &str) -> Option<Device>
where
I: Iterator<Item = Device>,
{
let devices: Vec<_> = devices.collect();
if let Ok(idx) = spec.parse::<usize>() {
return devices.into_iter().nth(idx);
}
let spec_lower = spec.to_lowercase();
devices.into_iter().find(|d| {
d.name()
.map(|n| n.to_lowercase().contains(&spec_lower))
.unwrap_or(false)
})
}
/// Returns the default output device.
pub fn default_output_device() -> Option<Device> {
default_host().default_output_device()
}
/// Returns the default input device.
pub fn default_input_device() -> Option<Device> {
default_host().default_input_device()
}
/// Gets the default output config for a device.
pub fn default_output_config(device: &Device) -> Option<SupportedStreamConfig> {
device.default_output_config().ok()
}
/// Gets the maximum number of output channels supported by a device.
pub fn max_output_channels(device: &Device) -> u16 {
device
.supported_output_configs()
.map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2))
.unwrap_or(2)
}

66
src/config.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Configuration types for the Doux audio engine.
use std::path::PathBuf;
/// Configuration for the Doux audio engine.
#[derive(Debug, Clone)]
pub struct DouxConfig {
/// Output device specification (name or index). None uses system default.
pub output_device: Option<String>,
/// Input device specification (name or index). None uses system default.
pub input_device: Option<String>,
/// Number of output channels (will be clamped to device maximum).
pub channels: u16,
/// Paths to sample directories for lazy loading.
pub sample_paths: Vec<PathBuf>,
/// Audio buffer size in samples. None uses system default.
pub buffer_size: Option<u32>,
}
impl Default for DouxConfig {
fn default() -> Self {
Self {
output_device: None,
input_device: None,
channels: 2,
sample_paths: Vec::new(),
buffer_size: None,
}
}
}
impl DouxConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_output_device(mut self, device: impl Into<String>) -> Self {
self.output_device = Some(device.into());
self
}
pub fn with_input_device(mut self, device: impl Into<String>) -> Self {
self.input_device = Some(device.into());
self
}
pub fn with_channels(mut self, channels: u16) -> Self {
self.channels = channels;
self
}
pub fn with_sample_path(mut self, path: impl Into<PathBuf>) -> Self {
self.sample_paths.push(path.into());
self
}
pub fn with_sample_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
self.sample_paths.extend(paths);
self
}
pub fn with_buffer_size(mut self, size: u32) -> Self {
self.buffer_size = Some(size);
self
}
}

134
src/effects/chorus.rs Normal file
View File

@@ -0,0 +1,134 @@
//! Multi-voice chorus effect with stereo modulation.
//!
//! Creates a shimmering, widened sound by mixing the dry signal with multiple
//! delayed copies whose delay times are modulated by LFOs. Each voice uses a
//! different LFO phase, and left/right channels are modulated in opposite
//! directions for stereo spread.
//!
//! # Signal Flow
//!
//! ```text
//! L+R → mono → delay line ─┬─ voice 0 (LFO phase 0°) ─┬─→ L
//! ├─ voice 1 (LFO phase 120°) ─┤
//! └─ voice 2 (LFO phase 240°) ─┴─→ R
//! ```
//!
//! The three voices are phase-offset by 120° to avoid reinforcement artifacts.
//! Left and right taps use opposite modulation polarity for stereo width.
use crate::oscillator::Phasor;
/// Delay buffer size in samples (~42ms at 48kHz).
const BUFFER_SIZE: usize = 2048;
/// Number of chorus voices (phase-offset delay taps).
const VOICES: usize = 3;
/// Multi-voice stereo chorus effect.
///
/// Uses a circular delay buffer with three LFO-modulated tap points.
/// The LFOs are phase-offset by 1/3 cycle (120°) to create smooth,
/// non-pulsing modulation.
#[derive(Clone, Copy)]
pub struct Chorus {
/// Circular delay buffer (mono, power-of-2 for efficient wrapping).
buffer: [f32; BUFFER_SIZE],
/// Current write position in the delay buffer.
write_pos: usize,
/// Per-voice LFOs for delay time modulation.
lfo: [Phasor; VOICES],
}
impl Default for Chorus {
fn default() -> Self {
let mut lfo = [Phasor::default(); VOICES];
// Distribute LFO phases evenly: 0°, 120°, 240°
for (i, l) in lfo.iter_mut().enumerate() {
l.phase = i as f32 / VOICES as f32;
}
Self {
buffer: [0.0; BUFFER_SIZE],
write_pos: 0,
lfo,
}
}
}
impl Chorus {
/// Processes one stereo sample through the chorus.
///
/// # Parameters
///
/// - `left`, `right`: Input stereo sample
/// - `rate`: LFO frequency in Hz (typical: 0.5-3.0)
/// - `depth`: Modulation intensity `[0.0, 1.0]`
/// - `delay_ms`: Base delay time in milliseconds (typical: 10-30)
/// - `sr`: Sample rate in Hz
/// - `isr`: Inverse sample rate (1.0 / sr)
///
/// # Returns
///
/// Stereo output `[left, right]` with 50/50 dry/wet mix (equal power).
pub fn process(
&mut self,
left: f32,
right: f32,
rate: f32,
depth: f32,
delay_ms: f32,
sr: f32,
isr: f32,
) -> [f32; 2] {
let depth = depth.clamp(0.0, 1.0);
let mod_range = delay_ms * 0.8;
// Sum to mono for delay line (common chorus technique)
let mono = (left + right) * 0.5;
self.buffer[self.write_pos] = mono;
let mut out_l = 0.0_f32;
let mut out_r = 0.0_f32;
let min_delay = 1.5;
let max_delay = 50.0_f32.min((BUFFER_SIZE as f32 - 2.0) * 1000.0 / sr);
for v in 0..VOICES {
let lfo = self.lfo[v].sine(rate, isr);
// Opposite modulation for L/R creates stereo width
let modulation = depth * mod_range * lfo;
let dly_l = (delay_ms + modulation).clamp(min_delay, max_delay);
let dly_r = (delay_ms - modulation).clamp(min_delay, max_delay);
// Convert ms to samples
let samp_l = (dly_l * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
let samp_r = (dly_r * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
// Linear interpolation for sub-sample accuracy
let pos_l = samp_l.floor() as usize;
let frac_l = samp_l - pos_l as f32;
let idx_l0 = (self.write_pos + BUFFER_SIZE - pos_l) & (BUFFER_SIZE - 1);
let idx_l1 = (self.write_pos + BUFFER_SIZE - pos_l - 1) & (BUFFER_SIZE - 1);
let tap_l = self.buffer[idx_l0] + frac_l * (self.buffer[idx_l1] - self.buffer[idx_l0]);
let pos_r = samp_r.floor() as usize;
let frac_r = samp_r - pos_r as f32;
let idx_r0 = (self.write_pos + BUFFER_SIZE - pos_r) & (BUFFER_SIZE - 1);
let idx_r1 = (self.write_pos + BUFFER_SIZE - pos_r - 1) & (BUFFER_SIZE - 1);
let tap_r = self.buffer[idx_r0] + frac_r * (self.buffer[idx_r1] - self.buffer[idx_r0]);
out_l += tap_l;
out_r += tap_r;
}
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
// Average the voices
out_l /= VOICES as f32;
out_r /= VOICES as f32;
// Equal-power mix: dry × 0.707 + wet × 0.707
const MIX: f32 = std::f32::consts::FRAC_1_SQRT_2;
[mono * MIX + out_l * MIX, mono * MIX + out_r * MIX]
}
}

47
src/effects/coarse.rs Normal file
View File

@@ -0,0 +1,47 @@
//! Sample rate reduction (bitcrusher-style decimation).
//!
//! Reduces the effective sample rate by holding each sample value for multiple
//! output samples, creating the characteristic "crunchy" lo-fi sound of early
//! samplers and video game consoles.
//!
//! # Example
//!
//! With `factor = 4` at 48kHz, the effective sample rate becomes 12kHz:
//!
//! ```text
//! Input: [a, b, c, d, e, f, g, h, ...]
//! Output: [a, a, a, a, e, e, e, e, ...]
//! ```
/// Sample-and-hold decimator for lo-fi effects.
///
/// Holds input values for `factor` samples, reducing effective sample rate.
/// Often combined with bit depth reduction for full bitcrusher effects.
#[derive(Clone, Copy, Default)]
pub struct Coarse {
/// Currently held sample value.
hold: f32,
/// Sample counter (0 to factor-1).
t: usize,
}
impl Coarse {
/// Processes one sample through the decimator.
///
/// # Parameters
///
/// - `input`: Input sample
/// - `factor`: Decimation factor (1.0 = bypass, 2.0 = half rate, etc.)
///
/// # Returns
///
/// The held sample value. Updates only when the internal counter wraps.
pub fn process(&mut self, input: f32, factor: f32) -> f32 {
let n = factor.max(1.0) as usize;
if self.t == 0 {
self.hold = input;
}
self.t = (self.t + 1) % n;
self.hold
}
}

58
src/effects/comb.rs Normal file
View File

@@ -0,0 +1,58 @@
//! Comb filter with damping.
//!
//! Creates resonant peaks at `freq` and its harmonics by feeding delayed
//! signal back into itself. Damping applies a lowpass in the feedback path,
//! causing higher harmonics to decay faster (Karplus-Strong style).
const BUFFER_SIZE: usize = 2048;
/// Feedback comb filter with one-pole damping.
#[derive(Clone, Copy)]
pub struct Comb {
buffer: [f32; BUFFER_SIZE],
write_pos: usize,
damp_state: f32,
}
impl Default for Comb {
fn default() -> Self {
Self {
buffer: [0.0; BUFFER_SIZE],
write_pos: 0,
damp_state: 0.0,
}
}
}
impl Comb {
/// Processes one sample through the comb filter.
///
/// - `freq`: Fundamental frequency (delay = 1/freq)
/// - `feedback`: Feedback amount `[-0.99, 0.99]`
/// - `damp`: High-frequency loss per iteration `[0.0, 1.0]`
///
/// Returns the delayed signal (wet only).
pub fn process(&mut self, input: f32, freq: f32, feedback: f32, damp: f32, sr: f32) -> f32 {
let delay_samples = (sr / freq).clamp(1.0, (BUFFER_SIZE - 1) as f32);
let delay_int = delay_samples.floor() as usize;
let frac = delay_samples - delay_int as f32;
// Linear interpolation for precise tuning
let idx0 = (self.write_pos + BUFFER_SIZE - delay_int) & (BUFFER_SIZE - 1);
let idx1 = (self.write_pos + BUFFER_SIZE - delay_int - 1) & (BUFFER_SIZE - 1);
let delayed = self.buffer[idx0] + frac * (self.buffer[idx1] - self.buffer[idx0]);
let feedback = feedback.clamp(-0.99, 0.99);
let fb_signal = if damp > 0.0 {
self.damp_state = delayed * (1.0 - damp) + self.damp_state * damp;
self.damp_state
} else {
delayed
};
self.buffer[self.write_pos] = input + fb_signal * feedback;
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
delayed
}
}

19
src/effects/crush.rs Normal file
View File

@@ -0,0 +1,19 @@
//! Bit depth reduction for lo-fi effects.
//!
//! Quantizes amplitude to fewer bits, creating the stepped distortion
//! characteristic of early digital audio. Pair with [`super::coarse`] for
//! full bitcrusher (sample rate + bit depth reduction).
use crate::fastmath::exp2f;
/// Reduces bit depth by quantizing to `2^(bits-1)` levels.
///
/// - `bits = 16`: Near-transparent (CD quality)
/// - `bits = 8`: Classic 8-bit crunch
/// - `bits = 4`: Heavily degraded
/// - `bits = 1`: Square wave (extreme)
pub fn crush(input: f32, bits: f32) -> f32 {
let bits = bits.max(1.0);
let x = exp2f(bits - 1.0);
(input * x).round() / x
}

37
src/effects/distort.rs Normal file
View File

@@ -0,0 +1,37 @@
//! Waveshaping distortion effects.
//!
//! Three flavors of nonlinear distortion:
//! - [`distort`]: Soft saturation (tube-like warmth)
//! - [`fold`]: Wavefolding (complex harmonics)
//! - [`wrap`]: Phase wrapping (harsh, digital)
use crate::fastmath::{expm1f, sinf};
/// Soft-knee saturation with adjustable drive.
///
/// Uses `x / (1 + k|x|)` transfer function for smooth clipping.
/// Higher `amount` = more compression and harmonics.
pub fn distort(input: f32, amount: f32, postgain: f32) -> f32 {
let k = expm1f(amount);
((1.0 + k) * input / (1.0 + k * input.abs())) * postgain
}
/// Sine wavefolder.
///
/// Folds the waveform back on itself using `sin(x × amount × π/2)`.
/// Creates rich harmonic content without hard clipping.
pub fn fold(input: f32, amount: f32) -> f32 {
sinf(input * amount * std::f32::consts::FRAC_PI_2)
}
/// Wraps signal into `[-1, 1]` range using modulo.
///
/// Creates harsh, digital-sounding distortion with discontinuities.
/// `wraps` controls how many times the signal can wrap.
pub fn wrap(input: f32, wraps: f32) -> f32 {
if wraps < 1.0 {
return input;
}
let x = input * (1.0 + wraps);
(x + 1.0).rem_euclid(2.0) - 1.0
}

75
src/effects/flanger.rs Normal file
View File

@@ -0,0 +1,75 @@
//! Flanger effect with LFO-modulated delay.
//!
//! Creates the characteristic "jet plane" sweep by mixing the input with a
//! short, modulated delay (0.5-10ms). Feedback intensifies the comb filtering.
use crate::oscillator::Phasor;
const BUFFER_SIZE: usize = 512;
const MIN_DELAY_MS: f32 = 0.5;
const MAX_DELAY_MS: f32 = 10.0;
const DELAY_RANGE_MS: f32 = MAX_DELAY_MS - MIN_DELAY_MS;
/// Mono flanger with feedback.
#[derive(Clone, Copy)]
pub struct Flanger {
buffer: [f32; BUFFER_SIZE],
write_pos: usize,
lfo: Phasor,
feedback: f32,
}
impl Default for Flanger {
fn default() -> Self {
Self {
buffer: [0.0; BUFFER_SIZE],
write_pos: 0,
lfo: Phasor::default(),
feedback: 0.0,
}
}
}
impl Flanger {
/// Processes one sample.
///
/// - `rate`: LFO speed in Hz (typical: 0.1-2.0)
/// - `depth`: Modulation amount `[0.0, 1.0]` (squared for smoother response)
/// - `feedback`: Resonance `[0.0, 0.95]`
///
/// Returns 50/50 dry/wet mix.
pub fn process(
&mut self,
input: f32,
rate: f32,
depth: f32,
feedback: f32,
sr: f32,
isr: f32,
) -> f32 {
let lfo_val = self.lfo.sine(rate, isr);
let depth_curve = depth * depth;
let delay_ms = MIN_DELAY_MS + depth_curve * DELAY_RANGE_MS * (lfo_val * 0.5 + 0.5);
let delay_samples = (delay_ms * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0);
let read_pos_int = delay_samples.floor() as usize;
let frac = delay_samples - read_pos_int as f32;
let read_index1 = (self.write_pos + BUFFER_SIZE - read_pos_int) & (BUFFER_SIZE - 1);
let read_index2 = (self.write_pos + BUFFER_SIZE - read_pos_int - 1) & (BUFFER_SIZE - 1);
let delayed1 = self.buffer[read_index1];
let delayed2 = self.buffer[read_index2];
let delayed = delayed1 + frac * (delayed2 - delayed1);
let feedback = feedback.clamp(0.0, 0.95);
self.buffer[self.write_pos] = input + self.feedback * feedback;
self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1);
self.feedback = delayed;
input * 0.5 + delayed * 0.5
}
}

24
src/effects/lag.rs Normal file
View File

@@ -0,0 +1,24 @@
//! One-pole smoothing filter (slew limiter).
//!
//! Smooths abrupt parameter changes to prevent clicks and zipper noise.
//! Higher rate = slower response.
/// One-pole lowpass for parameter smoothing.
#[derive(Clone, Copy, Default)]
pub struct Lag {
/// Current smoothed value.
pub s: f32,
}
impl Lag {
/// Moves toward `input` at a rate controlled by `rate × lag_unit`.
///
/// - `rate`: Smoothing factor (higher = slower)
/// - `lag_unit`: Scaling factor (typically sample-rate dependent)
#[inline]
pub fn update(&mut self, input: f32, rate: f32, lag_unit: f32) -> f32 {
let coeff = 1.0 / (rate * lag_unit).max(1.0);
self.s += coeff * (input - self.s);
self.s
}
}

17
src/effects/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
mod chorus;
mod coarse;
mod comb;
mod crush;
mod distort;
mod flanger;
mod lag;
mod phaser;
pub use chorus::Chorus;
pub use coarse::Coarse;
pub use comb::Comb;
pub use crush::crush;
pub use distort::{distort, fold, wrap};
pub use flanger::Flanger;
pub use lag::Lag;
pub use phaser::Phaser;

50
src/effects/phaser.rs Normal file
View File

@@ -0,0 +1,50 @@
//! Phaser effect using cascaded notch filters.
//!
//! Creates the sweeping, hollow sound by modulating two notch filters
//! with an LFO. The notches are offset by ~282 Hz for a richer effect.
use crate::fastmath::exp2f;
use crate::filter::Biquad;
use crate::oscillator::Phasor;
use crate::types::FilterType;
/// Frequency offset between the two notch filters (Hz).
const NOTCH_OFFSET: f32 = 282.0;
/// Two-stage phaser with LFO modulation.
#[derive(Clone, Copy, Default)]
pub struct Phaser {
notch1: Biquad,
notch2: Biquad,
lfo: Phasor,
}
impl Phaser {
/// Processes one sample.
///
/// - `rate`: LFO speed in Hz
/// - `depth`: Notch resonance (higher = more pronounced, max ~0.95)
/// - `center`: Base frequency in Hz
/// - `sweep`: Modulation range in cents (1200 = ±1 octave)
pub fn process(
&mut self,
input: f32,
rate: f32,
depth: f32,
center: f32,
sweep: f32,
sr: f32,
isr: f32,
) -> f32 {
let lfo_val = self.lfo.sine(rate, isr);
let q = 2.0 - (depth * 2.0).min(1.9);
let detune = exp2f(lfo_val * sweep * (1.0 / 1200.0));
let max_freq = sr * 0.45;
let freq1 = (center * detune).clamp(20.0, max_freq);
let freq2 = ((center + NOTCH_OFFSET) * detune).clamp(20.0, max_freq);
let out = self.notch1.process(input, FilterType::Notch, freq1, q, sr);
self.notch2.process(out, FilterType::Notch, freq2, q, sr)
}
}

256
src/envelope.rs Normal file
View File

@@ -0,0 +1,256 @@
//! ADSR envelope generation for audio synthesis.
//!
//! This module provides a state-machine based ADSR (Attack, Decay, Sustain, Release)
//! envelope generator with configurable curve shapes. The envelope responds to gate
//! signals and produces amplitude values in the range `[0.0, 1.0]`.
//!
//! # Curve Shaping
//!
//! Attack and decay/release phases use exponential curves controlled by internal
//! parameters. Positive exponents create convex curves (slow start, fast finish),
//! while negative exponents create concave curves (fast start, slow finish).
use crate::fastmath::powf;
/// Attempt to scale the input `x` from range `[0, 1]` to range `[y0, y1]` with an exponent `exp`.
///
/// Attempt because the expression `powf(1.0 - x, -exp)` can lead to a NaN when `exp` is greater than 1.0.
/// Using this function on 1.0 - x reverses the curve direction.
///
/// - `exp > 0`: Convex curve (slow start, accelerates toward end)
/// - `exp < 0`: Concave curve (fast start, decelerates toward end)
/// - `exp == 0`: Linear interpolation
fn lerp(x: f32, y0: f32, y1: f32, exp: f32) -> f32 {
if x <= 0.0 {
return y0;
}
if x >= 1.0 {
return y1;
}
let curved = if exp == 0.0 {
x
} else if exp > 0.0 {
powf(x, exp)
} else {
1.0 - powf(1.0 - x, -exp)
};
y0 + (y1 - y0) * curved
}
/// Current phase of the ADSR envelope state machine.
#[derive(Clone, Copy)]
pub enum AdsrState {
/// Envelope is inactive, outputting zero.
Off,
/// Rising from current value toward peak (1.0).
Attack,
/// Falling from peak toward sustain level.
Decay,
/// Holding at sustain level while gate remains high.
Sustain,
/// Falling from current value toward zero after gate release.
Release,
}
/// State-machine ADSR envelope generator.
///
/// Tracks envelope phase and timing internally. Call [`Adsr::update`] each sample
/// with the current time and gate signal to produce envelope values.
///
/// # Curve Parameters
///
/// Default curves use an exponent of `2.0` for attack (convex) and decay/release
/// (concave when negated internally), producing natural-sounding amplitude shapes.
#[derive(Clone, Copy)]
pub struct Adsr {
state: AdsrState,
start_time: f32,
start_val: f32,
attack_curve: f32,
decay_curve: f32,
}
impl Default for Adsr {
fn default() -> Self {
Self {
state: AdsrState::Off,
start_time: 0.0,
start_val: 0.0,
attack_curve: 2.0,
decay_curve: 2.0,
}
}
}
impl Adsr {
/// Returns `true` if the envelope is in the [`AdsrState::Off`] state.
pub fn is_off(&self) -> bool {
matches!(self.state, AdsrState::Off)
}
/// Advances the envelope state machine and returns the current amplitude.
///
/// The envelope responds to gate transitions:
/// - Gate going high (`> 0.0`) triggers attack from current value
/// - Gate going low (`<= 0.0`) triggers release from current value
///
/// This allows retriggering during any phase without clicks, as the envelope
/// always starts from its current position rather than jumping to zero.
///
/// # Parameters
///
/// - `time`: Current time in seconds (must be monotonically increasing)
/// - `gate`: Gate signal (`> 0.0` = note on, `<= 0.0` = note off)
/// - `attack`: Attack duration in seconds
/// - `decay`: Decay duration in seconds
/// - `sustain`: Sustain level in range `[0.0, 1.0]`
/// - `release`: Release duration in seconds
///
/// # Returns
///
/// Envelope amplitude in range `[0.0, 1.0]`.
pub fn update(
&mut self,
time: f32,
gate: f32,
attack: f32,
decay: f32,
sustain: f32,
release: f32,
) -> f32 {
match self.state {
AdsrState::Off => {
if gate > 0.0 {
self.state = AdsrState::Attack;
self.start_time = time;
self.start_val = 0.0;
}
0.0
}
AdsrState::Attack => {
let t = time - self.start_time;
if t > attack {
self.state = AdsrState::Decay;
self.start_time = time;
return 1.0;
}
lerp(t / attack, self.start_val, 1.0, self.attack_curve)
}
AdsrState::Decay => {
let t = time - self.start_time;
let val = lerp(t / decay, 1.0, sustain, -self.decay_curve);
if gate <= 0.0 {
self.state = AdsrState::Release;
self.start_time = time;
self.start_val = val;
return val;
}
if t > decay {
self.state = AdsrState::Sustain;
self.start_time = time;
return sustain;
}
val
}
AdsrState::Sustain => {
if gate <= 0.0 {
self.state = AdsrState::Release;
self.start_time = time;
self.start_val = sustain;
}
sustain
}
AdsrState::Release => {
let t = time - self.start_time;
if t > release {
self.state = AdsrState::Off;
return 0.0;
}
let val = lerp(t / release, self.start_val, 0.0, -self.decay_curve);
if gate > 0.0 {
self.state = AdsrState::Attack;
self.start_time = time;
self.start_val = val;
}
val
}
}
}
}
/// Parsed envelope parameters with activation flag.
///
/// Used to pass envelope configuration from pattern parsing to voice rendering.
/// The `active` field indicates whether the user explicitly specified any
/// envelope parameters, allowing voices to skip envelope processing when unused.
#[derive(Clone, Copy, Default)]
pub struct EnvelopeParams {
/// Overall envelope amplitude multiplier.
pub env: f32,
/// Attack time in seconds.
pub att: f32,
/// Decay time in seconds.
pub dec: f32,
/// Sustain level in range `[0.0, 1.0]`.
pub sus: f32,
/// Release time in seconds.
pub rel: f32,
/// Whether envelope parameters were explicitly provided.
pub active: bool,
}
/// Constructs envelope parameters from optional user inputs.
///
/// Applies sensible defaults and infers sustain level from context:
/// - If sustain is explicit, use it (clamped to `1.0`)
/// - If only attack is set, sustain defaults to `1.0` (full level after attack)
/// - If decay is set (with or without attack), sustain defaults to `0.0`
/// - Otherwise, sustain defaults to `1.0`
///
/// When no parameters are provided, returns inactive defaults suitable for
/// bypassing envelope processing entirely.
///
/// # Default Values
///
/// | Parameter | Default |
/// |-----------|---------|
/// | `env` | `1.0` |
/// | `att` | `0.001` |
/// | `dec` | `0.0` |
/// | `sus` | `1.0` |
/// | `rel` | `0.005` |
pub fn init_envelope(
env: Option<f32>,
att: Option<f32>,
dec: Option<f32>,
sus: Option<f32>,
rel: Option<f32>,
) -> EnvelopeParams {
if env.is_none() && att.is_none() && dec.is_none() && sus.is_none() && rel.is_none() {
return EnvelopeParams {
env: 1.0,
att: 0.001,
dec: 0.0,
sus: 1.0,
rel: 0.005,
active: false,
};
}
let sus_val = match (sus, att, dec) {
(Some(s), _, _) => s.min(1.0),
(None, Some(_), None) => 1.0,
(None, None, Some(_)) => 0.0,
(None, Some(_), Some(_)) => 0.0,
_ => 1.0,
};
EnvelopeParams {
env: env.unwrap_or(1.0),
att: att.unwrap_or(0.001),
dec: dec.unwrap_or(0.0),
sus: sus_val,
rel: rel.unwrap_or(0.005),
active: true,
}
}

42
src/error.rs Normal file
View File

@@ -0,0 +1,42 @@
//! Error types for the Doux audio engine.
use std::fmt;
/// Errors that can occur when working with the Doux audio engine.
#[derive(Debug)]
pub enum DouxError {
/// The specified audio device was not found.
DeviceNotFound(String),
/// No default audio device is available.
NoDefaultDevice,
/// Failed to create an audio stream.
StreamCreationFailed(String),
/// The requested channel count is invalid.
InvalidChannelCount(u16),
/// Failed to get device configuration.
DeviceConfigError(String),
}
impl fmt::Display for DouxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DouxError::DeviceNotFound(name) => {
write!(f, "audio device not found: {name}")
}
DouxError::NoDefaultDevice => {
write!(f, "no default audio device available")
}
DouxError::StreamCreationFailed(msg) => {
write!(f, "failed to create audio stream: {msg}")
}
DouxError::InvalidChannelCount(count) => {
write!(f, "invalid channel count: {count}")
}
DouxError::DeviceConfigError(msg) => {
write!(f, "device configuration error: {msg}")
}
}
}
}
impl std::error::Error for DouxError {}

291
src/event.rs Normal file
View File

@@ -0,0 +1,291 @@
use crate::types::{midi2freq, DelayType, FilterSlope, LfoShape};
#[derive(Clone, Default, Debug)]
pub struct Event {
pub cmd: Option<String>,
// Timing
pub time: Option<f64>,
pub repeat: Option<f32>,
pub duration: Option<f32>,
pub gate: Option<f32>,
// Voice control
pub voice: Option<usize>,
pub reset: Option<bool>,
pub orbit: Option<usize>,
// Pitch
pub freq: Option<f32>,
pub detune: Option<f32>,
pub speed: Option<f32>,
pub glide: Option<f32>,
// Source
pub sound: Option<String>,
pub pw: Option<f32>,
pub spread: Option<f32>,
pub size: Option<u16>,
pub mult: Option<f32>,
pub warp: Option<f32>,
pub mirror: Option<f32>,
pub harmonics: Option<f32>,
pub timbre: Option<f32>,
pub morph: Option<f32>,
pub n: Option<usize>,
pub cut: Option<usize>,
pub begin: Option<f32>,
pub end: Option<f32>,
// Web sample (WASM only - set by JavaScript)
pub file_pcm: Option<usize>,
pub file_frames: Option<usize>,
pub file_channels: Option<u8>,
pub file_freq: Option<f32>,
// Gain
pub gain: Option<f32>,
pub postgain: Option<f32>,
pub velocity: Option<f32>,
pub pan: Option<f32>,
// Gain envelope
pub attack: Option<f32>,
pub decay: Option<f32>,
pub sustain: Option<f32>,
pub release: Option<f32>,
// Lowpass filter
pub lpf: Option<f32>,
pub lpq: Option<f32>,
pub lpe: Option<f32>,
pub lpa: Option<f32>,
pub lpd: Option<f32>,
pub lps: Option<f32>,
pub lpr: Option<f32>,
// Highpass filter
pub hpf: Option<f32>,
pub hpq: Option<f32>,
pub hpe: Option<f32>,
pub hpa: Option<f32>,
pub hpd: Option<f32>,
pub hps: Option<f32>,
pub hpr: Option<f32>,
// Bandpass filter
pub bpf: Option<f32>,
pub bpq: Option<f32>,
pub bpe: Option<f32>,
pub bpa: Option<f32>,
pub bpd: Option<f32>,
pub bps: Option<f32>,
pub bpr: Option<f32>,
// Filter type
pub ftype: Option<FilterSlope>,
// Pitch envelope
pub penv: Option<f32>,
pub patt: Option<f32>,
pub pdec: Option<f32>,
pub psus: Option<f32>,
pub prel: Option<f32>,
// Vibrato
pub vib: Option<f32>,
pub vibmod: Option<f32>,
pub vibshape: Option<LfoShape>,
// FM synthesis
pub fm: Option<f32>,
pub fmh: Option<f32>,
pub fmshape: Option<LfoShape>,
pub fme: Option<f32>,
pub fma: Option<f32>,
pub fmd: Option<f32>,
pub fms: Option<f32>,
pub fmr: Option<f32>,
// AM
pub am: Option<f32>,
pub amdepth: Option<f32>,
pub amshape: Option<LfoShape>,
// Ring mod
pub rm: Option<f32>,
pub rmdepth: Option<f32>,
pub rmshape: Option<LfoShape>,
// Phaser
pub phaser: Option<f32>,
pub phaserdepth: Option<f32>,
pub phasersweep: Option<f32>,
pub phasercenter: Option<f32>,
// Flanger
pub flanger: Option<f32>,
pub flangerdepth: Option<f32>,
pub flangerfeedback: Option<f32>,
// Chorus
pub chorus: Option<f32>,
pub chorusdepth: Option<f32>,
pub chorusdelay: Option<f32>,
// Comb filter
pub comb: Option<f32>,
pub combfreq: Option<f32>,
pub combfeedback: Option<f32>,
pub combdamp: Option<f32>,
// Distortion
pub coarse: Option<f32>,
pub crush: Option<f32>,
pub fold: Option<f32>,
pub wrap: Option<f32>,
pub distort: Option<f32>,
pub distortvol: Option<f32>,
// Delay
pub delay: Option<f32>,
pub delaytime: Option<f32>,
pub delayfeedback: Option<f32>,
pub delaytype: Option<DelayType>,
// Reverb
pub verb: Option<f32>,
pub verbdecay: Option<f32>,
pub verbdamp: Option<f32>,
pub verbpredelay: Option<f32>,
pub verbdiff: Option<f32>,
}
impl Event {
pub fn parse(input: &str) -> Self {
let mut event = Self::default();
let tokens: Vec<&str> = input.trim().split('/').filter(|s| !s.is_empty()).collect();
let mut i = 0;
while i + 1 < tokens.len() {
let key = tokens[i];
let val = tokens[i + 1];
match key {
"doux" | "dirt" => event.cmd = Some(val.to_string()),
"time" | "t" => event.time = val.parse().ok(),
"repeat" | "rep" => event.repeat = val.parse().ok(),
"duration" | "dur" | "d" => event.duration = val.parse().ok(),
"gate" => event.gate = val.parse().ok(),
"voice" => event.voice = val.parse::<f32>().ok().map(|f| f as usize),
"reset" => event.reset = Some(val == "1" || val == "true"),
"orbit" => event.orbit = val.parse::<f32>().ok().map(|f| f as usize),
"freq" => event.freq = val.parse().ok(),
"note" => event.freq = val.parse().ok().map(midi2freq),
"detune" => event.detune = val.parse().ok(),
"speed" => event.speed = val.parse().ok(),
"glide" => event.glide = val.parse().ok(),
"sound" | "s" => event.sound = Some(val.to_string()),
"pw" => event.pw = val.parse().ok(),
"spread" => event.spread = val.parse().ok(),
"size" => event.size = val.parse().ok(),
"mult" => event.mult = val.parse().ok(),
"warp" => event.warp = val.parse().ok(),
"mirror" => event.mirror = val.parse().ok(),
"harmonics" | "harm" => event.harmonics = val.parse().ok(),
"timbre" => event.timbre = val.parse().ok(),
"morph" => event.morph = val.parse().ok(),
"n" => event.n = val.parse::<f32>().ok().map(|f| f as usize),
"cut" => event.cut = val.parse::<f32>().ok().map(|f| f as usize),
"begin" => event.begin = val.parse().ok(),
"end" => event.end = val.parse().ok(),
"file_pcm" => event.file_pcm = val.parse().ok(),
"file_frames" => event.file_frames = val.parse().ok(),
"file_channels" => event.file_channels = val.parse::<f32>().ok().map(|f| f as u8),
"file_freq" => event.file_freq = val.parse().ok(),
"gain" => event.gain = val.parse().ok(),
"postgain" => event.postgain = val.parse().ok(),
"velocity" => event.velocity = val.parse().ok(),
"pan" => event.pan = val.parse().ok(),
"attack" => event.attack = val.parse().ok(),
"decay" => event.decay = val.parse().ok(),
"sustain" => event.sustain = val.parse().ok(),
"release" => event.release = val.parse().ok(),
"lpf" | "cutoff" => event.lpf = val.parse().ok(),
"lpq" | "resonance" => event.lpq = val.parse().ok(),
"lpe" | "lpenv" => event.lpe = val.parse().ok(),
"lpa" | "lpattack" => event.lpa = val.parse().ok(),
"lpd" | "lpdecay" => event.lpd = val.parse().ok(),
"lps" | "lpsustain" => event.lps = val.parse().ok(),
"lpr" | "lprelease" => event.lpr = val.parse().ok(),
"hpf" | "hcutoff" => event.hpf = val.parse().ok(),
"hpq" | "hresonance" => event.hpq = val.parse().ok(),
"hpe" | "hpenv" => event.hpe = val.parse().ok(),
"hpa" => event.hpa = val.parse().ok(),
"hpd" => event.hpd = val.parse().ok(),
"hps" => event.hps = val.parse().ok(),
"hpr" => event.hpr = val.parse().ok(),
"bpf" | "bandf" => event.bpf = val.parse().ok(),
"bpq" | "bandq" => event.bpq = val.parse().ok(),
"bpe" | "bpenv" => event.bpe = val.parse().ok(),
"bpa" | "bpattack" => event.bpa = val.parse().ok(),
"bpd" | "bpdecay" => event.bpd = val.parse().ok(),
"bps" | "bpsustain" => event.bps = val.parse().ok(),
"bpr" | "bprelease" => event.bpr = val.parse().ok(),
"ftype" => event.ftype = val.parse().ok(),
"penv" => event.penv = val.parse().ok(),
"patt" => event.patt = val.parse().ok(),
"pdec" => event.pdec = val.parse().ok(),
"psus" => event.psus = val.parse().ok(),
"prel" => event.prel = val.parse().ok(),
"vib" => event.vib = val.parse().ok(),
"vibmod" => event.vibmod = val.parse().ok(),
"vibshape" => event.vibshape = val.parse().ok(),
"fm" | "fmi" => event.fm = val.parse().ok(),
"fmh" => event.fmh = val.parse().ok(),
"fmshape" => event.fmshape = val.parse().ok(),
"fme" => event.fme = val.parse().ok(),
"fma" => event.fma = val.parse().ok(),
"fmd" => event.fmd = val.parse().ok(),
"fms" => event.fms = val.parse().ok(),
"fmr" => event.fmr = val.parse().ok(),
"am" => event.am = val.parse().ok(),
"amdepth" => event.amdepth = val.parse().ok(),
"amshape" => event.amshape = val.parse().ok(),
"rm" => event.rm = val.parse().ok(),
"rmdepth" => event.rmdepth = val.parse().ok(),
"rmshape" => event.rmshape = val.parse().ok(),
"phaser" | "phaserrate" => event.phaser = val.parse().ok(),
"phaserdepth" => event.phaserdepth = val.parse().ok(),
"phasersweep" => event.phasersweep = val.parse().ok(),
"phasercenter" => event.phasercenter = val.parse().ok(),
"flanger" | "flangerrate" => event.flanger = val.parse().ok(),
"flangerdepth" => event.flangerdepth = val.parse().ok(),
"flangerfeedback" => event.flangerfeedback = val.parse().ok(),
"chorus" | "chorusrate" => event.chorus = val.parse().ok(),
"chorusdepth" => event.chorusdepth = val.parse().ok(),
"chorusdelay" => event.chorusdelay = val.parse().ok(),
"comb" => event.comb = val.parse().ok(),
"combfreq" => event.combfreq = val.parse().ok(),
"combfeedback" => event.combfeedback = val.parse().ok(),
"combdamp" => event.combdamp = val.parse().ok(),
"coarse" => event.coarse = val.parse().ok(),
"crush" => event.crush = val.parse().ok(),
"fold" => event.fold = val.parse().ok(),
"wrap" => event.wrap = val.parse().ok(),
"distort" => event.distort = val.parse().ok(),
"distortvol" => event.distortvol = val.parse().ok(),
"delay" => event.delay = val.parse().ok(),
"delaytime" => event.delaytime = val.parse().ok(),
"delayfeedback" => event.delayfeedback = val.parse().ok(),
"delaytype" | "dtype" => event.delaytype = val.parse().ok(),
"verb" | "reverb" => event.verb = val.parse().ok(),
"verbdecay" => event.verbdecay = val.parse().ok(),
"verbdamp" => event.verbdamp = val.parse().ok(),
"verbpredelay" => event.verbpredelay = val.parse().ok(),
"verbdiff" => event.verbdiff = val.parse().ok(),
_ => {}
}
i += 2;
}
event
}
}

237
src/fastmath.rs Normal file
View File

@@ -0,0 +1,237 @@
//! Fast approximations for common mathematical functions.
//!
//! This module provides SIMD-friendly, branch-minimal implementations of
//! transcendental functions optimized for audio synthesis. These trade some
//! accuracy for significant performance gains in tight DSP loops.
//!
//! # Accuracy
//!
//! | Function | Typical Error |
//! |----------|---------------|
//! | `exp2f` | < 0.1% |
//! | `log2f` | < 0.1% |
//! | `sinf` | < 1% |
//! | `pow10` | < 1% |
//!
//! # Implementation Notes
//!
//! The logarithm and exponential functions exploit IEEE 754 float bit layout,
//! extracting and manipulating exponent/mantissa fields directly. Trigonometric
//! functions use rational polynomial approximations.
use std::f32::consts::{LOG2_10, LOG2_E, PI, SQRT_2};
/// Bit position of the exponent field in IEEE 754 single precision.
const F32_EXP_SHIFT: i32 = 23;
/// Exponent bias for IEEE 754 single precision.
const F32_BIAS: i32 = 127;
/// Fast base-2 logarithm approximation.
///
/// Uses IEEE 754 bit manipulation to extract the exponent, then applies a
/// rational polynomial correction for the mantissa contribution.
///
/// # Panics
///
/// Does not panic, but returns meaningless results for `x <= 0`.
#[inline]
pub fn log2f(x: f32) -> f32 {
let bits = x.to_bits();
let mantissa_bits = bits & ((1 << F32_EXP_SHIFT) - 1);
let biased_mantissa = f32::from_bits(mantissa_bits | ((F32_BIAS as u32 - 1) << F32_EXP_SHIFT));
let y = bits as f32 * (1.0 / (1 << F32_EXP_SHIFT) as f32);
y - 124.225_45 - 1.498_030_3 * biased_mantissa - 1.725_88 / (0.352_088_72 + biased_mantissa)
}
/// Fast base-2 exponential approximation.
///
/// Separates the integer and fractional parts of the exponent. The integer
/// part is computed via bit manipulation, while the fractional part uses a
/// Taylor-like polynomial expansion centered at 0.5.
#[inline]
pub fn exp2f(x: f32) -> f32 {
let xf = x.floor();
let exp_bits = ((127 + xf as i32) as u32) << 23;
let ystep = f32::from_bits(exp_bits);
let x1 = x - xf;
let xt = x1 - 0.5;
const C1: f32 = 0.980_258_17;
const C2: f32 = 0.339_731_57;
const C3: f32 = 0.078_494_66;
const C4: f32 = 0.013_602_088;
let ytaylor = SQRT_2 + xt * (C1 + xt * (C2 + xt * (C3 + xt * C4)));
const M0: f32 = 0.999_944_3;
const M1: f32 = 1.000_031_2;
ystep * ytaylor * (M0 + (M1 - M0) * x1)
}
/// Fast power function: `x^y`.
///
/// Computed as `2^(y * log2(x))` using fast approximations.
///
/// # Special Cases
///
/// - Returns `NAN` if `x < 0`
/// - Returns `0.0` if `x == 0`
/// - Returns `1.0` if `y == 0`
#[inline]
pub fn powf(x: f32, y: f32) -> f32 {
if x < 0.0 {
return f32::NAN;
}
if x == 0.0 {
return 0.0;
}
if y == 0.0 {
return 1.0;
}
exp2f(y * log2f(x))
}
/// Fast `e^x - 1` approximation.
///
/// Useful for small `x` where `e^x` is close to 1 and direct subtraction
/// would lose precision (though this fast version doesn't preserve that property).
#[inline]
pub fn expm1f(x: f32) -> f32 {
exp2f(x * LOG2_E) - 1.0
}
/// Computes `0.5^x` (equivalently `2^(-x)`).
///
/// Useful for exponential decay calculations where the half-life is the
/// natural unit.
#[inline]
pub fn pow1half(x: f32) -> f32 {
exp2f(-x)
}
/// Fast `10^x` approximation.
///
/// Useful for decibel conversions: `pow10(db / 20.0)` gives amplitude ratio.
#[inline]
pub fn pow10(x: f32) -> f32 {
exp2f(x * LOG2_10)
}
/// Wraps angle to the range `[-π, π]`.
///
/// Essential for maintaining phase coherence in oscillators over long
/// running times, preventing floating-point precision loss.
#[inline]
pub fn modpi(x: f32) -> f32 {
let mut x = x + PI;
x *= 0.5 / PI;
x -= x.floor();
x *= 2.0 * PI;
x - PI
}
/// Parabolic sine approximation.
///
/// Very fast but lower accuracy than [`sinf`]. Uses a single parabola
/// fitted to match sine at 0, ±π/2, and ±π.
#[inline]
pub fn par_sinf(x: f32) -> f32 {
let x = modpi(x);
0.405_284_73 * x * (PI - x.abs())
}
/// Parabolic cosine approximation.
///
/// Phase-shifted [`par_sinf`].
#[inline]
pub fn par_cosf(x: f32) -> f32 {
par_sinf(x + 0.5 * PI)
}
/// Fast sine approximation using rational polynomial.
///
/// Higher accuracy than [`par_sinf`] but still significantly faster than
/// `std::f32::sin`. Uses a Padé-like rational approximation.
#[inline]
pub fn sinf(x: f32) -> f32 {
let x = 4.0 * (x * (0.5 / PI) - (x * (0.5 / PI) + 0.75).floor() + 0.25).abs() - 1.0;
let x = x * (PI / 2.0);
const C1: f32 = 1.0;
const C2: f32 = 445.0 / 12122.0;
const C3: f32 = -(2363.0 / 18183.0);
const C4: f32 = 601.0 / 872784.0;
const C5: f32 = 12671.0 / 4363920.0;
const C6: f32 = 121.0 / 16662240.0;
let xx = x * x;
let num = x * (C1 + xx * (C3 + xx * C5));
let denom = 1.0 + xx * (C2 + xx * (C4 + xx * C6));
num / denom
}
/// Fast cosine approximation.
///
/// Phase-shifted [`sinf`].
#[inline]
pub fn cosf(x: f32) -> f32 {
sinf(x + 0.5 * PI)
}
/// Flush to zero: clamps small values to zero.
///
/// Prevents denormalized floating-point numbers which can cause severe
/// performance degradation in audio processing loops on some architectures.
#[inline]
pub fn ftz(x: f32, limit: f32) -> f32 {
if x < limit && x > -limit {
0.0
} else {
x
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exp2f() {
for i in -10..10 {
let x = i as f32 * 0.5;
let fast = exp2f(x);
let std = 2.0_f32.powf(x);
assert!(
(fast - std).abs() < 0.001,
"exp2f({x}) = {fast} vs std {std}"
);
}
}
#[test]
fn test_sinf() {
for i in 0..20 {
let x = (i as f32 - 10.0) * 0.5;
let fast = sinf(x);
let std = x.sin();
assert!((fast - std).abs() < 0.01, "sinf({x}) = {fast} vs std {std}");
}
}
#[test]
fn test_pow10() {
for i in -5..5 {
let x = i as f32 * 0.5;
let fast = pow10(x);
let std = 10.0_f32.powf(x);
assert!(
(fast - std).abs() / std < 0.01,
"pow10({x}) = {fast} vs std {std}"
);
}
}
}

261
src/filter.rs Normal file
View File

@@ -0,0 +1,261 @@
//! Biquad filter implementation for audio processing.
//!
//! Provides a second-order IIR (biquad) filter with multiple filter types and
//! coefficient caching for efficient real-time parameter modulation.
//!
//! # Filter Types
//!
//! | Type | Description |
//! |-------------|--------------------------------------------------|
//! | Lowpass | Attenuates frequencies above cutoff |
//! | Highpass | Attenuates frequencies below cutoff |
//! | Bandpass | Passes frequencies near cutoff, attenuates rest |
//! | Notch | Attenuates frequencies near cutoff |
//! | Allpass | Passes all frequencies, shifts phase |
//! | Peaking | Boosts/cuts frequencies near cutoff |
//! | Lowshelf | Boosts/cuts frequencies below cutoff |
//! | Highshelf | Boosts/cuts frequencies above cutoff |
//!
//! # Coefficient Formulas
//!
//! Based on Robert Bristow-Johnson's Audio EQ Cookbook.
use crate::fastmath::{par_cosf, par_sinf, pow10};
use crate::types::FilterType;
use std::f32::consts::PI;
/// Second-order IIR (biquad) filter with coefficient caching.
///
/// Implements the standard Direct Form I difference equation:
///
/// ```text
/// y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]
/// ```
///
/// Coefficients are recalculated only when parameters change beyond a threshold,
/// reducing CPU overhead during smooth parameter automation.
#[derive(Clone, Copy)]
pub struct Biquad {
// Feedforward coefficients (numerator)
b0: f32,
b1: f32,
b2: f32,
// Feedback coefficients (denominator, negated)
a1: f32,
a2: f32,
// Input delay line
x1: f32,
x2: f32,
// Output delay line
y1: f32,
y2: f32,
// Cached parameters for change detection
cached_freq: f32,
cached_q: f32,
cached_gain: f32,
cached_filter_type: FilterType,
}
impl Default for Biquad {
fn default() -> Self {
Self {
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
cached_freq: 0.0,
cached_q: 0.0,
cached_gain: 0.0,
cached_filter_type: FilterType::Lowpass,
}
}
}
impl Biquad {
/// Checks if parameters have changed enough to warrant coefficient recalculation.
///
/// Uses relative thresholds: 0.1% for frequency and Q, 0.01 dB for gain.
#[inline]
fn needs_recalc(&self, freq: f32, q: f32, gain: f32, filter_type: FilterType) -> bool {
if filter_type != self.cached_filter_type {
return true;
}
let freq_delta = (freq - self.cached_freq).abs() / self.cached_freq.max(1.0);
let q_delta = (q - self.cached_q).abs() / self.cached_q.max(0.1);
let gain_delta = (gain - self.cached_gain).abs();
freq_delta > 0.001 || q_delta > 0.001 || gain_delta > 0.01
}
/// Processes a single sample through the filter.
///
/// Convenience wrapper for [`Biquad::process_with_gain`] with `gain = 0.0`.
pub fn process(
&mut self,
input: f32,
filter_type: FilterType,
freq: f32,
q: f32,
sr: f32,
) -> f32 {
self.process_with_gain(input, filter_type, freq, q, 0.0, sr)
}
/// Processes a single sample with gain parameter for shelving/peaking filters.
///
/// Recalculates coefficients only when parameters change significantly.
/// For lowpass and highpass, `q` is interpreted as resonance in dB.
/// For other types, `q` is the Q factor directly.
///
/// # Parameters
///
/// - `input`: Input sample
/// - `filter_type`: Type of filter response
/// - `freq`: Cutoff/center frequency in Hz
/// - `q`: Q factor or resonance (interpretation depends on filter type)
/// - `gain`: Boost/cut in dB (only used by peaking and shelving types)
/// - `sr`: Sample rate in Hz
pub fn process_with_gain(
&mut self,
input: f32,
filter_type: FilterType,
freq: f32,
q: f32,
gain: f32,
sr: f32,
) -> f32 {
let freq = freq.clamp(1.0, sr * 0.45);
if self.needs_recalc(freq, q, gain, filter_type) {
let omega = 2.0 * PI * freq / sr;
let sin_omega = par_sinf(omega);
let cos_omega = par_cosf(omega);
let q_linear = match filter_type {
FilterType::Lowpass | FilterType::Highpass => pow10(q / 20.0),
_ => q,
};
let alpha = sin_omega / (2.0 * q_linear);
let (b0, b1, b2, a0, a1, a2) = match filter_type {
FilterType::Lowpass => {
let b1 = 1.0 - cos_omega;
let b0 = b1 / 2.0;
let b2 = b0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Highpass => {
let b0 = (1.0 + cos_omega) / 2.0;
let b1 = -(1.0 + cos_omega);
let b2 = b0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Bandpass => {
let b0 = sin_omega / 2.0;
let b1 = 0.0;
let b2 = -b0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Notch => {
let b0 = 1.0;
let b1 = -2.0 * cos_omega;
let b2 = 1.0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Allpass => {
let b0 = 1.0 - alpha;
let b1 = -2.0 * cos_omega;
let b2 = 1.0 + alpha;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Peaking => {
let a = pow10(gain / 40.0);
let b0 = 1.0 + alpha * a;
let b1 = -2.0 * cos_omega;
let b2 = 1.0 - alpha * a;
let a0 = 1.0 + alpha / a;
let a1 = -2.0 * cos_omega;
let a2 = 1.0 - alpha / a;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Lowshelf => {
let a = pow10(gain / 40.0);
let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha;
let am1_cos = (a - 1.0) * cos_omega;
let ap1_cos = (a + 1.0) * cos_omega;
let b0 = a * ((a + 1.0) - am1_cos + sqrt2_a_alpha);
let b1 = 2.0 * a * ((a - 1.0) - ap1_cos);
let b2 = a * ((a + 1.0) - am1_cos - sqrt2_a_alpha);
let a0 = (a + 1.0) + am1_cos + sqrt2_a_alpha;
let a1 = -2.0 * ((a - 1.0) + ap1_cos);
let a2 = (a + 1.0) + am1_cos - sqrt2_a_alpha;
(b0, b1, b2, a0, a1, a2)
}
FilterType::Highshelf => {
let a = pow10(gain / 40.0);
let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha;
let am1_cos = (a - 1.0) * cos_omega;
let ap1_cos = (a + 1.0) * cos_omega;
let b0 = a * ((a + 1.0) + am1_cos + sqrt2_a_alpha);
let b1 = -2.0 * a * ((a - 1.0) + ap1_cos);
let b2 = a * ((a + 1.0) + am1_cos - sqrt2_a_alpha);
let a0 = (a + 1.0) - am1_cos + sqrt2_a_alpha;
let a1 = 2.0 * ((a - 1.0) - ap1_cos);
let a2 = (a + 1.0) - am1_cos - sqrt2_a_alpha;
(b0, b1, b2, a0, a1, a2)
}
};
self.b0 = b0 / a0;
self.b1 = b1 / a0;
self.b2 = b2 / a0;
self.a1 = a1 / a0;
self.a2 = a2 / a0;
self.cached_freq = freq;
self.cached_q = q;
self.cached_gain = gain;
self.cached_filter_type = filter_type;
}
let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = input;
self.y2 = self.y1;
self.y1 = output;
output
}
}
/// Multi-stage filter state for cascaded biquad processing.
///
/// Contains up to 4 biquad stages for steeper filter slopes (up to 48 dB/octave).
#[derive(Clone, Copy, Default)]
pub struct FilterState {
/// Current cutoff frequency in Hz.
pub cutoff: f32,
/// Cascaded biquad filter stages.
pub biquads: [Biquad; 4],
}

676
src/lib.rs Normal file
View File

@@ -0,0 +1,676 @@
#[cfg(feature = "native")]
pub mod audio;
#[cfg(feature = "native")]
pub mod config;
pub mod effects;
pub mod envelope;
#[cfg(feature = "native")]
pub mod error;
pub mod event;
pub mod fastmath;
pub mod filter;
#[cfg(feature = "native")]
pub mod loader;
pub mod noise;
pub mod orbit;
#[cfg(feature = "native")]
pub mod osc;
pub mod oscillator;
pub mod plaits;
pub mod sample;
pub mod schedule;
#[cfg(feature = "native")]
pub mod telemetry;
pub mod types;
pub mod voice;
#[cfg(target_arch = "wasm32")]
mod wasm;
use envelope::init_envelope;
use event::Event;
use orbit::{EffectParams, Orbit};
use sample::{FileSource, SampleEntry, SampleInfo, SamplePool, WebSampleSource};
use schedule::Schedule;
#[cfg(feature = "native")]
use telemetry::EngineMetrics;
use types::{DelayType, Source, BLOCK_SIZE, CHANNELS, MAX_ORBITS, MAX_VOICES};
use voice::{Voice, VoiceParams};
pub struct Engine {
pub sr: f32,
pub isr: f32,
pub voices: Vec<Voice>,
pub active_voices: usize,
pub orbits: Vec<Orbit>,
pub schedule: Schedule,
pub time: f64,
pub tick: u64,
pub output_channels: usize,
pub output: Vec<f32>,
// Sample storage
pub sample_pool: SamplePool,
pub samples: Vec<SampleInfo>,
pub sample_index: Vec<SampleEntry>,
// Default orbit params (used when voice doesn't specify)
pub effect_params: EffectParams,
// Telemetry (native only)
#[cfg(feature = "native")]
pub metrics: EngineMetrics,
}
impl Engine {
pub fn new(sample_rate: f32) -> Self {
Self::new_with_channels(sample_rate, CHANNELS)
}
pub fn new_with_channels(sample_rate: f32, output_channels: usize) -> Self {
let mut orbits = Vec::with_capacity(MAX_ORBITS);
for _ in 0..MAX_ORBITS {
orbits.push(Orbit::new(sample_rate));
}
Self {
sr: sample_rate,
isr: 1.0 / sample_rate,
voices: vec![Voice::default(); MAX_VOICES],
active_voices: 0,
orbits,
schedule: Schedule::new(),
time: 0.0,
tick: 0,
output_channels,
output: vec![0.0; BLOCK_SIZE * output_channels],
sample_pool: SamplePool::new(),
samples: Vec::with_capacity(256),
sample_index: Vec::new(),
effect_params: EffectParams {
delay_time: 0.333,
delay_feedback: 0.6,
delay_type: DelayType::Standard,
verb_decay: 0.75,
verb_damp: 0.95,
verb_predelay: 0.1,
verb_diff: 0.7,
comb_freq: 220.0,
comb_feedback: 0.9,
comb_damp: 0.1,
},
#[cfg(feature = "native")]
metrics: EngineMetrics::default(),
}
}
pub fn load_sample(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option<usize> {
let info = self.sample_pool.add(samples, channels, freq)?;
let idx = self.samples.len();
self.samples.push(info);
Some(idx)
}
/// Look up sample by name (e.g., "wave_tek") and n (e.g., 0 for "wave_tek/0")
/// n wraps around using modulo if it exceeds the folder count
fn find_sample_index(&self, name: &str, n: usize) -> Option<usize> {
let prefix = format!("{name}/");
let count = self
.sample_index
.iter()
.filter(|e| e.name.starts_with(&prefix))
.count();
if count == 0 {
return None;
}
let wrapped_n = n % count;
let target = format!("{name}/{wrapped_n}");
self.sample_index.iter().position(|e| e.name == target)
}
/// Get a loaded sample index, loading lazily if needed (native only)
fn get_or_load_sample(&mut self, name: &str, n: usize) -> Option<usize> {
// First check if this is a direct index into already-loaded samples (WASM path)
// For WASM, treat `name` as numeric index if sample_index is empty
if self.sample_index.is_empty() {
let idx: usize = name.parse().ok()?;
if idx < self.samples.len() {
return Some(idx);
}
return None;
}
// Find the sample in the index by name
let index_idx = self.find_sample_index(name, n)?;
// If already loaded, return the loaded index
if let Some(loaded_idx) = self.sample_index[index_idx].loaded {
return Some(loaded_idx);
}
// Load the sample now (native only)
#[cfg(feature = "native")]
{
let path = self.sample_index[index_idx].path.clone();
match loader::load_sample_file(self, &path) {
Ok(loaded_idx) => {
self.sample_index[index_idx].loaded = Some(loaded_idx);
Some(loaded_idx)
}
Err(e) => {
eprintln!(
"Failed to load sample {}: {e}",
self.sample_index[index_idx].name
);
None
}
}
}
#[cfg(not(feature = "native"))]
{
None
}
}
pub fn evaluate(&mut self, input: &str) -> Option<usize> {
let event = Event::parse(input);
// Default to "play" if no explicit command - matches dough's JS wrapper behavior
let cmd = event.cmd.as_deref().unwrap_or("play");
match cmd {
"play" => self.play_event(event),
"hush" => {
self.hush();
None
}
"panic" => {
self.panic();
None
}
"reset" => {
self.panic();
self.schedule.clear();
self.time = 0.0;
self.tick = 0;
None
}
"release" => {
if let Some(v) = event.voice {
if v < self.active_voices {
self.voices[v].params.gate = 0.0;
}
}
None
}
"hush_endless" => {
for i in 0..self.active_voices {
if self.voices[i].params.duration.is_none() {
self.voices[i].params.gate = 0.0;
}
}
None
}
"reset_time" => {
self.time = 0.0;
self.tick = 0;
None
}
"reset_schedule" => {
self.schedule.clear();
None
}
_ => None,
}
}
fn play_event(&mut self, event: Event) -> Option<usize> {
if event.time.is_some() {
// ALL events with time go to schedule (like dough.c)
// This ensures repeat works correctly for time=0 events
self.schedule.push(event);
return None;
}
self.process_event(&event)
}
pub fn play(&mut self, params: VoiceParams) -> Option<usize> {
if self.active_voices >= MAX_VOICES {
return None;
}
let i = self.active_voices;
self.voices[i] = Voice::default();
self.voices[i].params = params;
self.voices[i].sr = self.sr;
self.active_voices += 1;
Some(i)
}
/// Process an event, handling voice selection like dough.c's process_engine_event()
fn process_event(&mut self, event: &Event) -> Option<usize> {
// Cut group: release any voices in the same cut group
if let Some(cut) = event.cut {
for i in 0..self.active_voices {
if self.voices[i].params.cut == Some(cut) {
self.voices[i].params.gate = 0.0;
}
}
}
let (voice_idx, is_new_voice) = if let Some(v) = event.voice {
if v < self.active_voices {
// Voice exists - reuse it
(v, false)
} else {
// Voice index out of range - allocate new
if self.active_voices >= MAX_VOICES {
return None;
}
let i = self.active_voices;
self.active_voices += 1;
(i, true)
}
} else {
// No voice specified - allocate new
if self.active_voices >= MAX_VOICES {
return None;
}
let i = self.active_voices;
self.active_voices += 1;
(i, true)
};
let should_reset = is_new_voice || event.reset.unwrap_or(false);
if should_reset {
self.voices[voice_idx] = Voice::default();
self.voices[voice_idx].sr = self.sr;
// Initialize glide_lag to target freq to prevent glide from 0
if let Some(freq) = event.freq {
self.voices[voice_idx].glide_lag.s = freq;
}
}
// Update voice params (only the ones explicitly set in event)
self.update_voice_params(voice_idx, event);
Some(voice_idx)
}
/// Update voice params - only updates fields that are explicitly set in the event
fn update_voice_params(&mut self, idx: usize, event: &Event) {
macro_rules! copy_opt {
($src:expr, $dst:expr, $($field:ident),+ $(,)?) => {
$(if let Some(val) = $src.$field { $dst.$field = val; })+
};
}
macro_rules! copy_opt_some {
($src:expr, $dst:expr, $($field:ident),+ $(,)?) => {
$(if let Some(val) = $src.$field { $dst.$field = Some(val); })+
};
}
// Resolve sound/sample first (before borrowing voice)
// If sound parses as a Source, use it; otherwise treat as sample folder name
let (parsed_source, loaded_sample) = if let Some(ref sound_str) = event.sound {
if let Ok(source) = sound_str.parse::<Source>() {
(Some(source), None)
} else {
// Treat as sample folder name
let sample = self.get_or_load_sample(sound_str, event.n.unwrap_or(0));
(None, sample)
}
} else {
(None, None)
};
let v = &mut self.voices[idx];
// --- Pitch ---
copy_opt!(event, v.params, freq, detune, speed);
copy_opt_some!(event, v.params, glide);
// --- Source ---
if let Some(source) = parsed_source {
v.params.sound = source;
}
copy_opt!(event, v.params, pw, spread);
if let Some(size) = event.size {
v.params.shape.size = size.min(256);
}
if let Some(mult) = event.mult {
v.params.shape.mult = mult.clamp(0.25, 16.0);
}
if let Some(warp) = event.warp {
v.params.shape.warp = warp.clamp(-1.0, 1.0);
}
if let Some(mirror) = event.mirror {
v.params.shape.mirror = mirror.clamp(0.0, 1.0);
}
if let Some(harmonics) = event.harmonics {
v.params.harmonics = harmonics.clamp(0.01, 0.999);
}
if let Some(timbre) = event.timbre {
v.params.timbre = timbre.clamp(0.01, 0.999);
}
if let Some(morph) = event.morph {
v.params.morph = morph.clamp(0.01, 0.999);
}
copy_opt_some!(event, v.params, cut);
// Sample playback (native)
if let Some(sample_idx) = loaded_sample {
v.params.sound = Source::Sample;
let begin = event.begin.unwrap_or(0.0);
let end = event.end.unwrap_or(1.0);
v.file_source = Some(FileSource::new(sample_idx, begin, end));
} else if event.begin.is_some() || event.end.is_some() {
// Update begin/end on existing file_source
if let Some(ref mut fs) = v.file_source {
if let Some(begin) = event.begin {
fs.begin = begin.clamp(0.0, 1.0);
}
if let Some(end) = event.end {
fs.end = end.clamp(fs.begin, 1.0);
}
}
}
// Web sample playback (WASM - set by JavaScript)
if let (Some(offset), Some(frames)) = (event.file_pcm, event.file_frames) {
v.params.sound = Source::WebSample;
v.web_sample = Some(WebSampleSource::new(
SampleInfo {
offset,
frames: frames as u32,
channels: event.file_channels.unwrap_or(1),
freq: event.file_freq.unwrap_or(65.406),
},
event.begin.unwrap_or(0.0),
event.end.unwrap_or(1.0),
));
}
// --- Gain ---
copy_opt!(event, v.params, gain, postgain, velocity, pan, gate);
copy_opt_some!(event, v.params, duration);
// --- Gain Envelope ---
let gain_env = init_envelope(
None,
event.attack,
event.decay,
event.sustain,
event.release,
);
if gain_env.active {
v.params.attack = gain_env.att;
v.params.decay = gain_env.dec;
v.params.sustain = gain_env.sus;
v.params.release = gain_env.rel;
}
// --- Filters ---
// Macro to apply envelope params (env amount + ADSR) to a target
macro_rules! apply_env {
($src:expr, $dst:expr, $e:ident, $a:ident, $d:ident, $s:ident, $r:ident, $active:ident) => {
let env = init_envelope($src.$e, $src.$a, $src.$d, $src.$s, $src.$r);
if env.active {
$dst.$e = env.env;
$dst.$a = env.att;
$dst.$d = env.dec;
$dst.$s = env.sus;
$dst.$r = env.rel;
$dst.$active = true;
}
};
}
copy_opt_some!(event, v.params, lpf);
copy_opt!(event, v.params, lpq);
apply_env!(event, v.params, lpe, lpa, lpd, lps, lpr, lp_env_active);
copy_opt_some!(event, v.params, hpf);
copy_opt!(event, v.params, hpq);
apply_env!(event, v.params, hpe, hpa, hpd, hps, hpr, hp_env_active);
copy_opt_some!(event, v.params, bpf);
copy_opt!(event, v.params, bpq);
apply_env!(event, v.params, bpe, bpa, bpd, bps, bpr, bp_env_active);
copy_opt!(event, v.params, ftype);
// --- Modulation ---
apply_env!(
event,
v.params,
penv,
patt,
pdec,
psus,
prel,
pitch_env_active
);
copy_opt!(event, v.params, vib, vibmod, vibshape);
copy_opt!(event, v.params, fm, fmh, fmshape);
apply_env!(event, v.params, fme, fma, fmd, fms, fmr, fm_env_active);
copy_opt!(event, v.params, am, amdepth, amshape);
copy_opt!(event, v.params, rm, rmdepth, rmshape);
// --- Effects ---
copy_opt!(
event,
v.params,
phaser,
phaserdepth,
phasersweep,
phasercenter
);
copy_opt!(event, v.params, flanger, flangerdepth, flangerfeedback);
copy_opt!(event, v.params, chorus, chorusdepth, chorusdelay);
copy_opt!(event, v.params, comb, combfreq, combfeedback, combdamp);
copy_opt_some!(event, v.params, coarse, crush, fold, wrap, distort);
copy_opt!(event, v.params, distortvol);
// --- Sends ---
copy_opt!(
event,
v.params,
orbit,
delay,
delaytime,
delayfeedback,
delaytype
);
copy_opt!(
event,
v.params,
verb,
verbdecay,
verbdamp,
verbpredelay,
verbdiff
);
}
fn free_voice(&mut self, i: usize) {
if self.active_voices > 0 {
self.active_voices -= 1;
self.voices.swap(i, self.active_voices);
}
}
fn process_schedule(&mut self) {
loop {
// O(1) early-exit: check only the first (earliest) event
let t = match self.schedule.peek_time() {
Some(t) if t <= self.time => t,
_ => return,
};
let diff = self.time - t;
let mut event = self.schedule.pop_front().unwrap();
// Fire only if event is fresh (within 1ms) - matches dough.c WASM
// Old events are silently rescheduled to catch up
if diff < 0.001 {
self.process_event(&event);
}
// Reschedule repeating events (re-insert in sorted order)
if let Some(rep) = event.repeat {
event.time = Some(t + rep as f64);
self.schedule.push(event);
}
// Loop continues for catch-up behavior
}
}
pub fn gen_sample(
&mut self,
output: &mut [f32],
sample_idx: usize,
web_pcm: &[f32],
live_input: &[f32],
) {
let base_idx = sample_idx * self.output_channels;
let num_pairs = self.output_channels / 2;
for c in 0..self.output_channels {
output[base_idx + c] = 0.0;
}
// Clear orbit sends
for orbit in &mut self.orbits {
orbit.clear_sends();
}
// Process voices - matches dough.c behavior exactly:
// When a voice dies, it's freed immediately and the loop continues,
// which means the swapped-in voice (from the end) gets skipped this frame.
let isr = self.isr;
let num_orbits = self.orbits.len();
let mut i = 0;
while i < self.active_voices {
// Reborrow for each iteration to allow free_voice during loop
let pool = self.sample_pool.data.as_slice();
let samples = self.samples.as_slice();
let alive = self.voices[i].process(isr, pool, samples, web_pcm, sample_idx, live_input);
if !alive {
self.free_voice(i);
// Match dough.c: increment i, skipping the swapped-in voice
i += 1;
continue;
}
let orbit_idx = self.voices[i].params.orbit % num_orbits;
let out_pair = orbit_idx % num_pairs;
let pair_offset = out_pair * 2;
output[base_idx + pair_offset] += self.voices[i].ch[0];
output[base_idx + pair_offset + 1] += self.voices[i].ch[1];
// Add to orbit sends
if self.voices[i].params.delay > 0.0 {
for c in 0..CHANNELS {
self.orbits[orbit_idx]
.add_delay_send(c, self.voices[i].ch[c] * self.voices[i].params.delay);
}
// Update orbit delay params from voice
self.effect_params.delay_time = self.voices[i].params.delaytime;
self.effect_params.delay_feedback = self.voices[i].params.delayfeedback;
self.effect_params.delay_type = self.voices[i].params.delaytype;
}
if self.voices[i].params.verb > 0.0 {
for c in 0..CHANNELS {
self.orbits[orbit_idx]
.add_verb_send(c, self.voices[i].ch[c] * self.voices[i].params.verb);
}
// Update orbit verb params from voice
self.effect_params.verb_decay = self.voices[i].params.verbdecay;
self.effect_params.verb_damp = self.voices[i].params.verbdamp;
self.effect_params.verb_predelay = self.voices[i].params.verbpredelay;
self.effect_params.verb_diff = self.voices[i].params.verbdiff;
}
if self.voices[i].params.comb > 0.0 {
for c in 0..CHANNELS {
self.orbits[orbit_idx]
.add_comb_send(c, self.voices[i].ch[c] * self.voices[i].params.comb);
}
// Update orbit comb params from voice
self.effect_params.comb_freq = self.voices[i].params.combfreq;
self.effect_params.comb_feedback = self.voices[i].params.combfeedback;
self.effect_params.comb_damp = self.voices[i].params.combdamp;
}
i += 1;
}
for (orbit_idx, orbit) in self.orbits.iter_mut().enumerate() {
orbit.process(&self.effect_params);
let out_pair = orbit_idx % num_pairs;
let pair_offset = out_pair * 2;
output[base_idx + pair_offset] +=
orbit.delay_out[0] + orbit.verb_out[0] + orbit.comb_out[0];
output[base_idx + pair_offset + 1] +=
orbit.delay_out[1] + orbit.verb_out[1] + orbit.comb_out[1];
}
for c in 0..self.output_channels {
output[base_idx + c] = (output[base_idx + c] * 0.5).clamp(-1.0, 1.0);
}
}
pub fn process_block(&mut self, output: &mut [f32], web_pcm: &[f32], live_input: &[f32]) {
#[cfg(feature = "native")]
let start = std::time::Instant::now();
let samples = output.len() / self.output_channels;
for i in 0..samples {
self.process_schedule();
self.tick += 1;
self.time = self.tick as f64 / self.sr as f64;
self.gen_sample(output, i, web_pcm, live_input);
}
#[cfg(feature = "native")]
{
use std::sync::atomic::Ordering;
let elapsed_ns = start.elapsed().as_nanos() as u64;
self.metrics.load.record_sample(elapsed_ns);
self.metrics
.active_voices
.store(self.active_voices as u32, Ordering::Relaxed);
self.metrics
.peak_voices
.fetch_max(self.active_voices as u32, Ordering::Relaxed);
self.metrics
.schedule_depth
.store(self.schedule.len() as u32, Ordering::Relaxed);
}
}
pub fn dsp(&mut self) {
let mut output = std::mem::take(&mut self.output);
self.process_block(&mut output, &[], &[]);
self.output = output;
}
pub fn dsp_with_web_pcm(&mut self, web_pcm: &[f32], live_input: &[f32]) {
let mut output = std::mem::take(&mut self.output);
self.process_block(&mut output, web_pcm, live_input);
self.output = output;
}
pub fn get_time(&self) -> f64 {
self.time
}
pub fn hush(&mut self) {
for i in 0..self.active_voices {
self.voices[i].params.gate = 0.0;
}
}
pub fn panic(&mut self) {
self.active_voices = 0;
}
}

266
src/loader.rs Normal file
View File

@@ -0,0 +1,266 @@
//! Audio sample loading and directory scanning.
//!
//! Handles discovery and decoding of audio files into the engine's sample pool.
//! Supports common audio formats via Symphonia: WAV, MP3, OGG, FLAC, AAC, M4A.
//!
//! # Directory Structure
//!
//! The scanner expects samples organized as:
//!
//! ```text
//! samples/
//! ├── kick.wav → named "kick"
//! ├── snare.wav → named "snare"
//! └── hats/ → folder creates numbered entries
//! ├── closed.wav → named "hats/0"
//! ├── open.wav → named "hats/1"
//! └── pedal.wav → named "hats/2"
//! ```
//!
//! Files within folders are sorted alphabetically and assigned sequential indices.
//!
//! # Lazy Loading
//!
//! [`scan_samples_dir`] only builds the index without decoding audio data.
//! Actual decoding happens on first use via [`load_sample_file`], keeping
//! startup fast even with large sample libraries.
use std::fs::File;
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use crate::sample::SampleEntry;
use crate::Engine;
/// Default base frequency assigned to loaded samples (C2 = 65.406 Hz).
///
/// Samples are assumed to be pitched at this frequency unless overridden.
/// Used for pitch-shifting calculations during playback.
const DEFAULT_BASE_FREQ: f32 = 65.406;
/// Supported audio file extensions.
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "ogg", "flac", "aac", "m4a"];
/// Checks if a file path has a supported audio extension.
fn is_audio_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
AUDIO_EXTENSIONS
.iter()
.any(|ext| e.eq_ignore_ascii_case(ext))
})
}
/// Scans a directory for audio samples without loading audio data.
///
/// Builds an index of [`SampleEntry`] with paths and names. Audio data
/// remains unloaded (`loaded: None`) until explicitly requested.
///
/// Top-level audio files are named by their stem (filename without extension).
/// Subdirectories create grouped entries named `folder/index` where index
/// is the alphabetical position within that folder.
///
/// Prints a summary of discovered samples and folders to stdout.
pub fn scan_samples_dir(dir: &Path) -> Vec<SampleEntry> {
let mut entries = Vec::new();
let mut folder_count = 0;
let items = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("Failed to read directory {}: {e}", dir.display());
return entries;
}
};
let mut paths: Vec<_> = items.filter_map(|e| e.ok()).map(|e| e.path()).collect();
paths.sort();
for item in paths {
if item.is_dir() {
let folder_name = item
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let sub_entries = match std::fs::read_dir(&item) {
Ok(e) => e,
Err(_) => continue,
};
let mut files: Vec<_> = sub_entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| is_audio_file(p))
.collect();
files.sort();
if !files.is_empty() {
folder_count += 1;
}
for (i, path) in files.into_iter().enumerate() {
let name = format!("{folder_name}/{i}");
entries.push(SampleEntry {
path,
name,
loaded: None,
});
}
} else if is_audio_file(&item) {
let name = item
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
entries.push(SampleEntry {
path: item,
name,
loaded: None,
});
}
}
if !entries.is_empty() {
println!(
" Found {} samples in {} folders",
entries.len(),
folder_count
);
}
entries
}
/// Decodes an audio file and loads it into the engine's sample pool.
///
/// Handles format detection, decoding, and sample rate conversion automatically.
/// The decoded audio is resampled to match the engine's sample rate if necessary.
///
/// # Returns
///
/// The sample pool index on success, or an error description on failure.
///
/// # Errors
///
/// Returns `Err` if:
/// - File cannot be opened or read
/// - Format is unsupported or corrupted
/// - No audio track is found
/// - Decoding fails completely (partial decode errors are skipped)
/// - Sample pool is full
pub fn load_sample_file(engine: &mut Engine, path: &Path) -> Result<usize, String> {
let file = File::open(path).map_err(|e| format!("Failed to open file: {e}"))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| format!("Failed to probe format: {e}"))?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
.ok_or("No audio track found")?;
let codec_params = &track.codec_params;
let channels = codec_params.channels.map(|c| c.count()).unwrap_or(1) as u8;
let sample_rate = codec_params.sample_rate.unwrap_or(44100) as f32;
let mut decoder = symphonia::default::get_codecs()
.make(codec_params, &DecoderOptions::default())
.map_err(|e| format!("Failed to create decoder: {e}"))?;
let track_id = track.id;
let mut samples: Vec<f32> = Vec::new();
let mut sample_buf: Option<SampleBuffer<f32>> = None;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(e) => return Err(format!("Failed to read packet: {e}")),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(e) => return Err(format!("Decode error: {e}")),
};
let spec = *decoded.spec();
let duration = decoded.capacity() as u64;
let buf = sample_buf.get_or_insert_with(|| SampleBuffer::<f32>::new(duration, spec));
buf.copy_interleaved_ref(decoded);
samples.extend_from_slice(buf.samples());
}
if samples.is_empty() {
return Err("No samples decoded".to_string());
}
let target_sr = engine.sr;
let resampled = if (sample_rate - target_sr).abs() > 1.0 {
resample_linear(&samples, channels as usize, sample_rate, target_sr)
} else {
samples
};
engine
.load_sample(&resampled, channels, DEFAULT_BASE_FREQ)
.ok_or_else(|| "Sample pool full".to_string())
}
/// Resamples interleaved audio using linear interpolation.
///
/// Simple but fast resampling suitable for non-critical applications.
/// For higher quality, consider using a dedicated resampling library like rubato.
fn resample_linear(samples: &[f32], channels: usize, from_sr: f32, to_sr: f32) -> Vec<f32> {
let ratio = to_sr / from_sr;
let in_frames = samples.len() / channels;
let out_frames = (in_frames as f32 * ratio) as usize;
let mut output = vec![0.0; out_frames * channels];
for out_frame in 0..out_frames {
let in_pos = out_frame as f32 / ratio;
let in_frame = in_pos as usize;
let next_frame = (in_frame + 1).min(in_frames - 1);
let frac = in_pos - in_frame as f32;
for ch in 0..channels {
let s0 = samples[in_frame * channels + ch];
let s1 = samples[next_frame * channels + ch];
output[out_frame * channels + ch] = s0 + frac * (s1 - s0);
}
}
output
}

190
src/main.rs Normal file
View File

@@ -0,0 +1,190 @@
//! Doux audio synthesis engine CLI.
//!
//! Provides real-time audio synthesis with OSC control. Supports sample
//! playback, multiple output channels, and live audio input processing.
use clap::Parser;
use cpal::traits::{DeviceTrait, StreamTrait};
use doux::audio::{
default_input_device, default_output_device, find_input_device, find_output_device,
list_input_devices, list_output_devices, max_output_channels,
};
use doux::Engine;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Command-line arguments for the doux audio engine.
#[derive(Parser)]
#[command(name = "doux")]
#[command(about = "Audio synthesis engine with OSC control", long_about = None)]
struct Args {
/// Directory containing audio samples to load.
#[arg(short, long)]
samples: Option<PathBuf>,
/// OSC port to listen on.
#[arg(short, long, default_value = "57120")]
port: u16,
/// List available audio devices and exit.
#[arg(long)]
list_devices: bool,
/// Input device (name or index).
#[arg(short, long)]
input: Option<String>,
/// Output device (name or index).
#[arg(short, long)]
output: Option<String>,
/// Number of output channels (default: 2, max depends on device).
#[arg(long, default_value = "2")]
channels: u16,
/// Audio buffer size in samples (lower = less latency, higher = more stable).
/// Common values: 64, 128, 256, 512, 1024. Default: system choice.
#[arg(short, long)]
buffer_size: Option<u32>,
}
fn print_devices() {
println!("Input devices:");
for info in list_input_devices() {
let marker = if info.is_default { " *" } else { "" };
println!(" {}: {}{}", info.index, info.name, marker);
}
println!("\nOutput devices:");
for info in list_output_devices() {
let marker = if info.is_default { " *" } else { "" };
println!(" {}: {}{}", info.index, info.name, marker);
}
}
fn main() {
let args = Args::parse();
if args.list_devices {
print_devices();
return;
}
// Resolve output device
let device = match &args.output {
Some(spec) => find_output_device(spec)
.unwrap_or_else(|| panic!("output device '{spec}' not found")),
None => default_output_device().expect("no output device"),
};
// Clamp channels to device maximum
let max_channels = max_output_channels(&device);
let output_channels = (args.channels as usize).min(max_channels as usize);
if args.channels as usize > output_channels {
eprintln!(
"Warning: device supports max {} channels, using that instead of {}",
max_channels, args.channels
);
}
let default_config = device.default_output_config().unwrap();
let sample_rate = default_config.sample_rate().0 as f32;
let config = cpal::StreamConfig {
channels: output_channels as u16,
sample_rate: default_config.sample_rate(),
buffer_size: args
.buffer_size
.map(cpal::BufferSize::Fixed)
.unwrap_or(cpal::BufferSize::Default),
};
println!("Output: {}", device.name().unwrap_or_default());
println!("Sample rate: {sample_rate}");
println!("Channels: {output_channels}");
if let Some(buf) = args.buffer_size {
let latency_ms = buf as f32 / sample_rate * 1000.0;
println!("Buffer: {buf} samples ({latency_ms:.1} ms)");
}
// Initialize engine with sample index if provided
let mut engine = Engine::new_with_channels(sample_rate, output_channels);
if let Some(ref dir) = args.samples {
println!("\nScanning samples from: {}", dir.display());
let index = doux::loader::scan_samples_dir(dir);
println!("Found {} samples (lazy loading enabled)\n", index.len());
engine.sample_index = index;
}
let engine = Arc::new(Mutex::new(engine));
// Ring buffer for live audio input
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(8192)));
// Set up input stream if device available
let input_device = match &args.input {
Some(spec) => find_input_device(spec),
None => default_input_device(),
};
let _input_stream = input_device.and_then(|input_device| {
let input_config = input_device.default_input_config().ok()?;
println!("Input: {}", input_device.name().unwrap_or_default());
let buf = Arc::clone(&input_buffer);
let stream = input_device
.build_input_stream(
&input_config.into(),
move |data: &[f32], _| {
let mut b = buf.lock().unwrap();
for &sample in data {
b.push_back(sample);
if b.len() > 8192 {
b.pop_front();
}
}
},
|err| eprintln!("input stream error: {err}"),
None,
)
.ok()?;
stream.play().ok()?;
Some(stream)
});
// Build output stream with audio callback
let engine_clone = Arc::clone(&engine);
let input_buf_clone = Arc::clone(&input_buffer);
let live_scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(vec![0.0; 1024]));
let live_scratch_clone = Arc::clone(&live_scratch);
let stream = device
.build_output_stream(
&config,
move |data: &mut [f32], _| {
let mut buf = input_buf_clone.lock().unwrap();
let mut scratch = live_scratch_clone.lock().unwrap();
if scratch.len() < data.len() {
scratch.resize(data.len(), 0.0);
}
for sample in scratch[..data.len()].iter_mut() {
*sample = buf.pop_front().unwrap_or(0.0);
}
drop(buf);
engine_clone
.lock()
.unwrap()
.process_block(data, &[], &scratch[..data.len()]);
},
|err| eprintln!("stream error: {err}"),
None,
)
.unwrap();
stream.play().unwrap();
println!("Listening for OSC on port {}", args.port);
println!("Press Ctrl+C to stop");
// Block on OSC server (runs until interrupted)
doux::osc::run(engine, args.port);
}

87
src/noise.rs Normal file
View File

@@ -0,0 +1,87 @@
//! Colored noise generators.
//!
//! Transforms white noise into spectrally-shaped noise with different frequency
//! characteristics. Both generators are stateful filters that process white noise
//! sample-by-sample.
//!
//! # Noise Colors
//!
//! | Color | Slope | Character |
//! |-------|------------|----------------------------------|
//! | White | 0 dB/oct | Equal energy per frequency |
//! | Pink | -3 dB/oct | Equal energy per octave |
//! | Brown | -6 dB/oct | Rumbling, emphasizes low freqs |
/// Pink noise generator using the Voss-McCartney algorithm.
///
/// Applies a parallel bank of first-order lowpass filters to shape white noise
/// into pink noise with -3 dB/octave rolloff. The coefficients approximate an
/// ideal pink spectrum across the audio range.
///
/// Also known as 1/f noise, pink noise has equal energy per octave, making it
/// useful for audio testing and as a natural-sounding noise source.
#[derive(Clone, Copy)]
pub struct PinkNoise {
b: [f32; 7],
}
impl Default for PinkNoise {
fn default() -> Self {
Self { b: [0.0; 7] }
}
}
impl PinkNoise {
/// Processes one white noise sample and returns the corresponding pink noise sample.
///
/// The input should be uniformly distributed white noise in the range `[-1, 1]`.
/// Output is scaled to approximately the same amplitude range.
pub fn next(&mut self, white: f32) -> f32 {
self.b[0] = 0.99886 * self.b[0] + white * 0.0555179;
self.b[1] = 0.99332 * self.b[1] + white * 0.0750759;
self.b[2] = 0.96900 * self.b[2] + white * 0.153852;
self.b[3] = 0.86650 * self.b[3] + white * 0.3104856;
self.b[4] = 0.55000 * self.b[4] + white * 0.5329522;
self.b[5] = -0.7616 * self.b[5] - white * 0.0168980;
let pink = self.b[0]
+ self.b[1]
+ self.b[2]
+ self.b[3]
+ self.b[4]
+ self.b[5]
+ self.b[6]
+ white * 0.5362;
self.b[6] = white * 0.115926;
pink * 0.11
}
}
/// Brown noise generator using leaky integration.
///
/// Applies a simple first-order lowpass filter (leaky integrator) to produce
/// noise with -6 dB/octave rolloff. Named after Robert Brown (Brownian motion),
/// not the color.
///
/// Also known as red noise or random walk noise. Has a deep, rumbling character
/// with strong low-frequency content.
#[derive(Clone, Copy)]
pub struct BrownNoise {
out: f32,
}
impl Default for BrownNoise {
fn default() -> Self {
Self { out: 0.0 }
}
}
impl BrownNoise {
/// Processes one white noise sample and returns the corresponding brown noise sample.
///
/// The input should be uniformly distributed white noise in the range `[-1, 1]`.
/// Output amplitude depends on the integration coefficient.
pub fn next(&mut self, white: f32) -> f32 {
self.out = (self.out + 0.02 * white) / 1.02;
self.out
}
}

504
src/orbit.rs Normal file
View File

@@ -0,0 +1,504 @@
use crate::effects::Comb;
use crate::fastmath::ftz;
use crate::types::{DelayType, CHANNELS};
const MAX_DELAY_SAMPLES: usize = 48000;
const SILENCE_THRESHOLD: f32 = 1e-7;
const SILENCE_HOLDOFF: u32 = 48000;
#[derive(Clone, Copy, Default)]
pub struct EffectParams {
pub delay_time: f32,
pub delay_feedback: f32,
pub delay_type: DelayType,
pub verb_decay: f32,
pub verb_damp: f32,
pub verb_predelay: f32,
pub verb_diff: f32,
pub comb_freq: f32,
pub comb_feedback: f32,
pub comb_damp: f32,
}
#[derive(Clone)]
pub struct DelayLine {
buffer: Vec<f32>,
write_pos: usize,
}
impl DelayLine {
pub fn new(max_samples: usize) -> Self {
Self {
buffer: vec![0.0; max_samples],
write_pos: 0,
}
}
pub fn process(&mut self, input: f32, delay_samples: usize) -> f32 {
let delay_samples = delay_samples.min(self.buffer.len() - 1);
self.buffer[self.write_pos] = input;
let read_pos = if self.write_pos >= delay_samples {
self.write_pos - delay_samples
} else {
self.buffer.len() - (delay_samples - self.write_pos)
};
self.write_pos = (self.write_pos + 1) % self.buffer.len();
self.buffer[read_pos]
}
pub fn read_at(&self, delay_samples: usize) -> f32 {
let delay_samples = delay_samples.min(self.buffer.len() - 1);
let read_pos = if self.write_pos >= delay_samples {
self.write_pos - delay_samples
} else {
self.buffer.len() - (delay_samples - self.write_pos)
};
self.buffer[read_pos]
}
pub fn write(&mut self, input: f32) {
self.buffer[self.write_pos] = input;
self.write_pos = (self.write_pos + 1) % self.buffer.len();
}
pub fn clear(&mut self) {
self.buffer.fill(0.0);
}
}
impl Default for DelayLine {
fn default() -> Self {
Self::new(MAX_DELAY_SAMPLES)
}
}
const REVERB_SR_REF: f32 = 29761.0;
fn scale_delay(samples: usize, sr: f32) -> usize {
((samples as f32 * sr / REVERB_SR_REF) as usize).max(1)
}
#[derive(Clone)]
pub struct ReverbBuffer {
buffer: Vec<f32>,
write_pos: usize,
}
impl ReverbBuffer {
pub fn new(size: usize) -> Self {
Self {
buffer: vec![0.0; size],
write_pos: 0,
}
}
pub fn write(&mut self, value: f32) {
self.buffer[self.write_pos] = value;
self.write_pos = (self.write_pos + 1) % self.buffer.len();
}
pub fn read(&self, delay: usize) -> f32 {
let delay = delay.min(self.buffer.len() - 1);
let pos = if self.write_pos >= delay {
self.write_pos - delay
} else {
self.buffer.len() - (delay - self.write_pos)
};
self.buffer[pos]
}
pub fn read_write(&mut self, value: f32, delay: usize) -> f32 {
let out = self.read(delay);
self.write(value);
out
}
pub fn allpass(&mut self, input: f32, delay: usize, coeff: f32) -> f32 {
let delayed = self.read(delay);
let v = input - coeff * delayed;
self.write(v);
delayed + coeff * v
}
pub fn clear(&mut self) {
self.buffer.fill(0.0);
}
}
#[derive(Clone)]
pub struct DattorroVerb {
pre_delay: ReverbBuffer,
in_diff1: ReverbBuffer,
in_diff2: ReverbBuffer,
in_diff3: ReverbBuffer,
in_diff4: ReverbBuffer,
decay_diff1_l: ReverbBuffer,
delay1_l: ReverbBuffer,
decay_diff2_l: ReverbBuffer,
delay2_l: ReverbBuffer,
decay_diff1_r: ReverbBuffer,
delay1_r: ReverbBuffer,
decay_diff2_r: ReverbBuffer,
delay2_r: ReverbBuffer,
damp_l: f32,
damp_r: f32,
pre_delay_len: usize,
in_diff1_len: usize,
in_diff2_len: usize,
in_diff3_len: usize,
in_diff4_len: usize,
decay_diff1_l_len: usize,
delay1_l_len: usize,
decay_diff2_l_len: usize,
delay2_l_len: usize,
decay_diff1_r_len: usize,
delay1_r_len: usize,
decay_diff2_r_len: usize,
delay2_r_len: usize,
tap1_l: usize,
tap2_l: usize,
tap3_l: usize,
tap4_l: usize,
tap5_l: usize,
tap6_l: usize,
tap7_l: usize,
tap1_r: usize,
tap2_r: usize,
tap3_r: usize,
tap4_r: usize,
tap5_r: usize,
tap6_r: usize,
tap7_r: usize,
}
impl DattorroVerb {
pub fn new(sr: f32) -> Self {
let pre_delay_len = scale_delay(4800, sr);
let in_diff1_len = scale_delay(142, sr);
let in_diff2_len = scale_delay(107, sr);
let in_diff3_len = scale_delay(379, sr);
let in_diff4_len = scale_delay(277, sr);
let decay_diff1_l_len = scale_delay(672, sr);
let delay1_l_len = scale_delay(4453, sr);
let decay_diff2_l_len = scale_delay(1800, sr);
let delay2_l_len = scale_delay(3720, sr);
let decay_diff1_r_len = scale_delay(908, sr);
let delay1_r_len = scale_delay(4217, sr);
let decay_diff2_r_len = scale_delay(2656, sr);
let delay2_r_len = scale_delay(3163, sr);
Self {
pre_delay: ReverbBuffer::new(pre_delay_len + 1),
in_diff1: ReverbBuffer::new(in_diff1_len + 1),
in_diff2: ReverbBuffer::new(in_diff2_len + 1),
in_diff3: ReverbBuffer::new(in_diff3_len + 1),
in_diff4: ReverbBuffer::new(in_diff4_len + 1),
decay_diff1_l: ReverbBuffer::new(decay_diff1_l_len + 1),
delay1_l: ReverbBuffer::new(delay1_l_len + 1),
decay_diff2_l: ReverbBuffer::new(decay_diff2_l_len + 1),
delay2_l: ReverbBuffer::new(delay2_l_len + 1),
decay_diff1_r: ReverbBuffer::new(decay_diff1_r_len + 1),
delay1_r: ReverbBuffer::new(delay1_r_len + 1),
decay_diff2_r: ReverbBuffer::new(decay_diff2_r_len + 1),
delay2_r: ReverbBuffer::new(delay2_r_len + 1),
damp_l: 0.0,
damp_r: 0.0,
pre_delay_len,
in_diff1_len,
in_diff2_len,
in_diff3_len,
in_diff4_len,
decay_diff1_l_len,
delay1_l_len,
decay_diff2_l_len,
delay2_l_len,
decay_diff1_r_len,
delay1_r_len,
decay_diff2_r_len,
delay2_r_len,
tap1_l: scale_delay(266, sr),
tap2_l: scale_delay(2974, sr),
tap3_l: scale_delay(1913, sr),
tap4_l: scale_delay(1996, sr),
tap5_l: scale_delay(1990, sr),
tap6_l: scale_delay(187, sr),
tap7_l: scale_delay(1066, sr),
tap1_r: scale_delay(353, sr),
tap2_r: scale_delay(3627, sr),
tap3_r: scale_delay(1228, sr),
tap4_r: scale_delay(2673, sr),
tap5_r: scale_delay(2111, sr),
tap6_r: scale_delay(335, sr),
tap7_r: scale_delay(121, sr),
}
}
pub fn process(
&mut self,
input: f32,
decay: f32,
damping: f32,
predelay: f32,
diffusion: f32,
) -> [f32; 2] {
let decay = decay.clamp(0.0, 0.99);
let damping = damping.clamp(0.0, 1.0);
let diffusion = diffusion.clamp(0.0, 1.0);
let diff1 = 0.75 * diffusion;
let diff2 = 0.625 * diffusion;
let decay_diff1 = 0.7 * diffusion;
let decay_diff2 = 0.5 * diffusion;
let pre_delay_samples =
((predelay * self.pre_delay_len as f32) as usize).min(self.pre_delay_len);
let input = ftz(input, 0.0001);
let pre = self.pre_delay.read_write(input, pre_delay_samples);
let mut x = pre;
x = self.in_diff1.allpass(x, self.in_diff1_len, diff1);
x = self.in_diff2.allpass(x, self.in_diff2_len, diff1);
x = self.in_diff3.allpass(x, self.in_diff3_len, diff2);
x = self.in_diff4.allpass(x, self.in_diff4_len, diff2);
let tank_l_in = x + self.delay2_r.read(self.delay2_r_len) * decay;
let tank_r_in = x + self.delay2_l.read(self.delay2_l_len) * decay;
let mut l = self
.decay_diff1_l
.allpass(tank_l_in, self.decay_diff1_l_len, -decay_diff1);
l = self.delay1_l.read_write(l, self.delay1_l_len);
self.damp_l = ftz(l * (1.0 - damping) + self.damp_l * damping, 0.0001);
l = self.damp_l * decay;
l = self
.decay_diff2_l
.allpass(l, self.decay_diff2_l_len, decay_diff2);
self.delay2_l.write(l);
let mut r = self
.decay_diff1_r
.allpass(tank_r_in, self.decay_diff1_r_len, -decay_diff1);
r = self.delay1_r.read_write(r, self.delay1_r_len);
self.damp_r = ftz(r * (1.0 - damping) + self.damp_r * damping, 0.0001);
r = self.damp_r * decay;
r = self
.decay_diff2_r
.allpass(r, self.decay_diff2_r_len, decay_diff2);
self.delay2_r.write(r);
let out_l = self.delay1_l.read(self.tap1_l) + self.delay1_l.read(self.tap2_l)
- self.decay_diff2_l.read(self.tap3_l)
+ self.delay2_l.read(self.tap4_l)
- self.delay1_r.read(self.tap5_r)
- self.decay_diff2_r.read(self.tap6_r)
- self.delay2_r.read(self.tap7_r);
let out_r = self.delay1_r.read(self.tap1_r) + self.delay1_r.read(self.tap2_r)
- self.decay_diff2_r.read(self.tap3_r)
+ self.delay2_r.read(self.tap4_r)
- self.delay1_l.read(self.tap5_l)
- self.decay_diff2_l.read(self.tap6_l)
- self.delay2_l.read(self.tap7_l);
[out_l * 0.6, out_r * 0.6]
}
pub fn clear(&mut self) {
self.pre_delay.clear();
self.in_diff1.clear();
self.in_diff2.clear();
self.in_diff3.clear();
self.in_diff4.clear();
self.decay_diff1_l.clear();
self.delay1_l.clear();
self.decay_diff2_l.clear();
self.delay2_l.clear();
self.decay_diff1_r.clear();
self.delay1_r.clear();
self.decay_diff2_r.clear();
self.delay2_r.clear();
self.damp_l = 0.0;
self.damp_r = 0.0;
}
}
pub struct Orbit {
pub delay: [DelayLine; CHANNELS],
pub delay_send: [f32; CHANNELS],
pub delay_out: [f32; CHANNELS],
pub delay_feedback: [f32; CHANNELS],
pub delay_lp: [f32; CHANNELS],
pub verb: DattorroVerb,
pub verb_send: [f32; CHANNELS],
pub verb_out: [f32; CHANNELS],
pub comb: Comb,
pub comb_send: [f32; CHANNELS],
pub comb_out: [f32; CHANNELS],
pub sr: f32,
silent_samples: u32,
}
impl Orbit {
pub fn new(sr: f32) -> Self {
Self {
delay: [DelayLine::default(), DelayLine::default()],
delay_send: [0.0; CHANNELS],
delay_out: [0.0; CHANNELS],
delay_feedback: [0.0; CHANNELS],
delay_lp: [0.0; CHANNELS],
verb: DattorroVerb::new(sr),
verb_send: [0.0; CHANNELS],
verb_out: [0.0; CHANNELS],
comb: Comb::default(),
comb_send: [0.0; CHANNELS],
comb_out: [0.0; CHANNELS],
sr,
silent_samples: SILENCE_HOLDOFF + 1,
}
}
pub fn clear_sends(&mut self) {
self.delay_send = [0.0; CHANNELS];
self.verb_send = [0.0; CHANNELS];
self.comb_send = [0.0; CHANNELS];
}
pub fn add_delay_send(&mut self, ch: usize, value: f32) {
self.delay_send[ch] += value;
}
pub fn add_verb_send(&mut self, ch: usize, value: f32) {
self.verb_send[ch] += value;
}
pub fn add_comb_send(&mut self, ch: usize, value: f32) {
self.comb_send[ch] += value;
}
pub fn process(&mut self, p: &EffectParams) {
let has_input = self.delay_send[0] != 0.0
|| self.delay_send[1] != 0.0
|| self.verb_send[0] != 0.0
|| self.verb_send[1] != 0.0
|| self.comb_send[0] != 0.0
|| self.comb_send[1] != 0.0;
if has_input {
self.silent_samples = 0;
} else if self.silent_samples > SILENCE_HOLDOFF {
self.delay_out = [0.0; CHANNELS];
self.verb_out = [0.0; CHANNELS];
self.comb_out = [0.0; CHANNELS];
return;
}
let delay_samples = ((p.delay_time * self.sr) as usize).min(MAX_DELAY_SAMPLES - 1);
let feedback = p.delay_feedback.clamp(0.0, 0.95);
match p.delay_type {
DelayType::Standard => {
for c in 0..CHANNELS {
let fb = ftz(self.delay_feedback[c], 0.0001);
let input = self.delay_send[c] + fb * feedback;
self.delay_out[c] = self.delay[c].process(input, delay_samples);
self.delay_feedback[c] = self.delay_out[c];
}
}
DelayType::PingPong => {
// True ping-pong: mono input → L only, then bounces L↔R
let mono_in = (self.delay_send[0] + self.delay_send[1]) * 0.5;
let fb_l = ftz(self.delay_feedback[0], 0.0001);
let fb_r = ftz(self.delay_feedback[1], 0.0001);
// L gets new input + feedback from R
let input_l = mono_in + fb_r * feedback;
// R gets ONLY feedback from L (no direct input - this creates the bounce)
let input_r = fb_l * feedback;
self.delay_out[0] = self.delay[0].process(input_l, delay_samples);
self.delay_out[1] = self.delay[1].process(input_r, delay_samples);
self.delay_feedback[0] = self.delay_out[0];
self.delay_feedback[1] = self.delay_out[1];
}
DelayType::Tape => {
// Tape delay: one-pole lowpass in feedback (darkening each repeat)
const DAMP: f32 = 0.35;
for c in 0..CHANNELS {
// Apply lowpass to feedback before using it
let fb_raw = ftz(self.delay_feedback[c], 0.0001);
let fb = self.delay_lp[c] + DAMP * (fb_raw - self.delay_lp[c]);
self.delay_lp[c] = fb;
let input = self.delay_send[c] + fb * feedback;
self.delay_out[c] = self.delay[c].process(input, delay_samples);
self.delay_feedback[c] = self.delay_out[c];
}
}
DelayType::Multitap => {
// Multitap: 4 taps with straight-to-triplet morph
// feedback 0 = straight (1, 1/2, 1/4, 1/8)
// feedback 1 = triplet (1, 2/3, 1/3, 1/6)
// in between = swing
let swing = feedback;
let t = delay_samples as f32;
let tap1 = delay_samples;
let tap2 = (t * (0.5 + swing * 0.167)).max(1.0) as usize; // 1/2 → 2/3
let tap3 = (t * (0.25 + swing * 0.083)).max(1.0) as usize; // 1/4 → 1/3
let tap4 = (t * (0.125 + swing * 0.042)).max(1.0) as usize; // 1/8 → 1/6
for c in 0..CHANNELS {
let fb = ftz(self.delay_feedback[c], 0.0001);
let input = self.delay_send[c] + fb * 0.5;
self.delay[c].write(input);
let out1 = self.delay[c].read_at(tap1);
let out2 = self.delay[c].read_at(tap2) * 0.7;
let out3 = self.delay[c].read_at(tap3) * 0.5;
let out4 = self.delay[c].read_at(tap4) * 0.35;
self.delay_out[c] = out1 + out2 + out3 + out4;
self.delay_feedback[c] = out1;
}
}
}
let verb_input = (self.verb_send[0] + self.verb_send[1]) * 0.5;
let verb_stereo = self.verb.process(
verb_input,
p.verb_decay,
p.verb_damp,
p.verb_predelay,
p.verb_diff,
);
self.verb_out[0] = verb_stereo[0];
self.verb_out[1] = verb_stereo[1];
// Comb filter (mono in, mono out to both channels)
let comb_input = (self.comb_send[0] + self.comb_send[1]) * 0.5;
let comb_out = self.comb.process(
comb_input,
p.comb_freq,
p.comb_feedback,
p.comb_damp,
self.sr,
);
self.comb_out[0] = comb_out;
self.comb_out[1] = comb_out;
let energy = self.delay_out[0].abs()
+ self.delay_out[1].abs()
+ self.verb_out[0].abs()
+ self.verb_out[1].abs()
+ self.comb_out[0].abs()
+ self.comb_out[1].abs();
if energy < SILENCE_THRESHOLD {
self.silent_samples = self.silent_samples.saturating_add(1);
} else {
self.silent_samples = 0;
}
}
}

121
src/osc.rs Normal file
View File

@@ -0,0 +1,121 @@
//! OSC (Open Sound Control) message receiver.
//!
//! Listens for UDP packets containing OSC messages and translates them into
//! engine commands. Runs in a dedicated thread, forwarding parsed messages
//! to the audio engine for evaluation.
//!
//! # Message Format
//!
//! OSC arguments are interpreted as key-value pairs and converted to a path
//! string for the engine. Arguments are processed in pairs: odd positions are
//! keys (must be strings), even positions are values.
//!
//! ```text
//! OSC: /play ["sound", "kick", "note", 60, "amp", 0.8]
//! → Engine path: "sound/kick/note/60/amp/0.8"
//! ```
//!
//! # Protocol
//!
//! - Transport: UDP
//! - Default bind: `0.0.0.0:<port>` (all interfaces)
//! - Supports both single messages and bundles (bundles are flattened)
use rosc::{OscMessage, OscPacket, OscType};
use std::net::UdpSocket;
use std::sync::{Arc, Mutex};
use crate::Engine;
/// Maximum UDP packet size for incoming OSC messages.
const BUFFER_SIZE: usize = 4096;
/// Starts the OSC receiver loop on the specified port.
///
/// Binds to all interfaces (`0.0.0.0`) and blocks indefinitely, processing
/// incoming messages. Intended to be spawned in a dedicated thread.
///
/// # Panics
///
/// Panics if the UDP socket cannot be bound (e.g., port already in use).
pub fn run(engine: Arc<Mutex<Engine>>, port: u16) {
let addr = format!("0.0.0.0:{port}");
let socket = UdpSocket::bind(&addr).expect("failed to bind OSC socket");
let mut buf = [0u8; BUFFER_SIZE];
loop {
match socket.recv_from(&mut buf) {
Ok((size, _addr)) => {
if let Ok(packet) = rosc::decoder::decode_udp(&buf[..size]) {
handle_packet(&engine, &packet.1);
}
}
Err(e) => {
eprintln!("OSC recv error: {e}");
}
}
}
}
/// Recursively processes an OSC packet, handling both messages and bundles.
fn handle_packet(engine: &Arc<Mutex<Engine>>, packet: &OscPacket) {
match packet {
OscPacket::Message(msg) => handle_message(engine, msg),
OscPacket::Bundle(bundle) => {
for p in &bundle.content {
handle_packet(engine, p);
}
}
}
}
/// Converts an OSC message to a path string and evaluates it on the engine.
fn handle_message(engine: &Arc<Mutex<Engine>>, msg: &OscMessage) {
let path = osc_to_path(msg);
if !path.is_empty() {
if let Ok(mut e) = engine.lock() {
e.evaluate(&path);
}
}
}
/// Converts OSC message arguments to a slash-separated path string.
///
/// Arguments are processed as key-value pairs. Keys must be strings;
/// non-string keys cause the pair to be skipped. Values are converted
/// to their string representation.
fn osc_to_path(msg: &OscMessage) -> String {
let mut parts: Vec<String> = Vec::new();
let args = &msg.args;
let mut i = 0;
while i + 1 < args.len() {
let key = match &args[i] {
OscType::String(s) => s.clone(),
_ => {
i += 1;
continue;
}
};
let val = arg_to_string(&args[i + 1]);
parts.push(key);
parts.push(val);
i += 2;
}
parts.join("/")
}
/// Converts an OSC type to its string representation.
fn arg_to_string(arg: &OscType) -> String {
match arg {
OscType::Int(v) => v.to_string(),
OscType::Float(v) => v.to_string(),
OscType::Double(v) => v.to_string(),
OscType::Long(v) => v.to_string(),
OscType::String(s) => s.clone(),
OscType::Bool(b) => if *b { "1" } else { "0" }.to_string(),
_ => String::new(),
}
}

421
src/oscillator.rs Normal file
View File

@@ -0,0 +1,421 @@
//! Band-limited oscillators with phase shaping.
//!
//! Provides a phasor-based oscillator system with multiple waveforms and
//! optional phase distortion. Anti-aliasing is achieved via PolyBLEP
//! (Polynomial Band-Limited Step) for discontinuous waveforms.
//!
//! # Waveforms
//!
//! | Name | Alias | Description |
//! |--------|-------|------------------------------------------|
//! | `sine` | - | Pure sinusoid |
//! | `tri` | - | Triangle wave |
//! | `saw` | - | Sawtooth with PolyBLEP anti-aliasing |
//! | `zaw` | - | Raw sawtooth (no anti-aliasing) |
//! | `pulse`| - | Variable-width pulse with PolyBLEP |
//! | `pulze`| - | Raw pulse (no anti-aliasing) |
//!
//! # Phase Shaping
//!
//! [`PhaseShape`] transforms the oscillator phase before waveform generation,
//! enabling complex timbres from simple waveforms:
//!
//! - **mult**: Phase multiplication creates harmonic partials
//! - **warp**: Power curve distortion shifts harmonic balance
//! - **mirror**: Reflection creates symmetrical waveforms
//! - **size**: Step quantization for lo-fi/bitcrushed effects
use crate::fastmath::{exp2f, powf, sinf};
use crate::types::LfoShape;
use std::f32::consts::PI;
/// PolyBLEP correction for band-limited discontinuities.
///
/// Applies a polynomial correction near waveform discontinuities to reduce
/// aliasing. The correction is applied within one sample of the transition.
///
/// - `t`: Current phase position in `[0, 1)`
/// - `dt`: Phase increment per sample (frequency × inverse sample rate)
fn poly_blep(t: f32, dt: f32) -> f32 {
if t < dt {
let t = t / dt;
return t + t - t * t - 1.0;
}
if t > 1.0 - dt {
let t = (t - 1.0) / dt;
return t * t + t + t + 1.0;
}
0.0
}
/// Phase transformation parameters for waveform shaping.
///
/// Applies a chain of transformations to the oscillator phase:
/// mult → warp → mirror → size (in that order).
///
/// All parameters have neutral defaults that result in no transformation.
#[derive(Clone, Copy)]
pub struct PhaseShape {
/// Phase multiplier. Values > 1 create harmonic overtones.
pub size: u16,
/// Phase multiplication factor. Default: 1.0 (no multiplication).
pub mult: f32,
/// Power curve exponent. Positive values compress early phase,
/// negative values compress late phase. Default: 0.0 (linear).
pub warp: f32,
/// Mirror/fold position in `[0, 1]`. Phase reflects at this point.
/// Default: 0.0 (disabled).
pub mirror: f32,
}
impl Default for PhaseShape {
fn default() -> Self {
Self {
size: 0,
mult: 1.0,
warp: 0.0,
mirror: 0.0,
}
}
}
impl PhaseShape {
/// Returns `true` if any shaping parameter is non-neutral.
#[inline]
pub fn is_active(&self) -> bool {
self.size >= 2 || self.mult != 1.0 || self.warp != 0.0 || self.mirror > 0.0
}
/// Applies the full transformation chain to a phase value.
///
/// Input and output are in the range `[0, 1)`.
/// Assumes `is_active()` returned true; call unconditionally for simplicity
/// or guard with `is_active()` to skip the function call entirely.
#[inline]
pub fn apply(&self, phase: f32) -> f32 {
let mut p = phase;
// MULT: multiply and wrap
if self.mult != 1.0 {
p = (p * self.mult).fract();
if p < 0.0 {
p += 1.0;
}
}
// WARP: power curve asymmetry
if self.warp != 0.0 {
p = powf(p, exp2f(self.warp * 2.0));
}
// MIRROR: reflect at position
if self.mirror > 0.0 && self.mirror < 1.0 {
let m = self.mirror;
p = if p < m {
p / m
} else {
1.0 - (p - m) / (1.0 - m)
};
}
// SIZE: quantize
if self.size >= 2 {
let steps = self.size as f32;
p = ((p * steps).floor() / (steps - 1.0)).min(1.0);
}
p
}
}
/// Phase accumulator with waveform generation methods.
///
/// Maintains a phase value in `[0, 1)` that advances each sample based on
/// frequency. Provides both stateful methods (advance phase) and stateless
/// methods (compute at arbitrary phase for unison/spread).
#[derive(Clone, Copy)]
pub struct Phasor {
/// Current phase position in `[0, 1)`.
pub phase: f32,
/// Held value for sample-and-hold LFO.
sh_value: f32,
/// PRNG state for sample-and-hold randomization.
sh_seed: u32,
}
impl Default for Phasor {
fn default() -> Self {
Self {
phase: 0.0,
sh_value: 0.0,
sh_seed: 123456789,
}
}
}
impl Phasor {
/// Advances the phase by one sample.
///
/// - `freq`: Oscillator frequency in Hz
/// - `isr`: Inverse sample rate (1.0 / sample_rate)
pub fn update(&mut self, freq: f32, isr: f32) {
self.phase += freq * isr;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
if self.phase < 0.0 {
self.phase += 1.0;
}
}
/// Generates an LFO sample for the given shape.
///
/// Sample-and-hold (`Sh`) latches a new random value at each cycle start.
pub fn lfo(&mut self, shape: LfoShape, freq: f32, isr: f32) -> f32 {
let p = self.phase;
self.update(freq, isr);
match shape {
LfoShape::Sine => sinf(p * 2.0 * PI),
LfoShape::Tri => {
if p < 0.5 {
4.0 * p - 1.0
} else {
3.0 - 4.0 * p
}
}
LfoShape::Saw => p * 2.0 - 1.0,
LfoShape::Square => {
if p < 0.5 {
1.0
} else {
-1.0
}
}
LfoShape::Sh => {
if self.phase < p {
self.sh_seed = self.sh_seed.wrapping_mul(1103515245).wrapping_add(12345);
self.sh_value = ((self.sh_seed >> 16) & 0x7fff) as f32 / 16383.5 - 1.0;
}
self.sh_value
}
}
}
/// Pure sine wave.
pub fn sine(&mut self, freq: f32, isr: f32) -> f32 {
let s = sinf(self.phase * 2.0 * PI);
self.update(freq, isr);
s
}
/// Triangle wave (no anti-aliasing needed, naturally band-limited).
pub fn tri(&mut self, freq: f32, isr: f32) -> f32 {
let s = if self.phase < 0.5 {
4.0 * self.phase - 1.0
} else {
3.0 - 4.0 * self.phase
};
self.update(freq, isr);
s
}
/// Band-limited sawtooth using PolyBLEP.
pub fn saw(&mut self, freq: f32, isr: f32) -> f32 {
let dt = freq * isr;
let p = poly_blep(self.phase, dt);
let s = self.phase * 2.0 - 1.0 - p;
self.update(freq, isr);
s
}
/// Raw sawtooth without anti-aliasing.
///
/// Use for low frequencies or when aliasing is acceptable/desired.
pub fn zaw(&mut self, freq: f32, isr: f32) -> f32 {
let s = self.phase * 2.0 - 1.0;
self.update(freq, isr);
s
}
/// Band-limited pulse wave with variable width using PolyBLEP.
///
/// - `pw`: Pulse width in `[0, 1]`. 0.5 = square wave.
pub fn pulse(&mut self, freq: f32, pw: f32, isr: f32) -> f32 {
let dt = freq * isr;
let mut phi = self.phase + pw;
if phi >= 1.0 {
phi -= 1.0;
}
let p1 = poly_blep(phi, dt);
let p2 = poly_blep(self.phase, dt);
let pulse = 2.0 * (self.phase - phi) - p2 + p1;
self.update(freq, isr);
pulse + pw * 2.0 - 1.0
}
/// Raw pulse wave without anti-aliasing.
///
/// - `duty`: Duty cycle in `[0, 1]`. 0.5 = square wave.
pub fn pulze(&mut self, freq: f32, duty: f32, isr: f32) -> f32 {
let s = if self.phase < duty { 1.0 } else { -1.0 };
self.update(freq, isr);
s
}
/// Sine wave with phase shaping.
pub fn sine_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(self.phase)
} else {
self.phase
};
let s = sinf(p * 2.0 * PI);
self.update(freq, isr);
s
}
/// Triangle wave with phase shaping.
pub fn tri_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(self.phase)
} else {
self.phase
};
let s = if p < 0.5 {
4.0 * p - 1.0
} else {
3.0 - 4.0 * p
};
self.update(freq, isr);
s
}
/// Sawtooth with phase shaping. Falls back to raw saw when shaped.
pub fn saw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
if !shape.is_active() {
return self.saw(freq, isr);
}
let p = shape.apply(self.phase);
let s = p * 2.0 - 1.0;
self.update(freq, isr);
s
}
/// Raw sawtooth with phase shaping.
pub fn zaw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(self.phase)
} else {
self.phase
};
let s = p * 2.0 - 1.0;
self.update(freq, isr);
s
}
/// Pulse wave with phase shaping. Falls back to raw pulse when shaped.
pub fn pulse_shaped(&mut self, freq: f32, pw: f32, isr: f32, shape: &PhaseShape) -> f32 {
if !shape.is_active() {
return self.pulse(freq, pw, isr);
}
let p = shape.apply(self.phase);
let s = if p < pw { 1.0 } else { -1.0 };
self.update(freq, isr);
s
}
/// Raw pulse with phase shaping.
pub fn pulze_shaped(&mut self, freq: f32, duty: f32, isr: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(self.phase)
} else {
self.phase
};
let s = if p < duty { 1.0 } else { -1.0 };
self.update(freq, isr);
s
}
// -------------------------------------------------------------------------
// Stateless variants for unison/spread - compute at arbitrary phase
// -------------------------------------------------------------------------
/// Sine at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn sine_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
sinf(p * 2.0 * PI)
}
/// Triangle at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn tri_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
if p < 0.5 {
4.0 * p - 1.0
} else {
3.0 - 4.0 * p
}
}
/// Sawtooth at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn saw_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
p * 2.0 - 1.0
}
/// Raw sawtooth at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn zaw_at(&self, phase: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
p * 2.0 - 1.0
}
/// Pulse at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn pulse_at(&self, phase: f32, pw: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
if p < pw {
1.0
} else {
-1.0
}
}
/// Raw pulse at arbitrary phase (stateless, for unison voices).
#[inline]
pub fn pulze_at(&self, phase: f32, duty: f32, shape: &PhaseShape) -> f32 {
let p = if shape.is_active() {
shape.apply(phase)
} else {
phase
};
if p < duty {
1.0
} else {
-1.0
}
}
}

212
src/plaits.rs Normal file
View File

@@ -0,0 +1,212 @@
//! Unified wrapper for Mutable Instruments Plaits synthesis engines.
//!
//! This module provides a single enum that wraps all 13 synthesis engines from
//! the `mi_plaits_dsp` crate (a Rust port of the Mutable Instruments Plaits
//! Eurorack module). Each engine produces sound through a different synthesis
//! technique.
//!
//! # Engine Categories
//!
//! ## Pitched Engines
//! - [`Modal`](PlaitsEngine::Modal) - Physical modeling of resonant structures
//! - [`Va`](PlaitsEngine::Va) - Virtual analog (classic subtractive synthesis)
//! - [`Ws`](PlaitsEngine::Ws) - Waveshaping synthesis
//! - [`Fm`](PlaitsEngine::Fm) - 2-operator FM synthesis
//! - [`Grain`](PlaitsEngine::Grain) - Granular synthesis
//! - [`Additive`](PlaitsEngine::Additive) - Additive synthesis with harmonic control
//! - [`Wavetable`](PlaitsEngine::Wavetable) - Wavetable oscillator
//! - [`Chord`](PlaitsEngine::Chord) - Polyphonic chord generator
//! - [`Swarm`](PlaitsEngine::Swarm) - Swarm of detuned oscillators
//! - [`Noise`](PlaitsEngine::Noise) - Filtered noise with resonance
//!
//! ## Percussion Engines
//! - [`Bass`](PlaitsEngine::Bass) - Analog kick drum model
//! - [`Snare`](PlaitsEngine::Snare) - Analog snare drum model
//! - [`Hat`](PlaitsEngine::Hat) - Hi-hat synthesis
//!
//! # Control Parameters
//!
//! All engines share a common control interface via [`EngineParameters`]:
//! - `note` - MIDI note number (pitch)
//! - `harmonics` - Timbre brightness/harmonics control
//! - `timbre` - Primary timbre parameter
//! - `morph` - Secondary timbre/morph parameter
//! - `accent` - Velocity/accent amount
//! - `trigger` - Gate/trigger state
use crate::types::{Source, BLOCK_SIZE};
use mi_plaits_dsp::engine::additive_engine::AdditiveEngine;
use mi_plaits_dsp::engine::bass_drum_engine::BassDrumEngine;
use mi_plaits_dsp::engine::chord_engine::ChordEngine;
use mi_plaits_dsp::engine::fm_engine::FmEngine;
use mi_plaits_dsp::engine::grain_engine::GrainEngine;
use mi_plaits_dsp::engine::hihat_engine::HihatEngine;
use mi_plaits_dsp::engine::modal_engine::ModalEngine;
use mi_plaits_dsp::engine::noise_engine::NoiseEngine;
use mi_plaits_dsp::engine::snare_drum_engine::SnareDrumEngine;
use mi_plaits_dsp::engine::swarm_engine::SwarmEngine;
use mi_plaits_dsp::engine::virtual_analog_engine::VirtualAnalogEngine;
use mi_plaits_dsp::engine::waveshaping_engine::WaveshapingEngine;
use mi_plaits_dsp::engine::wavetable_engine::WavetableEngine;
use mi_plaits_dsp::engine::{Engine, EngineParameters};
/// Wrapper enum containing all Plaits synthesis engines.
///
/// Only one engine is active at a time. The engine is lazily initialized
/// when first needed and can be switched by creating a new instance with
/// [`PlaitsEngine::new`].
pub enum PlaitsEngine {
/// Physical modeling of resonant structures (strings, plates, tubes).
Modal(ModalEngine),
/// Classic virtual analog with saw, pulse, and sub oscillator.
Va(VirtualAnalogEngine),
/// Waveshaping synthesis for harsh, aggressive timbres.
Ws(WaveshapingEngine),
/// Two-operator FM synthesis.
Fm(FmEngine),
/// Granular synthesis with pitch-shifting grains.
Grain(GrainEngine),
/// Additive synthesis with individual harmonic control.
Additive(AdditiveEngine),
/// Wavetable oscillator with smooth morphing.
Wavetable(WavetableEngine<'static>),
/// Polyphonic chord generator (boxed due to size).
Chord(Box<ChordEngine<'static>>),
/// Swarm of detuned sawtooth oscillators.
Swarm(SwarmEngine),
/// Filtered noise with variable resonance.
Noise(NoiseEngine),
/// Analog bass drum synthesis.
Bass(BassDrumEngine),
/// Analog snare drum synthesis.
Snare(SnareDrumEngine),
/// Metallic hi-hat synthesis.
Hat(HihatEngine),
}
impl PlaitsEngine {
/// Creates and initializes a new engine based on the given source type.
///
/// # Panics
/// Panics if `source` is not a Plaits source variant (e.g., `Source::Tri`).
pub fn new(source: Source, sample_rate: f32) -> Self {
match source {
Source::PlModal => {
let mut e = ModalEngine::new(BLOCK_SIZE);
e.init(sample_rate);
Self::Modal(e)
}
Source::PlVa => {
let mut e = VirtualAnalogEngine::new(BLOCK_SIZE);
e.init(sample_rate);
Self::Va(e)
}
Source::PlWs => {
let mut e = WaveshapingEngine::new();
e.init(sample_rate);
Self::Ws(e)
}
Source::PlFm => {
let mut e = FmEngine::new();
e.init(sample_rate);
Self::Fm(e)
}
Source::PlGrain => {
let mut e = GrainEngine::new();
e.init(sample_rate);
Self::Grain(e)
}
Source::PlAdd => {
let mut e = AdditiveEngine::new();
e.init(sample_rate);
Self::Additive(e)
}
Source::PlWt => {
let mut e = WavetableEngine::new();
e.init(sample_rate);
Self::Wavetable(e)
}
Source::PlChord => {
let mut e = ChordEngine::new();
e.init(sample_rate);
Self::Chord(Box::new(e))
}
Source::PlSwarm => {
let mut e = SwarmEngine::new();
e.init(sample_rate);
Self::Swarm(e)
}
Source::PlNoise => {
let mut e = NoiseEngine::new(BLOCK_SIZE);
e.init(sample_rate);
Self::Noise(e)
}
Source::PlBass => {
let mut e = BassDrumEngine::new();
e.init(sample_rate);
Self::Bass(e)
}
Source::PlSnare => {
let mut e = SnareDrumEngine::new();
e.init(sample_rate);
Self::Snare(e)
}
Source::PlHat => {
let mut e = HihatEngine::new(BLOCK_SIZE);
e.init(sample_rate);
Self::Hat(e)
}
_ => unreachable!(),
}
}
/// Renders a block of audio samples.
///
/// # Arguments
/// - `params` - Engine parameters (pitch, timbre, morph, etc.)
/// - `out` - Output buffer for main signal (length must be `BLOCK_SIZE`)
/// - `aux` - Output buffer for auxiliary signal (length must be `BLOCK_SIZE`)
/// - `already_enveloped` - Set to true by percussion engines that apply their own envelope
pub fn render(
&mut self,
params: &EngineParameters,
out: &mut [f32],
aux: &mut [f32],
already_enveloped: &mut bool,
) {
match self {
Self::Modal(e) => e.render(params, out, aux, already_enveloped),
Self::Va(e) => e.render(params, out, aux, already_enveloped),
Self::Ws(e) => e.render(params, out, aux, already_enveloped),
Self::Fm(e) => e.render(params, out, aux, already_enveloped),
Self::Grain(e) => e.render(params, out, aux, already_enveloped),
Self::Additive(e) => e.render(params, out, aux, already_enveloped),
Self::Wavetable(e) => e.render(params, out, aux, already_enveloped),
Self::Chord(e) => e.render(params, out, aux, already_enveloped),
Self::Swarm(e) => e.render(params, out, aux, already_enveloped),
Self::Noise(e) => e.render(params, out, aux, already_enveloped),
Self::Bass(e) => e.render(params, out, aux, already_enveloped),
Self::Snare(e) => e.render(params, out, aux, already_enveloped),
Self::Hat(e) => e.render(params, out, aux, already_enveloped),
}
}
/// Returns the [`Source`] variant corresponding to the current engine.
pub fn source(&self) -> Source {
match self {
Self::Modal(_) => Source::PlModal,
Self::Va(_) => Source::PlVa,
Self::Ws(_) => Source::PlWs,
Self::Fm(_) => Source::PlFm,
Self::Grain(_) => Source::PlGrain,
Self::Additive(_) => Source::PlAdd,
Self::Wavetable(_) => Source::PlWt,
Self::Chord(_) => Source::PlChord,
Self::Swarm(_) => Source::PlSwarm,
Self::Noise(_) => Source::PlNoise,
Self::Bass(_) => Source::PlBass,
Self::Snare(_) => Source::PlSnare,
Self::Hat(_) => Source::PlHat,
}
}
}

429
src/repl.rs Normal file
View File

@@ -0,0 +1,429 @@
//! Interactive REPL for the doux audio engine.
//!
//! Provides a command-line interface for live-coding audio patterns with
//! readline-style editing and persistent history.
//!
//! # Usage
//!
//! ```text
//! doux-repl [OPTIONS]
//!
//! Options:
//! -s, --samples <PATH> Directory containing audio samples
//! -i, --input <DEVICE> Input device (name or index)
//! -o, --output <DEVICE> Output device (name or index)
//! --channels <N> Number of output channels (default: 2)
//! --list-devices List available audio devices and exit
//! ```
//!
//! # REPL Commands
//!
//! | Command | Alias | Description |
//! |-----------|-------|--------------------------------------|
//! | `.quit` | `.q` | Exit the REPL |
//! | `.reset` | `.r` | Reset engine state |
//! | `.hush` | | Fade out all voices |
//! | `.panic` | | Immediately silence all voices |
//! | `.voices` | | Show active voice count |
//! | `.time` | | Show engine time in seconds |
//! | `.help` | `.h` | Show available commands |
//!
//! Any other input is evaluated as a doux pattern.
use clap::Parser;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, Host};
use doux::Engine;
use rustyline::completion::Completer;
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::Helper;
use std::borrow::Cow;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
// ANSI color codes
const RESET: &str = "\x1b[0m";
const GRAY: &str = "\x1b[90m";
const BOLD: &str = "\x1b[1m";
const RED: &str = "\x1b[31m";
const DIM_GRAY: &str = "\x1b[2;90m";
const CYAN: &str = "\x1b[36m";
struct DouxHighlighter;
impl Highlighter for DouxHighlighter {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
// Comment: everything after //
if let Some(idx) = line.find("//") {
let before = &line[..idx];
let comment = &line[idx..];
let highlighted_before = highlight_pattern(before);
return Cow::Owned(format!("{highlighted_before}{DIM_GRAY}{comment}{RESET}"));
}
// Dot command
if line.trim_start().starts_with('.') {
return Cow::Owned(format!("{CYAN}{line}{RESET}"));
}
// Pattern with /key/value
Cow::Owned(highlight_pattern(line))
}
fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
true
}
}
fn highlight_pattern(line: &str) -> String {
let mut result = String::new();
let mut chars = line.chars().peekable();
let mut after_slash = false;
while let Some(c) = chars.next() {
if c == '/' {
result.push_str(GRAY);
result.push(c);
result.push_str(RESET);
after_slash = true;
} else if after_slash {
// Collect the token until next /
let mut token = String::new();
token.push(c);
while let Some(&next) = chars.peek() {
if next == '/' {
break;
}
token.push(chars.next().unwrap());
}
// Is it a number?
if is_number(&token) {
result.push_str(RED);
result.push_str(&token);
result.push_str(RESET);
} else {
result.push_str(BOLD);
result.push_str(&token);
result.push_str(RESET);
}
after_slash = false;
} else {
result.push(c);
}
}
result
}
fn is_number(s: &str) -> bool {
if s.is_empty() {
return false;
}
let s = s.strip_prefix('-').unwrap_or(s);
s.chars().all(|c| c.is_ascii_digit() || c == '.')
}
impl Completer for DouxHighlighter {
type Candidate = String;
}
impl Hinter for DouxHighlighter {
type Hint = String;
}
impl Validator for DouxHighlighter {}
impl Helper for DouxHighlighter {}
/// Maximum samples buffered from audio input.
const INPUT_BUFFER_SIZE: usize = 8192;
#[derive(Parser)]
#[command(name = "doux-repl")]
#[command(about = "Interactive REPL for doux audio engine")]
struct Args {
/// Directory containing audio samples
#[arg(short, long)]
samples: Option<PathBuf>,
/// List available audio devices and exit
#[arg(long)]
list_devices: bool,
/// Input device (name or index)
#[arg(short, long)]
input: Option<String>,
/// Output device (name or index)
#[arg(short, long)]
output: Option<String>,
/// Number of output channels (default: 2, max depends on device)
#[arg(long, default_value = "2")]
channels: u16,
/// Audio buffer size in samples (lower = less latency, higher = more stable).
/// Common values: 64, 128, 256, 512, 1024. Default: system choice.
#[arg(short, long)]
buffer_size: Option<u32>,
}
/// Prints available audio input and output devices.
///
/// Default devices are marked with `*`.
fn list_devices(host: &Host) {
let default_in = host.default_input_device().and_then(|d| d.name().ok());
let default_out = host.default_output_device().and_then(|d| d.name().ok());
println!("Input devices:");
if let Ok(devices) = host.input_devices() {
for (i, d) in devices.enumerate() {
let name = d.name().unwrap_or_else(|_| "???".into());
let marker = if Some(&name) == default_in.as_ref() {
" *"
} else {
""
};
println!(" {i}: {name}{marker}");
}
}
println!("\nOutput devices:");
if let Ok(devices) = host.output_devices() {
for (i, d) in devices.enumerate() {
let name = d.name().unwrap_or_else(|_| "???".into());
let marker = if Some(&name) == default_out.as_ref() {
" *"
} else {
""
};
println!(" {i}: {name}{marker}");
}
}
}
/// Finds a device by index or substring match on name.
fn find_device<I>(devices: I, spec: &str) -> Option<Device>
where
I: Iterator<Item = Device>,
{
let devices: Vec<_> = devices.collect();
if let Ok(idx) = spec.parse::<usize>() {
return devices.into_iter().nth(idx);
}
let spec_lower = spec.to_lowercase();
devices.into_iter().find(|d| {
d.name()
.map(|n| n.to_lowercase().contains(&spec_lower))
.unwrap_or(false)
})
}
/// Prints available REPL commands.
fn print_help() {
println!("Commands:");
println!(" .quit, .q Exit the REPL");
println!(" .reset, .r Reset engine state");
println!(" .hush Fade out all voices");
println!(" .panic Immediately silence all voices");
println!(" .voices Show active voice count");
println!(" .time Show engine time");
println!(" .stats, .s Show engine telemetry (CPU, voices, memory)");
println!(" .help, .h Show this help");
println!();
println!("Any other input is evaluated as a doux pattern.");
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let host = cpal::default_host();
if args.list_devices {
list_devices(&host);
return Ok(());
}
let device = match &args.output {
Some(spec) => host
.output_devices()
.ok()
.and_then(|d| find_device(d, spec))
.unwrap_or_else(|| panic!("output device '{spec}' not found")),
None => host.default_output_device().expect("no output device"),
};
let max_channels = device
.supported_output_configs()
.map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2))
.unwrap_or(2);
let output_channels = (args.channels as usize).min(max_channels as usize);
if args.channels as usize > output_channels {
eprintln!(
"Warning: device supports max {} channels, using that instead of {}",
max_channels, args.channels
);
}
let default_config = device.default_output_config()?;
let sample_rate = default_config.sample_rate().0 as f32;
let config = cpal::StreamConfig {
channels: output_channels as u16,
sample_rate: default_config.sample_rate(),
buffer_size: args
.buffer_size
.map(cpal::BufferSize::Fixed)
.unwrap_or(cpal::BufferSize::Default),
};
println!("doux-repl");
print!(
"Output: {} @ {}Hz, {} channels",
device.name().unwrap_or_default(),
sample_rate,
output_channels
);
if let Some(buf) = args.buffer_size {
let latency_ms = buf as f32 / sample_rate * 1000.0;
println!(", {buf} samples ({latency_ms:.1} ms)");
} else {
println!();
}
let mut engine = Engine::new_with_channels(sample_rate, output_channels);
if let Some(ref dir) = args.samples {
let index = doux::loader::scan_samples_dir(dir);
println!("Samples: {} from {}", index.len(), dir.display());
engine.sample_index = index;
}
let engine = Arc::new(Mutex::new(engine));
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(INPUT_BUFFER_SIZE)));
let input_device = match &args.input {
Some(spec) => host.input_devices().ok().and_then(|d| find_device(d, spec)),
None => host.default_input_device(),
};
let _input_stream = input_device.and_then(|input_device| {
let input_config = input_device.default_input_config().ok()?;
println!("Input: {}", input_device.name().unwrap_or_default());
let buf = Arc::clone(&input_buffer);
let stream = input_device
.build_input_stream(
&input_config.into(),
move |data: &[f32], _| {
let mut b = buf.lock().unwrap();
b.extend(data.iter().copied());
let excess = b.len().saturating_sub(INPUT_BUFFER_SIZE);
if excess > 0 {
drop(b.drain(..excess));
}
},
|err| eprintln!("input error: {err}"),
None,
)
.ok()?;
stream.play().ok()?;
Some(stream)
});
let engine_clone = Arc::clone(&engine);
let input_buf_clone = Arc::clone(&input_buffer);
let sr = sample_rate;
let ch = output_channels;
let stream = device.build_output_stream(
&config,
move |data: &mut [f32], _| {
let mut scratch = vec![0.0f32; data.len()];
{
let mut buf = input_buf_clone.lock().unwrap();
let available = buf.len().min(data.len());
for (i, sample) in buf.drain(..available).enumerate() {
scratch[i] = sample;
}
}
let mut engine = engine_clone.lock().unwrap();
let buffer_samples = data.len() / ch;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &scratch);
},
|err| eprintln!("stream error: {err}"),
None,
)?;
stream.play()?;
let mut rl = rustyline::Editor::new()?;
rl.set_helper(Some(DouxHighlighter));
let history_path = std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".doux_history"))
.unwrap_or_else(|_| PathBuf::from(".doux_history"));
let _ = rl.load_history(&history_path);
println!("Type .help for commands");
loop {
match rl.readline("doux> ") {
Ok(line) => {
let _ = rl.add_history_entry(&line);
let trimmed = line.trim();
match trimmed {
".quit" | ".q" => break,
".reset" | ".r" => {
engine.lock().unwrap().evaluate("/doux/reset");
}
".voices" | ".v" => {
println!("{}", engine.lock().unwrap().active_voices);
}
".time" | ".t" => {
println!("{:.3}s", engine.lock().unwrap().time);
}
".stats" | ".s" => {
use std::sync::atomic::Ordering;
let e = engine.lock().unwrap();
let cpu = e.metrics.load.get_load() * 100.0;
let voices = e.metrics.active_voices.load(Ordering::Relaxed);
let peak = e.metrics.peak_voices.load(Ordering::Relaxed);
let sched = e.metrics.schedule_depth.load(Ordering::Relaxed);
let mem = e.metrics.sample_pool_mb();
println!("CPU: {cpu:5.1}%");
println!("Voices: {voices:3}/{}", doux::types::MAX_VOICES);
println!("Peak: {peak:3}");
println!("Schedule: {sched:3}");
println!("Samples: {mem:.1} MB");
}
".hush" => {
engine.lock().unwrap().hush();
}
".panic" => {
engine.lock().unwrap().panic();
}
".help" | ".h" => {
print_help();
}
s if !s.is_empty() => {
engine.lock().unwrap().evaluate(s);
}
_ => {}
}
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
Err(e) => {
eprintln!("readline error: {e}");
break;
}
}
}
let _ = rl.save_history(&history_path);
Ok(())
}

264
src/sample.rs Normal file
View File

@@ -0,0 +1,264 @@
//! Sample storage and playback primitives.
//!
//! Provides a memory pool for audio samples and playback cursors for reading
//! them back at variable speeds with interpolation.
//!
//! # Architecture
//!
//! ```text
//! SampleEntry (index) SamplePool (storage) FileSource (playhead)
//! ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
//! │ path: kick.wav │ │ [f32; N] │ │ sample_idx: 0 │
//! │ name: "kick" │────▶│ ├─ sample 0 ─────│◀────│ pos: 0.0 │
//! │ loaded: Some(0) │ │ ├─ sample 1 │ │ begin: 0.0 │
//! └─────────────────┘ │ └─ ... │ │ end: 1.0 │
//! └──────────────────┘ └─────────────────┘
//! ```
//!
//! - [`SampleEntry`]: Metadata for lazy-loaded samples (path, name, pool index)
//! - [`SamplePool`]: Contiguous f32 storage for all loaded sample data
//! - [`SampleInfo`]: Location and format of a sample within the pool
//! - [`FileSource`]: Playback cursor with position, speed, and loop points
//! - [`WebSampleSource`]: Simplified playback for WASM (no interpolation)
use std::path::PathBuf;
/// Index entry for a discoverable sample file.
///
/// Created during directory scanning with [`crate::loader::scan_samples_dir`].
/// The `loaded` field is `None` until the sample is actually decoded and
/// added to the pool.
pub struct SampleEntry {
/// Filesystem path to the audio file.
pub path: PathBuf,
/// Display name (derived from filename or folder/index).
pub name: String,
/// Pool index if loaded, `None` if not yet decoded.
pub loaded: Option<usize>,
}
/// Contiguous storage for all loaded sample data.
///
/// Samples are stored sequentially as interleaved f32 frames. Each sample's
/// location is tracked by a corresponding [`SampleInfo`].
///
/// This design minimizes allocations and improves cache locality compared
/// to storing each sample in a separate `Vec`.
#[derive(Default)]
pub struct SamplePool {
/// Raw interleaved sample data for all loaded samples.
pub data: Vec<f32>,
}
impl SamplePool {
/// Creates an empty sample pool.
pub fn new() -> Self {
Self::default()
}
/// Adds sample data to the pool and returns its metadata.
///
/// The samples should be interleaved if multi-channel (e.g., `[L, R, L, R, ...]`).
///
/// # Parameters
///
/// - `samples`: Interleaved audio data
/// - `channels`: Number of channels (1 = mono, 2 = stereo)
/// - `freq`: Base frequency in Hz for pitch calculations
///
/// # Returns
///
/// [`SampleInfo`] describing the sample's location in the pool.
pub fn add(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option<SampleInfo> {
let frames = samples.len() / channels as usize;
let offset = self.data.len();
let info = SampleInfo {
offset,
frames: frames as u32,
channels,
freq,
};
self.data.extend_from_slice(samples);
Some(info)
}
/// Returns the total memory usage in megabytes.
pub fn size_mb(&self) -> f32 {
(self.data.len() * 4) as f32 / (1024.0 * 1024.0)
}
}
/// Metadata for a sample stored in the pool.
///
/// Describes where a sample lives in the pool's data array and its format.
#[derive(Clone, Copy, Default)]
pub struct SampleInfo {
/// Byte offset into [`SamplePool::data`] where this sample begins.
pub offset: usize,
/// Total number of frames (samples per channel).
pub frames: u32,
/// Number of interleaved channels.
pub channels: u8,
/// Base frequency in Hz (used for pitch-shifting calculations).
pub freq: f32,
}
/// Playback cursor for reading samples from the pool.
///
/// Tracks playback position and supports:
/// - Variable-speed playback (including reverse with negative speed)
/// - Start/end points for partial playback or slicing
/// - Linear interpolation between samples for smooth pitch shifting
#[derive(Clone, Copy)]
pub struct FileSource {
/// Index into the sample info array.
pub sample_idx: usize,
/// Current playback position in frames (fractional for interpolation).
pub pos: f32,
/// Start point as fraction of total length `[0.0, 1.0]`.
pub begin: f32,
/// End point as fraction of total length `[0.0, 1.0]`.
pub end: f32,
}
impl Default for FileSource {
fn default() -> Self {
Self {
sample_idx: 0,
pos: 0.0,
begin: 0.0,
end: 1.0,
}
}
}
impl FileSource {
/// Creates a new playback cursor for a sample with start/end points.
///
/// Points are clamped to valid ranges: begin to `[0, 1]`, end to `[begin, 1]`.
pub fn new(sample_idx: usize, begin: f32, end: f32) -> Self {
let begin_clamped = begin.clamp(0.0, 1.0);
Self {
sample_idx,
pos: 0.0,
begin: begin_clamped,
end: end.clamp(begin_clamped, 1.0),
}
}
/// Reads the next sample value and advances the playback position.
///
/// Uses linear interpolation for fractional positions, enabling smooth
/// pitch shifting without stair-stepping artifacts.
///
/// # Parameters
///
/// - `pool`: The sample pool's data slice
/// - `info`: Sample metadata (offset, frames, channels)
/// - `speed`: Playback rate multiplier (1.0 = normal, 2.0 = double speed)
/// - `channel`: Which channel to read (clamped to available channels)
///
/// # Returns
///
/// The interpolated sample value, or `0.0` if past the end point.
pub fn update(&mut self, pool: &[f32], info: &SampleInfo, speed: f32, channel: usize) -> f32 {
let begin_frame = (self.begin * info.frames as f32) as usize;
let end_frame = (self.end * info.frames as f32) as usize;
let channels = info.channels as usize;
let current = self.pos as usize + begin_frame;
if current >= end_frame {
return 0.0;
}
let frac = self.pos.fract();
let ch = channel.min(channels - 1);
let idx0 = info.offset + current * channels + ch;
let idx1 = if current + 1 < end_frame {
info.offset + (current + 1) * channels + ch
} else {
idx0
};
let s0 = pool.get(idx0).copied().unwrap_or(0.0);
let s1 = pool.get(idx1).copied().unwrap_or(0.0);
let sample = s0 + frac * (s1 - s0);
self.pos += speed;
sample
}
/// Returns `true` if playback has reached or passed the end point.
pub fn is_done(&self, info: &SampleInfo) -> bool {
let begin_frame = (self.begin * info.frames as f32) as usize;
let end_frame = (self.end * info.frames as f32) as usize;
let current = self.pos as usize + begin_frame;
current >= end_frame
}
}
/// Simplified sample playback for WASM environments.
///
/// Unlike [`FileSource`], this struct embeds its [`SampleInfo`] and does not
/// perform interpolation. Designed for web playback where JavaScript populates
/// a shared PCM buffer that Rust reads from.
#[derive(Clone, Copy, Default)]
pub struct WebSampleSource {
/// Sample metadata (location, size, format).
pub info: SampleInfo,
/// Current playback position in frames (relative to begin point).
pub pos: f32,
/// Normalized start point (0.0 = sample start, 1.0 = sample end).
pub begin: f32,
/// Normalized end point (0.0 = sample start, 1.0 = sample end).
pub end: f32,
}
impl WebSampleSource {
/// Creates a new sample source with the given loop points.
///
/// Both `begin` and `end` are normalized values in the range 0.0 to 1.0,
/// representing positions within the sample. Values are clamped automatically.
pub fn new(info: SampleInfo, begin: f32, end: f32) -> Self {
let begin_clamped = begin.clamp(0.0, 1.0);
Self {
info,
pos: 0.0,
begin: begin_clamped,
end: end.clamp(begin_clamped, 1.0),
}
}
/// Advances playback and returns the next sample value.
///
/// Returns 0.0 if playback has reached the end point. The `speed` parameter
/// controls playback rate (1.0 = normal, 2.0 = double speed, 0.5 = half speed).
/// The `channel` parameter selects which channel to read (clamped to available channels).
pub fn update(&mut self, pcm_buffer: &[f32], speed: f32, channel: usize) -> f32 {
let begin_frame = (self.begin * self.info.frames as f32) as usize;
let end_frame = (self.end * self.info.frames as f32) as usize;
let current = self.pos as usize + begin_frame;
if current >= end_frame {
return 0.0;
}
let ch = channel.min(self.info.channels as usize - 1);
let idx = self.info.offset + current * self.info.channels as usize + ch;
let sample = pcm_buffer.get(idx).copied().unwrap_or(0.0);
self.pos += speed;
sample
}
/// Returns true if playback has reached or passed the end point.
pub fn is_done(&self) -> bool {
let begin_frame = (self.begin * self.info.frames as f32) as usize;
let end_frame = (self.end * self.info.frames as f32) as usize;
let current = self.pos as usize + begin_frame;
current >= end_frame
}
}

98
src/schedule.rs Normal file
View File

@@ -0,0 +1,98 @@
//! Time-based event scheduling with sorted storage.
//!
//! Manages a queue of [`Event`]s that should fire at specific times.
//! Events are kept sorted by time for O(1) early-exit when no events are ready.
//!
//! # Event Lifecycle
//!
//! 1. Event with `time` field is parsed → inserted in sorted order
//! 2. Engine calls `process_schedule()` each sample
//! 3. When `event.time <= engine.time`:
//! - Event fires (triggers voice/sound)
//! - If `repeat` is set, event is re-inserted with new time
//! - Otherwise, event is removed
//!
//! # Complexity
//!
//! - Insertion: O(N) due to sorted insert (infrequent, ~10-100/sec)
//! - Processing: O(1) when no events ready (99.9% of calls)
//! - Processing: O(K) when K events fire (rare)
//!
//! # Capacity
//!
//! Limited to [`MAX_EVENTS`](crate::types::MAX_EVENTS) to prevent unbounded
//! growth. Events beyond this limit are silently dropped.
use crate::event::Event;
use crate::types::MAX_EVENTS;
/// Queue of time-scheduled events, sorted by time ascending.
///
/// Invariant: `events[i].time <= events[i+1].time` for all valid indices.
/// This enables O(1) early-exit: if `events[0].time > now`, no events are ready.
pub struct Schedule {
events: Vec<Event>,
}
impl Schedule {
/// Creates an empty schedule with pre-allocated capacity.
pub fn new() -> Self {
Self {
events: Vec::with_capacity(MAX_EVENTS),
}
}
/// Adds an event to the schedule in sorted order.
///
/// Events at capacity are silently dropped.
/// Insertion is O(N) but occurs infrequently (user actions).
pub fn push(&mut self, event: Event) {
if self.events.len() >= MAX_EVENTS {
return;
}
let time = event.time.unwrap_or(f64::MAX);
let pos = self
.events
.partition_point(|e| e.time.unwrap_or(f64::MAX) < time);
self.events.insert(pos, event);
}
/// Returns the time of the earliest event, if any.
#[inline]
pub fn peek_time(&self) -> Option<f64> {
self.events.first().and_then(|e| e.time)
}
/// Removes and returns the earliest event.
#[inline]
pub fn pop_front(&mut self) -> Option<Event> {
if self.events.is_empty() {
None
} else {
Some(self.events.remove(0))
}
}
/// Returns the number of scheduled events.
#[inline]
pub fn len(&self) -> usize {
self.events.len()
}
/// Returns true if no events are scheduled.
#[inline]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Removes all scheduled events.
pub fn clear(&mut self) {
self.events.clear();
}
}
impl Default for Schedule {
fn default() -> Self {
Self::new()
}
}

112
src/telemetry.rs Normal file
View File

@@ -0,0 +1,112 @@
//! Audio engine telemetry. Native only.
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
const LOAD_SCALE: f32 = 1_000_000.0; // fixed-point for atomic float storage
const DEFAULT_SMOOTHING: f32 = 0.9;
/// Measures DSP load as ratio of processing time to buffer time.
///
/// Thread-safe via atomics. Load of 1.0 means using all available time.
pub struct ProcessLoadMeasurer {
buffer_time_ns: AtomicU64,
load_fixed: AtomicU32,
smoothing: f32,
}
impl Default for ProcessLoadMeasurer {
fn default() -> Self {
Self::new(DEFAULT_SMOOTHING)
}
}
impl ProcessLoadMeasurer {
/// Creates a new measurer. Smoothing in [0.0, 0.99]: higher = slower response.
pub fn new(smoothing: f32) -> Self {
Self {
buffer_time_ns: AtomicU64::new(0),
load_fixed: AtomicU32::new(0),
smoothing: smoothing.clamp(0.0, 0.99),
}
}
pub fn set_buffer_time(&self, ns: u64) {
self.buffer_time_ns.store(ns, Ordering::Relaxed);
}
/// Returns a timer that records elapsed time on drop.
pub fn start_timer(&self) -> ScopedTimer<'_> {
ScopedTimer {
measurer: self,
start: Instant::now(),
}
}
pub fn record_sample(&self, elapsed_ns: u64) {
let buffer_ns = self.buffer_time_ns.load(Ordering::Relaxed);
if buffer_ns == 0 {
return;
}
let instant_load = (elapsed_ns as f64 / buffer_ns as f64).min(2.0) as f32;
let old_fixed = self.load_fixed.load(Ordering::Relaxed);
let old_load = old_fixed as f32 / LOAD_SCALE;
let new_load = self.smoothing * old_load + (1.0 - self.smoothing) * instant_load;
let new_fixed = (new_load * LOAD_SCALE) as u32;
self.load_fixed.store(new_fixed, Ordering::Relaxed);
}
pub fn get_load(&self) -> f32 {
self.load_fixed.load(Ordering::Relaxed) as f32 / LOAD_SCALE
}
pub fn reset(&self) {
self.load_fixed.store(0, Ordering::Relaxed);
}
}
/// RAII timer that records elapsed time on drop.
pub struct ScopedTimer<'a> {
measurer: &'a ProcessLoadMeasurer,
start: Instant,
}
impl Drop for ScopedTimer<'_> {
fn drop(&mut self) {
let elapsed = self.start.elapsed().as_nanos() as u64;
self.measurer.record_sample(elapsed);
}
}
/// Aggregated engine metrics. All fields atomic for cross-thread access.
pub struct EngineMetrics {
pub load: ProcessLoadMeasurer,
pub active_voices: AtomicU32,
pub peak_voices: AtomicU32,
pub schedule_depth: AtomicU32,
pub sample_pool_bytes: AtomicU64,
}
impl Default for EngineMetrics {
fn default() -> Self {
Self {
load: ProcessLoadMeasurer::default(),
active_voices: AtomicU32::new(0),
peak_voices: AtomicU32::new(0),
schedule_depth: AtomicU32::new(0),
sample_pool_bytes: AtomicU64::new(0),
}
}
}
impl EngineMetrics {
pub fn reset_peak_voices(&self) {
self.peak_voices.store(0, Ordering::Relaxed);
}
pub fn sample_pool_mb(&self) -> f32 {
self.sample_pool_bytes.load(Ordering::Relaxed) as f32 / (1024.0 * 1024.0)
}
}

168
src/types.rs Normal file
View File

@@ -0,0 +1,168 @@
use std::str::FromStr;
pub const BLOCK_SIZE: usize = 128;
pub const CHANNELS: usize = 2;
pub const MAX_VOICES: usize = 32;
pub const MAX_EVENTS: usize = 64;
pub const MAX_ORBITS: usize = 8;
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum Source {
#[default]
Tri,
Sine,
Saw,
Zaw,
Pulse,
Pulze,
White,
Pink,
Brown,
Sample, // Native: disk-loaded samples via FileSource
WebSample, // Web: inline PCM from JavaScript
LiveInput, // Live audio input (microphone, line-in)
PlModal,
PlVa,
PlWs,
PlFm,
PlGrain,
PlAdd,
PlWt,
PlChord,
PlSwarm,
PlNoise,
PlBass,
PlSnare,
PlHat,
}
impl Source {
pub fn is_plaits_percussion(&self) -> bool {
matches!(self, Self::PlBass | Self::PlSnare | Self::PlHat)
}
}
impl FromStr for Source {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"triangle" | "tri" => Ok(Self::Tri),
"sine" => Ok(Self::Sine),
"sawtooth" | "saw" => Ok(Self::Saw),
"zawtooth" | "zaw" => Ok(Self::Zaw),
"pulse" | "square" => Ok(Self::Pulse),
"pulze" | "zquare" => Ok(Self::Pulze),
"white" => Ok(Self::White),
"pink" => Ok(Self::Pink),
"brown" => Ok(Self::Brown),
"sample" => Ok(Self::Sample),
"websample" => Ok(Self::WebSample),
"live" | "livein" | "mic" => Ok(Self::LiveInput),
"plmodal" | "modal" => Ok(Self::PlModal),
"plva" | "va" | "analog" => Ok(Self::PlVa),
"plws" | "ws" | "waveshape" => Ok(Self::PlWs),
"plfm" | "fm2" => Ok(Self::PlFm),
"plgrain" | "grain" => Ok(Self::PlGrain),
"pladd" | "additive" => Ok(Self::PlAdd),
"plwt" | "wavetable" => Ok(Self::PlWt),
"plchord" | "chord" => Ok(Self::PlChord),
"plswarm" | "swarm" => Ok(Self::PlSwarm),
"plnoise" | "pnoise" => Ok(Self::PlNoise),
"plbass" | "bass" | "kick" => Ok(Self::PlBass),
"plsnare" | "snare" => Ok(Self::PlSnare),
"plhat" | "hat" | "hihat" => Ok(Self::PlHat),
_ => Err(()),
}
}
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum FilterSlope {
#[default]
Db12,
Db24,
Db48,
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum LfoShape {
#[default]
Sine,
Tri,
Saw,
Square,
Sh,
}
impl FromStr for LfoShape {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"sine" | "sin" => Ok(Self::Sine),
"tri" | "triangle" => Ok(Self::Tri),
"saw" | "sawtooth" => Ok(Self::Saw),
"square" | "sq" => Ok(Self::Square),
"sh" | "sah" | "random" => Ok(Self::Sh),
_ => Err(()),
}
}
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum DelayType {
#[default]
Standard,
PingPong,
Tape,
Multitap,
}
impl FromStr for DelayType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"standard" | "std" | "0" => Ok(Self::Standard),
"pingpong" | "pp" | "1" => Ok(Self::PingPong),
"tape" | "2" => Ok(Self::Tape),
"multitap" | "multi" | "3" => Ok(Self::Multitap),
_ => Err(()),
}
}
}
impl FromStr for FilterSlope {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"12db" | "0" => Ok(Self::Db12),
"24db" | "1" => Ok(Self::Db24),
"48db" | "2" => Ok(Self::Db48),
_ => Err(()),
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum FilterType {
Lowpass,
Highpass,
Bandpass,
Notch,
Allpass,
Peaking,
Lowshelf,
Highshelf,
}
pub fn midi2freq(note: f32) -> f32 {
2.0_f32.powf((note - 69.0) / 12.0) * 440.0
}
pub fn freq2midi(freq: f32) -> f32 {
let safe_freq = freq.max(0.001);
69.0 + 12.0 * (safe_freq / 440.0).log2()
}

462
src/voice/mod.rs Normal file
View File

@@ -0,0 +1,462 @@
//! Voice - the core synthesis unit.
mod params;
mod source;
pub use params::VoiceParams;
use std::f32::consts::PI;
use crate::effects::{crush, distort, fold, wrap, Chorus, Coarse, Flanger, Lag, Phaser};
use crate::envelope::Adsr;
use crate::fastmath::{cosf, exp2f, sinf};
use crate::filter::FilterState;
use crate::noise::{BrownNoise, PinkNoise};
use crate::oscillator::Phasor;
use crate::plaits::PlaitsEngine;
use crate::sample::{FileSource, SampleInfo, WebSampleSource};
use crate::types::{FilterSlope, FilterType, BLOCK_SIZE, CHANNELS};
fn apply_filter(
signal: f32,
filter: &mut FilterState,
ftype: FilterType,
q: f32,
num_stages: usize,
sr: f32,
) -> f32 {
let mut out = signal;
for stage in 0..num_stages {
out = filter.biquads[stage].process(out, ftype, filter.cutoff, q, sr);
}
out
}
pub struct Voice {
pub params: VoiceParams,
pub phasor: Phasor,
pub spread_phasors: [Phasor; 7],
pub adsr: Adsr,
pub lp_adsr: Adsr,
pub hp_adsr: Adsr,
pub bp_adsr: Adsr,
pub lp: FilterState,
pub hp: FilterState,
pub bp: FilterState,
// Modulation
pub pitch_adsr: Adsr,
pub fm_adsr: Adsr,
pub vib_lfo: Phasor,
pub fm_phasor: Phasor,
pub am_lfo: Phasor,
pub rm_lfo: Phasor,
pub glide_lag: Lag,
pub current_freq: f32,
// Noise
pub pink_noise: PinkNoise,
pub brown_noise: BrownNoise,
// Sample playback (native)
pub file_source: Option<FileSource>,
// Sample playback (web)
pub web_sample: Option<WebSampleSource>,
// Effects
pub phaser: Phaser,
pub flanger: Flanger,
pub chorus: Chorus,
pub coarse: Coarse,
pub time: f32,
pub ch: [f32; CHANNELS],
pub spread_side: f32,
pub sr: f32,
pub lag_unit: f32,
pub(super) seed: u32,
// Plaits engines
pub(super) plaits_engine: Option<PlaitsEngine>,
pub(super) plaits_out: [f32; BLOCK_SIZE],
pub(super) plaits_aux: [f32; BLOCK_SIZE],
pub(super) plaits_idx: usize,
pub(super) plaits_prev_gate: bool,
}
impl Default for Voice {
fn default() -> Self {
let sr = 44100.0;
Self {
params: VoiceParams::default(),
phasor: Phasor::default(),
spread_phasors: std::array::from_fn(|i| {
let mut p = Phasor::default();
p.phase = i as f32 / 7.0;
p
}),
adsr: Adsr::default(),
lp_adsr: Adsr::default(),
hp_adsr: Adsr::default(),
bp_adsr: Adsr::default(),
lp: FilterState::default(),
hp: FilterState::default(),
bp: FilterState::default(),
pitch_adsr: Adsr::default(),
fm_adsr: Adsr::default(),
vib_lfo: Phasor::default(),
fm_phasor: Phasor::default(),
am_lfo: Phasor::default(),
rm_lfo: Phasor::default(),
glide_lag: Lag::default(),
current_freq: 330.0,
pink_noise: PinkNoise::default(),
brown_noise: BrownNoise::default(),
file_source: None,
web_sample: None,
phaser: Phaser::default(),
flanger: Flanger::default(),
chorus: Chorus::default(),
coarse: Coarse::default(),
time: 0.0,
ch: [0.0; CHANNELS],
spread_side: 0.0,
sr,
lag_unit: sr / 10.0,
seed: 123456789,
plaits_engine: None,
plaits_out: [0.0; BLOCK_SIZE],
plaits_aux: [0.0; BLOCK_SIZE],
plaits_idx: BLOCK_SIZE,
plaits_prev_gate: false,
}
}
}
impl Clone for Voice {
fn clone(&self) -> Self {
Self {
params: self.params,
phasor: self.phasor,
spread_phasors: self.spread_phasors,
adsr: self.adsr,
lp_adsr: self.lp_adsr,
hp_adsr: self.hp_adsr,
bp_adsr: self.bp_adsr,
lp: self.lp,
hp: self.hp,
bp: self.bp,
pitch_adsr: self.pitch_adsr,
fm_adsr: self.fm_adsr,
vib_lfo: self.vib_lfo,
fm_phasor: self.fm_phasor,
am_lfo: self.am_lfo,
rm_lfo: self.rm_lfo,
glide_lag: self.glide_lag,
current_freq: self.current_freq,
pink_noise: self.pink_noise,
brown_noise: self.brown_noise,
file_source: self.file_source,
web_sample: self.web_sample,
phaser: self.phaser,
flanger: self.flanger,
chorus: self.chorus,
coarse: self.coarse,
time: self.time,
ch: self.ch,
spread_side: self.spread_side,
sr: self.sr,
lag_unit: self.lag_unit,
seed: self.seed,
plaits_engine: None,
plaits_out: [0.0; BLOCK_SIZE],
plaits_aux: [0.0; BLOCK_SIZE],
plaits_idx: BLOCK_SIZE,
plaits_prev_gate: false,
}
}
}
impl Voice {
pub(super) fn rand(&mut self) -> f32 {
self.seed = self.seed.wrapping_mul(1103515245).wrapping_add(12345);
((self.seed >> 16) & 0x7fff) as f32 / 32767.0
}
pub(super) fn white(&mut self) -> f32 {
self.rand() * 2.0 - 1.0
}
fn compute_freq(&mut self, isr: f32) -> f32 {
let mut freq = self.params.freq;
// Detune (cents offset)
if self.params.detune != 0.0 {
freq *= exp2f(self.params.detune / 1200.0);
}
// Speed multiplier
freq *= self.params.speed;
// Glide
if let Some(glide_time) = self.params.glide {
freq = self.glide_lag.update(freq, glide_time, self.lag_unit);
}
// FM synthesis
if self.params.fm > 0.0 {
let mut fm_amount = self.params.fm;
if self.params.fm_env_active {
let env = self.fm_adsr.update(
self.time,
self.params.gate,
self.params.fma,
self.params.fmd,
self.params.fms,
self.params.fmr,
);
fm_amount = self.params.fme * env * fm_amount + fm_amount;
}
let mod_freq = freq * self.params.fmh;
let mod_gain = mod_freq * fm_amount;
let modulator = self.fm_phasor.lfo(self.params.fmshape, mod_freq, isr);
freq += modulator * mod_gain;
}
// Pitch envelope
if self.params.pitch_env_active && self.params.penv != 0.0 {
let env = self.pitch_adsr.update(
self.time,
1.0,
self.params.patt,
self.params.pdec,
self.params.psus,
self.params.prel,
);
let env_adj = if self.params.psus == 1.0 {
env - 1.0
} else {
env
};
freq *= exp2f(env_adj * self.params.penv / 12.0);
}
// Vibrato
if self.params.vib > 0.0 && self.params.vibmod > 0.0 {
let mod_val = self.vib_lfo.lfo(self.params.vibshape, self.params.vib, isr);
freq *= exp2f(mod_val * self.params.vibmod / 12.0);
}
self.current_freq = freq;
freq
}
fn num_stages(&self) -> usize {
match self.params.ftype {
FilterSlope::Db12 => 1,
FilterSlope::Db24 => 2,
FilterSlope::Db48 => 4,
}
}
pub fn process(
&mut self,
isr: f32,
pool: &[f32],
samples: &[SampleInfo],
web_pcm: &[f32],
sample_idx: usize,
live_input: &[f32],
) -> bool {
let env = self.adsr.update(
self.time,
self.params.gate,
self.params.attack,
self.params.decay,
self.params.sustain,
self.params.release,
);
if self.adsr.is_off() {
return false;
}
let freq = self.compute_freq(isr);
if !self.run_source(freq, isr, pool, samples, web_pcm, sample_idx, live_input) {
return false;
}
// Update filter envelopes
if let Some(lpf) = self.params.lpf {
self.lp.cutoff = lpf;
if self.params.lp_env_active {
let lp_env = self.lp_adsr.update(
self.time,
self.params.gate,
self.params.lpa,
self.params.lpd,
self.params.lps,
self.params.lpr,
);
self.lp.cutoff = self.params.lpe * lp_env * lpf + lpf;
}
}
if let Some(hpf) = self.params.hpf {
self.hp.cutoff = hpf;
if self.params.hp_env_active {
let hp_env = self.hp_adsr.update(
self.time,
self.params.gate,
self.params.hpa,
self.params.hpd,
self.params.hps,
self.params.hpr,
);
self.hp.cutoff = self.params.hpe * hp_env * hpf + hpf;
}
}
if let Some(bpf) = self.params.bpf {
self.bp.cutoff = bpf;
if self.params.bp_env_active {
let bp_env = self.bp_adsr.update(
self.time,
self.params.gate,
self.params.bpa,
self.params.bpd,
self.params.bps,
self.params.bpr,
);
self.bp.cutoff = self.params.bpe * bp_env * bpf + bpf;
}
}
// Pre-filter gain
self.ch[0] *= self.params.gain * self.params.velocity;
// Apply filters (LP -> HP -> BP)
let num_stages = self.num_stages();
if self.params.lpf.is_some() {
self.ch[0] = apply_filter(
self.ch[0],
&mut self.lp,
FilterType::Lowpass,
self.params.lpq,
num_stages,
self.sr,
);
}
if self.params.hpf.is_some() {
self.ch[0] = apply_filter(
self.ch[0],
&mut self.hp,
FilterType::Highpass,
self.params.hpq,
num_stages,
self.sr,
);
}
if self.params.bpf.is_some() {
self.ch[0] = apply_filter(
self.ch[0],
&mut self.bp,
FilterType::Bandpass,
self.params.bpq,
num_stages,
self.sr,
);
}
// Distortion effects
if let Some(coarse_factor) = self.params.coarse {
self.ch[0] = self.coarse.process(self.ch[0], coarse_factor);
}
if let Some(crush_bits) = self.params.crush {
self.ch[0] = crush(self.ch[0], crush_bits);
}
if let Some(fold_amount) = self.params.fold {
self.ch[0] = fold(self.ch[0], fold_amount);
}
if let Some(wrap_amount) = self.params.wrap {
self.ch[0] = wrap(self.ch[0], wrap_amount);
}
if let Some(dist_amount) = self.params.distort {
self.ch[0] = distort(self.ch[0], dist_amount, self.params.distortvol);
}
// AM modulation
if self.params.am > 0.0 {
let modulator = self.am_lfo.lfo(self.params.amshape, self.params.am, isr);
let depth = self.params.amdepth.clamp(0.0, 1.0);
self.ch[0] *= 1.0 + modulator * depth;
}
// Ring modulation
if self.params.rm > 0.0 {
let modulator = self.rm_lfo.lfo(self.params.rmshape, self.params.rm, isr);
let depth = self.params.rmdepth.clamp(0.0, 1.0);
self.ch[0] *= (1.0 - depth) + modulator * depth;
}
// Phaser
if self.params.phaser > 0.0 {
self.ch[0] = self.phaser.process(
self.ch[0],
self.params.phaser,
self.params.phaserdepth,
self.params.phasercenter,
self.params.phasersweep,
self.sr,
isr,
);
}
// Flanger
if self.params.flanger > 0.0 {
self.ch[0] = self.flanger.process(
self.ch[0],
self.params.flanger,
self.params.flangerdepth,
self.params.flangerfeedback,
self.sr,
isr,
);
}
// Apply gain envelope and postgain
self.ch[0] *= env * self.params.postgain;
// Restore stereo for spread mode
if self.params.spread > 0.0 {
let side = self.spread_side * env * self.params.postgain;
self.ch[1] = self.ch[0] - side;
self.ch[0] += side;
} else {
self.ch[1] = self.ch[0];
}
// Chorus
if self.params.chorus > 0.0 {
let stereo = self.chorus.process(
self.ch[0],
self.ch[1],
self.params.chorus,
self.params.chorusdepth,
self.params.chorusdelay,
self.sr,
isr,
);
self.ch[0] = stereo[0];
self.ch[1] = stereo[1];
}
// Panning
if self.params.pan != 0.5 {
let pan_pos = self.params.pan * PI / 2.0;
self.ch[0] *= cosf(pan_pos);
self.ch[1] *= sinf(pan_pos);
}
self.time += isr;
if let Some(dur) = self.params.duration {
if dur > 0.0 && self.time > dur {
self.params.gate = 0.0;
}
}
true
}
}

405
src/voice/params.rs Normal file
View File

@@ -0,0 +1,405 @@
//! Voice parameters - pure data structure for synthesis configuration.
//!
//! This module contains [`VoiceParams`], which holds all parameters that control
//! a single voice's sound. Parameters are grouped by function:
//!
//! - **Core** - frequency, gain, panning, gate
//! - **Oscillator** - sound source, pulse width, spread, waveshaping
//! - **Amplitude Envelope** - ADSR for volume
//! - **Filters** - lowpass, highpass, bandpass with optional envelopes
//! - **Pitch Modulation** - glide, pitch envelope, vibrato, FM
//! - **Amplitude Modulation** - AM, ring modulation
//! - **Effects** - phaser, flanger, chorus, distortion
//! - **Routing** - orbit assignment, effect sends
use crate::oscillator::PhaseShape;
use crate::types::{DelayType, FilterSlope, LfoShape, Source};
/// All parameters that control a voice's sound generation.
///
/// This is a pure data structure with no methods beyond [`Default`].
/// The actual signal processing happens in [`Voice`](super::Voice).
#[derive(Clone, Copy)]
pub struct VoiceParams {
// ─────────────────────────────────────────────────────────────────────
// Core
// ─────────────────────────────────────────────────────────────────────
/// Base frequency in Hz.
pub freq: f32,
/// Pitch offset in cents (1/100th of a semitone).
pub detune: f32,
/// Playback speed multiplier (also affects pitch for samples).
pub speed: f32,
/// Pre-filter gain (0.0 to 1.0+).
pub gain: f32,
/// MIDI velocity (0.0 to 1.0), multiplied with gain.
pub velocity: f32,
/// Post-envelope gain (0.0 to 1.0+).
pub postgain: f32,
/// Stereo pan position (0.0 = left, 0.5 = center, 1.0 = right).
pub pan: f32,
/// Gate signal (> 0.0 = note on, 0.0 = note off).
pub gate: f32,
/// Optional note duration in seconds. Voice releases when exceeded.
pub duration: Option<f32>,
// ─────────────────────────────────────────────────────────────────────
// Oscillator
// ─────────────────────────────────────────────────────────────────────
/// Sound source type (oscillator waveform, sample, or Plaits engine).
pub sound: Source,
/// Pulse width for pulse/square waves (0.0 to 1.0).
pub pw: f32,
/// Unison spread amount in cents. Enables 7-voice supersaw when > 0.
pub spread: f32,
/// Phase shaping parameters for waveform modification.
pub shape: PhaseShape,
/// Harmonics control for Plaits engines (0.0 to 1.0).
pub harmonics: f32,
/// Timbre control for Plaits engines (0.0 to 1.0).
pub timbre: f32,
/// Morph control for Plaits engines (0.0 to 1.0).
pub morph: f32,
/// Sample slice/cut index for sample playback.
pub cut: Option<usize>,
// ─────────────────────────────────────────────────────────────────────
// Amplitude Envelope (ADSR)
// ─────────────────────────────────────────────────────────────────────
/// Attack time in seconds.
pub attack: f32,
/// Decay time in seconds.
pub decay: f32,
/// Sustain level (0.0 to 1.0).
pub sustain: f32,
/// Release time in seconds.
pub release: f32,
// ─────────────────────────────────────────────────────────────────────
// Lowpass Filter
// ─────────────────────────────────────────────────────────────────────
/// Lowpass cutoff frequency in Hz. `None` = filter bypassed.
pub lpf: Option<f32>,
/// Lowpass resonance/Q (0.0 to 1.0).
pub lpq: f32,
/// Lowpass envelope depth multiplier.
pub lpe: f32,
/// Lowpass envelope attack time.
pub lpa: f32,
/// Lowpass envelope decay time.
pub lpd: f32,
/// Lowpass envelope sustain level.
pub lps: f32,
/// Lowpass envelope release time.
pub lpr: f32,
/// Enable lowpass filter envelope modulation.
pub lp_env_active: bool,
// ─────────────────────────────────────────────────────────────────────
// Highpass Filter
// ─────────────────────────────────────────────────────────────────────
/// Highpass cutoff frequency in Hz. `None` = filter bypassed.
pub hpf: Option<f32>,
/// Highpass resonance/Q (0.0 to 1.0).
pub hpq: f32,
/// Highpass envelope depth multiplier.
pub hpe: f32,
/// Highpass envelope attack time.
pub hpa: f32,
/// Highpass envelope decay time.
pub hpd: f32,
/// Highpass envelope sustain level.
pub hps: f32,
/// Highpass envelope release time.
pub hpr: f32,
/// Enable highpass filter envelope modulation.
pub hp_env_active: bool,
// ─────────────────────────────────────────────────────────────────────
// Bandpass Filter
// ─────────────────────────────────────────────────────────────────────
/// Bandpass center frequency in Hz. `None` = filter bypassed.
pub bpf: Option<f32>,
/// Bandpass resonance/Q (0.0 to 1.0).
pub bpq: f32,
/// Bandpass envelope depth multiplier.
pub bpe: f32,
/// Bandpass envelope attack time.
pub bpa: f32,
/// Bandpass envelope decay time.
pub bpd: f32,
/// Bandpass envelope sustain level.
pub bps: f32,
/// Bandpass envelope release time.
pub bpr: f32,
/// Enable bandpass filter envelope modulation.
pub bp_env_active: bool,
// ─────────────────────────────────────────────────────────────────────
// Filter Slope
// ─────────────────────────────────────────────────────────────────────
/// Filter slope (12/24/48 dB per octave) for all filters.
pub ftype: FilterSlope,
// ─────────────────────────────────────────────────────────────────────
// Glide (Portamento)
// ─────────────────────────────────────────────────────────────────────
/// Glide time in seconds. `None` = no glide.
pub glide: Option<f32>,
// ─────────────────────────────────────────────────────────────────────
// Pitch Envelope
// ─────────────────────────────────────────────────────────────────────
/// Pitch envelope depth in semitones.
pub penv: f32,
/// Pitch envelope attack time.
pub patt: f32,
/// Pitch envelope decay time.
pub pdec: f32,
/// Pitch envelope sustain level.
pub psus: f32,
/// Pitch envelope release time.
pub prel: f32,
/// Enable pitch envelope modulation.
pub pitch_env_active: bool,
// ─────────────────────────────────────────────────────────────────────
// Vibrato
// ─────────────────────────────────────────────────────────────────────
/// Vibrato LFO rate in Hz.
pub vib: f32,
/// Vibrato depth in semitones.
pub vibmod: f32,
/// Vibrato LFO waveform.
pub vibshape: LfoShape,
// ─────────────────────────────────────────────────────────────────────
// FM Synthesis
// ─────────────────────────────────────────────────────────────────────
/// FM modulation index (depth).
pub fm: f32,
/// FM harmonic ratio (modulator freq = carrier freq * fmh).
pub fmh: f32,
/// FM modulator waveform.
pub fmshape: LfoShape,
/// FM envelope depth multiplier.
pub fme: f32,
/// FM envelope attack time.
pub fma: f32,
/// FM envelope decay time.
pub fmd: f32,
/// FM envelope sustain level.
pub fms: f32,
/// FM envelope release time.
pub fmr: f32,
/// Enable FM envelope modulation.
pub fm_env_active: bool,
// ─────────────────────────────────────────────────────────────────────
// Amplitude Modulation
// ─────────────────────────────────────────────────────────────────────
/// AM LFO rate in Hz.
pub am: f32,
/// AM depth (0.0 to 1.0).
pub amdepth: f32,
/// AM LFO waveform.
pub amshape: LfoShape,
// ─────────────────────────────────────────────────────────────────────
// Ring Modulation
// ─────────────────────────────────────────────────────────────────────
/// Ring modulator frequency in Hz.
pub rm: f32,
/// Ring modulation depth (0.0 to 1.0).
pub rmdepth: f32,
/// Ring modulator waveform.
pub rmshape: LfoShape,
// ─────────────────────────────────────────────────────────────────────
// Phaser
// ─────────────────────────────────────────────────────────────────────
/// Phaser LFO rate in Hz. 0 = bypassed.
pub phaser: f32,
/// Phaser depth/feedback (0.0 to 1.0).
pub phaserdepth: f32,
/// Phaser sweep range in Hz.
pub phasersweep: f32,
/// Phaser center frequency in Hz.
pub phasercenter: f32,
// ─────────────────────────────────────────────────────────────────────
// Flanger
// ─────────────────────────────────────────────────────────────────────
/// Flanger LFO rate in Hz. 0 = bypassed.
pub flanger: f32,
/// Flanger depth (0.0 to 1.0).
pub flangerdepth: f32,
/// Flanger feedback amount (0.0 to 1.0).
pub flangerfeedback: f32,
// ─────────────────────────────────────────────────────────────────────
// Chorus
// ─────────────────────────────────────────────────────────────────────
/// Chorus LFO rate in Hz. 0 = bypassed.
pub chorus: f32,
/// Chorus depth/modulation amount (0.0 to 1.0).
pub chorusdepth: f32,
/// Chorus base delay time in ms.
pub chorusdelay: f32,
// ─────────────────────────────────────────────────────────────────────
// Distortion
// ─────────────────────────────────────────────────────────────────────
/// Coarse sample rate reduction factor. `None` = bypassed.
pub coarse: Option<f32>,
/// Bit crush depth (bits). `None` = bypassed.
pub crush: Option<f32>,
/// Wavefolding amount. `None` = bypassed.
pub fold: Option<f32>,
/// Wavewrapping amount. `None` = bypassed.
pub wrap: Option<f32>,
/// Distortion/saturation amount. `None` = bypassed.
pub distort: Option<f32>,
/// Distortion output volume compensation.
pub distortvol: f32,
// ─────────────────────────────────────────────────────────────────────
// Routing / Sends
// ─────────────────────────────────────────────────────────────────────
/// Orbit index for effect bus routing (0 to MAX_ORBITS-1).
pub orbit: usize,
/// Delay send level (0.0 to 1.0).
pub delay: f32,
/// Delay time in seconds (overrides orbit default).
pub delaytime: f32,
/// Delay feedback amount (overrides orbit default).
pub delayfeedback: f32,
/// Delay type (overrides orbit default).
pub delaytype: DelayType,
/// Reverb send level (0.0 to 1.0).
pub verb: f32,
/// Reverb decay time (overrides orbit default).
pub verbdecay: f32,
/// Reverb damping (overrides orbit default).
pub verbdamp: f32,
/// Reverb pre-delay in seconds.
pub verbpredelay: f32,
/// Reverb diffusion amount.
pub verbdiff: f32,
/// Comb filter send level (0.0 to 1.0).
pub comb: f32,
/// Comb filter frequency in Hz.
pub combfreq: f32,
/// Comb filter feedback amount.
pub combfeedback: f32,
/// Comb filter damping.
pub combdamp: f32,
}
impl Default for VoiceParams {
fn default() -> Self {
Self {
freq: 330.0,
detune: 0.0,
speed: 1.0,
gain: 1.0,
velocity: 1.0,
postgain: 1.0,
pan: 0.5,
gate: 1.0,
duration: None,
sound: Source::Tri,
pw: 0.5,
spread: 0.0,
shape: PhaseShape::default(),
harmonics: 0.5,
timbre: 0.5,
morph: 0.5,
cut: None,
attack: 0.001,
decay: 0.0,
sustain: 1.0,
release: 0.005,
lpf: None,
lpq: 0.2,
lpe: 1.0,
lpa: 0.001,
lpd: 0.0,
lps: 1.0,
lpr: 0.005,
lp_env_active: false,
hpf: None,
hpq: 0.2,
hpe: 1.0,
hpa: 0.001,
hpd: 0.0,
hps: 1.0,
hpr: 0.005,
hp_env_active: false,
bpf: None,
bpq: 0.2,
bpe: 1.0,
bpa: 0.001,
bpd: 0.0,
bps: 1.0,
bpr: 0.005,
bp_env_active: false,
ftype: FilterSlope::Db12,
glide: None,
penv: 1.0,
patt: 0.001,
pdec: 0.0,
psus: 1.0,
prel: 0.005,
pitch_env_active: false,
vib: 0.0,
vibmod: 0.5,
vibshape: LfoShape::Sine,
fm: 0.0,
fmh: 1.0,
fmshape: LfoShape::Sine,
fme: 1.0,
fma: 0.001,
fmd: 0.0,
fms: 1.0,
fmr: 0.005,
fm_env_active: false,
am: 0.0,
amdepth: 0.5,
amshape: LfoShape::Sine,
rm: 0.0,
rmdepth: 1.0,
rmshape: LfoShape::Sine,
phaser: 0.0,
phaserdepth: 0.75,
phasersweep: 2000.0,
phasercenter: 1000.0,
flanger: 0.0,
flangerdepth: 0.5,
flangerfeedback: 0.5,
chorus: 0.0,
chorusdepth: 0.5,
chorusdelay: 25.0,
coarse: None,
crush: None,
fold: None,
wrap: None,
distort: None,
distortvol: 1.0,
orbit: 0,
delay: 0.0,
delaytime: 0.333,
delayfeedback: 0.6,
delaytype: DelayType::Standard,
verb: 0.0,
verbdecay: 0.75,
verbdamp: 0.95,
verbpredelay: 0.1,
verbdiff: 0.7,
comb: 0.0,
combfreq: 220.0,
combfeedback: 0.9,
combdamp: 0.1,
}
}
}

213
src/voice/source.rs Normal file
View File

@@ -0,0 +1,213 @@
//! Source generation - oscillators, samples, Plaits engines, spread mode.
use crate::fastmath::exp2f;
use crate::oscillator::Phasor;
use crate::plaits::PlaitsEngine;
use crate::sample::SampleInfo;
use crate::types::{freq2midi, Source, BLOCK_SIZE, CHANNELS};
use mi_plaits_dsp::engine::{EngineParameters, TriggerState};
use super::Voice;
impl Voice {
#[inline]
pub(super) fn osc_at(&self, phasor: &Phasor, phase: f32) -> f32 {
match self.params.sound {
Source::Tri => phasor.tri_at(phase, &self.params.shape),
Source::Sine => phasor.sine_at(phase, &self.params.shape),
Source::Saw => phasor.saw_at(phase, &self.params.shape),
Source::Zaw => phasor.zaw_at(phase, &self.params.shape),
Source::Pulse => phasor.pulse_at(phase, self.params.pw, &self.params.shape),
Source::Pulze => phasor.pulze_at(phase, self.params.pw, &self.params.shape),
_ => 0.0,
}
}
pub(super) fn run_source(
&mut self,
freq: f32,
isr: f32,
pool: &[f32],
samples: &[SampleInfo],
web_pcm: &[f32],
sample_idx: usize,
live_input: &[f32],
) -> bool {
match self.params.sound {
Source::Sample => {
if let Some(ref mut fs) = self.file_source {
if let Some(info) = samples.get(fs.sample_idx) {
if fs.is_done(info) {
return false;
}
for c in 0..CHANNELS {
self.ch[c] = fs.update(pool, info, self.params.speed, c) * 0.2;
}
return true;
}
}
self.ch[0] = 0.0;
self.ch[1] = 0.0;
}
Source::WebSample => {
if let Some(ref mut ws) = self.web_sample {
if ws.is_done() {
return false;
}
for c in 0..CHANNELS {
self.ch[c] = ws.update(web_pcm, self.params.speed, c) * 0.2;
}
return true;
}
self.ch[0] = 0.0;
self.ch[1] = 0.0;
}
Source::LiveInput => {
let input_idx = sample_idx * CHANNELS;
for c in 0..CHANNELS {
let idx = input_idx + c;
self.ch[c] = live_input.get(idx).copied().unwrap_or(0.0) * 0.2;
}
}
Source::PlModal
| Source::PlVa
| Source::PlWs
| Source::PlFm
| Source::PlGrain
| Source::PlAdd
| Source::PlWt
| Source::PlChord
| Source::PlSwarm
| Source::PlNoise
| Source::PlBass
| Source::PlSnare
| Source::PlHat => {
if self.plaits_idx >= BLOCK_SIZE {
let need_new = self
.plaits_engine
.as_ref()
.is_none_or(|e| e.source() != self.params.sound);
if need_new {
let sample_rate = 1.0 / isr;
self.plaits_engine = Some(PlaitsEngine::new(self.params.sound, sample_rate));
}
let engine = self.plaits_engine.as_mut().unwrap();
let trigger = if self.params.sound.is_plaits_percussion() {
TriggerState::Unpatched
} else {
let gate_high = self.params.gate > 0.5;
let t = if gate_high && !self.plaits_prev_gate {
TriggerState::RisingEdge
} else if gate_high {
TriggerState::High
} else {
TriggerState::Low
};
self.plaits_prev_gate = gate_high;
t
};
let params = EngineParameters {
trigger,
note: freq2midi(freq),
timbre: self.params.timbre,
morph: self.params.morph,
harmonics: self.params.harmonics,
accent: self.params.velocity,
a0_normalized: 55.0 * isr,
};
let mut already_enveloped = false;
engine.render(
&params,
&mut self.plaits_out,
&mut self.plaits_aux,
&mut already_enveloped,
);
self.plaits_idx = 0;
}
self.ch[0] = self.plaits_out[self.plaits_idx] * 0.2;
self.ch[1] = self.ch[0];
self.plaits_idx += 1;
}
_ => {
let spread = self.params.spread;
if spread > 0.0 {
self.run_spread(freq, isr);
} else {
self.run_single_osc(freq, isr);
}
}
}
true
}
fn run_spread(&mut self, freq: f32, isr: f32) {
let mut left = 0.0;
let mut right = 0.0;
const PAN: [f32; 3] = [0.3, 0.6, 0.9];
// Center oscillator
let phase_c = self.spread_phasors[3].phase;
let center = self.osc_at(&self.spread_phasors[3], phase_c);
self.spread_phasors[3].phase = (phase_c + freq * isr) % 1.0;
left += center;
right += center;
// Symmetric pairs with parabolic detuning + stereo spread
for i in 1..=3 {
let detune_cents = (i * i) as f32 * self.params.spread;
let ratio_up = exp2f(detune_cents / 1200.0);
let ratio_down = exp2f(-detune_cents / 1200.0);
let phase_up = self.spread_phasors[3 + i].phase;
let voice_up = self.osc_at(&self.spread_phasors[3 + i], phase_up);
self.spread_phasors[3 + i].phase = (phase_up + freq * ratio_up * isr) % 1.0;
let phase_down = self.spread_phasors[3 - i].phase;
let voice_down = self.osc_at(&self.spread_phasors[3 - i], phase_down);
self.spread_phasors[3 - i].phase = (phase_down + freq * ratio_down * isr) % 1.0;
let pan = PAN[i - 1];
left += voice_down * (0.5 + pan * 0.5) + voice_up * (0.5 - pan * 0.5);
right += voice_up * (0.5 + pan * 0.5) + voice_down * (0.5 - pan * 0.5);
}
// Store as mid/side - effects process mid, stereo restored later
let mid = (left + right) / 2.0;
let side = (left - right) / 2.0;
self.ch[0] = mid / 4.0 * 0.2;
self.spread_side = side / 4.0 * 0.2;
}
fn run_single_osc(&mut self, freq: f32, isr: f32) {
self.ch[0] = match self.params.sound {
Source::Tri => self.phasor.tri_shaped(freq, isr, &self.params.shape) * 0.2,
Source::Sine => self.phasor.sine_shaped(freq, isr, &self.params.shape) * 0.2,
Source::Saw => self.phasor.saw_shaped(freq, isr, &self.params.shape) * 0.2,
Source::Zaw => self.phasor.zaw_shaped(freq, isr, &self.params.shape) * 0.2,
Source::Pulse => {
self.phasor
.pulse_shaped(freq, self.params.pw, isr, &self.params.shape)
* 0.2
}
Source::Pulze => {
self.phasor
.pulze_shaped(freq, self.params.pw, isr, &self.params.shape)
* 0.2
}
Source::White => self.white() * 0.2,
Source::Pink => {
let w = self.white();
self.pink_noise.next(w) * 0.2
}
Source::Brown => {
let w = self.white();
self.brown_noise.next(w) * 0.2
}
_ => 0.0,
};
}
}

385
src/wasm.rs Normal file
View File

@@ -0,0 +1,385 @@
//! WebAssembly FFI bindings for browser-based audio.
//!
//! Exposes the doux engine to JavaScript via a C-compatible interface. The host
//! (browser) and WASM module communicate through shared memory buffers.
//!
//! # Memory Layout
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────────┐
//! │ Static Buffers (shared with JS via pointers) │
//! ├─────────────────┬───────────────────────────────────────────────────┤
//! │ OUTPUT │ Audio output buffer (BLOCK_SIZE × CHANNELS f32) │
//! │ INPUT_BUFFER │ Live audio input (BLOCK_SIZE × CHANNELS f32) │
//! │ EVENT_INPUT │ Command strings from JS (1024 bytes, null-term) │
//! │ SAMPLE_BUFFER │ Staging area for sample uploads (16MB of f32) │
//! │ FRAMEBUFFER │ Ring buffer for waveform visualization │
//! └─────────────────┴───────────────────────────────────────────────────┘
//! ```
//!
//! # Typical Usage Flow
//!
//! ```text
//! JS WASM
//! ── ────
//! 1. doux_init(sampleRate) → Create engine
//! 2. get_*_pointer() → Get buffer addresses
//! 3. Write command to EVENT_INPUT
//! 4. evaluate() → Parse & execute command
//! 5. [Optional] Write samples to SAMPLE_BUFFER
//! 6. load_sample(len, ch, freq) → Add to pool
//! 7. [Optional] Write mic input to INPUT_BUFFER
//! 8. dsp() → Process one block
//! 9. Read OUTPUT ← Get audio samples
//! 10. Repeat 3-9 in audio callback
//! ```
//!
//! # Audio Worklet Integration
//!
//! In the browser, this typically runs in an AudioWorkletProcessor:
//! - `dsp()` is called each audio quantum (~128 samples)
//! - Output buffer is copied to the worklet's output
//! - Input buffer receives microphone data for live processing
#![allow(static_mut_refs)]
use crate::types::{Source, BLOCK_SIZE, CHANNELS};
use crate::Engine;
/// Maximum length of command strings from JavaScript.
const EVENT_INPUT_SIZE: usize = 1024;
/// Ring buffer size for waveform visualization (~60fps at 48kHz stereo).
/// Calculation: floor(48000/60) × 2 channels × 4 (double-buffer headroom) = 6400
const FRAMEBUFFER_SIZE: usize = 6400;
// Global engine instance (single-threaded WASM environment)
static mut ENGINE: Option<Engine> = None;
// Shared memory buffers accessible from JavaScript
static mut OUTPUT: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS];
static mut EVENT_INPUT: [u8; EVENT_INPUT_SIZE] = [0; EVENT_INPUT_SIZE];
static mut FRAMEBUFFER: [f32; FRAMEBUFFER_SIZE] = [0.0; FRAMEBUFFER_SIZE];
static mut FRAME_IDX: i32 = 0;
/// Sample upload staging buffer (16MB = 4M floats).
/// JS decodes audio files and writes f32 samples here before calling `load_sample`.
const SAMPLE_BUFFER_SIZE: usize = 4_194_304;
static mut SAMPLE_BUFFER: [f32; SAMPLE_BUFFER_SIZE] = [0.0; SAMPLE_BUFFER_SIZE];
/// Live audio input buffer (microphone/line-in from Web Audio).
static mut INPUT_BUFFER: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS];
// =============================================================================
// Lifecycle
// =============================================================================
/// Initializes the audio engine at the given sample rate.
///
/// Must be called once before any other functions.
#[no_mangle]
pub extern "C" fn doux_init(sample_rate: f32) {
unsafe {
ENGINE = Some(Engine::new(sample_rate));
}
}
// =============================================================================
// Audio Processing
// =============================================================================
/// Processes one block of audio and updates the framebuffer.
///
/// Call this from the AudioWorklet's `process()` method. Reads from
/// `INPUT_BUFFER`, writes to `OUTPUT`, and appends to `FRAMEBUFFER`.
#[no_mangle]
pub extern "C" fn dsp() {
unsafe {
if let Some(ref mut engine) = ENGINE {
engine.process_block(&mut OUTPUT, &SAMPLE_BUFFER, &INPUT_BUFFER);
// Copy to ring buffer for visualization
let fb_len = FRAMEBUFFER.len() as i32;
for (i, &sample) in OUTPUT.iter().enumerate() {
let idx = (FRAME_IDX + i as i32) % fb_len;
FRAMEBUFFER[idx as usize] = sample;
}
FRAME_IDX = (FRAME_IDX + (BLOCK_SIZE * CHANNELS) as i32) % fb_len;
}
}
}
// =============================================================================
// Command Interface
// =============================================================================
/// Parses and executes the command string in `EVENT_INPUT`.
///
/// The command should be written as a null-terminated UTF-8 string to the
/// buffer returned by `get_event_input_pointer()`.
///
/// # Returns
///
/// - Sample index if the command triggered a sample load
/// - `-1` on error or for commands that don't return a value
#[no_mangle]
pub extern "C" fn evaluate() -> i32 {
unsafe {
if let Some(ref mut engine) = ENGINE {
let len = EVENT_INPUT
.iter()
.position(|&b| b == 0)
.unwrap_or(EVENT_INPUT_SIZE);
if len == 0 {
return -1;
}
if let Ok(s) = core::str::from_utf8(&EVENT_INPUT[..len]) {
let result = engine.evaluate(s).map(|i| i as i32).unwrap_or(-1);
EVENT_INPUT[0] = 0; // Clear for next command
return result;
}
}
-1
}
}
// =============================================================================
// Buffer Pointers (for JS interop)
// =============================================================================
/// Returns pointer to the audio output buffer.
#[no_mangle]
pub extern "C" fn get_output_pointer() -> *const f32 {
unsafe { OUTPUT.as_ptr() }
}
/// Returns the length of the output buffer in samples.
#[no_mangle]
pub extern "C" fn get_output_len() -> usize {
BLOCK_SIZE * CHANNELS
}
/// Returns mutable pointer to the event input buffer.
///
/// Write null-terminated UTF-8 command strings here, then call `evaluate()`.
#[no_mangle]
pub extern "C" fn get_event_input_pointer() -> *mut u8 {
unsafe { EVENT_INPUT.as_mut_ptr() }
}
/// Returns mutable pointer to the sample upload staging buffer.
///
/// Write decoded f32 samples here, then call `load_sample()`.
#[no_mangle]
pub extern "C" fn get_sample_buffer_pointer() -> *mut f32 {
unsafe { SAMPLE_BUFFER.as_mut_ptr() }
}
/// Returns the capacity of the sample buffer in floats.
#[no_mangle]
pub extern "C" fn get_sample_buffer_len() -> usize {
SAMPLE_BUFFER_SIZE
}
/// Returns mutable pointer to the live audio input buffer.
///
/// Write microphone/line-in samples here before calling `dsp()`.
#[no_mangle]
pub extern "C" fn get_input_buffer_pointer() -> *mut f32 {
unsafe { INPUT_BUFFER.as_mut_ptr() }
}
/// Returns the length of the input buffer in samples.
#[no_mangle]
pub extern "C" fn get_input_buffer_len() -> usize {
BLOCK_SIZE * CHANNELS
}
/// Returns pointer to the waveform visualization ring buffer.
#[no_mangle]
pub extern "C" fn get_framebuffer_pointer() -> *const f32 {
unsafe { FRAMEBUFFER.as_ptr() }
}
/// Returns pointer to the current frame index in the ring buffer.
#[no_mangle]
pub extern "C" fn get_frame_pointer() -> *const i32 {
unsafe { &FRAME_IDX as *const i32 }
}
// =============================================================================
// Sample Loading
// =============================================================================
/// Loads sample data from the staging buffer into the engine's pool.
///
/// # Parameters
///
/// - `len`: Number of f32 samples in `SAMPLE_BUFFER`
/// - `channels`: Channel count (1 = mono, 2 = stereo)
/// - `freq`: Base frequency for pitch calculations
///
/// # Returns
///
/// Pool index on success, `-1` on failure.
#[no_mangle]
pub extern "C" fn load_sample(len: usize, channels: u8, freq: f32) -> i32 {
unsafe {
if let Some(ref mut engine) = ENGINE {
let samples = &SAMPLE_BUFFER[..len.min(SAMPLE_BUFFER_SIZE)];
match engine.load_sample(samples, channels, freq) {
Some(idx) => idx as i32,
None => -1,
}
} else {
-1
}
}
}
/// Returns the number of samples loaded in the pool.
#[no_mangle]
pub extern "C" fn get_sample_count() -> usize {
unsafe {
if let Some(ref engine) = ENGINE {
engine.samples.len()
} else {
0
}
}
}
// =============================================================================
// Engine State
// =============================================================================
/// Returns the current engine time in seconds.
#[no_mangle]
pub extern "C" fn get_time() -> f64 {
unsafe {
if let Some(ref engine) = ENGINE {
engine.time
} else {
0.0
}
}
}
/// Returns the engine's sample rate.
#[no_mangle]
pub extern "C" fn get_sample_rate() -> f32 {
unsafe {
if let Some(ref engine) = ENGINE {
engine.sr
} else {
0.0
}
}
}
/// Returns the number of currently active voices.
#[no_mangle]
pub extern "C" fn get_active_voices() -> usize {
unsafe {
if let Some(ref engine) = ENGINE {
engine.active_voices
} else {
0
}
}
}
/// Fades out all active voices smoothly.
#[no_mangle]
pub extern "C" fn hush() {
unsafe {
if let Some(ref mut engine) = ENGINE {
engine.hush();
}
}
}
/// Immediately silences all voices (may click).
#[no_mangle]
pub extern "C" fn panic() {
unsafe {
if let Some(ref mut engine) = ENGINE {
engine.panic();
}
}
}
// =============================================================================
// Debug Helpers
// =============================================================================
/// Debug: reads a byte from the event input buffer.
#[no_mangle]
pub extern "C" fn debug_event_input_byte(idx: usize) -> u8 {
unsafe { EVENT_INPUT.get(idx).copied().unwrap_or(255) }
}
/// Debug: returns the source type of a voice as an integer.
///
/// Mapping: Tri=0, Sine=1, Saw=2, ... LiveInput=11, PlModal=12, etc.
/// Returns `-1` if voice index is invalid.
#[no_mangle]
pub extern "C" fn debug_voice_source(voice_idx: usize) -> i32 {
unsafe {
if let Some(ref engine) = ENGINE {
if voice_idx < engine.active_voices {
match engine.voices[voice_idx].params.sound {
Source::Tri => 0,
Source::Sine => 1,
Source::Saw => 2,
Source::Zaw => 3,
Source::Pulse => 4,
Source::Pulze => 5,
Source::White => 6,
Source::Pink => 7,
Source::Brown => 8,
Source::Sample => 9,
Source::WebSample => 10,
Source::LiveInput => 11,
Source::PlModal => 12,
Source::PlVa => 13,
Source::PlWs => 14,
Source::PlFm => 15,
Source::PlGrain => 16,
Source::PlAdd => 17,
Source::PlWt => 18,
Source::PlChord => 19,
Source::PlSwarm => 20,
Source::PlNoise => 21,
Source::PlBass => 22,
Source::PlSnare => 23,
Source::PlHat => 24,
}
} else {
-1
}
} else {
-1
}
}
}
/// Debug: returns 1 if voice has a web sample attached, 0 otherwise.
#[no_mangle]
pub extern "C" fn debug_voice_has_web_sample(voice_idx: usize) -> i32 {
unsafe {
if let Some(ref engine) = ENGINE {
if voice_idx < engine.active_voices {
if engine.voices[voice_idx].web_sample.is_some() {
1
} else {
0
}
} else {
-1
}
} else {
-1
}
}
}

24
website/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "doux-website",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"mdsvex": "^0.12.3",
"svelte": "^5.16.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"type": "module",
"dependencies": {
"coi-serviceworker": "^0.1.7",
"lucide-svelte": "^0.562.0"
}
}

298
website/src/app.css Normal file
View File

@@ -0,0 +1,298 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 13px;
line-height: 1.5;
color: #111;
background: #fff;
}
a {
color: #000;
text-decoration: underline;
}
a:hover {
color: #666;
}
code {
font-family: inherit;
background: #f5f5f5;
padding: 2px 6px;
border: 1px solid #ccc;
}
pre,
input,
textarea {
font-family: inherit;
font-size: 12px;
padding: 8px;
border: 1px solid #ccc;
background: #f5f5f5;
color: #111;
width: 100%;
outline: none;
resize: none;
}
button {
font-family: inherit;
font-size: 12px;
background: #f5f5f5;
color: #111;
border: 1px solid #ccc;
padding: 8px 16px;
cursor: pointer;
}
button:hover {
background: #eee;
}
h1,
h2,
h3 {
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.1em;
}
h1 {
font-size: 14px;
}
h2 {
font-size: 13px;
}
h3 {
font-size: 12px;
margin-top: 1em;
}
nav {
border-bottom: 1px solid #ccc;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
right: 0;
background: #fff;
z-index: 50;
}
nav h1 {
margin: 0;
}
.mic-btn {
padding: 6px 12px;
}
.mic-btn.mic-enabled {
background: #e8f5e8;
border-color: #8c8;
color: #2a5a2a;
}
.mic-btn:disabled {
cursor: wait;
opacity: 0.7;
}
.layout {
display: flex;
min-height: calc(100vh - 57px);
margin-top: 57px;
}
.sidebar {
width: 160px;
border-right: 1px solid #ccc;
padding: 12px 0;
flex-shrink: 0;
position: sticky;
top: 57px;
height: calc(100vh - 57px);
overflow-y: auto;
align-self: flex-start;
}
.sidebar-section {
padding: 8px 16px 8px;
font-size: 12px;
color: #000;
text-transform: uppercase;
letter-spacing: 0.15em;
border-bottom: 1px solid #ddd;
margin-bottom: 4px;
}
.sidebar a {
display: block;
padding: 4px 16px;
text-decoration: none;
color: #666;
}
.sidebar a:hover {
color: #000;
background: #f5f5f5;
}
.content {
flex: 1;
padding: 0 20px 40px;
}
.nav-scope {
flex: 1;
margin: 0 16px;
}
.nav-scope .scope {
height: 32px;
border: 1px solid #ccc;
background: #fff;
}
.scope canvas {
width: 100%;
height: 100%;
display: block;
}
.content section {
max-width: 700px;
margin: 0 auto;
}
.content h2 {
padding: 12px 0;
margin: 0;
}
.intro {
border-bottom: 1px solid #ccc;
padding-bottom: 1em;
margin-bottom: 1em;
}
ul,
ol {
padding-left: 20px;
}
li {
padding: 2px 0;
}
.repl {
display: flex;
gap: 8px;
margin: 1em 0;
}
.repl-editor {
flex: 1;
position: relative;
}
.repl-editor textarea {
display: block;
background: transparent;
color: transparent;
caret-color: #111;
position: relative;
z-index: 1;
}
.hl-pre {
position: absolute;
inset: 0;
margin: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.hl-slash {
color: #999;
}
.hl-command {
color: #000;
font-weight: bold;
}
.hl-number {
color: #c00;
}
.hl-comment {
color: #999;
font-style: italic;
}
.repl-controls {
display: flex;
}
.repl-controls button {
width: 48px;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes eval-flash {
from {
outline: 1px solid #999;
}
to {
outline: 1px solid transparent;
}
}
.evaluated {
animation: eval-flash 0.3s;
}
@media (max-width: 768px) {
.nav-scope {
position: fixed;
bottom: 48px;
left: 0;
right: 0;
margin: 0;
z-index: 100;
}
.nav-scope .scope {
height: 48px;
border: none;
}
.layout {
padding-bottom: 96px;
}
.sidebar {
display: none;
}
}

13
website/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="@sveltejs/kit" />
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

16
website/src/app.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>doux</title>
<meta name="description" content="Audio engine for live coding" />
<meta name="author" content="Raphaël Forment" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☁️</text></svg>" />
<script src="/coi-serviceworker.min.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

37
website/src/content/am.md Normal file
View File

@@ -0,0 +1,37 @@
---
title: "Amplitude Modulation"
slug: "am"
group: "synthesis"
order: 109
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Amplitude modulation multiplies the signal by a modulating oscillator. The formula preserves the original signal at depth 0: <code>signal &#42;= 1.0 + modulator &#42; depth</code>. This creates sidebands at <code>carrier ± modulator</code> frequencies while keeping the carrier present.
<CommandEntry name="am" type="number" min={0} default={0} unit="Hz">
AM oscillator frequency in Hz. When set above 0, an LFO modulates the signal amplitude.
<CodeEditor code={`/freq/300/am/4/amdepth/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="amdepth" type="number" min={0} max={1} default={0.5}>
Modulation depth (0-1). At 0, the signal is unchanged. At 1, the signal varies between 0 and 2x its amplitude.
<CodeEditor code={`/freq/300/am/2/amdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="amshape" type="string" default="sine">
AM LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/freq/300/am/4/amdepth/0.8/amshape/square`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Bandpass Filter"
slug: "bandpass"
group: "effects"
order: 112
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A bandpass filter attenuates frequencies outside a band around the center frequency. Each filter has its own ADSR envelope that modulates the center frequency.
<CommandEntry name="bpf" type="number" min={20} max={20000} unit="Hz">
Center frequency in Hz. Frequencies outside the band are attenuated.
<CodeEditor code={`/sound/saw/bpf/800`} rows={2} />
</CommandEntry>
<CommandEntry name="bpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Higher values narrow the passband.
<CodeEditor code={`/sound/saw/bpf/800/bpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="bpe" type="number" default={0}>
Envelope amount. Positive values sweep the center up, negative values sweep down.
<CodeEditor code={`/sound/saw/bpf/800/bpe/5/bpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="bpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpa/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="bpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpd/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="bps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/bpf/800/bpd/.2/bps/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="bpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/bpf/800/bpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,103 @@
---
title: "Basic"
slug: "basic"
group: "sources"
order: 0
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
These sources provide fundamental waveforms that can be combined and manipulated to create complex sounds. They are inspired by classic substractive synthesizers.
<CommandEntry name="sine" type="source">
Pure sine wave. The simplest waveform with no harmonics.
<CodeEditor code={`/sound/sine`} rows={2} />
<CodeEditor code={`/sound/sine/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="tri" type="source">
Triangle wave. The default source. Contains only odd harmonics with gentle rolloff.
<CodeEditor code={`/sound/tri`} rows={2} />
<CodeEditor code={`/sound/tri/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="saw" type="source">
Band-limited sawtooth wave. Rich in harmonics, bright and buzzy.
<CodeEditor code={`/sound/saw`} rows={2} />
<CodeEditor code={`/sound/saw/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="zaw" type="source">
Naive sawtooth with no anti-aliasing. Cheaper but more aliasing artifacts than saw.
<CodeEditor code={`/sound/zaw`} rows={2} />
<CodeEditor code={`/sound/zaw/note/60`} rows={2} />
</CommandEntry>
<CommandEntry name="pulse" type="source">
Band-limited pulse wave. Hollow sound with only odd harmonics. Use /pw to control pulse width.
<CodeEditor code={`/sound/pulse`} rows={2} />
<CodeEditor code={`/sound/pulse/pw/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="pulze" type="source">
Naive pulse with no anti-aliasing. Cheaper but more aliasing artifacts than pulse.
<CodeEditor code={`/sound/pulze`} rows={2} />
<CodeEditor code={`/sound/pulze/pw/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="white" type="source">
White noise. Equal energy at all frequencies.
<CodeEditor code={`/sound/white`} rows={2} />
<CodeEditor code={`/sound/white/lpf/2000`} rows={2} />
</CommandEntry>
<CommandEntry name="pink" type="source">
Pink noise (1/f). Equal energy per octave, more natural sounding.
<CodeEditor code={`/sound/pink`} rows={2} />
<CodeEditor code={`/sound/pink/lpf/4000`} rows={2} />
</CommandEntry>
<CommandEntry name="brown" type="source">
Brown/red noise (1/f^2). Deep rumbling, heavily weighted toward low frequencies.
<CodeEditor code={`/sound/brown`} rows={2} />
<CodeEditor code={`/sound/brown/hpf/100`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,43 @@
---
title: "Chorus"
slug: "chorus"
group: "effects"
order: 202
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A rich chorus effect that adds depth and movement to any sound.
<CommandEntry name="chorus" type="number" min={0} default={0} unit="Hz">
Chorus LFO rate in Hz.
<CodeEditor code={`/sound/saw/freq/100/chorus/0.1`} rows={2} />
<CodeEditor code={`/sound/saw/freq/100/chorus/0.05/chorusdepth/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="chorusdepth" type="number" min={0} max={1} default={0.5}>
Chorus modulation depth (0-1).
<CodeEditor code={`/sound/saw/freq/200/chorus/0.5/chorusdepth/0.3`} rows={2} />
<CodeEditor code={`/sound/pulse/freq/100/chorus/0.2/chorusdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="chorusdelay" type="number" min={0} default={20} unit="ms">
Chorus base delay time in milliseconds.
<CodeEditor code={`/sound/saw/freq/200/chorus/0.3/chorusdelay/20`} rows={2} />
<CodeEditor code={`/sound/saw/freq/200/chorus/0.3/chorusdelay/30`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,47 @@
---
title: "Comb Filter"
slug: "comb"
group: "effects"
order: 113
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Send effect with feedback comb filter. Creates pitched resonance, metallic timbres, and Karplus-Strong plucked sounds. Tail persists after voice ends.
<CommandEntry name="comb" type="number" min={0} max={1} default={0}>
Send amount to comb filter.
<CodeEditor code={`/sound/white/comb/1/combfreq/110/decay/.5/sustain/0`} rows={2} />
Noise into a tuned comb creates plucked string sounds (Karplus-Strong).
</CommandEntry>
<CommandEntry name="combfreq" type="number" min={20} max={20000} default={220} unit="Hz">
Resonant frequency. All voices share the same orbit comb.
<CodeEditor code={`/sound/saw/comb/0.5/combfreq/880/decay/.5/sustain/0`} rows={2} />
</CommandEntry>
<CommandEntry name="combfeedback" type="number" min={0} max={0.99} default={0.9}>
Feedback amount. Higher values create longer resonance.
<CodeEditor code={`/sound/white/comb/1/combfeedback/0.99/combfreq/220/decay/.5/sustain/0`} rows={2} />
</CommandEntry>
<CommandEntry name="combdamp" type="number" min={0} max={1} default={0.1}>
High-frequency damping. Higher values darken the sound over time.
<CodeEditor code={`/sound/white/comb/1/combfeedback/0.95/combdamp/0.4/combfreq/220/decay/.5/sustain/0`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,58 @@
---
title: "Delay"
slug: "delay"
group: "effects"
order: 203
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Stereo delay line with feedback (max 1 second at 48kHz, clamped to 0.95 feedback).
<CommandEntry name="delay" type="number" min={0} max={1} default={0}>
Send level to the delay bus.
<CodeEditor code={`/delay/.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delayfeedback" type="number" min={0} max={1} default={0.5}>
Feedback amount (clamped to 0.95 max). Output is fed back into input.
<CodeEditor code={`/delay/.5/delayfeedback/.8/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delaytime" type="number" min={0} default={0.25} unit="s">
Delay time in seconds (max ~1s at 48kHz).
<CodeEditor code={`/delay/.5/delaytime/.08/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="delaytype" type="enum" default="standard" values={["standard", "pingpong", "tape", "multitap"]}>
<ul>
<li><strong>standard</strong> — Clean digital. Precise repeats.</li>
<li><strong>pingpong</strong> — Mono in, bounces L→R→L→R.</li>
<li><strong>tape</strong> — Each repeat darker. Analog warmth.</li>
<li><strong>multitap</strong> — 4 taps. Feedback 0=straight, 1=triplet, between=swing.</li>
</ul>
<CodeEditor code={`/sound/saw/delay/.6/dtype/std/delaytime/.15/delayfeedback/.7/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/pp/delaytime/.12/delayfeedback/.8/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.6/dtype/tape/delaytime/.2/delayfeedback/.9/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/multi/delaytime/.3/delayfeedback/0/d/.05`} rows={2} />
<CodeEditor code={`/sound/saw/delay/.7/dtype/multi/delaytime/.3/delayfeedback/1/d/.05`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,56 @@
---
title: "Envelope"
slug: "envelope"
group: "synthesis"
order: 102
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The envelope parameters control the shape of the gain envelope over time. It uses a typical ADSR envelope with exponential curves:
- **Attack**: Ramps from 0 to full amplitude. Uses <code></code> (slow start, fast finish).
- **Decay**: Falls from full amplitude to the sustain level. Uses <code>1-(1-x)²</code> (fast drop, slow finish).
- **Sustain**: Holds at a constant level while the note is held.
- **Release**: Falls from the sustain level to 0 when the note ends. Uses <code>1-(1-x)²</code> (fast drop, slow finish).
<CommandEntry name="attack" type="number" min={0} default={0.001} unit="s">
The duration (seconds) of the attack phase of the gain envelope.
<CodeEditor code={`/attack/.1`} rows={2} />
<CodeEditor code={`/attack/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="decay" type="number" min={0} default={0} unit="s">
The duration (seconds) of the decay phase of the gain envelope.
<CodeEditor code={`/decay/.1`} rows={2} />
<CodeEditor code={`/decay/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="sustain" type="number" min={0} max={1} default={1}>
The sustain level (0-1) of the gain envelope.
<CodeEditor code={`/decay/.1/sustain/.2`} rows={2} />
<CodeEditor code={`/decay/.1/sustain/.6`} rows={2} />
</CommandEntry>
<CommandEntry name="release" type="number" min={0} default={0.005} unit="s">
The duration (seconds) of the release phase of the gain envelope.
<CodeEditor code={`/duration/.25/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,43 @@
---
title: "Flanger"
slug: "flanger"
group: "effects"
order: 201
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
LFO-modulated delay (0.5-10ms) with feedback and linear interpolation. Output is 50% dry, 50% wet.
<CommandEntry name="flanger" type="number" min={0} default={0} unit="Hz">
Flanger LFO rate in Hz. Creates sweeping comb filter effect with short delay modulation.
<CodeEditor code={`/sound/saw/freq/100/flanger/0.5`} rows={2} />
<CodeEditor code={`/sound/tri/freq/200/flanger/2/flangerdepth/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="flangerdepth" type="number" min={0} max={1} default={0.5}>
Flanger modulation depth (0-1). Controls delay time sweep range.
<CodeEditor code={`/sound/saw/freq/100/flanger/1/flangerdepth/0.3`} rows={2} />
<CodeEditor code={`/sound/pulse/freq/80/flanger/0.5/flangerdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="flangerfeedback" type="number" min={0} max={0.95} default={0}>
Flanger feedback amount (0-0.95).
<CodeEditor code={`/sound/saw/freq/100/flanger/1/flangerfeedback/0.7`} rows={2} />
<CodeEditor code={`/sound/tri/freq/150/flanger/0.3/flangerdepth/0.5/flangerfeedback/0.9`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,83 @@
---
title: "Frequency Modulation"
slug: "frequency-modulation"
group: "synthesis"
order: 108
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Any source can be frequency modulated. Frequency modulation (FM) is a technique where the frequency of a carrier wave is varied by an audio signal. This creates complex timbres and can produce rich harmonics, from mellow timbres to harsh digital noise.
<CommandEntry name="fm" type="number" min={0} default={0}>
The frequency modulation index. FM multiplies the gain of the modulator, thus controls the amount of FM applied.
<CodeEditor code={`/fm/2/note/60\n\n/fm/4/note/63\n\n/fm/2/note/67`} rows={6} />
<CodeEditor code={`/voice/0/fm/2/time/0\n\n/voice/0/fm/4/time/1`} rows={4} />
</CommandEntry>
<CommandEntry name="fmh" type="number" default={1}>
The harmonic ratio of the frequency modulation. fmh*freq defines the modulation frequency. As a rule of thumb, numbers close to simple ratios sound more harmonic.
<CodeEditor code={`/fm/2/fmh/2/`} rows={2} />
<CodeEditor code={`/fm/0.5/fmh/1.5/`} rows={2} />
<CodeEditor code={`/fm/0.25/fmh/3/`} rows={2} />
</CommandEntry>
<CommandEntry name="fmshape" type="string" default="sine">
FM modulator waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). Different shapes create different harmonic spectra.
<CodeEditor code={`/fm/2/fmshape/saw`} rows={2} />
</CommandEntry>
<CommandEntry name="fmenv" type="number" default={0}>
Envelope amount of frequency envelope.
<CodeEditor code={`/fm/4/fmenv/4/fmd/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fma" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's attack phase.
<CodeEditor code={`/fm/4/fma/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fmd" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's decay phase.
<CodeEditor code={`/fm/4/fmd/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="fms" type="number" min={0} max={1} default={1}>
The sustain level of the fm envelope.
<CodeEditor code={`/fm/4/fmd/0.25/fms/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="fmr" type="number" min={0} default={0} unit="s">
The duration (seconds) of the fm envelope's release phase.
<CodeEditor code={`/fm/4/fmr/1/release/1/duration/.1`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,21 @@
---
title: "Filter Type"
slug: "ftype"
group: "effects"
order: 114
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Controls the steepness of all filters. Higher dB/octave values create sharper transitions between passed and attenuated frequencies.
<CommandEntry name="ftype" type="enum" default="12db" values={["12db", "24db", "48db"]}>
Filter slope steepness. Higher dB/octave values create sharper cutoffs. Applies to all filter types (lowpass, highpass, bandpass).
<CodeEditor code={`/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/12db/d/.5\n\n/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/24db/time/1/d/.5\n\n/sound/pulse/freq/50/lpf/500/lpq/0.8/lpe/4/lpd/0.2/ftype/48db/time/2/d/.5`} rows={6} />
</CommandEntry>

View File

@@ -0,0 +1,45 @@
---
title: "Gain"
slug: "gain"
group: "synthesis"
order: 105
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The signal path is: oscillator → <code>gain &#42; velocity</code> → filters → distortion → modulation → phaser/flanger → <code>envelope &#42; postgain</code> → chorus → <code>pan</code>.
<CommandEntry name="gain" type="number" min={0} default={1}>
Pre-filter gain multiplier. Applied before filters and distortion, combined with <code>velocity</code> as <code>gain &#42; velocity</code>.
<CodeEditor code={`/sound/saw/gain/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="postgain" type="number" min={0} default={1}>
Post-effects gain multiplier. Applied after phaser/flanger, combined with the envelope as <code>envelope &#42; postgain</code>.
<CodeEditor code={`/sound/saw/postgain/0.2\n\n/sound/saw/postgain/1/time/0.25`} rows={4} />
</CommandEntry>
<CommandEntry name="velocity" type="number" min={0} max={1} default={1}>
Multiplied with <code>gain</code> before filters. Also passed as <code>accent</code> to Plaits engines.
<CodeEditor code={`/sound/saw/velocity/0.2\n\n/sound/saw/velocity/1/time/0.25`} rows={4} />
</CommandEntry>
<CommandEntry name="pan" type="number" min={0} max={1} default={0.5}>
Stereo position using constant-power panning: <code>left = cos(pan &#42; π/2)</code>, <code>right = sin(pan &#42; π/2)</code>. 0 = left, 0.5 = center, 1 = right.
<CodeEditor code={`/pan/0/freq/329\n\n/pan/1/freq/331`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Highpass Filter"
slug: "highpass"
group: "effects"
order: 111
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A highpass filter attenuates frequencies below the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency.
<CommandEntry name="hpf" type="number" min={20} max={20000} unit="Hz">
Cutoff frequency in Hz. Frequencies below this are attenuated.
<CodeEditor code={`/sound/saw/hpf/500`} rows={2} />
</CommandEntry>
<CommandEntry name="hpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Boosts frequencies near the cutoff.
<CodeEditor code={`/sound/saw/hpf/500/hpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="hpe" type="number" default={0}>
Envelope amount. Positive values sweep the cutoff up, negative values sweep down.
<CodeEditor code={`/sound/saw/hpf/500/hpe/5/hpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpa/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="hps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/hpf/500/hpd/.25/hps/.4`} rows={2} />
</CommandEntry>
<CommandEntry name="hpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/hpf/500/hpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

25
website/src/content/io.md Normal file
View File

@@ -0,0 +1,25 @@
---
title: "Io"
slug: "io"
group: "sources"
order: 2
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
This special source allows you to create a live audio input (microphone) source. Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally, envelopes are applied to the input signal too.
<CommandEntry name="live" type="source">
Live audio input (microphone). Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally.
<CodeEditor code={`/sound/live`} rows={2} />
<CodeEditor code={`/sound/live/lpf/800`} rows={2} />
<CodeEditor code={`/sound/live/verb/0.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,61 @@
---
title: "Lo-Fi"
slug: "lofi"
group: "effects"
order: 205
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Sample rate reduction, bit crushing, and waveshaping distortion.
<CommandEntry name="coarse" type="number" min={1} default={1}>
Sample rate reduction. Holds each sample for <code>n</code> samples, creating stair-stepping and aliasing artifacts.
<CodeEditor code={`/penv/36/pdec/.5/coarse/8`} rows={2} />
</CommandEntry>
<CommandEntry name="crush" type="number" min={1} max={16} default={16} unit="bits">
Bit depth reduction. Quantizes amplitude to <code>2^(bits-1)</code> levels, creating stepping distortion.
<CodeEditor code={`/penv/36/pdec/.5/crush/4`} rows={2} />
</CommandEntry>
<CommandEntry name="fold" type="number" min={1} default={1}>
Sine-based wavefold (Serge-style). At 1, near-passthrough. At 2, one fold per peak. At 4, two folds.
<CodeEditor code={`/sound/sine/fold/3`} rows={2} />
</CommandEntry>
<CommandEntry name="wrap" type="number" min={1} default={1}>
Wrap distortion. Signal wraps around creating harsh digital artifacts.
<CodeEditor code={`/sound/tri/wrap/2`} rows={2} />
</CommandEntry>
<CommandEntry name="distort" type="number" min={0} default={0}>
Soft-clipping waveshaper using <code>(1+k)&#42;x / (1+k&#42;|x|)</code> where <code>k = e^amount - 1</code>. Higher values add harmonic saturation.
<CodeEditor code={`/sound/sine/distort/4`} rows={2} />
</CommandEntry>
<CommandEntry name="distortvol" type="number" min={0} default={1}>
Output gain applied after distortion to compensate for increased level.
<CodeEditor code={`/sound/sine/distort/4/distortvol/.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,69 @@
---
title: "Lowpass Filter"
slug: "lowpass"
group: "effects"
order: 110
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
A lowpass filter attenuates frequencies above the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency.
<CommandEntry name="lpf" type="number" min={20} max={20000} unit="Hz">
Cutoff frequency in Hz. Frequencies above this are attenuated.
<CodeEditor code={`/sound/saw/lpf/200`} rows={2} />
</CommandEntry>
<CommandEntry name="lpq" type="number" min={0} max={1} default={0.2}>
Resonance (0-1). Boosts frequencies near the cutoff.
<CodeEditor code={`/sound/saw/lpf/200/lpq/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="lpe" type="number" default={0}>
Envelope amount. Positive values sweep the cutoff up, negative values sweep down.
<CodeEditor code={`/sound/saw/lpf/100/lpe/5/lpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lpa" type="number" min={0} default={0} unit="s">
Envelope attack time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpa/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lpd" type="number" min={0} default={0} unit="s">
Envelope decay time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpd/.25`} rows={2} />
</CommandEntry>
<CommandEntry name="lps" type="number" min={0} max={1} default={1}>
Envelope sustain level (0-1).
<CodeEditor code={`/sound/saw/lpf/100/lpd/.25/lps/.4`} rows={2} />
</CommandEntry>
<CommandEntry name="lpr" type="number" min={0} default={0} unit="s">
Envelope release time in seconds.
<CodeEditor code={`/sound/saw/lpf/100/lpr/.25/duration/.1/release/.25`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,63 @@
---
title: "Oscillator"
slug: "oscillator"
group: "synthesis"
order: 104
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
These parameters are dedicated to alter the nominal behavior of each oscillator. Some parameters are specific to certain oscillators, most others can be used with all oscillators.
<CommandEntry name="pw" type="number" min={0} max={1} default={0.5}>
The pulse width (between 0 and 1) of the pulse oscillator. The default is 0.5 (square wave). Only has an effect when used with <code>/sound/pulse</code> or <code>/sound/pulze</code>.
<CodeEditor code={`/sound/pulse/pw/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="spread" type="number" min={0} max={100} default={0}>
Stereo unison. Adds 6 detuned voices (7 total) with stereo panning. Works with sine, tri, saw, zaw, pulse, pulze.
<CodeEditor code={`/sound/saw/spread/30`} rows={2} />
</CommandEntry>
Inspired by the M8 Tracker's WavSynth, these parameters transform the oscillator phase to create new timbres from basic waveforms. They work with all basic oscillators (sine, tri, saw, zaw, pulse, pulze).
<CommandEntry name="size" type="number" min={0} max={256} default={0}>
Phase quantization steps. Creates stair-step waveforms similar to 8-bit sound chips. Set to 0 to disable, or 2-256 for increasing resolution. Lower values produce more lo-fi, chiptune-like sounds.
<CodeEditor code={`/sound/sine/size/8`} rows={2} />
</CommandEntry>
<CommandEntry name="mult" type="number" min={0.25} max={16} default={1}>
Phase multiplier that wraps the waveform multiple times per cycle. Creates hard-sync-like harmonic effects. A value of 2 doubles the frequency content, 4 quadruples it, etc.
<CodeEditor code={`/sound/saw/mult/4`} rows={2} />
</CommandEntry>
<CommandEntry name="warp" type="number" min={-1} max={1} default={0}>
Phase asymmetry using a power curve. Positive values compress the early phase and expand the late phase. Negative values do the opposite. Creates timbral variations without changing pitch.
<CodeEditor code={`/sound/tri/warp/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="mirror" type="number" min={0} max={1} default={0}>
Reflects the phase at the specified position. At 0.5, creates symmetric waveforms (a saw becomes triangle-like). Values closer to 0 or 1 create increasingly asymmetric reflections.
<CodeEditor code={`/sound/saw/mirror/.5`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,53 @@
---
title: "Phaser"
slug: "phaser"
group: "effects"
order: 200
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Two cascaded notch filters (offset by 282Hz) with LFO-modulated center frequency.
<CommandEntry name="phaser" type="number" min={0} default={0} unit="Hz">
Phaser LFO rate in Hz. Creates sweeping notch filter effect.
<CodeEditor code={`/sound/saw/freq/50/phaser/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/2/phaserdepth/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="phaserdepth" type="number" min={0} max={1} default={0.5}>
Phaser effect intensity (0-1). Controls resonance and wet/dry mix.
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phaserdepth/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/0.25/phaserdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="phasersweep" type="number" min={0} default={2000} unit="Hz">
Phaser frequency sweep range in Hz. Default is 2000 (±2000Hz sweep).
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phasersweep/4000`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/0.5/phasersweep/500`} rows={2} />
</CommandEntry>
<CommandEntry name="phasercenter" type="number" min={20} max={20000} default={1000} unit="Hz">
Phaser center frequency in Hz. Default is 1000Hz.
<CodeEditor code={`/sound/saw/freq/50/phaser/1/phasercenter/500`} rows={2} />
<CodeEditor code={`/sound/saw/freq/50/phaser/2/phasercenter/2000`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,49 @@
---
title: "Pitch Env"
slug: "pitch-env"
group: "synthesis"
order: 106
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
An ADSR envelope applied to pitch. The envelope runs with gate always on (no release phase during note). The frequency is multiplied by <code>2^(env &#42; penv / 12)</code>. When <code>psus = 1</code>, the envelope value is offset by -1 so sustained notes return to base pitch.
<CommandEntry name="penv" type="number" default={0} unit="semitones">
Pitch envelope depth in semitones. Positive values sweep up, negative values sweep down.
<CodeEditor code={`/penv/24/pdec/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="patt" type="number" min={0} default={0.001} unit="s">
Attack time. Duration to reach peak pitch offset.
<CodeEditor code={`/patt/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="pdec" type="number" min={0} default={0} unit="s">
Decay time. Duration to fall from peak to sustain level.
<CodeEditor code={`/pdec/.2`} rows={2} />
</CommandEntry>
<CommandEntry name="psus" type="number" min={0} max={1} default={1}>
Sustain level. At 1.0, the envelope returns to base pitch after decay.
</CommandEntry>
<CommandEntry name="prel" type="number" min={0} default={0.005} unit="s">
Release time. Not typically audible since pitch envelope gate stays on.
</CommandEntry>

View File

@@ -0,0 +1,65 @@
---
title: "Pitch"
slug: "pitch"
group: "synthesis"
order: 100
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Pitch control for all sources, including audio samples.
<CommandEntry name="freq" type="number" min={20} max={20000} default={330} unit="Hz">
The frequency of the sound. Has no effect on noise.
<CodeEditor code={`/freq/400`} rows={2} />
<CodeEditor code={`/freq/800`} rows={2} />
<CodeEditor code={`/freq/1200`} rows={2} />
</CommandEntry>
<CommandEntry name="note" type="number" min={0} max={127} unit="midi">
The note (midi number) that should be played.
If both note and freq is set, freq wins.
<CodeEditor code={`/note/60\n\n/note/67`} rows={4} />
<CodeEditor code={`/note/48\n\n/note/60\n\n/note/63\n\n/note/67`} rows={8} />
</CommandEntry>
<CommandEntry name="speed" type="number" default={1}>
Multiplies with the source frequency or buffer playback speed.
<CodeEditor code={`/sound/saw/freq/220/speed/0.5`} rows={2} />
<CodeEditor code={`/sound/saw/freq/220/speed/1.5`} rows={2} />
</CommandEntry>
<CommandEntry name="detune" type="number" default={0} unit="cents">
Shifts the pitch by the given amount in cents. 100 cents = 1 semitone.
<CodeEditor code={`/freq/440/detune/50`} rows={2} />
<CodeEditor code={`/freq/440/detune/-50`} rows={2} />
</CommandEntry>
<CommandEntry name="glide" type="number" min={0} default={0} unit="s">
Creates a pitch slide when changing the frequency of an active voice.
Only has an effect when used with <code>voice</code>.
<CodeEditor code={`/voice/0/freq/220\n\n/voice/0/freq/330/glide/0.5/time/0.25`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,175 @@
---
title: "Complex"
slug: "plaits"
group: "sources"
order: 1
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Complex oscillator engines based on Mutable Instruments Plaits. All engines share three parameters (0 to 1):
- **harmonics** — harmonic content, structure, detuning, etc.
- **timbre** — brightness, tonal color, etc.
- **morph** — smooth transitions between variations, etc.
Each engine interprets these differently.
<CommandEntry name="modal" type="source">
Modal resonator (physical modeling). Simulates struck/plucked resonant bodies. harmonics: structure, timbre: brightness, morph: damping/decay.
<CodeEditor code={`/sound/modal/note/48`} rows={2} />
<CodeEditor code={`/sound/modal/note/36/harmonics/0.8/morph/0.2`} rows={2} />
<CodeEditor code={`/sound/modal/note/60/timbre/0.7/morph/0.5/verb/0.3`} rows={2} />
</CommandEntry>
<CommandEntry name="va" type="source">
Virtual analog. Classic waveforms with sync and crossfading. harmonics: detuning, timbre: variable square, morph: variable saw.
<CodeEditor code={`/sound/va/note/36`} rows={2} />
<CodeEditor code={`/sound/va/note/48/harmonics/0.3/morph/0.8`} rows={2} />
<CodeEditor code={`/sound/va/note/36/timbre/0.2/lpf/1000`} rows={2} />
</CommandEntry>
<CommandEntry name="ws" type="source">
Waveshaping oscillator. Asymmetric triangle through waveshaper and wavefolder. harmonics: waveshaper shape, timbre: fold amount, morph: waveform asymmetry.
<CodeEditor code={`/sound/ws/note/36`} rows={2} />
<CodeEditor code={`/sound/ws/note/48/timbre/0.7/harmonics/0.5`} rows={2} />
<CodeEditor code={`/sound/ws/note/36/morph/0.8/timbre/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="fm2" type="source">
Two-operator FM synthesis. harmonics: frequency ratio, timbre: modulation index, morph: feedback.
<CodeEditor code={`/sound/fm2/note/48`} rows={2} />
<CodeEditor code={`/sound/fm2/note/60/timbre/0.5/harmonics/0.3`} rows={2} />
<CodeEditor code={`/sound/fm2/note/36/morph/0.7/timbre/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="grain" type="source">
Granular formant oscillator. Simulates formants through windowed sines. harmonics: formant ratio, timbre: formant frequency, morph: formant width.
<CodeEditor code={`/sound/grain/note/48`} rows={2} />
<CodeEditor code={`/sound/grain/note/36/timbre/0.6/harmonics/0.4`} rows={2} />
<CodeEditor code={`/sound/grain/note/60/morph/0.3/timbre/0.8`} rows={2} />
</CommandEntry>
<CommandEntry name="additive" type="source">
Harmonic oscillator. Additive mixture of sine harmonics. harmonics: number of bumps, timbre: prominent harmonic index, morph: bump shape.
<CodeEditor code={`/sound/additive/note/48`} rows={2} />
<CodeEditor code={`/sound/additive/note/36/timbre/0.5/harmonics/0.3`} rows={2} />
<CodeEditor code={`/sound/additive/note/60/morph/0.8/timbre/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="wavetable" type="source">
Wavetable oscillator. Four banks of 8x8 waveforms. harmonics: bank selection, timbre: row index, morph: column index.
<CodeEditor code={`/sound/wavetable/note/48`} rows={2} />
<CodeEditor code={`/sound/wavetable/note/36/timbre/0.5/morph/0.5`} rows={2} />
<CodeEditor code={`/sound/wavetable/note/60/harmonics/0.3/timbre/0.7/morph/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="chord" type="source">
Four-note chord engine. Virtual analog or wavetable chords. harmonics: chord type, timbre: inversion/transposition, morph: waveform.
<CodeEditor code={`/sound/chord/note/48`} rows={2} />
<CodeEditor code={`/sound/chord/note/36/harmonics/0.5`} rows={2} />
<CodeEditor code={`/sound/chord/note/48/harmonics/0.3/morph/0.7/verb/0.2`} rows={2} />
</CommandEntry>
<CommandEntry name="swarm" type="source">
Granular cloud of 8 enveloped sawtooth oscillators. harmonics: pitch randomization, timbre: grain density, morph: grain duration/overlap.
<CodeEditor code={`/sound/swarm/note/36`} rows={2} />
<CodeEditor code={`/sound/swarm/note/48/harmonics/0.5/morph/0.3`} rows={2} />
<CodeEditor code={`/sound/swarm/note/36/timbre/0.4/harmonics/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="pnoise" type="source">
Filtered noise. Clocked noise through multimode filter. harmonics: filter type (LP/BP/HP), timbre: clock frequency, morph: filter resonance.
<CodeEditor code={`/sound/pnoise/note/48`} rows={2} />
<CodeEditor code={`/sound/pnoise/note/36/harmonics/0.5/morph/0.7`} rows={2} />
<CodeEditor code={`/sound/pnoise/note/60/timbre/0.8/morph/0.9`} rows={2} />
</CommandEntry>
<CommandEntry name="kick" type="source">
Analog bass drum. 808-style kick. harmonics: punch, timbre: tone, morph: decay.
<CodeEditor code={`/sound/kick/note/36`} rows={2} />
<CodeEditor code={`/sound/kick/note/36/morph/0.3`} rows={2} />
<CodeEditor code={`/sound/kick/note/36/timbre/0.5/harmonics/0.7`} rows={2} />
</CommandEntry>
<CommandEntry name="snare" type="source">
Analog snare drum. harmonics: tone/noise balance, timbre: drum mode balance, morph: decay.
<CodeEditor code={`/sound/snare/note/48`} rows={2} />
<CodeEditor code={`/sound/snare/note/48/morph/0.5`} rows={2} />
<CodeEditor code={`/sound/snare/note/48/harmonics/0.8/timbre/0.3`} rows={2} />
</CommandEntry>
<CommandEntry name="hihat" type="source">
Analog hihat. 808-style metallic hihat. harmonics: metallic tone, timbre: high-pass filter, morph: decay.
<CodeEditor code={`/sound/hihat/note/60`} rows={2} />
<CodeEditor code={`/sound/hihat/note/60/morph/0.2`} rows={2} />
<CodeEditor code={`/sound/hihat/note/60/harmonics/0.5/timbre/0.6`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,53 @@
---
title: "Reverb"
slug: "reverb"
group: "effects"
order: 204
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Dattorro plate reverb with 4 input diffusers and a cross-fed stereo tank.
<CommandEntry name="verb" type="number" min={0} max={1} default={0}>
Send level to the reverb bus.
<CodeEditor code={`/verb/0.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdecay" type="number" min={0} max={1} default={0.5}>
Tank feedback amount (clamped to 0.99 max). Controls tail length.
<CodeEditor code={`/verb/0.8/verbdecay/0.9/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdamp" type="number" min={0} max={1} default={0.5}>
One-pole lowpass in the tank feedback path. Higher values darken the tail.
<CodeEditor code={`/verb/0.7/verbdamp/0.5/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbpredelay" type="number" min={0} max={1} default={0}>
Delay before the diffusers (0-1 of max ~100ms). Creates space before reverb onset.
<CodeEditor code={`/verb/0.6/verbpredelay/0.3/duration/.1`} rows={2} />
</CommandEntry>
<CommandEntry name="verbdiff" type="number" min={0} max={1} default={0.7}>
Allpass coefficients in both input and tank diffusers. Higher values smear transients.
<CodeEditor code={`/verb/0.7/verbdiff/0.9/duration/.1`} rows={2} />
</CommandEntry>

37
website/src/content/rm.md Normal file
View File

@@ -0,0 +1,37 @@
---
title: "Ring Modulation"
slug: "rm"
group: "synthesis"
order: 110
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Ring modulation is a crossfade between dry signal and full multiplication: <code>signal &#42;= (1.0 - depth) + modulator &#42; depth</code>. Unlike AM, ring modulation at full depth removes the carrier entirely, leaving only sum and difference frequencies at <code>carrier ± modulator</code>.
<CommandEntry name="rm" type="number" min={0} default={0} unit="Hz">
Ring modulation oscillator frequency in Hz. When set above 0, an LFO multiplies the signal.
<CodeEditor code={`/freq/300/rm/440/rmdepth/1.0`} rows={2} />
</CommandEntry>
<CommandEntry name="rmdepth" type="number" min={0} max={1} default={1}>
Modulation depth (0-1). At 0, the signal is unchanged. At 1, full ring modulation with no dry signal.
<CodeEditor code={`/freq/300/rm/440/rmdepth/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="rmshape" type="string" default="sine">
Ring modulation LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/freq/300/rm/8/rmdepth/1/rmshape/sh`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,51 @@
---
title: "Sample"
slug: "sample"
group: "synthesis"
order: 111
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Doux can play back audio samples organized in folders. Point to a samples directory using the <code>--samples</code> flag. Each subfolder becomes a sample bank accessible via <code>/s/folder_name</code>. Use <code>/n/</code> to index into a folder.
<CommandEntry name="n" type="number" min={0} default={0}>
Sample index within the folder. If the index exceeds the number of samples, it wraps around using modulo. Samples in a folder are indexed starting from 0.
<CodeEditor code={`/s/crate_rd/n/0`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/2`} rows={2} />
</CommandEntry>
<CommandEntry name="begin" type="number" min={0} max={1} default={0}>
Sample start position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples.
<CodeEditor code={`/s/crate_rd/n/2/begin/0.0`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/2/begin/0.25`} rows={2} />
</CommandEntry>
<CommandEntry name="end" type="number" min={0} max={1} default={1}>
Sample end position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples.
<CodeEditor code={`/s/crate_rd/n/2/end/0.05`} rows={2} />
<CodeEditor code={`/s/crate_rd/n/3/end/0.1/speed/0.5`} rows={2} />
</CommandEntry>
<CommandEntry name="cut" type="number" min={0}>
Choke group. Voices with the same cut value silence each other. Use for hi-hats where open should be cut by closed.
<CodeEditor code={`/s/crate_hh/n/0/cut/1\n\n/s/crate_hh/n/1/cut/1/time/.25`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,37 @@
---
title: "Timing"
slug: "timing"
group: "synthesis"
order: 101
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The engine clock starts at 0 and advances with each sample. Events with <code>time</code> are scheduled and fired when the clock reaches that value. The <code>duration</code> sets how long the gate stays open before triggering release. The <code>repeat</code> reschedules the event at regular intervals.
<CommandEntry name="time" type="number" min={0} default={0} unit="s">
The time at which the voice should start. Defaults to 0.
<CodeEditor code={`/freq/330/time/0\n\n/freq/440/time/0.5`} rows={4} />
</CommandEntry>
<CommandEntry name="duration" type="number" min={0} unit="s">
The duration (seconds) of the gate phase. If not set, the voice will play indefinitely, until released explicitly.
<CodeEditor code={`/duration/.5`} rows={2} />
</CommandEntry>
<CommandEntry name="repeat" type="number" min={0} unit="s">
If set, the command is repeated within the given number of seconds.
<CodeEditor code={`/freq/330/time/0/duration/0.5/repeat/1\n\n/freq/440/time/0.5/duration/0.5/repeat/1`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,37 @@
---
title: "Vibrato"
slug: "vibrato"
group: "synthesis"
order: 107
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
The pitch of every oscillator can be modulated by a vibrato effect. Vibrato is a technique where the pitch of a note is modulated slightly around a central pitch, creating a shimmering effect.
<CommandEntry name="vib" type="number" min={0} default={0} unit="Hz">
Vibrato frequency (in hertz).
<CodeEditor code={`/vib/8`} rows={2} />
</CommandEntry>
<CommandEntry name="vibmod" type="number" min={0} default={0} unit="semitones">
Vibrato modulation depth (semitones).
<CodeEditor code={`/vib/8/vibmod/24`} rows={2} />
</CommandEntry>
<CommandEntry name="vibshape" type="string" default="sine">
Vibrato LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold).
<CodeEditor code={`/vib/4/vibmod/1/vibshape/tri`} rows={2} />
</CommandEntry>

View File

@@ -0,0 +1,29 @@
---
title: "Voice"
slug: "voice"
group: "synthesis"
order: 103
---
<script lang="ts">
import CodeEditor from '$lib/components/CodeEditor.svelte';
import CommandEntry from '$lib/components/CommandEntry.svelte';
</script>
Doux is a polyphonic synthesizer with up to 32 simultaneous voices. By default, each event allocates a new voice automatically. When a voice finishes (envelope reaches zero), it is freed and recycled. The <code>voice</code> parameter lets you take manual control over voice allocation, enabling parameter updates on active voices (e.g., pitch slides with <code>glide</code>) or retriggering with <code>reset</code>.
<CommandEntry name="voice" type="number" min={0}>
The voice index to use. If set, voice allocation will be skipped and the selected voice will be used. If the voice is still active, the sent params will update the active voice.
<CodeEditor code={`/voice/0/freq/220\n\n/voice/0/freq/330/time/.5`} rows={4} />
</CommandEntry>
<CommandEntry name="reset" type="boolean" default={false}>
Only has an effect when used together with voice. If set to 1, the selected voice will be reset, even when it's still active. This will cause envelopes to retrigger for example.
<CodeEditor code={`/voice/0/freq/220/attack/.1\n\n/voice/0/freq/330/time/.25/reset/1`} rows={4} />
</CommandEntry>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { untrack } from 'svelte';
import { doux } from '$lib/doux';
import { startScope, stopScope, registerActiveEditor, unregisterActiveEditor } from '$lib/scope';
import { Play, Square } from 'lucide-svelte';
interface Props {
code?: string;
rows?: number;
}
let { code = '', rows = 3 }: Props = $props();
let textarea: HTMLTextAreaElement;
let active = $state(false);
let currentCode = $state(untrack(() => code));
let evaluated = $state(false);
const resetCallback = () => {
active = false;
};
const highlight = (code: string) =>
code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(\/\/.*)/g, '<span class="hl-comment">$1</span>')
.replace(
/(\/)([a-zA-Z_][a-zA-Z0-9_]*)/g,
'<span class="hl-slash">$1</span><span class="hl-command">$2</span>'
)
.replace(
/(\/)(-?[0-9]*\.?[0-9]+)/g,
'<span class="hl-slash">$1</span><span class="hl-number">$2</span>'
) + '\n';
let highlighted = $derived(highlight(currentCode));
function flash() {
evaluated = false;
setTimeout(() => {
evaluated = true;
}, 50);
}
async function run() {
flash();
await doux.ready;
doux.evaluate({ doux: 'reset_schedule' });
doux.evaluate({ doux: !active ? 'reset' : 'hush_endless' });
const blocks = currentCode.split('\n\n').filter(Boolean);
const msgs = await Promise.all(
blocks.map((block) => {
const event = doux.parsePath(block);
return doux.prepare({ doux: 'play', ...event });
})
);
if (!active) {
doux.evaluate({ doux: 'reset_time' });
registerActiveEditor(resetCallback);
active = true;
startScope();
}
msgs.forEach((msg) => doux.send(msg));
}
function stop() {
active = false;
unregisterActiveEditor(resetCallback);
stopScope();
doux.hush();
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.altKey) && e.key === 'Enter') {
run();
}
}
function handleScroll() {
const pre = textarea.previousElementSibling as HTMLPreElement;
if (pre) pre.scrollTop = textarea.scrollTop;
}
</script>
<div class="repl">
<div class="repl-editor">
<pre class="hl-pre" aria-hidden="true">{@html highlighted}</pre>
<textarea
bind:this={textarea}
bind:value={currentCode}
spellcheck="false"
{rows}
class:evaluated
onkeydown={handleKeydown}
onscroll={handleScroll}
></textarea>
</div>
<div class="repl-controls">
{#if active}
<button class="stop" onclick={stop}><Square size={16} /></button>
{:else}
<button class="play" onclick={run}><Play size={16} /></button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
name: string;
type?: "number" | "boolean" | "enum" | "source";
min?: number;
max?: number;
default?: number | string | boolean;
unit?: string;
values?: string[];
children: Snippet;
}
let {
name,
type,
min,
max,
default: defaultValue,
unit,
values,
children,
}: Props = $props();
function formatRange(): string | null {
if (min !== undefined && max !== undefined) {
return `${min}${max}`;
}
if (min !== undefined) {
return `≥${min}`;
}
if (max !== undefined) {
return `≤${max}`;
}
return null;
}
</script>
<details id={name}>
<summary>
<span class="name">{name}</span>
{#if type && type !== "source"}
<span class="meta">
<span class="type">{type}</span>
{#if formatRange()}
<span class="range"
>{formatRange()}{#if unit}
{unit}{/if}</span
>
{:else if unit}
<span class="unit">{unit}</span>
{/if}
{#if defaultValue !== undefined}
<span class="default">={defaultValue}</span>
{/if}
{#if values}
<span class="values">{values.join(" | ")}</span>
{/if}
</span>
{/if}
</summary>
<div class="content">
{@render children()}
</div>
</details>
<style>
details {
margin: 16px 0;
background: #f5f5f5;
border: 1px solid #ccc;
}
summary {
padding: 10px 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::before {
content: "▶";
font-size: 0.7em;
color: #999;
transition: transform 0.15s;
}
details[open] summary::before {
transform: rotate(90deg);
}
.name {
font-weight: bold;
}
.meta {
display: inline-flex;
gap: 6px;
font-size: 0.85em;
}
.meta span {
padding: 2px 6px;
background: #eee;
color: #666;
}
.type {
color: #666 !important;
}
.range {
color: #666 !important;
}
.default {
color: #999 !important;
}
.content {
padding: 0 14px 14px;
border-top: 1px solid #ddd;
}
</style>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { doux } from '$lib/doux';
import { Home, FileText, LifeBuoy, Terminal } from 'lucide-svelte';
import Scope from './Scope.svelte';
let micEnabled = $state(false);
let micLoading = $state(false);
async function toggleMic() {
if (micEnabled) {
doux.disableMic();
micEnabled = false;
} else {
micLoading = true;
await doux.enableMic();
micEnabled = true;
micLoading = false;
}
}
</script>
<nav>
<a href="/" class="nav-title"><h1>Doux</h1></a>
<div class="nav-links">
<a href="/" class="nav-link"><Home size={16} /> Home</a>
<a href="/reference" class="nav-link"><FileText size={16} /> Reference</a>
<a href="/native" class="nav-link"><Terminal size={16} /> Native</a>
<a href="/support" class="nav-link"><LifeBuoy size={16} /> Support</a>
</div>
<div class="nav-scope">
<Scope />
</div>
<button
class="mic-btn"
class:mic-enabled={micEnabled}
disabled={micLoading}
onclick={toggleMic}
>
{micLoading ? '...' : '🎤 Microphone'}
</button>
</nav>
<div class="nav-tabs">
<a href="/" class="nav-tab"><Home size={20} /></a>
<a href="/reference" class="nav-tab"><FileText size={20} /></a>
<a href="/native" class="nav-tab"><Terminal size={20} /></a>
<a href="/support" class="nav-tab"><LifeBuoy size={20} /></a>
</div>
<style>
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-title {
text-decoration: none;
margin-right: 16px;
}
.nav-title h1 {
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: #666;
}
.nav-link:hover {
color: #000;
}
.nav-tabs {
display: none;
}
.nav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: #666;
text-decoration: none;
}
.nav-tab:hover {
color: #000;
background: #f5f5f5;
}
@media (max-width: 768px) {
.nav-links {
display: none;
}
.nav-tabs {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: #fff;
border-top: 1px solid #ccc;
z-index: 100;
}
}
</style>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initScope } from '$lib/scope';
let { class: className = '' }: { class?: string } = $props();
let canvas: HTMLCanvasElement;
onMount(() => {
const cleanup = initScope(canvas);
return cleanup;
});
</script>
<div class="scope {className}">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
interface NavItem {
name: string;
category: string;
group: string;
}
interface Props {
items: NavItem[];
}
let { items }: Props = $props();
let expanded = $state<Record<string, boolean>>({});
function toggleCategory(category: string) {
expanded[category] = !expanded[category];
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const categoryNames: Record<string, string> = {
plaits: "Complex",
io: "Audio Input",
am: "Amplitude Modulation",
rm: "Ring Modulation",
lowpass: "Lowpass Filter",
highpass: "Highpass Filter",
bandpass: "Bandpass Filter",
ftype: "Filter Type",
};
function formatCategory(str: string): string {
return categoryNames[str] ?? capitalize(str);
}
const grouped = $derived.by(() => {
const groups: Record<string, Record<string, NavItem[]>> = {
sources: {},
synthesis: {},
effects: {},
};
for (const item of items) {
const group = item.group;
const category = item.category;
if (!groups[group]) continue;
if (!groups[group][category]) {
groups[group][category] = [];
}
groups[group][category].push(item);
}
return groups;
});
</script>
<aside class="sidebar">
{#each ["sources", "synthesis", "effects"] as group (group)}
<div class="sidebar-section">{capitalize(group)}</div>
{#each Object.entries(grouped[group]) as [category, navItems] (category)}
<button
class="category-toggle"
onclick={() => toggleCategory(category)}
>
{formatCategory(category)}
</button>
{#if expanded[category]}
<div class="commands">
{#each navItems as item (item.name)}
<a href="#{item.name}" class="command-link"
>{item.name}</a
>
{/each}
</div>
{/if}
{/each}
{/each}
</aside>
<style>
.sidebar-section {
margin-top: 16px;
}
.category-toggle {
display: block;
width: 100%;
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 4px 8px;
font-size: inherit;
font-family: inherit;
text-align: left;
}
.category-toggle:hover {
color: #000;
background: #f5f5f5;
}
.commands {
padding-left: 18px;
}
.command-link {
display: block;
color: #666;
text-decoration: none;
padding: 2px 8px;
font-size: 0.9em;
}
.command-link:hover {
color: #000;
background: #f5f5f5;
}
</style>

443
website/src/lib/doux.ts Normal file
View File

@@ -0,0 +1,443 @@
import type { DouxEvent, SoundInfo, ClockMessage, DouxOptions, PreparedMessage } from './types';
const soundMap = new Map<string, string[]>();
const loadedSounds = new Map<string, SoundInfo>();
const loadingSounds = new Map<string, Promise<SoundInfo>>();
let pcm_offset = 0;
const sources = [
'triangle', 'tri', 'sine', 'sawtooth', 'saw', 'zawtooth', 'zaw',
'pulse', 'square', 'pulze', 'zquare', 'white', 'pink', 'brown',
'live', 'livein', 'mic'
];
function githubPath(base: string, subpath = ''): string {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let [, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
path += '/main';
}
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
async function fetchSampleMap(url: string): Promise<[Record<string, string[]>, string] | undefined> {
if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json');
}
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
if (url.startsWith('shabda:')) {
const [, path] = url.split('shabda:');
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
}
if (url.startsWith('shabda/speech')) {
let [, path] = url.split('shabda/speech');
path = path.startsWith('/') ? path.substring(1) : path;
const [params, words] = path.split(':');
let gender = 'f';
let language = 'en-GB';
if (params) {
[language, gender] = params.split('/');
}
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
}
if (typeof fetch !== 'function') {
return;
}
const base = url.split('/').slice(0, -1).join('/');
const json = await fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.catch((error) => {
throw new Error(`error loading "${url}": ${error.message}`);
});
return [json, json._base || base];
}
export async function douxsamples(
sampleMap: string | Record<string, string[]>,
baseUrl?: string
): Promise<void> {
if (typeof sampleMap === 'string') {
const result = await fetchSampleMap(sampleMap);
if (!result) return;
const [json, base] = result;
return douxsamples(json, base);
}
Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') {
urls = urls.map((url) => baseUrl + url);
soundMap.set(key, urls);
}
});
}
const BLOCK_SIZE = 128;
const CHANNELS = 2;
const CLOCK_SIZE = 16;
// AudioWorklet processor code - runs in worklet context
const workletCode = `
const BLOCK_SIZE = 128;
const CHANNELS = 2;
const CLOCK_SIZE = 16;
let wasmExports = null;
let wasmMemory = null;
let output = null;
let input_buffer = null;
let event_input_ptr = 0;
let framebuffer = null;
let framebuffer_ptr = 0;
let frame_ptr = 0;
let frameIdx = 0;
let block = 0;
class DouxProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.active = true;
this.clock_active = options.processorOptions?.clock_active || false;
this.clockmsg = {
clock: true,
t0: 0,
t1: 0,
latency: (CLOCK_SIZE * BLOCK_SIZE) / sampleRate,
};
this.port.onmessage = async (e) => {
const { wasm, evaluate, event_input, panic, writePcm } = e.data;
if (wasm) {
const { instance } = await WebAssembly.instantiate(wasm, {});
wasmExports = instance.exports;
wasmMemory = wasmExports.memory;
wasmExports.doux_init(sampleRate);
event_input_ptr = wasmExports.get_event_input_pointer();
output = new Float32Array(
wasmMemory.buffer,
wasmExports.get_output_pointer(),
BLOCK_SIZE * CHANNELS,
);
input_buffer = new Float32Array(
wasmMemory.buffer,
wasmExports.get_input_buffer_pointer(),
BLOCK_SIZE * CHANNELS,
);
framebuffer_ptr = wasmExports.get_framebuffer_pointer();
frame_ptr = wasmExports.get_frame_pointer();
const framebufferLen = Math.floor((sampleRate / 60) * CHANNELS) * 4;
framebuffer = new Float32Array(framebufferLen);
this.port.postMessage({ ready: true, sampleRate });
} else if (writePcm) {
const { data, offset } = writePcm;
const pcm_ptr = wasmExports.get_sample_buffer_pointer();
const pcm_len = wasmExports.get_sample_buffer_len();
const pcm = new Float32Array(wasmMemory.buffer, pcm_ptr, pcm_len);
pcm.set(data, offset);
this.port.postMessage({ pcmWritten: offset });
} else if (evaluate && event_input) {
new Uint8Array(
wasmMemory.buffer,
event_input_ptr,
event_input.length,
).set(event_input);
wasmExports.evaluate();
} else if (panic) {
wasmExports.panic();
}
};
}
process(inputs, outputs, parameters) {
if (wasmExports && outputs[0][0]) {
if (input_buffer && inputs[0] && inputs[0][0]) {
for (let i = 0; i < inputs[0][0].length; i++) {
const offset = i * CHANNELS;
for (let c = 0; c < CHANNELS; c++) {
input_buffer[offset + c] = inputs[0][c]?.[i] ?? inputs[0][0][i];
}
}
}
wasmExports.dsp();
const out = outputs[0];
for (let i = 0; i < out[0].length; i++) {
const offset = i * CHANNELS;
for (let c = 0; c < CHANNELS; c++) {
out[c][i] = output[offset + c];
if (framebuffer) {
framebuffer[frameIdx * CHANNELS + c] = output[offset + c];
}
}
frameIdx = (frameIdx + 1) % (framebuffer.length / CHANNELS);
}
block++;
if (block % 8 === 0 && framebuffer) {
this.port.postMessage({
framebuffer: framebuffer.slice(),
frame: frameIdx,
});
}
if (this.clock_active && block % CLOCK_SIZE === 0) {
this.clockmsg.t0 = this.clockmsg.t1;
this.clockmsg.t1 = wasmExports.get_time();
this.port.postMessage(this.clockmsg);
}
}
return this.active;
}
}
registerProcessor("doux-processor", DouxProcessor);
`;
export class Doux {
base: string;
BLOCK_SIZE = BLOCK_SIZE;
CHANNELS = CHANNELS;
ready: Promise<void>;
sampleRate = 0;
frame: Int32Array = new Int32Array(1);
framebuffer: Float32Array = new Float32Array(0);
samplesReady: Promise<void> | null = null;
private initAudio: Promise<AudioContext>;
private worklet: AudioWorkletNode | null = null;
private encoder: TextEncoder | null = null;
private micSource: MediaStreamAudioSourceNode | null = null;
private micStream: MediaStream | null = null;
private onTick?: (msg: ClockMessage) => void;
constructor(options: DouxOptions = {}) {
this.base = options.base ?? '/';
this.onTick = options.onTick;
this.initAudio = new Promise((resolve) => {
if (typeof document === 'undefined') return;
document.addEventListener('click', async function init() {
const ac = new AudioContext();
await ac.resume();
resolve(ac);
document.removeEventListener('click', init);
});
});
this.ready = this.runWorklet();
}
private async initWorklet(): Promise<AudioWorkletNode> {
const ac = await this.initAudio;
const blob = new Blob([workletCode], { type: 'application/javascript' });
const dataURL = URL.createObjectURL(blob);
await ac.audioWorklet.addModule(dataURL);
const worklet = new AudioWorkletNode(ac, 'doux-processor', {
outputChannelCount: [CHANNELS],
processorOptions: { clock_active: !!this.onTick }
});
worklet.connect(ac.destination);
const res = await fetch(`${this.base}doux.wasm?t=${Date.now()}`);
const wasm = await res.arrayBuffer();
return new Promise((resolve) => {
worklet.port.onmessage = async (e) => {
if (e.data.ready) {
this.sampleRate = e.data.sampleRate;
this.frame = new Int32Array(1);
this.frame[0] = 0;
const framebufferLen = Math.floor((this.sampleRate / 60) * CHANNELS) * 4;
this.framebuffer = new Float32Array(framebufferLen);
this.samplesReady = douxsamples('https://samples.raphaelforment.fr');
resolve(worklet);
} else if (e.data.clock) {
this.onTick?.(e.data);
} else if (e.data.framebuffer) {
this.framebuffer.set(e.data.framebuffer);
this.frame[0] = e.data.frame;
}
};
worklet.port.postMessage({ wasm });
});
}
private async runWorklet(): Promise<void> {
const ac = await this.initAudio;
if (ac.state !== 'running') await ac.resume();
if (this.worklet) return;
this.worklet = await this.initWorklet();
}
parsePath(path: string): DouxEvent {
const chunks = path
.trim()
.split('\n')
.map((line) => line.split('//')[0])
.join('')
.split('/')
.filter(Boolean);
const pairs: [string, string | undefined][] = [];
for (let i = 0; i < chunks.length; i += 2) {
pairs.push([chunks[i].trim(), chunks[i + 1]?.trim()]);
}
return Object.fromEntries(pairs);
}
private encodeEvent(input: string | DouxEvent): Uint8Array {
if (!this.encoder) this.encoder = new TextEncoder();
const str =
typeof input === 'string'
? input
: Object.entries(input)
.map(([k, v]) => `${k}/${v}`)
.join('/');
return this.encoder.encode(str + '\0');
}
async evaluate(input: DouxEvent): Promise<void> {
const msg = await this.prepare(input);
return this.send(msg);
}
async hush(): Promise<void> {
await this.panic();
const ac = await this.initAudio;
ac.suspend();
}
async resume(): Promise<void> {
const ac = await this.initAudio;
if (ac.state !== 'running') await ac.resume();
}
async panic(): Promise<void> {
await this.ready;
this.worklet?.port.postMessage({ panic: true });
}
async enableMic(): Promise<void> {
await this.ready;
const ac = await this.initAudio;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = ac.createMediaStreamSource(stream);
if (this.worklet) source.connect(this.worklet);
this.micSource = source;
this.micStream = stream;
}
disableMic(): void {
if (this.micSource) {
this.micSource.disconnect();
this.micSource = null;
}
if (this.micStream) {
this.micStream.getTracks().forEach((t) => t.stop());
this.micStream = null;
}
}
async prepare(event: DouxEvent): Promise<PreparedMessage> {
await this.ready;
if (this.samplesReady) await this.samplesReady;
await this.maybeLoadFile(event);
const encoded = this.encodeEvent(event);
return {
evaluate: true,
event_input: encoded
};
}
async send(msg: PreparedMessage): Promise<void> {
await this.resume();
this.worklet?.port.postMessage(msg);
}
private async fetchSample(url: string): Promise<Float32Array> {
const ac = await this.initAudio;
const encoded = encodeURI(url);
const buffer = await fetch(encoded)
.then((res) => res.arrayBuffer())
.then((buf) => ac.decodeAudioData(buf));
return buffer.getChannelData(0);
}
private async loadSound(s: string, n = 0): Promise<SoundInfo> {
const soundKey = `${s}:${n}`;
if (loadedSounds.has(soundKey)) {
return loadedSounds.get(soundKey)!;
}
if (!loadingSounds.has(soundKey)) {
const urls = soundMap.get(s);
if (!urls) throw new Error(`sound ${s} not found in soundMap`);
const url = urls[n % urls.length];
const promise = this.fetchSample(url).then(async (data) => {
const offset = pcm_offset;
pcm_offset += data.length;
await this.sendPcmData(data, offset);
const info: SoundInfo = {
pcm_offset: offset,
frames: data.length,
channels: 1,
freq: 65.406
};
loadedSounds.set(soundKey, info);
return info;
});
loadingSounds.set(soundKey, promise);
}
return loadingSounds.get(soundKey)!;
}
private sendPcmData(data: Float32Array, offset: number): Promise<void> {
return new Promise((resolve) => {
const handler = (e: MessageEvent) => {
if (e.data.pcmWritten === offset) {
this.worklet?.port.removeEventListener('message', handler);
resolve();
}
};
this.worklet?.port.addEventListener('message', handler);
this.worklet?.port.postMessage({ writePcm: { data, offset } });
});
}
private async maybeLoadFile(event: DouxEvent): Promise<void> {
const s = event.s || event.sound;
if (!s || typeof s !== 'string') return;
if (sources.includes(s)) return;
if (!soundMap.has(s)) return;
const n = typeof event.n === 'string' ? parseInt(event.n) : event.n ?? 0;
const info = await this.loadSound(s, n);
event.file_pcm = info.pcm_offset;
event.file_frames = info.frames;
event.file_channels = info.channels;
event.file_freq = info.freq;
}
async play(path: string): Promise<void> {
await this.resume();
if (this.samplesReady) await this.samplesReady;
const event = this.parsePath(path);
await this.maybeLoadFile(event);
const encoded = this.encodeEvent(event);
const msg = {
evaluate: true,
event_input: encoded
};
this.worklet?.port.postMessage(msg);
}
}
// Singleton instance
export const doux = new Doux();
// Load default samples
douxsamples('github:eddyflux/crate');

View File

@@ -0,0 +1,132 @@
[
{ "name": "sine", "category": "basic", "group": "sources" },
{ "name": "tri", "category": "basic", "group": "sources" },
{ "name": "saw", "category": "basic", "group": "sources" },
{ "name": "zaw", "category": "basic", "group": "sources" },
{ "name": "pulse", "category": "basic", "group": "sources" },
{ "name": "pulze", "category": "basic", "group": "sources" },
{ "name": "white", "category": "basic", "group": "sources" },
{ "name": "pink", "category": "basic", "group": "sources" },
{ "name": "brown", "category": "basic", "group": "sources" },
{ "name": "modal", "category": "plaits", "group": "sources" },
{ "name": "va", "category": "plaits", "group": "sources" },
{ "name": "ws", "category": "plaits", "group": "sources" },
{ "name": "fm2", "category": "plaits", "group": "sources" },
{ "name": "grain", "category": "plaits", "group": "sources" },
{ "name": "additive", "category": "plaits", "group": "sources" },
{ "name": "wavetable", "category": "plaits", "group": "sources" },
{ "name": "chord", "category": "plaits", "group": "sources" },
{ "name": "swarm", "category": "plaits", "group": "sources" },
{ "name": "pnoise", "category": "plaits", "group": "sources" },
{ "name": "particle", "category": "plaits", "group": "sources" },
{ "name": "string", "category": "plaits", "group": "sources" },
{ "name": "speech", "category": "plaits", "group": "sources" },
{ "name": "kick", "category": "plaits", "group": "sources" },
{ "name": "snare", "category": "plaits", "group": "sources" },
{ "name": "hihat", "category": "plaits", "group": "sources" },
{ "name": "live", "category": "io", "group": "sources" },
{ "name": "freq", "category": "pitch", "group": "synthesis" },
{ "name": "note", "category": "pitch", "group": "synthesis" },
{ "name": "speed", "category": "pitch", "group": "synthesis" },
{ "name": "detune", "category": "pitch", "group": "synthesis" },
{ "name": "glide", "category": "pitch", "group": "synthesis" },
{ "name": "time", "category": "timing", "group": "synthesis" },
{ "name": "duration", "category": "timing", "group": "synthesis" },
{ "name": "repeat", "category": "timing", "group": "synthesis" },
{ "name": "attack", "category": "envelope", "group": "synthesis" },
{ "name": "decay", "category": "envelope", "group": "synthesis" },
{ "name": "sustain", "category": "envelope", "group": "synthesis" },
{ "name": "release", "category": "envelope", "group": "synthesis" },
{ "name": "voice", "category": "voice", "group": "synthesis" },
{ "name": "reset", "category": "voice", "group": "synthesis" },
{ "name": "pw", "category": "oscillator", "group": "synthesis" },
{ "name": "spread", "category": "oscillator", "group": "synthesis" },
{ "name": "size", "category": "oscillator", "group": "synthesis" },
{ "name": "mult", "category": "oscillator", "group": "synthesis" },
{ "name": "warp", "category": "oscillator", "group": "synthesis" },
{ "name": "mirror", "category": "oscillator", "group": "synthesis" },
{ "name": "timbre", "category": "plaits", "group": "synthesis" },
{ "name": "harmonics", "category": "plaits", "group": "synthesis" },
{ "name": "morph", "category": "plaits", "group": "synthesis" },
{ "name": "gain", "category": "gain", "group": "synthesis" },
{ "name": "postgain", "category": "gain", "group": "synthesis" },
{ "name": "velocity", "category": "gain", "group": "synthesis" },
{ "name": "pan", "category": "gain", "group": "synthesis" },
{ "name": "penv", "category": "pitch-env", "group": "synthesis" },
{ "name": "patt", "category": "pitch-env", "group": "synthesis" },
{ "name": "pdec", "category": "pitch-env", "group": "synthesis" },
{ "name": "psus", "category": "pitch-env", "group": "synthesis" },
{ "name": "prel", "category": "pitch-env", "group": "synthesis" },
{ "name": "vib", "category": "vibrato", "group": "synthesis" },
{ "name": "vibmod", "category": "vibrato", "group": "synthesis" },
{ "name": "vibshape", "category": "vibrato", "group": "synthesis" },
{ "name": "fmh", "category": "fm", "group": "synthesis" },
{ "name": "fm", "category": "fm", "group": "synthesis" },
{ "name": "fmshape", "category": "fm", "group": "synthesis" },
{ "name": "fmenv", "category": "fm", "group": "synthesis" },
{ "name": "fma", "category": "fm", "group": "synthesis" },
{ "name": "fmd", "category": "fm", "group": "synthesis" },
{ "name": "fms", "category": "fm", "group": "synthesis" },
{ "name": "fmr", "category": "fm", "group": "synthesis" },
{ "name": "am", "category": "am", "group": "synthesis" },
{ "name": "amdepth", "category": "am", "group": "synthesis" },
{ "name": "amshape", "category": "am", "group": "synthesis" },
{ "name": "rm", "category": "rm", "group": "synthesis" },
{ "name": "rmdepth", "category": "rm", "group": "synthesis" },
{ "name": "rmshape", "category": "rm", "group": "synthesis" },
{ "name": "n", "category": "sample", "group": "synthesis" },
{ "name": "begin", "category": "sample", "group": "synthesis" },
{ "name": "end", "category": "sample", "group": "synthesis" },
{ "name": "cut", "category": "sample", "group": "synthesis" },
{ "name": "lpf", "category": "lowpass", "group": "effects" },
{ "name": "lpq", "category": "lowpass", "group": "effects" },
{ "name": "lpe", "category": "lowpass", "group": "effects" },
{ "name": "lpa", "category": "lowpass", "group": "effects" },
{ "name": "lpd", "category": "lowpass", "group": "effects" },
{ "name": "lps", "category": "lowpass", "group": "effects" },
{ "name": "lpr", "category": "lowpass", "group": "effects" },
{ "name": "hpf", "category": "highpass", "group": "effects" },
{ "name": "hpq", "category": "highpass", "group": "effects" },
{ "name": "hpe", "category": "highpass", "group": "effects" },
{ "name": "hpa", "category": "highpass", "group": "effects" },
{ "name": "hpd", "category": "highpass", "group": "effects" },
{ "name": "hps", "category": "highpass", "group": "effects" },
{ "name": "hpr", "category": "highpass", "group": "effects" },
{ "name": "bpf", "category": "bandpass", "group": "effects" },
{ "name": "bpq", "category": "bandpass", "group": "effects" },
{ "name": "bpe", "category": "bandpass", "group": "effects" },
{ "name": "bpa", "category": "bandpass", "group": "effects" },
{ "name": "bpd", "category": "bandpass", "group": "effects" },
{ "name": "bps", "category": "bandpass", "group": "effects" },
{ "name": "bpr", "category": "bandpass", "group": "effects" },
{ "name": "comb", "category": "comb-filter", "group": "effects" },
{ "name": "combfreq", "category": "comb-filter", "group": "effects" },
{ "name": "combfeedback", "category": "comb-filter", "group": "effects" },
{ "name": "combdamp", "category": "comb-filter", "group": "effects" },
{ "name": "ftype", "category": "ftype", "group": "effects" },
{ "name": "phaser", "category": "phaser", "group": "effects" },
{ "name": "phaserdepth", "category": "phaser", "group": "effects" },
{ "name": "phasersweep", "category": "phaser", "group": "effects" },
{ "name": "phasercenter", "category": "phaser", "group": "effects" },
{ "name": "flanger", "category": "flanger", "group": "effects" },
{ "name": "flangerdepth", "category": "flanger", "group": "effects" },
{ "name": "flangerfeedback", "category": "flanger", "group": "effects" },
{ "name": "chorus", "category": "chorus", "group": "effects" },
{ "name": "chorusdepth", "category": "chorus", "group": "effects" },
{ "name": "chorusdelay", "category": "chorus", "group": "effects" },
{ "name": "delay", "category": "delay", "group": "effects" },
{ "name": "delayfeedback", "category": "delay", "group": "effects" },
{ "name": "delaytime", "category": "delay", "group": "effects" },
{ "name": "delaytype", "category": "delay", "group": "effects" },
{ "name": "verb", "category": "reverb", "group": "effects" },
{ "name": "verbdecay", "category": "reverb", "group": "effects" },
{ "name": "verbdamp", "category": "reverb", "group": "effects" },
{ "name": "verbpredelay", "category": "reverb", "group": "effects" },
{ "name": "verbdiff", "category": "reverb", "group": "effects" },
{ "name": "coarse", "category": "lofi", "group": "effects" },
{ "name": "crush", "category": "lofi", "group": "effects" },
{ "name": "fold", "category": "lofi", "group": "effects" },
{ "name": "wrap", "category": "lofi", "group": "effects" },
{ "name": "distort", "category": "lofi", "group": "effects" },
{ "name": "distortvol", "category": "lofi", "group": "effects" }
]

99
website/src/lib/scope.ts Normal file
View File

@@ -0,0 +1,99 @@
import { doux } from './doux';
let ctx: CanvasRenderingContext2D | null = null;
let raf: number | null = null;
const lerp = (v: number, min: number, max: number) => v * (max - min) + min;
const invLerp = (v: number, min: number, max: number) => (v - min) / (max - min);
const remap = (v: number, vmin: number, vmax: number, omin: number, omax: number) =>
lerp(invLerp(v, vmin, vmax), omin, omax);
function drawBuffer(
ctx: CanvasRenderingContext2D,
samples: Float32Array,
channels: number,
channel: number,
ampMin: number,
ampMax: number
) {
const lineWidth = 2;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = 'black';
const perChannel = samples.length / channels / 2;
const pingbuffer = doux.frame[0] > samples.length / 2;
const s0 = pingbuffer ? 0 : perChannel;
const s1 = pingbuffer ? perChannel : perChannel * 2;
const px0 = ctx.lineWidth;
const px1 = ctx.canvas.width - ctx.lineWidth;
const py0 = ctx.lineWidth;
const py1 = ctx.canvas.height - ctx.lineWidth;
ctx.beginPath();
for (let px = 1; px <= ctx.canvas.width; px++) {
const si = remap(px, px0, px1, s0, s1);
const idx = Math.floor(si) * channels + channel;
const amp = samples[idx];
if (amp >= 1) ctx.strokeStyle = 'red';
const py = remap(amp, ampMin, ampMax, py1, py0);
px === 1 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
}
function drawScope() {
if (!ctx) return;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let c = 0; c < doux.CHANNELS; c++) {
drawBuffer(ctx, doux.framebuffer, doux.CHANNELS, c, -1, 1);
}
raf = requestAnimationFrame(drawScope);
}
export function initScope(canvas: HTMLCanvasElement) {
function resize() {
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
}
resize();
ctx = canvas.getContext('2d');
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}
export function startScope() {
if (!raf && ctx) {
drawScope();
}
}
export function stopScope() {
if (raf) {
cancelAnimationFrame(raf);
raf = null;
}
if (ctx) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
}
let activeResetCallback: (() => void) | null = null;
export function registerActiveEditor(resetCallback: () => void) {
if (activeResetCallback && activeResetCallback !== resetCallback) {
activeResetCallback();
}
activeResetCallback = resetCallback;
}
export function unregisterActiveEditor(resetCallback: () => void) {
if (activeResetCallback === resetCallback) {
activeResetCallback = null;
}
}
export function resetActiveEditor() {
if (activeResetCallback) {
activeResetCallback();
activeResetCallback = null;
}
}

37
website/src/lib/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface DouxEvent {
doux?: string;
s?: string;
sound?: string;
n?: string | number;
freq?: number;
wave?: string;
file_pcm?: number;
file_frames?: number;
file_channels?: number;
file_freq?: number;
[key: string]: string | number | undefined;
}
export interface SoundInfo {
pcm_offset: number;
frames: number;
channels: number;
freq: number;
}
export interface ClockMessage {
clock: boolean;
t0: number;
t1: number;
latency: number;
}
export interface DouxOptions {
onTick?: (msg: ClockMessage) => void;
base?: string;
}
export interface PreparedMessage {
evaluate: boolean;
event_input: Uint8Array;
}

View File

@@ -0,0 +1,2 @@
export const prerender = true;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,4 @@
// Layout doesn't need to load content - it's loaded by +page.ts
export function load() {
return {};
}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import "../app.css";
import Nav from "$lib/components/Nav.svelte";
import { doux } from "$lib/doux";
import { stopScope, resetActiveEditor } from "$lib/scope";
let { children } = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
resetActiveEditor();
stopScope();
doux.hush();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<Nav />
<div class="layout">
{@render children()}
</div>

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import CodeEditor from "$lib/components/CodeEditor.svelte";
</script>
<main class="tutorial">
<section class="intro">
<p>
A monolithic audio engine for live coding ported from C to Rust by <a
href="https://raphaelforment.fr">BuboBubo</a
>. The initial project is called
<a href="https://dough.strudel.cc/">Dough</a>, designed by
<a href="https://eddyflux.cc/">Felix Roos</a>
and al. Doux can run in a web browser via
<a href="https://webassembly.org/">WebAssembly</a>, or natively
using an an OSC server and/or a REPL. Doux is an opinionated, fixed
path, semi-modular synth that is remote-controlled via messages. It
was designed to be used with
<a href="https://strudel.cc">Strudel</a> and
<a href="https://tidalcycles.org">TidalCycles</a>.
<b
>This fork is a bit special: it adapts and specialize the engine
for integration with
<a href="https://sova.livecoding.fr">Sova</a>, a live coding
environment built in Rust</b
>.
</p>
<p>
<b>Important note</b>: this project is AGPL 3.0 licensed. We
encourage you to support the development of the original version
through the
<a href="https://opencollective.com/tidalcycles"
>TidalCycles Open Collective</a
>. See the license page for more information.
</p>
<a href="/support" class="support-link">License & Support</a>
</section>
<section>
<h2>Getting Started</h2>
<p>
Click anywhere on the page to start the audio context. Then click
inside a code block and press <code>Ctrl+Enter</code> to run it, or
<code>Escape</code> to stop. The easiest way to start is just to
specify a <code>sound</code> to use:
</p>
<CodeEditor code={`/sound/sine`} rows={2} />
<p>
You can set the pitch with the <code>/note</code> parameter (MIDI note
numbers):
</p>
<CodeEditor code={`/sound/sine/note/64`} rows={2} />
<p>
Or use frequency directly with <code>/freq</code>:
</p>
<CodeEditor code={`/sound/sine/freq/330`} rows={2} />
</section>
<section>
<h2>Omitting parameters</h2>
<p>
It is possible to omit a large number of parameters or to
under-specify a voice. Doux has preconfigured defaults for most of
the core parameters.
</p>
<CodeEditor code={`/freq/330`} rows={2} />
<CodeEditor code={`/spread/5/decay/0.5`} rows={2} />
<p>
The default voice is always a <code>tri</code> with sensible envelope
defaults.
</p>
</section>
<section>
<h2>Sound Sources</h2>
<p>
There are multiple sound sources you can use, detailed in the
reference. You can also import your own audio samples or use a live
input as a source.
</p>
<CodeEditor code={`/sound/tri`} rows={2} />
<CodeEditor code={`/sound/saw`} rows={2} />
<CodeEditor code={`/sound/pulse/pw/0.25`} rows={2} />
<CodeEditor code={`/sound/white`} rows={2} />
<CodeEditor code={`/sound/pink`} rows={2} />
<CodeEditor code={`/sound/analog`} rows={2} />
<p>Turn on your microphone (and beware of feedback!):</p>
<CodeEditor code={`/sound/live/gain/2`} rows={2} />
</section>
<section>
<h2>Envelopes</h2>
<p>
The amplitude envelope controls how the sound fades in and out. It
uses the classic ADSR model: attack, decay, sustain, release.
</p>
<CodeEditor
code={`/sound/saw/attack/0.5/decay/0.2/sustain/0.0/release/1`}
rows={2}
/>
<p>
<code>/attack</code> is the time (in seconds) to reach full volume.<br
/>
<code>/decay</code> is the time to fall to the sustain level.<br />
<code>/sustain</code> is the level held while the note is on (0-1).<br
/>
<code>/release</code> is the time to fade to silence after note off.
</p>
<p>Try changing each parameter to hear how it affects the sound.</p>
</section>
<section>
<h2>Filters</h2>
<p>
Most of the default sources are producing very rich timbres, full of
harmonics. You are likely to play a lot with filters to remove some
components of the spectrum. Doux has all the basic filters needed:
</p>
<CodeEditor code={`/sound/saw/lpf/800`} rows={2} />
<p>
All the basic filters come with a control over resonance <code
>/..q</code
>:
</p>
<CodeEditor code={`/sound/saw/lpf/800/lpq/10`} rows={2} />
<CodeEditor code={`/sound/white/hpf/2000`} rows={2} />
<CodeEditor code={`/sound/white/bpf/1000/bpq/20`} rows={2} />
</section>
<section>
<h2>Effects</h2>
<p>Doux includes several effects. Here's a sound with reverb:</p>
<CodeEditor code={`/sound/saw/note/48/reverb/0.8/decay/0.2`} rows={2} />
<p>And now another sound with a delay:</p>
<CodeEditor
code={`/sound/saw/note/60/delay/0.5/delaytime/0.3/delayfeedback/0.6/decay/0.5`}
rows={2}
/>
<p>If you stack up effects, it can become quite crazy:</p>
<CodeEditor
code={`/sound/saw/note/48/delay/0.5/delaytime/0.1/delayfeedback/0.8/decay/1.5/fanger/0.5/coarse/12/phaser/0.9/gain/1/phaserfeedback/0.9`}
rows={2}
/>
<p>
Note that the order in which the effects is applied is fixed by
default!
</p>
</section>
</main>
<style>
.tutorial {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.tutorial h2 {
margin-top: 2.5em;
margin-bottom: 0.5em;
color: #000;
}
.intro {
padding-bottom: 1em;
}
.support-link {
display: block;
margin: 1.5em 0;
padding: 12px 24px;
background: #f5f5f5;
border: 1px solid #ccc;
color: #000;
text-decoration: none;
text-align: center;
}
.support-link:hover {
border-color: #999;
background: #eee;
}
</style>

View File

@@ -0,0 +1,3 @@
export function load() {
return {};
}

View File

@@ -0,0 +1,167 @@
<main class="native-page">
<h1>Native</h1>
<p>
The web version is really fun if you want to build a website that uses a
robust open source audio engine. However, Doux was ported from C mostly
to be used natively. You will need to compile it yourself or use it
directly as it is integrated in <a href="https://sova.livecoding.fr"
>Sova</a
>. Read the instructions in the repo to learn how to compile it. You
will need the Rust toolchain that you can get using
<a href="https://rustup.rs/">Rustup</a>.
</p>
<br />
<section>
<h2>Binaries</h2>
<ul>
<li>
<code>doux</code> runs as an OSC server, listening on a configurable
port (default 57120). Use it with TidalCycles, Strudel, or Sova.
It will listen to any incoming message until it is killed.
</li>
<br />
<li>
REPL with readline support and command history saved to
<code>~/.doux_history</code>. Built-in commands:
<code>.quit</code>, <code>.reset</code>, <code>.hush</code>,
<code>.panic</code>, <code>.voices</code>, <code>.time</code>.
</li>
</ul>
</section>
<section>
<h2>Flags</h2>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Short</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--samples</code></td>
<td><code>-s</code></td>
<td>Directory containing audio samples</td>
</tr>
<tr>
<td><code>--list-devices</code></td>
<td></td>
<td>List audio devices and exit</td>
</tr>
<tr>
<td><code>--input</code></td>
<td><code>-i</code></td>
<td>Input device (name or index)</td>
</tr>
<tr>
<td><code>--output</code></td>
<td><code>-o</code></td>
<td>Output device (name or index)</td>
</tr>
<tr>
<td><code>--channels</code></td>
<td></td>
<td>Number of output channels (default: 2)</td>
</tr>
<tr>
<td><code>--buffer-size</code></td>
<td><code>-b</code></td>
<td>Audio buffer size in samples (default: system)</td>
</tr>
<tr>
<td><code>--port</code></td>
<td><code>-p</code></td>
<td>OSC port, <code>doux</code> only (default: 57120)</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Multichannel</h2>
<p>
The <code>--channels</code> flag enables multichannel output beyond
stereo. The number of channels is clamped to your device's maximum
supported count. The <code>orbit</code> parameter controls output routing.
With N output channels, there are N/2 stereo pairs. Voices on orbit 0
output to channels 01, orbit 1 to channels 23, and so on. When the
orbit exceeds the number of pairs, it wraps around via modulo. Effects
(delay, reverb) follow the same routing: each orbit's effect bus outputs
to its corresponding stereo pair.
</p>
</section>
<section>
<h2>Buffer size</h2>
<p>
The <code>--buffer-size</code> flag controls audio latency. Lower values
mean less latency but require more CPU. Common values: 64, 128, 256, 512,
1024. At 48kHz, 256 samples gives ~5.3ms latency. If unset, the system
chooses a default. Use lower values for live performance, higher values
for stability.
</p>
</section>
<section>
<h2>Sample loading</h2>
<p>
The <code>--samples</code> flag allows you to (lazy-)load audio
samples to play with. The engine expects a folder containing folders
of audio samples. Samples will be available using the folder name.
You can index into a folder by using the <code>/n/</code> command. Check
the reference to learn more about this.
</p>
</section>
</main>
<style>
.native-page {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.native-page h1 {
font-size: 18px;
margin-top: 0;
margin-bottom: 1.5em;
color: #000;
}
.native-page h2 {
margin-top: 2em;
margin-bottom: 0.5em;
color: #000;
}
.native-page section:first-of-type h2 {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
}
th,
td {
text-align: left;
padding: 8px 12px;
border: 1px solid #ccc;
}
th {
background: #f5f5f5;
}
code {
background: #f5f5f5;
padding: 2px 4px;
}
</style>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Component } from "svelte";
import Sidebar from "$lib/components/Sidebar.svelte";
interface Category {
path: string;
title: string;
slug: string;
group: string;
order: number;
component: Component;
}
interface NavItem {
name: string;
category: string;
group: string;
}
interface Props {
data: {
categories: Category[];
navigation: NavItem[];
};
}
let { data }: Props = $props();
</script>
<Sidebar items={data.navigation} />
<main class="content">
{#each data.categories as category}
{@const Component = category.component}
<section id={category.slug} class="category">
<h2 class="category-title">{category.title}</h2>
<Component />
</section>
{/each}
</main>
<style>
.category-title {
border-bottom: 1px solid #ccc;
padding-bottom: 8px;
margin-bottom: 24px;
font-size: 1.2em;
}
.category :global(h2:not(.category-title)) {
background: #f5f5f5;
border: 1px solid #ccc;
font-size: 1em;
font-weight: normal;
margin: 24px 0 8px;
padding: 8px 12px;
}
</style>

View File

@@ -0,0 +1,41 @@
import type { Component } from "svelte";
import navigation from "$lib/navigation.json";
const contentModules = import.meta.glob("/src/content/*.md", { eager: true });
interface ContentMetadata {
title: string;
slug: string;
group: string;
order: number;
}
interface ContentModule {
metadata: ContentMetadata;
default: Component;
}
export function load() {
const categories = Object.entries(contentModules).map(([path, module]) => {
const mod = module as ContentModule;
return {
path,
...mod.metadata,
component: mod.default,
};
});
const groupOrder = { sources: 0, synthesis: 1, effects: 2 };
categories.sort((a, b) => {
const groupDiff =
(groupOrder[a.group as keyof typeof groupOrder] ?? 99) -
(groupOrder[b.group as keyof typeof groupOrder] ?? 99);
if (groupDiff !== 0) return groupDiff;
return a.order - b.order;
});
return {
categories,
navigation,
};
}

View File

@@ -0,0 +1,105 @@
<script>
</script>
<main class="support-page">
<h1>License & Support</h1>
<section>
<p>
Doux is free & open source software under the
<a href="https://www.gnu.org/licenses/agpl-3.0.html"
>GNU Affero General Public License</a
> (AGPL-3.0). This means you are free to use, modify, and distribute Doux,
as long as any modifications are also released under the same license.
</p>
</section>
<section>
<p>
The original Dough project is part of the <a
href="https://tidalcycles.org">TidalCycles</a
> ecosystem. If you find Doux useful, consider supporting Dough instead
through Open Collective. Your contributions help maintain and improve
Dough and related live coding projects.
</p>
<a href="https://opencollective.com/tidalcycles" class="donate-link">
Support on Open Collective
</a>
</section>
<section>
<h2>Credits</h2>
<ul>
<li>
Doux is a Rust port of <a href="https://dough.strudel.cc/"
>Dough</a
>, originally written in C by
<a href="https://github.com/felixroos">Felix Roos</a>. This
project is AGPL 3.0 licensed, just like Doux!
</li>
<li>
<a href="https://github.com/sourcebox/mi-plaits-dsp-rs"
>mi-plaits-dsp-rs</a
>
is a Rust port of the code used by the
<a
href="https://pichenettes.github.io/mutable-instruments-documentation/modules/plaits/"
>Mutable Instruments Plaits</a
>.
<ul>
<li>
<b>Author:</b> Oliver Rockstedt
<a href="mailto:info@sourcebox.de">info@sourcebox.de</a>
</li>
<li>
<b>Original author:</b> Emilie Gillet
<a href="mailto:emilie.o.gillet@gmail.com"
>emilie.o.gillet@gmail.com</a
>
</li>
</ul>
</li>
</ul>
</section>
</main>
<style>
.support-page {
max-width: 650px;
margin: 0 auto;
padding: 20px 20px 60px;
overflow-y: auto;
height: 100%;
}
.support-page h1 {
font-size: 18px;
margin-top: 0;
margin-bottom: 1.5em;
color: #000;
}
.support-page h2 {
margin-top: 2em;
margin-bottom: 0.5em;
color: #000;
}
.support-page section:first-of-type h2 {
margin-top: 0;
}
.donate-link {
display: inline-block;
margin-top: 1em;
padding: 12px 24px;
background: #f5f5f5;
border: 1px solid #ccc;
color: #000;
text-decoration: none;
}
.donate-link:hover {
background: #eee;
}
</style>

0
website/static/.nojekyll Normal file
View File

Some files were not shown because too many files have changed in this diff Show More