diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc81a86..6e70e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: permissions: @@ -34,6 +34,8 @@ jobs: persist-credentials: false - name: Validate public-readiness contracts run: bash tests/scripts/public-readiness-contract-tests.sh + - name: Validate MPL licensing contracts + run: bash tests/scripts/licensing-contract-tests.sh - name: Validate website localization contracts run: bash tests/scripts/website-localization-contract-tests.sh - name: Validate public Wiki contracts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d538817..94f3af4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: CodeQL on: push: - branches: [main] + branches: [main, develop] pull_request: schedule: - cron: '17 3 * * 1' diff --git a/CHANGELOG.md b/CHANGELOG.md index abdbed0..9cc9ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ semantico a partire dalla prima release pubblica stabile. ## [Unreleased] +## [0.6.0-beta.1] - 2026-08-20 + ### Added - build e packaging Windows x64 keyboard-only con esclusione verificata di CoreMIDI; @@ -19,13 +21,22 @@ semantico a partire dalla prima release pubblica stabile. - metronomo opzionale sincronizzato al clock DSP della sessione; - controlli CI, CodeQL, Dependabot e template per contributi; - documentazione privacy, sicurezza e pubblicazione. +- Chart Creator locale con import audio, registrazione, editing delle note, + waveform, scrubbing e pacchetti portabili `.htksong` senza audio incorporato; +- Practice Lab con loop di sezioni e punti A/B; +- Performance Error Map e Auto Tempo Coach basati sui risultati reali; +- sound check guidato per la compensazione locale della latenza; +- Ghost Replay visuale locale, isolato da matcher e punteggio; +- palco reattivo procedurale con movimento ridotto e contrasto elevato. +- pannello risultati scorrevole con controlli diagnostici e azioni sempre + separati e leggibili. ### Changed - timestamp MIDI preservati fino al clock della canzone; - catalogo incluso limitato a contenuti originali o esempi locali neutrali; - toolchain xUnit aggiornata per rimuovere dipendenze transitive vulnerabili; -- identità applicazione impostata su HitTheKit 0.5.0. +- identità applicazione impostata su HitTheKit 0.6.0. ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3986d7e..e53d978 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,9 @@ Thank you for helping make HitTheKit more reliable, accessible, and useful. Before participating, read the [Code of Conduct](CODE_OF_CONDUCT.md), [support guide](SUPPORT.md), and [project governance](GOVERNANCE.md). -HitTheKit is intended to remain available under both an open-source GPL license -and a possible separate commercial-license path. That model requires clear -copyright permissions for every substantial contribution. +HitTheKit is intended to remain available under both the open-source MPL-2.0 +Community license and a possible separate commercial-license path. That model +requires clear copyright permissions for every substantial contribution. ## Current contribution policy @@ -57,11 +57,32 @@ Approval to discuss an idea is not approval to contribute substantial code or assets. A merged change must still satisfy provenance, security, accessibility, and release requirements. +### Branch target + +Authorized feature, fix, documentation, and maintenance pull requests normally +target `develop`. The `main` branch represents the latest stable, releasable +source state and accepts only reviewed release-candidate or urgent hotfix pull +requests. Use the repository prefixes `feature/`, `fix/`, `chore/`, `docs/`, or +`hotfix/` for short-lived branches; do not create permanent `beta` or `stable` +branches. + +The complete development, beta, release-candidate, stable, and hotfix workflow +is documented in +[`docs/development/branching-and-releases.md`](docs/development/branching-and-releases.md). + A Contributor License Agreement, copyright assignment, or another contribution mechanism may be introduced after professional legal review. This document is not a CLA, does not transfer copyright, and does not create an implied assignment or commercial-license grant. +A Harmony-based copyright-license CLA is currently being evaluated in +[`docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md`](docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md), +with unresolved adoption gates recorded in +[`docs/legal/CLA_ADOPTION_CHECKLIST.md`](docs/legal/CLA_ADOPTION_CHECKLIST.md). +Both documents are non-operative drafts. Do not sign or rely on them. Opening a +pull request, issue, or discussion does not accept the draft, and no CLA +automation or electronic acceptance process is active. + Until a reviewed mechanism exists, maintainers should not merge a substantial external code or asset contribution merely because it was submitted under the repository's Community license. The required rights for the proposed diff --git a/FUNDING.md b/FUNDING.md index 3e36c52..b06ed7f 100644 --- a/FUNDING.md +++ b/FUNDING.md @@ -54,7 +54,7 @@ help without creating a recurring commitment: accessibility, and focused development time a meaningful one-time boost. Custom monthly and one-time support remains available through GitHub Sponsors. -Rewards are digital, low-maintenance, privacy-conscious and independent of GPL +Rewards are digital, low-maintenance, privacy-conscious and independent of MPL rights. Sponsorship does not purchase guaranteed features, roadmap control, private GoMyRO access, commercial-song rights or a commercial HitTheKit license. diff --git a/LICENSE b/LICENSE index f288702..d0a1fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,373 @@ - 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 -. +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/LICENSING.md b/LICENSING.md index efd440c..82f396b 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,66 +1,68 @@ # HitTheKit Licensing -HitTheKit is intended to be available under a dual-licensing model. - -This repository currently grants the Community license under version 3 of the -GNU General Public License. No project-specific "or any later version" grant -was found during the licensing audit, so the current grant is treated as -GPL-3.0-only. The complete, unmodified license text is in [`LICENSE`](LICENSE). +HitTheKit uses a dual-licensing model. ## Community license -The Community edition may be used, studied, modified, shared, and used -commercially under the GNU General Public License version 3, provided that the -applicable GPL obligations are respected. +Unless a file or directory says otherwise, original HitTheKit source code is +available under the [Mozilla Public License 2.0](LICENSE) (MPL-2.0). +Third-party components, Unity packages, generated engine material, trademarks, +and content retain their own terms; see +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), [NOTICE](NOTICE), and +[TRADEMARKS.md](TRADEMARKS.md). + +The MPL permits personal, educational, community, and commercial use. When +MPL-covered source files are modified and distributed, those covered files +must remain available under the MPL. A larger work may contain separate files +under other terms, provided the MPL requirements for covered files are met. +The full license text controls; this overview does not replace it. + +## Unity players and other executable distributions -In particular, conveying modified or combined covered works may require -providing corresponding source code and licensing the covered work under the -GPL. Consult the full GPL text for the actual terms; this overview does not -replace it. +MPL-2.0 permits covered HitTheKit code to be distributed in a larger executable +under different terms while preserving recipients' rights to the MPL-covered +source. This removes the former GPL whole-work copyleft concern for a player +that incorporates the proprietary Unity Runtime. + +It does not itself approve a particular binary release. Every macOS or Windows +candidate must still satisfy the applicable Unity terms, dependency notices, +asset and content rights, signing requirements, platform testing, and the +project release checklist. Unity and third-party technology are not relicensed +by HitTheKit. ## Commercial license -A separate commercial license may be available for organizations or -individuals that want to use HitTheKit under terms incompatible with the GPL, -including use within proprietary or closed-source products. Possible use cases -include: +A separate commercial license may be available to organizations or individuals +that need terms different from MPL-2.0, including: -- proprietary desktop or mobile applications; -- closed-source training platforms; -- commercial hardware integrations; -- bundled products; -- private-label or white-label distributions; and -- redistribution without GPL copyleft obligations for HitTheKit code. +- keeping modifications to MPL-covered HitTheKit files proprietary; +- proprietary hardware or software integrations; +- bundled, private-label, or white-label distribution; or +- separately negotiated support, warranty, or maintenance terms. -The commercial license is not automatically granted by this repository. -Commercial terms require a separate written agreement with a copyright holder -who has authority to license the relevant code. Such an agreement covers only -the rights that licensor controls; it does not replace licenses or commercial -terms for Unity or any other third-party component. +The commercial license is not automatically granted and is not required merely +because an activity generates revenue. It requires a separate written agreement +with a copyright holder authorized to license the relevant code. It cannot +replace Unity's terms or the licenses of third-party components. -## Personal, educational, and commercial use +## Existing GPL copies -Personal, educational, and community use is welcome under the GPL Community -license. No commercial license is required merely because an activity -generates revenue, provided that the use and any distribution comply with the -GPL. +Copies validly received under GPL-3.0-only before the MPL relicensing remain +available to their recipients under GPL-3.0-only. The MPL change does not revoke +rights already granted. A recipient must use the license offered for the exact +copy or version they received. ## Contributions -Contributions require separate governance rules so that the project can remain -eligible for dual licensing. See [`CONTRIBUTING.md`](CONTRIBUTING.md). +Future contribution terms must preserve a clear rights chain for both the MPL +Community edition and any separate commercial licensing. Until that mechanism +has been reviewed and adopted, follow [CONTRIBUTING.md](CONTRIBUTING.md). ## No trademark permission -The software license does not automatically grant permission to use the -HitTheKit name, logo, or branding to market a modified or competing product. -Trademark rights, if any, are separate from copyright licensing. See -[`TRADEMARKS.md`](TRADEMARKS.md). - -## Contact - -Commercial licensing inquiries: open a private inquiry with the copyright -holder. A dedicated contact address will be published later. +MPL-2.0 does not grant permission to use the HitTheKit name, logo, or branding +to market a modified or competing product. Truthful attribution remains +permitted. See [TRADEMARKS.md](TRADEMARKS.md). This document is a project licensing overview, not legal advice and not a -substitute for the full GPL text or a signed commercial agreement. +substitute for the MPL text or a signed commercial agreement. diff --git a/NOTICE b/NOTICE index 06d7692..dd79329 100644 --- a/NOTICE +++ b/NOTICE @@ -3,18 +3,18 @@ HitTheKit public source notice Copyright (c) 2026 HitTheKit contributors and the respective rightsholders identified in COPYRIGHT.md. -Original HitTheKit source code is made available under GPL-3.0-only. See -LICENSE. Third-party software and tools retain their own licenses and notices; -see THIRD_PARTY_NOTICES.md. +Unless stated otherwise, original HitTheKit source code is made available under +MPL-2.0. See LICENSE and LICENSING.md. Third-party software and tools retain +their own licenses and notices; see THIRD_PARTY_NOTICES.md. This source repository contains no commercial recordings, stems, MIDI files, lyrics, transcriptions, album artwork, or download links. Player-owned local content is outside the repository and remains the player's responsibility. -The initial public publication is source-and-website only. No Unity player -binary is approved for public distribution. Unity-runtime redistribution and -the proposed separate commercial-license path remain subject to qualified -legal review and are not granted by this notice. +No Unity player binary is approved merely by this notice. Each binary candidate +must independently pass the Unity terms, dependency, provenance, signing, +platform-testing, and release gates. The proposed separate commercial-license +path requires a written agreement and is not granted by this notice. HitTheKit is independent and is not sponsored, endorsed, certified by, or affiliated with Unity Technologies, Apple Inc., or referenced hardware diff --git a/README.md b/README.md index 3181fbc..766eb9a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@

- Version 0.5.0 pre-release - License GPL-3.0-only + Version 0.6.0 beta + License MPL-2.0 Unity 6 MIDI support macOS only Sponsor HitTheKit @@ -66,10 +66,10 @@ for HitTheKit. ## Project status > [!IMPORTANT] -> HitTheKit is an active pre-release project. This public repository distributes -> source code and the website only. No Unity player binary is currently approved -> for public distribution. Historical builds and private development history are -> intentionally excluded. +> HitTheKit is an active pre-release project. Signed and notarized Apple-silicon +> beta builds, when available, are published only through the official GitHub +> prereleases. They are tester-facing candidates rather than stable releases. +> Historical private builds and private development history remain excluded. | Capability | Current status | | --- | --- | @@ -77,7 +77,7 @@ for HitTheKit. | Electronic-drum MIDI | macOS Apple Silicon through CoreMIDI | | Learning path | 12 playable lessons; 12 more planned | | Included music | Original rights-clean demo only | -| Public binary | Not yet approved for distribution | +| Public binary | macOS Apple-silicon beta only; see GitHub Releases | Current limitations and release gates are tracked in [SUPPORT.md](SUPPORT.md) and the @@ -176,8 +176,9 @@ hardware result, improve documentation or pick a ## Licensing and content boundary Original project code is licensed under -[GPL-3.0-only](LICENSE). A possible separate commercial-license path is under -preparation and remains subject to legal review; see [LICENSING.md](LICENSING.md). +[MPL-2.0](LICENSE). A separate commercial-license path remains available only +through a written agreement; see [LICENSING.md](LICENSING.md). Existing copies +previously received under GPL-3.0-only retain those rights. The public repository does not include commercial songs, recordings, charts, lyrics or artwork. Third-party names describe compatibility only. HitTheKit is diff --git a/ROADMAP.md b/ROADMAP.md index 81e9dfb..c142fa0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,8 +15,8 @@ documented, and represented honestly in the release notes. reduced motion; - publish screenshots, checksums, known limitations, and reproducible release notes from the exact candidate commit; and -- complete professional review of GPL, Unity distribution, contribution, and - commercial-licensing terms. +- validate the MPL-based Unity distribution, contribution, and + commercial-licensing terms for the exact release candidate. ## Next: broader compatibility and confidence diff --git a/SUPPORT.md b/SUPPORT.md index 018d9b0..f197ba9 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -29,7 +29,7 @@ proposals so that actionable reports retain the information needed for review. ## Supported versions -Only the current `0.5.x` pre-release line receives best-effort fixes. Historical +Only the current `0.6.x` pre-release line receives best-effort fixes. Historical playtest tags and binaries are unsupported and must not be redistributed as current releases. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6f99ee6..f47116a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,7 +8,7 @@ provenance is recorded separately in | Component | Version | Declared license or terms | Role | Redistributed by this repository? | Review status / action | | --- | --- | --- | --- | --- | --- | -| Unity Editor and built-in Unity modules | Editor 6000.5.6f1; modules 1.0.0 | Unity Terms of Service and Unity Editor Software Terms | Authoring, compilation, runtime APIs | Editor and module source/binaries are not tracked; a future player build may contain Unity runtime components | **Legal review required:** confirm current plan eligibility, player redistribution, GPL interaction, and separate Unity permission for embedded systems. | +| Unity Editor and built-in Unity modules | Editor 6000.5.6f1; modules 1.0.0 | Unity Terms of Service and Unity Editor Software Terms | Authoring, compilation, runtime APIs | Editor and module source/binaries are not tracked; a future player build may contain Unity runtime components | Confirm current plan eligibility, player redistribution terms, required notices, and separate Unity permission for embedded systems for every candidate. MPL-2.0 does not relicense Unity. | | Unity Test Framework | 1.7.0 | Unity Companion License v1.4 | EditMode and PlayMode testing | Package source is resolved by Unity Package Manager, not tracked or intended for player distribution | Preserve the package license and copyright notice if substantial package portions are redistributed. | | Unity Custom NUnit (`com.unity.ext.nunit`) | 2.1.0, based on NUnit 3.5 | Unity Package Distribution License v2.1; bundled NUnit portions under MIT | Transitive Unity test dependency | Resolved by Unity Package Manager, not tracked or intended for player distribution | Preserve Unity package terms and the bundled NUnit MIT notice when applicable. | | .NET SDK | 8.0.411 requested by `global.json` | MIT plus the distribution's third-party notices | Build tooling | No | Build tool only; follow the license and notices of the installed distribution. | @@ -21,7 +21,7 @@ provenance is recorded separately in ## Verified sources -- GNU GPL version 3: +- Mozilla Public License 2.0: - Unity terms: - Unity Companion License: - Unity Package Distribution License: diff --git a/TRADEMARKS.md b/TRADEMARKS.md index de7ab9a..84cc21f 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -1,10 +1,10 @@ # HitTheKit Name and Branding -The GPL license for HitTheKit source code does not itself grant trademark +The MPL-2.0 license for HitTheKit source code does not itself grant trademark rights in the HitTheKit name, logo, or other project branding. No claim that a mark is registered is made here. -The GPL permits recipients to copy, modify, and distribute covered code under +MPL-2.0 permits recipients to copy, modify, and distribute covered code under its terms. A fork may identify the code it came from in a truthful, descriptive way, but should not imply sponsorship, endorsement, certification, or official status that has not been granted. @@ -14,7 +14,7 @@ brand for a modified, competing, private-label, or commercial product. Any separate branding permission should be documented in writing. This guidance is provisional and requires legal review. It does not restrict -the copyright permissions granted by the GPL. +the copyright permissions granted by MPL-2.0. ## Third-party names diff --git a/VERSION b/VERSION index 8f0916f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/docs/README.md b/docs/README.md index 5fc5e9d..694819f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,8 +41,16 @@ without creating an unreviewed second source of truth. ## Development and testing +- [Branching and release channels](development/branching-and-releases.md) - [DSP song clock](development/dsp-song-clock.md) - [Chart timeline](development/chart-timeline.md) +- [Practice Lab](development/practice-lab.md) +- [Chart Creator foundation](development/chart-creator.md) +- [Performance error map](development/performance-error-map.md) +- [Auto Tempo Coach](development/auto-tempo-coach.md) +- [Audio and latency sound check](development/audio-latency-sound-check.md) +- [Ghost Replay](development/ghost-replay.md) +- [Accessible reactive stage](development/reactive-stage.md) - [Demo-song vertical slice](development/demo-song-vertical-slice.md) - [Keyboard hit matching](development/keyboard-hit-matching.md) - [Pad visuals](development/pad-visuals.md) @@ -68,8 +76,11 @@ without creating an unreviewed second source of truth. ## Rights and governance - [Asset provenance](legal/ASSET_PROVENANCE.md) +- [Contributor License Agreement draft](legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md) +- [CLA adoption checklist](legal/CLA_ADOPTION_CHECKLIST.md) - [Dual-licensing readiness report](legal/dual-licensing-readiness-report.md) -- [Dual-licensing decision record](governance/dual-licensing-decision.md) +- [MPL Community license decision](governance/mpl-community-license-decision.md) +- [Historical GPL dual-licensing proposal](governance/dual-licensing-decision.md) - [Commercial-license draft](legal/COMMERCIAL-LICENSE-DRAFT.md) - [Project governance](../GOVERNANCE.md) - [License](../LICENSE) diff --git a/docs/development/audio-latency-sound-check.md b/docs/development/audio-latency-sound-check.md new file mode 100644 index 0000000..0cce5a0 --- /dev/null +++ b/docs/development/audio-latency-sound-check.md @@ -0,0 +1,25 @@ +# Audio and latency sound check + +The guided sound check lives in **Settings → Input calibration** and reuses the +same local timing-offset preferences consumed by gameplay. + +## Flow + +1. **Test audio** plays an original generated click through Unity's active + output device. HitTheKit does not choose the hardware output; macOS or the + drum module must route it to the desired headphones/speakers. +2. Select **Keyboard** or **MIDI**. The two recommendations never share samples. +3. Start the check, listen to four count-in clicks, then strike with the next + twelve clicks. +4. At least eight matched strikes are required. The existing + `TimingCalibrationAdvisor` calculates a median and rejects out-of-window + hits. Applying the recommendation updates only the selected input offset. + +The generated track is scheduled against Unity DSP time. Target timestamps use +the corresponding unscaled-time origin, so the measurement does not depend on +frame-by-frame accumulated time. All samples remain in memory and local to the +running app; there is no telemetry, upload, or raw-hit persistence. + +This is a guided user calibration, not laboratory round-trip latency +measurement: human timing variation is reduced by the median and the minimum +sample gate, not claimed to be eliminated. diff --git a/docs/development/auto-tempo-coach.md b/docs/development/auto-tempo-coach.md new file mode 100644 index 0000000..2114204 --- /dev/null +++ b/docs/development/auto-tempo-coach.md @@ -0,0 +1,28 @@ +# Auto Tempo Coach + +Auto Tempo Coach turns a completed lesson or Song Library attempt into a safe +next-speed recommendation. It uses the existing score and hit-matching results; +it does not introduce a second scoring model. + +## Progression + +Song Library sessions follow the already supported song speeds. Lessons follow +the existing study progression (`0.5x`, `0.75x`, `1.0x`). A step unlocks when: + +- accuracy is at least 85%; +- misses are no more than 10% of chart notes; +- unmatched hits are no more than 10% of chart notes. + +Below those guardrails the coach asks the player to repeat the current speed. +At the final supported speed it reports the target as mastered. + +## Applying a recommendation + +The results screen displays the recommendation. The player explicitly confirms +the next speed; the gameplay scene then reloads the same lesson or song with a +new immutable `GameplaySessionDefinition`. Original BPM is recovered from the +current effective BPM and multiplier, then chart timing, audio pitch, count-in +and display metadata are rebuilt together. + +This avoids changing playback speed halfway through a scored attempt and keeps +the DSP clock, chart timeline and external audio on one timing contract. diff --git a/docs/development/branching-and-releases.md b/docs/development/branching-and-releases.md new file mode 100644 index 0000000..5352d21 --- /dev/null +++ b/docs/development/branching-and-releases.md @@ -0,0 +1,96 @@ +# Branching and release channels + +HitTheKit separates active development from stable public source without +keeping duplicate permanent `beta` or `stable` branches. + +## Permanent branches + +| Branch | Meaning | Accepted changes | +| --- | --- | --- | +| `main` | Latest stable, releasable source state | Reviewed release candidates and urgent hotfixes | +| `develop` | Integration state for the next version | Reviewed feature, fix, documentation, and maintenance pull requests | + +Both branches are protected. Pull requests, required CI and CodeQL checks, +linear history, and resolved review conversations are required. Force pushes +and deletion are disabled. The single-maintainer project currently requires +zero external approvals so that protection does not make maintenance +impossible; review evidence and passing gates are still required by the release +process. + +The repository default branch remains `main`. It is the public landing point +and must not expose partially integrated work. + +## Short-lived branches + +Start normal work from the latest `develop` and target `develop`: + +- `feature/` for product or engineering capabilities; +- `fix/` for defects; +- `chore/` for maintenance and automation; and +- `docs/` for documentation-only changes. + +Delete these branches after merge. Keep each pull request focused and do not +mix unrelated changes. + +## Beta and release candidate + +When `develop` contains a coherent candidate, create +`release/v..` from its exact reviewed commit. Freeze feature +work on that branch: only release blockers, evidence, versioning, and release +documentation may change. + +Preview milestones are immutable tags and GitHub prereleases, not permanent +branches: + +- `vX.Y.Z-alpha.N` for an early integration snapshot; +- `vX.Y.Z-beta.N` for a tester-facing candidate; and +- `vX.Y.Z-rc.N` for a candidate expected to become stable if its gates pass. + +Source-only prereleases must use the attested source-snapshot workflow and must +not attach Unity player binaries. A prerelease label communicates maturity; it +does not waive content, provenance, security, or release checks. + +## Stable release + +After the release checklist passes: + +1. open a pull request from `release/vX.Y.Z` to `main`; +2. verify the exact head commit and all required checks; +3. merge with the repository's linear-history policy; +4. create the stable `vX.Y.Z` tag from the resulting `main` commit; +5. publish the approved source-only release and its checksum/attestation; and +6. merge or replay any release-only fixes into `develop` before deleting the + release branch. + +A stable tag is never moved or reused. If a published version needs a fix, +release a new patch version. + +## Hotfix + +Create `hotfix/vX.Y.Z` from `main` only for a defect that cannot wait for the +next normal release. Validate it through a pull request to `main`, create the +new patch tag, then merge or replay the same fix into `develop`. Never force +push a protected branch or silently leave the two permanent branches with +different fixes. + +## Flow summary + +```text +feature/* fix/* chore/* docs/* + └── pull request ──> develop + │ + ├── optional alpha tag + │ + └──> release/vX.Y.Z + ├── beta / rc tags + └── pull request ──> main + └── stable vX.Y.Z + +main ──> hotfix/vX.Y.Z ── pull request ──> main + └── replay the fix ──> develop +``` + +GitHub Pages continues to deploy only from `main`. CI and CodeQL run for pull +requests and after merges to both permanent branches. Unity CI remains governed +by the separate license-aware gate described in +[`unity-test-gate.md`](unity-test-gate.md). diff --git a/docs/development/chart-creator.md b/docs/development/chart-creator.md new file mode 100644 index 0000000..07753a0 --- /dev/null +++ b/docs/development/chart-creator.md @@ -0,0 +1,101 @@ +# Chart Creator foundation + +Chart Creator turns a performance from the existing keyboard/CoreMIDI gameplay +input into a reviewable schema-v1 chart draft. It deliberately reuses the real +gameplay scene rather than maintaining a second audio clock, input mapper, or +timeline. + +## Workflow + +1. Select **Import audio & create** in the Song Library and choose a local WAV + or OGG file, or select an existing playable song. +2. For new audio, enter title, artist, verified BPM, bar count and beats per bar. + HitTheKit copies the selected audio into a private local authoring folder; it + never modifies the source file. +3. Choose practice speed and select **Record chart**. A new audio-only source + starts with a real empty schema-v1 timeline; an existing song keeps its + current chart only as a playback reference. +4. Play during the normal count-in and backing track. Hits before song time zero + or beyond the declared song duration are ignored. +5. At the result screen, review the captured notes against the bounded waveform. + Drag its playhead to scrub the source timeline, zoom around a passage, and + preview audio from the selected time. Select a note to change its time, drum + pad, velocity or physical articulation, add missing notes, or delete unwanted + notes. The original recorded take remains unchanged in memory. +6. Save the edited timing as recorded, or quantize the edited draft + non-destructively to an eighth- or sixteenth-note grid. + +Keyboard and MIDI events reach the recorder through `HitMatchingPrototype`'s +`InputProcessed` boundary. Consequently, the existing per-source timing offset +is applied before recording. A take made at a reduced practice speed is scaled +back to the source song's original timeline before it is serialized. + +## Output and rights boundary + +The exporter creates a new, never-overwritten folder under +`~/Documents/HTKSongs` containing only: + +- `song.json`; +- `notes.json`. + +It also creates one portable `.htksong` file alongside the folder. +The package is a ZIP-compatible, chart-only container with exactly three +entries: + +- `htksong-version` (`1`); +- `song.json`; +- `notes.json`. + +To transfer a take, copy only the `.htksong` file into +`Documents/HTKSongs` on the other computer and refresh the Song Library. The +game validates and atomically imports it. Existing song folders are never +overwritten. Select the imported entry, choose **Bind local audio**, select +your own authorized WAV/OGG copy, and confirm the local-only binding. The game +copies that audio into the imported song folder and makes the entry playable; +the source file and portable package are not modified. + +The publish is atomic and both documents are parsed by the production loaders +before the folder or package becomes visible. When the recording used a local +WAV/OGG source, the private exported folder receives its own local audio copy and +is immediately playable. The `.htksong` manifest still declares audio as +`missing`: Chart Creator never embeds, downloads, or redistributes source audio +in the portable package. A recipient supplies their own authorized local copy. + +Import is fail-closed. Unknown/archive entries, audio declarations, symbolic +links, duplicate names, unsupported versions, malformed JSON, invalid charts, +oversized data and path traversal are rejected before extraction. Version 1 is +intentionally chart-only; adding optional distributable audio requires a future +explicit package version and rights-aware UX. + +The exported title is marked `Recorded Take` and the difficulty hint says that +the performance must be reviewed before sharing. This is a captured performance, +not a claim of authoritative transcription. + +## Note expression + +Schema v1 remains backward compatible: `velocity` and `articulation` are +optional note properties. A legacy note without them keeps unknown velocity and +the `default` articulation wildcard. Newly recorded/exported notes preserve +velocity; non-default articulations use explicit identifiers such as `rim`, +`bell`, `bow`, `edge`, `open`, or `pedal`. + +Known articulations are validated against the selected pad. An explicit Ride +Bell target, for example, matches only a bell hit and highlights the bell zone +of the instructional kit. Changing a note to an incompatible pad resets its +articulation to the backward-compatible default rather than inventing a zone. + +The waveform is an editor envelope, not a second clock or audio importer. It is +sampled from the already-loaded `AudioClip` into a bounded 512-point model, and +preview playback reuses the existing `DspSongClockPrototype` audio source. The +chart timing remains source-time based and the recorded take remains immutable. + +## Current foundation limits + +- The waveform provides envelope scrubbing, zoom and preview; it is not yet a + sample-accurate destructive audio editor. +- The native picker currently targets macOS. WAV and OGG are supported; MP3 is + intentionally rejected by the production loader. +- The author must enter BPM, bars and meter explicitly. Unknown timing never + receives a hidden default. +- Portable packages remain chart-only; the receiving computer must explicitly + bind an authorized local WAV/OGG copy before the imported chart is playable. diff --git a/docs/development/ghost-replay.md b/docs/development/ghost-replay.md new file mode 100644 index 0000000..0f7ec8f --- /dev/null +++ b/docs/development/ghost-replay.md @@ -0,0 +1,21 @@ +# Ghost Replay + +Ghost Replay is a local visual comparison against the immediately preceding +take. + +After a run, **Riprova con Ghost** freezes the player's evaluated input times +and restarts the same session. The previous hits appear as white outlined +cross-markers on the existing DSP-driven highway. Their position is derived +from absolute song time, exactly like chart notes. + +The ghost is deliberately isolated from gameplay rules: + +- it never enters `HitMatchingSession`; +- it never changes score, combo, accuracy, misses, or audio; +- count-in hits are ignored; +- a take is bounded to 8,192 hits; +- it is kept in memory only and is discarded with the gameplay scene. + +Normal restart discards the unfinished current take while preserving an +already committed ghost. This keeps pause/restart deterministic and makes the +comparison an explicit player choice. diff --git a/docs/development/macos-playtest-package.md b/docs/development/macos-playtest-package.md index 01c0588..843985d 100644 --- a/docs/development/macos-playtest-package.md +++ b/docs/development/macos-playtest-package.md @@ -14,7 +14,9 @@ Build and package it with Unity `6000.5.6f1`: The script builds the native plug-in, synchronizes Core, invokes the checked-in Unity build method, embeds the arm64 plug-in, applies an ad-hoc signature, verifies the bundle, and creates a resource-fork-safe ZIP under -`artifacts/game/`. +`artifacts/game/`. The application and ZIP include `Legal/` with the MPL text, +project and third-party notices, and `SOURCE-CODE.txt`, which identifies the +exact public Git revision corresponding to the build. This playtest packaging is intentionally not a production distribution flow. Unity gives the local build an ad-hoc signature, but downloading it on another diff --git a/docs/development/macos-signing-notarization.md b/docs/development/macos-signing-notarization.md index a737dff..067522b 100644 --- a/docs/development/macos-signing-notarization.md +++ b/docs/development/macos-signing-notarization.md @@ -72,7 +72,10 @@ is the simpler and safer release procedure. `build-macos-distribution-app.sh` builds the native arm64 CoreMIDI plug-in, synchronizes the managed Core assembly, and asks Unity to create a non- Development macOS player. The bundle identifier remains -`com.codewriter90x.hitthekit`. +`com.codewriter90x.hitthekit`. Before signing, it installs the MPL license, +project notice, third-party notices, licensing overview, and an exact-revision +`SOURCE-CODE.txt` under `Contents/Resources/Legal`. The final DMG exposes the +same `Legal/` directory at its top level. `sign-macos-app.sh` signs a staged copy and replaces the input only after the staged bundle passes strict verification. It signs every nested Mach-O first, diff --git a/docs/development/performance-error-map.md b/docs/development/performance-error-map.md new file mode 100644 index 0000000..c1f6695 --- /dev/null +++ b/docs/development/performance-error-map.md @@ -0,0 +1,30 @@ +# Performance error map + +The results screen groups real `HitResult` values by four-bar Practice Lab +section and drum piece. It highlights where the player struggled, rather than +showing only a whole-song average. + +## Analysis + +`PracticeErrorMapAnalyzer` lives in Core and consumes the same resolved notes +used by scoring. Each section/piece cell records Perfect, Good, Early, Late and +Miss counts. Its displayed accuracy uses the existing gameplay weights: + +- Perfect: 100%; +- Good: 75%; +- Early or Late: 50%; +- Miss: 0%. + +The map is deterministic and its section boundaries are start-inclusive and +end-exclusive. Results outside the declared song sections are ignored rather +than assigned to a misleading bucket. + +## Targeted practice + +The results screen lists the four weakest cells and exposes **Train weakest +area**. That action selects the corresponding Practice Lab range, filters the +existing matcher and seeks the existing DSP/audio transport with the normal +two-beat preparation window. It does not build a second chart or matcher. + +Error-map state is per attempt and in-memory. Long-term history is deliberately +left to a later schema change in the existing progress persistence service. diff --git a/docs/development/practice-lab.md b/docs/development/practice-lab.md new file mode 100644 index 0000000..731ccdb --- /dev/null +++ b/docs/development/practice-lab.md @@ -0,0 +1,37 @@ +# Practice Lab + +Practice Lab adds repeatable song sections and manual A–B loops to the existing +gameplay scene. It deliberately reuses the production DSP clock, chart timeline, +hit matcher, score tracker and audio sources. + +## Player flow + +Pause a song with `Esc` or `P`, then use the Practice Lab panel: + +- choose the previous or next four-bar section and select **Loop section**; +- select **Set A**, resume and pause later, then select **Set B** for a custom + range; +- select **Whole song** to leave practice mode and restart normally. + +Every repetition includes a two-beat preparation window before point A when the +range does not start at the beginning. Only notes inside `[A, B)` are sent to the +matcher. Score, combo and timing analysis restart for each pass, while recorded +practice time remains cumulative. + +## Timing contract + +`DspSongClock.Seek` re-anchors absolute song position to the current DSP time. +`DspSongClockPrototype.SeekPlayback` moves the audio playhead to the equivalent +clip position, including playback-speed conversion. The highway continues to +derive marker position from absolute chart time, so repeated loops do not +accumulate frame-based drift. + +Automatic sections are derived from verified session timing in groups of four +bars. The final section may contain fewer bars. A selected section is clamped to +the real audio duration before it can become active. + +## Boundaries + +This foundation does not persist loop selections and does not add a second +timeline, scoring engine or audio transport. Named musical sections can be added +later as optional chart metadata without changing the loop transport contract. diff --git a/docs/development/reactive-stage.md b/docs/development/reactive-stage.md new file mode 100644 index 0000000..066e5a0 --- /dev/null +++ b/docs/development/reactive-stage.md @@ -0,0 +1,21 @@ +# Accessible reactive stage + +The gameplay stage reacts to real runtime signals already produced by the hit matcher and score tracker. It does not own timing, scoring, input, or song state. + +## Signals + +- Combo selects a bounded energy band: calm, building, live, or peak. +- The latest hit grade provides a short accent. +- The latest drum pad selects the accent color already used by that lane. +- Misses and unmatched inputs enter a visible recovery state. + +The stage uses text and geometric patterns as well as color. A miss therefore remains distinguishable in high-contrast and color-impaired viewing conditions. + +## Motion and intensity safety + +- Full visual intensity is capped at `0.78`. +- `Reduce motion` replaces moving patterns with a steady composition and caps intensity at `0.22`. +- Pulses last at least `0.18` seconds and the stage never alternates the full screen on/off. +- `High contrast` increases outlines and preserves the textual stage state. + +All visuals are procedural UI Toolkit drawing. No commercial assets, per-frame material creation, telemetry, or additional gameplay systems are introduced. diff --git a/docs/development/song-library.md b/docs/development/song-library.md index 10485f6..81cbfa1 100644 --- a/docs/development/song-library.md +++ b/docs/development/song-library.md @@ -94,3 +94,39 @@ machine-specific path into bundled `song.json`. The refresh button performs an explicit rescan. Discovery does not watch the filesystem continuously, allocate every frame, or access folders outside the two configured roots. + +## Local audio authoring + +The Song Library's **Import audio & create** action opens the macOS file picker +for WAV/OGG content. After the player supplies title, artist and verified timing +metadata, the importer copies the selected file into a new, never-overwritten +folder under `~/Documents/HTKSongs`. The source file is not changed and its +absolute path is not persisted in `song.json`. + +The resulting entry is intentionally audio-only: it is not playable as a normal +song, but it can start Chart Creator with the existing DSP clock and an empty +timeline. Once a take is saved, the local folder contains authorized audio plus +the new chart and is playable immediately. Its sibling `.htksong` remains +chart-only for safe transfer. + +## Portable `.htksong` chart packages + +Chart Creator writes a portable chart-only package next to each recorded take. +Copy a `.htksong` file directly into `~/Documents/HTKSongs` and select +**Refresh library**. Before normal folder discovery, the game validates the +container and atomically installs its `song.json` and `notes.json` into a folder +named after the validated song ID. The package remains in place so it can be +copied to another computer; subsequent refreshes are idempotent. + +Package schema version 1 contains no audio and rejects any audio declaration or +extra archive entry. Imports are bounded to 5 MiB, reject links, duplicate or +case-colliding names, malformed ZIP/JSON/chart data, unsupported versions and +path traversal, and never replace an existing song folder. + +An imported chart is therefore visible but unavailable until the player +selects **Bind local audio** and supplies a WAV/OGG file they are entitled to +use. The confirmation panel identifies the selected song and local filename; +on confirmation the game copies the audio into that song's direct user-library +folder, atomically updates its manifest, and refreshes the entry as playable. +The source audio and `.htksong` package are never modified, and no absolute +machine path is persisted. diff --git a/docs/development/windows-playtest-package.md b/docs/development/windows-playtest-package.md index 4b938d5..594b257 100644 --- a/docs/development/windows-playtest-package.md +++ b/docs/development/windows-playtest-package.md @@ -20,7 +20,9 @@ Build and package with: The script synchronizes the tested Core assembly, asks Unity for a clean `StandaloneWindows64` player, rejects a package containing the CoreMIDI native -plug-in, adds player instructions, creates a ZIP, and prints its SHA-256. +plug-in, adds player instructions and a `Legal/` directory, creates a ZIP, and +prints its SHA-256. `Legal/SOURCE-CODE.txt` identifies the exact public Git +revision for the MPL-covered HitTheKit files in that executable distribution. Generated output remains under the ignored `artifacts/game` directory. The Windows player contains `HitTheKit.exe`, `HitTheKit_Data`, Unity runtime @@ -41,6 +43,13 @@ test the exact ZIP on clean Windows 10 and Windows 11 machines: 6. confirm Device Setup states clearly that production MIDI is macOS-only; and 7. collect logs and screenshots without personal paths or third-party content. +## MIDI support + +Installing Unity Windows Build Support enables player compilation only. It +does not provide production MIDI input. Windows electronic-drum support needs +a separately implemented and tested Windows MIDI backend; until then the +Windows player is keyboard-only. + ## Signing and public distribution The current script intentionally creates an **unsigned playtest**. It is not a diff --git a/docs/governance/dual-licensing-decision.md b/docs/governance/dual-licensing-decision.md index 20c77b0..1f52d0b 100644 --- a/docs/governance/dual-licensing-decision.md +++ b/docs/governance/dual-licensing-decision.md @@ -1,9 +1,14 @@ # Dual-Licensing Decision Record -Status: `PROPOSED_PENDING_LEGAL_REVIEW` +Status: `SUPERSEDED_BY_MPL_COMMUNITY_LICENSE_DECISION` Date: 2026-08-05 +> Historical record. The project subsequently adopted MPL-2.0 as its Community +> license. See +> [MPL Community License Decision](mpl-community-license-decision.md). Existing +> GPL copies retain the rights already granted to their recipients. + ## Decision HitTheKit will preserve its Community edition under GNU General Public License diff --git a/docs/governance/mpl-community-license-decision.md b/docs/governance/mpl-community-license-decision.md new file mode 100644 index 0000000..65d5956 --- /dev/null +++ b/docs/governance/mpl-community-license-decision.md @@ -0,0 +1,93 @@ +# MPL Community License Decision + +Status: `ADOPTED` + +Decision date: 2026-08-19 + +## Decision + +HitTheKit changes the Community license for original project source code from +`GPL-3.0-only` to the Mozilla Public License 2.0 (`MPL-2.0`) beginning with +the repository version that merges this decision. + +The project retains a possible separate commercial-license path for recipients +who need terms different from MPL-2.0. A commercial license is not granted by +this repository and requires a separate written agreement. + +## Scope + +Unless a file or directory declares different terms, MPL-2.0 applies to +original HitTheKit source code distributed with the effective repository +version. It does not relicense: + +- Unity Editor, Unity Runtime, Unity packages, or Unity-generated technology; +- third-party dependencies or their bundled notices; +- player-owned songs, recordings, charts, MIDI data, or other local content; +- third-party trademarks or compatibility names; or +- HitTheKit branding and trademark rights. + +The exact candidate dependency and asset inventories still control binary +release readiness. + +## Reason + +MPL-2.0 preserves source-level reciprocity for modified HitTheKit files while +allowing those files to be combined in a larger work with separately licensed +software. That model fits a Unity application better than whole-work GPL +copyleft: the proprietary Unity Runtime can retain its own terms while covered +HitTheKit source remains available under MPL-2.0. + +The change removes the identified GPL whole-work compatibility concern; it does +not alone approve macOS or Windows binaries. Signing, notarization, platform +testing, dependency notices, content provenance, Unity plan eligibility, and +the applicable Unity distribution terms remain independent release gates. + +## Existing GPL copies + +Licenses already granted are not revoked. Anyone who validly received an older +HitTheKit copy under GPL-3.0-only may continue to exercise those GPL rights for +that copy. The maintainer does not represent that downstream GPL copies have +been converted to MPL. + +## Commercial option + +Commercial use is allowed by MPL-2.0 and does not by itself require payment. +The separate commercial option is intended for needs such as: + +- proprietary modifications to MPL-covered files; +- private-label or white-label distribution; +- proprietary hardware and platform integrations; or +- separately negotiated warranty, support, or maintenance. + +Prices, warranties, support, territory, governing law, and transaction terms +remain to be agreed in writing. HitTheKit can license only rights controlled by +the relevant copyright holder. + +## Contributions + +MPL-2.0 does not automatically give the maintainer permission to offer another +contributor's work under a proprietary commercial license. Substantial external +contributions remain paused until a reviewed CLA, assignment, dual-license +grant, or equivalent mechanism is adopted. + +The repository history identifies the maintainer through the `Baron Luca` and +`Codewriter90x` aliases, plus automated dependency updates. By merging this +decision, the maintainer represents that they are authorized to offer the +original project material in the effective repository version under MPL-2.0. +That representation is not a professional chain-of-title opinion. + +## Implementation + +- replace the root `LICENSE` with the unmodified official MPL-2.0 text; +- identify the Community grant as MPL-2.0 in README, NOTICE, website, wiki, UI, + packaging, release, contribution, and governance material; +- keep third-party licenses and trademark guidance separate; +- retain the earlier GPL audit and decision as explicitly superseded historical + records; and +- validate every future executable from its exact source and dependency set. + +## Review boundary + +This record is an engineering and project-governance decision, not legal advice +or a guarantee that a particular distribution satisfies every applicable +contract or law. diff --git a/docs/legal/ASSET_PROVENANCE.sha256 b/docs/legal/ASSET_PROVENANCE.sha256 index c219137..d88912f 100644 --- a/docs/legal/ASSET_PROVENANCE.sha256 +++ b/docs/legal/ASSET_PROVENANCE.sha256 @@ -36,6 +36,7 @@ aeb85dcd4d2146f541a20b059d0202b4b74e26d36bb0c4d6a69e3216b53d32b3 branding/app-i 33d46381305d6bfc6fd2e46756f60161d72c3dce538ad91159cb535867d8621a branding/app-icon/web/favicon-48.png dd14c102c960582dce3a4a2f666acbd5fb6534aba34f065ce48ba176734e300f branding/app-icon/web/pwa-icon-192.png 09f9ef07c0a1e723f81ee00a8116eb2750d8d3138e679ca73f49e4d7e8652c73 branding/app-icon/web/pwa-icon-512.png +dc7b225e09748d09fcf02c46b71f575d3d42547b4e49143da01a7710703f0ee3 branding/app-icon/windows/HitTheKit.ico 8b02be0ccd2c4565006246b3f8ce576367bed735bc122716c28d5120c53afd70 docs/design/evidence/gameplay-environments-v2-comparison.jpg 1503f47d91d43f2e7ea3484036329c839c338d757d6a7051e54ce97c9bd70512 docs/design/references/main-menu-stage-command-concept.png 0a150d383929d087670d0aefe28964e3786875964f88901fe8053a96e8895321 docs/design/references/modern-drum-kit-hd-concept.png diff --git a/docs/legal/CLA_ADOPTION_CHECKLIST.md b/docs/legal/CLA_ADOPTION_CHECKLIST.md new file mode 100644 index 0000000..6b43274 --- /dev/null +++ b/docs/legal/CLA_ADOPTION_CHECKLIST.md @@ -0,0 +1,226 @@ +# HitTheKit CLA adoption checklist + +> **DRAFT PROCESS — NOT IN FORCE — NOT LEGALLY REVIEWED** +> +> This checklist does not activate the accompanying CLA draft, request a +> signature, or authorize substantial external contributions. + +## Selected planning model + +The current planning candidate is the Harmony Combined Contributor Agreement +Template 1.0 configured as: + +- copyright license, not copyright assignment; +- contributor retains copyright; +- broad sublicensable copyright grant; +- patent grant; +- outbound licensing based on Harmony Option Five; and +- separate treatment of individual and Legal Entity contributors still to be + decided. + +Option Five is being evaluated because it permits open-source and commercial +or proprietary outbound licenses while promising that an incorporated +Contribution is also available under the community license used for the +Material. Its breadth is not yet approved. + +## Blocking legal and identity decisions + +- [ ] Obtain review from a qualified lawyer, FOSS legal clinic, or equivalent + professional familiar with contributor agreements and dual licensing. +- [ ] Verify the legal identity and capacity of the party receiving the grant. +- [ ] Decide whether the recipient is an individual, company, foundation, or + another legal structure. +- [ ] Record the recipient's legal address and a private legal contact. +- [ ] Select governing law, forum, and dispute process with qualified advice. +- [ ] Verify chain of title and authority for the existing HitTheKit code. +- [ ] Confirm the intended MPL-2.0 transition separately; the CLA must not be + used to imply that a repository license change has already occurred. +- [ ] Review the scope of “transferable,” “irrevocable,” “otherwise exploit,” + multi-tier sublicensing, and future commercial/proprietary licensing. +- [ ] Review patent scope, Affiliate coverage, subsequently acquired claims, + combination claims, limitations, and termination/retaliation. +- [ ] Decide whether and how to address moral rights without an unnecessarily + broad waiver. +- [ ] Draft jurisdiction-appropriate warranty, disclaimer, liability, + severability, assignment, notices, and entire-agreement terms. + +## Contributor types and authority + +- [ ] Decide whether to publish separate Individual and Entity agreements. +- [ ] Define when an individual contributor needs employer consent. +- [ ] Define acceptable evidence of employer consent. +- [ ] Define the Entity signatory's required authority and title. +- [ ] Decide whether entities maintain a list of authorized contributors. +- [ ] Define how an entity adds or removes authorized contributors. +- [ ] Decide how contractors, students, public employees, and grant-funded + contributors are handled. +- [ ] Decide whether contributions from minors will be accepted. +- [ ] If minors may contribute, obtain qualified advice on age, guardian + consent, identity verification, privacy, and revocation. + +## Contributions and third-party material + +- [ ] Define the channels that count as intentionally “Submitted.” +- [ ] Define a clear “Not a Contribution” mechanism. +- [ ] Keep issues, discussions, hardware observations, ideas, and facts outside + contractual acceptance unless qualified review says otherwise. +- [ ] Define a non-owner/mixed-submission review process—or continue to reject + all mixed submissions. +- [ ] Explicitly reject unapproved third-party code, music, recordings, + performances, charts, MIDI captures, artwork, fonts, trademarks, + confidential information, and personal data. +- [ ] Require provenance and license evidence for any exceptional third-party + material accepted through a separate process. +- [ ] Confirm that a CLA grants only rights the contributor controls and never + substitutes for third-party licenses. + +## Existing and future contributions + +- [ ] Inventory any substantial external contribution made before activation. +- [ ] Decide whether prior contributors must separately opt in. +- [ ] Do not presume retroactive acceptance based on an old pull request, + commit, discussion, or repository license. +- [ ] Define the effective date for each contributor and Contribution. +- [ ] Define treatment when the community license changes after acceptance. +- [ ] Define the procedure if the receiving legal entity changes. +- [ ] Define a transparent amendment process and when re-acceptance is needed. + +## Privacy, signatures, and records + +- [ ] Publish a privacy notice before collecting acceptances. +- [ ] Identify the data controller, purposes, lawful basis, processors, + transfers, and contact. +- [ ] Decide which identity, name, email, employer, timestamp, IP address, and + other evidence is actually necessary; minimize collection. +- [ ] Define retention periods rather than retaining personal data by default. +- [ ] Define access, correction, objection, deletion, export, and complaint + handling as applicable. +- [ ] Record the exact CLA version and immutable digest accepted. +- [ ] Preserve an auditable timestamp and authenticated contributor identity. +- [ ] Design the electronic signature/acceptance action with qualified review. +- [ ] Define recovery, backup, access control, incident response, and processor + exit procedures for acceptance records. + +## CLA Assistant and repository controls + +- [ ] Complete legal and privacy review **before** enabling CLA Assistant. +- [ ] Publish only the approved, versioned CLA text—not this draft—as the + acceptance source. +- [ ] Review CLA Assistant's hosting, authentication, custom fields, privacy, + retention, availability, and version-change behavior. +- [ ] Configure a test repository before connecting HitTheKit. +- [ ] Verify individual and Entity workflows, bot comments, re-signing, and + failure/recovery behavior. +- [ ] Add the CLA status check to branch protection only after a successful + test run and with a unique check name. +- [ ] Keep maintainer bypass disabled or document narrowly reviewed emergency + handling. +- [ ] Update the pull-request template without claiming that opening a PR + constitutes acceptance. +- [ ] Update `CONTRIBUTING.md`, `GOVERNANCE.md`, privacy documentation, and the + public website only when the agreement actually enters into force. +- [ ] Publish a plain-language announcement of the effective date, scope, + contributor choices, community-license promise, and contact route. +- [ ] Confirm substantial external contributions remain blocked until every + activation item is complete. + +GitHub required status checks enforce workflow results; they do not determine +whether the CLA text or acceptance process is legally sufficient. CLA +Assistant automates prompts, GitHub authentication, acceptance records, and a +pull-request status check, but it is not the legal reviewer or receiving party. + +## DCO, CLA, and CLA Assistant are different + +| Mechanism | Primary function | What it does not establish by itself | +| --- | --- | --- | +| Developer Certificate of Origin (DCO) | Contributor attests provenance and a right to submit under the indicated open-source terms, commonly recorded with `Signed-off-by` | It does not grant HitTheKit the separate, broad sublicensing rights needed for proprietary/commercial outbound licensing. | +| Contributor License Agreement (CLA) | A contract can expressly grant copyright and patent rights while the contributor retains ownership | A draft is not operative without identified parties, reviewed terms, valid acceptance, and appropriate records. | +| CLA Assistant | Automates the presentation and collection of a project's chosen CLA and reports status to GitHub | It does not draft, approve, interpret, or make legally sufficient the CLA and privacy process. | + +MPL 2.0 grants rights from contributors to recipients of MPL-covered software, +but its contributor grant does not automatically appoint the HitTheKit +maintainer to relicense every external Contribution under unrelated proprietary +terms. The proposed CLA is intended to address that separate inbound-rights +need if qualified review approves it. + +## Review findings to resolve before adoption + +### P0 + +None identified in a non-operative documentation draft. + +### P1 — adoption blockers + +- The receiving legal party, governing law, forum, legal contact, privacy + controller, and acceptance process are unknown. +- Harmony Option Five and the copyright grant are deliberately broad; their + sublicensing, transferability, irrevocability, and proprietary-license scope + require qualified review and plain-language disclosure. +- Patent scope and any retaliation/termination mechanism are unresolved. +- Individual, Entity, employer-consent, Affiliate, and minor-contributor + processes are unresolved. +- The public `main` license and the intended MPL-2.0 state are temporarily + different; activation before the community-license transition is settled + would create avoidable ambiguity. +- Privacy, retention, version evidence, electronic signature, and treatment of + prior Contributions are unresolved. + +### P2 — process and clarity risks + +- One combined draft is convenient for review but separate Individual and + Entity forms may be clearer operationally. +- Media and third-party-content rules may need their own contributor guide and + provenance workflow rather than additional breadth in the CLA. +- A CLA tool outage, Gist change, account rename, or bot-status ambiguity needs + an operational recovery procedure. + +### P3 — editorial follow-up + +- Defined terms, capitalization, translations, accessibility, and + plain-language summaries should be normalized after legal text stabilizes. +- A version identifier and digest format should be selected before publication. + +## Pro bono review request template + +Subject: Limited pro bono review request — open-source contributor agreement + +> Hello, +> +> I maintain HitTheKit, an open-source electronic-drum learning game. The +> project is preparing to use Mozilla Public License 2.0 for its community +> edition and may later offer separate commercial licenses. I would like +> contributors to retain their copyright while granting the project the +> copyright and patent permissions needed to distribute accepted contributions +> under MPL 2.0 and, transparently, under separate commercial/proprietary terms. +> +> I have prepared a non-operative draft based on the Harmony Agreements +> copyright-license model and outbound Option Five. The project is not yet +> accepting substantial external code or assets, no one is being asked to sign +> the draft, and CLA Assistant has not been configured. +> +> I currently have no budget for a full commercial engagement. Would your +> clinic, practice, or open-source legal community be willing to provide a +> limited pro bono review of the draft, focusing on the receiving party, +> sublicensing scope, patent grant, employer/entity contributions, governing +> law, privacy and electronic acceptance? I can provide the short draft, +> adoption checklist, current license documents, and a concise list of open +> questions. +> +> I understand that availability may be limited and that an informal community +> response may not create a lawyer-client relationship or replace advice from a +> qualified professional in the relevant jurisdiction. + +## Primary drafting references + +- Harmony agreement templates and guide: + +- Mozilla Public License 2.0: + +- Developer Certificate of Origin 1.1: + +- CLA Assistant project documentation: + +- GitHub protected branches and required status checks: + + +This checklist is engineering and governance preparation, not legal advice. diff --git a/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md b/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md new file mode 100644 index 0000000..ddbbd5e --- /dev/null +++ b/docs/legal/CONTRIBUTOR_LICENSE_AGREEMENT_DRAFT.md @@ -0,0 +1,327 @@ +# DRAFT — NOT IN FORCE — NOT LEGALLY REVIEWED — DO NOT ACCEPT OR RELY ON THIS DOCUMENT + +> **No one should sign, accept, or rely on this draft.** It does not currently +> govern any contribution to HitTheKit, and opening a pull request, issue, or +> discussion does not constitute acceptance. Substantial external code and +> asset contributions remain paused under +> [`CONTRIBUTING.md`](../../CONTRIBUTING.md). + +## Drafting basis and unresolved decisions + +This planning draft is adapted from the Harmony Combined Contributor Agreement +Template 1.0. It selects the **copyright-license** model rather than copyright +assignment, includes a patent grant, and follows the substance of Harmony +Outbound License Option Five so that a covered Contribution could be offered +both under HitTheKit's community license and under separate commercial or +proprietary terms. + +This is a modified planning document, not an official Harmony agreement. The +Harmony template and guide remain the authoritative sources for the template +options. The draft must be replaced or approved by a qualified human reviewer +before adoption. + +The following decisions are deliberately unresolved: + +- receiving party: **[LEGAL REVIEW REQUIRED — LEGAL NAME AND CAPACITY]**; +- receiving-party address: **[TO BE AGREED]**; +- legal contact: **[TO BE AGREED]**; +- governing law and forum: **[TO BE AGREED]**; +- separate individual and entity forms: **[TO BE AGREED]**; +- treatment of minors: **[LEGAL REVIEW REQUIRED]**; +- employer approval and entity authorization evidence: **[TO BE AGREED]**; +- electronic acceptance and signature process: **[TO BE AGREED]**; +- privacy notice, data controller, retention, access, correction, and deletion + process: **[LEGAL REVIEW REQUIRED]**; +- versioning and record of the exact text accepted: **[TO BE AGREED]**; +- treatment of contributions made before the effective date: + **[LEGAL REVIEW REQUIRED — NO RETROACTIVE ASSUMPTION]**; and +- exact community-license transition: HitTheKit's public `main` currently uses + GPL-3.0-only, while a possible MPL-2.0 transition is separately under review. + This draft does not change the repository license. + +--- + +# HitTheKit Contributor License Agreement — planning draft + +Thank you for your interest in contributing to HitTheKit (the “Project”). This +draft uses “Project Recipient” for the person or entity that would receive the +rights below. Before adoption, “Project Recipient” must be replaced with the +verified legal identity of **[RECEIVING PARTY]**. + +If adopted after legal review, this agreement (the “Agreement”) would document +rights granted by a contributor (“You”) to the Project Recipient (“We” or +“Us”). It would become effective only through the approved acceptance process +described at **[ACCEPTANCE AND SUBMISSION INSTRUCTIONS]**. No such process is +currently active. + +## 1. Definitions + +### 1.1 Contributor + +“You” means either: + +1. the individual who submits a Contribution; or +2. the Legal Entity on whose behalf an authorized person submits a + Contribution. + +“Legal Entity” means an organization rather than a natural person. +“Affiliates” means entities that control, are controlled by, or are under +common control with that Legal Entity. Whether and how Affiliates are included +is **[LEGAL REVIEW REQUIRED]**. + +The adopted process must distinguish individual contributors from contributors +acting for a Legal Entity. An individual may not bind an employer or another +organization without authority. + +### 1.2 Contribution + +“Contribution” means the portion of an original work of authorship that You +Submit for inclusion in HitTheKit and for which You own, or have authority to +license, all rights required by this Agreement. + +A Submission is not a Contribution to the extent it contains third-party code, +music, recordings, performances, charts, MIDI captures, artwork, fonts, +trademarks, personal data, confidential material, or other material that You +do not have authority to license under this Agreement. Do not submit such +material. HitTheKit currently has no non-owner or mixed-submission acceptance +procedure. + +Bug reports, ideas, functional requests, facts, and general discussion may not +be copyrightable Contributions. Their treatment must be assessed separately; +this definition does not convert an idea or fact into owned project material. + +### 1.3 Submit + +“Submit” means intentionally providing a proposed Contribution through a +Project channel designated in the future adoption instructions for that +purpose. Ordinary participation in an issue, discussion, hardware report, or +support request is not acceptance of this Agreement. + +A communication conspicuously marked “Not a Contribution” is excluded, subject +to the adopted submission procedure. The final procedure must define which +repositories, branches, pull requests, email addresses, or other channels are +designated for Contributions. + +### 1.4 Material + +“Material” means the HitTheKit software, documentation, or other project work +into which a Contribution may be incorporated. We are not required to +incorporate or distribute any Contribution. + +### 1.5 Copyright and Patent Claims + +“Copyright” includes copyright and, where applicable, closely related rights +that You own or control and may license for the full term of those rights. + +“Patent Claims” means only patent claims that You own or control and may +license, and that would necessarily be infringed by exercising the patent +rights expressly granted in Section 2.2 with respect to the Contribution alone +or the Contribution combined with the Material to which it was submitted. + +The treatment of Affiliates, subsequently acquired claims, and patent +termination or retaliation is **[LEGAL REVIEW REQUIRED]**. + +### 1.6 Effective Date + +“Effective Date” means **[TO BE AGREED]**. The final text must not presume that +this Agreement retroactively covers a contribution or period that the +contributor did not knowingly accept. + +## 2. Grant of rights + +### 2.1 Copyright license — no assignment + +You retain ownership of the Copyright in Your Contribution and retain the same +rights to use or license it that You would have had without this Agreement. +This Agreement does not assign copyright to Us. + +Subject to the community-license commitment in Section 2.3, and to the maximum +extent You are legally able to do so, You grant Us a perpetual, worldwide, +non-exclusive, transferable, royalty-free, irrevocable license under the +Copyright in the Contribution, with the right to sublicense through multiple +tiers, to use, reproduce, make available, modify, prepare derivative works of, +display, perform, distribute, and otherwise exploit the Contribution as part +of or in connection with the Material. + +The words “transferable,” “irrevocable,” “otherwise exploit,” and “multiple +tiers” are intentionally broad adaptations of the Harmony license model and +must receive specific human legal review before adoption. + +### 2.2 Patent license + +Subject to the community-license commitment in Section 2.3, and to the maximum +extent You are legally able to do so, You grant Us a perpetual, worldwide, +non-exclusive, transferable, royalty-free, irrevocable patent license, with +the right to sublicense through multiple tiers, under the Patent Claims to +make, have made, use, sell, offer for sale, import, and otherwise transfer the +Contribution and the Contribution combined with the Material to which it was +submitted. + +No patent license is intended for claims infringed solely by unrelated changes +or combinations added after Your Contribution. The final patent scope, +limitations, Affiliate coverage, and any termination provision require +qualified review for consistency with MPL 2.0 and future commercial terms. + +### 2.3 Outbound licensing + +If We include Your Contribution in the Material, the grants in Sections 2.1 +and 2.2 are intended to allow Us to license the Contribution under open-source, +copyleft, permissive, commercial, or proprietary terms, including: + +- Mozilla Public License 2.0, if and when that becomes the adopted community + license for the relevant Material; and +- a separate commercial or proprietary HitTheKit license offered by a party + with authority over all relevant rights. + +Consistent with the purpose of Harmony Outbound Option Five, whenever We first +include a Contribution in publicly distributed Material, We would also make +that Contribution available under the community license that applies to that +Material at the time of inclusion. Exercising the grants in Sections 2.1 and +2.2 would remain subject to this commitment. The final text must specify how +the commitment works during license transitions and must not imply that MPL +2.0 itself grants Us the right to relicense a contributor's work commercially. + +This Agreement would grant rights only in Your Contribution. It would not +relicense Unity, package dependencies, third-party code, music, recordings, +charts, MIDI data, artwork, trademarks, or other material outside Your rights. + +### 2.4 Moral and similar rights + +Harmony includes a waiver and non-assertion provision for moral rights. Whether +such a provision is necessary, proportionate, or enforceable for HitTheKit is +**[LEGAL REVIEW REQUIRED]**. No waiver is included in this non-operative draft +as an approved term. + +### 2.5 Reservation of rights + +Rights not expressly licensed under this Agreement remain reserved by You. +Neither this Agreement nor a Contribution grants rights to Your name, +trademarks, or personal likeness except for accurate attribution and record +keeping expressly described in the final agreement and privacy notice. + +## 3. Contributor confirmations + +If this Agreement is adopted, You would confirm that: + +1. You have legal authority to enter into it and grant the stated rights. +2. You own or control the Copyright and Patent Claims needed for the grants. +3. The grants do not violate an agreement or obligation to an employer, + client, school, funder, or other third party. +4. If the Contribution was created in the course of employment or using an + employer's resources, You have obtained any required written employer + consent, or the authorized Legal Entity has accepted the appropriate entity + agreement. +5. You have not knowingly included third-party material unless a separately + documented Project procedure approved it in writing. No such procedure is + currently active. +6. You will identify any known restriction, provenance issue, or third-party + claim before submission. + +These confirmations are limited to matters within Your knowledge and authority +as specified in the final reviewed text. They are not a promise that the +Contribution is error-free or suitable for a particular purpose. + +Contributors who may be minors must not accept this Agreement until a qualified +reviewer has defined an appropriate age, guardian-consent, identity, and data +handling process: **[LEGAL REVIEW REQUIRED]**. + +## 4. Project decisions and no obligation + +We may accept, reject, modify, defer, or remove a Contribution. Acceptance of +this Agreement would not guarantee merge, attribution in a particular form, +support, payment, employment, or participation in project governance. + +## 5. Disclaimer and liability provisions + +Harmony includes conspicuous warranty disclaimers and a consequential-damages +waiver. The exact language and whether obligations should be mutual depend on +the chosen parties and jurisdiction. The adopted agreement must include +counsel-reviewed provisions at **[LEGAL REVIEW REQUIRED]**. + +Until then, this draft makes no warranty and does not create contractual +rights or obligations. + +## 6. Privacy, records, and electronic acceptance + +Before activation, the Project must publish a privacy notice that identifies: + +- the legal recipient/data controller and contact; +- personal data collected for acceptance; +- purpose and lawful basis; +- processors, recipients, and cross-border transfers; +- retention and deletion rules; +- contributor access, correction, objection, and other applicable rights; +- the version, timestamp, account identity, and evidence retained; and +- the process for security incidents and future agreement updates. + +The final agreement must identify the exact acceptance action and the version +accepted. CLA Assistant or another tool may automate identity checks, prompts, +records, and status checks, but using a tool does not itself make the legal +text sufficient or enforceable. + +## 7. Employer and entity contributions + +Before accepting work performed for an employer or other Legal Entity, the +Project must decide whether to require: + +- a separate Harmony-style Entity Contributor License Agreement; +- individual acceptance plus employer consent; +- a list of authorized contributors maintained by the entity; or +- another professionally reviewed process. + +The authorized signatory's name, title, authority, entity identity, Affiliates, +and changes to authorization must be recorded under the final privacy and +retention process. + +## 8. Miscellaneous terms + +The adopted agreement requires complete provisions for governing law, forum or +dispute process, assignment, successors, severability, waiver, entire +agreement, amendments, notices, language, and counterparts or electronic +signatures. Those provisions are **[TO BE AGREED AFTER QUALIFIED REVIEW]** and +are not inferred from this draft. + +## 9. Acceptance blocks — intentionally disabled + +### Individual contributor + +- Legal name: **[DO NOT COMPLETE — PROCESS NOT ACTIVE]** +- Address/contact: **[TO BE AGREED WITH PRIVACY REVIEW]** +- GitHub identity: **[TO BE AGREED]** +- Agreement version: **[TO BE AGREED]** +- Signature/acceptance: **[TO BE AGREED]** + +### Legal Entity + +- Entity legal name: **[DO NOT COMPLETE — PROCESS NOT ACTIVE]** +- Authorized signatory and title: **[TO BE AGREED]** +- Address/contact: **[TO BE AGREED WITH PRIVACY REVIEW]** +- Authorized contributors: **[TO BE AGREED]** +- Agreement version: **[TO BE AGREED]** +- Signature/acceptance: **[TO BE AGREED]** + +### Project Recipient + +- Legal identity: **[LEGAL REVIEW REQUIRED]** +- Capacity/title: **[TO BE AGREED]** +- Address/contact: **[TO BE AGREED]** +- Signature/acceptance: **[TO BE AGREED]** + +--- + +## Source attribution + +Planning source: Harmony Combined Contributor Agreement Template, version 1.0, +and the Harmony Guide to the Contributor Agreements, published at + and licensed by Project Harmony +under Creative Commons Attribution 3.0 Unported. + +Other reference points used to identify—not resolve—the required boundaries: + +- official MPL 2.0 text: ; +- Developer Certificate of Origin 1.1: ; +- CLA Assistant project documentation: + . + +No reference source, template, automation, or repository maintainer has +approved this HitTheKit draft as legally sufficient. diff --git a/docs/legal/dual-licensing-readiness-report.md b/docs/legal/dual-licensing-readiness-report.md index 3b1d700..6c12207 100644 --- a/docs/legal/dual-licensing-readiness-report.md +++ b/docs/legal/dual-licensing-readiness-report.md @@ -10,6 +10,11 @@ Baseline: `44d47f3acc8e6f305fb95fc8f9a0baae06c57f84` > Current visual-asset evidence is maintained in > [`ASSET_PROVENANCE.md`](ASSET_PROVENANCE.md), and every public release still > requires a fresh exact-commit legal and dependency review. +> +> The GPL findings below describe the audited 2026-08-05 baseline and are not +> the current Community license. HitTheKit subsequently adopted MPL-2.0; see +> [the current licensing overview](../../LICENSING.md) and +> [MPL Community License Decision](../governance/mpl-community-license-decision.md). ## Scope and limitations diff --git a/docs/release/0.6.0-beta.1-release-notes.md b/docs/release/0.6.0-beta.1-release-notes.md new file mode 100644 index 0000000..445be8c --- /dev/null +++ b/docs/release/0.6.0-beta.1-release-notes.md @@ -0,0 +1,56 @@ +# HitTheKit 0.6.0-beta.1 + +This tester-facing beta consolidates the current `develop` integration into a +signed and notarized Apple-silicon candidate. It contains only original, +rights-clean demo content and does not bundle commercial songs, charts, MIDI, +lyrics, or artwork. + +## Highlights + +- Practice Lab section loops and explicit A/B practice ranges; +- section and drum-piece Performance Error Map; +- Auto Tempo Coach recommendations that remain isolated from Chart Creator; +- guided keyboard/MIDI audio-latency sound check; +- local Ghost Replay markers that never enter matching or scoring; +- accessible procedural Reactive Stage with reduced-motion and high-contrast + behavior; +- scroll-safe results diagnostics that keep Practice Lab, Auto Tempo, + calibration, Ghost, and navigation controls readable; +- local Chart Creator foundation with audio import, waveform navigation, + editable timing/velocity/articulation, and portable `.htksong` packages; +- existing keyboard and macOS CoreMIDI gameplay, lessons, settings, progress, + device setup, and the original `Neon Circuit` demo. + +## Validation + +- .NET build: 0 warnings, 0 errors; +- Core tests: 58/58; +- MIDI tool tests: 39/39; +- Unity EditMode: 518/518; +- Unity PlayMode: 54/54; +- GitHub CI, repository contracts, packaging contracts, and CodeQL C#/C++: + passed on the feature integration heads. + +The final GitHub prerelease records the exact source commit, artifact SHA-256, +Developer ID signature, notarization, Gatekeeper, and runtime-smoke evidence. + +## Known limitations + +- the public player candidate targets Apple Silicon; +- production MIDI input is CoreMIDI on macOS; Windows remains keyboard-only; +- electronic-drum compatibility is limited to the guided mapping boundary and + models actually reported by testers; +- this pass does not claim a new real-hardware certification across two drum + modules or two audio interfaces; +- GitHub-hosted Unity CI remains disabled until real Unity license secrets are + configured; the candidate uses the documented local Unity gate; +- beta support is best effort and no compatibility promise is implied. + +## Privacy and content + +Preferences, mappings, progress, created charts, and local audio bindings stay +on the player's machine. HitTheKit has no configured application telemetry. +Only content the player is authorized to use should be imported or shared. + +Use GitHub Issues for reproducible bugs and sanitized hardware reports. Report +security vulnerabilities privately according to `SECURITY.md`. diff --git a/docs/release/PUBLICATION_RUNBOOK.md b/docs/release/PUBLICATION_RUNBOOK.md index 9e74153..25b90a9 100644 --- a/docs/release/PUBLICATION_RUNBOOK.md +++ b/docs/release/PUBLICATION_RUNBOOK.md @@ -36,9 +36,9 @@ remotes; do not rely on the temporary rename redirect. - the exact source commit is approved; - the initial publication is source-and-website only; - the technical rights/provenance audit and source contracts are green; -- unresolved Unity-runtime/GPL and commercial-license questions are disclosed, - with qualified legal review still required before public binaries or a - separate commercial license; +- MPL-2.0 is the current Community license, while Unity-runtime terms, + exact-candidate notices, and the commercial-license agreement remain + separately gated before public binaries or commercial licensing; - the worktree is clean; - historical releases remain private and are not linked as current downloads; - the target owner, repository name, visibility, and maintainer identity are diff --git a/docs/release/PUBLIC_RELEASE_CHECKLIST.md b/docs/release/PUBLIC_RELEASE_CHECKLIST.md index 1164d1e..7e5c9db 100644 --- a/docs/release/PUBLIC_RELEASE_CHECKLIST.md +++ b/docs/release/PUBLIC_RELEASE_CHECKLIST.md @@ -9,7 +9,8 @@ come completata. - [x] Provenienza e SHA-256 di tutti gli asset visuali correnti registrati e verificati in CI. - [x] Test core e MIDI disponibili senza Unity. - [x] CI, CodeQL e aggiornamenti dipendenze configurati. -- [ ] Revisione professionale della compatibilità GPL/Unity e dei termini di distribuzione, obbligatoria prima di qualsiasi binario pubblico o licenza commerciale separata. +- [x] Licenza Community migrata a MPL-2.0 per evitare il precedente conflitto di copyleft dell'intera opera con Unity. +- [ ] Verifica della licenza MPL, dei termini Unity e del modello contributivo sull'esatto candidato binario. - [x] Prima pubblicazione limitata a sorgenti e sito; nessun binario Unity pubblico. - [x] Strategia scelta: nuovo repository pubblico pulito da uno snapshot dell'HEAD, senza cronologia privata. - [x] Repository storico destinato a `HitTheKit-private-archive`, con tag e release mantenuti privati. diff --git a/docs/release/RELEASE_PROCESS.md b/docs/release/RELEASE_PROCESS.md index c34c26d..1956f5b 100644 --- a/docs/release/RELEASE_PROCESS.md +++ b/docs/release/RELEASE_PROCESS.md @@ -4,6 +4,11 @@ This process separates source readiness, gameplay validation, legal approval, and public distribution. Passing an early stage does not imply that later stages passed. +Release candidates follow the branch and tag lifecycle defined in +[`branching-and-releases.md`](../development/branching-and-releases.md). In +particular, normal work integrates through `develop`, while stable release tags +are created only from reviewed commits on `main`. + ## 1. Freeze an exact candidate 1. Choose an exact commit on a clean worktree. @@ -12,6 +17,8 @@ stages passed. 3. Stop feature work on the candidate; only reviewed release blockers may change it. 4. Record the commit SHA in the release evidence. +5. Keep the worktree clean so `SOURCE-CODE.txt` can identify the exact public + revision corresponding to every executable candidate. ## 2. Run source and packaging contracts @@ -74,7 +81,8 @@ Hardware observations must not be generalized beyond the tested models. 1. confirm the bundled catalog contains only authorized original material; 2. refresh asset provenance and third-party notices from the exact candidate; 3. review dependency and CodeQL results; -4. obtain the required professional GPL/Unity and contribution-model review; +4. validate MPL compliance, current Unity distribution terms, and the + contribution model for the exact candidate; 5. confirm no secrets, certificates, private captures, or personal data are in the candidate; and 6. decide the clean-history public repository boundary. @@ -89,6 +97,9 @@ Signing credentials remain outside Git. On the authorized release Mac: The pipeline must finish with `HITTHEKIT_MACOS_DISTRIBUTION_READY`. Verify the stapled application and each archive again after copying to the clean test Mac. +Confirm that the application resources and DMG both include `Legal/` with +`LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.md`, +`LICENSING.md`, and an exact-revision `SOURCE-CODE.txt`. ## 7. Prepare evidence diff --git a/docs/wiki/_Footer.md b/docs/wiki/_Footer.md index 04c6995..6b06a6f 100644 --- a/docs/wiki/_Footer.md +++ b/docs/wiki/_Footer.md @@ -1,4 +1,4 @@ HitTheKit is an independent open-source project. · [Website](https://codewriter90x.github.io/HitTheKit/en/) · [Source](https://github.com/Codewriter90x/HitTheKit) · -[GPL-3.0-only](https://github.com/Codewriter90x/HitTheKit/blob/main/LICENSE) +[MPL-2.0](https://github.com/Codewriter90x/HitTheKit/blob/main/LICENSE) diff --git a/scripts/build-macos-distribution-app.sh b/scripts/build-macos-distribution-app.sh index 9062ab5..5f482dd 100755 --- a/scripts/build-macos-distribution-app.sh +++ b/scripts/build-macos-distribution-app.sh @@ -21,6 +21,13 @@ fail() { [ ! -e "$APP_PATH" ] || fail "output already exists: $APP_PATH" [ -x "$UNITY_PATH" ] || fail "Unity 6000.5.6f1 was not found at: $UNITY_PATH" +SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-} +if [ -z "$SOURCE_COMMIT" ]; then + [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] || + fail "the release candidate must be committed and clean before building" + SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD) +fi + OUTPUT_ROOT=$(dirname -- "$APP_PATH") mkdir -p "$OUTPUT_ROOT" APP_PATH=$(cd "$OUTPUT_ROOT" && pwd)/$(basename -- "$APP_PATH") @@ -58,6 +65,9 @@ PLIST="$APP_PATH/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST" "$REPOSITORY_ROOT/scripts/apply-macos-app-icon.sh" "$APP_PATH" +HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \ + "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \ + "$APP_PATH/Contents/Resources/Legal" "$VERSION" EXECUTABLE=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$PLIST") MAIN_BINARY="$APP_PATH/Contents/MacOS/$EXECUTABLE" diff --git a/scripts/install-distribution-notices.sh b/scripts/install-distribution-notices.sh new file mode 100755 index 0000000..3d8a0da --- /dev/null +++ b/scripts/install-distribution-notices.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPOSITORY_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd) +DESTINATION=${1:-} +VERSION=${2:-} +SOURCE_REPOSITORY=https://github.com/Codewriter90x/HitTheKit + +fail() { + echo "distribution notices: $*" >&2 + exit 1 +} + +[ -n "$DESTINATION" ] && [ -n "$VERSION" ] || + fail "usage: $0 " +[[ "$VERSION" =~ ^[0-9]+([.][0-9]+){1,2}([-.][0-9A-Za-z.-]+)?$ ]] || + fail "version must identify a release, for example 0.5.0 or 0.5.0-rc.1" + +SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-} +if [ -z "$SOURCE_COMMIT" ]; then + [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] || + fail "the repository is dirty; commit the exact candidate or set HITTHEKIT_SOURCE_COMMIT explicitly" + SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD) +fi + +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || + fail "HITTHEKIT_SOURCE_COMMIT must be a full 40-character lowercase Git SHA" +git -C "$REPOSITORY_ROOT" cat-file -e "$SOURCE_COMMIT^{commit}" 2>/dev/null || + fail "source commit is not present in this repository: $SOURCE_COMMIT" + +for required_file in LICENSE NOTICE THIRD_PARTY_NOTICES.md LICENSING.md; do + [ -f "$REPOSITORY_ROOT/$required_file" ] || + fail "required legal file is missing: $required_file" +done + +mkdir -p "$DESTINATION" +install -m 0644 "$REPOSITORY_ROOT/LICENSE" "$DESTINATION/LICENSE" +install -m 0644 "$REPOSITORY_ROOT/NOTICE" "$DESTINATION/NOTICE" +install -m 0644 "$REPOSITORY_ROOT/THIRD_PARTY_NOTICES.md" \ + "$DESTINATION/THIRD_PARTY_NOTICES.md" +install -m 0644 "$REPOSITORY_ROOT/LICENSING.md" \ + "$DESTINATION/LICENSING.md" + +printf '%s\n' \ + 'HitTheKit corresponding source code notice' \ + '' \ + "Binary version: $VERSION" \ + "Exact source revision: $SOURCE_COMMIT" \ + '' \ + 'The Source Code Form of MPL-2.0-covered HitTheKit files in this' \ + 'distribution is available from the exact public revision below:' \ + '' \ + "$SOURCE_REPOSITORY/tree/$SOURCE_COMMIT" \ + '' \ + 'A downloadable archive of that revision is available at:' \ + '' \ + "$SOURCE_REPOSITORY/archive/$SOURCE_COMMIT.zip" \ + '' \ + 'The MPL-2.0 license text, project notice, licensing overview, and' \ + 'third-party notices are included in this Legal directory.' \ + '' \ + 'Unity and third-party components retain their own licenses and are not' \ + 'relicensed by HitTheKit.' \ + > "$DESTINATION/SOURCE-CODE.txt" + +echo "HITTHEKIT_DISTRIBUTION_NOTICES_INSTALLED" +echo "destination=$DESTINATION" +echo "sourceCommit=$SOURCE_COMMIT" diff --git a/scripts/package-game-macos-arm64.sh b/scripts/package-game-macos-arm64.sh index 0c7ff84..7fdb736 100755 --- a/scripts/package-game-macos-arm64.sh +++ b/scripts/package-game-macos-arm64.sh @@ -1,8 +1,8 @@ -#!/bin/sh -set -eu +#!/bin/bash +set -euo pipefail -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -REPOSITORY_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPOSITORY_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd) UNITY_PATH=${UNITY_PATH:-/Applications/Unity/Hub/Editor/6000.5.6f1/Unity.app/Contents/MacOS/Unity} BUILD_VERSION=${1:-0.1.0} OUTPUT_ROOT=${2:-"$REPOSITORY_ROOT/artifacts/game/macos-arm64-$BUILD_VERSION"} @@ -21,6 +21,15 @@ if [ -e "$OUTPUT_ROOT" ]; then exit 3 fi +SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-} +if [ -z "$SOURCE_COMMIT" ]; then + [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] || { + echo "The playtest source must be committed and clean before packaging." >&2 + exit 4 + } + SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD) +fi + mkdir -p "$OUTPUT_ROOT" "$REPOSITORY_ROOT/scripts/build-coremidi-plugin-macos-arm64.sh" @@ -51,11 +60,19 @@ cp "$SOURCE_COREMIDI_PLUGIN" "$COREMIDI_PLUGIN" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUILD_VERSION" "$APP_PATH/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_VERSION" "$APP_PATH/Contents/Info.plist" "$REPOSITORY_ROOT/scripts/apply-macos-app-icon.sh" "$APP_PATH" +HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \ + "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \ + "$APP_PATH/Contents/Resources/Legal" "$BUILD_VERSION" BINARY_NAME=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$APP_PATH/Contents/Info.plist") MAIN_BINARY="$APP_PATH/Contents/MacOS/$BINARY_NAME" -codesign --force --sign - "$COREMIDI_PLUGIN" -codesign --force --deep --sign - "$APP_PATH" +while IFS= read -r -d '' candidate; do + [ "$candidate" != "$MAIN_BINARY" ] || continue + if file -b "$candidate" | grep -q 'Mach-O'; then + codesign --force --sign - "$candidate" + fi +done < <(find "$APP_PATH/Contents" -type f -print0) +codesign --force --sign - "$APP_PATH" test -x "$MAIN_BINARY" test -f "$COREMIDI_PLUGIN" @@ -65,6 +82,7 @@ codesign --verify --deep --strict --verbose=2 "$APP_PATH" mkdir -p "$PACKAGE_ROOT" cp -R "$APP_PATH" "$PACKAGE_ROOT/HitTheKit.app" +ditto "$APP_PATH/Contents/Resources/Legal" "$PACKAGE_ROOT/Legal" printf '%s\n' \ 'HitTheKit macOS Apple Silicon playtest' \ '' \ @@ -74,6 +92,7 @@ printf '%s\n' \ '' \ 'This early playtest is ad-hoc signed and not yet Apple-notarized.' \ 'No Unity installation is required.' \ + 'License, notices, and exact source information are in Legal/.' \ > "$PACKAGE_ROOT/README-FIRST.txt" ditto -c -k --sequesterRsrc --keepParent "$PACKAGE_ROOT" "$ZIP_PATH" diff --git a/scripts/package-game-windows-x64.sh b/scripts/package-game-windows-x64.sh index daed283..37b0d3c 100755 --- a/scripts/package-game-windows-x64.sh +++ b/scripts/package-game-windows-x64.sh @@ -20,8 +20,16 @@ OUTPUT_ROOT=${OUTPUT_ROOT:-"$REPOSITORY_ROOT/artifacts/game/windows-x64-$VERSION [ ! -e "$OUTPUT_ROOT" ] || fail "output directory already exists: $OUTPUT_ROOT" [ -x "$UNITY_PATH" ] || fail "Unity 6000.5.6f1 was not found at: $UNITY_PATH" +SOURCE_COMMIT=${HITTHEKIT_SOURCE_COMMIT:-} +if [ -z "$SOURCE_COMMIT" ]; then + [ -z "$(git -C "$REPOSITORY_ROOT" status --porcelain)" ] || + fail "the playtest source must be committed and clean before packaging" + SOURCE_COMMIT=$(git -C "$REPOSITORY_ROOT" rev-parse HEAD) +fi + UNITY_APP=$(CDPATH='' cd -- "$(dirname -- "$UNITY_PATH")/../.." && pwd) -WINDOWS_SUPPORT="$UNITY_APP/Contents/PlaybackEngines/WindowsStandaloneSupport" +UNITY_INSTALL_ROOT=$(CDPATH='' cd -- "$UNITY_APP/.." && pwd) +WINDOWS_SUPPORT="$UNITY_INSTALL_ROOT/PlaybackEngines/WindowsStandaloneSupport" [ -d "$WINDOWS_SUPPORT" ] || \ fail "Unity Windows Build Support (Mono) is not installed for 6000.5.6f1" @@ -56,6 +64,10 @@ UNEXPECTED_PLUGIN=$(find "$PLAYER_ROOT" -iname '*HitTheKitCoreMidi*' -print -qui [ -z "$UNEXPECTED_PLUGIN" ] || \ fail "macOS CoreMIDI plug-in leaked into the Windows package: $UNEXPECTED_PLUGIN" +HITTHEKIT_SOURCE_COMMIT="$SOURCE_COMMIT" \ + "$REPOSITORY_ROOT/scripts/install-distribution-notices.sh" \ + "$PLAYER_ROOT/Legal" "$VERSION" + printf '%s\n' \ 'HitTheKit Windows x64 playtest' \ '' \ @@ -66,6 +78,7 @@ printf '%s\n' \ 'Electronic-drum MIDI is not implemented on Windows yet.' \ 'This playtest package is unsigned and is not approved for public release.' \ 'Verify the published SHA-256 before running any future release package.' \ + 'License, notices, and exact source information are in Legal/.' \ > "$PLAYER_ROOT/README-FIRST.txt" ditto --norsrc --noextattr -c -k --keepParent "$PLAYER_ROOT" "$ZIP_PATH" diff --git a/scripts/package-macos-dmg.sh b/scripts/package-macos-dmg.sh index 4e293d1..20f2340 100755 --- a/scripts/package-macos-dmg.sh +++ b/scripts/package-macos-dmg.sh @@ -31,6 +31,9 @@ resolve_identity() { fail "usage: $0 " [[ "$APP_PATH" == *.app ]] || fail "input must be an .app bundle" [ -d "$APP_PATH/Contents" ] || fail "app bundle does not exist or is invalid: $APP_PATH" +LEGAL_SOURCE="$APP_PATH/Contents/Resources/Legal" +[ -f "$LEGAL_SOURCE/SOURCE-CODE.txt" ] || + fail "app bundle lacks the MPL source disclosure: $LEGAL_SOURCE/SOURCE-CODE.txt" [[ "$OUTPUT_DMG" == *.dmg ]] || fail "output must use the .dmg extension" [ ! -e "$OUTPUT_DMG" ] || fail "output already exists: $OUTPUT_DMG" @@ -59,6 +62,7 @@ trap cleanup EXIT INT TERM mkdir -p "$STAGE_ROOT" ditto "$APP_PATH" "$STAGE_ROOT/HitTheKit.app" +ditto "$LEGAL_SOURCE" "$STAGE_ROOT/Legal" ln -s /Applications "$STAGE_ROOT/Applications" hdiutil create -quiet -volname "HitTheKit" -srcfolder "$STAGE_ROOT" -format UDZO "$TEMP_DMG" hdiutil verify "$TEMP_DMG" >/dev/null diff --git a/scripts/package-midi-capture-macos-arm64.sh b/scripts/package-midi-capture-macos-arm64.sh index 463ed09..6d3d403 100755 --- a/scripts/package-midi-capture-macos-arm64.sh +++ b/scripts/package-midi-capture-macos-arm64.sh @@ -20,7 +20,7 @@ cp "$PUBLISH_DIRECTORY/hitthekit-midi-capture" "$PACKAGE_DIRECTORY/" cp "$PACKAGE_SOURCE/README.txt" "$PACKAGE_DIRECTORY/" cp "$PACKAGE_SOURCE/run.command" "$PACKAGE_DIRECTORY/" cp "$PACKAGE_SOURCE/LICENSES/DryWetMIDI-MIT.txt" "$PACKAGE_DIRECTORY/LICENSES/" -cp "$REPOSITORY_ROOT/LICENSE" "$PACKAGE_DIRECTORY/LICENSES/HitTheKit-GPL-3.0.txt" +cp "$REPOSITORY_ROOT/LICENSE" "$PACKAGE_DIRECTORY/LICENSES/HitTheKit-MPL-2.0.txt" chmod +x "$PACKAGE_DIRECTORY/hitthekit-midi-capture" "$PACKAGE_DIRECTORY/run.command" mkdir -p "$(dirname "$ARCHIVE")" diff --git a/src/HitTheKit.Core/ChartNote.cs b/src/HitTheKit.Core/ChartNote.cs index 36d09f2..cba7952 100644 --- a/src/HitTheKit.Core/ChartNote.cs +++ b/src/HitTheKit.Core/ChartNote.cs @@ -4,7 +4,11 @@ namespace HitTheKit.Core { public sealed class ChartNote { - public ChartNote(double timeSeconds, DrumPad pad) + public ChartNote( + double timeSeconds, + DrumPad pad, + int? velocity = null, + DrumArticulation articulation = DrumArticulation.Default) { if (!IsFinite(timeSeconds) || timeSeconds < 0) { @@ -13,14 +17,24 @@ public ChartNote(double timeSeconds, DrumPad pad) "A chart note time must be finite and non-negative."); } + if (velocity.HasValue && (velocity.Value < 1 || velocity.Value > 127)) + throw new ArgumentOutOfRangeException(nameof(velocity), "Target velocity must be between 1 and 127."); + DrumArticulationValidator.EnsureValid(pad, articulation); + TimeSeconds = timeSeconds; Pad = pad; + Velocity = velocity; + Articulation = articulation; } public double TimeSeconds { get; } public DrumPad Pad { get; } + public int? Velocity { get; } + + public DrumArticulation Articulation { get; } + private static bool IsFinite(double value) { return !double.IsNaN(value) && !double.IsInfinity(value); diff --git a/src/HitTheKit.Core/DrumArticulation.cs b/src/HitTheKit.Core/DrumArticulation.cs new file mode 100644 index 0000000..c4bac2c --- /dev/null +++ b/src/HitTheKit.Core/DrumArticulation.cs @@ -0,0 +1,66 @@ +using System; + +namespace HitTheKit.Core +{ + public enum DrumArticulation + { + Default, + Head, + Rim, + Bow, + Edge, + Bell, + Closed, + HalfOpen, + Open, + Pedal, + Choke + } + + public static class DrumArticulationValidator + { + public static bool IsValid(DrumPad pad, DrumArticulation articulation) + { + if (!Enum.IsDefined(typeof(DrumPad), pad) || + !Enum.IsDefined(typeof(DrumArticulation), articulation)) + return false; + if (articulation == DrumArticulation.Default) return true; + + switch (pad) + { + case DrumPad.Kick: + return false; + case DrumPad.Snare: + case DrumPad.Tom1: + case DrumPad.Tom2: + case DrumPad.FloorTom: + return articulation == DrumArticulation.Head || articulation == DrumArticulation.Rim; + case DrumPad.HiHat: + return articulation == DrumArticulation.Bow || + articulation == DrumArticulation.Edge || + articulation == DrumArticulation.Closed || + articulation == DrumArticulation.HalfOpen || + articulation == DrumArticulation.Open || + articulation == DrumArticulation.Pedal || + articulation == DrumArticulation.Choke; + case DrumPad.Crash: + return articulation == DrumArticulation.Bow || + articulation == DrumArticulation.Edge || + articulation == DrumArticulation.Choke; + case DrumPad.Ride: + return articulation == DrumArticulation.Bow || + articulation == DrumArticulation.Edge || + articulation == DrumArticulation.Bell || + articulation == DrumArticulation.Choke; + default: + return false; + } + } + + public static void EnsureValid(DrumPad pad, DrumArticulation articulation) + { + if (!IsValid(pad, articulation)) + throw new ArgumentException($"Articulation '{articulation}' is not valid for drum pad '{pad}'."); + } + } +} diff --git a/src/HitTheKit.Core/DrumHit.cs b/src/HitTheKit.Core/DrumHit.cs index 88c8acf..1514ad7 100644 --- a/src/HitTheKit.Core/DrumHit.cs +++ b/src/HitTheKit.Core/DrumHit.cs @@ -4,7 +4,11 @@ namespace HitTheKit.Core { public readonly struct DrumHit { - public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127) + public DrumHit( + DrumPad pad, + double timeSeconds, + int velocity = 127, + DrumArticulation articulation = DrumArticulation.Default) { if (double.IsNaN(timeSeconds) || double.IsInfinity(timeSeconds)) { @@ -20,9 +24,11 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127) "Velocity must be between 0 and 127."); } + DrumArticulationValidator.EnsureValid(pad, articulation); Pad = pad; TimeSeconds = timeSeconds; Velocity = velocity; + Articulation = articulation; } public DrumPad Pad { get; } @@ -30,5 +36,7 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127) public double TimeSeconds { get; } public int Velocity { get; } + + public DrumArticulation Articulation { get; } } } diff --git a/src/HitTheKit.Core/GuidedLatencySoundCheck.cs b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs new file mode 100644 index 0000000..4cc358c --- /dev/null +++ b/src/HitTheKit.Core/GuidedLatencySoundCheck.cs @@ -0,0 +1,153 @@ +using System; + +namespace HitTheKit.Core +{ + public enum GuidedSoundCheckState + { + Idle, + Running, + Complete + } + + public enum GuidedSoundCheckInput + { + Keyboard, + Midi + } + + public sealed class GuidedSoundCheckSnapshot + { + internal GuidedSoundCheckSnapshot( + GuidedSoundCheckState state, + GuidedSoundCheckInput source, + int targetCount, + int acceptedCount, + int missedCount, + double? nextTargetTimeSeconds, + TimingCalibrationSnapshot calibration) + { + State = state; + Source = source; + TargetCount = targetCount; + AcceptedCount = acceptedCount; + MissedCount = missedCount; + NextTargetTimeSeconds = nextTargetTimeSeconds; + Calibration = calibration ?? throw new ArgumentNullException(nameof(calibration)); + } + + public GuidedSoundCheckState State { get; } + public GuidedSoundCheckInput Source { get; } + public int TargetCount { get; } + public int AcceptedCount { get; } + public int MissedCount { get; } + public int ResolvedCount => AcceptedCount + MissedCount; + public double? NextTargetTimeSeconds { get; } + public TimingCalibrationSnapshot Calibration { get; } + public bool CanApplyRecommendation => State == GuidedSoundCheckState.Complete && Calibration.HasRecommendation; + } + + public sealed class GuidedLatencySoundCheck + { + public const int DefaultTargetCount = 12; + public const double DefaultIntervalSeconds = 0.5; + + private readonly TimingCalibrationAdvisor advisor = new TimingCalibrationAdvisor(); + private readonly int targetCount; + private GuidedSoundCheckState state; + private GuidedSoundCheckInput source; + private double firstTargetTimeSeconds; + private double intervalSeconds; + private int targetIndex; + private int acceptedCount; + private int missedCount; + + public GuidedLatencySoundCheck(int targetCount = DefaultTargetCount) + { + if (targetCount < TimingCalibrationAdvisor.MinimumSamples) + throw new ArgumentOutOfRangeException(nameof(targetCount)); + this.targetCount = targetCount; + } + + public GuidedSoundCheckSnapshot Snapshot => new GuidedSoundCheckSnapshot( + state, + source, + targetCount, + acceptedCount, + missedCount, + state == GuidedSoundCheckState.Running ? TargetTime(targetIndex) : (double?)null, + advisor.Snapshot); + + public void Begin(GuidedSoundCheckInput source, double firstTargetTimeSeconds, double intervalSeconds = DefaultIntervalSeconds) + { + if (source != GuidedSoundCheckInput.Keyboard && source != GuidedSoundCheckInput.Midi) + throw new ArgumentOutOfRangeException(nameof(source)); + if (!IsFinite(firstTargetTimeSeconds) || firstTargetTimeSeconds < 0) + throw new ArgumentOutOfRangeException(nameof(firstTargetTimeSeconds)); + if (!IsFinite(intervalSeconds) || intervalSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalSeconds)); + + this.source = source; + this.firstTargetTimeSeconds = firstTargetTimeSeconds; + this.intervalSeconds = intervalSeconds; + targetIndex = 0; + acceptedCount = 0; + missedCount = 0; + advisor.Reset(); + state = GuidedSoundCheckState.Running; + } + + public bool TryRecord(GuidedSoundCheckInput inputSource, double hitTimeSeconds) + { + if (!IsFinite(hitTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(hitTimeSeconds)); + if (state != GuidedSoundCheckState.Running || inputSource != source) return false; + Advance(hitTimeSeconds); + if (state != GuidedSoundCheckState.Running) return false; + + double delta = hitTimeSeconds - TargetTime(targetIndex); + if (Math.Abs(delta) > TimingWindows.Default.HitSeconds) return false; + advisor.Add(delta); + acceptedCount++; + targetIndex++; + CompleteIfResolved(); + return true; + } + + public void Advance(double currentTimeSeconds) + { + if (!IsFinite(currentTimeSeconds)) throw new ArgumentOutOfRangeException(nameof(currentTimeSeconds)); + if (state != GuidedSoundCheckState.Running) return; + while (targetIndex < targetCount && + currentTimeSeconds > TargetTime(targetIndex) + TimingWindows.Default.HitSeconds) + { + missedCount++; + targetIndex++; + } + CompleteIfResolved(); + } + + public double RecommendOffsetSeconds(double currentOffsetSeconds) + { + if (!Snapshot.CanApplyRecommendation) + throw new InvalidOperationException("The sound check does not have enough accepted hits."); + return advisor.RecommendOffsetSeconds(currentOffsetSeconds); + } + + public void Reset() + { + state = GuidedSoundCheckState.Idle; + targetIndex = 0; + acceptedCount = 0; + missedCount = 0; + advisor.Reset(); + } + + private double TargetTime(int index) => firstTargetTimeSeconds + index * intervalSeconds; + + private void CompleteIfResolved() + { + if (targetIndex >= targetCount) state = GuidedSoundCheckState.Complete; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } +} diff --git a/src/HitTheKit.Core/HitMatcher.cs b/src/HitTheKit.Core/HitMatcher.cs index 55468b6..32cdef9 100644 --- a/src/HitTheKit.Core/HitMatcher.cs +++ b/src/HitTheKit.Core/HitMatcher.cs @@ -47,7 +47,8 @@ public bool TryMatch( "The candidate list cannot contain null notes.", nameof(notes)); - if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad) + if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad || + note.Articulation != DrumArticulation.Default && note.Articulation != hit.Articulation) { continue; } diff --git a/src/HitTheKit.Core/PerformanceGhostReplay.cs b/src/HitTheKit.Core/PerformanceGhostReplay.cs new file mode 100644 index 0000000..29e5fb5 --- /dev/null +++ b/src/HitTheKit.Core/PerformanceGhostReplay.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace HitTheKit.Core +{ + public sealed class GhostReplayHit + { + public GhostReplayHit(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade) + { + if (!IsFinite(timeSeconds) || timeSeconds < 0) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad)); + if (velocity < 0 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity)); + if (grade.HasValue && !Enum.IsDefined(typeof(HitGrade), grade.Value)) + throw new ArgumentOutOfRangeException(nameof(grade)); + TimeSeconds = timeSeconds; + Pad = pad; + Velocity = velocity; + Grade = grade; + } + + public double TimeSeconds { get; } + public DrumPad Pad { get; } + public int Velocity { get; } + public HitGrade? Grade { get; } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class PerformanceGhostReplay + { + public const int MaximumHitsPerTake = 8192; + + private readonly List current = new List(); + private IReadOnlyList ghost = Array.Empty(); + + public IReadOnlyList Ghost => ghost; + public int CurrentHitCount => current.Count; + public bool HasGhost => ghost.Count > 0; + + public bool Record(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade) + { + if (timeSeconds < 0) return false; + if (current.Count >= MaximumHitsPerTake) return false; + current.Add(new GhostReplayHit(timeSeconds, pad, velocity, grade)); + return true; + } + + public bool CommitCurrentTake() + { + if (current.Count == 0) return false; + var copy = current.ToArray(); + Array.Sort(copy, CompareHits); + ghost = Array.AsReadOnly(copy); + current.Clear(); + return true; + } + + public void ResetCurrent() => current.Clear(); + public void ClearGhost() => ghost = Array.Empty(); + + private static int CompareHits(GhostReplayHit left, GhostReplayHit right) + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.Pad.CompareTo(right.Pad); + } + } +} diff --git a/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs new file mode 100644 index 0000000..473ebb4 --- /dev/null +++ b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; + +namespace HitTheKit.Core +{ + public sealed class PracticeSectionDefinition + { + public PracticeSectionDefinition(int index, string label, double startSeconds, double endSeconds) + { + if (index < 0) throw new ArgumentOutOfRangeException(nameof(index)); + if (string.IsNullOrWhiteSpace(label)) throw new ArgumentException("Section label is required.", nameof(label)); + if (!IsFinite(startSeconds) || startSeconds < 0) throw new ArgumentOutOfRangeException(nameof(startSeconds)); + if (!IsFinite(endSeconds) || endSeconds <= startSeconds) throw new ArgumentOutOfRangeException(nameof(endSeconds)); + Index = index; + Label = label; + StartSeconds = startSeconds; + EndSeconds = endSeconds; + } + + public int Index { get; } + public string Label { get; } + public double StartSeconds { get; } + public double EndSeconds { get; } + + public bool Contains(double noteTimeSeconds) => + noteTimeSeconds >= StartSeconds && noteTimeSeconds < EndSeconds; + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class PracticeErrorCell + { + internal PracticeErrorCell( + PracticeSectionDefinition section, + DrumPad pad, + int perfect, + int good, + int early, + int late, + int miss) + { + Section = section; + Pad = pad; + Perfect = perfect; + Good = good; + Early = early; + Late = late; + Miss = miss; + } + + public PracticeSectionDefinition Section { get; } + public DrumPad Pad { get; } + public int Perfect { get; } + public int Good { get; } + public int Early { get; } + public int Late { get; } + public int Miss { get; } + public int Resolved => Perfect + Good + Early + Late + Miss; + public double Accuracy => Resolved == 0 + ? 0 + : (Perfect + Good * 0.75 + (Early + Late) * 0.5) * 100.0 / Resolved; + } + + public sealed class PracticeErrorMapAnalyzer + { + private readonly IReadOnlyList sections; + private readonly Dictionary cells = new Dictionary(); + + public PracticeErrorMapAnalyzer(IReadOnlyList sections) + { + if (sections == null) throw new ArgumentNullException(nameof(sections)); + if (sections.Count == 0) throw new ArgumentException("At least one practice section is required.", nameof(sections)); + var copy = new PracticeSectionDefinition[sections.Count]; + for (int index = 0; index < sections.Count; index++) + { + PracticeSectionDefinition section = sections[index] + ?? throw new ArgumentException("Practice sections cannot contain null entries.", nameof(sections)); + if (section.Index != index) + throw new ArgumentException("Practice section indices must be contiguous and ordered.", nameof(sections)); + if (index > 0 && Math.Abs(copy[index - 1].EndSeconds - section.StartSeconds) > 0.000001) + throw new ArgumentException("Practice sections must be contiguous.", nameof(sections)); + copy[index] = section; + } + this.sections = Array.AsReadOnly(copy); + } + + public IReadOnlyList Sections => sections; + + public void Record(HitResult result) + { + if (result == null) throw new ArgumentNullException(nameof(result)); + PracticeSectionDefinition? section = FindSection(result.Note.TimeSeconds); + if (section == null) return; + var key = new CellKey(section.Index, result.Note.Pad); + if (!cells.TryGetValue(key, out MutableCell cell)) + { + cell = new MutableCell(); + cells.Add(key, cell); + } + switch (result.Grade) + { + case HitGrade.Perfect: cell.Perfect++; break; + case HitGrade.Good: cell.Good++; break; + case HitGrade.Early: cell.Early++; break; + case HitGrade.Late: cell.Late++; break; + case HitGrade.Miss: cell.Miss++; break; + default: throw new ArgumentOutOfRangeException(); + } + } + + public IReadOnlyList Snapshot() + { + var result = new List(cells.Count); + foreach (KeyValuePair pair in cells) + { + MutableCell value = pair.Value; + result.Add(new PracticeErrorCell( + sections[pair.Key.SectionIndex], + pair.Key.Pad, + value.Perfect, + value.Good, + value.Early, + value.Late, + value.Miss)); + } + result.Sort(CompareCells); + return result.AsReadOnly(); + } + + public PracticeErrorCell? Weakest() + { + IReadOnlyList snapshot = Snapshot(); + if (snapshot.Count == 0) return null; + PracticeErrorCell weakest = snapshot[0]; + for (int index = 1; index < snapshot.Count; index++) + { + PracticeErrorCell candidate = snapshot[index]; + if (candidate.Accuracy < weakest.Accuracy || + (Math.Abs(candidate.Accuracy - weakest.Accuracy) < 0.0001 && candidate.Resolved > weakest.Resolved)) + weakest = candidate; + } + return weakest; + } + + public void Reset() => cells.Clear(); + + private PracticeSectionDefinition? FindSection(double noteTimeSeconds) + { + for (int index = 0; index < sections.Count; index++) + if (sections[index].Contains(noteTimeSeconds)) return sections[index]; + return null; + } + + private static int CompareCells(PracticeErrorCell left, PracticeErrorCell right) + { + int bySection = left.Section.Index.CompareTo(right.Section.Index); + return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad); + } + + private readonly struct CellKey : IEquatable + { + public CellKey(int sectionIndex, DrumPad pad) + { + SectionIndex = sectionIndex; + Pad = pad; + } + + public int SectionIndex { get; } + public DrumPad Pad { get; } + public bool Equals(CellKey other) => SectionIndex == other.SectionIndex && Pad == other.Pad; + public override bool Equals(object obj) => obj is CellKey other && Equals(other); + public override int GetHashCode() => (SectionIndex * 397) ^ (int)Pad; + } + + private sealed class MutableCell + { + public int Perfect; + public int Good; + public int Early; + public int Late; + public int Miss; + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs index 6e6f8c6..1aac2aa 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClock.cs @@ -75,6 +75,27 @@ public void Resume() IsPaused = false; } + public void Seek(double positionSeconds) + { + EnsureScheduled(); + EnsureFinite(positionSeconds, nameof(positionSeconds)); + if (positionSeconds < 0 || positionSeconds >= DurationSeconds) + { + throw new ArgumentOutOfRangeException( + nameof(positionSeconds), + "Song position must be within the scheduled duration."); + } + + if (IsPaused) + { + StartDspTime = pausedAtDspTime - positionSeconds; + } + else + { + StartDspTime = timeSource.Now - positionSeconds; + } + } + private static void EnsureFinite(double value, string parameterName) { if (double.IsNaN(value) || double.IsInfinity(value)) diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs index 3735fbd..dc24a2b 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs @@ -25,6 +25,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour private string externalAudioPath; private double audioPlaybackSpeed = 1.0; private UnityWebRequest audioRequest; + private bool seekedWhilePaused; public DspSongClock Clock { get; private set; } public double StartDspTime => Clock != null && Clock.IsScheduled ? Clock.StartDspTime : double.NaN; @@ -37,6 +38,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour public double CountInSeconds => countInBeats * 60.0 / bpm; public string ExternalAudioPath => externalAudioPath; public double AudioPlaybackSpeed => audioPlaybackSpeed; + public bool IsPreviewing { get; private set; } public string LoadError { get; private set; } public void Configure( @@ -141,7 +143,15 @@ public void ResumePlayback() { if (Clock == null || !Clock.IsPaused) return; Clock.Resume(); - audioSource.UnPause(); + if (seekedWhilePaused) + { + audioSource.Play(); + seekedWhilePaused = false; + } + else + { + audioSource.UnPause(); + } } public void RestartPlayback() @@ -153,8 +163,60 @@ public void RestartPlayback() completedLogged = false; } + public void SeekPlayback(double positionSeconds) + { + if (generatedClip == null || Clock == null || !Clock.IsScheduled) + throw new InvalidOperationException("Song playback must be scheduled before seeking."); + if (double.IsNaN(positionSeconds) || double.IsInfinity(positionSeconds) || + positionSeconds < 0 || positionSeconds >= Clock.DurationSeconds) + throw new ArgumentOutOfRangeException(nameof(positionSeconds)); + + bool wasPaused = Clock.IsPaused; + double clipPosition = Math.Min( + generatedClip.length - (1.0 / Math.Max(1, generatedClip.frequency)), + positionSeconds * audioPlaybackSpeed); + + audioSource.Stop(); + audioSource.time = (float)Math.Max(0, clipPosition); + Clock.Seek(positionSeconds); + if (wasPaused) + { + seekedWhilePaused = true; + } + else + { + audioSource.Play(); + seekedWhilePaused = false; + } + startedLogged = positionSeconds >= 0; + completedLogged = false; + } + + public void PreviewFromSourceTime(double sourceTimeSeconds, double speed = 1.0) + { + if (generatedClip == null || audioSource == null) + throw new InvalidOperationException("Song audio is not loaded."); + if (double.IsNaN(sourceTimeSeconds) || double.IsInfinity(sourceTimeSeconds) || + sourceTimeSeconds < 0 || sourceTimeSeconds >= generatedClip.length) + throw new ArgumentOutOfRangeException(nameof(sourceTimeSeconds)); + if (double.IsNaN(speed) || double.IsInfinity(speed) || speed <= 0 || speed > 3) + throw new ArgumentOutOfRangeException(nameof(speed)); + audioSource.Stop(); + audioSource.pitch = (float)speed; + audioSource.time = (float)sourceTimeSeconds; + audioSource.Play(); + IsPreviewing = true; + } + + public void StopPreview() + { + if (audioSource != null) audioSource.Stop(); + IsPreviewing = false; + } + private void SchedulePlayback() { + IsPreviewing = false; audioSource.clip = generatedClip; audioSource.pitch = (float)audioPlaybackSpeed; var timeSource = new UnityDspTimeSource(); @@ -162,6 +224,7 @@ private void SchedulePlayback() Clock = new DspSongClock(timeSource); Clock.Schedule(startDspTime, generatedClip.length / audioPlaybackSpeed); audioSource.PlayScheduled(startDspTime); + seekedWhilePaused = false; } private void Update() diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs index 44880b2..e95a9b7 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs @@ -75,7 +75,17 @@ public LoadedChart Load(string json, string difficulty) } DrumPad pad = ParsePad(noteDto.pad, index); - notes.Add(new IndexedChartNote(new ChartNote(noteDto.time, pad), index)); + int? velocity = noteDto.velocity == int.MinValue ? (int?)null : noteDto.velocity; + DrumArticulation articulation = ParseArticulation(noteDto.articulation, pad, index); + try + { + notes.Add(new IndexedChartNote( + new ChartNote(noteDto.time, pad, velocity, articulation), index)); + } + catch (ArgumentException exception) + { + throw new ChartLoadException($"Chart note at index {index} is invalid: {exception.Message}", exception); + } } notes.Sort((left, right) => @@ -161,6 +171,34 @@ private static DrumPad ParsePad(string pad, int index) } } + private static DrumArticulation ParseArticulation(string value, DrumPad pad, int index) + { + DrumArticulation articulation; + switch (value) + { + case null: + case "default": articulation = DrumArticulation.Default; break; + case "head": articulation = DrumArticulation.Head; break; + case "rim": articulation = DrumArticulation.Rim; break; + case "bow": articulation = DrumArticulation.Bow; break; + case "edge": articulation = DrumArticulation.Edge; break; + case "bell": articulation = DrumArticulation.Bell; break; + case "closed": articulation = DrumArticulation.Closed; break; + case "halfOpen": articulation = DrumArticulation.HalfOpen; break; + case "open": articulation = DrumArticulation.Open; break; + case "pedal": articulation = DrumArticulation.Pedal; break; + case "choke": articulation = DrumArticulation.Choke; break; + default: + throw new ChartLoadException( + $"Chart note at index {index} has unknown articulation '{value}'."); + } + + if (!DrumArticulationValidator.IsValid(pad, articulation)) + throw new ChartLoadException( + $"Chart note at index {index} articulation '{value}' is invalid for pad '{pad}'."); + return articulation; + } + private static bool IsFinite(double value) { return !double.IsNaN(value) && !double.IsInfinity(value); @@ -190,6 +228,8 @@ private sealed class ChartNoteDto { public double time = double.NaN; public string pad; + public int velocity = int.MinValue; + public string articulation; } } } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs index 22b2bd7..e04042f 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimeline.cs @@ -85,6 +85,22 @@ public IReadOnlyList GetElapsed(double songPositionSeconds) return result.AsReadOnly(); } + public IReadOnlyList GetRange(double startSeconds, double endSeconds) + { + ValidateFinite(startSeconds, nameof(startSeconds)); + ValidateFinite(endSeconds, nameof(endSeconds)); + if (startSeconds < 0 || endSeconds <= startSeconds) + throw new ArgumentOutOfRangeException(nameof(endSeconds), "Range end must be after a non-negative start."); + + var result = new List(); + foreach (TimelineNote note in notes) + { + if (note.EffectiveTimeSeconds >= startSeconds && note.EffectiveTimeSeconds < endSeconds) + result.Add(note); + } + return result.AsReadOnly(); + } + private static void ValidateFinite(double value, string parameterName) { if (!IsFinite(value)) diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs index cbbc55b..e66a68d 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs @@ -65,9 +65,27 @@ private void Start() public IReadOnlyList CreateMatchingNotes() { if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started."); - var result = new ChartNote[Timeline.Notes.Count]; + return CreateMatchingNotes(Timeline.Notes); + } + + public IReadOnlyList CreateMatchingNotes(double startSeconds, double endSeconds) + { + if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started."); + return CreateMatchingNotes(Timeline.GetRange(startSeconds, endSeconds)); + } + + private static IReadOnlyList CreateMatchingNotes(IReadOnlyList source) + { + var result = new ChartNote[source.Count]; for (int index = 0; index < result.Length; index++) - result[index] = new ChartNote(Timeline.Notes[index].EffectiveTimeSeconds, Timeline.Notes[index].Note.Pad); + { + ChartNote note = source[index].Note; + result[index] = new ChartNote( + source[index].EffectiveTimeSeconds, + note.Pad, + note.Velocity, + note.Articulation); + } return Array.AsReadOnly(result); } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs index e4f544b..c9541d9 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs @@ -232,8 +232,28 @@ public MvpDrumInputMappingStatus Map( pad, hit.Velocity, hit.TimestampSeconds.Value, - DrumInputSource.Midi); + DrumInputSource.Midi, + ToDrumArticulation(hit.Articulation)); return MvpDrumInputMappingStatus.Mapped; } + + private static DrumArticulation ToDrumArticulation(KitArticulation articulation) + { + switch (articulation) + { + case KitArticulation.Default: return DrumArticulation.Default; + case KitArticulation.Head: return DrumArticulation.Head; + case KitArticulation.Rim: return DrumArticulation.Rim; + case KitArticulation.Bow: return DrumArticulation.Bow; + case KitArticulation.Edge: return DrumArticulation.Edge; + case KitArticulation.Bell: return DrumArticulation.Bell; + case KitArticulation.Closed: return DrumArticulation.Closed; + case KitArticulation.HalfOpen: return DrumArticulation.HalfOpen; + case KitArticulation.Open: return DrumArticulation.Open; + case KitArticulation.Pedal: return DrumArticulation.Pedal; + case KitArticulation.Choke: return DrumArticulation.Choke; + default: throw new ArgumentOutOfRangeException(nameof(articulation)); + } + } } } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs new file mode 100644 index 0000000..00b0d11 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs @@ -0,0 +1,240 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class ChartAuthoringAudioRequest + { + public ChartAuthoringAudioRequest( + string sourceAudioPath, + string title, + string artist, + double bpm, + int bars, + int beatsPerBar) + { + if (string.IsNullOrWhiteSpace(sourceAudioPath)) + throw new ArgumentException("An audio file is required.", nameof(sourceAudioPath)); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title)); + if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist)); + if (!IsFinite(bpm) || bpm <= 0 || bpm > 400) throw new ArgumentOutOfRangeException(nameof(bpm)); + if (bars <= 0 || bars > 10000) throw new ArgumentOutOfRangeException(nameof(bars)); + if (beatsPerBar <= 0 || beatsPerBar > 32) throw new ArgumentOutOfRangeException(nameof(beatsPerBar)); + + SourceAudioPath = Path.GetFullPath(sourceAudioPath); + Title = title.Trim(); + Artist = artist.Trim(); + Bpm = bpm; + Bars = bars; + BeatsPerBar = beatsPerBar; + } + + public string SourceAudioPath { get; } + public string Title { get; } + public string Artist { get; } + public double Bpm { get; } + public int Bars { get; } + public int BeatsPerBar { get; } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartAuthoringAudioImportResult + { + internal ChartAuthoringAudioImportResult(SongLibraryEntry song, string sourcePath) + { + Song = song; + SourcePath = sourcePath; + } + + public SongLibraryEntry Song { get; } + public string SourcePath { get; } + } + + public sealed class ChartAuthoringAudioImportException : Exception + { + public ChartAuthoringAudioImportException(string message) : base(message) { } + public ChartAuthoringAudioImportException(string message, Exception innerException) : base(message, innerException) { } + } + + public sealed class ChartAuthoringAudioImporter + { + public const long MaximumAudioBytes = 1024L * 1024 * 1024; + private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false); + + public ChartAuthoringAudioImportResult Import( + ChartAuthoringAudioRequest request, + string libraryRoot, + DateTimeOffset createdAtUtc) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(libraryRoot)) + throw new ArgumentException("Song-library root is required.", nameof(libraryRoot)); + if (createdAtUtc.Offset != TimeSpan.Zero) + throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc)); + + string source = ValidateSource(request.SourceAudioPath); + string root = Path.GetFullPath(libraryRoot); + Directory.CreateDirectory(root); + string baseId = BuildId(request.Artist, request.Title, createdAtUtc); + string songId = UniqueSongId(root, baseId); + string destination = Path.Combine(root, songId); + string temporary = Path.Combine(root, $".hitthekit-audio-{Guid.NewGuid():N}"); + string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant(); + Directory.CreateDirectory(temporary); + + try + { + string copiedAudio = Path.Combine(temporary, audioFileName); + File.Copy(source, copiedAudio, false); + string manifest = Manifest(songId, request, audioFileName); + File.WriteAllText(Path.Combine(temporary, SongLibraryDiscovery.ManifestFileName), manifest, Utf8WithoutBom); + SongLibraryEntry validated = new SongLibraryDiscovery().Parse( + manifest, + temporary, + SongLibraryOrigin.UserFolder); + if (!validated.CanAuthorChart || validated.IsPlayable) + throw new ChartAuthoringAudioImportException("The imported audio did not produce a valid authoring source."); + + Directory.Move(temporary, destination); + SongLibraryEntry published = new SongLibraryDiscovery().Parse( + File.ReadAllText(Path.Combine(destination, SongLibraryDiscovery.ManifestFileName)), + destination, + SongLibraryOrigin.UserFolder); + return new ChartAuthoringAudioImportResult(published, source); + } + catch (ChartAuthoringAudioImportException) + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw; + } + catch (Exception exception) + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw new ChartAuthoringAudioImportException("The selected audio could not be imported.", exception); + } + } + + public static string ValidateSource(string sourceAudioPath) + { + if (string.IsNullOrWhiteSpace(sourceAudioPath)) + throw new ChartAuthoringAudioImportException("Select a WAV or OGG audio file."); + string source = Path.GetFullPath(sourceAudioPath); + var info = new FileInfo(source); + if (!info.Exists || info.Length <= 0 || info.Length > MaximumAudioBytes) + throw new ChartAuthoringAudioImportException("Audio must be between 1 byte and 1 GiB."); + if ((File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0) + throw new ChartAuthoringAudioImportException("The selected audio cannot be a symbolic link."); + string extension = Path.GetExtension(source); + if (!string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".ogg", StringComparison.OrdinalIgnoreCase)) + throw new ChartAuthoringAudioImportException("Chart Creator supports WAV and OGG audio."); + return source; + } + + private static string UniqueSongId(string root, string baseId) + { + string candidate = baseId; + int suffix = 2; + while (Directory.Exists(Path.Combine(root, candidate)) || + File.Exists(Path.Combine(root, candidate + HtkSongPackageService.Extension))) + candidate = $"{baseId}-{suffix++}"; + ChartCreatorMetadata.ValidateIdentifier(candidate, nameof(baseId)); + return candidate; + } + + private static string BuildId(string artist, string title, DateTimeOffset createdAtUtc) + { + string text = (artist + "-" + title).Normalize(NormalizationForm.FormD); + var id = new StringBuilder(64); + bool separator = false; + for (int index = 0; index < text.Length && id.Length < 48; index++) + { + char value = char.ToLowerInvariant(text[index]); + if (value >= 'a' && value <= 'z' || value >= '0' && value <= '9') + { + id.Append(value); + separator = false; + } + else if (id.Length > 0 && !separator) + { + id.Append('-'); + separator = true; + } + } + string prefix = id.ToString().Trim('-'); + if (prefix.Length == 0) prefix = "local-song"; + return $"{prefix}-authoring-{createdAtUtc:yyyyMMdd-HHmmss}"; + } + + private static string Manifest( + string songId, + ChartAuthoringAudioRequest request, + string audioFileName) + { + return "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"id\": \"{Escape(songId)}\",\n" + + $" \"title\": \"{Escape(request.Title)}\",\n" + + $" \"artist\": \"{Escape(request.Artist)}\",\n" + + $" \"bpm\": {request.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" + + $" \"bars\": {request.Bars},\n" + + $" \"beatsPerBar\": {request.BeatsPerBar},\n" + + " \"difficultyHint\": \"New local chart\",\n" + + " \"sortOrder\": 0,\n" + + " \"audioAvailability\": \"available\",\n" + + $" \"audioFile\": \"{Escape(audioFileName)}\",\n" + + " \"chartAvailability\": \"unavailable\"\n" + + "}\n"; + } + + private static string Escape(string value) => value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + + public interface IChartAuthoringAudioPicker + { + string PickAudioFile(); + } + + public sealed class MacOsChartAuthoringAudioPicker : IChartAuthoringAudioPicker + { + public string PickAudioFile() + { +#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX + const string script = + "const app = Application.currentApplication(); " + + "app.includeStandardAdditions = true; " + + "const chosen = app.chooseFile({withPrompt: 'Choose a WAV or OGG backing track'}); " + + "Path(chosen).toString();"; + var start = new ProcessStartInfo + { + FileName = "/usr/bin/osascript", + Arguments = "-l JavaScript -e \"" + + script.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using (Process process = Process.Start(start)) + { + if (process == null) throw new InvalidOperationException("The macOS audio picker could not start."); + string output = process.StandardOutput.ReadToEnd(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 ? output.Trim() : null; + } +#else + throw new PlatformNotSupportedException("The native audio picker currently supports macOS."); +#endif + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta new file mode 100644 index 0000000..f8f807d --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e8f45ac662e4b50b9fd231315fd1419 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs new file mode 100644 index 0000000..1b5634f --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Text; +using HitTheKit.Core; +using HitTheKit.Unity.Charts; +using HitTheKit.Unity.Input; + +namespace HitTheKit.Unity.Gameplay +{ + public enum ChartQuantization + { + None, + EighthNote, + SixteenthNote + } + + public sealed class RecordedChartHit + { + internal RecordedChartHit(DrumInputEvent input, int recordingIndex) + { + Pad = input.Pad; + Velocity = input.Velocity; + TimeSeconds = input.SongTimeSeconds; + Source = input.Source; + Articulation = input.Articulation; + RecordingIndex = recordingIndex; + } + + public DrumPad Pad { get; } + public int Velocity { get; } + public double TimeSeconds { get; } + public DrumInputSource Source { get; } + public DrumArticulation Articulation { get; } + internal int RecordingIndex { get; } + } + + public sealed class ChartRecordingDraft + { + internal ChartRecordingDraft(double durationSeconds, IReadOnlyList hits) + { + DurationSeconds = durationSeconds; + var copy = new RecordedChartHit[hits.Count]; + for (int index = 0; index < hits.Count; index++) copy[index] = hits[index]; + Hits = Array.AsReadOnly(copy); + } + + public double DurationSeconds { get; } + public IReadOnlyList Hits { get; } + } + + public sealed class ChartRecordingSession + { + public const int MaximumHits = 100000; + private readonly double durationSeconds; + private readonly double outputTimeScale; + private readonly List hits = new List(); + + public ChartRecordingSession(double durationSeconds, double outputTimeScale = 1.0) + { + if (!IsFinite(durationSeconds) || durationSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(durationSeconds)); + if (!IsFinite(outputTimeScale) || outputTimeScale <= 0) + throw new ArgumentOutOfRangeException(nameof(outputTimeScale)); + this.durationSeconds = durationSeconds; + this.outputTimeScale = outputTimeScale; + } + + public bool IsRecording { get; private set; } = true; + public int HitCount => hits.Count; + public int IgnoredCount { get; private set; } + + public bool Record(DrumInputEvent input) + { + if (!IsRecording) return false; + if (input.SongTimeSeconds < 0 || input.SongTimeSeconds > durationSeconds) + { + IgnoredCount++; + return false; + } + if (hits.Count >= MaximumHits) + throw new InvalidOperationException($"A chart recording cannot exceed {MaximumHits} hits."); + + hits.Add(new RecordedChartHit(input.WithSongTime(input.SongTimeSeconds * outputTimeScale), hits.Count)); + return true; + } + + public ChartRecordingDraft Finish() + { + IsRecording = false; + return new ChartRecordingDraft(durationSeconds * outputTimeScale, hits); + } + + public void Restart() + { + hits.Clear(); + IgnoredCount = 0; + IsRecording = true; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartCreatorMetadata + { + public ChartCreatorMetadata( + string sourceSongId, + string title, + string artist, + string difficulty, + double bpm, + int bars, + int beatsPerBar) + { + ValidateIdentifier(sourceSongId, nameof(sourceSongId)); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title)); + if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist)); + if (!Contains(ChartLoader.SupportedDifficulties, difficulty)) + throw new ArgumentOutOfRangeException(nameof(difficulty)); + if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm)); + if (bars <= 0) throw new ArgumentOutOfRangeException(nameof(bars)); + if (beatsPerBar <= 0) throw new ArgumentOutOfRangeException(nameof(beatsPerBar)); + + SourceSongId = sourceSongId; + Title = title.Trim(); + Artist = artist.Trim(); + Difficulty = difficulty; + Bpm = bpm; + Bars = bars; + BeatsPerBar = beatsPerBar; + } + + public string SourceSongId { get; } + public string Title { get; } + public string Artist { get; } + public string Difficulty { get; } + public double Bpm { get; } + public int Bars { get; } + public int BeatsPerBar { get; } + + internal static void ValidateIdentifier(string value, string parameter) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 80) + throw new ArgumentException("Song ID is required and must not exceed 80 characters.", parameter); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (!(character >= 'a' && character <= 'z') && + !(character >= '0' && character <= '9') && character != '-') + throw new ArgumentException("Song ID must use lowercase letters, numbers, and hyphens.", parameter); + } + } + + private static bool Contains(IReadOnlyList values, string value) + { + for (int index = 0; index < values.Count; index++) + if (string.Equals(values[index], value, StringComparison.Ordinal)) return true; + return false; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public static class ChartCreatorJson + { + public static string Serialize( + ChartRecordingDraft draft, + string difficulty, + double bpm, + ChartQuantization quantization) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + if (!Enum.IsDefined(typeof(ChartQuantization), quantization)) + throw new ArgumentOutOfRangeException(nameof(quantization)); + if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm)); + bool supported = false; + for (int index = 0; index < ChartLoader.SupportedDifficulties.Count; index++) + if (string.Equals(ChartLoader.SupportedDifficulties[index], difficulty, StringComparison.Ordinal)) + supported = true; + if (!supported) throw new ArgumentOutOfRangeException(nameof(difficulty)); + + var projected = new List(draft.Hits.Count); + for (int index = 0; index < draft.Hits.Count; index++) + { + RecordedChartHit hit = draft.Hits[index]; + double time = Quantize(hit.TimeSeconds, bpm, quantization); + time = Math.Max(0, Math.Min(draft.DurationSeconds, time)); + projected.Add(new ProjectedHit( + time, + hit.Pad, + hit.Velocity, + hit.Articulation, + hit.RecordingIndex)); + } + projected.Sort((left, right) => + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.RecordingIndex.CompareTo(right.RecordingIndex); + }); + + var json = new StringBuilder(128 + projected.Count * 48); + json.Append("{\n \"version\": 1,\n \"offsetSeconds\": 0,\n \"difficulties\": {\n \"") + .Append(difficulty) + .Append("\": ["); + for (int index = 0; index < projected.Count; index++) + { + ProjectedHit hit = projected[index]; + json.Append(index == 0 ? "\n " : ",\n ") + .Append("{ \"time\": ") + .Append(hit.TimeSeconds.ToString("0.#########", CultureInfo.InvariantCulture)) + .Append(", \"pad\": \"") + .Append(PadId(hit.Pad)) + .Append("\", \"velocity\": ") + .Append(hit.Velocity.ToString(CultureInfo.InvariantCulture)); + if (hit.Articulation != DrumArticulation.Default) + json.Append(", \"articulation\": \"") + .Append(ArticulationId(hit.Articulation)) + .Append('"'); + json.Append(" }"); + } + if (projected.Count > 0) json.Append('\n').Append(" "); + json.Append("]\n }\n}\n"); + + string result = json.ToString(); + new ChartLoader().Load(result, difficulty); + return result; + } + + private static double Quantize(double value, double bpm, ChartQuantization quantization) + { + int subdivisions; + switch (quantization) + { + case ChartQuantization.None: return value; + case ChartQuantization.EighthNote: subdivisions = 2; break; + case ChartQuantization.SixteenthNote: subdivisions = 4; break; + default: throw new ArgumentOutOfRangeException(nameof(quantization)); + } + double step = 60.0 / bpm / subdivisions; + return Math.Round(value / step, MidpointRounding.AwayFromZero) * step; + } + + private static string PadId(DrumPad pad) + { + switch (pad) + { + case DrumPad.Kick: return "kick"; + case DrumPad.Snare: return "snare"; + case DrumPad.HiHat: return "hiHat"; + case DrumPad.Tom1: return "tom1"; + case DrumPad.Tom2: return "tom2"; + case DrumPad.FloorTom: return "floorTom"; + case DrumPad.Crash: return "crash"; + case DrumPad.Ride: return "ride"; + default: throw new ArgumentOutOfRangeException(nameof(pad)); + } + } + + private static string ArticulationId(DrumArticulation articulation) + { + switch (articulation) + { + case DrumArticulation.Default: return "default"; + case DrumArticulation.Head: return "head"; + case DrumArticulation.Rim: return "rim"; + case DrumArticulation.Bow: return "bow"; + case DrumArticulation.Edge: return "edge"; + case DrumArticulation.Bell: return "bell"; + case DrumArticulation.Closed: return "closed"; + case DrumArticulation.HalfOpen: return "halfOpen"; + case DrumArticulation.Open: return "open"; + case DrumArticulation.Pedal: return "pedal"; + case DrumArticulation.Choke: return "choke"; + default: throw new ArgumentOutOfRangeException(nameof(articulation)); + } + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private sealed class ProjectedHit + { + public ProjectedHit( + double timeSeconds, + DrumPad pad, + int velocity, + DrumArticulation articulation, + int recordingIndex) + { + TimeSeconds = timeSeconds; + Pad = pad; + Velocity = velocity; + Articulation = articulation; + RecordingIndex = recordingIndex; + } + public double TimeSeconds { get; } + public DrumPad Pad { get; } + public int Velocity { get; } + public DrumArticulation Articulation { get; } + public int RecordingIndex { get; } + } + } + + public sealed class ChartCreatorExportResult + { + internal ChartCreatorExportResult( + string songId, + string folderPath, + string chartPath, + string manifestPath, + string packagePath, + string localAudioPath) + { + SongId = songId; + FolderPath = folderPath; + ChartPath = chartPath; + ManifestPath = manifestPath; + PackagePath = packagePath; + LocalAudioPath = localAudioPath; + } + public string SongId { get; } + public string FolderPath { get; } + public string ChartPath { get; } + public string ManifestPath { get; } + public string PackagePath { get; } + public string LocalAudioPath { get; } + public bool IsLocallyPlayable => !string.IsNullOrWhiteSpace(LocalAudioPath); + } + + public sealed class ChartCreatorExporter + { + private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false); + + public ChartCreatorExportResult ExportChartOnly( + ChartRecordingDraft draft, + ChartCreatorMetadata metadata, + ChartQuantization quantization, + string libraryRoot, + DateTimeOffset createdAtUtc, + string sourceAudioPath = null) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + if (draft.Hits.Count == 0) + throw new InvalidOperationException("A recorded chart must contain at least one hit."); + if (metadata == null) throw new ArgumentNullException(nameof(metadata)); + if (string.IsNullOrWhiteSpace(libraryRoot)) throw new ArgumentException("Library root is required.", nameof(libraryRoot)); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc)); + + string root = Path.GetFullPath(libraryRoot); + Directory.CreateDirectory(root); + string prefix = metadata.SourceSongId.Length <= 48 ? metadata.SourceSongId : metadata.SourceSongId.Substring(0, 48).TrimEnd('-'); + string baseId = $"{prefix}-take-{createdAtUtc:yyyyMMdd-HHmmss}"; + string songId = UniqueSongId(root, baseId); + string destination = Path.Combine(root, songId); + string packagePath = Path.Combine(root, songId + HtkSongPackageService.Extension); + string temporary = Path.Combine(root, $".hitthekit-chart-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temporary); + bool packagePublished = false; + try + { + string chartJson = ChartCreatorJson.Serialize(draft, metadata.Difficulty, metadata.Bpm, quantization); + string chartPath = Path.Combine(temporary, "notes.json"); + string manifestPath = Path.Combine(temporary, "song.json"); + File.WriteAllText(chartPath, chartJson, Utf8WithoutBom); + File.WriteAllText(manifestPath, Manifest(songId, metadata, null), Utf8WithoutBom); + + // First validate and publish the shareable chart-only package. Local audio is + // added only to the private folder afterwards and never enters .htksong v1. + new ChartLoader().Load(chartJson, metadata.Difficulty); + new SongLibraryDiscovery().Parse(File.ReadAllText(manifestPath), temporary, SongLibraryOrigin.UserFolder); + new HtkSongPackageService().CreateChartOnlyPackage(temporary, packagePath); + packagePublished = true; + + string localAudioPath = null; + if (!string.IsNullOrWhiteSpace(sourceAudioPath)) + { + string source = ChartAuthoringAudioImporter.ValidateSource(sourceAudioPath); + string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant(); + localAudioPath = Path.Combine(temporary, audioFileName); + File.Copy(source, localAudioPath, false); + File.WriteAllText(manifestPath, Manifest(songId, metadata, audioFileName), Utf8WithoutBom); + SongLibraryEntry local = new SongLibraryDiscovery().Parse( + File.ReadAllText(manifestPath), + temporary, + SongLibraryOrigin.UserFolder); + if (!local.IsPlayable) + throw new InvalidOperationException("The local recorded take did not become playable."); + } + + Directory.Move(temporary, destination); + return new ChartCreatorExportResult( + songId, + destination, + Path.Combine(destination, "notes.json"), + Path.Combine(destination, "song.json"), + packagePath, + localAudioPath == null ? null : Path.Combine(destination, Path.GetFileName(localAudioPath))); + } + catch + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + if (Directory.Exists(destination)) Directory.Delete(destination, true); + if (packagePublished && File.Exists(packagePath)) File.Delete(packagePath); + throw; + } + } + + private static string UniqueSongId(string root, string baseId) + { + string value = baseId; + int suffix = 2; + while (Directory.Exists(Path.Combine(root, value)) || + File.Exists(Path.Combine(root, value + HtkSongPackageService.Extension))) + value = $"{baseId}-{suffix++}"; + ChartCreatorMetadata.ValidateIdentifier(value, nameof(baseId)); + return value; + } + + private static string Manifest(string songId, ChartCreatorMetadata metadata, string audioFileName) + { + string audio = string.IsNullOrWhiteSpace(audioFileName) + ? " \"audioAvailability\": \"missing\",\n" + : " \"audioAvailability\": \"available\",\n" + + $" \"audioFile\": \"{Escape(audioFileName)}\",\n"; + return "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"id\": \"{Escape(songId)}\",\n" + + $" \"title\": \"{Escape(metadata.Title + " · Recorded Take")}\",\n" + + $" \"artist\": \"{Escape(metadata.Artist)}\",\n" + + $" \"bpm\": {metadata.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" + + $" \"bars\": {metadata.Bars},\n" + + $" \"beatsPerBar\": {metadata.BeatsPerBar},\n" + + " \"difficultyHint\": \"Recorded performance · review before sharing\",\n" + + " \"sortOrder\": 0,\n" + + audio + + " \"chartAvailability\": \"available\",\n" + + " \"chartFile\": \"notes.json\"\n" + + "}\n"; + } + + private static string Escape(string value) => value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta new file mode 100644 index 0000000..ea60eb3 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f78758e52aef4c5b8c593dc737ae4a4d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs new file mode 100644 index 0000000..6634fe6 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using HitTheKit.Core; +using HitTheKit.Unity.Input; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class EditableChartNote + { + internal EditableChartNote( + int identity, + DrumPad pad, + int velocity, + DrumArticulation articulation, + double timeSeconds, + DrumInputSource source) + { + Identity = identity; + Pad = pad; + Velocity = velocity; + Articulation = articulation; + TimeSeconds = timeSeconds; + Source = source; + } + + public int Identity { get; } + public DrumPad Pad { get; internal set; } + public int Velocity { get; internal set; } + public DrumArticulation Articulation { get; internal set; } + public double TimeSeconds { get; internal set; } + public DrumInputSource Source { get; } + } + + public sealed class ChartDraftEditor + { + private readonly double durationSeconds; + private readonly List notes; + private readonly ReadOnlyCollection readOnlyNotes; + private int nextIdentity; + + public ChartDraftEditor(ChartRecordingDraft draft) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + durationSeconds = draft.DurationSeconds; + notes = new List(draft.Hits.Count); + for (int index = 0; index < draft.Hits.Count; index++) + { + RecordedChartHit hit = draft.Hits[index]; + notes.Add(new EditableChartNote( + nextIdentity++, hit.Pad, hit.Velocity, hit.Articulation, hit.TimeSeconds, hit.Source)); + } + Sort(); + readOnlyNotes = notes.AsReadOnly(); + } + + public double DurationSeconds => durationSeconds; + public IReadOnlyList Notes => readOnlyNotes; + + public int Update(int index, double timeSeconds, DrumPad pad) + { + EditableChartNote note = At(index); + DrumArticulation articulation = DrumArticulationValidator.IsValid(pad, note.Articulation) + ? note.Articulation + : DrumArticulation.Default; + return Update(index, timeSeconds, pad, note.Velocity, articulation); + } + + public int Update( + int index, + double timeSeconds, + DrumPad pad, + int velocity, + DrumArticulation articulation) + { + EditableChartNote note = At(index); + ValidateTime(timeSeconds); + ValidatePad(pad); + ValidateVelocity(velocity); + DrumArticulationValidator.EnsureValid(pad, articulation); + note.TimeSeconds = timeSeconds; + note.Pad = pad; + note.Velocity = velocity; + note.Articulation = articulation; + Sort(); + return notes.IndexOf(note); + } + + public int Add( + double timeSeconds, + DrumPad pad, + int velocity = 100, + DrumArticulation articulation = DrumArticulation.Default) + { + ValidateTime(timeSeconds); + ValidatePad(pad); + ValidateVelocity(velocity); + DrumArticulationValidator.EnsureValid(pad, articulation); + if (notes.Count >= ChartRecordingSession.MaximumHits) + throw new InvalidOperationException($"A chart cannot exceed {ChartRecordingSession.MaximumHits} notes."); + var note = new EditableChartNote( + nextIdentity++, pad, velocity, articulation, timeSeconds, DrumInputSource.Test); + notes.Add(note); + Sort(); + return notes.IndexOf(note); + } + + public void Delete(int index) => notes.RemoveAt(ValidatedIndex(index)); + + public ChartRecordingDraft BuildDraft() + { + var hits = new RecordedChartHit[notes.Count]; + for (int index = 0; index < notes.Count; index++) + { + EditableChartNote note = notes[index]; + hits[index] = new RecordedChartHit( + new DrumInputEvent( + note.Pad, + note.Velocity, + note.TimeSeconds, + note.Source, + note.Articulation), + index); + } + return new ChartRecordingDraft(durationSeconds, hits); + } + + private EditableChartNote At(int index) => notes[ValidatedIndex(index)]; + + private int ValidatedIndex(int index) + { + if (index < 0 || index >= notes.Count) throw new ArgumentOutOfRangeException(nameof(index)); + return index; + } + + private void ValidateTime(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > durationSeconds) + throw new ArgumentOutOfRangeException(nameof(value), $"Note time must be between 0 and {durationSeconds:0.###} seconds."); + } + + private static void ValidatePad(DrumPad pad) + { + if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad)); + } + + private static void ValidateVelocity(int velocity) + { + if (velocity < 1 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity)); + } + + private void Sort() => notes.Sort((left, right) => + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.Identity.CompareTo(right.Identity); + }); + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta new file mode 100644 index 0000000..66036f9 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1f6bc7d8a244151b6712fdd0bf0fc77 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs new file mode 100644 index 0000000..849bd1f --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs @@ -0,0 +1,257 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class ChartWaveformModel + { + private readonly float[] peaks; + + public ChartWaveformModel(float[] sourcePeaks, double durationSeconds) + { + if (sourcePeaks == null || sourcePeaks.Length < 2) + throw new ArgumentException("A waveform requires at least two peak values.", nameof(sourcePeaks)); + if (!IsFinite(durationSeconds) || durationSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(durationSeconds)); + peaks = new float[sourcePeaks.Length]; + for (int index = 0; index < peaks.Length; index++) + { + float value = sourcePeaks[index]; + if (float.IsNaN(value) || float.IsInfinity(value)) + throw new ArgumentException("Waveform peaks must be finite.", nameof(sourcePeaks)); + peaks[index] = Mathf.Clamp01(Mathf.Abs(value)); + } + DurationSeconds = durationSeconds; + ResetZoom(); + } + + public double DurationSeconds { get; } + public double ViewStartSeconds { get; private set; } + public double ViewEndSeconds { get; private set; } + public double SelectedTimeSeconds { get; private set; } + public int PeakCount => peaks.Length; + public float PeakAt(int index) => peaks[index]; + + public double Scrub(double normalizedPosition) + { + if (!IsFinite(normalizedPosition)) throw new ArgumentOutOfRangeException(nameof(normalizedPosition)); + double clamped = Math.Max(0, Math.Min(1, normalizedPosition)); + SelectedTimeSeconds = ViewStartSeconds + (ViewEndSeconds - ViewStartSeconds) * clamped; + return SelectedTimeSeconds; + } + + public void Select(double timeSeconds) + { + if (!IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, timeSeconds)); + } + + public void Zoom(double factor) + { + if (!IsFinite(factor) || factor <= 0) throw new ArgumentOutOfRangeException(nameof(factor)); + double current = ViewEndSeconds - ViewStartSeconds; + double minimum = Math.Min(DurationSeconds, 0.5); + double next = Math.Max(minimum, Math.Min(DurationSeconds, current / factor)); + double anchor = SelectedTimeSeconds; + double ratio = current > 0 ? (anchor - ViewStartSeconds) / current : 0.5; + double start = anchor - next * ratio; + start = Math.Max(0, Math.Min(DurationSeconds - next, start)); + ViewStartSeconds = start; + ViewEndSeconds = start + next; + } + + public void ResetZoom() + { + ViewStartSeconds = 0; + ViewEndSeconds = DurationSeconds; + SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, SelectedTimeSeconds)); + } + + public static ChartWaveformModel FromInterleaved( + float[] samples, + int channels, + double durationSeconds, + int pointCount = 512) + { + if (samples == null || samples.Length == 0) throw new ArgumentException("Samples are required.", nameof(samples)); + if (channels <= 0 || samples.Length % channels != 0) throw new ArgumentOutOfRangeException(nameof(channels)); + if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount)); + var result = new float[pointCount]; + int frames = samples.Length / channels; + for (int point = 0; point < pointCount; point++) + { + int startFrame = (int)((long)point * frames / pointCount); + int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * frames / pointCount)); + endFrame = Math.Min(frames, endFrame); + float peak = 0; + for (int frame = startFrame; frame < endFrame; frame++) + for (int channel = 0; channel < channels; channel++) + peak = Math.Max(peak, Math.Abs(samples[frame * channels + channel])); + result[point] = peak; + } + return new ChartWaveformModel(result, durationSeconds); + } + + public static ChartWaveformModel FromAudioClip( + AudioClip clip, + int pointCount = 512, + int framesPerRead = 4096) + { + if (clip == null) throw new ArgumentNullException(nameof(clip)); + if (clip.samples <= 0 || clip.channels <= 0 || clip.length <= 0) + throw new ArgumentException("Audio clip is empty.", nameof(clip)); + if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount)); + if (framesPerRead <= 0 || framesPerRead > 32768) + throw new ArgumentOutOfRangeException(nameof(framesPerRead)); + + int boundedRead = Math.Min(framesPerRead, clip.samples); + var buffer = new float[boundedRead * clip.channels]; + var result = new float[pointCount]; + for (int point = 0; point < pointCount; point++) + { + int startFrame = (int)((long)point * clip.samples / pointCount); + int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * clip.samples / pointCount)); + endFrame = Math.Min(clip.samples, endFrame); + float peak = 0; + for (int offset = startFrame; offset < endFrame; offset += boundedRead) + { + int frames = Math.Min(boundedRead, endFrame - offset); + if (!clip.GetData(buffer, offset)) + throw new InvalidOperationException("Audio samples could not be read."); + int sampleCount = frames * clip.channels; + for (int index = 0; index < sampleCount; index++) + peak = Math.Max(peak, Math.Abs(buffer[index])); + } + result[point] = peak; + } + return new ChartWaveformModel(result, clip.length); + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartWaveformView : VisualElement + { + private bool dragging; + private ChartWaveformModel model; + + public ChartWaveformView() + { + focusable = true; + generateVisualContent += Draw; + RegisterCallback(OnPointerDown); + RegisterCallback(OnPointerMove); + RegisterCallback(OnPointerUp); + RegisterCallback(_ => dragging = false); + } + + public event Action Scrubbed; + public ChartWaveformModel Model => model; + + public void SetModel(ChartWaveformModel value) + { + model = value; + MarkDirtyRepaint(); + } + + public void SetSelectedTime(double value) + { + model?.Select(value); + MarkDirtyRepaint(); + } + + public void Zoom(double factor) + { + model?.Zoom(factor); + MarkDirtyRepaint(); + } + + public void ResetZoom() + { + model?.ResetZoom(); + MarkDirtyRepaint(); + } + + private void OnPointerDown(PointerDownEvent evt) + { + if (evt.button != 0 || model == null) return; + dragging = true; + this.CapturePointer(evt.pointerId); + Scrub(evt.localPosition.x); + evt.StopPropagation(); + } + + private void OnPointerMove(PointerMoveEvent evt) + { + if (!dragging || model == null) return; + Scrub(evt.localPosition.x); + evt.StopPropagation(); + } + + private void OnPointerUp(PointerUpEvent evt) + { + dragging = false; + if (this.HasPointerCapture(evt.pointerId)) this.ReleasePointer(evt.pointerId); + } + + private void Scrub(float x) + { + double width = Math.Max(1, contentRect.width); + double selected = model.Scrub(x / width); + MarkDirtyRepaint(); + Scrubbed?.Invoke(selected); + } + + private void Draw(MeshGenerationContext context) + { + Painter2D painter = context.painter2D; + Rect rect = contentRect; + painter.fillColor = new Color(0.01f, 0.04f, 0.075f, 0.98f); + painter.BeginPath(); + painter.MoveTo(new Vector2(0, 0)); + painter.LineTo(new Vector2(rect.width, 0)); + painter.LineTo(new Vector2(rect.width, rect.height)); + painter.LineTo(new Vector2(0, rect.height)); + painter.ClosePath(); + painter.Fill(); + if (model == null || rect.width <= 1 || rect.height <= 1) return; + + float center = rect.height * 0.5f; + painter.strokeColor = new Color(0.22f, 0.55f, 0.68f, 0.45f); + painter.lineWidth = 1; + painter.BeginPath(); + painter.MoveTo(new Vector2(0, center)); + painter.LineTo(new Vector2(rect.width, center)); + painter.Stroke(); + + double viewDuration = model.ViewEndSeconds - model.ViewStartSeconds; + int first = Math.Max(0, (int)Math.Floor(model.ViewStartSeconds / model.DurationSeconds * (model.PeakCount - 1))); + int last = Math.Min(model.PeakCount - 1, (int)Math.Ceiling(model.ViewEndSeconds / model.DurationSeconds * (model.PeakCount - 1))); + painter.strokeColor = new Color(0.24f, 0.86f, 1f, 0.9f); + painter.lineWidth = Math.Max(1, rect.width / Math.Max(1, last - first + 1)); + for (int index = first; index <= last; index++) + { + double time = index / (double)(model.PeakCount - 1) * model.DurationSeconds; + float x = (float)((time - model.ViewStartSeconds) / viewDuration * rect.width); + float amplitude = model.PeakAt(index) * (rect.height * 0.42f); + painter.BeginPath(); + painter.MoveTo(new Vector2(x, center - amplitude)); + painter.LineTo(new Vector2(x, center + amplitude)); + painter.Stroke(); + } + + if (model.SelectedTimeSeconds >= model.ViewStartSeconds && + model.SelectedTimeSeconds <= model.ViewEndSeconds) + { + float x = (float)((model.SelectedTimeSeconds - model.ViewStartSeconds) / viewDuration * rect.width); + painter.strokeColor = new Color(1f, 0.67f, 0.18f, 1f); + painter.lineWidth = 2.5f; + painter.BeginPath(); + painter.MoveTo(new Vector2(x, 0)); + painter.LineTo(new Vector2(x, rect.height)); + painter.Stroke(); + } + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta new file mode 100644 index 0000000..d4d7a02 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b0d8b53ae8a42c2bfc569bf322e7f35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs new file mode 100644 index 0000000..410aad7 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using HitTheKit.Unity.Matching; + +namespace HitTheKit.Unity.Gameplay +{ + public enum GameplayAutoTempoStatus + { + Unavailable, + Repeat, + Advance, + Mastered + } + + public sealed class GameplayAutoTempoRecommendation + { + internal GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus status, + double currentSpeed, + double nextSpeed, + string message) + { + Status = status; + CurrentSpeed = currentSpeed; + NextSpeed = nextSpeed; + Message = message ?? throw new ArgumentNullException(nameof(message)); + } + + public GameplayAutoTempoStatus Status { get; } + public double CurrentSpeed { get; } + public double NextSpeed { get; } + public string Message { get; } + public bool CanAdvance => Status == GameplayAutoTempoStatus.Advance; + } + + public static class GameplayAutoTempoCoach + { + public const double MinimumAccuracy = 85.0; + public const double MaximumMissRatio = 0.10; + public const double MaximumNoMatchRatio = 0.10; + + public static GameplayAutoTempoRecommendation Evaluate( + GameplaySessionDefinition session, + GameplayScoreSnapshot score, + HitMatchingSnapshot matching) + { + if (score == null) throw new ArgumentNullException(nameof(score)); + if (matching == null) throw new ArgumentNullException(nameof(matching)); + return Evaluate( + session, + score.Accuracy, + matching.MissCount, + matching.NoMatchCount, + matching.TotalNoteCount); + } + + public static GameplayAutoTempoRecommendation Evaluate( + GameplaySessionDefinition session, + double accuracy, + int misses, + int noMatches, + int totalNotes) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (double.IsNaN(accuracy) || double.IsInfinity(accuracy) || accuracy < 0 || accuracy > 100) + throw new ArgumentOutOfRangeException(nameof(accuracy)); + if (misses < 0) throw new ArgumentOutOfRangeException(nameof(misses)); + if (noMatches < 0) throw new ArgumentOutOfRangeException(nameof(noMatches)); + if (totalNotes <= 0) throw new ArgumentOutOfRangeException(nameof(totalNotes)); + + IReadOnlyList speeds = SpeedsFor(session); + if (speeds == null) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Unavailable, + session.SpeedMultiplier, + session.SpeedMultiplier, + "AUTO TEMPO DISPONIBILE PER LEZIONI E BRANI DELLA LIBRERIA"); + } + + int currentIndex = FindSpeed(speeds, session.SpeedMultiplier); + if (currentIndex < 0) + throw new InvalidOperationException("The current session speed is outside the Auto Tempo progression."); + + double missRatio = misses / (double)totalNotes; + double noMatchRatio = noMatches / (double)totalNotes; + bool passed = accuracy >= MinimumAccuracy && + missRatio <= MaximumMissRatio && + noMatchRatio <= MaximumNoMatchRatio; + if (!passed) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Repeat, + session.SpeedMultiplier, + session.SpeedMultiplier, + $"RIPETI {session.SpeedMultiplier:0.##}× · SERVONO {MinimumAccuracy:0}% E POCHI ERRORI"); + } + + if (currentIndex == speeds.Count - 1) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Mastered, + session.SpeedMultiplier, + session.SpeedMultiplier, + "TEMPO OBIETTIVO RAGGIUNTO · 100%"); + } + + double next = speeds[currentIndex + 1]; + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Advance, + session.SpeedMultiplier, + next, + $"PRONTO PER {next:0.##}× · PRECISIONE {accuracy:0.0}%"); + } + + private static IReadOnlyList SpeedsFor(GameplaySessionDefinition session) + { + if (session.IsChartCreator) return null; + if (session.Kind == GameplaySessionKind.Lesson) return GameplayStudySpeeds.All; + if (!string.IsNullOrWhiteSpace(session.SongId)) return GameplaySongSpeeds.All; + return null; + } + + private static int FindSpeed(IReadOnlyList speeds, double value) + { + for (int index = 0; index < speeds.Count; index++) + if (Math.Abs(speeds[index] - value) < 0.0001) return index; + return -1; + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta new file mode 100644 index 0000000..5e9a41d --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b31ad33e30d04494a1ad4c9ff350b94e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index 4f14249..3b98c37 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -1,6 +1,8 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; +using System.IO; using HitTheKit.Core; using HitTheKit.Unity.Audio; using HitTheKit.Unity.Charts; @@ -19,6 +21,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private const double HighwayLookAheadSeconds = 4.0; private const double KitPreparationSeconds = 1.35; private const float PulseDurationSeconds = 0.18f; + private const int PracticeLeadInBeats = 2; [SerializeField] private UIDocument document; [SerializeField] private ChartTimelinePrototype chartTimeline; @@ -51,12 +54,14 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label environmentTitleLabel; private Label environmentSubtitleLabel; private Label currentInputLabel; + private Label ghostStatusLabel; private Label deviceLabel; private Label keyGuideCymbalsLabel; private Label keyGuideTomsLabel; private Label keyGuideSnareHiHatLabel; private Label keyGuideFloorKickLabel; private Label impactCueLabel; + private Label reactiveStageStatusLabel; private Button menuButton; private Button pauseButton; private Button resumeButton; @@ -64,6 +69,15 @@ public sealed class GameplayHighwayController : MonoBehaviour private Button resultRestartButton; private Button resultMenuButton; private Button resultApplyCalibrationButton; + private Button resultGhostButton; + private Button resultPracticeWeakestButton; + private Button autoTempoAdvanceButton; + private Button practicePreviousSectionButton; + private Button practiceNextSectionButton; + private Button practiceLoopSectionButton; + private Button practiceSetAButton; + private Button practiceSetBButton; + private Button practiceClearButton; private VisualElement pauseOverlay; private VisualElement resultsOverlay; private VisualElement countdownOverlay; @@ -75,8 +89,39 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultBreakdownLabel; private Label resultPracticeLabel; private Label resultCalibrationLabel; + private Label resultErrorMapLabel; + private Label autoTempoStatusLabel; + private Label practiceSectionLabel; + private Label practiceStatusLabel; + private VisualElement resultPerformancePanel; + private VisualElement chartCreatorResults; + private Label chartCreatorSummaryLabel; + private Label chartCreatorStatusLabel; + private Button chartSaveRawButton; + private Button chartSaveEighthButton; + private Button chartSaveSixteenthButton; + private ListView chartNoteList; + private Label chartNoteSelectionLabel; + private TextField chartNoteTimeField; + private DropdownField chartNotePadField; + private TextField chartNoteVelocityField; + private DropdownField chartNoteArticulationField; + private Button chartNoteAddButton; + private Button chartNoteApplyButton; + private Button chartNoteDeleteButton; + private Label chartNoteStatusLabel; + private VisualElement chartWaveformHost; + private ChartWaveformView chartWaveformView; + private Label chartWaveformTimeLabel; + private Button chartWaveformZoomInButton; + private Button chartWaveformZoomOutButton; + private Button chartWaveformResetButton; + private Button chartWaveformPreviewButton; + private Button chartWaveformStopButton; + private readonly List chartArticulationChoices = new List(); private GameplayHighwaySurface surface; private GameplayKitSurface kitSurface; + private GameplayReactiveStageSurface reactiveStageSurface; private bool showInstructionalKit; private HitMatchingPrototype subscribedMatching; private readonly GameplayScoreTracker scoreTracker = new GameplayScoreTracker(); @@ -84,6 +129,9 @@ public sealed class GameplayHighwayController : MonoBehaviour private readonly TimingCalibrationAdvisor keyboardCalibration = new TimingCalibrationAdvisor(); private readonly TimingCalibrationAdvisor midiCalibration = new TimingCalibrationAdvisor(); private readonly PracticePerformanceAnalyzer performanceAnalyzer = new PracticePerformanceAnalyzer(); + private readonly PerformanceGhostReplay ghostReplay = new PerformanceGhostReplay(); + private PracticeErrorMapAnalyzer errorMapAnalyzer; + private PracticeErrorCell weakestPracticeError; private DrumInputSource lastCalibrationSource = DrumInputSource.Keyboard; private DrumPad? latestPulsePad; private bool invalidConfigurationLogged; @@ -92,13 +140,28 @@ public sealed class GameplayHighwayController : MonoBehaviour private AudioSource metronomeSource; private AudioClip metronomeClip; private bool metronomeScheduled; + private bool metronomeSeekedWhilePaused; private bool resultRecorded; + private HitGrade? latestStageGrade; + private bool latestStageWrongInput; + private float stagePulseDeadline; + private GameplayAutoTempoRecommendation autoTempoRecommendation; + private bool isChangingTempo; + private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop(); + private IReadOnlyList practiceSections = Array.Empty(); + private int selectedPracticeSectionIndex; + private ChartRecordingSession chartRecording; + private ChartRecordingDraft chartDraft; + private ChartDraftEditor chartDraftEditor; public event Action ThemeChanged; public GameplayPresentationTheme Theme { get; private set; } public GameplayHighwaySurface Surface => surface; public GameplayKitSurface KitSurface => kitSurface; + public GameplayReactiveStageSurface ReactiveStageSurface => reactiveStageSurface; + public GameplayReactiveStageState ReactiveStageState => reactiveStageSurface?.State ?? + GameplayReactiveStageCalculator.Calculate(0, null, null, 0, false, false, false); public bool IsInstructionalKitVisible => showInstructionalKit; public Texture2D ActiveBackground => BackgroundFor(Theme); public string EnvironmentTitle => GameplayEnvironmentProfile.For(Theme).Title; @@ -114,6 +177,15 @@ public sealed class GameplayHighwayController : MonoBehaviour public GameplaySessionDefinition CurrentSession => sessionCoordinator?.Session ?? GameplaySessionContext.Current; public double CurrentAttemptPracticeSeconds => practiceTimer.CurrentAttemptSeconds; + public GameplayPracticeRange ActivePracticeRange => practiceLoop.Range; + public IReadOnlyList PracticeSections => practiceSections; + public int RecordedChartHitCount => chartRecording?.HitCount ?? chartDraft?.Hits.Count ?? 0; + public string LastChartExportPath { get; private set; } + public string LastChartPackagePath { get; private set; } + public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0; + public PracticeErrorCell WeakestPracticeError => weakestPracticeError; + public IReadOnlyList GhostHits => ghostReplay.Ghost; + public int CurrentGhostTakeHitCount => ghostReplay.CurrentHitCount; private void Awake() { @@ -141,6 +213,8 @@ private void Start() BindNavigation(); Subscribe(); InitializeAudioFeedback(); + InitializePracticeLab(); + InitializeChartCreator(); SetTheme(CurrentSession.Theme); RefreshSessionCopy(); } @@ -150,6 +224,7 @@ private void Update() HandleShortcuts(); TrackPracticeTime(); TryScheduleMetronome(); + UpdatePracticeLoop(); UpdatePulseState(); RefreshPresentation(); } @@ -165,6 +240,7 @@ private void OnDisable() private void OnDestroy() { FlushPracticeTime(); + songClock?.StopPreview(); ReleaseAudioFeedback(); } @@ -205,6 +281,7 @@ private void SetTheme(GameplayPresentationTheme theme) } surface?.SetTheme(theme); + reactiveStageSurface?.SetTheme(theme); if (impactCueLabel != null) impactCueLabel.style.top = Length.Percent(environment.StrikeRatio * 100f); if (kitSurface != null) @@ -238,9 +315,11 @@ private void BindView() positionLabel = root.Q