diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..4c34f46
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,26 @@
+# Changelog
+
+All notable changes to the Drum Highway plugin. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow `plugin.json`.
+
+## [4.1.0] - Unreleased
+
+### Added
+- **Rock Band-style scoring** from a JavaScript port of YARG.Core's drums engine (`assets/engine.js`,
+ LGPL-3.0, see `NOTICE.md`): ±70 ms hit window, multiplier up to x4 (x8 in star power), pro-drums
+ cymbal/tom lanes, accent/ghost dynamics bonus, stars, and star power with fill activation. Driven
+ through `assets/session.js`, which also adds spot-on timing, roll and drum-solo bonuses. The 2D view
+ marks hits / misses from the engine and shows a score HUD when **Score** is on.
+- **Difficulty per player** — Easy, Medium, Hard, Expert, Expert+ (`drums_difficulty_v1`), from the
+ settings panel, the HUD badge or D / Shift+D; Expert drops the double-kick notes. Easier levels, star
+ power, fills and solos come from the drum tab's feedpak 1.20.0 gameplay metadata; without it the song
+ plays Expert only, without star power.
+- **Assists** — auto kick / auto cymbals up to a chosen difficulty (played on the drum sound at their own
+ volume), hit-timing presets (Relaxed / Forgiving / Normal / Precision), **Pro cymbals** toggle and an
+ input **Offset** (ms) for audio / MIDI latency.
+
+### Changed
+- A drum-tab hit is an accent only when the tab marks it (`a`, feedpak 1.20.0) or, for older tabs, when it
+ has a written velocity of 100 or more; a hit with no velocity is no longer drawn as an accent. A kick
+ marked `x2` is a double kick (GM 35).
+- The "Hits" checkbox is now **Score**.
diff --git a/LICENSE.GPL-3.0 b/LICENSE.GPL-3.0
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE.GPL-3.0
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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:
+
+ Copyright (C)
+ 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
+.
+
+ 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
+.
diff --git a/LICENSE.LGPL-3.0 b/LICENSE.LGPL-3.0
new file mode 100644
index 0000000..153d416
--- /dev/null
+++ b/LICENSE.LGPL-3.0
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser 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
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/NOTICE.md b/NOTICE.md
new file mode 100644
index 0000000..cda1843
--- /dev/null
+++ b/NOTICE.md
@@ -0,0 +1,25 @@
+# Licensing notice for the drums plugin
+
+Most of this plugin is original code under the **MIT** license (see `README.md`). That includes
+`screen.js`, `assets/session.js`, `plugin.json`, the other assets and the tests other than
+`tests/engine.test.js`.
+
+Two files are ports of [YARG.Core](https://github.com/YARC-Official/YARG.Core) and are licensed under the
+**GNU Lesser General Public License v3.0** (full text in [`LICENSE.LGPL-3.0`](LICENSE.LGPL-3.0); the LGPL
+builds on the GNU GPL v3.0, whose text is in [`LICENSE.GPL-3.0`](LICENSE.GPL-3.0)):
+
+| File | Status |
+|------|--------|
+| `assets/engine.js` | LGPL-3.0. JavaScript port, with modifications, of YARG.Core's drums engine (`Engine/Drums/DrumsEngine.cs`, `Engine/Drums/Engines/YargDrumsEngine.cs`, `DrumsEngineParameters.cs`, `DrumsStats.cs`, `Engine/BaseEngine*.cs`, `BaseStats.cs`, `HitWindowSettings.cs`, `Chart/Notes/DrumNote.cs`, `Note.cs`, drum activation flags from `InstrumentDifficultyExtensions.cs`, and defaults from `Game/Presets/EnginePreset*.cs`). It also takes the drum star thresholds from YARG's `DrumsPlayer.cs` (LGPL-3.0). Copyright (c) YARC (YARG) contributors. |
+| `tests/engine.test.js` | LGPL-3.0. Tests for `engine.js`, partly ported from `YARG.Core.UnitTests/Engine/DrumEngineTester.cs` and `DrumsStatsTests.cs`. |
+
+`assets/engine.js` is a standalone, unminified script with no dependencies, shipped as its own file and
+loaded at runtime. `screen.js` and `assets/session.js` only use it through its public API
+(`window.DrumsEngine` / `require('./engine.js')`), so they remain separate MIT works that use the Library
+as described in LGPL-3.0 section 4 ("Combined Works"). You may replace `assets/engine.js` with a modified
+version. If you distribute changes to `assets/engine.js`, they must stay under LGPL-3.0 and their source
+must stay available. Combining an LGPL-3.0 file with MIT code this way is permitted by both licenses, and
+both are compatible with feedBack's AGPL-3.0.
+
+The header of `assets/engine.js` lists every behaviour that deviates from YARG ("Deviations from YARG").
+The scoring look and feel imitates rhythm-game drum tracks but uses no artwork, fonts or logos from them.
diff --git a/README.md b/README.md
index baa3cd9..04b5f4a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,9 @@ A plugin for [Slopsmith](https://github.com/got-feedback/feedback) that replaces
- **MIDI drum pad input** — connect any MIDI drum pad, electronic kit, or controller via Web MIDI API
- **Custom MIDI mapping** — "Learn" mode to assign any MIDI note to any lane, for non-standard drum pads
- **Built-in drum sounds** — WebAudioFont-powered GM drum kit playback on MIDI hit
-- **Accuracy scoring** — hit detection with tight +/-50ms timing window, accuracy %, streak counter
+- **Rock Band-style scoring** — score, multiplier, streak, stars and star power from a port of the YARG drums engine (`assets/engine.js`). See [Scoring](#scoring).
+- **Difficulty** — Easy, Medium, Hard, Expert or Expert+ per player (browser), changeable mid-song. See [Difficulty](#difficulty).
+- **Assists** — auto kick, auto cymbals (each up to a chosen difficulty) and hit-timing presets. See [Assists](#assists).
- **Inline settings** — MIDI device, volume, channel filter, lane labels, hit detection, and mapping table
## Drum Lanes
@@ -32,6 +34,68 @@ A plugin for [Slopsmith](https://github.com/got-feedback/feedback) that replaces
| Ride | Ri | 51, 59 | White | Diamond |
| Kick | Ki | 35, 36 | Red | Full-width bar |
+## Scoring
+
+Turn on **Score** in the settings panel (gear) to see the scoring HUD: multiplier, score, streak, stars
+and the star power meter. Hit notes turn green, missed notes grey, and a pad hit on an empty lane flashes it
+red.
+
+Scoring is a JavaScript port of YARG.Core's drums engine (`assets/engine.js`, LGPL-3.0 — see
+[License](#license)), used through `assets/session.js`:
+
+- **Hit window** ±70 ms (see [Assists](#assists) for wider ones); one pad hit hits at most one note.
+- **Multiplier** grows every 10 notes up to x4, doubled during star power (x8). 60 points per note (pro
+ drums), plus a dynamics bonus for hitting an accent hard or a ghost note soft (needs a velocity-sensitive
+ kit). Pro drums: a cymbal and the tom of the same colour are different notes; with **Pro cymbals** off
+ either one hits that colour's notes.
+- **Star power** — notes in a star power phrase are drawn silver-white; hitting every note of a phrase fills
+ a quarter of the meter. With half a meter, the chart's **fills** are marked green: the hit that ends the
+ fill activates star power.
+- **Bonuses** (shown in the score) — +10 x multiplier for a hit within 25 ms, extra points for snare / tom
+ rolls, and a drum-solo bonus (100 per note hit, doubled for a perfect solo) with a live percentage during
+ a solo.
+- **Offset** (ms) — subtracted from the time of every hit; raise it if your hits register late (audio or
+ MIDI latency). Hits between frames are placed using the playback rate, not quantised to the frame rate.
+- Seeking (or a loop jumping back) restarts scoring from the new position; skipped notes aren't misses.
+
+The scoring files load on first use from core's plugin assets route (`/api/plugins/drums/assets/`). Until
+they have loaded the view shows the chart as it is.
+
+### Difficulty
+
+Each player picks **Easy**, **Medium**, **Hard**, **Expert** or **Expert+**, stored per browser
+(`localStorage` key `drums_difficulty_v1`, default Expert), so in splitscreen / multiplayer every player
+keeps their own. Change it in the settings panel, by clicking the difficulty badge (top-left of the lanes),
+or with **D** (harder) / **Shift+D** (easier) while the view is focused.
+
+- **Expert+** is the drum part as charted, double kicks included; **Expert** drops the double-kick notes
+ (`x2`). **Easy / Medium / Hard** come from the chart's own easier levels; the badge says **AUTO** when a
+ level was reduced by software rather than hand-charted.
+- A level the chart doesn't have is greyed out with the reason; a song without your saved level plays
+ Expert (the badge is dimmed) and your choice is kept for the next song.
+- Changing difficulty mid-song rebuilds the chart from the current position, like a seek.
+
+### Where star power, fills and levels come from
+
+They are part of the song's drum tab (`drum_tab.json`, feedpak 1.20.0 "drum gameplay metadata"): per-hit
+`a` (accent) and `x2` (double kick) flags, `star_power` / `fills` / `solos` as `{start_time, end_time}`
+ranges, and `levels.easy|medium|hard` with their own `hits` and a `generated` marker. feedBack's Rock Band /
+Clone Hero / YARG chart importer writes them, and core streams them to the plugin with the tab
+(`bundle.drumTab`). A tab without them — an older tab, a core that doesn't stream them yet, or drums encoded
+as guitar notes — plays **Expert only, without star power**; everything else works the same.
+
+### Assists
+
+- **Auto kick / Auto cymbals** (`drums_auto_kick_v1`, `drums_auto_cymbals_v1`): `Off`, `Easy only`,
+ `Easy – Medium`, `Easy – Hard` or `Every difficulty` — the assist is on at that difficulty and the ones
+ below it. Those notes are left out of scoring, marked as played when they reach the now line, and pad hits
+ on them are ignored. Cymbals = the chart's hi-hat, ride and crash notes. They are **played on the drum
+ sound** (scheduled ~60 ms ahead on the audio clock) so muting the drum stem leaves no holes, at their own
+ **Auto vol** (`drums_auto_volume_v1`, default 80%), so pads can stay silent when the kit module makes the
+ sound.
+- **Timing** (`drums_timing_v1`): Relaxed ±130 ms, Forgiving ±100 ms, Normal ±70 ms (YARG's default),
+ Precision (YARG's dynamic window that tightens on fast notes).
+
## Requirements
- **Chrome or Edge** for MIDI drum pad input (Firefox does not support Web MIDI)
@@ -71,6 +135,14 @@ Different drum pads send different MIDI note numbers. Use the "Learn" mode in se
Click "Reset Map" to return to standard GM mapping.
+## Development
+
+Tests run with plain Node (the org CI runs each `tests/*.test.js`): `node tests/engine.test.js`,
+`node tests/session.test.js`, `node tests/screen.test.js`.
+
## License
-MIT
+MIT, except `assets/engine.js` and `tests/engine.test.js`: they are ports of
+[YARG.Core](https://github.com/YARC-Official/YARG.Core) and stay under the **LGPL-3.0**
+(`LICENSE.LGPL-3.0`, with the GPL-3.0 it builds on in `LICENSE.GPL-3.0`). The engine is a separate,
+replaceable file that the MIT code only calls through its public API. Details in [NOTICE.md](NOTICE.md).
diff --git a/assets/engine.js b/assets/engine.js
new file mode 100644
index 0000000..4c6aeb8
--- /dev/null
+++ b/assets/engine.js
@@ -0,0 +1,1081 @@
+/*
+ * engine.js: Rock Band-style drums hit detection and scoring for the feedBack drums plugin.
+ *
+ * Ported from YARG.Core (https://github.com/YARC-Official/YARG.Core):
+ * YARG.Core/Engine/Drums/DrumsEngine.cs
+ * YARG.Core/Engine/Drums/Engines/YargDrumsEngine.cs
+ * YARG.Core/Engine/Drums/DrumsEngineParameters.cs
+ * YARG.Core/Engine/Drums/DrumsStats.cs
+ * YARG.Core/Engine/BaseEngine.cs, BaseEngine.Generic.cs, BaseEngineParameters.cs, BaseStats.cs
+ * YARG.Core/Engine/HitWindowSettings.cs
+ * YARG.Core/Chart/Notes/DrumNote.cs, YARG.Core/Chart/Notes/Note.cs
+ * YARG.Core/Chart/Tracks/InstrumentDifficultyExtensions.cs (SetDrumActivationFlags)
+ * YARG.Core/Chart/Events/WaitCountdown.cs (MIN_SECONDS)
+ * YARG.Core/Game/Presets/EnginePreset.Instruments.cs and EnginePreset.Defaults.cs (default parameters)
+ * and from the YARG game (https://github.com/YARC-Official/YARG, also LGPL-3.0):
+ * Assets/Script/Gameplay/Player/DrumsPlayer.cs (drum star score thresholds)
+ *
+ * Copyright (c) YARC (YARG) contributors
+ * Licensed under the GNU Lesser General Public License v3.0
+ * (see https://github.com/YARC-Official/YARG.Core/blob/master/LICENSE, and LICENSE.LGPL-3.0 at the plugin root; LGPL-3.0 builds on the GPL-3.0 in LICENSE.GPL-3.0).
+ *
+ * Ported and modified to JavaScript for the feedBack drums plugin. This file is a derivative work of the
+ * above files and stays under LGPL-3.0. The rest of the plugin is MIT (see NOTICE.md).
+ * Modifications: rewritten as a dependency-free JavaScript module, uses an immediate hit()/update() API
+ * in place of YARG's input queue, works in seconds/measures in place of chart ticks, and decodes feedBack
+ * wire notes (General MIDI drum numbers). The deviations are listed below.
+ *
+ * -------------------------------------------------------------------------------------------------
+ * Rules ported from YARG (defaults = EnginePreset.Default):
+ * - Hit window: 140 ms total, front/back ratio 1.0, so it runs from 70 ms early to 70 ms late. It is not
+ * dynamic by default. YARG's "Dark YARG" dynamic window is ported (params.hitWindow.isDynamic) along with
+ * the "Precision" preset (DrumsEngine.PRESETS.precision). The window scales with params.songSpeed.
+ * A note can still be hit exactly at the back edge. It is missed once time > t + backEnd.
+ * - Matching (YargDrumsEngine.CheckForNoteHit): scan from the oldest unresolved chord forward while the
+ * chords are inside the hit window, and hit the first note whose lane equals the input lane. Every note
+ * in a chord is hit separately. Hitting a later chord counts every unresolved note in earlier chords as
+ * a miss (SkipPreviousNotes). One input hits at most one note.
+ * - Lanes (FourLaneDrumPad): with pro drums, a yellow, blue or green cymbal is a separate lane from the tom
+ * of the same colour, so a cymbal hit on a tom note does not hit it and is an overhit. Non-pro drums map
+ * cymbal inputs onto the drum lanes. The engine has no cymbal/tom fallback.
+ * - Overhit (DrumsEngine.Overhit): a pad hit that hits no note resets the combo, adds 1 to overhits and
+ * fails the star power phrase in progress (unless the current note starts the phrase). It does not
+ * change the score. An overhit is ignored before the first note is resolved, after the last note, and
+ * during a WaitCountdown (a gap of 9 s or more between notes).
+ * - Combo: +1 for each note hit, so a chord adds 1 per note. Multiplier = min(floor(combo / 10) + 1, 4),
+ * doubled while star power is active (up to 8x). The multiplier updates before the note is scored, so
+ * the 10th note already scores at 2x.
+ * - Score: 60 points per note with pro drums, 50 without, times the multiplier. Dynamics: an accent or ghost
+ * note hit with the right velocity scores +25 x multiplier (ApplyVelocity, threshold 0.35, 1.5 s window
+ * for relative velocity). This only applies when hit() gets a velocity.
+ * - Star power: hitting every note of a phrase awards a quarter bar (2 measures). The bar holds 8 measures.
+ * Activation needs half a bar (4 measures). While active, the bar drains 1 measure per measure of song
+ * time, so a full bar lasts 8 measures. A phrase hit while star power is active adds to the bar and
+ * pushes the end time back (unless params.noStarPowerOverlap is set). A miss in a phrase fails it.
+ * - Drum fill activation (SetDrumActivationFlags + DrumsEngine.HitNote): the activation chord of a fill
+ * gets the "activator" flag. With YARG's default RightmostNote type, only the rightmost lane of that
+ * chord gets it. Hitting the activator when the bar is at least half full activates star power.
+ * If the player lets the activator pass while star power could activate, it is auto-hit: it scores and
+ * adds to the combo but does not activate star power and fires no 'hit' event.
+ * - Stars: BaseScore = the score of a full combo with no star power and no dynamics bonus, using the same
+ * pre-increment multiplier as CalculateChartScores. Thresholds come from DrumsPlayer.cs:
+ * [0.06, 0.12, 0.2, 0.45, 0.75, 1.09] x BaseScore for 1-5 stars and gold.
+ *
+ * Deviations from YARG:
+ * 1. Time model: YARG runs a queued-input simulation in chart ticks. This engine uses seconds and a
+ * time->measure map built from opts.measures (barline times), opts.beats (numbers or {time, measure}
+ * objects, where a measure >= 0 marks a downbeat), or opts.tempo ([{t, bpm}] plus opts.beatsPerMeasure,
+ * default 4). The fallback is 120 BPM in 4/4, so 1 measure = 2 s. Star power is tracked as fractional
+ * measures rather than integer measure ticks, so there is no tick rounding.
+ * 2. hit() and update() run immediately. Times earlier than the engine clock are clamped forward, as
+ * YARG's QueueInput does. Misses and the star power end are processed in time order inside each call.
+ * 3. Not ported: trill/tremolo lanes, kick lanes and their lane leniency, BRE/coda, solos and solo bonus,
+ * band/unison bonus, revives, the Elite and 5-lane modes, bots, and replays. The wildcard pad is not
+ * reachable from this API.
+ * 4. Chart import: notes less than 1 ms apart form a chord (YARG uses identical ticks). A second note on
+ * the same lane in one chord is dropped, like DrumNote.AddChildNote. With proDrums:false a yellow tom
+ * and a yellow cymbal in one chord collapse into one note, and noteState() of the dropped id reports
+ * the surviving note. 35 (kick 2x) is kept unless opts.kick2x === false, rather than YARG's
+ * Expert+ difficulty switch.
+ * 5. Star power phrase membership: start - 0.1 ms <= chord.t < end - 0.1 ms (half-open, like tick ranges).
+ * Phrases that contain no notes are left out of phrasesTotal.
+ * 6. Phrase failure is tracked per phrase. Missing any note of a phrase, including a child note of a
+ * chord, fails the whole phrase. YARG's StripStarPower only strips the child note in that case, which
+ * looks like a bug.
+ * 7. Activation window -> activator chord: the last chord with start - 0.1 ms <= t <= end + 0.1 ms
+ * (Rock Band: the fill ends on the activation chord). If no chord falls in the window, YARG's rule is
+ * used: the first chord at or after `end`. Without opts.activation, eng.activateStarPower() works
+ * whenever the bar is half full. With fills, manual activation is off unless opts.manualActivation is
+ * true. YARG drums have no manual activation; it auto-generates fills.
+ * 8. Hitting a pad that matches a chord note already hit is consumed with no effect, as in YARG. This
+ * engine returns {type:'ignored'} and, unlike YARG, does not run the velocity bonus again.
+ * 9. Velocity comparison (ApplyVelocity): the comparison note is looked up across all notes of earlier
+ * chords on the same lane, not only parent notes. Velocities above 1 are read as MIDI 0-127.
+ * 10. Reporting: getState().notesMissed counts notes actually missed so far (YARG reports
+ * TotalNotes - NotesHit). accuracy = hit / (hit + missed) so far, and percent = YARG's
+ * NotesHit / TotalNotes. maxMultiplier is the cap (params.maxMultiplier) and peakMultiplier is the
+ * highest multiplier reached. 'combo-break' fires only when a combo above 0 resets.
+ * -------------------------------------------------------------------------------------------------
+ */
+(function (root, factory) {
+ 'use strict';
+ const api = factory();
+ if (typeof module === 'object' && module && module.exports) module.exports = api;
+ if (typeof window !== 'undefined' && window) window.DrumsEngine = api;
+ else if (typeof module !== 'object' && root) root.DrumsEngine = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ // ── Constants ───────────────────────────────────────────────────────────────
+
+ // Plugin-facing pad colours (shared with screen.js).
+ const PAD = Object.freeze({ KICK: 0, RED: 1, YELLOW: 2, BLUE: 3, GREEN: 4 });
+
+ // YARG FourLaneDrumPad (DrumNote.cs). These are the engine's internal "lanes".
+ const LANE = Object.freeze({
+ KICK: 0, RED: 1, YELLOW_DRUM: 2, BLUE_DRUM: 3, GREEN_DRUM: 4,
+ YELLOW_CYMBAL: 5, BLUE_CYMBAL: 6, GREEN_CYMBAL: 7,
+ });
+
+ // BaseEngine.cs
+ const POINTS_PER_NOTE = 50;
+ const POINTS_PER_PRO_NOTE = POINTS_PER_NOTE + 10;
+ // YargDrumsEngine.CheckForNoteHit: velocityBonus = POINTS_PER_NOTE / 2
+ const VELOCITY_BONUS = POINTS_PER_NOTE / 2;
+ // BaseEngine.UpdateMultiplier: (Combo / 10) + 1
+ const NOTES_PER_MULTIPLIER = 10;
+
+ const CHORD_EPS = 0.001; // notes closer than 1 ms form a chord
+ const PHRASE_EPS = 0.0001; // tolerance for phrase/window boundaries
+ const SP_EPS = 1e-9;
+
+ // General MIDI drum number -> pad/cymbal (the drums wire encoding is midi = s*24 + f).
+ const MIDI_MAP = Object.freeze({
+ 36: { pad: PAD.KICK, cymbal: false },
+ 35: { pad: PAD.KICK, cymbal: false, kick2x: true },
+ 38: { pad: PAD.RED, cymbal: false }, // acoustic snare
+ 37: { pad: PAD.RED, cymbal: false }, // side stick / cross-stick
+ 40: { pad: PAD.RED, cymbal: false }, // electric snare
+ 42: { pad: PAD.YELLOW, cymbal: true }, // closed hi-hat
+ 46: { pad: PAD.YELLOW, cymbal: true }, // open hi-hat
+ 44: { pad: PAD.YELLOW, cymbal: true, hatPedal: true }, // pedal hi-hat (opt-in)
+ 48: { pad: PAD.YELLOW, cymbal: false }, // hi-mid tom
+ 50: { pad: PAD.YELLOW, cymbal: false }, // high tom
+ 45: { pad: PAD.BLUE, cymbal: false }, // low tom
+ 47: { pad: PAD.BLUE, cymbal: false }, // low-mid tom
+ 41: { pad: PAD.GREEN, cymbal: false }, // low floor tom
+ 43: { pad: PAD.GREEN, cymbal: false }, // high floor tom
+ 58: { pad: PAD.GREEN, cymbal: false }, // vibraslap (some kits send it from the floor tom)
+ 51: { pad: PAD.BLUE, cymbal: true }, // ride
+ 53: { pad: PAD.BLUE, cymbal: true }, // ride bell
+ 59: { pad: PAD.BLUE, cymbal: true }, // ride 2
+ 49: { pad: PAD.GREEN, cymbal: true }, // crash
+ 57: { pad: PAD.GREEN, cymbal: true }, // crash 2
+ 55: { pad: PAD.GREEN, cymbal: true }, // splash
+ 52: { pad: PAD.GREEN, cymbal: true }, // china
+ });
+
+ // ── Default parameters ─────────────────────────────────────────────────────
+
+ /**
+ * YARG's default drum engine parameters. Sources:
+ * - hitWindow: EnginePreset.Instruments.cs DrumsPreset.HitWindow (MaxWindow 0.14, MinWindow 0.14,
+ * IsDynamic false, FrontToBackRatio 1.0, LaneAutohitWindow 0.160) plus HitWindowPreset defaults
+ * (DynamicScale 1.0, DynamicSlope 0.93, DynamicGamma 1.5, LaneProximityProtectionWindow 0.080).
+ * - maxMultiplier: EnginePreset.DEFAULT_MAX_MULTIPLIER = 4.
+ * - velocityThreshold 0.35, situationalVelocityWindow 1.5: DrumsEngineParameters constructor.
+ * - noStarPowerOverlap false: DrumsPreset.NoStarPowerOverlap.
+ * - starPowerMeasures 8: BaseEngine.STAR_POWER_MAX_MEASURES (TicksPerFullSpBar = 8 measures).
+ * A phrase gives TicksPerQuarterSpBar (2 measures), and activation needs TicksPerHalfSpBar (4 measures).
+ * - starMultiplierThresholds: YARG DrumsPlayer.cs StarMultiplierThresholds.
+ * - waitCountdownSeconds 9: WaitCountdown.MIN_SECONDS.
+ * - activationType 'rightmost': YargProfile default StarPowerActivationType.RightmostNote.
+ * - pointsPerNote/pointsPerProNote/velocityBonus/notesPerMultiplier: BaseEngine.cs constants.
+ */
+ function defaultParams() {
+ return {
+ hitWindow: {
+ maxWindow: 0.14,
+ minWindow: 0.14,
+ isDynamic: false,
+ frontToBackRatio: 1.0,
+ dynamicScale: 1.0,
+ dynamicSlope: 0.93,
+ dynamicGamma: 1.5,
+ laneAutohitWindow: 0.160, // unused (lanes not ported); kept for parity
+ laneProximityProtectionWindow: 0.080, // unused (lanes not ported); kept for parity
+ },
+ songSpeed: 1.0,
+ maxMultiplier: 4,
+ notesPerMultiplier: NOTES_PER_MULTIPLIER,
+ pointsPerNote: POINTS_PER_NOTE,
+ pointsPerProNote: POINTS_PER_PRO_NOTE,
+ velocityBonus: VELOCITY_BONUS,
+ velocityThreshold: 0.35,
+ situationalVelocityWindow: 1.5,
+ noStarPowerOverlap: false,
+ starPowerMeasures: 8,
+ starMultiplierThresholds: [0.06, 0.12, 0.2, 0.45, 0.75, 1.09],
+ waitCountdownSeconds: 9,
+ activationType: 'rightmost', // 'rightmost' (YARG default) | 'all' (old YARG style)
+ };
+ }
+
+ // EnginePreset.Defaults.cs: overrides applied by YARG's built-in presets for drums.
+ const PRESETS = Object.freeze({
+ default: Object.freeze({}),
+ casual: Object.freeze({ hitWindow: { laneAutohitWindow: 0.200, laneProximityProtectionWindow: 0.100 } }),
+ precision: Object.freeze({
+ hitWindow: {
+ maxWindow: 0.13, minWindow: 0.05, isDynamic: true,
+ dynamicScale: 1, dynamicSlope: 0.60615, dynamicGamma: 2,
+ laneAutohitWindow: 0.160, laneProximityProtectionWindow: 0.080,
+ },
+ }),
+ });
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+
+ function mergeParams(over) {
+ const p = defaultParams();
+ if (!over || typeof over !== 'object') return finalizeParams(p);
+ for (const k of Object.keys(over)) {
+ if (k === 'hitWindow' && over.hitWindow && typeof over.hitWindow === 'object') {
+ Object.assign(p.hitWindow, over.hitWindow);
+ } else if (over[k] !== undefined) {
+ p[k] = over[k];
+ }
+ }
+ return finalizeParams(p);
+ }
+
+ // HitWindowSettings constructor normalisation.
+ function finalizeParams(p) {
+ const hw = p.hitWindow;
+ if (hw.maxWindow < hw.minWindow) { const t = hw.maxWindow; hw.maxWindow = hw.minWindow; hw.minWindow = t; }
+ hw.dynamicSlope = clamp(hw.dynamicSlope, 0, 1);
+ hw.dynamicScale = clamp(hw.dynamicScale, 0.3, 3);
+ hw.dynamicGamma = clamp(hw.dynamicGamma, 0.1, 10);
+ if (!(p.songSpeed > 0)) p.songSpeed = 1;
+ return p;
+ }
+
+ // HitWindowSettings.CalculateHitWindow (+ Dark_Yarg_Impl)
+ function calculateHitWindow(hw, averageTimeDistance) {
+ if (!hw.isDynamic) return hw.maxWindow;
+ const x = averageTimeDistance * 1000;
+ const minMs = hw.minWindow * 1000;
+ const maxMs = hw.maxWindow * 1000;
+ const maxMultiScale = maxMs * hw.dynamicScale;
+ const gammaPow = Math.pow(x / maxMultiScale, hw.dynamicGamma);
+ const minMultiSlope = minMs * hw.dynamicSlope;
+ const result = (gammaPow * (maxMs - minMultiSlope) + minMultiSlope) / 1000;
+ return clamp(result, hw.minWindow, hw.maxWindow);
+ }
+ // HitWindowSettings.GetFrontEnd / GetBackEnd (Scale = song speed)
+ function frontEnd(hw, full, scale) { return -(Math.abs(full / 2) * hw.frontToBackRatio) * scale; }
+ function backEnd(hw, full, scale) { return Math.abs(full / 2) * (2 - hw.frontToBackRatio) * scale; }
+
+ // ── Chart decoding ─────────────────────────────────────────────────────────
+
+ function padFromMidi(midi, opts) {
+ const m = MIDI_MAP[midi];
+ if (!m) return null;
+ if (m.hatPedal && !(opts && opts.includeHatPedal)) return null;
+ return { pad: m.pad, cymbal: m.cymbal };
+ }
+
+ function wireMidi(n) {
+ if (n.midi != null && Number.isFinite(+n.midi)) return +n.midi;
+ return ((n.s | 0) * 24) + (n.f | 0);
+ }
+
+ /**
+ * Decode drums wire notes ({t, s, f, ac, mt}) into a drum chart.
+ * opts.kick2x (default true): keep 35 (expert+ double bass).
+ * opts.includeHatPedal (default false): keep 44 (pedal hi-hat) as a yellow cymbal.
+ */
+ function decodeNotes(wireNotes, opts) {
+ opts = opts || {};
+ const raw = [];
+ const list = Array.isArray(wireNotes) ? wireNotes : [];
+ for (let i = 0; i < list.length; i++) {
+ const n = list[i];
+ if (!n || typeof n !== 'object') continue;
+ const t = +n.t;
+ if (!Number.isFinite(t)) continue;
+ const midi = wireMidi(n);
+ const m = MIDI_MAP[midi];
+ if (!m) continue;
+ if (m.hatPedal && !opts.includeHatPedal) continue;
+ if (m.kick2x && opts.kick2x === false) continue;
+ raw.push({
+ t,
+ pad: m.pad,
+ cymbal: !!m.cymbal,
+ kick2x: !!m.kick2x,
+ dyn: n.ac ? 'accent' : (n.mt ? 'ghost' : null),
+ midi,
+ _order: i,
+ });
+ }
+ raw.sort((a, b) => (a.t - b.t) || (a._order - b._order));
+
+ const notes = [];
+ const chords = [];
+ let cur = null;
+ for (const r of raw) {
+ if (!cur || r.t - cur.t > CHORD_EPS) {
+ cur = { t: r.t, notes: [] };
+ chords.push(cur);
+ } else if (cur.notes.some(x => x.pad === r.pad && x.cymbal === r.cymbal)) {
+ continue; // DrumNote.AddChildNote ignores a duplicate pad in a chord
+ }
+ delete r._order;
+ r.id = notes.length;
+ r.chord = chords.length - 1;
+ notes.push(r);
+ cur.notes.push(r);
+ }
+ return { notes, chords };
+ }
+
+ function groupNotes(notes) {
+ const sorted = notes.slice().sort((a, b) => a.t - b.t);
+ const chords = [];
+ let cur = null;
+ for (const n of sorted) {
+ if (!cur || n.t - cur.t > CHORD_EPS) { cur = { t: n.t, notes: [] }; chords.push(cur); }
+ cur.notes.push(n);
+ }
+ return chords;
+ }
+
+ // Lane for a pad/cymbal pair (ConvertInputToPad). Pro drums keep cymbals separate on Y/B/G.
+ function laneFor(pad, cymbal, proDrums) {
+ if (proDrums !== false && cymbal) {
+ if (pad === PAD.YELLOW) return LANE.YELLOW_CYMBAL;
+ if (pad === PAD.BLUE) return LANE.BLUE_CYMBAL;
+ if (pad === PAD.GREEN) return LANE.GREEN_CYMBAL;
+ }
+ return pad; // KICK/RED/drum lanes share numbers with PAD
+ }
+
+ // ── Time -> measure map (stands in for YARG's SyncTrack measure ticks) ─────
+
+ function buildMeasureMap(opts) {
+ const bpmDefault = 120;
+ const bpmPerMeasure = (opts && opts.beatsPerMeasure > 0) ? opts.beatsPerMeasure : 4;
+ let times = [];
+ let pos = [];
+ let slopeBefore = null;
+ let slopeAfter = null;
+
+ const pushKnot = (t, p) => {
+ if (!Number.isFinite(t) || !Number.isFinite(p)) return;
+ if (times.length && t <= times[times.length - 1]) return; // keep strictly increasing
+ times.push(t); pos.push(p);
+ };
+
+ if (opts && Array.isArray(opts.measures) && opts.measures.length) {
+ const ms = opts.measures.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
+ ms.forEach((t, i) => pushKnot(t, i));
+ } else if (opts && Array.isArray(opts.beats) && opts.beats.length) {
+ const beats = opts.beats;
+ const isObj = typeof beats[0] === 'object' && beats[0] !== null;
+ if (isObj) {
+ const bt = beats.map(b => +(b.time != null ? b.time : b.t)).filter(Number.isFinite);
+ const down = beats.filter(b => b && b.measure != null && +b.measure >= 0)
+ .map(b => +(b.time != null ? b.time : b.t)).filter(Number.isFinite).sort((a, b) => a - b);
+ if (down.length >= 2) down.forEach((t, i) => pushKnot(t, i));
+ else bt.sort((a, b) => a - b).forEach((t, i) => pushKnot(t, i / bpmPerMeasure));
+ } else {
+ beats.map(Number).filter(Number.isFinite).sort((a, b) => a - b)
+ .forEach((t, i) => pushKnot(t, i / bpmPerMeasure));
+ }
+ } else if (opts && Array.isArray(opts.tempo) && opts.tempo.length) {
+ const tempos = opts.tempo
+ .map(x => ({ t: +x.t, bpm: +x.bpm }))
+ .filter(x => Number.isFinite(x.t) && x.bpm > 0)
+ .sort((a, b) => a.t - b.t);
+ let p = 0;
+ for (let i = 0; i < tempos.length; i++) {
+ if (i > 0) p += (tempos[i].t - tempos[i - 1].t) * tempos[i - 1].bpm / 60 / bpmPerMeasure;
+ pushKnot(tempos[i].t, p);
+ }
+ if (tempos.length) {
+ slopeBefore = tempos[0].bpm / 60 / bpmPerMeasure;
+ slopeAfter = tempos[tempos.length - 1].bpm / 60 / bpmPerMeasure;
+ }
+ }
+
+ const fallbackSlope = bpmDefault / 60 / bpmPerMeasure; // 0.5 measures/s at 120 BPM 4/4
+ if (times.length === 0) { times = [0]; pos = [0]; }
+ if (times.length >= 2) {
+ const n = times.length;
+ if (slopeBefore == null) slopeBefore = (pos[1] - pos[0]) / (times[1] - times[0]);
+ if (slopeAfter == null) slopeAfter = (pos[n - 1] - pos[n - 2]) / (times[n - 1] - times[n - 2]);
+ }
+ if (!(slopeBefore > 0)) slopeBefore = fallbackSlope;
+ if (!(slopeAfter > 0)) slopeAfter = fallbackSlope;
+
+ function upper(arr, v) { // first index with arr[i] > v
+ let lo = 0, hi = arr.length;
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] > v) hi = mid; else lo = mid + 1; }
+ return lo;
+ }
+ function measureAt(t) {
+ const n = times.length;
+ if (t <= times[0]) return pos[0] - (times[0] - t) * slopeBefore;
+ if (t >= times[n - 1]) return pos[n - 1] + (t - times[n - 1]) * slopeAfter;
+ const i = upper(times, t) - 1;
+ return pos[i] + (t - times[i]) * (pos[i + 1] - pos[i]) / (times[i + 1] - times[i]);
+ }
+ function timeAtMeasure(m) {
+ const n = pos.length;
+ if (m <= pos[0]) return times[0] - (pos[0] - m) / slopeBefore;
+ if (m >= pos[n - 1]) return times[n - 1] + (m - pos[n - 1]) / slopeAfter;
+ const i = upper(pos, m) - 1;
+ return times[i] + (m - pos[i]) * (times[i + 1] - times[i]) / (pos[i + 1] - pos[i]);
+ }
+ return { measureAt, timeAtMeasure };
+ }
+
+ // ── Engine ──────────────────────────────────────────────────────────────────
+
+ const EVENTS = ['hit', 'miss', 'overhit', 'combo-break', 'multiplier', 'sp-phrase', 'sp-phrase-fail',
+ 'sp-ready', 'sp-activate', 'sp-end'];
+
+ function Engine(chart, opts) {
+ opts = opts || {};
+ if (Array.isArray(chart)) chart = decodeNotes(chart, opts);
+ chart = chart || { notes: [], chords: [] };
+ this.opts = opts;
+ this.params = mergeParams(opts.params);
+ this.proDrums = opts.proDrums !== false;
+ this._tempo = buildMeasureMap(opts);
+ this._listeners = Object.create(null);
+
+ const p = this.params;
+ const hw = p.hitWindow;
+ const srcChords = Array.isArray(chart.chords) && chart.chords.length
+ ? chart.chords.slice().sort((a, b) => a.t - b.t)
+ : groupNotes(Array.isArray(chart.notes) ? chart.notes : []);
+
+ // Build engine chords (YARG parent note + child notes).
+ this.chords = [];
+ this._byId = new Map();
+ this._alias = new Map();
+ let autoId = 0;
+ for (const sc of srcChords) {
+ if (!sc || !Array.isArray(sc.notes) || !sc.notes.length) continue;
+ const t = Number.isFinite(+sc.t) ? +sc.t : +sc.notes[0].t;
+ const ec = { index: this.chords.length, t, notes: [], phrase: null, front: 0, back: 0 };
+ for (const sn of sc.notes) {
+ const id = sn.id != null ? sn.id : ('n' + (autoId++));
+ const pad = sn.pad | 0;
+ if (pad < 0 || pad > 4) continue;
+ const lane = laneFor(pad, !!sn.cymbal, this.proDrums);
+ const dup = ec.notes.find(x => x.lane === lane);
+ if (dup) { this._alias.set(id, dup.id); continue; }
+ const en = {
+ id, src: sn, lane, pad, cymbal: !!sn.cymbal,
+ dyn: sn.dyn === 'accent' || sn.dyn === 'ghost' ? sn.dyn : null,
+ chord: ec, activator: false,
+ hit: false, missed: false, autoHit: false,
+ hitTime: null, offset: null, hitVelocity: null, bonus: false,
+ };
+ ec.notes.push(en);
+ this._byId.set(id, en);
+ }
+ if (ec.notes.length) this.chords.push(ec);
+ }
+
+ // Hit window per chord (GetAverageNoteDistance + CalculateHitWindow).
+ const ch = this.chords;
+ for (let i = 0; i < ch.length; i++) {
+ const c = ch[i];
+ const currentToNext = i + 1 < ch.length ? (ch[i + 1].t - c.t) / 2 : hw.maxWindow / 2;
+ const prevToCurrent = i > 0 ? (c.t - ch[i - 1].t) / 2 : currentToNext;
+ const full = calculateHitWindow(hw, prevToCurrent + currentToNext);
+ c.front = frontEnd(hw, full, p.songSpeed);
+ c.back = backEnd(hw, full, p.songSpeed);
+ }
+
+ // Star power phrases.
+ this.phrases = [];
+ const sp = Array.isArray(opts.starPower) ? opts.starPower.slice() : [];
+ sp.sort((a, b) => (+a.start) - (+b.start));
+ for (const ph of sp) {
+ const start = +ph.start, end = +ph.end;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
+ const members = ch.filter(c => !c.phrase && c.t >= start - PHRASE_EPS && c.t < end - PHRASE_EPS);
+ if (!members.length) continue;
+ const phrase = { index: this.phrases.length, start, end, first: members[0], last: members[members.length - 1],
+ chords: members, failed: false, awarded: false };
+ for (const c of members) c.phrase = phrase;
+ this.phrases.push(phrase);
+ }
+
+ // Drum fill activation phrases (SetDrumActivationFlags).
+ const act = Array.isArray(opts.activation) ? opts.activation : [];
+ this.activationWindows = [];
+ for (const w of act) {
+ const start = +w.start, end = +w.end;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
+ let target = null;
+ for (const c of ch) {
+ if (c.t >= start - PHRASE_EPS && c.t <= end + PHRASE_EPS) target = c;
+ else if (c.t > end + PHRASE_EPS) break;
+ }
+ if (!target) target = ch.find(c => c.t >= end) || null; // YARG: first note at/after the fill end
+ if (!target) continue;
+ if (p.activationType === 'all') {
+ for (const n of target.notes) n.activator = true;
+ } else {
+ let right = target.notes[0];
+ for (const n of target.notes) if (n.lane > right.lane) right = n;
+ right.activator = true;
+ }
+ this.activationWindows.push({ start, end, chord: target });
+ }
+ this.manualActivation = opts.manualActivation != null ? !!opts.manualActivation : act.length === 0;
+
+ // Chart totals (SetInitialStats / DrumsEngine constructor).
+ this.totalNotes = 0;
+ this.totalAccents = 0;
+ this.totalGhosts = 0;
+ for (const c of ch) {
+ this.totalNotes += c.notes.length;
+ for (const n of c.notes) {
+ if (n.dyn === 'accent') this.totalAccents++;
+ else if (n.dyn === 'ghost') this.totalGhosts++;
+ }
+ }
+ const scores = this._calculateChartScores();
+ this.baseScore = scores.baseScore;
+ this.baseNoteScore = scores.noteScore;
+ this.starThresholds = (p.starMultiplierThresholds || []).map(x => Math.floor(this.baseScore * x));
+
+ this.reset();
+ }
+
+ Engine.prototype._pointsPerNote = function () {
+ return this.proDrums ? this.params.pointsPerProNote : this.params.pointsPerNote;
+ };
+
+ // DrumsEngine.CalculateChartScores
+ Engine.prototype._calculateChartScores = function () {
+ const p = this.params;
+ let base = 0, noteScore = 0, combo = 0;
+ for (const c of this.chords) {
+ const mult = Math.min(Math.floor(combo / p.notesPerMultiplier) + 1, p.maxMultiplier);
+ const s = this._pointsPerNote() * c.notes.length;
+ base += mult * s;
+ noteScore += s;
+ combo += c.notes.length;
+ }
+ return { baseScore: Math.round(base), noteScore: Math.round(noteScore) };
+ };
+
+ Engine.prototype.reset = function () {
+ this.noteIndex = 0;
+ this.currentTime = -Infinity;
+ this.stats = {
+ score: 0, noteScore: 0, multiplierScore: 0, starPowerScore: 0,
+ combo: 0, maxCombo: 0, multiplier: 1, peakMultiplier: 1,
+ notesHit: 0, notesMissed: 0, overhits: 0, overhitsByLane: {},
+ dynamicsBonus: 0, accentsHit: 0, ghostsHit: 0,
+ totalOffset: 0, offsetSamples: 0,
+ stars: 0, starIndex: 0,
+ };
+ this.sp = {
+ amount: 0, // measures (full bar = params.starPowerMeasures)
+ active: false,
+ phrasesHit: 0,
+ activations: 0,
+ activationTime: null,
+ lastPos: 0,
+ endTime: Infinity,
+ timeInStarPower: 0,
+ totalGained: 0,
+ };
+ for (const c of this.chords) {
+ for (const n of c.notes) {
+ n.hit = false; n.missed = false; n.autoHit = false;
+ n.hitTime = null; n.offset = null; n.hitVelocity = null; n.bonus = false;
+ }
+ }
+ for (const ph of this.phrases) { ph.failed = false; ph.awarded = false; }
+ this._updateStars();
+ };
+
+ // ── Events ──
+
+ Engine.prototype.on = function (name, cb) {
+ if (typeof cb !== 'function') return () => {};
+ (this._listeners[name] || (this._listeners[name] = [])).push(cb);
+ return () => this.off(name, cb);
+ };
+ Engine.prototype.off = function (name, cb) {
+ const l = this._listeners[name];
+ if (!l) return;
+ if (cb === undefined) { delete this._listeners[name]; return; }
+ const i = l.indexOf(cb);
+ if (i >= 0) l.splice(i, 1);
+ };
+ Engine.prototype._emit = function (name, payload) {
+ const l = this._listeners[name];
+ if (!l || !l.length) return;
+ for (const cb of l.slice()) {
+ try { cb(payload); } catch (e) {
+ if (typeof console !== 'undefined' && console.error) console.error('[DrumsEngine] listener error', name, e);
+ }
+ }
+ };
+
+ // ── Helpers ──
+
+ function chordResolved(c) { return c.notes.every(n => n.hit || n.missed); }
+ function chordFullyHit(c) { return c.notes.every(n => n.hit); }
+ function isSp(c) { return !!(c.phrase && !c.phrase.failed); }
+
+ Engine.prototype._fullBar = function () { return this.params.starPowerMeasures; };
+ Engine.prototype._halfBar = function () { return this.params.starPowerMeasures / 2; };
+ Engine.prototype._quarterBar = function () { return this.params.starPowerMeasures / 4; };
+ Engine.prototype._canActivate = function () { return this.sp.amount >= this._halfBar() - SP_EPS; };
+
+ Engine.prototype._advanceIndex = function () {
+ while (this.noteIndex < this.chords.length && chordResolved(this.chords[this.noteIndex])) this.noteIndex++;
+ };
+
+ // BaseEngine.UpdateMultiplier
+ Engine.prototype._updateMultiplier = function (time) {
+ const p = this.params;
+ let m = Math.min(Math.floor(this.stats.combo / p.notesPerMultiplier) + 1, p.maxMultiplier);
+ if (this.sp.active) m *= 2;
+ const prev = this.stats.multiplier;
+ this.stats.multiplier = m;
+ if (m > this.stats.peakMultiplier) this.stats.peakMultiplier = m;
+ if (m !== prev) this._emit('multiplier', { time, multiplier: m, previous: prev });
+ };
+
+ Engine.prototype._incrementCombo = function () {
+ this.stats.combo++;
+ if (this.stats.combo > this.stats.maxCombo) this.stats.maxCombo = this.stats.combo;
+ };
+
+ Engine.prototype._resetCombo = function (time, reason) {
+ const prev = this.stats.combo;
+ this.stats.combo = 0;
+ if (prev > 0) this._emit('combo-break', { time, combo: prev, reason });
+ };
+
+ // BaseEngine.AddScore(int)
+ Engine.prototype._addScore = function (points) {
+ const s = this.stats;
+ const scored = points * s.multiplier;
+ s.score += scored;
+ if (this.sp.active) {
+ const spScore = Math.trunc(scored / 2);
+ s.starPowerScore += spScore;
+ s.multiplierScore += spScore - points;
+ } else {
+ s.multiplierScore += scored - points;
+ }
+ this._updateStars();
+ };
+
+ // BaseEngine.UpdateStars
+ Engine.prototype._updateStars = function () {
+ const s = this.stats;
+ const th = this.starThresholds || [];
+ while (s.starIndex < th.length && s.score > th[s.starIndex]) s.starIndex++;
+ let progress = 0;
+ if (s.starIndex < th.length) {
+ const prev = s.starIndex > 0 ? th[s.starIndex - 1] : 0;
+ const next = th[s.starIndex];
+ progress = next === prev ? 0 : clamp((s.score - prev) / (next - prev), 0, 1);
+ }
+ s.stars = s.starIndex + progress;
+ };
+
+ // ── Star power ──
+
+ Engine.prototype._drainTo = function (time) {
+ const sp = this.sp;
+ if (!sp.active) return;
+ const pos = this._tempo.measureAt(time);
+ const d = pos - sp.lastPos;
+ if (d > 0) {
+ sp.amount -= d;
+ if (sp.amount < SP_EPS) sp.amount = 0;
+ sp.lastPos = pos;
+ }
+ };
+
+ Engine.prototype._recomputeSpEnd = function () {
+ this.sp.endTime = this._tempo.timeAtMeasure(this.sp.lastPos + this.sp.amount);
+ };
+
+ // BaseEngine.GainStarPower
+ Engine.prototype._gainStarPower = function (measures, time) {
+ const sp = this.sp;
+ const prev = sp.amount;
+ if (!sp.active && prev < this._halfBar() - SP_EPS && prev + measures >= this._halfBar() - SP_EPS) {
+ this._emit('sp-ready', { time, amount: (prev + measures) / this._fullBar() });
+ }
+ sp.amount = Math.min(this._fullBar(), sp.amount + measures);
+ sp.totalGained += measures;
+ if (sp.active) this._recomputeSpEnd();
+ };
+
+ // BaseEngine.ActivateStarPower
+ Engine.prototype._activateStarPower = function (time) {
+ const sp = this.sp;
+ if (sp.active) return false;
+ sp.active = true;
+ sp.activationTime = time;
+ sp.lastPos = this._tempo.measureAt(time);
+ this._recomputeSpEnd();
+ sp.activations++;
+ this._updateMultiplier(time);
+ this._emit('sp-activate', { time, amount: sp.amount / this._fullBar(), endTime: sp.endTime });
+ return true;
+ };
+
+ // BaseEngine.ReleaseStarPower
+ Engine.prototype._releaseStarPower = function (time) {
+ const sp = this.sp;
+ sp.active = false;
+ sp.amount = 0;
+ sp.timeInStarPower += time - sp.activationTime;
+ sp.endTime = Infinity;
+ this._updateMultiplier(time);
+ this._emit('sp-end', { time });
+ };
+
+ // BaseEngine.StripStarPower: fail the whole phrase.
+ Engine.prototype._failPhrase = function (phrase, time) {
+ if (!phrase || phrase.failed || phrase.awarded) return;
+ phrase.failed = true;
+ this._emit('sp-phrase-fail', { time, phrase: phrase.index, start: phrase.start, end: phrase.end });
+ };
+
+ // BaseEngine.AwardStarPower
+ Engine.prototype._awardPhrase = function (phrase, time) {
+ phrase.awarded = true;
+ this.sp.phrasesHit++;
+ this._gainStarPower(this._quarterBar(), time);
+ this._emit('sp-phrase', { time, phrase: phrase.index, start: phrase.start, end: phrase.end,
+ amount: this.sp.amount / this._fullBar() });
+ };
+
+ // ── Clock ──
+
+ // Process note misses and the star power end up to `time`, in time order.
+ Engine.prototype._advance = function (time) {
+ if (!(time >= this.currentTime)) return;
+ for (;;) {
+ const c = this.chords[this.noteIndex];
+ const missAt = c ? c.t + c.back : Infinity;
+ const spEnd = this.sp.active ? this.sp.endTime : Infinity;
+ if (spEnd <= time && spEnd <= missAt) {
+ this._drainTo(spEnd);
+ this.currentTime = Math.max(this.currentTime, spEnd);
+ this._releaseStarPower(spEnd);
+ continue;
+ }
+ if (time > missAt) {
+ this._drainTo(missAt);
+ this.currentTime = Math.max(this.currentTime, missAt);
+ this._missChord(c, missAt);
+ continue;
+ }
+ break;
+ }
+ this._drainTo(time);
+ this.currentTime = time;
+ };
+
+ // YargDrumsEngine.CheckForNoteHit: the first chord was missed out the back end.
+ Engine.prototype._missChord = function (c, time) {
+ for (const n of c.notes) {
+ if (n.hit || n.missed) continue;
+ // Players may skip SP activation notes without penalty.
+ if (n.activator && this._canActivate()) {
+ this._hitNote(n, time, true);
+ continue;
+ }
+ this._missNote(n, time);
+ }
+ this._advanceIndex();
+ };
+
+ // DrumsEngine.MissNote
+ Engine.prototype._missNote = function (n, time) {
+ if (n.hit || n.missed) return;
+ n.missed = true;
+ n.hitTime = time;
+ this.stats.notesMissed++;
+ if (isSp(n.chord)) this._failPhrase(n.chord.phrase, time);
+ this._resetCombo(time, 'miss');
+ this._updateMultiplier(time);
+ this._emit('miss', { time, id: n.id, note: n.src, lane: n.lane, pad: n.pad, cymbal: n.cymbal, noteTime: n.chord.t });
+ this._advanceIndex();
+ };
+
+ // BaseEngine.SkipPreviousNotes
+ Engine.prototype._skipPreviousNotes = function (c, time) {
+ for (let i = c.index - 1; i >= 0; i--) {
+ const prev = this.chords[i];
+ if (chordResolved(prev)) break;
+ for (const n of prev.notes) if (!n.hit && !n.missed) this._missNote(n, time);
+ }
+ };
+
+ // DrumsEngine.HitNote
+ Engine.prototype._hitNote = function (n, time, autoHit) {
+ if (n.hit || n.missed) return false;
+ const c = n.chord;
+ n.hit = true;
+ n.autoHit = !!autoHit;
+ n.hitTime = time;
+ n.offset = time - c.t;
+
+ this._skipPreviousNotes(c, time);
+
+ if (isSp(c)) {
+ if (this.sp.active && this.params.noStarPowerOverlap) {
+ this._failPhrase(c.phrase, time);
+ } else if (c === c.phrase.last && chordFullyHit(c)) {
+ this._awardPhrase(c.phrase, time);
+ }
+ }
+
+ if (!autoHit && n.activator && this._canActivate() && c.notes.every(x => !x.activator || x.hit)) {
+ this._activateStarPower(time);
+ }
+
+ this._incrementCombo();
+ this.stats.notesHit++;
+ if (!autoHit) { this.stats.totalOffset += n.offset; this.stats.offsetSamples++; }
+ this._updateMultiplier(time);
+ const pts = this._pointsPerNote();
+ this._addScore(pts);
+ this.stats.noteScore += pts;
+
+ this._advanceIndex();
+ return true;
+ };
+
+ // DrumsEngine.ApplyVelocity
+ Engine.prototype._applyVelocity = function (n, velocity) {
+ if (!n.dyn) return false;
+ if (velocity == null) return false;
+ n.hitVelocity = velocity;
+ const p = this.params;
+ let threshold = p.velocityThreshold;
+ let compare = null;
+ for (let i = n.chord.index - 1; i >= 0 && !compare; i--) {
+ const pc = this.chords[i];
+ if (n.chord.t - pc.t > p.situationalVelocityWindow) break;
+ compare = pc.notes.find(x => x.lane === n.lane && x.hitVelocity != null) || null;
+ }
+ if (compare) {
+ const rel = compare.dyn === n.dyn ? compare.hitVelocity : compare.hitVelocity - p.velocityThreshold;
+ threshold = Math.max(threshold, rel != null ? rel : 0);
+ }
+ if (n.dyn === 'ghost') return velocity < threshold;
+ if (n.dyn === 'accent') return velocity > (1 - threshold);
+ return false;
+ };
+
+ function normVelocity(v) {
+ if (v == null) return null;
+ v = +v;
+ if (!Number.isFinite(v)) return null;
+ if (v > 1) v = v / 127;
+ return clamp(v, 0, 1);
+ }
+
+ // BaseEngine.WaitCountdown: a gap of >= MIN_SECONDS between notes (or song start) and the next note.
+ Engine.prototype._inWaitCountdown = function (time) {
+ const ch = this.chords;
+ let k = this.noteIndex;
+ while (k < ch.length && ch[k].t <= time) k++;
+ if (k >= ch.length) return false;
+ const prevEnd = k > 0 ? ch[k - 1].t : 0;
+ return ch[k].t - prevEnd >= this.params.waitCountdownSeconds && time >= prevEnd && time < ch[k].t;
+ };
+
+ // DrumsEngine.Overhit. Returns null if the overhit was counted, or the reason it was ignored.
+ Engine.prototype._overhit = function (time, lane, pad, cymbal) {
+ if (this.noteIndex === 0) return 'before-first-note';
+ if (this.noteIndex > this.chords.length - 1) return 'after-last-note';
+ if (this._inWaitCountdown(time)) return 'countdown';
+
+ const c = this.chords[this.noteIndex];
+ if (isSp(c) && c !== c.phrase.first) this._failPhrase(c.phrase, time);
+
+ this._resetCombo(time, 'overhit');
+ this.stats.overhits++;
+ this.stats.overhitsByLane[lane] = (this.stats.overhitsByLane[lane] || 0) + 1;
+ this._updateMultiplier(time);
+ this._emit('overhit', { time, pad, cymbal, lane });
+ return null;
+ };
+
+ // ── Public API ──
+
+ Engine.prototype.update = function (time) {
+ time = +time;
+ if (!Number.isFinite(time)) return;
+ this._advance(time);
+ };
+
+ /**
+ * Register a pad hit. pad: DrumsEngine.PAD value. opts.cymbal: cymbal input (pro drums only).
+ * opts.velocity: 0..1 (or MIDI 1..127). Returns {type:'hit'|'overhit'|'ignored', note?, offset?, bonus?, reason?}.
+ */
+ Engine.prototype.hit = function (time, pad, opts) {
+ opts = opts || {};
+ time = +time;
+ if (!Number.isFinite(time)) return { type: 'ignored', reason: 'bad-time' };
+ pad = pad | 0;
+ if (pad < 0 || pad > 4) return { type: 'ignored', reason: 'bad-pad' };
+ if (time < this.currentTime) time = this.currentTime; // QueueInput moves stale inputs forward
+ this._advance(time);
+
+ const cymbal = !!opts.cymbal;
+ const lane = laneFor(pad, cymbal, this.proDrums);
+ const velocity = normVelocity(opts.velocity);
+
+ for (let i = this.noteIndex; i < this.chords.length; i++) {
+ const c = this.chords[i];
+ if (time < c.t + c.front || time > c.t + c.back) break; // you can't skip past a note outside the window
+ for (const n of c.notes) {
+ if (n.lane !== lane) continue;
+ if (n.hit || n.missed) return { type: 'ignored', reason: 'already-hit', note: n.src };
+ const bonus = this._applyVelocity(n, velocity);
+ this._hitNote(n, time, false);
+ if (bonus) {
+ this._addScore(this.params.velocityBonus);
+ this.stats.dynamicsBonus += this.params.velocityBonus;
+ if (n.dyn === 'accent') this.stats.accentsHit++;
+ else if (n.dyn === 'ghost') this.stats.ghostsHit++;
+ }
+ n.bonus = bonus;
+ this._emit('hit', {
+ time, id: n.id, note: n.src, lane: n.lane, pad: n.pad, cymbal: n.cymbal,
+ noteTime: c.t, offset: n.offset, bonus, velocity,
+ combo: this.stats.combo, multiplier: this.stats.multiplier, score: this.stats.score,
+ });
+ return { type: 'hit', note: n.src, id: n.id, offset: n.offset, bonus };
+ }
+ }
+
+ const reason = this._overhit(time, lane, pad, cymbal);
+ if (reason === null) return { type: 'overhit' };
+ return { type: 'ignored', reason };
+ };
+
+ Engine.prototype.activateStarPower = function (time) {
+ time = +time;
+ if (Number.isFinite(time)) {
+ if (time < this.currentTime) time = this.currentTime;
+ this._advance(time);
+ } else {
+ time = this.currentTime;
+ }
+ if (!this.manualActivation) return false;
+ if (this.sp.active || !this._canActivate()) return false;
+ return this._activateStarPower(time);
+ };
+
+ Engine.prototype.getState = function () {
+ const s = this.stats;
+ const sp = this.sp;
+ const resolved = s.notesHit + s.notesMissed;
+ return {
+ score: s.score,
+ combo: s.combo,
+ maxCombo: s.maxCombo,
+ multiplier: s.multiplier,
+ maxMultiplier: this.params.maxMultiplier,
+ peakMultiplier: s.peakMultiplier,
+ notesHit: s.notesHit,
+ notesMissed: s.notesMissed,
+ totalNotes: this.totalNotes,
+ overhits: s.overhits,
+ accuracy: resolved > 0 ? s.notesHit / resolved : 1,
+ percent: this.totalNotes === 0 ? 1 : s.notesHit / this.totalNotes,
+ stars: s.stars,
+ starThresholds: this.starThresholds.slice(),
+ baseScore: this.baseScore,
+ dynamicsBonus: s.dynamicsBonus,
+ starPowerScore: s.starPowerScore,
+ accentsHit: s.accentsHit,
+ ghostsHit: s.ghostsHit,
+ totalAccents: this.totalAccents,
+ totalGhosts: this.totalGhosts,
+ averageOffset: s.offsetSamples ? s.totalOffset / s.offsetSamples : 0,
+ fullCombo: s.notesMissed === 0 && s.overhits === 0 && s.notesHit === this.totalNotes,
+ time: this.currentTime,
+ starPower: {
+ amount: sp.amount / this._fullBar(),
+ active: sp.active,
+ canActivate: this._canActivate() && !sp.active,
+ phrasesHit: sp.phrasesHit,
+ phrasesTotal: this.phrases.length,
+ activations: sp.activations,
+ endTime: sp.active ? sp.endTime : null,
+ timeInStarPower: sp.timeInStarPower + (sp.active ? Math.max(0, this.currentTime - sp.activationTime) : 0),
+ },
+ };
+ };
+
+ Engine.prototype._note = function (id) {
+ if (this._alias.has(id)) id = this._alias.get(id);
+ return this._byId.get(id) || null;
+ };
+
+ Engine.prototype.noteState = function (id) {
+ const n = this._note(id);
+ if (!n) return null;
+ return n.hit ? 'hit' : (n.missed ? 'miss' : null);
+ };
+
+ Engine.prototype.noteInfo = function (id) {
+ const n = this._note(id);
+ if (!n) return null;
+ return {
+ state: n.hit ? 'hit' : (n.missed ? 'miss' : null),
+ offset: n.hit ? n.offset : null,
+ time: n.hitTime,
+ velocity: n.hitVelocity,
+ bonus: n.bonus,
+ autoHit: n.autoHit,
+ starPower: isSp(n.chord),
+ activator: n.activator,
+ lane: n.lane,
+ };
+ };
+
+ Engine.prototype.isStarPowerNote = function (id) {
+ const n = this._note(id);
+ return !!(n && isSp(n.chord));
+ };
+
+ Engine.prototype.isActivatorNote = function (id) {
+ const n = this._note(id);
+ return !!(n && n.activator);
+ };
+
+ Engine.prototype.hitWindow = function () {
+ const c = this.chords[this.noteIndex];
+ if (c) return { front: c.front, back: c.back };
+ const hw = this.params.hitWindow;
+ return { front: frontEnd(hw, hw.maxWindow, this.params.songSpeed), back: backEnd(hw, hw.maxWindow, this.params.songSpeed) };
+ };
+
+ function create(chart, opts) { return new Engine(chart, opts); }
+
+ return {
+ PAD,
+ LANE,
+ MIDI_MAP,
+ PRESETS,
+ EVENTS,
+ POINTS_PER_NOTE,
+ POINTS_PER_PRO_NOTE,
+ decodeNotes,
+ padFromMidi,
+ laneFor,
+ create,
+ defaultParams,
+ calculateHitWindow,
+ buildMeasureMap,
+ Engine,
+ };
+});
diff --git a/assets/session.js b/assets/session.js
new file mode 100644
index 0000000..8e31d04
--- /dev/null
+++ b/assets/session.js
@@ -0,0 +1,872 @@
+/*
+ * session.js: chart model, difficulty levels and the scoring session for the feedBack drums plugin
+ * (MIT, original code).
+ *
+ * Shared by both views (screen.js loads it lazily as a plain script). No DOM, no THREE. Exported on
+ * window.DrumsSession (browser) and module.exports (Node tests):
+ * - Pure helpers: chart flattening, gem classification, drum_tab gameplay metadata (star power,
+ * fills, solos, difficulty levels: feedpak 1.20.0), difficulty availability / fallback / labels,
+ * auto lanes, HUD formatting, MIDI -> pad mapping, timing helpers.
+ * - createSession(DrumsEngine, opts): owns the engine (engine.js, LGPL-3.0, used only through its
+ * public API) for one chart. Builds it from wire notes, applies star power / fill metadata,
+ * rebuilds on seeks, adds the bonus score (spot-on timing, rolls, drum solos) and queues engine
+ * events for the view.
+ */
+(function (root, factory) {
+ 'use strict';
+ const api = factory();
+ if (typeof module === 'object' && module && module.exports) module.exports = api;
+ if (typeof window !== 'undefined' && window) window.DrumsSession = api;
+ else if (root) root.DrumsSession = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ // ── Constants ───────────────────────────────────────────────────────────
+
+ // Engine pads (engine.js PAD). Lane index = pad - 1 for the four pad lanes; the kick has no lane.
+ const PAD = Object.freeze({ KICK: 0, RED: 1, YELLOW: 2, BLUE: 3, GREEN: 4 });
+ const LANE_NAMES = Object.freeze(['red', 'yellow', 'blue', 'green']);
+
+ // Lane / gem colours (0xRRGGBB). One place to tweak the look (both views).
+ const LANE_COLORS = Object.freeze({
+ red: 0xff2b3d,
+ yellow: 0xffcc1f,
+ blue: 0x2b8bff,
+ green: 0x27d65c,
+ });
+ const COLORS = Object.freeze({
+ kick: 0xff8a14,
+ kick2x: 0xff4fc8,
+ starPowerGem: 0xdcf1ff, // silver-white with a blue tint
+ starPowerEdge: 0x86ddff,
+ activator: 0x6dffb6,
+ fill: 0x38ffa8,
+ miss: 0xff2424,
+ missedGem: 0x3b3b48,
+ background: 0x05060c,
+ track: 0x0c0f1c,
+ rail: 0x3a4a78,
+ beat: 0x8090c0,
+ measure: 0xd0dcff,
+ strike: 0xe8ecff,
+ });
+
+ // screen.js lane ids (Learn / custom mapping) -> engine pad.
+ const LANE_ID_TO_PAD = Object.freeze({
+ kick: { pad: PAD.KICK, cymbal: false },
+ snare: { pad: PAD.RED, cymbal: false },
+ hihat: { pad: PAD.YELLOW, cymbal: true },
+ tom1: { pad: PAD.YELLOW, cymbal: false },
+ tom2: { pad: PAD.BLUE, cymbal: false },
+ tom3: { pad: PAD.GREEN, cymbal: false },
+ ride: { pad: PAD.BLUE, cymbal: true },
+ crash: { pad: PAD.GREEN, cymbal: true },
+ });
+
+ // Seeks: a jump larger than this (seconds) rebuilds the engine from the new position.
+ const SEEK_BACK = 0.25;
+ const SEEK_FORWARD = 1.5;
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+ function defaultNow() {
+ return (typeof performance !== 'undefined' && performance && typeof performance.now === 'function')
+ ? performance.now() : Date.now();
+ }
+
+ // ── Pure helpers: chart ─────────────────────────────────────────────────
+
+ /**
+ * Flatten bundle.notes + bundle.chords into one wire-note list. Chord notes take the chord's
+ * time. Visual-only notes (drum_tab flam graces, `_noScore`) are left out.
+ */
+ function collectWireNotes(notes, chords) {
+ const out = [];
+ if (Array.isArray(notes)) {
+ for (const n of notes) if (n && typeof n === 'object' && !n._noScore) out.push(n);
+ }
+ if (Array.isArray(chords)) {
+ for (const c of chords) {
+ if (!c || !Array.isArray(c.notes)) continue;
+ for (const cn of c.notes) {
+ if (!cn || typeof cn !== 'object' || cn._noScore) continue;
+ const t = Number.isFinite(+cn.t) ? +cn.t : +c.t;
+ out.push(cn.t === t ? cn : Object.assign({}, cn, { t }));
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Classify a decoded drum note (DrumsEngine.decodeNotes output: {pad, cymbal, kick2x, dyn}) into
+ * its gem: kind 'kick' | 'kick2x' | 'pad' | 'cymbal', lane 0..3 (-1 for the kick), dynamics and
+ * the base colour.
+ */
+ function classifyNote(n, opts) {
+ if (!n || typeof n !== 'object') return null;
+ const pro = !(opts && opts.pro === false);
+ const pad = n.pad | 0;
+ if (pad < 0 || pad > 4) return null;
+ const accent = n.dyn === 'accent';
+ const ghost = n.dyn === 'ghost';
+ if (pad === PAD.KICK) {
+ const kick2x = !!n.kick2x;
+ return { kind: kick2x ? 'kick2x' : 'kick', lane: -1, cymbal: false, kick2x, accent, ghost,
+ color: kick2x ? COLORS.kick2x : COLORS.kick };
+ }
+ const lane = pad - 1;
+ // Non-pro drums (opts.pro === false): no cymbals, every Y/B/G note is a plain pad.
+ const cymbal = pro && !!n.cymbal && pad !== PAD.RED;
+ return { kind: cymbal ? 'cymbal' : 'pad', lane, cymbal, kick2x: false, accent, ghost,
+ color: LANE_COLORS[LANE_NAMES[lane]] };
+ }
+
+ /**
+ * Render model: one gem per decoded note, sorted by time, keyed by the engine note id.
+ * opts.pro === false (non-pro drums) draws cymbals as pads and, like the engine, keeps one
+ * gem when a tom and a cymbal of the same colour land together (< 1 ms apart).
+ */
+ function buildGems(decoded, opts) {
+ const notes = decoded && Array.isArray(decoded.notes) ? decoded.notes : [];
+ const pro = !(opts && opts.pro === false);
+ const gems = [];
+ for (const n of notes) {
+ const c = classifyNote(n, opts);
+ if (!c) continue;
+ c.id = n.id;
+ c.t = n.t;
+ c.pad = n.pad | 0;
+ c.chartCymbal = !!n.cymbal; // the chart's cymbal marking, even when non-pro draws it as a pad
+ c.auto = isAutoNote(c.pad, c.chartCymbal, opts && opts.auto);
+ gems.push(c);
+ }
+ gems.sort((a, b) => a.t - b.t);
+ if (pro) return gems;
+ const out = [];
+ for (const g of gems) {
+ let dup = false;
+ for (let i = out.length - 1; i >= 0 && g.t - out[i].t < 0.001; i--) {
+ if (out[i].lane === g.lane && out[i].kind === g.kind) { dup = true; break; }
+ }
+ if (!dup) out.push(g);
+ }
+ return out;
+ }
+
+ /** [i0, i1) of the gems with t0 <= t < t1 (gems sorted by t). */
+ function visibleRange(gems, t0, t1) {
+ let lo = 0, hi = gems.length;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (gems[m].t < t0) lo = m + 1; else hi = m; }
+ const i0 = lo;
+ hi = gems.length;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (gems[m].t < t1) lo = m + 1; else hi = m; }
+ return [i0, lo];
+ }
+
+ /** bundle.beats ([{time, measure}] or numbers) -> [{t, measure: bool}] sorted. */
+ function normalizeBeats(beats) {
+ if (!Array.isArray(beats)) return [];
+ const out = [];
+ for (const b of beats) {
+ if (b == null) continue;
+ if (typeof b === 'number') { if (Number.isFinite(b)) out.push({ t: b, measure: false }); continue; }
+ const t = +(b.time != null ? b.time : b.t);
+ if (!Number.isFinite(t)) continue;
+ out.push({ t, measure: b.measure != null && +b.measure >= 0 });
+ }
+ out.sort((a, b) => a.t - b.t);
+ return out;
+ }
+
+ // ── Pure helpers: auto lanes (accessibility) ────────────────────────────
+ //
+ // Auto kick / auto cymbals take those notes off the player: they are left out of the chart the
+ // engine scores (YARG "no kicks" style, so score / accuracy / streak only count what you play),
+ // still drawn (dimmed) and flash at the strikeline as if played, and pad hits on them are ignored.
+ // The setting is a difficulty ceiling: 'off' | 'easy' | 'medium' | 'hard' | 'all' = on at that
+ // difficulty and the ones below it.
+
+ const AUTO_LEVELS = Object.freeze(['off', 'easy', 'medium', 'hard', 'all']);
+
+ function normalizeAutoLevel(raw) {
+ return typeof raw === 'string' && AUTO_LEVELS.includes(raw) ? raw : 'off';
+ }
+
+ /** Is an auto setting on at this difficulty? */
+ function autoAppliesAt(level, difficultyId) {
+ level = normalizeAutoLevel(level);
+ if (level === 'off') return false;
+ if (level === 'all') return true;
+ return DIFFICULTIES.indexOf(normalizeDifficulty(difficultyId)) <= DIFFICULTIES.indexOf(level);
+ }
+
+ /** {kick, cymbals} booleans for the difficulty being played. */
+ function autoFor(settings, difficultyId) {
+ settings = settings || {};
+ return {
+ kick: autoAppliesAt(settings.kick, difficultyId),
+ cymbals: autoAppliesAt(settings.cymbals, difficultyId),
+ };
+ }
+
+ function normalizeAuto(auto) {
+ return { kick: !!(auto && auto.kick), cymbals: !!(auto && auto.cymbals) };
+ }
+
+ /** A chart note (engine pad + the chart's cymbal marking) the player does not have to hit. */
+ function isAutoNote(pad, cymbal, auto) {
+ if (!auto) return false;
+ if (auto.kick && pad === PAD.KICK) return true;
+ return !!(auto.cymbals && cymbal && pad >= PAD.YELLOW);
+ }
+
+ /** First index of a gem with g.t > t (gems sorted by t). */
+ function firstAfter(gems, t) {
+ let lo = 0, hi = gems.length;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (gems[m].t <= t) lo = m + 1; else hi = m; }
+ return lo;
+ }
+
+ // ── Pure helpers: drum_tab gameplay metadata (feedpak 1.20.0) ───────────
+
+ /** A drum_tab range list ([{start_time, end_time}]) -> sorted [{start, end}], malformed entries dropped. */
+ function _ranges(list) {
+ if (!Array.isArray(list)) return [];
+ const out = [];
+ for (const p of list) {
+ if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
+ const start = +p.start_time, end = +p.end_time;
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
+ out.push({ start, end });
+ }
+ out.sort((a, b) => a.start - b.start);
+ return out;
+ }
+
+ /**
+ * The gameplay metadata of a drum tab (bundle.drumTab, as core streams it): star power, fills
+ * (star power activation), solos and the easier difficulty levels. `toNotes` converts drum_tab
+ * hits into wire notes (screen.js _drumTabHitsToNotes) so a level plays exactly like the Expert
+ * chart. Returns null when the tab carries none of it (a pre-1.20.0 tab, or a core that does not
+ * stream it yet): the song then plays Expert only, without star power.
+ * Output is in DrumsEngine.create option shape: starPower / activation as [{start, end}].
+ */
+ function parseDrumTabMeta(tab, toNotes) {
+ if (!tab || typeof tab !== 'object' || Array.isArray(tab)) return null;
+ const starPower = _ranges(tab.star_power);
+ const activation = _ranges(tab.fills);
+ const solos = _ranges(tab.solos);
+ let levels = null;
+ const generated = [];
+ const raw = tab.levels;
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof toNotes === 'function') {
+ for (const id of LOWER_LEVELS) {
+ const lv = Object.prototype.hasOwnProperty.call(raw, id) ? raw[id] : null;
+ if (!lv || typeof lv !== 'object' || !Array.isArray(lv.hits)) continue;
+ const notes = toNotes(lv.hits);
+ if (!Array.isArray(notes) || !notes.length) continue;
+ (levels || (levels = {}))[id] = notes;
+ if (lv.generated === true) generated.push(id);
+ }
+ }
+ if (!starPower.length && !activation.length && !solos.length && !levels) return null;
+ return {
+ pro: true, // drum_tab pieces always say cymbal vs tom
+ starPower,
+ activation,
+ solos,
+ levels, // {easy?, medium?, hard?}: wire notes, or null
+ levelsGenerated: generated, // lower levels reduced by software rather than hand-charted
+ };
+ }
+
+ // ── Pure helpers: difficulty levels ─────────────────────────────────────
+ //
+ // The drum tab's `hits[]` (or the legacy GM-encoded notes) are the full chart. Expert+ = all of
+ // it; plain Expert drops the double-kick notes (drum_tab `x2`, GM 35 on the wire). Easy / Medium /
+ // Hard come from the tab's `levels` (parseDrumTabMeta) when the chart has them.
+
+ const DIFFICULTIES = Object.freeze(['easy', 'medium', 'hard', 'expert', 'expert_plus']);
+ const LOWER_LEVELS = Object.freeze(['easy', 'medium', 'hard']);
+ const DEFAULT_DIFFICULTY = 'expert';
+ const DIFFICULTY_LABELS = Object.freeze({
+ easy: 'EASY', medium: 'MEDIUM', hard: 'HARD', expert: 'EXPERT', expert_plus: 'EXPERT+',
+ });
+ const DIFFICULTY_NAMES = Object.freeze({
+ easy: 'Easy', medium: 'Medium', hard: 'Hard', expert: 'Expert', expert_plus: 'Expert+',
+ });
+ const DIFFICULTY_COLORS = Object.freeze({
+ easy: '#4be37a', medium: '#ffd23f', hard: '#ff8a3d', expert: '#ff4b5c', expert_plus: '#ff4fc8',
+ });
+ const KICK2X_MIDI = 35;
+
+ /** A stored / user-supplied difficulty id, or the default ('expert') when it is not a known id. */
+ function normalizeDifficulty(raw) {
+ return typeof raw === 'string' && DIFFICULTIES.includes(raw) ? raw : DEFAULT_DIFFICULTY;
+ }
+
+ function _wireGm(n) {
+ if (n.midi != null && Number.isFinite(+n.midi)) return +n.midi;
+ return ((n.s | 0) * 24) + (n.f | 0);
+ }
+
+ /** True when the wire chart (notes + chords) has 2x kick (GM 35) notes. */
+ function hasKick2x(notes, chords) {
+ if (Array.isArray(notes)) {
+ for (const n of notes) if (n && typeof n === 'object' && _wireGm(n) === KICK2X_MIDI) return true;
+ }
+ if (Array.isArray(chords)) {
+ for (const c of chords) {
+ if (!c || !Array.isArray(c.notes)) continue;
+ for (const n of c.notes) if (n && typeof n === 'object' && _wireGm(n) === KICK2X_MIDI) return true;
+ }
+ }
+ return false;
+ }
+
+ /** Notes / chords without the 2x kick notes. Returns the same arrays when there are none. */
+ function stripKick2x(notes, chords) {
+ if (!hasKick2x(notes, chords)) return { notes, chords };
+ const keep = (n) => !(n && typeof n === 'object' && _wireGm(n) === KICK2X_MIDI);
+ const outNotes = Array.isArray(notes) ? notes.filter(keep) : notes;
+ let outChords = chords;
+ if (Array.isArray(chords)) {
+ outChords = [];
+ for (const c of chords) {
+ if (!c || !Array.isArray(c.notes)) { outChords.push(c); continue; }
+ const cn = c.notes.filter(keep);
+ if (cn.length === c.notes.length) outChords.push(c);
+ else if (cn.length) outChords.push(Object.assign({}, c, { notes: cn }));
+ }
+ }
+ return { notes: outNotes, chords: outChords };
+ }
+
+ /**
+ * Which difficulties the loaded chart offers.
+ * ctx.meta parseDrumTabMeta() output (or null)
+ * ctx.has2x the full chart has double-kick notes (hasKick2x)
+ * -> [{id, label, name, available, reason, generated}] in DIFFICULTIES order. Expert is always
+ * available; Expert+ needs double-kick notes; Easy/Medium/Hard need the tab's levels.
+ */
+ function difficultyOptions(ctx) {
+ ctx = ctx || {};
+ const meta = ctx.meta || null;
+ const levels = meta && meta.levels ? meta.levels : null;
+ const gen = meta && Array.isArray(meta.levelsGenerated) ? meta.levelsGenerated : [];
+ return DIFFICULTIES.map((id) => {
+ const o = { id, label: DIFFICULTY_LABELS[id], name: DIFFICULTY_NAMES[id], available: true, reason: '', generated: false };
+ if (id === 'expert') return o;
+ if (id === 'expert_plus') {
+ if (!ctx.has2x) {
+ o.available = false;
+ o.reason = 'No double-kick notes in this chart';
+ }
+ return o;
+ }
+ const lv = levels && Array.isArray(levels[id]) ? levels[id] : null;
+ if (lv && lv.length) {
+ o.generated = gen.includes(id);
+ return o;
+ }
+ o.available = false;
+ if (!levels) o.reason = 'This chart only has Expert';
+ else o.reason = 'This chart has no ' + o.name + ' part';
+ return o;
+ });
+ }
+
+ /**
+ * Pick the difficulty to play: the saved choice when the chart has it, otherwise Expert
+ * (the saved preference itself is left alone). -> {id, requested, fallback, reason}.
+ */
+ function resolveDifficulty(saved, options) {
+ const requested = normalizeDifficulty(saved);
+ const opts = Array.isArray(options) ? options : [];
+ const want = opts.find(o => o && o.id === requested);
+ if (!want || want.available) return { id: requested, requested, fallback: false, reason: '' };
+ return { id: DEFAULT_DIFFICULTY, requested, fallback: true, reason: want.reason || '' };
+ }
+
+ /** The next available difficulty up (dir > 0) or down (dir < 0), wrapping around. */
+ function nextDifficulty(current, options, dir) {
+ const avail = DIFFICULTIES.filter(id => {
+ const o = Array.isArray(options) ? options.find(x => x && x.id === id) : null;
+ return !o || o.available;
+ });
+ if (!avail.length) return normalizeDifficulty(current);
+ const step = dir < 0 ? -1 : 1;
+ let i = DIFFICULTIES.indexOf(normalizeDifficulty(current));
+ for (let k = 0; k < DIFFICULTIES.length; k++) {
+ i = (i + step + DIFFICULTIES.length) % DIFFICULTIES.length;
+ if (avail.includes(DIFFICULTIES[i])) return DIFFICULTIES[i];
+ }
+ return normalizeDifficulty(current);
+ }
+
+ /** 'hard' -> 'HARD'; generated levels get an AUTO marker ('HARD · AUTO'). */
+ function difficultyLabel(id, generated) {
+ const base = DIFFICULTY_LABELS[normalizeDifficulty(id)];
+ return generated ? base + ' · AUTO' : base;
+ }
+
+ /**
+ * HUD badge for a resolved difficulty: {id, text, sub, color, title}. `sub` is 'AUTO' for an
+ * auto-generated level; `title` explains a fallback.
+ */
+ function difficultyBadge(resolved, options) {
+ const id = resolved ? normalizeDifficulty(resolved.id) : DEFAULT_DIFFICULTY;
+ const o = Array.isArray(options) ? options.find(x => x && x.id === id) : null;
+ const generated = !!(o && o.generated);
+ let title = 'Drum difficulty: ' + DIFFICULTY_NAMES[id] + (generated ? ' (auto-generated from Expert)' : '');
+ if (resolved && resolved.fallback) {
+ title += '. ' + DIFFICULTY_NAMES[resolved.requested] + ' is not available for this song'
+ + (resolved.reason ? ' (' + resolved.reason + ')' : '');
+ }
+ return {
+ id,
+ text: DIFFICULTY_LABELS[id],
+ sub: generated ? 'AUTO' : null,
+ color: DIFFICULTY_COLORS[id],
+ fallback: !!(resolved && resolved.fallback),
+ title,
+ };
+ }
+
+ /**
+ * Wire chart for a difficulty: Expert+ = the chart's notes/chords as they are, Expert = the same
+ * without double-kick notes, Easy/Medium/Hard = the tab's level (no chords). Unknown /
+ * unavailable lower levels fall back to Expert.
+ */
+ function difficultyChart(id, notes, chords, meta) {
+ id = normalizeDifficulty(id);
+ if (id === 'expert_plus') return { id, notes, chords };
+ if (LOWER_LEVELS.includes(id)) {
+ const lv = meta && meta.levels && Array.isArray(meta.levels[id]) ? meta.levels[id] : null;
+ if (lv && lv.length) return { id, notes: lv, chords: [] };
+ id = 'expert';
+ }
+ const s = stripKick2x(notes, chords);
+ return { id, notes: s.notes, chords: s.chords };
+ }
+
+ // ── Pure helpers: input ─────────────────────────────────────────────────
+
+ /**
+ * Difficulty shortcut: D = next harder difficulty, Shift+D = next easier (both views). D is not a
+ * drum key and not a core player shortcut. -> {dir: 1 | -1} or null.
+ */
+ function isDifficultyKey(e) {
+ if (!e || e.ctrlKey || e.metaKey || e.altKey) return null;
+ const code = typeof e.code === 'string' ? e.code : '';
+ const key = typeof e.key === 'string' ? e.key : '';
+ if (code !== 'KeyD' && !(code === '' && key.toLowerCase() === 'd')) return null;
+ return { dir: e.shiftKey ? -1 : 1 };
+ }
+
+ /**
+ * MIDI note -> engine pad. A Learn / custom mapping entry for the note wins (its lane id is
+ * translated with LANE_ID_TO_PAD); otherwise the General MIDI map (DrumsEngine.padFromMidi).
+ * The 2D lane preset is ignored on purpose: scoring always uses the 4-lane pro-drums layout.
+ */
+ function midiToPad(midi, customMapping, padFromMidi) {
+ midi = midi | 0;
+ if (midi < 0 || midi > 127) return null;
+ if (customMapping && typeof customMapping === 'object'
+ && Object.prototype.hasOwnProperty.call(customMapping, midi)) {
+ const m = LANE_ID_TO_PAD[customMapping[midi]];
+ if (m) return { pad: m.pad, cymbal: m.cymbal };
+ }
+ return typeof padFromMidi === 'function' ? (padFromMidi(midi) || null) : null;
+ }
+
+ /** True for elements that take typed text (keyboard drumming must not steal their keys). */
+ function isTypingTarget(el) {
+ if (!el || typeof el !== 'object') return false;
+ if (el.isContentEditable) return true;
+ const tag = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : '';
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
+ if (tag === 'INPUT') {
+ const type = String(el.type || 'text').toLowerCase();
+ return !['checkbox', 'radio', 'range', 'button', 'submit', 'reset', 'color'].includes(type);
+ }
+ return false;
+ }
+
+ /**
+ * Song time of an input event that arrived between frames. bundle.currentTime only advances once
+ * per frame, so extrapolate from the last two frames while playback is running (up to 60 ms).
+ */
+ function estimateTime(clock, nowWall) {
+ if (!clock || !Number.isFinite(clock.time)) return NaN;
+ const { time, wall, prevTime, prevWall } = clock;
+ if (!Number.isFinite(prevTime) || !Number.isFinite(wall) || !Number.isFinite(prevWall)) return time;
+ const dw = (wall - prevWall) / 1000;
+ const dt = time - prevTime;
+ if (!(dw > 0) || dw > 0.25 || !(dt > 0)) return time; // paused, stalled or seeking
+ const rate = clamp(dt / dw, 0.1, 4);
+ const ahead = clamp((nowWall - wall) / 1000, 0, 0.06);
+ return time + ahead * rate;
+ }
+
+ /** True when the playhead jumped (seek, loop restart) and the engine must be rebuilt. */
+ function isSeek(prevTime, time) {
+ if (!Number.isFinite(prevTime) || !Number.isFinite(time)) return false;
+ return time < prevTime - SEEK_BACK || time > prevTime + SEEK_FORWARD;
+ }
+
+ // ── Pure helpers: HUD ───────────────────────────────────────────────────
+
+ function formatScore(n) {
+ n = Math.max(0, Math.floor(+n || 0));
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ }
+
+ function formatAccuracy(acc) {
+ if (!Number.isFinite(+acc)) return '--';
+ return (clamp(+acc, 0, 1) * 100).toFixed(1) + '%';
+ }
+
+ /** Multiplier badge colour: x1 grey, x2 yellow, x3 green, x4 violet, star power (x>4 or SP) cyan. */
+ function multiplierColor(mult, spActive) {
+ if (spActive || mult > 4) return '#7fe6ff';
+ if (mult >= 4) return '#c77dff';
+ if (mult === 3) return '#4be37a';
+ if (mult === 2) return '#ffd23f';
+ return '#c8ccd8';
+ }
+
+ /** Stars earned and progress toward the next one (0..1). 6 stars = gold. */
+ function starProgress(state) {
+ const th = state && Array.isArray(state.starThresholds) ? state.starThresholds : [];
+ const score = state ? +state.score || 0 : 0;
+ let count = 0;
+ while (count < th.length && score >= th[count]) count++;
+ if (count >= th.length) return { count, frac: 1, gold: th.length > 0 && count >= 6 };
+ const prev = count > 0 ? th[count - 1] : 0;
+ const next = th[count];
+ const frac = next > prev ? clamp((score - prev) / (next - prev), 0, 1) : 0;
+ return { count, frac, gold: false };
+ }
+
+ /** Strings + numbers the HUD draws, from DrumsEngine.getState() (null state -> zeros). */
+ function hudModel(state) {
+ const s = state || {};
+ const sp = s.starPower || {};
+ const mult = s.multiplier || 1;
+ const notesPerMult = 10;
+ const maxBase = s.maxMultiplier || 4;
+ const baseMult = sp.active ? mult / 2 : mult;
+ const progress = baseMult >= maxBase ? 1 : ((s.combo || 0) % notesPerMult) / notesPerMult;
+ const stars = starProgress(s);
+ return {
+ score: formatScore(s.score),
+ multiplier: mult,
+ multiplierText: 'x' + mult,
+ multiplierColor: multiplierColor(mult, !!sp.active),
+ multiplierProgress: progress,
+ streak: s.combo || 0,
+ streakText: String(s.combo || 0),
+ accuracy: formatAccuracy(s.accuracy != null ? s.accuracy : 1),
+ hitsText: (s.notesHit || 0) + ' / ' + (s.totalNotes || 0),
+ stars: stars.count,
+ starFrac: stars.frac,
+ gold: stars.gold,
+ spAmount: clamp(+sp.amount || 0, 0, 1),
+ spActive: !!sp.active,
+ spReady: !!sp.canActivate,
+ fullCombo: !!s.fullCombo,
+ };
+ }
+
+ // ── Session: the engine for one chart ───────────────────────────────────
+
+ /**
+ * createSession(DrumsEngine, {now}) -> session. load() a chart (wire notes + beats), optionally
+ * setMeta() the drums block, then update(time) every frame and hit()/activate() on input.
+ * drainEvents() hands queued visual events ({type, pad, cymbal, sp, wall}) to the view.
+ */
+ function createSession(Engine, opts) {
+ opts = opts || {};
+ const now = typeof opts.now === 'function' ? opts.now : defaultNow;
+ const MAX_EVENTS = 256;
+ const s = {
+ decoded: null, gems: [], beats: [], rawBeats: null, meta: null, engine: null,
+ lastTime: null, events: [], builds: 0,
+ proWanted: opts.proDrums !== false, // the player's "Pro cymbals" setting
+ auto: normalizeAuto(opts.auto), // {kick, cymbals}: lanes played for the player
+ params: opts.params || null, // engine params override (hit window)
+ bonus: null, // Rock Band-style extras on top of the engine score
+ };
+
+ // ── Bonus score (on top of the engine's score) ──
+ // - timing: +10 x multiplier for a hit within 25 ms of the note;
+ // - rolls: snare/tom notes in a fast run (>= 4 within +-0.25 s, i.e. 8 per
+ // second) earn +25 x multiplier; a run of >= 8 pops "ROLL h/n +pts";
+ // - solos (meta.solos): live % of the solo's notes hit, and at the end
+ // 100 per note hit (x2 when perfect) with a Rock Band-style rating.
+ // The engine already scores dynamics (accent/ghost) and star power.
+ const ROLL_WIN = 0.25, ROLL_N = 4, ROLL_MIN = 8, ROLL_GAP = 0.6;
+ const noteKey = (t, pad) => Math.round(t * 1000) * 8 + (pad | 0);
+ function newBonus() {
+ const rolls = new Set();
+ if (s.decoded) {
+ const toms = s.decoded.notes.filter(n => n.pad >= 1 && n.pad <= 4 && !n.cymbal)
+ .map(n => ({ t: s.decoded.chords[n.chord].t, pad: n.pad })).sort((a, b) => a.t - b.t);
+ for (let i = 0, lo = 0, hi = 0; i < toms.length; i++) {
+ while (toms[lo].t < toms[i].t - ROLL_WIN) lo++;
+ while (hi + 1 < toms.length && toms[hi + 1].t <= toms[i].t + ROLL_WIN) hi++;
+ if (hi - lo + 1 >= ROLL_N) rolls.add(noteKey(toms[i].t, toms[i].pad));
+ }
+ }
+ const solos = ((s.meta && s.meta.solos) || []).map(r => ({ start: r.start, end: r.end, h: 0, n: 0, done: false }));
+ return { total: 0, timing: 0, rolls: 0, solo: 0, perfect: 0, dynamics: 0, rollSet: rolls, run: null,
+ runs: [], solos, soloDone: [], feed: [] };
+ }
+ function bonusFeed(text, color) {
+ const b = s.bonus;
+ b.feed.push({ text, color, wall: now() });
+ if (b.feed.length > 4) b.feed.shift();
+ }
+ function soloOf(t) { return s.bonus.solos.find(x => t >= x.start - 0.05 && t < x.end); }
+ function bonusHit(e) {
+ const b = s.bonus;
+ const mult = e.multiplier || 1;
+ const nt = Number.isFinite(e.noteTime) ? e.noteTime : e.time;
+ let pts = 0;
+ if (Math.abs(e.time - nt) <= 0.025) { pts += 10 * mult; b.timing += 10 * mult; b.perfect++; }
+ if (b.rollSet.has(noteKey(nt, e.pad))) {
+ const r = 25 * mult;
+ pts += r; b.rolls += r;
+ if (!b.run || nt - b.run.last > ROLL_GAP) b.run = { h: 0, n: 0, pts: 0, last: nt };
+ b.run.h++; b.run.n++; b.run.pts += r; b.run.last = nt;
+ }
+ if (e.bonus) { b.dynamics++; bonusFeed('+' + (e.velocityBonus || 25) * mult + ' dynamics', '#b77bff'); }
+ const so = soloOf(nt);
+ if (so && !so.done) { so.h++; so.n++; }
+ b.total += pts;
+ }
+ function bonusMiss(e) {
+ const b = s.bonus;
+ const nt = Number.isFinite(e.noteTime) ? e.noteTime : e.time;
+ if (b.rollSet.has(noteKey(nt, e.pad)) && b.run && nt - b.run.last <= ROLL_GAP) { b.run.n++; b.run.last = nt; }
+ const so = soloOf(nt);
+ if (so && !so.done) so.n++;
+ }
+ function bonusTick(time) {
+ const b = s.bonus;
+ if (!b) return;
+ if (b.run && time - b.run.last > ROLL_GAP + 0.2) {
+ const r = b.run; b.run = null;
+ if (r.n >= ROLL_MIN) {
+ b.runs.push({ h: r.h, n: r.n, pts: r.pts });
+ push({ type: 'bonus-pop', text: 'ROLL ' + r.h + '/' + r.n + ' +' + formatScore(r.pts), color: r.h === r.n ? '#ffc531' : '#ff9a40' });
+ }
+ }
+ for (const so of b.solos) {
+ if (so.done || time < so.end + 0.3) continue;
+ so.done = true;
+ if (!so.n) continue;
+ const pct = 100 * so.h / so.n, perfect = so.h === so.n;
+ const pts = so.h * 100 * (perfect ? 2 : 1);
+ b.solo += pts; b.total += pts;
+ const rating = perfect ? 'PERFECT SOLO!' : pct >= 95 ? 'AWESOME SOLO!' : pct >= 90 ? 'GREAT SOLO!' : pct >= 80 ? 'GOOD SOLO!'
+ : pct >= 70 ? 'SOLID SOLO' : pct >= 60 ? 'OKAY SOLO' : 'MESSY SOLO';
+ b.soloDone.push({ h: so.h, n: so.n, pct: Math.round(pct * 100) / 100, bonus: pts, rating });
+ push({ type: 'solo-end', text: rating, sub: pct.toFixed(2) + '% · solo bonus +' + formatScore(pts), perfect });
+ }
+ }
+
+ function gemsFor() {
+ return buildGems(s.decoded, { pro: effectivePro(), auto: s.auto });
+ }
+
+ // Pro drums only when the player wants it and the chart has cymbal markings.
+ function effectivePro() {
+ return s.proWanted && !(s.meta && s.meta.pro === false);
+ }
+
+ // Rebuild from where the song is now (mid-song changes keep the score from here on).
+ function rebuildHere() {
+ build(s.lastTime != null && s.lastTime > (s.gems.length ? s.gems[0].t - 0.5 : 0)
+ ? s.lastTime : undefined);
+ }
+
+ function push(ev) {
+ ev.wall = now();
+ s.events.push(ev);
+ if (s.events.length > MAX_EVENTS) s.events.splice(0, s.events.length - MAX_EVENTS);
+ }
+
+ function build(fromTime) {
+ if (!s.decoded) { s.engine = null; return; }
+ let chart = s.decoded;
+ const auto = s.auto.kick || s.auto.cymbals;
+ if (Number.isFinite(fromTime) || auto) {
+ // Auto lanes are left out of the scored chart (chords regroup from the notes).
+ chart = { notes: s.decoded.notes.filter(n => (!Number.isFinite(fromTime) || n.t >= fromTime - 0.001)
+ && !(auto && isAutoNote(n.pad, n.cymbal, s.auto))), chords: [] };
+ }
+ const m = s.meta;
+ const eopts = { beats: s.rawBeats || [], proDrums: effectivePro() };
+ if (s.params) eopts.params = s.params;
+ if (m) {
+ eopts.starPower = m.starPower;
+ eopts.activation = m.activation;
+ }
+ const eng = Engine.create(chart, eopts);
+ s.bonus = newBonus(); // a new engine starts its score over, and so do the bonuses
+ eng.on('hit', e => {
+ push({ type: 'hit', pad: e.pad, cymbal: e.cymbal, id: e.id, sp: eng.isStarPowerNote(e.id), bonus: !!e.bonus, time: e.time });
+ bonusHit(e);
+ });
+ eng.on('miss', e => {
+ push({ type: 'miss', pad: e.pad, cymbal: e.cymbal, id: e.id, time: e.time });
+ bonusMiss(e);
+ });
+ eng.on('overhit', e => push({ type: 'overhit', pad: e.pad, cymbal: e.cymbal, time: e.time }));
+ eng.on('sp-phrase', e => push({ type: 'sp-phrase', time: e.time }));
+ eng.on('sp-phrase-fail', e => push({ type: 'sp-phrase-fail', time: e.time }));
+ eng.on('sp-ready', e => push({ type: 'sp-ready', time: e.time }));
+ eng.on('sp-activate', e => push({ type: 'sp-activate', time: e.time }));
+ eng.on('sp-end', e => push({ type: 'sp-end', time: e.time }));
+ eng.on('combo-break', e => push({ type: 'combo-break', combo: e.combo, time: e.time }));
+ s.engine = eng;
+ s.builds++;
+ }
+
+ const api = {
+ /** Load a chart. notes/chords: wire arrays (drum_tab hits converted by screen.js, or legacy notes). */
+ load(chart) {
+ chart = chart || {};
+ const wire = collectWireNotes(chart.notes, chart.chords);
+ s.decoded = Engine.decodeNotes(wire, { kick2x: true });
+ s.gems = gemsFor();
+ s.rawBeats = Array.isArray(chart.beats) ? chart.beats : [];
+ s.beats = normalizeBeats(s.rawBeats);
+ s.lastTime = null;
+ s.events.length = 0;
+ build(undefined);
+ return api;
+ },
+ /** Apply the gameplay metadata (parseDrumTabMeta output, or null). Rebuilds from the current time. */
+ setMeta(meta) {
+ s.meta = meta || null;
+ if (s.decoded) {
+ s.gems = gemsFor();
+ rebuildHere();
+ }
+ return api;
+ },
+ /** Auto lanes ({kick, cymbals}); mid-song changes score from the current time. */
+ setAuto(auto) {
+ auto = normalizeAuto(auto);
+ if (auto.kick === s.auto.kick && auto.cymbals === s.auto.cymbals) return api;
+ s.auto = auto;
+ if (s.decoded) {
+ s.gems = gemsFor();
+ rebuildHere();
+ }
+ return api;
+ },
+ /** Engine params override (e.g. {hitWindow: {...}}), or null for the defaults. */
+ setParams(params) {
+ params = params || null;
+ if (JSON.stringify(params) === JSON.stringify(s.params)) return api;
+ s.params = params;
+ if (s.decoded) rebuildHere();
+ return api;
+ },
+ /** The player's "Pro cymbals" setting. false = non-pro drums (no cymbal lanes). */
+ setProDrums(on) {
+ on = on !== false;
+ if (on === s.proWanted) return api;
+ s.proWanted = on;
+ if (s.decoded) {
+ s.gems = gemsFor();
+ rebuildHere();
+ }
+ return api;
+ },
+ update(time) {
+ time = +time;
+ if (!Number.isFinite(time) || !s.engine) return;
+ if (s.lastTime != null) {
+ if (isSeek(s.lastTime, time)) build(time);
+ else if (time > s.lastTime && (s.auto.kick || s.auto.cymbals)) {
+ // Auto notes crossing the strikeline this frame flash as if played.
+ for (let i = firstAfter(s.gems, s.lastTime); i < s.gems.length && s.gems[i].t <= time; i++) {
+ const g = s.gems[i];
+ if (g.auto) push({ type: 'hit', pad: g.pad, cymbal: g.cymbal, id: g.id, sp: false, auto: true, time: g.t });
+ }
+ }
+ }
+ // First frame after a load that starts mid-song (view switched while playing,
+ // renderer installed late): score from here instead of missing everything before.
+ else if (s.gems.length && time > s.gems[0].t - SEEK_BACK) build(time);
+ s.engine.update(time);
+ bonusTick(time);
+ s.lastTime = time;
+ },
+ hit(time, pad, o) {
+ o = o || {};
+ push({ type: 'press', pad: pad | 0, cymbal: !!o.cymbal });
+ // Pads the game plays for you: no score, no overhit.
+ if (isAutoNote(pad | 0, !!o.cymbal, s.auto)) return { type: 'ignored', reason: 'auto' };
+ if (!s.engine || !Number.isFinite(+time)) return { type: 'ignored', reason: 'no-chart' };
+ if (s.lastTime != null && isSeek(s.lastTime, +time)) return { type: 'ignored', reason: 'seek' };
+ return s.engine.hit(+time, pad, { cymbal: !!o.cymbal, velocity: o.velocity });
+ },
+ activate(time) {
+ if (!s.engine) return false;
+ return s.engine.activateStarPower(time);
+ },
+ drainEvents() {
+ if (!s.events.length) return [];
+ const out = s.events.slice();
+ s.events.length = 0;
+ return out;
+ },
+ getState() { return s.engine ? s.engine.getState() : null; },
+ /** The chart note behind an event id: {t, midi, pad, cymbal}, or null. */
+ noteInfo(id) {
+ const n = s.decoded && Number.isInteger(id) ? s.decoded.notes[id] : null;
+ return n ? { t: n.t, midi: n.midi, pad: n.pad, cymbal: n.cymbal } : null;
+ },
+ /** True when the note (event / gem id) belongs to a star power phrase still alive. */
+ isStarPowerNote(id) { return !!(s.engine && s.engine.isStarPowerNote(id)); },
+ /** Bonus score on top of the engine's: totals, recent feed, the live solo. */
+ getBonus() {
+ const b = s.bonus;
+ if (!b) return null;
+ const t = s.lastTime;
+ const live = t == null ? null : b.solos.find(x => t >= x.start - 0.5 && t < x.end + 0.3);
+ return { total: b.total, timing: b.timing, rolls: b.rolls, solo: b.solo, perfect: b.perfect, dynamics: b.dynamics,
+ feed: b.feed.slice(), rollActive: !!(b.run && b.run.n >= 3), runs: b.runs.slice(), solos: b.soloDone.slice(),
+ soloLive: live ? { h: live.h, n: live.n, active: t >= live.start && t < live.end } : null };
+ },
+ get engine() { return s.engine; },
+ get gems() { return s.gems; },
+ get beats() { return s.beats; },
+ get meta() { return s.meta; },
+ get proDrums() { return effectivePro(); },
+ get auto() { return { kick: s.auto.kick, cymbals: s.auto.cymbals }; },
+ get builds() { return s.builds; },
+ get lastTime() { return s.lastTime; },
+ };
+ return api;
+ }
+
+ return {
+ PAD, LANE_NAMES, LANE_COLORS, COLORS, LANE_ID_TO_PAD, SEEK_BACK, SEEK_FORWARD,
+ collectWireNotes, classifyNote, buildGems, visibleRange, normalizeBeats,
+ AUTO_LEVELS, normalizeAutoLevel, autoAppliesAt, autoFor, isAutoNote,
+ parseDrumTabMeta,
+ DIFFICULTIES, LOWER_LEVELS, DEFAULT_DIFFICULTY, DIFFICULTY_LABELS, DIFFICULTY_NAMES, DIFFICULTY_COLORS,
+ normalizeDifficulty, hasKick2x, stripKick2x, difficultyOptions,
+ resolveDifficulty, nextDifficulty, difficultyLabel, difficultyBadge, difficultyChart, isDifficultyKey,
+ midiToPad, isTypingTarget, estimateTime, isSeek,
+ formatScore, formatAccuracy, multiplierColor, starProgress, hudModel,
+ createSession,
+ };
+});
diff --git a/plugin.json b/plugin.json
index 3697612..510fa33 100644
--- a/plugin.json
+++ b/plugin.json
@@ -1,7 +1,7 @@
{
"id": "drums",
"name": "Drum Highway",
- "version": "4.0.0",
+ "version": "4.1.0",
"type": "visualization",
"private": false,
"script": "screen.js",
@@ -30,6 +30,6 @@
}
},
"category": "practice",
- "description": "Drum chart playback and notation.",
+ "description": "Drum chart playback with Rock Band-style scoring, difficulty levels (Easy to Expert+), star power and assists.",
"icon": "assets/thumb.svg"
}
diff --git a/screen.js b/screen.js
index 4055811..bee46f1 100644
--- a/screen.js
+++ b/screen.js
@@ -1,6 +1,11 @@
// Drum Highway visualization plugin — lane-based scrolling drum
// renderer (Rock Band-style) with MIDI drum pad input, WebAudioFont
-// drum kit sounds, and accuracy scoring.
+// drum kit sounds, and Rock Band-style scoring.
+//
+// Scoring: assets/engine.js (an LGPL-3.0 port of YARG.Core's drums
+// engine, see NOTICE.md) driven through assets/session.js (difficulty
+// levels, star power, assists, bonuses). Both load lazily as plain
+// scripts on first use; this file only uses their public APIs.
//
// Wave C (slopsmith#36): per-instance refactor. Earlier Wave B
// landed setRenderer support with an explicit single-instance
@@ -65,12 +70,52 @@ const STORE_KEYS = {
// Cr/Ri/Ki layout. 'rb4' is a denser 7-lane Rock-Band-style preset.
// Persisted via _saveCfg below.
lanePreset: 'drums_lane_preset_v1',
+ // Drum difficulty: easy | medium | hard | expert | expert_plus. Per
+ // browser, so each player keeps their own; songs without that level
+ // play Expert without overwriting the choice.
+ difficulty: 'drums_difficulty_v1',
+ // Pro cymbals: true = pro drums (cymbal and tom of a colour are
+ // separate); false = non-pro, either pad of a colour hits its notes.
+ proCymbals: 'drums_pro_cymbals_v1',
+ // Accessibility: auto kick / auto cymbals are played for you up to a
+ // difficulty ('off' | 'easy' | 'medium' | 'hard' | 'all').
+ autoKick: 'drums_auto_kick_v1',
+ autoCymbals: 'drums_auto_cymbals_v1',
+ // Hit window preset (TIMING_PRESETS).
+ timing: 'drums_timing_v1',
+ // Volume of the notes the auto kick / auto cymbals play for you (0..1),
+ // separate from the pad volume so pads can be silent (the kit module
+ // makes the sound) while the auto notes still fill in for a muted drum
+ // stem.
+ autoVolume: 'drums_auto_volume_v1',
+ // Subtracted from the song time of every scored hit, in ms (positive =
+ // your hits register earlier; for audio / MIDI latency).
+ inputOffsetMs: 'drums_input_offset_ms',
};
// Valid preset ids — kept here so _saveCfg can validate before persisting
// (drums_lane_preset_v1 is user-controlled, like every other storage key
// in this plugin).
const _VALID_LANE_PRESETS = new Set(['phase_shift_8', 'rb4']);
+// Same ids as DrumsSession.DIFFICULTIES (session.js loads lazily).
+const DIFFICULTY_IDS = ['easy', 'medium', 'hard', 'expert', 'expert_plus'];
+const DIFFICULTY_NAMES = { easy: 'Easy', medium: 'Medium', hard: 'Hard', expert: 'Expert', expert_plus: 'Expert+' };
+const _VALID_DIFFICULTIES = new Set(DIFFICULTY_IDS);
+// Same ids as DrumsSession.AUTO_LEVELS.
+const AUTO_LEVEL_IDS = ['off', 'easy', 'medium', 'hard', 'all'];
+const AUTO_LEVEL_NAMES = { off: 'Off', easy: 'Easy only', medium: 'Easy – Medium', hard: 'Easy – Hard', all: 'Every difficulty' };
+const _VALID_AUTO_LEVELS = new Set(AUTO_LEVEL_IDS);
+
+// Hit window presets for the engine (engine.js params.hitWindow).
+// Normal = YARG's default 140 ms window (±70 ms).
+const TIMING_PRESETS = {
+ relaxed: { name: 'Relaxed (±130 ms)', params: { hitWindow: { maxWindow: 0.26, minWindow: 0.26, isDynamic: false } } },
+ forgiving: { name: 'Forgiving (±100 ms)', params: { hitWindow: { maxWindow: 0.20, minWindow: 0.20, isDynamic: false } } },
+ normal: { name: 'Normal (±70 ms)', params: null },
+ precision: { name: 'Precision (tightens on fast notes)', params: { hitWindow: {
+ maxWindow: 0.13, minWindow: 0.05, isDynamic: true, dynamicScale: 1, dynamicSlope: 0.60615, dynamicGamma: 2 } } },
+};
+const TIMING_IDS = ['relaxed', 'forgiving', 'normal', 'precision'];
// Safe localStorage reader — getItem can throw SecurityError in
// sandboxed iframes, under Safari on file://, or when storage is
@@ -146,6 +191,25 @@ const _cfg = {
const raw = _readStore(STORE_KEYS.lanePreset);
return _VALID_LANE_PRESETS.has(raw) ? raw : 'phase_shift_8';
})(),
+ autoVolume: _readNum(STORE_KEYS.autoVolume, 0.8, 0, 1),
+ inputOffsetMs: Math.round(_readNum(STORE_KEYS.inputOffsetMs, 0, -250, 250)),
+ difficulty: (function () {
+ const raw = _readStore(STORE_KEYS.difficulty);
+ return _VALID_DIFFICULTIES.has(raw) ? raw : 'expert';
+ })(),
+ proCymbals: _readStore(STORE_KEYS.proCymbals) !== 'false',
+ autoKick: (function () {
+ const raw = _readStore(STORE_KEYS.autoKick);
+ return _VALID_AUTO_LEVELS.has(raw) ? raw : 'off';
+ })(),
+ autoCymbals: (function () {
+ const raw = _readStore(STORE_KEYS.autoCymbals);
+ return _VALID_AUTO_LEVELS.has(raw) ? raw : 'off';
+ })(),
+ timing: (function () {
+ const raw = _readStore(STORE_KEYS.timing);
+ return Object.prototype.hasOwnProperty.call(TIMING_PRESETS, raw) ? raw : 'normal';
+ })(),
// Transient: which lane is in learn mode. Module-scope across
// panels — the Learn-mode UX is "click Learn in any panel, then
// hit a pad on the focused MIDI device." The next focused-panel
@@ -169,6 +233,23 @@ function _saveCfg(key, val) {
if (key === 'lanePreset' && !_VALID_LANE_PRESETS.has(val)) {
val = 'phase_shift_8';
}
+ if (key === 'difficulty' && !_VALID_DIFFICULTIES.has(val)) {
+ val = 'expert';
+ }
+ if ((key === 'autoKick' || key === 'autoCymbals') && !_VALID_AUTO_LEVELS.has(val)) {
+ val = 'off';
+ }
+ if (key === 'timing' && !Object.prototype.hasOwnProperty.call(TIMING_PRESETS, val)) {
+ val = 'normal';
+ }
+ if (key === 'autoVolume' || key === 'synthVolume') {
+ const n = Number(val);
+ val = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : (key === 'autoVolume' ? 0.8 : 0.7);
+ }
+ if (key === 'inputOffsetMs') {
+ const n = Math.round(Number(val));
+ val = Number.isFinite(n) ? Math.max(-250, Math.min(250, n)) : 0;
+ }
_cfg[key] = val;
const storeKey = STORE_KEYS[key];
if (!storeKey) return;
@@ -208,10 +289,92 @@ const _instances = new Set();
// Monotonic id for per-instance DOM tagging (useful for debugging).
let _nextInstanceId = 0;
+// The host event bus (window.feedBack; window.slopsmith is its legacy alias).
+function _bus() {
+ const b = (typeof window !== 'undefined') && (window.feedBack || window.slopsmith);
+ return b && typeof b.on === 'function' ? b : null;
+}
+
+// Save the drum difficulty (per browser) and apply it to every live
+// instance: each one resolves it against its own chart (falling back to
+// Expert for that song when the level is missing) and, mid-song, rebuilds
+// its chart from the current position on its next frame.
+function _setDifficulty(id) {
+ _saveCfg('difficulty', id);
+ try {
+ document.querySelectorAll('.drums-difficulty-select').forEach((sel) => { sel.value = _cfg.difficulty; });
+ } catch (_) { /* no DOM */ }
+ for (const inst of _instances) {
+ try { inst._difficultyChanged(); } catch (e) { console.warn('[Drums] difficulty update failed:', e); }
+ }
+}
+
+// Save "Pro cymbals" (per browser) and apply it to every live instance;
+// mid-song, scoring restarts from the current position.
+function _setProCymbals(on) {
+ _saveCfg('proCymbals', !!on);
+ try {
+ document.querySelectorAll('.drums-chk-pro').forEach((chk) => { chk.checked = _cfg.proCymbals; });
+ } catch (_) { /* no DOM */ }
+ for (const inst of _instances) {
+ try { inst._proCymbalsChanged(); } catch (e) { console.warn('[Drums] pro cymbals update failed:', e); }
+ }
+}
+
+// Save an assist setting (auto kick / auto cymbals / timing) and apply it
+// to every live instance (mid-song, scoring restarts from the current
+// position, like a difficulty change).
+function _setAssist(key, val) {
+ _saveCfg(key, val);
+ const cls = { autoKick: '.drums-auto-kick', autoCymbals: '.drums-auto-cym', timing: '.drums-timing-select' }[key];
+ try {
+ if (cls) document.querySelectorAll(cls).forEach((sel) => { sel.value = _cfg[key]; });
+ } catch (_) { /* no DOM */ }
+ for (const inst of _instances) {
+ try { if (inst._assistsChanged) inst._assistsChanged(); } catch (e) { console.warn('[Drums] assist update failed:', e); }
+ }
+}
+
+// `).join('');
+}
+function _autoOptions(current) { return _optList(AUTO_LEVEL_IDS, AUTO_LEVEL_NAMES, current); }
+function _timingOptions(current) {
+ const names = {};
+ for (const id of TIMING_IDS) names[id] = TIMING_PRESETS[id].name;
+ return _optList(TIMING_IDS, names, current);
+}
+
+const _SEL_CSS = 'background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:3px 6px;font-size:11px;color:#ccc;outline:none;';
+
+// Auto lanes for a difficulty: each setting is a ceiling ('easy' = on at
+// Easy only, 'hard' = Easy..Hard, 'all' = always). Mirrors
+// DrumsSession.autoFor (kept here so it works before session.js loads).
+function _autoAt(diffId) {
+ const rank = (id) => DIFFICULTY_IDS.indexOf(id);
+ const on = (lvl) => lvl === 'all' || (lvl !== 'off' && rank(diffId) >= 0 && rank(diffId) <= rank(lvl));
+ return { kick: on(_cfg.autoKick), cymbals: on(_cfg.autoCymbals) };
+}
+
+// 2D lane ids played for the player by the auto settings.
+function _laneIsAuto(laneIdx, auto) {
+ const lane = DRUM_LANES[laneIdx];
+ if (!lane || !auto) return false;
+ if (lane.id === 'kick') return !!auto.kick;
+ return !!auto.cymbals && (lane.id === 'hihat' || lane.id === 'crash' || lane.id === 'ride');
+}
+
+function _timingParams() {
+ const p = TIMING_PRESETS[_cfg.timing];
+ return p && p.params ? JSON.parse(JSON.stringify(p.params)) : null;
+}
+
// ── Synth ─────────────────────────────────────────────────────────────
let _audioCtx = null;
let _synthPlayer = null;
let _synthGain = null;
+let _autoGain = null; // bus for auto-played notes (its own volume)
let _synthLoading = false;
let _playerScriptLoaded = false;
const _drumPresets = {}; // midiNote -> preset
@@ -251,14 +414,19 @@ const PIECE_DEFAULT_MIDI = {
};
// Convert a drum_tab.hits[] payload into the legacy {t, s, f, ac, mt}
-// note objects the renderer already understands. Velocity ≥ 100 →
-// accent (renders larger + brighter glow). Ghost notes carry `mt: true`
-// (intent for dimming/shrinking — not yet consumed by the renderer). Flams
-// emit a small leading grace note 30 ms ahead so the user sees the
-// characteristic two-tap shape. Unknown piece-ids are dropped — better
-// silent than a mis-rendered piece on an outdated client.
+// note objects the renderer already understands. Accent (`ac`, renders
+// larger + brighter, scores the dynamics bonus): the tab's authored `a`
+// flag (feedpak 1.20.0) when the tab uses it anywhere, otherwise a written
+// velocity ≥ 100 as before. A hit without `v` is not an accent. Ghost notes
+// carry `mt: true`; a hit is never both. A double kick (`x2: true` on a
+// kick) becomes GM 35 — the engine's Expert+ kick — so the Expert chart can
+// drop it. Flams emit a small leading grace note 30 ms ahead so the user
+// sees the characteristic two-tap shape. Unknown piece-ids are dropped —
+// better silent than a mis-rendered piece on an outdated client.
+const KICK2X_MIDI = 35;
function _drumTabHitsToNotes(hits) {
if (!Array.isArray(hits)) return [];
+ const authoredAccents = hits.some(h => h && h.a === true);
const out = [];
for (const h of hits) {
const piece = h && h.p;
@@ -266,8 +434,10 @@ function _drumTabHitsToNotes(hits) {
// '__proto__', 'constructor', etc., the plain-object lookup would
// return an inherited value instead of undefined.
if (!Object.prototype.hasOwnProperty.call(PIECE_DEFAULT_MIDI, piece)) continue;
- const midi = PIECE_DEFAULT_MIDI[piece];
+ const midi = (piece === 'kick' && h.x2 === true) ? KICK2X_MIDI : PIECE_DEFAULT_MIDI[piece];
const v = (typeof h.v === 'number') ? h.v : 100;
+ const ghost = !!h.g;
+ const accent = !ghost && (authoredAccents ? h.a === true : (typeof h.v === 'number' && h.v >= 100));
const t = +h.t;
// Skip hits with missing or non-finite timestamps — rendering at t=0
// by default would score bogus notes at the song start.
@@ -276,8 +446,8 @@ function _drumTabHitsToNotes(hits) {
t,
s: (midi / 24) | 0,
f: midi % 24,
- ac: v >= 100,
- mt: !!h.g, // ghost — carries intent for renderer (dim/small); TODO: wire up
+ ac: accent,
+ mt: ghost, // ghost — the engine scores it; the renderer draws it smaller
_piece: piece, // carried for future rendering/debug use
_vel: v,
};
@@ -429,6 +599,37 @@ function _loadScript(url) {
});
}
+// ═══════════════════════════════════════════════════════════════════════
+// Scoring modules (engine.js + session.js, lazily loaded)
+// ═══════════════════════════════════════════════════════════════════════
+//
+// plugin.json names one script, so the scoring files live in assets/ and
+// load through core's sandboxed /api/plugins//assets/ route on first
+// use. One shared promise, so N instances never race on the same tags.
+
+const PLUGIN_ID = 'drums';
+const ASSET_VERSION = '4.1.0'; // cache-buster for the lazily loaded files
+const PLUGIN_ASSETS = '/api/plugins/' + PLUGIN_ID + '/assets/';
+
+let _scoringPromise = null;
+function _loadScoring() {
+ if (window.DrumsEngine && window.DrumsSession) {
+ return Promise.resolve({ E: window.DrumsEngine, H: window.DrumsSession });
+ }
+ if (_scoringPromise) return _scoringPromise;
+ const want = (name, global) => (window[global]
+ ? Promise.resolve()
+ : _loadScript(PLUGIN_ASSETS + name + '?v=' + ASSET_VERSION));
+ _scoringPromise = Promise.all([want('engine.js', 'DrumsEngine'), want('session.js', 'DrumsSession')]).then(() => {
+ if (!window.DrumsEngine || !window.DrumsSession) throw new Error('drums scoring modules did not register');
+ return { E: window.DrumsEngine, H: window.DrumsSession };
+ }).catch((e) => {
+ _scoringPromise = null; // allow a retry on the next init
+ throw e;
+ });
+ return _scoringPromise;
+}
+
// ═══════════════════════════════════════════════════════════════════════
// WebAudioFont drum kit synthesizer (module-level — one audio context per tab)
// ═══════════════════════════════════════════════════════════════════════
@@ -455,10 +656,13 @@ async function _synthInit() {
}
if (typeof WebAudioFontPlayer === 'undefined') return;
- _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ _audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
_synthGain = _audioCtx.createGain();
_synthGain.gain.value = _cfg.synthVolume;
_synthGain.connect(_audioCtx.destination);
+ _autoGain = _audioCtx.createGain();
+ _autoGain.gain.value = _cfg.autoVolume;
+ _autoGain.connect(_audioCtx.destination);
_synthPlayer = new WebAudioFontPlayer();
await _synthLoadDrumKit();
@@ -497,21 +701,77 @@ function _synthEnsureCtx() {
}
}
-function _synthDrumHit(midiNote, velocity) {
+// One drum sound at `when` (AudioContext time; 0 = now). dest: the output
+// bus (default: the pad bus _synthGain; _autoGain for auto-played notes).
+function _synthPlayNote(midiNote, velocity, when, dest) {
if (!_synthPlayer || !_audioCtx || !_synthGain) return;
const preset = _drumPresets[midiNote];
if (!preset) return;
- _synthEnsureCtx();
+ dest = dest || _synthGain;
+ _synthPlayer.queueWaveTable(_audioCtx, dest, preset, when || 0, midiNote, when ? 0.6 : 0.5,
+ (velocity / 127) * (dest === _synthGain ? _cfg.synthVolume : 1));
+}
- const vol = (velocity / 127) * _cfg.synthVolume;
- _synthPlayer.queueWaveTable(
- _audioCtx, _synthGain, preset, 0, midiNote, 0.5, vol
- );
+function _synthDrumHit(midiNote, velocity) {
+ if (!_synthPlayer || !_audioCtx || !_synthGain) return;
+ _synthEnsureCtx();
+ _synthPlayNote(midiNote, velocity, 0);
}
function _synthSetVolume(vol) {
_saveCfg('synthVolume', vol);
- if (_synthGain) _synthGain.gain.value = vol;
+ if (_synthGain) _synthGain.gain.value = _cfg.synthVolume;
+ try {
+ document.querySelectorAll('.drums-vol-slider').forEach((el) => { el.value = String(Math.round(_cfg.synthVolume * 100)); });
+ } catch (_) { /* no DOM */ }
+}
+
+function _setAutoVolume(vol) {
+ _saveCfg('autoVolume', vol);
+ if (_autoGain) _autoGain.gain.value = _cfg.autoVolume;
+ try {
+ document.querySelectorAll('.drums-autovol-slider').forEach((el) => { el.value = String(Math.round(_cfg.autoVolume * 100)); });
+ } catch (_) { /* no DOM */ }
+}
+
+// GM notes the auto settings play for the player (engine.js MIDI_MAP: kick
+// pads and the yellow/blue/green cymbals; the hat pedal is never scored).
+const _AUTO_KICK_MIDI = new Set([35, 36]);
+const _AUTO_CYM_MIDI = new Set([42, 46, 49, 51, 52, 53, 55, 57, 59]);
+
+function _isAutoMidi(midi, auto) {
+ return !!auto && ((auto.kick && _AUTO_KICK_MIDI.has(midi)) || (auto.cymbals && _AUTO_CYM_MIDI.has(midi)));
+}
+
+// Velocity for an auto note: drum-tab velocity, else accent / ghost / normal.
+function _autoVelocity(n) {
+ if (Number.isFinite(n._vel)) return n._vel;
+ return n.ac ? 118 : (n.mt ? 55 : 100);
+}
+
+// Auto notes with from < t <= to in a sorted wire list ({t, s, f}) and its
+// chords, as [{t, midi, vel}]. Pure (tested).
+function _autoNotesBetween(notes, chords, from, to, auto) {
+ const out = [];
+ if (!auto || (!auto.kick && !auto.cymbals) || !(to > from)) return out;
+ const scan = (list, each) => {
+ if (!Array.isArray(list)) return;
+ let lo = 0, hi = list.length;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (!(list[m] && list[m].t > from)) lo = m + 1; else hi = m; }
+ for (let i = lo; i < list.length && list[i] && list[i].t <= to; i++) each(list[i]);
+ };
+ scan(notes, (n) => {
+ if (n._noScore) return;
+ const midi = (n.s | 0) * 24 + (n.f | 0);
+ if (_isAutoMidi(midi, auto)) out.push({ t: n.t, midi, vel: _autoVelocity(n) });
+ });
+ scan(chords, (c) => {
+ for (const cn of (c.notes || [])) {
+ const midi = (cn.s | 0) * 24 + (cn.f | 0);
+ if (_isAutoMidi(midi, auto)) out.push({ t: c.t, midi, vel: _autoVelocity(cn) });
+ }
+ });
+ return out;
}
// ═══════════════════════════════════════════════════════════════════════
@@ -1058,6 +1318,459 @@ function createFactory() {
// closure across the destroy.
let _focusSubscribed = false;
+ // ── Scoring (engine.js via session.js) ──
+ // The chart (drum tab hits, or the legacy notes) is the full Expert+
+ // part; Easy/Medium/Hard come from the tab's `levels` (feedpak 1.20.0).
+ let _h = null; // DrumsSession (helpers + session factory) once loaded
+ let _E = null; // DrumsEngine once loaded
+ let _session = null; // _h.createSession(...) for this instance
+ let _chartRefs = null; // [notes, chords, beats] identities loaded into the session
+ let _metaKey; // drum tab the metadata was parsed from (undefined = none yet)
+ let _metaCache = null; // _h.parseDrumTabMeta(...) for _metaKey (null = Expert only, no star power)
+ const _clock = { time: NaN, wall: NaN, prevTime: NaN, prevWall: NaN };
+ let _diffOptions = null; // _h.difficultyOptions(...) for the loaded chart
+ let _diff = null; // _h.resolveDifficulty(...) in use: {id, requested, fallback, reason}
+ let _badge = null; // _h.difficultyBadge(...) drawn in the HUD
+ let _diffUiKey = ''; // last difficulty state written to the DOM
+ let _lvlMemo = null; // {notes, chords, id, src, chart}: chart for the difficulty
+ let _has2xMemo = null; // {notes, chords, val}
+ let _badgeBtn = null; // clickable button over the HUD badge
+ let _badgeRectKey = '';
+ let _diffMenu = null; // difficulty pop-up menu
+ let _onDiffKey = null, _onMenuOutside = null;
+ let _onVisibility = null, _onCanvasReplaced = null;
+ let _hwVisible = true;
+ let _auto = { kick: false, cymbals: false }; // auto lanes for the difficulty in use
+ // Auto-note sounds: song time up to which they're scheduled, and the last
+ // frame's (song time, wall ms) for the playback-rate estimate.
+ let _autoSchedTo = NaN, _autoPrevT = NaN, _autoPrevWall = NaN;
+
+ // Play the auto notes (kick / cymbals) so a muted drum stem doesn't leave
+ // holes. Scheduled ~60 ms ahead on the audio clock for tight timing; a
+ // pause, seek or song change resets the schedule (nothing is replayed).
+ const AUTO_LOOKAHEAD = 0.06;
+ function _scheduleAutoSounds(notes, chords, t) {
+ const wall = performance.now();
+ const prevT = _autoPrevT, prevWall = _autoPrevWall;
+ _autoPrevT = t; _autoPrevWall = wall;
+ if ((!_auto.kick && !_auto.cymbals) || !_audioCtx || !_autoGain || _cfg.autoVolume <= 0) {
+ _autoSchedTo = NaN;
+ return;
+ }
+ const dt = t - prevT, dw = (wall - prevWall) / 1000;
+ if (!Number.isFinite(dt) || dt <= 0 || dt > 0.5 || !(dw > 0)) {
+ // first frame, paused, seek or jump back: start from here
+ _autoSchedTo = t;
+ return;
+ }
+ const rate = Math.max(0.25, Math.min(2, dt / dw));
+ if (!Number.isFinite(_autoSchedTo) || _autoSchedTo < t - 0.1 || _autoSchedTo > t + 1) _autoSchedTo = t;
+ const to = t + AUTO_LOOKAHEAD * rate;
+ const due = _autoNotesBetween(notes, chords, _autoSchedTo, to, _auto);
+ _autoSchedTo = to;
+ if (!due.length) return;
+ _synthEnsureCtx();
+ const now = _audioCtx.currentTime;
+ for (const n of due) _synthPlayNote(n.midi, n.vel, now + Math.max(0, (n.t - t) / rate), _autoGain);
+ }
+
+ function _now() { return performance.now(); }
+
+ // Song time for an input arriving now (between frames), minus the
+ // user's input offset.
+ function _inputTime() {
+ const t = _h ? _h.estimateTime(_clock, _now()) : _clock.time;
+ return t - (_cfg.inputOffsetMs || 0) / 1000;
+ }
+
+ // Load engine.js + session.js and build this instance's scoring session.
+ function _startScoring() {
+ _loadScoring().then(({ E, H }) => {
+ if (_instanceDestroyed || !_isReady) return;
+ _E = E;
+ _h = H;
+ if (!_session) {
+ _auto = _autoAt(_diff ? _diff.id : _cfg.difficulty);
+ _session = H.createSession(E, { proDrums: _cfg.proCymbals, auto: _auto, params: _timingParams() });
+ _chartRefs = null;
+ }
+ }).catch((e) => console.warn('[Drums] scoring unavailable (engine/session failed to load):', e));
+ }
+
+ // Shared bus wiring. The difficulty badge is sibling DOM, so hide it
+ // with the highway canvas (splitscreen display:none's #highway). Filter
+ // by canvas: every highway instance emits on the shared bus.
+ function _wireBus() {
+ const bus = _bus();
+ if (!bus || typeof bus.off !== 'function') return;
+ _onVisibility = (e) => {
+ if (!e || !e.detail || e.detail.canvas !== _highwayCanvas) return;
+ _hwVisible = e.detail.visible !== false;
+ if (!_hwVisible) { _placeBadge(null); _toggleDiffMenu(false); }
+ };
+ _onCanvasReplaced = (e) => {
+ if (!e || !e.detail || e.detail.oldCanvas !== _highwayCanvas) return;
+ _highwayCanvas = e.detail.newCanvas;
+ };
+ try { bus.on('highway:visibility', _onVisibility); } catch (_) { _onVisibility = null; }
+ try { bus.on('highway:canvas-replaced', _onCanvasReplaced); } catch (_) { _onCanvasReplaced = null; }
+ }
+
+ function _unwireBus() {
+ const bus = _bus();
+ if (bus && typeof bus.off === 'function') {
+ if (_onVisibility) { try { bus.off('highway:visibility', _onVisibility); } catch (_) { /* ignore */ } }
+ if (_onCanvasReplaced) { try { bus.off('highway:canvas-replaced', _onCanvasReplaced); } catch (_) { /* ignore */ } }
+ }
+ _onVisibility = null;
+ _onCanvasReplaced = null;
+ }
+
+ // Gameplay metadata (star power, fills, solos, difficulty levels)
+ // rides on the drum tab core streams (feedpak 1.20.0). Parsed once per
+ // tab: draw() only runs after `ready`, when every level chunk is in. A
+ // legacy chart (GM-encoded notes, no tab) or a pre-1.20.0 tab has none:
+ // Expert only, no star power.
+ function _syncMeta(dt) {
+ if (!_h || dt === _metaKey) return;
+ _metaKey = dt;
+ _metaCache = dt ? _h.parseDrumTabMeta(dt, _drumTabHitsToNotes) : null;
+ if (_session && _chartRefs) _session.setMeta(_metaCache);
+ }
+
+ // ── Difficulty ──
+
+ // Chart for the selected difficulty, memoised on the source arrays so
+ // identities stay stable across frames (the session reloads only when
+ // they change). Before session.js loads the chart plays as it is.
+ function _applyDifficulty(bundle, notes, chords) {
+ const H = _h;
+ if (!H) return { notes, chords };
+ const chordKey = (Array.isArray(chords) && chords.length) ? chords : null;
+ if (!_has2xMemo || _has2xMemo.notes !== notes || _has2xMemo.chords !== chordKey) {
+ _has2xMemo = { notes, chords: chordKey, val: H.hasKick2x(notes, chordKey) };
+ }
+ _diffOptions = H.difficultyOptions({ meta: _metaCache, has2x: _has2xMemo.val });
+ _diff = H.resolveDifficulty(_cfg.difficulty, _diffOptions);
+ const id = _diff.id;
+ const src = _metaCache && _metaCache.levels ? (_metaCache.levels[id] || null) : null;
+ const m = _lvlMemo;
+ if (!m || m.notes !== notes || m.chords !== chordKey || m.id !== id || m.src !== src) {
+ const switched = !!(m && m.notes === notes && m.chords === chordKey && m.id !== id);
+ const chart = H.difficultyChart(id, notes, chordKey || chords, _metaCache);
+ _lvlMemo = { notes, chords: chordKey, id, src, chart };
+ // Same song, other difficulty: the session reloads (scoring
+ // from the current time, like a seek); clear the hit marks.
+ if (switched) _resetScoring();
+ }
+ _refreshDifficultyUI();
+ _syncAuto();
+ return _lvlMemo.chart;
+ }
+
+ // Auto kick / cymbals follow the difficulty being played.
+ function _syncAuto() {
+ const a = _autoAt(_diff ? _diff.id : _cfg.difficulty);
+ if (a.kick !== _auto.kick || a.cymbals !== _auto.cymbals) {
+ _auto = a;
+ _resetScoring();
+ }
+ if (_session) _session.setAuto(_auto);
+ }
+
+ function _assistsChanged() {
+ _syncAuto();
+ if (_session) _session.setParams(_timingParams());
+ }
+
+ function _proCymbalsChanged() {
+ if (_session) _session.setProDrums(_cfg.proCymbals);
+ }
+
+ function _difficultyChanged() {
+ if (_h && _diffOptions) _diff = _h.resolveDifficulty(_cfg.difficulty, _diffOptions);
+ _refreshDifficultyUI(true);
+ }
+
+ function _optionsHtml() {
+ const H = _h;
+ const opts = _diffOptions || (H ? H.difficultyOptions({}) : null);
+ const esc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/ {
+ const o = opts ? opts.find(x => x.id === id) : null;
+ const off = !!(o && !o.available);
+ const text = DIFFICULTY_NAMES[id] + (o && o.generated ? ' (auto)' : '') + (off ? ' – n/a' : '');
+ return ``;
+ }).join('');
+ }
+
+ function _difficultyNote() {
+ if (!_diff) return '';
+ if (_diff.fallback) {
+ return 'Playing Expert: ' + (_diff.reason || (DIFFICULTY_NAMES[_diff.requested] + ' is not available'));
+ }
+ const o = _diffOptions ? _diffOptions.find(x => x.id === _diff.id) : null;
+ return o && o.generated ? DIFFICULTY_NAMES[_diff.id] + ' is auto-generated from Expert' : '';
+ }
+
+ // Push the difficulty state into the DOM (badge tooltip, settings
+ // panel, open menu) when it changed.
+ function _refreshDifficultyUI(force) {
+ const H = _h;
+ if (!H || !_diff || !_diffOptions) return;
+ const key = _cfg.difficulty + '|' + _diff.id + '|' + _diff.fallback + '|'
+ + _diffOptions.map(o => (o.available ? 1 : 0) + (o.generated ? 'g' : '') + o.reason).join(',');
+ if (!force && key === _diffUiKey) return;
+ _diffUiKey = key;
+ _badge = H.difficultyBadge(_diff, _diffOptions);
+ if (_badgeBtn) {
+ _badgeBtn.title = _badge.title + '. Click, or press D / Shift+D, to change.';
+ _badgeBtn.setAttribute('aria-label', 'Drum difficulty: ' + _badge.text
+ + (_badge.sub ? ' ' + _badge.sub : '') + '. Change difficulty');
+ _badgeBtn.dataset.difficulty = _badge.id;
+ _badgeBtn.dataset.fallback = _badge.fallback ? '1' : '0';
+ }
+ if (_settingsPanel) {
+ const sel = _settingsPanel.querySelector('.drums-difficulty-select');
+ if (sel) { sel.innerHTML = _optionsHtml(); sel.value = _cfg.difficulty; }
+ const note = _settingsPanel.querySelector('.drums-difficulty-note');
+ if (note) note.textContent = _difficultyNote();
+ }
+ if (_diffMenu && _diffMenu.style.display !== 'none') _renderDiffMenu();
+ }
+
+ // Clickable (transparent) button over the HUD difficulty badge.
+ function _ensureBadge() {
+ if (_badgeBtn || !_highwayCanvas || !_highwayCanvas.parentNode) return;
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'drums-diff-badge';
+ b.dataset.drumsInstance = String(_instanceId);
+ b.setAttribute('aria-haspopup', 'menu');
+ b.setAttribute('aria-label', 'Change drum difficulty');
+ b.style.cssText = 'position:absolute;z-index:3;display:none;margin:0;padding:0;border:0;'
+ + 'background:transparent;cursor:pointer;border-radius:999px;';
+ b.onclick = (e) => {
+ e.stopPropagation();
+ b.blur(); // keep Space = play/pause rather than "click the badge again"
+ _toggleDiffMenu();
+ };
+ _highwayCanvas.parentNode.insertBefore(b, _highwayCanvas.nextSibling);
+ _badgeBtn = b;
+ _diffUiKey = '';
+ _refreshDifficultyUI();
+ }
+
+ // rect: the badge in canvas css px (from the HUD draw), or null to hide.
+ function _placeBadge(rect) {
+ if (!_badgeBtn) return;
+ if (!rect || !_hwVisible || !_highwayCanvas) {
+ if (_badgeRectKey !== '') { _badgeBtn.style.display = 'none'; _badgeRectKey = ''; }
+ return;
+ }
+ const x = Math.round(_highwayCanvas.offsetLeft + rect.x);
+ const y = Math.round(_highwayCanvas.offsetTop + rect.y);
+ const w = Math.round(rect.w), h = Math.round(rect.h);
+ const key = x + '|' + y + '|' + w + '|' + h;
+ if (key === _badgeRectKey) return;
+ _badgeRectKey = key;
+ const st = _badgeBtn.style;
+ st.left = x + 'px'; st.top = y + 'px'; st.width = w + 'px'; st.height = h + 'px';
+ st.display = '';
+ }
+
+ function _renderDiffMenu() {
+ const menu = _diffMenu;
+ if (!menu) return;
+ menu.textContent = '';
+ const head = document.createElement('div');
+ head.textContent = 'Difficulty';
+ head.style.cssText = 'font:700 10px system-ui,sans-serif;color:#7a84a6;padding:2px 8px 4px;'
+ + 'letter-spacing:0.08em;text-transform:uppercase;';
+ menu.appendChild(head);
+ const colors = (_h && _h.DIFFICULTY_COLORS) || {};
+ for (const o of (_diffOptions || [])) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'drums-diff-option';
+ item.dataset.diff = o.id;
+ item.setAttribute('role', 'menuitemradio');
+ const current = !!(_diff && _diff.id === o.id);
+ item.setAttribute('aria-checked', current ? 'true' : 'false');
+ item.disabled = !o.available;
+ if (!o.available) item.title = o.reason;
+ item.style.cssText = 'display:block;width:100%;text-align:left;border:0;border-radius:6px;padding:4px 8px;'
+ + 'font:700 12px system-ui,sans-serif;background:' + (current ? 'rgba(255,255,255,0.09)' : 'transparent') + ';'
+ + 'color:' + (o.available ? (colors[o.id] || '#ddd') : '#5a6078') + ';'
+ + 'cursor:' + (o.available ? 'pointer' : 'not-allowed') + ';';
+ const label = document.createElement('span');
+ label.textContent = (current ? '▸ ' : ' ') + o.name;
+ item.appendChild(label);
+ const tag = (text) => {
+ const g = document.createElement('span');
+ g.textContent = ' ' + text;
+ g.style.cssText = 'font:800 9px system-ui,sans-serif;color:#9aa4c4;';
+ item.appendChild(g);
+ };
+ if (o.generated) tag('AUTO');
+ if (_diff && _diff.fallback && _diff.requested === o.id) tag('(saved)');
+ if (!o.available && o.reason) {
+ const r = document.createElement('div');
+ r.textContent = o.reason;
+ r.style.cssText = 'font:400 10px system-ui,sans-serif;color:#5a6078;padding-left:1.3em;white-space:normal;';
+ item.appendChild(r);
+ }
+ item.onclick = (e) => {
+ e.stopPropagation();
+ item.blur();
+ _toggleDiffMenu(false);
+ _setDifficulty(o.id);
+ };
+ menu.appendChild(item);
+ }
+ }
+
+ function _toggleDiffMenu(open) {
+ const isOpen = !!(_diffMenu && _diffMenu.style.display !== 'none');
+ if (open === undefined) open = !isOpen;
+ if (!open) {
+ if (_diffMenu) _diffMenu.style.display = 'none';
+ if (_onMenuOutside) { document.removeEventListener('pointerdown', _onMenuOutside, true); _onMenuOutside = null; }
+ return;
+ }
+ if (!_badgeBtn || !_badgeBtn.parentNode) return;
+ if (!_diffMenu) {
+ const m = document.createElement('div');
+ m.className = 'drums-diff-menu';
+ m.dataset.drumsInstance = String(_instanceId);
+ m.setAttribute('role', 'menu');
+ m.style.cssText = 'position:absolute;z-index:26;display:none;min-width:150px;max-width:260px;padding:4px;'
+ + 'background:rgba(8,10,24,0.96);border:1px solid #2a3150;border-radius:8px;'
+ + 'box-shadow:0 6px 20px rgba(0,0,0,0.5);';
+ _badgeBtn.parentNode.insertBefore(m, _badgeBtn.nextSibling);
+ _diffMenu = m;
+ }
+ _renderDiffMenu();
+ const bx = _badgeBtn.offsetLeft, by = _badgeBtn.offsetTop, bh = _badgeBtn.offsetHeight;
+ _diffMenu.style.left = bx + 'px';
+ _diffMenu.style.top = (by + bh + 4) + 'px';
+ _diffMenu.style.display = '';
+ // Open upward when it would run off the bottom of the highway.
+ if (_highwayCanvas) {
+ const mh = _diffMenu.offsetHeight;
+ const bottom = _highwayCanvas.offsetTop + _highwayCanvas.offsetHeight;
+ if (by + bh + 4 + mh > bottom && by - mh - 4 >= _highwayCanvas.offsetTop) {
+ _diffMenu.style.top = (by - mh - 4) + 'px';
+ }
+ }
+ if (!_onMenuOutside) {
+ _onMenuOutside = (e) => {
+ if (_diffMenu && _diffMenu.contains(e.target)) return;
+ if (_badgeBtn && _badgeBtn.contains(e.target)) return;
+ _toggleDiffMenu(false);
+ };
+ document.addEventListener('pointerdown', _onMenuOutside, true);
+ }
+ }
+
+ function _removeDifficultyUI() {
+ _toggleDiffMenu(false);
+ if (_diffMenu) { _diffMenu.remove(); _diffMenu = null; }
+ if (_badgeBtn) { _badgeBtn.remove(); _badgeBtn = null; }
+ _badgeRectKey = '';
+ if (_onDiffKey) { window.removeEventListener('keydown', _onDiffKey, true); _onDiffKey = null; }
+ }
+
+ // D = harder, Shift+D = easier (focused, visible panel only; not while typing).
+ function _wireDifficultyKey() {
+ if (_onDiffKey) return;
+ _onDiffKey = (e) => {
+ if (_instanceDestroyed || !_isReady || !_isFocused || !_hwVisible || !_h) return;
+ const H = _h;
+ if (H.isTypingTarget(e.target) || H.isTypingTarget(document.activeElement)) return;
+ const k = H.isDifficultyKey(e);
+ if (!k) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ if (e.repeat) return;
+ _setDifficulty(H.nextDifficulty(_diff ? _diff.id : _cfg.difficulty, _diffOptions, k.dir));
+ };
+ window.addEventListener('keydown', _onDiffKey, true);
+ }
+
+ // The badge is drawn on the lane canvas, top centre: left of the score
+ // HUD when it shows (hudLeft), else on its own (the player's song title
+ // overlay owns the top-left corner).
+ function _drawDifficulty2D(ctx, W, hudLeft) {
+ const b = _badge;
+ if (!b) { _placeBadge(null); return; }
+ const y = 8, h = 20, padX = 8, gap = 5;
+ ctx.save();
+ ctx.font = 'italic bold 11px sans-serif';
+ const tw = ctx.measureText(b.text).width;
+ ctx.font = 'bold 8px sans-serif';
+ const sw = b.sub ? ctx.measureText(b.sub).width + gap : 0;
+ const w = tw + sw + padX * 2;
+ const x = Number.isFinite(hudLeft) ? Math.max(4, hudLeft - w - 6) : Math.round((W - w) / 2);
+ ctx.fillStyle = 'rgba(8,8,20,0.8)';
+ _roundRect(ctx, x, y, w, h, h / 2);
+ ctx.fill();
+ ctx.globalAlpha = b.fallback ? 0.55 : 1;
+ ctx.strokeStyle = b.color;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.font = 'italic bold 11px sans-serif';
+ ctx.fillStyle = b.color;
+ ctx.fillText(b.text, x + padX, y + h / 2 + 0.5);
+ if (b.sub) {
+ ctx.font = 'bold 8px sans-serif';
+ ctx.fillStyle = '#9aa4c4';
+ ctx.fillText(b.sub, x + padX + tw + gap, y + h / 2 + 0.5);
+ }
+ ctx.restore();
+ _placeBadge({ x, y, w, h });
+ }
+
+ // ── Engine session ──
+
+ // Load the difficulty's chart into the session when its arrays change
+ // identity (new song / difficulty) and advance it to the playhead.
+ // Empty chord lists compare as "none": the drum_tab path builds a fresh
+ // [] every frame. A reload mid-song scores from the current time.
+ function _syncSession(bundle, notes, chords) {
+ if (!_session) return;
+ const chordKey = (Array.isArray(chords) && chords.length) ? chords : null;
+ if (!_chartRefs || _chartRefs[0] !== notes || _chartRefs[1] !== chordKey || _chartRefs[2] !== bundle.beats) {
+ _chartRefs = [notes, chordKey, bundle.beats];
+ _session.load({ notes, chords, beats: bundle.beats });
+ _session.setMeta(_metaCache);
+ _clock.time = _clock.wall = _clock.prevTime = _clock.prevWall = NaN;
+ _resetScoring();
+ }
+ const t = +bundle.currentTime || 0;
+ _clock.prevTime = _clock.time;
+ _clock.prevWall = _clock.wall;
+ _clock.time = t;
+ _clock.wall = _now();
+ _session.update(t);
+ }
+
+ // Engine verdicts -> the lane view's hit / miss marks (keyed like the
+ // drawn notes: time + GM note).
+ function _drainSessionEvents() {
+ if (!_session) return;
+ for (const ev of _session.drainEvents()) {
+ if (ev.type !== 'hit' && ev.type !== 'miss') continue;
+ const info = _session.noteInfo(ev.id);
+ if (!info) continue;
+ (ev.type === 'hit' ? _hitNoteKeys : _missedNoteKeys).add(_noteKey(info.t, info.midi));
+ }
+ }
+
// ── Listener refs (per-instance so destroy() detach matches) ──
const _onWinResize = () => _applyCanvasDims();
const _onFocusChange = () => _updateFocusState();
@@ -1142,6 +1855,16 @@ function createFactory() {
});
}
+ if (_session && _h && _E) {
+ // The engine scores: a Learn/custom mapping entry wins, else GM.
+ const m = _h.midiToPad(midiNote, _cfg.customMapping, _E.padFromMidi);
+ if (m) {
+ const r = _session.hit(_inputTime(), m.pad, { cymbal: m.cymbal, velocity });
+ if (r && r.type === 'overhit' && laneIdx >= 0) _wrongFlashes.push({ lane: laneIdx, wall: performance.now() });
+ }
+ return;
+ }
+
if (_cfg.hitDetection) {
_checkHit(midiNote);
}
@@ -1165,6 +1888,7 @@ function createFactory() {
const playedLane = _midiToLaneIdx(playedMidi);
if (playedLane < 0) return;
+ if (_laneIsAuto(playedLane, _auto)) return; // played for you: no hit, no miss
let foundHit = false;
@@ -1216,7 +1940,9 @@ function createFactory() {
}
function _updateMissedNotes(t, notes, chords) {
- if (!_cfg.hitDetection) return;
+ _expireFlashes();
+ // With the engine loaded its verdicts mark the notes (_drainSessionEvents).
+ if (!_cfg.hitDetection || _session) return;
const cutoff = t - HIT_TOLERANCE - 0.05;
if (notes) {
@@ -1227,6 +1953,7 @@ function createFactory() {
// user is expected to hit the main note, not the grace ornament.
if (n._noScore) continue;
const songMidi = noteToMidi(n.s, n.f);
+ if (_laneIsAuto(_songNoteToLaneIdx(songMidi), _auto)) continue;
const key = _noteKey(n.t, songMidi);
if (!_hitNoteKeys.has(key) && !_missedNoteKeys.has(key) && n.t < cutoff) {
_missedNoteKeys.add(key);
@@ -1239,6 +1966,7 @@ function createFactory() {
if (c.t < cutoff - 2) continue;
for (const cn of (c.notes || [])) {
const songMidi = noteToMidi(cn.s, cn.f);
+ if (_laneIsAuto(_songNoteToLaneIdx(songMidi), _auto)) continue;
const key = _noteKey(c.t, songMidi);
if (!_hitNoteKeys.has(key) && !_missedNoteKeys.has(key) && c.t < cutoff) {
_missedNoteKeys.add(key);
@@ -1246,7 +1974,9 @@ function createFactory() {
}
}
}
+ }
+ function _expireFlashes() {
const now = performance.now();
while (_wrongFlashes.length && now - _wrongFlashes[0].wall > 400) {
_wrongFlashes.shift();
@@ -1270,6 +2000,9 @@ function createFactory() {
function _resetForNewChart() {
_resetScoring();
_heldPads.clear();
+ _chartRefs = null;
+ _metaKey = undefined;
+ _metaCache = null;
// Drop the drum_tab → notes memo so a song-change replay
// doesn't keep showing the previous chart's drum hits while
// the new bundle is still loading.
@@ -1398,13 +2131,58 @@ function createFactory() {
Labels
-