Initial voice pack: seven live-map voices for CoreScope
Voices taken from CoreScope feat/audio-voices (ac257569), plus a compat shim for stock audio.js, an idempotent install/uninstall script, a derived-image Dockerfile, and tests that run against a real public/ dir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6dc4592ed2
16 files changed
+2872
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Stock CoreScope image + the extra live-map voices.
|
||||
# docker build -t corescope-voices .
|
||||
# Pin a CoreScope version with: --build-arg CORESCOPE_IMAGE=ghcr.io/kpa-clawbot/corescope:<tag>
|
||||
ARG CORESCOPE_IMAGE=ghcr.io/kpa-clawbot/corescope:latest
|
||||
FROM ${CORESCOPE_IMAGE}
|
||||
|
||||
COPY install.sh /opt/corescope-voices/install.sh
|
||||
COPY voices/ /opt/corescope-voices/voices/
|
||||
RUN sh /opt/corescope-voices/install.sh /app/public
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,156 @@
|
||||
# corescope-voices
|
||||
|
||||
Seven extra **audio voices** for the [CoreScope](https://github.com/Kpa-clawbot/CoreScope)
|
||||
Live page. CoreScope can turn every incoming mesh packet into sound; a *voice*
|
||||
decides what that sounds like. Stock CoreScope ships one (`constellation`).
|
||||
This pack adds:
|
||||
|
||||
| Voice | Style |
|
||||
|-------|-------|
|
||||
| `metal` | Distorted drop-D guitar: power chords, palm-mute chugs, leads, bends |
|
||||
| `synthmetal` | Metal guitar + supersaws, octave bass, kick/gated snare, ping-pong delay |
|
||||
| `technoir` | Dark 80s synthwave: driving bass, drum machine, chorused pads |
|
||||
| `ambient` | Slow FM bells and drone pads in a big hall, not tied to the beat |
|
||||
| `dub` | One-drop drums, offbeat skanks into a long feedback echo, sirens |
|
||||
| `acid` | TB-303 patterns from packet bytes over four-on-the-floor drums |
|
||||
| `lofi` | Swung boom-bap, electric-piano jazz chords, tape wobble, crackle |
|
||||
|
||||
The pack is front-end only: a few JavaScript files plus `<script>` tags in
|
||||
`index.html`. Nothing changes in CoreScope's server, database or config.
|
||||
|
||||
## What's in here
|
||||
|
||||
```
|
||||
voices/ the files that get copied into CoreScope's public/
|
||||
audio-voices-compat.js adds two helpers stock audio.js doesn't export yet
|
||||
audio-synthkit.js shared instruments and effects used by the voices
|
||||
audio-v2-metal.js … audio-v8-lofi.js the voices
|
||||
install.sh copies voices/ into a public/ dir and patches index.html
|
||||
Dockerfile stock CoreScope image + this pack
|
||||
tests/ install + smoke test against a real public/ dir
|
||||
docs/writing-voices.md how voices work, if you want to tweak or write one
|
||||
```
|
||||
|
||||
`install.sh` adds the tags right after `audio-v1-constellation.js`, so
|
||||
`constellation` stays the default voice. The tags sit between
|
||||
`<!-- corescope-voices:begin/end -->` markers, so re-running the script
|
||||
replaces them instead of adding duplicates, and `--uninstall` removes them
|
||||
cleanly. If your CoreScope already ships the voices, the script does nothing.
|
||||
|
||||
## Install
|
||||
|
||||
Pick the option that matches how you run CoreScope.
|
||||
|
||||
### Option 1: Docker, build a derived image (recommended)
|
||||
|
||||
This layers the pack on top of the official image, so you always get the
|
||||
upstream front end and back end with only the voices added.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<you>/corescope-voices.git
|
||||
cd corescope-voices
|
||||
docker build -t corescope-voices:latest .
|
||||
```
|
||||
|
||||
To pin a CoreScope version instead of `latest`:
|
||||
|
||||
```bash
|
||||
docker build --build-arg CORESCOPE_IMAGE=ghcr.io/kpa-clawbot/corescope:<tag> -t corescope-voices:<tag> .
|
||||
```
|
||||
|
||||
Then in your `docker-compose.yml` (or Portainer stack), change only the image
|
||||
line and redeploy:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
corescope:
|
||||
image: corescope-voices:latest # was ghcr.io/kpa-clawbot/corescope:latest
|
||||
```
|
||||
|
||||
**When CoreScope releases an update**, pull the new upstream image and rebuild:
|
||||
`docker build --pull -t corescope-voices:latest .`. Tooling that "pulls
|
||||
latest" for you (Watchtower, a Portainer "re-pull image" redeploy) can't pull a
|
||||
locally built image. Either rebuild on a schedule before it runs, or push the
|
||||
image to your own registry and point the stack there.
|
||||
|
||||
### Option 2: Docker, bind-mount a patched `public/`
|
||||
|
||||
Use this if you'd rather not build images. The downside is that your mounted
|
||||
front end stays fixed while the image updates, so you have to redo these steps
|
||||
after every CoreScope upgrade.
|
||||
|
||||
```bash
|
||||
# 1. Copy the front end out of the image you actually run
|
||||
docker create --name cs-tmp ghcr.io/kpa-clawbot/corescope:latest
|
||||
docker cp cs-tmp:/app/public ./corescope-public
|
||||
docker rm cs-tmp
|
||||
|
||||
# 2. Install the voices into that copy
|
||||
./install.sh ./corescope-public
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 3. Mount it over the image's copy
|
||||
services:
|
||||
corescope:
|
||||
volumes:
|
||||
- /absolute/path/to/corescope-public:/app/public:ro
|
||||
```
|
||||
|
||||
Then redeploy or restart the container. Don't `docker cp` the files *into* a
|
||||
running container: they're lost the next time it's recreated.
|
||||
|
||||
### Option 3: Running CoreScope from source
|
||||
|
||||
```bash
|
||||
./install.sh /path/to/CoreScope/public
|
||||
```
|
||||
|
||||
Restart the CoreScope server. It reads `index.html` only at startup.
|
||||
|
||||
## Turn it on
|
||||
|
||||
1. Open the **Live** page.
|
||||
2. Switch on **Audio**. The **Voice** dropdown only shows after audio is
|
||||
enabled. That's the usual reason someone reports "no dropdown."
|
||||
3. Pick a voice. Your choice is remembered in the browser.
|
||||
|
||||
The voices are also available in the Audio Lab (`#/audio-lab`).
|
||||
|
||||
Hard-refresh the page once after installing if your browser cached the old
|
||||
`index.html`.
|
||||
|
||||
## Uninstall
|
||||
|
||||
- Option 1: point the stack back at `ghcr.io/kpa-clawbot/corescope:latest`.
|
||||
- Option 2: remove the volume line, or run `./install.sh --uninstall ./corescope-public`.
|
||||
- Option 3: `./install.sh --uninstall /path/to/CoreScope/public` and restart.
|
||||
|
||||
## Test
|
||||
|
||||
Needs `node` and a CoreScope `public/` dir (a checkout, or one copied out of the
|
||||
image as in Option 2). It works on a temp copy and never modifies the directory
|
||||
you pass in:
|
||||
|
||||
```bash
|
||||
tests/run.sh /path/to/CoreScope/public
|
||||
```
|
||||
|
||||
It checks that install is idempotent, that all seven voices register with
|
||||
`constellation` still the default, that every voice plays every packet type
|
||||
through the real CoreScope audio engine, and that uninstall restores
|
||||
`index.html` byte for byte.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Tested against CoreScope `master` as of 2026-10-01. The pack depends on:
|
||||
|
||||
- `MeshAudio.registerVoice` and `MeshAudio.helpers` in CoreScope's `public/audio.js`
|
||||
- a `<script src="audio-v1-constellation.js…">` line in `index.html` (used as the anchor)
|
||||
|
||||
If a future CoreScope release changes either one, `install.sh` stops with an
|
||||
error rather than producing a broken page.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0, same as CoreScope. See `LICENSE`.
|
||||
@@ -0,0 +1,340 @@
|
||||
> Copied from the CoreScope `feat/audio-voices` branch. Paths like `public/audio-v2-metal.js`
|
||||
> refer to where the files live inside CoreScope; in this repo they are under `voices/`.
|
||||
> Stock CoreScope does not export `sampleBytes`/`panFor` yet; `voices/audio-voices-compat.js` adds them.
|
||||
|
||||
# Live map audio voices
|
||||
|
||||
The Live page can turn each incoming packet into sound. *How* a packet sounds is
|
||||
decided by a **voice**: a small, self-contained JavaScript module that receives a
|
||||
parsed packet and schedules Web Audio nodes. Voices are pluggable. Add a file,
|
||||
register it, and it shows up in the **Voice** dropdown on the Live page and in
|
||||
the Audio Lab (`#/audio-lab`).
|
||||
|
||||
This guide covers how the pieces fit together, the voice contract, the shared
|
||||
building blocks, and how to test a new voice.
|
||||
|
||||
## Existing voices
|
||||
|
||||
| Voice | File | Style |
|
||||
|-------|------|-------|
|
||||
| `constellation` | `public/audio-v1-constellation.js` | Melodic sine/triangle tones, scale per packet type (default voice) |
|
||||
| `metal` | `public/audio-v2-metal.js` | Distorted drop-D guitar: power chords, palm-mute chugs, leads, bends |
|
||||
| `synthmetal` | `public/audio-v3-synthmetal.js` | Metal guitar + supersaws, octave bass, kick/gated snare, ping-pong delay |
|
||||
| `technoir` | `public/audio-v4-technoir.js` | Dark 80s synthwave: driving bass, drum machine, chorused pads |
|
||||
| `ambient` | `public/audio-v5-ambient.js` | Slow FM bells and drone pads in a big hall, not tied to the beat |
|
||||
| `dub` | `public/audio-v6-dub.js` | One-drop drums, offbeat skanks into a long feedback echo, sirens |
|
||||
| `acid` | `public/audio-v7-acid.js` | TB-303 patterns from packet bytes over four-on-the-floor drums |
|
||||
| `lofi` | `public/audio-v8-lofi.js` | Swung boom-bap, electric-piano jazz chords, tape wobble, crackle |
|
||||
|
||||
`metal` doubles as a guitar rig that other voices reuse. Every voice after it
|
||||
is built on the shared synth kit (`public/audio-synthkit.js`).
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
live.js ──(each packet)──▶ MeshAudio.sonifyPacket(pkt) public/audio.js
|
||||
│ parsePacketBytes(pkt) → parsed
|
||||
│ voice-slot cap (MAX_VOICES = 12)
|
||||
▼
|
||||
currentVoice.play(audioCtx, masterGain, parsed, opts)
|
||||
│ schedules oscillators/filters/gains
|
||||
▼
|
||||
masterGain (volume slider) → speakers
|
||||
```
|
||||
|
||||
- **`public/audio.js`** is the engine. It owns the `AudioContext`, the master
|
||||
gain (the volume slider), BPM, the voice registry and the enabled/voice/BPM/
|
||||
volume settings in `localStorage`. It also exposes shared helpers for voices.
|
||||
- **`public/audio-synthkit.js`** (`MeshAudio.synthkit`) holds shared instruments
|
||||
(supersaw, FM bell/electric piano, 303 acid, bass, kick, snare, hi-hat, vinyl
|
||||
crackle) and effects buses (gated reverb, hall, ping-pong delay, dub echo,
|
||||
chorus).
|
||||
- **`public/audio-v*.js`** are the voices. Each is an IIFE that calls
|
||||
`MeshAudio.registerVoice(...)` when it loads.
|
||||
- **`public/index.html`** loads them in order. Load order matters (see below).
|
||||
|
||||
The first voice to register becomes the default, so `constellation` must stay
|
||||
first. A saved choice (`localStorage['live-audio-voice']`) is restored when the
|
||||
Live page initialises. The Voice dropdown only appears once two or more voices
|
||||
are registered.
|
||||
|
||||
## The voice contract
|
||||
|
||||
```js
|
||||
MeshAudio.registerVoice(name, {
|
||||
name, // same string, lowercase
|
||||
play(audioCtx, masterGain, parsed, opts) { // required
|
||||
// schedule audio starting at audioCtx.currentTime + a small lookahead
|
||||
return durationSeconds; // how long this packet's notes run
|
||||
},
|
||||
// anything else you attach (e.g. a pure arrange() for tests) is allowed
|
||||
});
|
||||
```
|
||||
|
||||
### `parsed`: what you get about the packet
|
||||
|
||||
Built by `parsePacketBytes()` in `audio.js`. `play()` is never called when
|
||||
`payloadBytes` is empty.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `allBytes` | `number[]` | Every byte of the raw packet (0–255) |
|
||||
| `headerBytes` | `number[]` | First 3 bytes |
|
||||
| `payloadBytes` | `number[]` | Everything after the first 3 bytes, the usual melody source |
|
||||
| `typeName` | `string` | Decoded payload type, e.g. `ADVERT`, `GRP_TXT`, `TXT_MSG`, `TRACE`, `REQ`, or `UNKNOWN` |
|
||||
| `hopCount` | `number` | Path hops, at least 1 |
|
||||
| `obsCount` | `number` | How many observers heard the packet, at least 1 |
|
||||
| `payload` | `object` | Decoded payload. May carry `lat`/`lon` (adverts) |
|
||||
| `hops` | `array` | Decoded path hops |
|
||||
|
||||
Always handle an unrecognised `typeName` (fall back to a default treatment).
|
||||
|
||||
### `opts`
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `bpm` | Current BPM slider value (40–300) |
|
||||
| `tempoMultiplier` | `120 / bpm`. Multiply all your durations by it. A 16th note is `0.125 * opts.tempoMultiplier` seconds |
|
||||
|
||||
### Return value and the voice-slot cap
|
||||
|
||||
The engine plays at most `MAX_VOICES` (12) packets at once. Each `play()` call
|
||||
takes a slot, which is released `duration + 0.5` s later. Packets that arrive
|
||||
while every slot is busy are skipped silently.
|
||||
|
||||
Return how long **this packet's own notes** run. Don't include echo or reverb
|
||||
tails that ring in shared effects buses. Those don't cost the packet anything,
|
||||
and counting them makes busy traffic drop more packets than it needs to.
|
||||
|
||||
If `play()` throws, the engine catches it, logs `[audio] voice error:` and frees
|
||||
the slot.
|
||||
|
||||
## Shared helpers: `MeshAudio.helpers`
|
||||
|
||||
| Helper | What it does |
|
||||
|--------|--------------|
|
||||
| `buildScale(intervals, rootMidi)` | 3 octaves of MIDI notes, e.g. `buildScale([0,3,5,7,10], 62)` for D minor pentatonic |
|
||||
| `quantizeToScale(byte, scale)` | Maps a byte (0–255) evenly onto a scale array |
|
||||
| `midiToFreq(midi)` | MIDI note → Hz (A4 = 69 = 440 Hz) |
|
||||
| `mapRange(v, inMin, inMax, outMin, outMax)` | Linear mapping |
|
||||
| `sampleBytes(bytes, minN, maxN)` | Evenly picks `sqrt(len)` bytes, clamped to `[minN, maxN]`. Use it for note count |
|
||||
| `panFor(parsed)` | Stereo position: longitude when the packet has coordinates, a small random spread for routed packets, centre otherwise |
|
||||
|
||||
## Shared synth kit: `MeshAudio.synthkit`
|
||||
|
||||
Load `audio-synthkit.js` before any voice that uses it.
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `bus(audioCtx, masterGain, name)` | Lazily builds, once per `AudioContext`, and returns a shared bus: `'noise'` and `'crackle'` (`{ buffer }`), `'gated'`, `'hall'`, `'chorus'` (`{ input }`), `'delay'` (ping-pong) and `'dubecho'` (`{ input, delays: [...] }`; set `delays[i].delayTime.value` per packet to follow BPM) |
|
||||
| `gridStart(audioCtx, sixteenth)` | Next 16th-note boundary on a global grid. Start riffs here so overlapping packets stay in time |
|
||||
| `stepTime(t0, step, sixteenth, swing?)` | Time of a grid step, with odd 16ths pushed late by `swing` × a 16th. Start swung riffs with `gridStart(ctx, 2 * sixteenth)` |
|
||||
| `createMix(audioCtx, masterGain, level)` | Per-packet `{ mix, nodes }`: gain → glue limiter → master. Disconnect `nodes` when done |
|
||||
| `duck(gainParam, t, recover, depth?)` | Sidechain "pump": dips a bus on a kick |
|
||||
| `playSynth(ctx, dest, preset, midis, start, dur, { glideFrom?, sends? })` | Detuned oscillator stack through an enveloped resonant lowpass. `preset = { type?, detunes, cutoff: [start, peak, end], filterAttack?, q, attack, sustain, release, level }`. `sends = [[busInput, amount], ...]` |
|
||||
| `playFM(ctx, dest, preset, midis, start, dur, { sends?, pitchMod? })` | Two-operator FM: bells (inharmonic `ratio`) or electric piano (`ratio: 1`). `preset = { ratio, index: [peak, settled], indexDecay, attack, decay, sustain, release, level }`. `pitchMod` is a node (e.g. an LFO gain in cents) fed into every oscillator's detune |
|
||||
| `playAcid(ctx, dest, { midi, accent, slide, prevMidi }, start, dur, { cutoff, q, envMod, decay, level })` | One TB-303 step: resonant filter envelope, accents, slides |
|
||||
| `playBass(ctx, dest, midi, start, dur, { cutoff: [open, closed], q, level, maxDecay }?)` | Plucked saw bass |
|
||||
| `playKick(ctx, dest, start, { from, to, sweep, decay, level }?)` | Sine kick with pitch drop |
|
||||
| `playSnare(ctx, dest, noiseBuffer, start, sends?, { level, decay, body }?)` | Noise snare with tonal body |
|
||||
| `playHat(ctx, dest, noiseBuffer, start, level)` | Closed hi-hat |
|
||||
| `playCrackle(ctx, dest, crackleBuffer, start, end, level)` | Looped vinyl crackle under a packet |
|
||||
|
||||
Every `play*` function returns the time its nodes stop, so you can track the
|
||||
packet's end time. Each one disconnects its own nodes in `onended`.
|
||||
|
||||
### Guitar rig (from `metal`)
|
||||
|
||||
`window._meshAudioVoices.metal` exposes `buildAmp(ctx, dest, cabLowpassHz,
|
||||
level, pan)` → `{ input, nodes }` and `playNote(ctx, ampInput, event, start,
|
||||
dur)`. `event = { midis, tech }` with `tech` one of `'chord'`, `'mute'`,
|
||||
`'lead'`, `'bend'`, `'pinch'`. Load `audio-v2-metal.js` before any voice that
|
||||
uses it.
|
||||
|
||||
## Adding a voice, step by step
|
||||
|
||||
### 1. Create the file
|
||||
|
||||
Name it `public/audio-v<N>-<name>.js` (the next free number is 9). A minimal working voice:
|
||||
|
||||
```js
|
||||
// Voice v9: "Chime" — one triangle-wave note per sampled byte
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { buildScale, quantizeToScale, midiToFreq, sampleBytes, panFor } = MeshAudio.helpers;
|
||||
const SCALE = buildScale([0, 2, 4, 7, 9], 60); // C major pentatonic from C4
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const out = audioCtx.createStereoPanner();
|
||||
out.pan.value = panFor(parsed);
|
||||
out.connect(masterGain);
|
||||
|
||||
const t0 = audioCtx.currentTime + 0.02; // small lookahead
|
||||
let end = t0;
|
||||
sampleBytes(parsed.payloadBytes, 3, 8).forEach((b, i) => {
|
||||
const start = t0 + i * 2 * sixteenth;
|
||||
const stop = start + 1.5 * sixteenth;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const env = audioCtx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = midiToFreq(quantizeToScale(b, SCALE));
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(0.5, start + 0.01);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, stop);
|
||||
osc.connect(env);
|
||||
env.connect(out);
|
||||
osc.start(start);
|
||||
osc.stop(stop + 0.05);
|
||||
osc.onended = () => { osc.disconnect(); env.disconnect(); };
|
||||
end = stop + 0.05;
|
||||
});
|
||||
|
||||
setTimeout(() => out.disconnect(), (end - audioCtx.currentTime + 1) * 1000);
|
||||
return end - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('chime', { name: 'chime', play });
|
||||
})();
|
||||
```
|
||||
|
||||
### 2. Load it in `public/index.html`
|
||||
|
||||
Add a script tag after the voices it depends on and before `audio-lab.js`,
|
||||
matching the existing tags:
|
||||
|
||||
```html
|
||||
<script src="audio-v9-chime.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
```
|
||||
|
||||
The order is `audio.js` → `audio-synthkit.js` → `audio-v1-constellation.js`
|
||||
(the default) → `audio-v2-metal.js` → other voices. Leave `__BUST__` as it is.
|
||||
The server swaps it for a cache-busting timestamp at startup, so **restart the
|
||||
server** after editing `index.html`.
|
||||
|
||||
### 3. Try it
|
||||
|
||||
Open `#/live`, tick **Audio**, and pick your voice in the Voice dropdown. For
|
||||
repeatable listening, use the Audio Lab (`#/audio-lab`). It has the same voice
|
||||
selector, a sidebar of sample packets grouped by type, and **Play** / **Loop**
|
||||
buttons. Its "Sound Mapping" table and per-note breakdown describe the
|
||||
`constellation` voice's mapping, not yours.
|
||||
|
||||
## Design guidelines
|
||||
|
||||
These are the patterns the existing voices follow. They are what keep audio
|
||||
cheap on a busy mesh (30K+ packets, several arriving per second).
|
||||
|
||||
- **Separate the music from the audio.** Write a pure `arrange(parsed)` (or
|
||||
`buildRiff`) that returns note events on a step grid, and a `play()` that
|
||||
renders them. The pure part can be unit-tested exactly. Expose it on the
|
||||
voice object (`registerVoice('x', { name, play, arrange })`).
|
||||
- **Be deterministic.** The same packet should always produce the same riff.
|
||||
Derive everything from the packet's bytes and fields. The only randomness
|
||||
allowed is `panFor`'s spread and noise buffers.
|
||||
- **Follow the tempo.** Scale every duration by `opts.tempoMultiplier`. If your
|
||||
voice has drums or a groove, start on `kit.gridStart()` so concurrent packets
|
||||
lock together.
|
||||
- **Budget oscillators.** Stay at or below **128 oscillators per packet** in the
|
||||
worst case (the longest payload, the highest `obsCount`, every type). For a
|
||||
sine sub bass, use `playSynth` with `type: 'sine'` and one detune rather
|
||||
than a new instrument. Use
|
||||
`sampleBytes` to bound the note count. Limit detuned stacks to 3–5
|
||||
oscillators.
|
||||
- **Never build expensive nodes per packet.** Convolvers (reverb), long delays
|
||||
and LFOs belong in shared buses, built once per `AudioContext` (`kit.bus`, or
|
||||
your own `WeakMap` keyed by the context).
|
||||
- **Clean up.** Disconnect every per-note node in the oscillator's `onended`, and
|
||||
disconnect per-packet buses in a `setTimeout` after the last note.
|
||||
- **Exponential ramps can't reach 0.** Use `0.0001` as "silent" for
|
||||
`exponentialRampToValueAtTime`.
|
||||
- **Match the other voices' loudness.** At master gain 0.3, the existing voices
|
||||
land around −33 to −24 dB RMS while sounding, with peaks well under 1.0. Check
|
||||
yours with the snippet below and adjust the per-packet level.
|
||||
|
||||
### Measuring level and output in the browser
|
||||
|
||||
Run this in the dev tools console on `#/live`. It renders offline, so nothing
|
||||
plays out loud.
|
||||
|
||||
```js
|
||||
const bytes = Array.from({ length: 40 }, (_, i) => (i * 97 + 13) & 255);
|
||||
for (const t of ['ADVERT', 'GRP_TXT', 'TXT_MSG', 'TRACE']) {
|
||||
const oc = new OfflineAudioContext(2, 44100 * 5, 44100);
|
||||
const master = oc.createGain(); master.gain.value = 0.3; master.connect(oc.destination);
|
||||
_meshAudioVoices.chime.play(oc, master, { payloadBytes: bytes, typeName: t, hopCount: 2, obsCount: 4, payload: {}, hops: [] }, { bpm: 120, tempoMultiplier: 1 });
|
||||
const d = (await oc.startRendering()).getChannelData(0);
|
||||
let peak = 0, sum = 0, n = 0;
|
||||
for (const x of d) { const a = Math.abs(x); peak = Math.max(peak, a); if (a > 1e-3) { sum += x * x; n++; } }
|
||||
console.log(t, 'peak', peak.toFixed(3), 'rms', (10 * Math.log10(sum / Math.max(1, n))).toFixed(1), 'dB');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Every voice needs tests (see `AGENTS.md`). Two layers:
|
||||
|
||||
### Unit tests (`tests/unit/test-audio-<name>.js`)
|
||||
|
||||
`tests/unit/audio-harness.js` loads the real `public/audio*.js` files into a
|
||||
`vm` context with a fake `AudioContext` that records every node, connection and
|
||||
parameter automation:
|
||||
|
||||
```js
|
||||
const assert = require('assert');
|
||||
const { loadAudio, playOnce, reaches, parsed } = require('./audio-harness');
|
||||
|
||||
const ctx = loadAudio(['public/audio-v1-constellation.js', 'public/audio-v9-chime.js']);
|
||||
const chime = ctx._meshAudioVoices.chime;
|
||||
|
||||
// Render one packet and inspect the graph
|
||||
const r = playOnce(chime, parsed('ADVERT', [1, 2, 3, 4, 5, 6, 7, 8, 9]));
|
||||
assert.ok(r.dur > 0);
|
||||
r.nodes.filter((n) => n.kind === 'osc').forEach((o) => assert.ok(reaches(o, r.master)));
|
||||
```
|
||||
|
||||
`voiceBasics(test, ctx, name, budget)` from the harness runs the checks every
|
||||
voice needs: registration with `constellation` still the default, every
|
||||
oscillator heard or modulating, the worst-case oscillator budget, deterministic
|
||||
`arrange()`, and engine integration. Load all voices with `loadAudio(ALL_VOICE_FILES)`
|
||||
after adding yours to `ALL_VOICE_FILES`. See `test-audio-dub.js` for a compact
|
||||
example, and `test-audio-technoir.js` for a longer one.
|
||||
|
||||
Also worth covering:
|
||||
|
||||
- `arrange()` output for each type: notes in the intended scale, rhythm on the
|
||||
grid, fallback for unknown types, same bytes giving the same result
|
||||
- the audio graph: every oscillator reaches the master; effects are shared
|
||||
(built once per context); worst-case oscillator count stays within budget
|
||||
- `MeshAudio.sonifyPacket()` with the voice selected doesn't throw for any type
|
||||
|
||||
Arrays created inside the `vm` context have a different prototype. Spread them
|
||||
(`[...arr]`) before `assert.deepStrictEqual`.
|
||||
|
||||
Add the file to `test-all.sh` in alphabetical order. The inventory check
|
||||
(`tests/unit/test-test-inventory.js`) fails if a `test-*.js` file isn't listed.
|
||||
|
||||
### E2E (`tests/e2e/test-audio-live-1297-e2e.js`)
|
||||
|
||||
This test stubs `AudioContext` in the browser and drives the Live page. Add your
|
||||
voice to the voice list checks: the dropdown lists it, selecting it persists,
|
||||
and it plays every type without logging `[audio] voice error`. If your voice
|
||||
uses a Web Audio node the stub doesn't provide yet, add it to the
|
||||
`FakeAudioContext` there.
|
||||
|
||||
## Trying a voice on a running Docker container
|
||||
|
||||
The image copies `public/` into `/app/public` at build time. To try a voice on
|
||||
an existing container without rebuilding:
|
||||
|
||||
```sh
|
||||
docker cp public/audio-v9-chime.js <container>:/app/public/
|
||||
docker exec <container> sed -i 's#\(<script src="audio-v8-lofi.js[^>]*></script>\)#\1\n <script src="audio-v9-chime.js?v=__BUST__"></script>#' /app/public/index.html
|
||||
docker restart <container> # re-applies cache busting
|
||||
```
|
||||
|
||||
Also copy any shared files your voice depends on (`audio.js`,
|
||||
`audio-synthkit.js`, `audio-v2-metal.js`) if they're newer than the image's.
|
||||
Changes made this way are lost when the container is recreated. For a lasting
|
||||
change, rebuild the image.
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# Install (or remove) the extra live-map voices into a CoreScope public/ dir.
|
||||
#
|
||||
# ./install.sh <path/to/corescope/public> install or update
|
||||
# ./install.sh --uninstall <path/to/corescope/public> remove
|
||||
#
|
||||
# Copies voices/*.js into the directory and adds their <script> tags to
|
||||
# index.html right after audio-v1-constellation.js (so constellation stays
|
||||
# the default voice). Safe to re-run: the tag block is replaced, not repeated.
|
||||
# POSIX sh + awk only, so it also runs inside the Alpine CoreScope image.
|
||||
set -eu
|
||||
|
||||
BEGIN_MARK='<!-- corescope-voices:begin -->'
|
||||
END_MARK='<!-- corescope-voices:end -->'
|
||||
ANCHOR='audio-v1-constellation.js'
|
||||
# Load order matters: compat shim, synth kit, metal (guitar rig), then the rest.
|
||||
FILES='audio-voices-compat.js audio-synthkit.js audio-v2-metal.js audio-v3-synthmetal.js audio-v4-technoir.js audio-v5-ambient.js audio-v6-dub.js audio-v7-acid.js audio-v8-lofi.js'
|
||||
|
||||
die() { echo "install.sh: $*" >&2; exit 1; }
|
||||
|
||||
UNINSTALL=0
|
||||
if [ "${1:-}" = "--uninstall" ]; then UNINSTALL=1; shift; fi
|
||||
[ $# -eq 1 ] || die "usage: $0 [--uninstall] <corescope public dir>"
|
||||
|
||||
PUBLIC=$1
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
INDEX=$PUBLIC/index.html
|
||||
[ -f "$INDEX" ] || die "$INDEX not found (point me at CoreScope's public/ directory)"
|
||||
|
||||
TMP=$INDEX.voices.tmp
|
||||
# Strip any block from a previous install.
|
||||
awk -v b="$BEGIN_MARK" -v e="$END_MARK" \
|
||||
'index($0, b) { skip = 1 } !skip { print } index($0, e) { skip = 0 }' "$INDEX" > "$TMP"
|
||||
|
||||
if [ $UNINSTALL -eq 1 ]; then
|
||||
mv "$TMP" "$INDEX"
|
||||
for f in $FILES; do rm -f "$PUBLIC/$f"; done
|
||||
echo "Removed corescope-voices from $PUBLIC"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If CoreScope already ships the voices, installing would load them twice.
|
||||
if grep -q 'audio-synthkit.js' "$TMP"; then
|
||||
rm -f "$TMP"
|
||||
echo "This CoreScope already includes the extra voices; nothing to install."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grep -q "$ANCHOR" "$TMP" || { rm -f "$TMP"; die "no <script> tag for $ANCHOR in $INDEX; cannot place the voices"; }
|
||||
|
||||
for f in $FILES; do
|
||||
[ -f "$HERE/voices/$f" ] || { rm -f "$TMP"; die "missing $HERE/voices/$f"; }
|
||||
cp "$HERE/voices/$f" "$PUBLIC/$f"
|
||||
done
|
||||
|
||||
export TAGS
|
||||
TAGS=$(for f in $FILES; do
|
||||
printf ' <script src="%s?v=__BUST__" onerror="console.error('"'"'Failed to load:'"'"', this.src)"></script>\n' "$f"
|
||||
done)
|
||||
|
||||
awk -v a="$ANCHOR" -v b="$BEGIN_MARK" -v e="$END_MARK" '
|
||||
{ print }
|
||||
!done && index($0, a) { print " " b; print ENVIRON["TAGS"]; print " " e; done = 1 }
|
||||
' "$TMP" > "$INDEX"
|
||||
rm -f "$TMP"
|
||||
|
||||
echo "Installed corescope-voices into $PUBLIC. Restart CoreScope so it re-reads index.html."
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# End-to-end test of install.sh against a real CoreScope public/ directory.
|
||||
#
|
||||
# tests/run.sh <path/to/corescope/public>
|
||||
#
|
||||
# Works on a temporary copy; the directory you pass is never modified.
|
||||
set -eu
|
||||
|
||||
[ $# -eq 1 ] || { echo "usage: $0 <corescope public dir>" >&2; exit 2; }
|
||||
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
cp -R "$1" "$WORK/public"
|
||||
cp "$WORK/public/index.html" "$WORK/index.orig.html"
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
sh "$HERE/install.sh" "$WORK/public" >/dev/null
|
||||
sh "$HERE/install.sh" "$WORK/public" >/dev/null # re-run must not duplicate
|
||||
n=$(grep -c 'audio-v2-metal.js' "$WORK/public/index.html")
|
||||
[ "$n" -eq 1 ] || fail "expected 1 metal <script> tag after two installs, got $n"
|
||||
echo " ok install is idempotent"
|
||||
|
||||
node "$HERE/tests/smoke.js" "$WORK/public"
|
||||
|
||||
sh "$HERE/install.sh" --uninstall "$WORK/public" >/dev/null
|
||||
cmp -s "$WORK/index.orig.html" "$WORK/public/index.html" || fail "uninstall did not restore index.html"
|
||||
[ ! -e "$WORK/public/audio-v2-metal.js" ] || fail "uninstall left voice files behind"
|
||||
echo " ok uninstall restores the original"
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Smoke test: load a CoreScope public/ dir's audio scripts in the order its
|
||||
* index.html lists them, using a fake AudioContext, then check every pack
|
||||
* voice registered, constellation stayed the default, and each voice plays
|
||||
* every packet type through the real engine without throwing.
|
||||
*
|
||||
* node tests/smoke.js <corescope public dir, after install.sh>
|
||||
*/
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const PACK_VOICES = ['metal', 'synthmetal', 'technoir', 'ambient', 'dub', 'acid', 'lofi'];
|
||||
const TYPES = ['ADVERT', 'GRP_TXT', 'TXT_MSG', 'TRACE', 'REQ', 'UNKNOWN'];
|
||||
|
||||
function makeParam(value) {
|
||||
const p = { value: value || 0 };
|
||||
['setValueAtTime', 'linearRampToValueAtTime', 'exponentialRampToValueAtTime', 'setTargetAtTime', 'cancelScheduledValues']
|
||||
.forEach((m) => { p[m] = () => p; });
|
||||
return p;
|
||||
}
|
||||
|
||||
class FakeCtx {
|
||||
constructor() { this.state = 'running'; this.currentTime = 0; this.sampleRate = 8000; this.destination = this._node(); }
|
||||
_node(extra) { return Object.assign({ connect: (t) => t, disconnect() {} }, extra); }
|
||||
createGain() { return this._node({ gain: makeParam(1) }); }
|
||||
createOscillator() { return this._node({ type: 'sine', frequency: makeParam(440), detune: makeParam(), start() {}, stop() {} }); }
|
||||
createBiquadFilter() { return this._node({ type: 'lowpass', frequency: makeParam(350), Q: makeParam(1), gain: makeParam() }); }
|
||||
createWaveShaper() { return this._node({ curve: null, oversample: 'none' }); }
|
||||
createDynamicsCompressor() { return this._node({ threshold: makeParam(), knee: makeParam(), ratio: makeParam(), attack: makeParam(), release: makeParam() }); }
|
||||
createStereoPanner() { return this._node({ pan: makeParam() }); }
|
||||
createDelay() { return this._node({ delayTime: makeParam() }); }
|
||||
createConvolver() { return this._node({ buffer: null }); }
|
||||
createBufferSource() { return this._node({ buffer: null, start() {}, stop() {} }); }
|
||||
createBuffer(channels, length) {
|
||||
const data = Array.from({ length: channels }, () => new Float32Array(length));
|
||||
return { numberOfChannels: channels, length, getChannelData: (c) => data[c] };
|
||||
}
|
||||
resume() {}
|
||||
}
|
||||
|
||||
const publicDir = process.argv[2];
|
||||
if (!publicDir) { console.error('usage: node tests/smoke.js <corescope public dir>'); process.exit(2); }
|
||||
|
||||
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
|
||||
const scripts = [...html.matchAll(/<script src="(audio[^"?]*\.js)/g)].map((m) => m[1]).filter((f) => f !== 'audio-lab.js');
|
||||
|
||||
const store = {};
|
||||
const ctx = {
|
||||
console, Math, Float32Array, Object, Array, WeakMap, Map, Set,
|
||||
setTimeout: () => 0, clearTimeout() {},
|
||||
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } },
|
||||
document: { createElement: () => ({}), body: { appendChild() {} }, addEventListener() {} },
|
||||
AudioContext: FakeCtx,
|
||||
};
|
||||
ctx.window = ctx;
|
||||
vm.createContext(ctx);
|
||||
scripts.forEach((f) => vm.runInContext(fs.readFileSync(path.join(publicDir, f), 'utf8'), ctx, { filename: f }));
|
||||
|
||||
let failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ok ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
const MA = ctx.MeshAudio;
|
||||
test(`scripts load in order: ${scripts.join(', ')}`, () => assert.ok(MA && MA.synthkit));
|
||||
test('every pack voice registers', () => {
|
||||
const names = MA.getVoiceNames();
|
||||
PACK_VOICES.forEach((v) => assert.ok(names.includes(v), `missing ${v} (have ${names.join(', ')})`));
|
||||
});
|
||||
test('constellation stays the default voice', () => assert.strictEqual(MA.getVoiceName(), 'constellation'));
|
||||
|
||||
MA.setEnabled(true);
|
||||
PACK_VOICES.forEach((name) => test(`${name} plays every packet type`, () => {
|
||||
MA.setVoice(name);
|
||||
TYPES.forEach((t) => MA.sonifyPacket({
|
||||
raw: 'a1b2c3' + '00112233445566778899aabbccddeeff', observation_count: 2,
|
||||
decoded: { header: { payloadTypeName: t }, payload: { lat: 40, lon: -100 }, path: { hops: ['ab'] } },
|
||||
}));
|
||||
// sonifyPacket swallows voice errors, so also call play() directly to surface them.
|
||||
const voice = ctx._meshAudioVoices[name];
|
||||
const ac = new FakeCtx();
|
||||
TYPES.forEach((t) => {
|
||||
const dur = voice.play(ac, ac.createGain(), { payloadBytes: [0, 0x42, 0x9a, 0xff, 0x11, 0xc3], typeName: t, hopCount: 2, obsCount: 3, payload: {}, hops: [] }, { bpm: 120, tempoMultiplier: 1 });
|
||||
assert.ok(dur > 0, `${t} duration ${dur}`);
|
||||
});
|
||||
}));
|
||||
|
||||
console.log(failed ? `\n${failed} failed` : '\nall passed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,451 @@
|
||||
// Synth kit — shared instruments and effects for the synth-based voices
|
||||
// (synthmetal, technoir, ambient, dub, acid, lofi). Exposed as MeshAudio.synthkit.
|
||||
// Effects buses are built lazily, once per AudioContext, and shared by every packet.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { midiToFreq } = MeshAudio.helpers;
|
||||
|
||||
// === Shared effects buses ===
|
||||
|
||||
const BUILDERS = {
|
||||
// White noise buffer for snares and hats
|
||||
noise(audioCtx) {
|
||||
const sr = audioCtx.sampleRate || 44100;
|
||||
const buffer = audioCtx.createBuffer(1, Math.floor(sr * 0.5), sr);
|
||||
const d = buffer.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
return { buffer };
|
||||
},
|
||||
// 80s gated reverb: dense noise impulse that stops dead at 300ms
|
||||
gated(audioCtx, masterGain) {
|
||||
return convolverBus(audioCtx, masterGain, 0.3, 0.25, (i, n) => 1 - 0.5 * i / n);
|
||||
},
|
||||
// Long hall reverb for pads and drums
|
||||
hall(audioCtx, masterGain) {
|
||||
return convolverBus(audioCtx, masterGain, 2.4, 0.3, (i, n) => Math.pow(1 - i / n, 3));
|
||||
},
|
||||
// Ping-pong delay with darkening repeats; time set per packet via setDelayTime
|
||||
delay(audioCtx, masterGain) {
|
||||
const input = audioCtx.createGain();
|
||||
const left = audioCtx.createDelay(2);
|
||||
const right = audioCtx.createDelay(2);
|
||||
const damp = audioCtx.createBiquadFilter();
|
||||
damp.type = 'lowpass';
|
||||
damp.frequency.value = 2800;
|
||||
const fbLR = audioCtx.createGain();
|
||||
const fbRL = audioCtx.createGain();
|
||||
fbLR.gain.value = 0.4;
|
||||
fbRL.gain.value = 0.4;
|
||||
const panL = audioCtx.createStereoPanner();
|
||||
const panR = audioCtx.createStereoPanner();
|
||||
panL.pan.value = -0.7;
|
||||
panR.pan.value = 0.7;
|
||||
input.connect(left);
|
||||
left.connect(panL);
|
||||
left.connect(damp);
|
||||
damp.connect(fbLR);
|
||||
fbLR.connect(right);
|
||||
right.connect(panR);
|
||||
right.connect(fbRL);
|
||||
fbRL.connect(left);
|
||||
panL.connect(masterGain);
|
||||
panR.connect(masterGain);
|
||||
return { input, delays: [left, right] };
|
||||
},
|
||||
// Dub echo: one long delay with heavy, darkening, thinning feedback (capped
|
||||
// well below 1 so it can't run away). Time set per packet like 'delay'.
|
||||
dubecho(audioCtx, masterGain) {
|
||||
const input = audioCtx.createGain();
|
||||
const hp = audioCtx.createBiquadFilter();
|
||||
hp.type = 'highpass';
|
||||
hp.frequency.value = 250;
|
||||
const echo = audioCtx.createDelay(2);
|
||||
const lp = audioCtx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 1800;
|
||||
const fb = audioCtx.createGain();
|
||||
fb.gain.value = 0.65;
|
||||
const out = audioCtx.createGain();
|
||||
out.gain.value = 0.8;
|
||||
input.connect(hp);
|
||||
hp.connect(echo);
|
||||
echo.connect(out);
|
||||
echo.connect(lp);
|
||||
lp.connect(fb);
|
||||
fb.connect(echo);
|
||||
out.connect(masterGain);
|
||||
return { input, delays: [echo] };
|
||||
},
|
||||
// Vinyl crackle: faint hiss with sparse random clicks, looped per packet
|
||||
crackle(audioCtx) {
|
||||
const sr = audioCtx.sampleRate || 44100;
|
||||
const buffer = audioCtx.createBuffer(1, Math.floor(sr * 2), sr);
|
||||
const d = buffer.getChannelData(0);
|
||||
let click = 0;
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
if (Math.random() < 20 / sr) click = 0.3 + Math.random() * 0.5; // ~20 clicks/s
|
||||
d[i] = (Math.random() * 2 - 1) * (0.02 + click);
|
||||
click *= 0.8;
|
||||
}
|
||||
return { buffer };
|
||||
},
|
||||
// Stereo chorus: two short delays swept by one slow LFO in opposite directions
|
||||
chorus(audioCtx, masterGain) {
|
||||
const input = audioCtx.createGain();
|
||||
const lfo = audioCtx.createOscillator();
|
||||
lfo.frequency.value = 0.6;
|
||||
[[-0.8, 0.012, 0.003], [0.8, 0.018, -0.003]].forEach(([pan, base, depth]) => {
|
||||
const d = audioCtx.createDelay(0.05);
|
||||
d.delayTime.value = base;
|
||||
const mod = audioCtx.createGain();
|
||||
mod.gain.value = depth;
|
||||
lfo.connect(mod);
|
||||
mod.connect(d.delayTime);
|
||||
const p = audioCtx.createStereoPanner();
|
||||
p.pan.value = pan;
|
||||
input.connect(d);
|
||||
d.connect(p);
|
||||
p.connect(masterGain);
|
||||
});
|
||||
lfo.start();
|
||||
return { input };
|
||||
},
|
||||
};
|
||||
|
||||
function convolverBus(audioCtx, masterGain, seconds, wet, shape) {
|
||||
const sr = audioCtx.sampleRate || 44100;
|
||||
const n = Math.floor(sr * seconds);
|
||||
const ir = audioCtx.createBuffer(2, n, sr);
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const d = ir.getChannelData(c);
|
||||
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * shape(i, n);
|
||||
}
|
||||
const input = audioCtx.createGain();
|
||||
const verb = audioCtx.createConvolver();
|
||||
verb.buffer = ir;
|
||||
const out = audioCtx.createGain();
|
||||
out.gain.value = wet;
|
||||
input.connect(verb);
|
||||
verb.connect(out);
|
||||
out.connect(masterGain);
|
||||
return { input };
|
||||
}
|
||||
|
||||
const _buses = new WeakMap();
|
||||
function bus(audioCtx, masterGain, name) {
|
||||
let m = _buses.get(audioCtx);
|
||||
if (!m) { m = {}; _buses.set(audioCtx, m); }
|
||||
return m[name] || (m[name] = BUILDERS[name](audioCtx, masterGain));
|
||||
}
|
||||
|
||||
// === Timing / mixing ===
|
||||
|
||||
// Next 16th on the shared grid, so concurrent packets lock together
|
||||
function gridStart(audioCtx, sixteenth) {
|
||||
return Math.ceil((audioCtx.currentTime + 0.02) / sixteenth) * sixteenth;
|
||||
}
|
||||
|
||||
// Time of a grid step with swing: odd 16ths are pushed late by swing × a 16th.
|
||||
// Start swung riffs on an 8th boundary (gridStart(ctx, 2 * sixteenth)) so the
|
||||
// odd/even steps line up with every other packet.
|
||||
function stepTime(t0, step, sixteenth, swing) {
|
||||
return t0 + step * sixteenth + (step % 2 ? (swing || 0) * sixteenth : 0);
|
||||
}
|
||||
|
||||
// Per-packet mix: gain → glue limiter → master
|
||||
function createMix(audioCtx, masterGain, level) {
|
||||
const mix = audioCtx.createGain();
|
||||
mix.gain.value = level;
|
||||
const limiter = audioCtx.createDynamicsCompressor();
|
||||
limiter.threshold.value = -8;
|
||||
limiter.knee.value = 6;
|
||||
limiter.ratio.value = 8;
|
||||
limiter.attack.value = 0.003;
|
||||
limiter.release.value = 0.1;
|
||||
mix.connect(limiter);
|
||||
limiter.connect(masterGain);
|
||||
return { mix, nodes: [mix, limiter] };
|
||||
}
|
||||
|
||||
// Sidechain pump: dip a bus on each kick.
|
||||
function duck(param, t, recover, depth) {
|
||||
param.setValueAtTime(1, t);
|
||||
param.linearRampToValueAtTime(depth || 0.35, t + 0.01);
|
||||
param.linearRampToValueAtTime(1, t + recover);
|
||||
}
|
||||
|
||||
function connectSends(audioCtx, from, sends) {
|
||||
return (sends || []).filter(([, amount]) => amount > 0).map(([input, amount]) => {
|
||||
const g = audioCtx.createGain();
|
||||
g.gain.value = amount;
|
||||
from.connect(g);
|
||||
g.connect(input);
|
||||
return g;
|
||||
});
|
||||
}
|
||||
|
||||
// === Instruments ===
|
||||
|
||||
// Detuned oscillator stack through a resonant, enveloped lowpass.
|
||||
// preset: { type?, detunes, cutoff: [start, peak, end], filterAttack?, q, attack, sustain, release, level }
|
||||
// opts: { glideFrom (midi), sends: [[busInput, amount], ...] }
|
||||
function playSynth(audioCtx, dest, preset, midis, start, dur, opts) {
|
||||
const p = preset;
|
||||
const o = opts || {};
|
||||
const end = Math.max(start + dur, start + 0.04);
|
||||
const filterPeak = Math.min(end, start + (p.filterAttack || 0.03));
|
||||
const filter = audioCtx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.Q.value = p.q;
|
||||
filter.frequency.setValueAtTime(p.cutoff[0], start);
|
||||
filter.frequency.exponentialRampToValueAtTime(p.cutoff[1], filterPeak);
|
||||
filter.frequency.exponentialRampToValueAtTime(p.cutoff[2], Math.max(end, filterPeak + 0.01));
|
||||
|
||||
const env = audioCtx.createGain();
|
||||
const peak = p.level / (midis.length * p.detunes.length);
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(peak, start + Math.min(p.attack, dur));
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak * p.sustain, 0.0001), end);
|
||||
env.gain.setTargetAtTime(0.0001, end, p.release / 3);
|
||||
filter.connect(env);
|
||||
env.connect(dest);
|
||||
const sendNodes = connectSends(audioCtx, env, o.sends);
|
||||
|
||||
const stopAt = end + p.release + 0.1;
|
||||
let live = 0;
|
||||
midis.forEach((midi) => {
|
||||
const freq = midiToFreq(midi);
|
||||
p.detunes.forEach((cents) => {
|
||||
const osc = audioCtx.createOscillator();
|
||||
osc.type = p.type || 'sawtooth';
|
||||
osc.detune.value = cents;
|
||||
if (o.glideFrom != null && o.glideFrom !== midi) {
|
||||
osc.frequency.setValueAtTime(midiToFreq(o.glideFrom), start);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq, start + 0.05);
|
||||
} else {
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
}
|
||||
osc.connect(filter);
|
||||
osc.start(start);
|
||||
osc.stop(stopAt);
|
||||
live++;
|
||||
osc.onended = () => {
|
||||
osc.disconnect();
|
||||
if (--live === 0) { filter.disconnect(); env.disconnect(); sendNodes.forEach((g) => g.disconnect()); }
|
||||
};
|
||||
});
|
||||
});
|
||||
return stopAt;
|
||||
}
|
||||
|
||||
// Two-operator FM: a sine modulator at freq × ratio drives the carrier's pitch,
|
||||
// with its depth (index) decaying — bells (inharmonic ratio) or electric piano
|
||||
// (ratio 1). preset: { ratio, index: [peak, settled], indexDecay, attack, decay,
|
||||
// sustain, release, level }. opts: { sends, pitchMod (node → both detunes) }
|
||||
function playFM(audioCtx, dest, preset, midis, start, dur, opts) {
|
||||
const p = preset;
|
||||
const o = opts || {};
|
||||
const end = Math.max(start + dur, start + 0.04);
|
||||
const env = audioCtx.createGain();
|
||||
const peak = p.level / midis.length;
|
||||
const decayEnd = Math.min(end, start + p.attack + p.decay);
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(peak, start + Math.min(p.attack, dur));
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak * p.sustain, 0.0001), Math.max(decayEnd, start + Math.min(p.attack, dur) + 0.01));
|
||||
env.gain.setTargetAtTime(0.0001, end, p.release / 3);
|
||||
env.connect(dest);
|
||||
const sendNodes = connectSends(audioCtx, env, o.sends);
|
||||
|
||||
const stopAt = end + p.release + 0.1;
|
||||
let live = 0;
|
||||
midis.forEach((midi) => {
|
||||
const freq = midiToFreq(midi);
|
||||
const carrier = audioCtx.createOscillator();
|
||||
const mod = audioCtx.createOscillator();
|
||||
const depth = audioCtx.createGain();
|
||||
carrier.type = 'sine';
|
||||
mod.type = 'sine';
|
||||
carrier.frequency.setValueAtTime(freq, start);
|
||||
mod.frequency.setValueAtTime(freq * p.ratio, start);
|
||||
depth.gain.setValueAtTime(freq * p.index[0], start);
|
||||
depth.gain.exponentialRampToValueAtTime(Math.max(freq * p.index[1], 0.01), start + p.indexDecay);
|
||||
mod.connect(depth);
|
||||
depth.connect(carrier.frequency);
|
||||
carrier.connect(env);
|
||||
if (o.pitchMod) { o.pitchMod.connect(carrier.detune); o.pitchMod.connect(mod.detune); }
|
||||
[carrier, mod].forEach((osc) => {
|
||||
osc.start(start);
|
||||
osc.stop(stopAt);
|
||||
live++;
|
||||
osc.onended = () => {
|
||||
osc.disconnect();
|
||||
if (osc === mod) depth.disconnect();
|
||||
if (--live === 0) { env.disconnect(); sendNodes.forEach((g) => g.disconnect()); }
|
||||
};
|
||||
});
|
||||
});
|
||||
return stopAt;
|
||||
}
|
||||
|
||||
// 303-style acid step: one saw through a very resonant lowpass whose envelope
|
||||
// snaps open on each note; accents open further and play louder, slides glide
|
||||
// in from the previous note. ev: { midi, accent, slide, prevMidi }
|
||||
// p: { cutoff, q, envMod, decay, level }
|
||||
function playAcid(audioCtx, dest, ev, start, dur, p) {
|
||||
const osc = audioCtx.createOscillator();
|
||||
const filter = audioCtx.createBiquadFilter();
|
||||
const env = audioCtx.createGain();
|
||||
const freq = midiToFreq(ev.midi);
|
||||
const end = Math.max(start + dur, start + 0.03);
|
||||
osc.type = 'sawtooth';
|
||||
if (ev.slide && ev.prevMidi != null) {
|
||||
osc.frequency.setValueAtTime(midiToFreq(ev.prevMidi), start);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq, start + 0.06);
|
||||
} else {
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
}
|
||||
filter.type = 'lowpass';
|
||||
filter.Q.value = p.q;
|
||||
const peakCut = p.cutoff * (1 + p.envMod * (ev.accent ? 1.6 : 1));
|
||||
filter.frequency.setValueAtTime(peakCut, start);
|
||||
filter.frequency.exponentialRampToValueAtTime(p.cutoff, start + p.decay * (ev.accent ? 0.6 : 1));
|
||||
const level = p.level * (ev.accent ? 1.4 : 1);
|
||||
env.gain.setValueAtTime(ev.slide ? level * 0.8 : 0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(level, start + 0.003);
|
||||
env.gain.setValueAtTime(level, end);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end + 0.02);
|
||||
osc.connect(filter);
|
||||
filter.connect(env);
|
||||
env.connect(dest);
|
||||
osc.start(start);
|
||||
osc.stop(end + 0.05);
|
||||
osc.onended = () => { osc.disconnect(); filter.disconnect(); env.disconnect(); };
|
||||
return end + 0.05;
|
||||
}
|
||||
|
||||
// Looped vinyl crackle under a packet, from the shared 'crackle' buffer
|
||||
function playCrackle(audioCtx, dest, buffer, start, end, level) {
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.loop = true;
|
||||
const bp = audioCtx.createBiquadFilter();
|
||||
bp.type = 'bandpass';
|
||||
bp.frequency.value = 2500;
|
||||
bp.Q.value = 0.7;
|
||||
const g = audioCtx.createGain();
|
||||
g.gain.value = level;
|
||||
src.connect(bp);
|
||||
bp.connect(g);
|
||||
g.connect(dest);
|
||||
src.start(start);
|
||||
src.stop(end);
|
||||
src.onended = () => { src.disconnect(); bp.disconnect(); g.disconnect(); };
|
||||
return end;
|
||||
}
|
||||
|
||||
// Plucked saw bass. p: { cutoff: [open, closed], q, level, maxDecay }
|
||||
const BASS_DEFAULT = { cutoff: [1400, 220], q: 4, level: 0.35, maxDecay: 0.2 };
|
||||
function playBass(audioCtx, dest, midi, start, dur, params) {
|
||||
const p = Object.assign({}, BASS_DEFAULT, params);
|
||||
const osc = audioCtx.createOscillator();
|
||||
const filter = audioCtx.createBiquadFilter();
|
||||
const env = audioCtx.createGain();
|
||||
const decayEnd = start + Math.max(0.04, Math.min(dur, p.maxDecay));
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(midiToFreq(midi), start);
|
||||
filter.type = 'lowpass';
|
||||
filter.Q.value = p.q;
|
||||
filter.frequency.setValueAtTime(p.cutoff[0], start);
|
||||
filter.frequency.exponentialRampToValueAtTime(p.cutoff[1], decayEnd);
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(p.level, start + 0.004);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, decayEnd);
|
||||
osc.connect(filter);
|
||||
filter.connect(env);
|
||||
env.connect(dest);
|
||||
osc.start(start);
|
||||
osc.stop(decayEnd + 0.05);
|
||||
osc.onended = () => { osc.disconnect(); filter.disconnect(); env.disconnect(); };
|
||||
return decayEnd + 0.05;
|
||||
}
|
||||
|
||||
// Sine kick with a pitch drop. p: { from, to, sweep, decay, level }
|
||||
const KICK_DEFAULT = { from: 150, to: 42, sweep: 0.11, decay: 0.35, level: 0.9 };
|
||||
function playKick(audioCtx, dest, start, params) {
|
||||
const p = Object.assign({}, KICK_DEFAULT, params);
|
||||
const osc = audioCtx.createOscillator();
|
||||
const env = audioCtx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(p.from, start);
|
||||
osc.frequency.exponentialRampToValueAtTime(p.to, start + p.sweep);
|
||||
env.gain.setValueAtTime(p.level, start);
|
||||
env.gain.exponentialRampToValueAtTime(0.001, start + p.decay);
|
||||
osc.connect(env);
|
||||
env.connect(dest);
|
||||
osc.start(start);
|
||||
osc.stop(start + p.decay + 0.05);
|
||||
osc.onended = () => { osc.disconnect(); env.disconnect(); };
|
||||
return start + p.decay + 0.05;
|
||||
}
|
||||
|
||||
// Noise snare with a tonal body. sends: [[busInput, amount], ...]
|
||||
const SNARE_DEFAULT = { level: 0.5, decay: 0.18, body: 185 };
|
||||
function playSnare(audioCtx, dest, noise, start, sends, params) {
|
||||
const p = Object.assign({}, SNARE_DEFAULT, params);
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = noise;
|
||||
const hp = audioCtx.createBiquadFilter();
|
||||
hp.type = 'highpass';
|
||||
hp.frequency.value = 1200;
|
||||
const noiseEnv = audioCtx.createGain();
|
||||
noiseEnv.gain.setValueAtTime(p.level, start);
|
||||
noiseEnv.gain.exponentialRampToValueAtTime(0.001, start + p.decay);
|
||||
src.connect(hp);
|
||||
hp.connect(noiseEnv);
|
||||
noiseEnv.connect(dest);
|
||||
const sendNodes = connectSends(audioCtx, noiseEnv, sends);
|
||||
|
||||
const body = audioCtx.createOscillator();
|
||||
const bodyEnv = audioCtx.createGain();
|
||||
body.type = 'triangle';
|
||||
body.frequency.value = p.body;
|
||||
bodyEnv.gain.setValueAtTime(p.level * 0.6, start);
|
||||
bodyEnv.gain.exponentialRampToValueAtTime(0.001, start + 0.08);
|
||||
body.connect(bodyEnv);
|
||||
bodyEnv.connect(dest);
|
||||
|
||||
src.start(start);
|
||||
src.stop(start + p.decay + 0.02);
|
||||
body.start(start);
|
||||
body.stop(start + 0.1);
|
||||
src.onended = () => { src.disconnect(); hp.disconnect(); noiseEnv.disconnect(); sendNodes.forEach((g) => g.disconnect()); };
|
||||
body.onended = () => { body.disconnect(); bodyEnv.disconnect(); };
|
||||
return start + p.decay + 0.02;
|
||||
}
|
||||
|
||||
// Closed hi-hat: short high-passed noise tick
|
||||
function playHat(audioCtx, dest, noise, start, level) {
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = noise;
|
||||
const hp = audioCtx.createBiquadFilter();
|
||||
hp.type = 'highpass';
|
||||
hp.frequency.value = 7000;
|
||||
const env = audioCtx.createGain();
|
||||
env.gain.setValueAtTime(level, start);
|
||||
env.gain.exponentialRampToValueAtTime(0.001, start + 0.05);
|
||||
src.connect(hp);
|
||||
hp.connect(env);
|
||||
env.connect(dest);
|
||||
src.start(start);
|
||||
src.stop(start + 0.06);
|
||||
src.onended = () => { src.disconnect(); hp.disconnect(); env.disconnect(); };
|
||||
return start + 0.06;
|
||||
}
|
||||
|
||||
MeshAudio.synthkit = {
|
||||
bus, gridStart, stepTime, createMix, duck,
|
||||
playSynth, playFM, playAcid, playBass, playKick, playSnare, playHat, playCrackle,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,221 @@
|
||||
// Voice v2: "Metal" — distorted electric guitar packet sonification
|
||||
// Drop-D tuning. Each payload type gets a playing technique:
|
||||
// ADVERT → power-chord riff GRP_TXT → palm-muted chugs + accents
|
||||
// TXT_MSG → lead line with vibrato TRACE → bends ending in a pinch-harmonic squeal
|
||||
// Payload bytes pick notes and rhythm on a 16th-note grid, hops darken the amp,
|
||||
// observations thicken the chord (added octave) and raise the level.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { buildScale, midiToFreq, mapRange, quantizeToScale, panFor, sampleBytes } = MeshAudio.helpers;
|
||||
|
||||
const LOW_D = 38; // D2 — open low string in drop-D
|
||||
const RIFF_SCALE = buildScale([0, 1, 3, 5, 7, 8, 10], LOW_D).slice(0, 10); // D Phrygian, ~1.5 octaves
|
||||
const LEAD_SCALE = buildScale([0, 3, 5, 7, 10], 62).slice(0, 11); // D minor pentatonic from D4
|
||||
const LENGTHS = [2, 2, 3, 4]; // in 16ths: 8th, 8th, dotted 8th, quarter
|
||||
const DRIVE = 14; // saturation amount inside the waveshaper curve
|
||||
|
||||
// Pure: packet → list of note events on a 16th-note grid. No audio here.
|
||||
// event = { step, len, midis: [..], tech: 'chord' | 'mute' | 'lead' | 'bend' | 'pinch' }
|
||||
function buildRiff(parsed) {
|
||||
const { payloadBytes, typeName, obsCount } = parsed;
|
||||
const bytes = sampleBytes(payloadBytes, 3, 8);
|
||||
const withOctave = obsCount >= 3;
|
||||
const powerChord = (root) => withOctave ? [root, root + 7, root + 12] : [root, root + 7];
|
||||
|
||||
const events = [];
|
||||
let step = 0;
|
||||
const push = (len, midis, tech) => { events.push({ step, len, midis, tech }); step += len; };
|
||||
|
||||
bytes.forEach((b, i) => {
|
||||
const last = i === bytes.length - 1;
|
||||
switch (typeName) {
|
||||
case 'GRP_TXT':
|
||||
if (b < 176) push(1, [LOW_D, LOW_D + 7], 'mute');
|
||||
else push(2, powerChord(quantizeToScale(b, RIFF_SCALE)), 'chord');
|
||||
break;
|
||||
case 'TXT_MSG':
|
||||
push(last ? 6 : LENGTHS[b & 3], [quantizeToScale(b, LEAD_SCALE)], 'lead');
|
||||
break;
|
||||
case 'TRACE': {
|
||||
const midi = quantizeToScale(b, LEAD_SCALE);
|
||||
if (last) {
|
||||
// Pinch harmonic: third partial of the fretted note (octave + fifth up)
|
||||
let squeal = midi + 19;
|
||||
while (squeal > 93) squeal -= 12;
|
||||
push(6, [squeal], 'pinch');
|
||||
} else {
|
||||
push(LENGTHS[b & 3], [midi], 'bend');
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: // ADVERT and everything else
|
||||
push(last ? 4 : LENGTHS[b & 3], powerChord(quantizeToScale(b, RIFF_SCALE)), 'chord');
|
||||
}
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
// Soft-clip transfer curve, built once and shared by every packet.
|
||||
let _curve = null;
|
||||
function distortionCurve() {
|
||||
if (_curve) return _curve;
|
||||
const n = 2048;
|
||||
const norm = Math.tanh(DRIVE);
|
||||
_curve = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * 2 - 1;
|
||||
_curve[i] = Math.tanh(DRIVE * x) / norm;
|
||||
}
|
||||
return _curve;
|
||||
}
|
||||
|
||||
function biquad(audioCtx, type, freq, q, gainDb) {
|
||||
const f = audioCtx.createBiquadFilter();
|
||||
f.type = type;
|
||||
f.frequency.value = freq;
|
||||
f.Q.value = q;
|
||||
if (gainDb !== undefined) f.gain.value = gainDb;
|
||||
return f;
|
||||
}
|
||||
|
||||
// Amp chain shared by all notes of one packet:
|
||||
// tighten HP → distortion → cab (thump, mid scoop, presence, hop-driven LP) → level → limiter → pan → master
|
||||
function buildAmp(audioCtx, masterGain, cabLowpass, level, pan) {
|
||||
const shaper = audioCtx.createWaveShaper();
|
||||
shaper.curve = distortionCurve();
|
||||
shaper.oversample = '4x';
|
||||
|
||||
const stages = [
|
||||
biquad(audioCtx, 'highpass', 110, 0.5),
|
||||
shaper,
|
||||
biquad(audioCtx, 'highpass', 70, 0.7),
|
||||
biquad(audioCtx, 'peaking', 120, 0.8, 3),
|
||||
biquad(audioCtx, 'peaking', 500, 0.8, -5),
|
||||
biquad(audioCtx, 'peaking', 2800, 1, 4),
|
||||
biquad(audioCtx, 'lowpass', cabLowpass, 0.9),
|
||||
];
|
||||
|
||||
const out = audioCtx.createGain();
|
||||
out.gain.value = level;
|
||||
|
||||
const limiter = audioCtx.createDynamicsCompressor();
|
||||
limiter.threshold.value = -6;
|
||||
limiter.knee.value = 6;
|
||||
limiter.ratio.value = 12;
|
||||
limiter.attack.value = 0.001;
|
||||
limiter.release.value = 0.05;
|
||||
|
||||
const panner = audioCtx.createStereoPanner();
|
||||
panner.pan.value = pan;
|
||||
|
||||
const chain = stages.concat([out, limiter, panner]);
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
panner.connect(masterGain);
|
||||
return { input: stages[0], nodes: chain };
|
||||
}
|
||||
|
||||
// Delayed vibrato on an oscillator's pitch (lead / bend / pinch).
|
||||
function addVibrato(audioCtx, osc, freq, start, end, depth) {
|
||||
const lfo = audioCtx.createOscillator();
|
||||
const lfoGain = audioCtx.createGain();
|
||||
lfo.frequency.value = 5.5;
|
||||
lfoGain.gain.setValueAtTime(0, start);
|
||||
lfoGain.gain.linearRampToValueAtTime(freq * depth, Math.min(end, start + 0.2));
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(osc.frequency);
|
||||
lfo.start(start);
|
||||
lfo.stop(end + 0.2);
|
||||
lfo.onended = () => { lfo.disconnect(); lfoGain.disconnect(); };
|
||||
}
|
||||
|
||||
function playNote(audioCtx, ampInput, ev, start, dur) {
|
||||
const end = start + dur;
|
||||
const muted = ev.tech === 'mute';
|
||||
const lead = ev.tech === 'lead' || ev.tech === 'bend' || ev.tech === 'pinch';
|
||||
|
||||
// Note envelope sits before the distortion, like a string into an amp.
|
||||
const env = audioCtx.createGain();
|
||||
const oscCount = ev.midis.length * 2;
|
||||
const peak = (ev.tech === 'pinch' ? 0.35 : 0.6) / oscCount;
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(peak, start + 0.004);
|
||||
if (muted) {
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, start + Math.min(dur, 0.11));
|
||||
} else {
|
||||
env.gain.exponentialRampToValueAtTime(peak * 0.4, end);
|
||||
env.gain.setTargetAtTime(0.0001, end, 0.03);
|
||||
}
|
||||
|
||||
// Palm mute: a closing lowpass before the amp makes the chug.
|
||||
let noteOut = env;
|
||||
let muteFilter = null;
|
||||
if (muted) {
|
||||
muteFilter = biquad(audioCtx, 'lowpass', 900, 1);
|
||||
muteFilter.frequency.setValueAtTime(900, start);
|
||||
muteFilter.frequency.exponentialRampToValueAtTime(250, start + 0.06);
|
||||
env.connect(muteFilter);
|
||||
noteOut = muteFilter;
|
||||
}
|
||||
noteOut.connect(ampInput);
|
||||
|
||||
const stopAt = end + 0.2;
|
||||
let live = 0;
|
||||
ev.midis.forEach((midi) => {
|
||||
const freq = midiToFreq(midi);
|
||||
[-6, 6].forEach((cents) => {
|
||||
const osc = audioCtx.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.detune.value = cents;
|
||||
if (ev.tech === 'bend') {
|
||||
// Whole-step bend up into the target note
|
||||
osc.frequency.setValueAtTime(freq * Math.pow(2, -2 / 12), start);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq, start + Math.min(0.12, dur * 0.4));
|
||||
} else {
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
}
|
||||
if (lead) addVibrato(audioCtx, osc, freq, start + 0.1, end, ev.tech === 'pinch' ? 0.025 : 0.012);
|
||||
osc.connect(env);
|
||||
osc.start(start);
|
||||
osc.stop(stopAt);
|
||||
live++;
|
||||
osc.onended = () => {
|
||||
osc.disconnect();
|
||||
if (--live === 0) {
|
||||
env.disconnect();
|
||||
if (muteFilter) muteFilter.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
return stopAt;
|
||||
}
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier; // 16th note at the engine BPM
|
||||
|
||||
const cabLowpass = mapRange(Math.min(hopCount, 10), 1, 10, 5500, 1800);
|
||||
const level = Math.min(0.2, 0.1 + (obsCount - 1) * 0.01);
|
||||
const amp = buildAmp(audioCtx, masterGain, cabLowpass, level, panFor(parsed));
|
||||
|
||||
const t0 = audioCtx.currentTime + 0.02;
|
||||
let lastEnd = t0;
|
||||
buildRiff(parsed).forEach((ev) => {
|
||||
// Slight gap between notes keeps the riff articulated
|
||||
const dur = Math.max(0.05, ev.len * sixteenth - 0.02);
|
||||
lastEnd = Math.max(lastEnd, playNote(audioCtx, amp.input, ev, t0 + ev.step * sixteenth, dur));
|
||||
});
|
||||
|
||||
const cleanupMs = (lastEnd - audioCtx.currentTime + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
amp.nodes.forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, cleanupMs);
|
||||
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
// buildAmp / playNote are the guitar rig, reused by the synthmetal voice.
|
||||
MeshAudio.registerVoice('metal', { name: 'metal', play, buildRiff, distortionCurve, buildAmp, playNote });
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
// Voice v3: "Synthmetal" — distorted guitar + 80s synths, D harmonic minor
|
||||
// Layers the metal voice's guitar rig with supersaw synths, a pulsing octave bass,
|
||||
// kick + gated-reverb snare (sidechain-ducking the synths) and a ping-pong delay.
|
||||
// ADVERT → guitar power chords + supersaw triad stabs, 8th bass, rock beat
|
||||
// GRP_TXT → palm-muted chugs + 16th octave bass, four-on-the-floor
|
||||
// TXT_MSG → supersaw lead with glide into the delay over a bass pedal
|
||||
// TRACE → 16th arpeggios ending in a guitar pinch-harmonic squeal
|
||||
// Riffs start on a shared 16th-note grid so overlapping packets stay in time.
|
||||
// Requires audio-synthkit.js and audio-v2-metal.js (guitar rig) to be loaded first.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { buildScale, mapRange, quantizeToScale, panFor, sampleBytes } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
const guitar = window._meshAudioVoices.metal;
|
||||
|
||||
const SCALE = [0, 2, 3, 5, 7, 8, 11]; // D harmonic minor
|
||||
const LEAD_SCALE = buildScale(SCALE, 62).slice(0, 15); // from D4, ~2 octaves
|
||||
const GTR_ROOT = 38; // D2, drop-D open string (guitar + bass register)
|
||||
const SYNTH_ROOT = 62; // D4
|
||||
const LENGTHS = [2, 2, 3, 4]; // in 16ths
|
||||
|
||||
const degree = (b) => Math.min(6, Math.floor((b / 256) * 7));
|
||||
const noteAt = (base, d) => base + 12 * Math.floor(d / 7) + SCALE[d % 7];
|
||||
const triad = (base, d) => [noteAt(base, d), noteAt(base, d + 2), noteAt(base, d + 4)];
|
||||
|
||||
// Pure: packet → layered arrangement on a 16th-note grid. No audio here.
|
||||
// { steps, guitar: [{step,len,midis,tech}], synth: [{step,len,midis,kind}],
|
||||
// bass: [{step,len,midi}], drums: [{step,kind}] }
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName, obsCount } = parsed;
|
||||
const bytes = sampleBytes(payloadBytes, 3, 6);
|
||||
const powerChord = (root) => obsCount >= 3 ? [root, root + 7, root + 12] : [root, root + 7];
|
||||
const out = { steps: 0, guitar: [], synth: [], bass: [], drums: [] };
|
||||
let step = 0;
|
||||
|
||||
// Octave-bouncing bass under [from, from + len) every `every` 16ths.
|
||||
// Root vs octave follows the global pulse, so the bounce runs across chord changes.
|
||||
const bassline = (root, from, len, every) => {
|
||||
for (let s = from; s < from + len; s += every) {
|
||||
out.bass.push({ step: s, len: Math.min(every, from + len - s), midi: Math.floor(s / every) % 2 ? root + 12 : root });
|
||||
}
|
||||
};
|
||||
|
||||
bytes.forEach((b, i) => {
|
||||
const last = i === bytes.length - 1;
|
||||
const d = degree(b);
|
||||
switch (typeName) {
|
||||
case 'GRP_TXT':
|
||||
if (b < 176) {
|
||||
out.guitar.push({ step, len: 1, midis: [GTR_ROOT, GTR_ROOT + 7], tech: 'mute' });
|
||||
bassline(GTR_ROOT, step, 1, 1);
|
||||
step += 1;
|
||||
} else {
|
||||
const root = noteAt(GTR_ROOT, d);
|
||||
out.guitar.push({ step, len: 2, midis: powerChord(root), tech: 'chord' });
|
||||
out.synth.push({ step, len: 2, midis: triad(SYNTH_ROOT, d), kind: 'stab' });
|
||||
bassline(root, step, 2, 1);
|
||||
step += 2;
|
||||
}
|
||||
break;
|
||||
case 'TXT_MSG': {
|
||||
const len = last ? 6 : LENGTHS[b & 3];
|
||||
out.synth.push({ step, len, midis: [quantizeToScale(b, LEAD_SCALE)], kind: 'lead' });
|
||||
bassline(GTR_ROOT, step, len, 2);
|
||||
step += len;
|
||||
break;
|
||||
}
|
||||
case 'TRACE': {
|
||||
if (last) {
|
||||
let squeal = noteAt(SYNTH_ROOT, d) + 19; // pinch harmonic: octave + fifth up
|
||||
while (squeal > 93) squeal -= 12;
|
||||
out.guitar.push({ step, len: 6, midis: [squeal], tech: 'pinch' });
|
||||
step += 6;
|
||||
} else {
|
||||
const len = LENGTHS[b & 3];
|
||||
const tones = triad(SYNTH_ROOT, d).concat(noteAt(SYNTH_ROOT, d + 7));
|
||||
for (let k = 0; k < len; k++) {
|
||||
out.synth.push({ step: step + k, len: 1, midis: [tones[k % tones.length]], kind: 'arp' });
|
||||
}
|
||||
bassline(noteAt(GTR_ROOT, d), step, len, 2);
|
||||
step += len;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: { // ADVERT and everything else
|
||||
const len = last ? 4 : LENGTHS[b & 3];
|
||||
const root = noteAt(GTR_ROOT, d);
|
||||
out.guitar.push({ step, len, midis: powerChord(root), tech: 'chord' });
|
||||
out.synth.push({ step, len, midis: triad(SYNTH_ROOT, d), kind: 'stab' });
|
||||
bassline(root, step, len, 2);
|
||||
step += len;
|
||||
}
|
||||
}
|
||||
});
|
||||
out.steps = step;
|
||||
|
||||
// Drums only under the riff types; leads and arps stay melodic.
|
||||
const fourOnFloor = typeName === 'GRP_TXT';
|
||||
if (typeName !== 'TXT_MSG' && typeName !== 'TRACE') {
|
||||
for (let s = 0; s < step; s += 4) {
|
||||
const backbeat = (s / 4) % 2 === 1; // beats 2 and 4
|
||||
if (fourOnFloor || !backbeat) out.drums.push({ step: s, kind: 'kick' });
|
||||
if (backbeat) out.drums.push({ step: s, kind: 'snare' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SYNTHS = {
|
||||
stab: { detunes: [-14, 0, 14], cutoff: [600, 5000, 1500], q: 5, attack: 0.005, sustain: 0.6, release: 0.08, send: 0, level: 0.5 },
|
||||
lead: { detunes: [-20, -10, 0, 10, 20], cutoff: [1500, 4000, 3000], q: 2, attack: 0.01, sustain: 0.8, release: 0.15, send: 0.35, level: 1.6 },
|
||||
arp: { detunes: [-10, 0, 10], cutoff: [800, 3500, 1200], q: 4, attack: 0.003, sustain: 0.2, release: 0.05, send: 0.3, level: 0.65 },
|
||||
};
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const noise = kit.bus(audioCtx, masterGain, 'noise').buffer;
|
||||
const gated = kit.bus(audioCtx, masterGain, 'gated');
|
||||
const delay = kit.bus(audioCtx, masterGain, 'delay');
|
||||
delay.delays.forEach((d) => { d.delayTime.value = 3 * sixteenth; }); // dotted 8th
|
||||
|
||||
const arr = arrange(parsed);
|
||||
|
||||
// Per-packet mix: guitar amp + ducked synth bus + drums → glue limiter → master
|
||||
const { mix, nodes } = kit.createMix(audioCtx, masterGain, Math.min(0.45, 0.25 + (obsCount - 1) * 0.015));
|
||||
const synthBus = audioCtx.createGain();
|
||||
synthBus.connect(mix);
|
||||
|
||||
const amp = arr.guitar.length
|
||||
? guitar.buildAmp(audioCtx, mix, mapRange(Math.min(hopCount, 10), 1, 10, 5500, 1800), 0.3, panFor(parsed))
|
||||
: null;
|
||||
|
||||
const t0 = kit.gridStart(audioCtx, sixteenth);
|
||||
const at = (step) => t0 + step * sixteenth;
|
||||
const durOf = (len) => Math.max(0.03, len * sixteenth - 0.02);
|
||||
let lastEnd = t0;
|
||||
const track = (t) => { if (t > lastEnd) lastEnd = t; };
|
||||
|
||||
arr.guitar.forEach((ev) => track(guitar.playNote(audioCtx, amp.input, ev, at(ev.step), durOf(ev.len))));
|
||||
let prevLead = null;
|
||||
arr.synth.forEach((ev) => {
|
||||
const p = SYNTHS[ev.kind];
|
||||
track(kit.playSynth(audioCtx, synthBus, p, ev.midis, at(ev.step), durOf(ev.len), {
|
||||
glideFrom: ev.kind === 'lead' ? prevLead : null,
|
||||
sends: [[delay.input, p.send]],
|
||||
}));
|
||||
if (ev.kind === 'lead') prevLead = ev.midis[0];
|
||||
});
|
||||
arr.bass.forEach((ev) => track(kit.playBass(audioCtx, synthBus, ev.midi, at(ev.step), durOf(ev.len))));
|
||||
const recover = Math.min(0.18, 3.2 * sixteenth);
|
||||
arr.drums.forEach((ev) => {
|
||||
if (ev.kind === 'kick') {
|
||||
track(kit.playKick(audioCtx, mix, at(ev.step)));
|
||||
kit.duck(synthBus.gain, at(ev.step), recover);
|
||||
} else {
|
||||
track(kit.playSnare(audioCtx, mix, noise, at(ev.step), [[gated.input, 1]]));
|
||||
}
|
||||
});
|
||||
|
||||
// Echo/reverb tails ring in the shared effect buses, so the voice slot frees when the notes end.
|
||||
const cleanupMs = (lastEnd - audioCtx.currentTime + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
nodes.concat(synthBus, amp ? amp.nodes : []).forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, cleanupMs);
|
||||
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('synthmetal', { name: 'synthmetal', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
// Voice v4: "Technoir" — dark 80s synthwave, D natural minor
|
||||
// Driving 8th-note bass with a slow filter sweep, drum-machine kick, big gated
|
||||
// snare, 8th hats, and lush chorused pads moving through minor-key progressions
|
||||
// (i–VI–III–VII–iv). Kicks pump the pads and bass; traces end on a guitar squeal.
|
||||
// ADVERT → full groove: pads + brass stabs + bass + drums
|
||||
// GRP_TXT → bass + drums only (the most frequent type, kept light)
|
||||
// TXT_MSG → slow pad swells over the bass, no drums
|
||||
// TRACE → pluck arpeggios + bass + hats, ending in a guitar pinch-harmonic squeal
|
||||
// Requires audio-synthkit.js and audio-v2-metal.js (guitar rig) to be loaded first.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { mapRange, panFor, sampleBytes } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
const guitar = window._meshAudioVoices.metal;
|
||||
|
||||
const SCALE = [0, 2, 3, 5, 7, 8, 10]; // D natural minor
|
||||
const PROGRESSION = [0, 5, 2, 6, 3]; // i, VI, III, VII, iv
|
||||
const BASS_ROOT = 38; // D2
|
||||
const PAD_ROOT = 50; // D3
|
||||
const HIGH_ROOT = 62; // D4
|
||||
const BASS_PATTERN = [0, 0, 12, 0, 7, 0, 12, 0]; // semitones over the chord root, per 8th
|
||||
const SWEEP_STEPS = 32; // bass filter opens and closes over two bars
|
||||
|
||||
const noteAt = (base, d) => base + 12 * Math.floor(d / 7) + SCALE[d % 7];
|
||||
const triad = (base, d) => [noteAt(base, d), noteAt(base, d + 2), noteAt(base, d + 4)];
|
||||
const chordOf = (b) => PROGRESSION[Math.min(PROGRESSION.length - 1, Math.floor((b / 256) * PROGRESSION.length))];
|
||||
|
||||
// Pure: packet → layered arrangement on a 16th-note grid. No audio here.
|
||||
// { steps, pads, stabs, arps: [{step,len,midis}], bass: [{step,len,midi,bright}],
|
||||
// drums: [{step,kind:'kick'|'snare'|'hat',accent?}], guitar: [{step,len,midis,tech}] }
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName } = parsed;
|
||||
const kind = ['GRP_TXT', 'TXT_MSG', 'TRACE'].includes(typeName) ? typeName : 'ADVERT';
|
||||
const bytes = sampleBytes(payloadBytes, 2, kind === 'GRP_TXT' ? 3 : 4);
|
||||
const out = { steps: 0, pads: [], stabs: [], arps: [], bass: [], drums: [], guitar: [] };
|
||||
let step = 0;
|
||||
|
||||
bytes.forEach((b) => {
|
||||
const d = chordOf(b);
|
||||
const len = kind === 'TXT_MSG' || (b & 1) ? 8 : 4;
|
||||
const root = noteAt(BASS_ROOT, d);
|
||||
|
||||
for (let s = step; s < step + len; s += 2) {
|
||||
const bright = 0.5 - 0.5 * Math.cos((2 * Math.PI * s) / SWEEP_STEPS);
|
||||
out.bass.push({ step: s, len: 2, midi: root + BASS_PATTERN[(s / 2) % BASS_PATTERN.length], bright });
|
||||
}
|
||||
if (kind === 'ADVERT' || kind === 'TXT_MSG') {
|
||||
out.pads.push({ step, len, midis: triad(PAD_ROOT, d).concat(noteAt(PAD_ROOT, d) + 12) });
|
||||
}
|
||||
if (kind === 'ADVERT') out.stabs.push({ step, len: 2, midis: triad(HIGH_ROOT, d) });
|
||||
if (kind === 'TRACE') {
|
||||
const tones = triad(HIGH_ROOT, d).concat(noteAt(HIGH_ROOT, d) + 12);
|
||||
for (let k = 0; k < len; k++) out.arps.push({ step: step + k, len: 1, midis: [tones[k % tones.length]] });
|
||||
}
|
||||
step += len;
|
||||
});
|
||||
|
||||
if (kind === 'TRACE') {
|
||||
let squeal = noteAt(HIGH_ROOT, chordOf(bytes[bytes.length - 1])) + 19; // pinch harmonic: octave + fifth
|
||||
while (squeal > 93) squeal -= 12;
|
||||
out.guitar.push({ step, len: 6, midis: [squeal], tech: 'pinch' });
|
||||
step += 6;
|
||||
}
|
||||
out.steps = step;
|
||||
|
||||
// Kick on 1 and 3 (plus the "and" of 3 on adverts), snare on 2 and 4, 8th hats.
|
||||
const drumEnd = kind === 'TRACE' ? step - 6 : step;
|
||||
for (let s = 0; kind !== 'TXT_MSG' && s < drumEnd; s += 2) {
|
||||
if (kind !== 'TRACE') {
|
||||
if (s % 8 === 0 || (kind === 'ADVERT' && s % 16 === 10)) out.drums.push({ step: s, kind: 'kick' });
|
||||
if (s % 8 === 4) out.drums.push({ step: s, kind: 'snare' });
|
||||
}
|
||||
out.drums.push({ step: s, kind: 'hat', accent: s % 4 === 2 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PAD = { detunes: [-12, 0, 12], cutoff: [500, 1800, 1300], filterAttack: 0.6, q: 1, attack: 0.35, sustain: 0.85, release: 0.9, level: 0.9 };
|
||||
const SWELL = Object.assign({}, PAD, { attack: 0.8, filterAttack: 1.2 });
|
||||
const STAB = { detunes: [-8, 0, 8], cutoff: [300, 3500, 900], filterAttack: 0.06, q: 1.5, attack: 0.015, sustain: 0.3, release: 0.2, level: 0.45 };
|
||||
const PLUCK = { type: 'square', detunes: [-6, 6], cutoff: [3000, 3500, 700], q: 3, attack: 0.003, sustain: 0.1, release: 0.12, level: 0.3 };
|
||||
const KICK = { from: 130, to: 48, sweep: 0.08, decay: 0.3, level: 1 };
|
||||
const SNARE = { level: 0.55, decay: 0.22, body: 200 };
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount, typeName } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const bus = (name) => kit.bus(audioCtx, masterGain, name);
|
||||
const noise = bus('noise').buffer;
|
||||
const hall = bus('hall').input;
|
||||
const chorus = bus('chorus').input;
|
||||
const delay = bus('delay');
|
||||
delay.delays.forEach((d) => { d.delayTime.value = 3 * sixteenth; }); // dotted 8th
|
||||
|
||||
const arr = arrange(parsed);
|
||||
|
||||
// Per-packet mix; pads/stabs/bass share a bus that the kick pumps
|
||||
const { mix, nodes } = kit.createMix(audioCtx, masterGain, Math.min(0.7, 0.45 + (obsCount - 1) * 0.02));
|
||||
const padBus = audioCtx.createGain();
|
||||
padBus.connect(mix);
|
||||
const amp = arr.guitar.length
|
||||
? guitar.buildAmp(audioCtx, mix, mapRange(Math.min(hopCount, 10), 1, 10, 5500, 1800), 0.3, panFor(parsed))
|
||||
: null;
|
||||
|
||||
const t0 = kit.gridStart(audioCtx, sixteenth);
|
||||
const at = (step) => t0 + step * sixteenth;
|
||||
const durOf = (len) => Math.max(0.03, len * sixteenth - 0.02);
|
||||
let lastEnd = t0;
|
||||
const track = (t) => { if (t > lastEnd) lastEnd = t; };
|
||||
|
||||
const padPreset = typeName === 'TXT_MSG' ? SWELL : PAD;
|
||||
arr.pads.forEach((ev) => track(kit.playSynth(audioCtx, padBus, padPreset, ev.midis, at(ev.step), durOf(ev.len),
|
||||
{ sends: [[chorus, 0.7], [hall, 0.35]] })));
|
||||
arr.stabs.forEach((ev) => track(kit.playSynth(audioCtx, padBus, STAB, ev.midis, at(ev.step), durOf(ev.len),
|
||||
{ sends: [[hall, 0.25], [chorus, 0.3]] })));
|
||||
arr.arps.forEach((ev) => track(kit.playSynth(audioCtx, padBus, PLUCK, ev.midis, at(ev.step), durOf(ev.len),
|
||||
{ sends: [[delay.input, 0.3], [hall, 0.2]] })));
|
||||
arr.bass.forEach((ev) => track(kit.playBass(audioCtx, padBus, ev.midi, at(ev.step), durOf(ev.len),
|
||||
{ cutoff: [300 + ev.bright * 1700, 180], q: 6, level: 0.4, maxDecay: 0.22 })));
|
||||
arr.guitar.forEach((ev) => track(guitar.playNote(audioCtx, amp.input, ev, at(ev.step), durOf(ev.len))));
|
||||
|
||||
const recover = Math.min(0.25, 3.2 * sixteenth);
|
||||
arr.drums.forEach((ev) => {
|
||||
const t = at(ev.step);
|
||||
if (ev.kind === 'kick') {
|
||||
track(kit.playKick(audioCtx, mix, t, KICK));
|
||||
kit.duck(padBus.gain, t, recover, 0.55);
|
||||
} else if (ev.kind === 'snare') {
|
||||
track(kit.playSnare(audioCtx, mix, noise, t, [[bus('gated').input, 1.2], [hall, 0.35]], SNARE));
|
||||
} else {
|
||||
track(kit.playHat(audioCtx, mix, noise, t, ev.accent ? 0.18 : 0.12));
|
||||
}
|
||||
});
|
||||
|
||||
// Echo/reverb tails ring in the shared effect buses, so the voice slot frees when the notes end.
|
||||
const cleanupMs = (lastEnd - audioCtx.currentTime + 1) * 1000;
|
||||
setTimeout(() => {
|
||||
nodes.concat(padBus, amp ? amp.nodes : []).forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, cleanupMs);
|
||||
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('technoir', { name: 'technoir', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
// Voice v5: "Ambient" — slow generative bells and pads, C major pentatonic
|
||||
// Sparse notes with long attacks and a big hall, in a scale with no clashing
|
||||
// intervals so overlapping packets always blend. Not tied to the beat grid:
|
||||
// random arrival times are part of the texture.
|
||||
// ADVERT → low drone pad (root + fifth; + octave with 3+ observers)
|
||||
// GRP_TXT → one or two mid bells
|
||||
// TXT_MSG → a short, gentle bell phrase
|
||||
// TRACE → quick high glints into the echo
|
||||
// Hops push notes into the distance (wetter, darker, quieter); observers add
|
||||
// FM brightness to the bells. Requires audio-synthkit.js.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { buildScale, quantizeToScale, sampleBytes } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
|
||||
const PENTA = [0, 2, 4, 7, 9]; // C major pentatonic
|
||||
const DRONE_ROOTS = [36, 38, 43, 45]; // C2 D2 G2 A2: roots whose fifth stays in the scale
|
||||
const BELL_SCALE = buildScale(PENTA, 60).slice(0, 10); // C4..A5
|
||||
const GLINT_SCALE = buildScale(PENTA, 84).slice(0, 6); // C6..C7
|
||||
|
||||
// Pure: packet → notes in 16th-note steps from the packet's start. No audio here.
|
||||
// [{ step, len, midis, kind: 'pad' | 'bell' | 'glint' }]
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName, obsCount } = parsed;
|
||||
const notes = [];
|
||||
switch (typeName) {
|
||||
case 'GRP_TXT':
|
||||
sampleBytes(payloadBytes, 1, 2).forEach((b, i) => {
|
||||
notes.push({ step: i * (4 + (b & 3)), len: 12, midis: [quantizeToScale(b, BELL_SCALE)], kind: 'bell' });
|
||||
});
|
||||
break;
|
||||
case 'TXT_MSG': {
|
||||
let step = 0;
|
||||
sampleBytes(payloadBytes, 2, 3).forEach((b) => {
|
||||
notes.push({ step, len: 12, midis: [quantizeToScale(b, BELL_SCALE)], kind: 'bell' });
|
||||
step += 4 + (b & 1) * 2;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'TRACE': {
|
||||
let step = 0;
|
||||
sampleBytes(payloadBytes, 3, 5).forEach((b) => {
|
||||
notes.push({ step, len: 3, midis: [quantizeToScale(b, GLINT_SCALE)], kind: 'glint' });
|
||||
step += 1 + (b & 1);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: { // ADVERT and everything else: a drone
|
||||
const root = quantizeToScale(payloadBytes[0], DRONE_ROOTS);
|
||||
const midis = obsCount >= 3 ? [root, root + 7, root + 12] : [root, root + 7];
|
||||
notes.push({ step: 0, len: 24, midis, kind: 'pad' });
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
const PAD = { type: 'triangle', detunes: [-7, 7], cutoff: [300, 900, 600], filterAttack: 2, q: 0.7, attack: 1.5, sustain: 0.9, release: 2.5, level: 0.5 };
|
||||
const BELL = { ratio: 3.5, index: [2.5, 0.3], indexDecay: 1.2, attack: 0.01, decay: 2.5, sustain: 0.05, release: 1.5, level: 0.3 };
|
||||
const GLINT = { ratio: 4.2, index: [1.5, 0.2], indexDecay: 0.3, attack: 0.005, decay: 0.6, sustain: 0.02, release: 0.5, level: 0.3 };
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const hall = kit.bus(audioCtx, masterGain, 'hall').input;
|
||||
const delay = kit.bus(audioCtx, masterGain, 'delay');
|
||||
delay.delays.forEach((d) => { d.delayTime.value = 3 * sixteenth; });
|
||||
|
||||
// Distance: more hops → quieter dry signal, more reverb, darker bells
|
||||
const distance = Math.min(1, (hopCount - 1) / 9);
|
||||
const dry = audioCtx.createGain();
|
||||
dry.gain.value = Math.min(1, 0.8 + (obsCount - 1) * 0.02) * (1 - 0.5 * distance);
|
||||
dry.connect(masterGain);
|
||||
const wet = 0.35 + 0.5 * distance;
|
||||
const brightness = (1 - 0.6 * distance) * Math.min(2, 1 + (obsCount - 1) * 0.1);
|
||||
|
||||
const t0 = audioCtx.currentTime + 0.05;
|
||||
let lastEnd = t0;
|
||||
arrange(parsed).forEach((n) => {
|
||||
const start = t0 + n.step * sixteenth;
|
||||
const dur = n.len * sixteenth;
|
||||
let end;
|
||||
if (n.kind === 'pad') {
|
||||
end = kit.playSynth(audioCtx, dry, PAD, n.midis, start, dur, { sends: [[hall, wet]] });
|
||||
} else {
|
||||
const base = n.kind === 'bell' ? BELL : GLINT;
|
||||
const preset = Object.assign({}, base, { index: [base.index[0] * brightness, base.index[1]] });
|
||||
const sends = n.kind === 'glint' ? [[hall, wet], [delay.input, 0.4]] : [[hall, wet]];
|
||||
end = kit.playFM(audioCtx, dry, preset, n.midis, start, dur, { sends });
|
||||
}
|
||||
if (end > lastEnd) lastEnd = end;
|
||||
});
|
||||
|
||||
// Reverb/echo tails ring in the shared buses, so the voice slot frees when the notes end.
|
||||
setTimeout(() => { try { dry.disconnect(); } catch (e) {} }, (lastEnd - audioCtx.currentTime + 1) * 1000);
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('ambient', { name: 'ambient', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,140 @@
|
||||
// Voice v6: "Dub" — reggae/dub, A natural minor
|
||||
// One-drop drums (kick + rimshot on beat 3), offbeat chord skanks and rims thrown
|
||||
// into a long feedback echo, a deep bass line, and a dub siren on traces.
|
||||
// ADVERT → two bars: skanks + bass + one-drop
|
||||
// GRP_TXT → one bar of skanks + rimshot (light, it's the most frequent type)
|
||||
// TXT_MSG → one bar: bass line + skanks, no drums
|
||||
// TRACE → a dub siren sweep + rimshots into the echo
|
||||
// More hops send more into the echo, so long routes trail away; observers
|
||||
// widen the skanks' stereo spread. Requires audio-synthkit.js.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { sampleBytes, midiToFreq } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
|
||||
const SCALE = [0, 2, 3, 5, 7, 8, 10]; // A natural minor
|
||||
const ROOT = 45; // A2
|
||||
const CHORDS = [0, 3, 6, 4]; // i, iv, VII, v
|
||||
const noteAt = (base, d) => base + 12 * Math.floor(d / 7) + SCALE[d % 7];
|
||||
const triad = (base, d) => [noteAt(base, d), noteAt(base, d + 2), noteAt(base, d + 4)];
|
||||
const chordOf = (b) => CHORDS[Math.min(CHORDS.length - 1, Math.floor((b / 256) * CHORDS.length))];
|
||||
|
||||
// Pure: packet → one-bar-per-chord arrangement on a 16th grid. No audio here.
|
||||
// { steps, skanks: [{step,midis,side}], bass: [{step,len,midi}],
|
||||
// drums: [{step,kind:'kick'|'rim'}], sirens: [{step,len,midi}] }
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName } = parsed;
|
||||
const kind = ['GRP_TXT', 'TXT_MSG', 'TRACE'].includes(typeName) ? typeName : 'ADVERT';
|
||||
const out = { steps: 0, skanks: [], bass: [], drums: [], sirens: [] };
|
||||
|
||||
if (kind === 'TRACE') {
|
||||
const bytes = sampleBytes(payloadBytes, 2, 3);
|
||||
bytes.forEach((b, i) => {
|
||||
out.sirens.push({ step: i * 8, len: 8, midi: noteAt(69, b % 5) }); // from A4..E5
|
||||
out.drums.push({ step: i * 8 + 6, kind: 'rim' });
|
||||
});
|
||||
out.steps = bytes.length * 8;
|
||||
return out;
|
||||
}
|
||||
|
||||
const bars = kind === 'ADVERT' ? sampleBytes(payloadBytes, 2, 2) : sampleBytes(payloadBytes, 1, 1);
|
||||
bars.forEach((b, bar) => {
|
||||
const d = chordOf(b);
|
||||
const s0 = bar * 16;
|
||||
// Skanks on the offbeat 8ths ("and" of each beat), alternating sides
|
||||
[2, 6, 10, 14].forEach((s, k) => out.skanks.push({ step: s0 + s, midis: triad(ROOT + 12, d), side: k % 2 ? 1 : -1 }));
|
||||
if (kind !== 'GRP_TXT') {
|
||||
// Bass: root and fifth on the beats; the byte picks a passing tone before beat 3 and the last note's octave
|
||||
const root = noteAt(ROOT, d);
|
||||
out.bass.push({ step: s0, len: 3, midi: root });
|
||||
out.bass.push({ step: s0 + 4, len: 2, midi: root });
|
||||
out.bass.push({ step: s0 + 7, len: 1, midi: noteAt(ROOT, d + 1 + (b & 1)) });
|
||||
out.bass.push({ step: s0 + 8, len: 3, midi: noteAt(ROOT, d + 4) });
|
||||
out.bass.push({ step: s0 + 12, len: 2, midi: root + (b & 2 ? 12 : 0) });
|
||||
}
|
||||
if (kind !== 'TXT_MSG') {
|
||||
// One drop: kick and rimshot together on beat 3
|
||||
if (kind === 'ADVERT') out.drums.push({ step: s0 + 8, kind: 'kick' });
|
||||
out.drums.push({ step: s0 + 8, kind: 'rim' });
|
||||
}
|
||||
});
|
||||
out.steps = bars.length * 16;
|
||||
return out;
|
||||
}
|
||||
|
||||
const SKANK = { type: 'square', detunes: [-5, 5], cutoff: [2600, 2200, 1400], q: 1, attack: 0.003, sustain: 0.15, release: 0.05, level: 0.6 };
|
||||
const BASS = { type: 'triangle', detunes: [0], cutoff: [700, 700, 500], q: 0.5, attack: 0.01, sustain: 0.9, release: 0.08, level: 0.5 };
|
||||
const RIM = { level: 0.35, decay: 0.07, body: 420 };
|
||||
const KICK = { from: 110, to: 45, sweep: 0.1, decay: 0.4, level: 0.9 };
|
||||
|
||||
// Dub siren: a sine sweeping up and back down, straight into the echo
|
||||
function playSiren(audioCtx, dest, echoIn, midi, start, dur) {
|
||||
const osc = audioCtx.createOscillator();
|
||||
const env = audioCtx.createGain();
|
||||
const send = audioCtx.createGain();
|
||||
const f = midiToFreq(midi);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(f, start);
|
||||
osc.frequency.exponentialRampToValueAtTime(f * 2, start + dur * 0.5);
|
||||
osc.frequency.exponentialRampToValueAtTime(f, start + dur);
|
||||
env.gain.setValueAtTime(0.0001, start);
|
||||
env.gain.exponentialRampToValueAtTime(0.12, start + 0.05);
|
||||
env.gain.setValueAtTime(0.12, start + dur * 0.8);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||
send.gain.value = 0.8;
|
||||
osc.connect(env);
|
||||
env.connect(dest);
|
||||
env.connect(send);
|
||||
send.connect(echoIn);
|
||||
osc.start(start);
|
||||
osc.stop(start + dur + 0.05);
|
||||
osc.onended = () => { osc.disconnect(); env.disconnect(); send.disconnect(); };
|
||||
return start + dur + 0.05;
|
||||
}
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const noise = kit.bus(audioCtx, masterGain, 'noise').buffer;
|
||||
const hall = kit.bus(audioCtx, masterGain, 'hall').input;
|
||||
const echo = kit.bus(audioCtx, masterGain, 'dubecho');
|
||||
echo.delays[0].delayTime.value = 3 * sixteenth; // dotted 8th
|
||||
|
||||
const arr = arrange(parsed);
|
||||
const distance = Math.min(1, (hopCount - 1) / 9);
|
||||
const throwAmount = 0.25 + 0.55 * distance; // more hops → more echo
|
||||
const width = Math.min(0.8, 0.2 + 0.1 * (obsCount - 1));
|
||||
|
||||
const { mix, nodes } = kit.createMix(audioCtx, masterGain, Math.min(0.7, 0.5 + (obsCount - 1) * 0.015));
|
||||
const sides = [-1, 1].map((side) => {
|
||||
const p = audioCtx.createStereoPanner();
|
||||
p.pan.value = side * width;
|
||||
p.connect(mix);
|
||||
return p;
|
||||
});
|
||||
|
||||
const t0 = kit.gridStart(audioCtx, sixteenth);
|
||||
const at = (step) => t0 + step * sixteenth;
|
||||
let lastEnd = t0;
|
||||
const track = (t) => { if (t > lastEnd) lastEnd = t; };
|
||||
|
||||
arr.skanks.forEach((ev) => track(kit.playSynth(audioCtx, sides[ev.side > 0 ? 1 : 0], SKANK, ev.midis, at(ev.step), sixteenth * 0.9,
|
||||
{ sends: [[echo.input, throwAmount]] })));
|
||||
arr.bass.forEach((ev) => track(kit.playSynth(audioCtx, mix, BASS, [ev.midi], at(ev.step), ev.len * sixteenth - 0.02)));
|
||||
arr.sirens.forEach((ev) => track(playSiren(audioCtx, mix, echo.input, ev.midi, at(ev.step), ev.len * sixteenth)));
|
||||
arr.drums.forEach((ev) => {
|
||||
if (ev.kind === 'kick') track(kit.playKick(audioCtx, mix, at(ev.step), KICK));
|
||||
else track(kit.playSnare(audioCtx, mix, noise, at(ev.step), [[echo.input, throwAmount + 0.2], [hall, 0.15]], RIM));
|
||||
});
|
||||
|
||||
// Echo tails ring in the shared bus, so the voice slot frees when the notes end.
|
||||
setTimeout(() => {
|
||||
nodes.concat(sides).forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, (lastEnd - audioCtx.currentTime + 1) * 1000);
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('dub', { name: 'dub', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,108 @@
|
||||
// Voice v7: "Acid" — TB-303 acid techno, A minor
|
||||
// Packet bytes are a 303 sequencer pattern: each byte is one 16th step, and its
|
||||
// bits pick the note, accent, slide or rest. A squelchy resonant filter sweeps
|
||||
// across the pattern over four-on-the-floor drums.
|
||||
// ADVERT → 16-step pattern, kick + offbeat hats + clap on 2 and 4
|
||||
// GRP_TXT → 8-step pattern, kick + hats (the most frequent type, kept short)
|
||||
// TXT_MSG → 16-step pattern, brighter filter, kick + hats + clap
|
||||
// TRACE → 8-step pattern with the filter sweeping wide open, hats only
|
||||
// Hops raise the filter resonance (more squelch); observers make it louder.
|
||||
// Requires audio-synthkit.js.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { sampleBytes } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
|
||||
const ROOT = 45; // A2
|
||||
// Semitone offsets over the root; the classic acid moves: root, octave jumps, minor third, fifth, seventh
|
||||
const OFFSETS = [0, 0, 12, 3, 7, 10, 0, 15];
|
||||
|
||||
// Pure: packet → 303 pattern + drums on a 16th grid. No audio here.
|
||||
// { steps, notes: [{step, midi, accent, slide, prevMidi}], drums: [{step, kind}], sweep: [lo, hi] }
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName } = parsed;
|
||||
const kind = ['GRP_TXT', 'TXT_MSG', 'TRACE'].includes(typeName) ? typeName : 'ADVERT';
|
||||
const len = kind === 'GRP_TXT' || kind === 'TRACE' ? 8 : 16;
|
||||
// Walk the payload evenly to get exactly `len` steps (repeating short payloads)
|
||||
const bytes = Array.from({ length: len }, (_, i) =>
|
||||
payloadBytes[Math.floor((i / len) * payloadBytes.length)] ^ (i * 29 & 0xff));
|
||||
|
||||
const notes = [];
|
||||
let prev = null;
|
||||
bytes.forEach((b, step) => {
|
||||
const rest = step > 0 && (b >> 5 & 3) === 0; // ~1 in 4 steps rests (never the downbeat)
|
||||
if (rest) { prev = null; return; }
|
||||
const midi = ROOT + OFFSETS[b & 7];
|
||||
const slide = prev != null && (b & 16) !== 0;
|
||||
notes.push({ step, midi, accent: (b & 8) !== 0, slide, prevMidi: slide ? prev : null });
|
||||
prev = midi;
|
||||
});
|
||||
|
||||
const drums = [];
|
||||
for (let s = 0; s < len; s++) {
|
||||
if (kind !== 'TRACE' && s % 4 === 0) drums.push({ step: s, kind: 'kick' });
|
||||
if (s % 4 === 2) drums.push({ step: s, kind: 'hat' });
|
||||
if ((kind === 'ADVERT' || kind === 'TXT_MSG') && s % 8 === 4) drums.push({ step: s, kind: 'clap' });
|
||||
}
|
||||
|
||||
const sweep = { ADVERT: [350, 1400], GRP_TXT: [300, 900], TXT_MSG: [700, 2200], TRACE: [300, 3500] }[kind];
|
||||
return { steps: len, notes, drums, sweep };
|
||||
}
|
||||
|
||||
const KICK = { from: 150, to: 45, sweep: 0.07, decay: 0.3, level: 1 };
|
||||
const CLAP = { level: 0.45, decay: 0.12, body: 900 };
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const noise = kit.bus(audioCtx, masterGain, 'noise').buffer;
|
||||
const delay = kit.bus(audioCtx, masterGain, 'delay');
|
||||
delay.delays.forEach((d) => { d.delayTime.value = 3 * sixteenth; });
|
||||
|
||||
const arr = arrange(parsed);
|
||||
const q = Math.min(20, 9 + hopCount); // more hops → more squelch
|
||||
const { mix, nodes } = kit.createMix(audioCtx, masterGain, Math.min(0.6, 0.4 + (obsCount - 1) * 0.015));
|
||||
const bassline = audioCtx.createGain();
|
||||
bassline.connect(mix);
|
||||
const send = audioCtx.createGain();
|
||||
send.gain.value = 0.2;
|
||||
bassline.connect(send);
|
||||
send.connect(delay.input);
|
||||
|
||||
const t0 = kit.gridStart(audioCtx, sixteenth);
|
||||
const at = (step) => t0 + step * sixteenth;
|
||||
let lastEnd = t0;
|
||||
const track = (t) => { if (t > lastEnd) lastEnd = t; };
|
||||
|
||||
// Filter "knob" sweeps up and back down across the pattern
|
||||
const [lo, hi] = arr.sweep;
|
||||
arr.notes.forEach((n) => {
|
||||
const knob = Math.sin((Math.PI * n.step) / arr.steps);
|
||||
const cutoff = lo * Math.pow(hi / lo, knob);
|
||||
// Slid notes tie into the next step; plain notes gate at ~60% of a 16th
|
||||
const dur = sixteenth * (n.slide ? 1 : 0.6);
|
||||
track(kit.playAcid(audioCtx, bassline, n, at(n.step), dur, { cutoff, q, envMod: 2.5, decay: 0.18, level: 0.35 }));
|
||||
});
|
||||
const recover = Math.min(0.15, 3 * sixteenth);
|
||||
arr.drums.forEach((d) => {
|
||||
const t = at(d.step);
|
||||
if (d.kind === 'kick') {
|
||||
track(kit.playKick(audioCtx, mix, t, KICK));
|
||||
kit.duck(bassline.gain, t, recover, 0.5);
|
||||
} else if (d.kind === 'hat') {
|
||||
track(kit.playHat(audioCtx, mix, noise, t, 0.2));
|
||||
} else {
|
||||
track(kit.playSnare(audioCtx, mix, noise, t, [], CLAP));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
nodes.concat(bassline, send).forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, (lastEnd - audioCtx.currentTime + 1) * 1000);
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('acid', { name: 'acid', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,128 @@
|
||||
// Voice v8: "Lofi" — lo-fi hip hop, F major jazz chords (ii–V–I–vi–IV)
|
||||
// Swung boom-bap drums, electric-piano 7th/9th chords, a soft bass and vinyl
|
||||
// crackle, with a tape-style wobble and muffle.
|
||||
// ADVERT → two chords with drums and bass
|
||||
// GRP_TXT → one chord with drums (the most frequent type, kept short)
|
||||
// TXT_MSG → two chords with a little electric-piano melody on top, no drums
|
||||
// TRACE → a swung electric-piano arpeggio with hats
|
||||
// Hops add tape wobble and muffle (longer routes sound like older tape);
|
||||
// observers make it louder. Requires audio-synthkit.js.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { sampleBytes, mapRange } = MeshAudio.helpers;
|
||||
const kit = MeshAudio.synthkit;
|
||||
|
||||
// ii–V–I–vi–IV colours in F, rootless-ish voicings around C4 (the bass plays roots)
|
||||
const CHORDS = [
|
||||
{ bass: 43, midis: [55, 58, 62, 65, 69] }, // Gm9: G Bb D F A
|
||||
{ bass: 36, midis: [52, 55, 58, 62, 67] }, // C9: E G Bb D G
|
||||
{ bass: 41, midis: [57, 60, 64, 67, 69] }, // Fmaj9: A C E G A
|
||||
{ bass: 38, midis: [53, 57, 60, 62, 64] }, // Dm9: F A C D E
|
||||
{ bass: 46, midis: [58, 62, 65, 69, 72] }, // Bbmaj9: Bb D F A C
|
||||
];
|
||||
const SWING = 0.3; // odd 16ths pushed late by 30% of a 16th
|
||||
|
||||
const chordOf = (b) => CHORDS[Math.min(CHORDS.length - 1, Math.floor((b / 256) * CHORDS.length))];
|
||||
|
||||
// Pure: packet → arrangement on a swung 16th grid (8 steps per chord). No audio here.
|
||||
// { steps, chords: [{step,len,midis}], melody|arp: [{step,len,midi}],
|
||||
// bass: [{step,len,midi}], drums: [{step,kind:'kick'|'snare'|'hat',soft?}] }
|
||||
function arrange(parsed) {
|
||||
const { payloadBytes, typeName } = parsed;
|
||||
const kind = ['GRP_TXT', 'TXT_MSG', 'TRACE'].includes(typeName) ? typeName : 'ADVERT';
|
||||
const bytes = sampleBytes(payloadBytes, kind === 'GRP_TXT' ? 1 : 2, kind === 'GRP_TXT' ? 1 : 2);
|
||||
const out = { steps: bytes.length * 8, chords: [], melody: [], arp: [], bass: [], drums: [] };
|
||||
|
||||
bytes.forEach((b, i) => {
|
||||
const c = chordOf(b);
|
||||
const s0 = i * 8;
|
||||
if (kind === 'TRACE') {
|
||||
const up = c.midis.concat(c.midis[0] + 12, c.midis[2] + 12, c.midis[4] + 12);
|
||||
for (let k = 0; k < 8; k++) out.arp.push({ step: s0 + k, len: 2, midi: up[k] });
|
||||
return;
|
||||
}
|
||||
out.chords.push({ step: s0, len: 8, midis: c.midis });
|
||||
if (kind !== 'GRP_TXT') {
|
||||
out.bass.push({ step: s0, len: 3, midi: c.bass });
|
||||
out.bass.push({ step: s0 + 6, len: 2, midi: c.bass + (b & 1 ? 7 : 12) });
|
||||
}
|
||||
if (kind === 'TXT_MSG') {
|
||||
// A little melody from the chord's upper tones, an octave up
|
||||
[1, 3, 6].forEach((s, k) => out.melody.push({ step: s0 + s, len: 2, midi: c.midis[(b >> k) % 3 + 2] + 12 }));
|
||||
}
|
||||
});
|
||||
|
||||
if (kind !== 'TXT_MSG') {
|
||||
for (let s = 0; s < out.steps; s++) {
|
||||
const inBar = s % 16;
|
||||
if (kind !== 'TRACE') {
|
||||
if (inBar === 0 || inBar === 10) out.drums.push({ step: s, kind: 'kick' });
|
||||
if (inBar === 4 || inBar === 12) out.drums.push({ step: s, kind: 'snare' });
|
||||
}
|
||||
if (s % 2 === 0 || inBar === 7 || inBar === 15) out.drums.push({ step: s, kind: 'hat', soft: s % 4 !== 0 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EP = { ratio: 1, index: [1.8, 0.4], indexDecay: 0.4, attack: 0.005, decay: 1.2, sustain: 0.35, release: 0.5, level: 0.6 };
|
||||
const EP_LEAD = Object.assign({}, EP, { index: [2.4, 0.5], level: 0.25 });
|
||||
const BASS = { type: 'sine', detunes: [0], cutoff: [900, 900, 700], q: 0.5, attack: 0.01, sustain: 0.8, release: 0.1, level: 0.4 };
|
||||
const KICK = { from: 110, to: 48, sweep: 0.09, decay: 0.28, level: 0.8 };
|
||||
const SNARE = { level: 0.3, decay: 0.14, body: 180 };
|
||||
|
||||
function play(audioCtx, masterGain, parsed, opts) {
|
||||
const { hopCount, obsCount } = parsed;
|
||||
const sixteenth = 0.125 * opts.tempoMultiplier;
|
||||
const noise = kit.bus(audioCtx, masterGain, 'noise').buffer;
|
||||
const crackle = kit.bus(audioCtx, masterGain, 'crackle').buffer;
|
||||
const hall = kit.bus(audioCtx, masterGain, 'hall').input;
|
||||
|
||||
const arr = arrange(parsed);
|
||||
const distance = Math.min(1, (hopCount - 1) / 9);
|
||||
const { mix, nodes } = kit.createMix(audioCtx, masterGain, Math.min(0.7, 0.5 + (obsCount - 1) * 0.015));
|
||||
|
||||
// Tape: everything goes through a muffling lowpass; hops darken it
|
||||
const tape = audioCtx.createBiquadFilter();
|
||||
tape.type = 'lowpass';
|
||||
tape.frequency.value = mapRange(distance, 0, 1, 5000, 1600);
|
||||
tape.Q.value = 0.5;
|
||||
tape.connect(mix);
|
||||
|
||||
// One slow wobble LFO per packet into every electric-piano oscillator's pitch
|
||||
const wobble = audioCtx.createOscillator();
|
||||
const wobbleDepth = audioCtx.createGain();
|
||||
wobble.frequency.value = 0.8;
|
||||
wobbleDepth.gain.value = 4 + 12 * distance; // cents
|
||||
wobble.connect(wobbleDepth);
|
||||
|
||||
// Swung riffs start on an 8th boundary so odd steps line up across packets
|
||||
const t0 = kit.gridStart(audioCtx, 2 * sixteenth);
|
||||
const at = (step) => kit.stepTime(t0, step, sixteenth, SWING);
|
||||
let lastEnd = t0;
|
||||
const track = (t) => { if (t > lastEnd) lastEnd = t; };
|
||||
const epOpts = { sends: [[hall, 0.2]], pitchMod: wobbleDepth };
|
||||
|
||||
arr.chords.forEach((c) => track(kit.playFM(audioCtx, tape, EP, c.midis, at(c.step), c.len * sixteenth, epOpts)));
|
||||
arr.melody.concat(arr.arp).forEach((n) => track(kit.playFM(audioCtx, tape, EP_LEAD, [n.midi], at(n.step), n.len * sixteenth, epOpts)));
|
||||
arr.bass.forEach((n) => track(kit.playSynth(audioCtx, tape, BASS, [n.midi], at(n.step), n.len * sixteenth - 0.02)));
|
||||
arr.drums.forEach((d) => {
|
||||
const t = at(d.step);
|
||||
if (d.kind === 'kick') track(kit.playKick(audioCtx, tape, t, KICK));
|
||||
else if (d.kind === 'snare') track(kit.playSnare(audioCtx, tape, noise, t, [[hall, 0.15]], SNARE));
|
||||
else track(kit.playHat(audioCtx, tape, noise, t, d.soft ? 0.06 : 0.1));
|
||||
});
|
||||
kit.playCrackle(audioCtx, mix, crackle, t0, lastEnd, 0.08);
|
||||
wobble.start(t0);
|
||||
wobble.stop(lastEnd);
|
||||
|
||||
setTimeout(() => {
|
||||
nodes.concat(tape, wobble, wobbleDepth).forEach((n) => { try { n.disconnect(); } catch (e) {} });
|
||||
}, (lastEnd - audioCtx.currentTime + 1) * 1000);
|
||||
return lastEnd - audioCtx.currentTime;
|
||||
}
|
||||
|
||||
MeshAudio.registerVoice('lofi', { name: 'lofi', play, arrange });
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
// Compatibility shim for stock CoreScope builds.
|
||||
// The voices in this pack use two helpers (sampleBytes, panFor) that stock
|
||||
// public/audio.js does not export yet. Add them only when missing, so this
|
||||
// file is harmless on a CoreScope that already ships them.
|
||||
// Must load after audio.js and before audio-synthkit.js / audio-v*.js.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof MeshAudio === 'undefined' || !MeshAudio.helpers) {
|
||||
console.error('[audio-voices] MeshAudio not found; load audio.js first');
|
||||
return;
|
||||
}
|
||||
const h = MeshAudio.helpers;
|
||||
|
||||
// Evenly sample sqrt(len) bytes, clamped to [minN, maxN] notes.
|
||||
if (!h.sampleBytes) {
|
||||
h.sampleBytes = function (bytes, minN, maxN) {
|
||||
const n = Math.max(minN, Math.min(maxN, Math.ceil(Math.sqrt(bytes.length))));
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) out.push(bytes[Math.floor((i / n) * bytes.length)]);
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
// Stereo position: longitude when the packet carries coordinates, a small
|
||||
// random spread for routed packets without them, centre otherwise.
|
||||
if (!h.panFor) {
|
||||
h.panFor = function (parsed) {
|
||||
const { payload, hops } = parsed;
|
||||
if (payload && payload.lat !== undefined && payload.lon !== undefined) {
|
||||
return Math.max(-1, Math.min(1, h.mapRange(payload.lon, -125, -65, -1, 1)));
|
||||
}
|
||||
if (hops && hops.length > 0) return (Math.random() - 0.5) * 0.6;
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user