├── LICENSE ├── README.md ├── app.js ├── beatBar.js ├── channelDiv.js ├── contextMenu.js ├── dataProcess ├── AI │ ├── basicamt.js │ ├── basicamt_44100.onnx │ ├── basicamt_worker.js │ └── dist │ │ ├── bundle.min.js │ │ └── ort-wasm-simd.wasm ├── CQT │ ├── .vscode │ │ ├── c_cpp_properties.json │ │ └── tasks.json │ ├── cqt.js │ ├── cqt.wasm.js │ ├── cqt.wasm.wasm │ ├── cqt_wasm.cpp │ └── cqt_worker.js ├── analyser.js ├── fft_real.js └── midiExport.js ├── fakeAudio.js ├── favicon.ico ├── img ├── bilibili-white.png ├── github-mark-white.png ├── logo-small.png ├── logo.png └── logo_text.png ├── index.html ├── midi.js ├── myRange.js ├── saver.js ├── siderMenu.js ├── snapshot.js ├── style ├── askUI.css ├── channelDiv.css ├── contextMenu.css ├── icon │ ├── iconfont.css │ ├── iconfont.ttf │ ├── iconfont.woff │ └── iconfont.woff2 ├── myRange.css ├── siderMenu.css └── style.css ├── tinySynth.js └── todo.md /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | logo
4 | noteDigger
5 |
6 | ~前端辅助人工扒谱工具~ 7 |
8 | 9 | # noteDigger! 10 | “Note Digger”——音符挖掘者,即扒谱。模仿的是软件wavetone,但是是双击即用、现代UI的前端应用。
11 | 目标是全部自己造轮子!即:不使用框架、不使用外部库;目的是减小项目大小,并掌握各个环节。目前频谱分析的软件非常多,功能也超级强大,自知比不过……所以唯一能一战的就是项目体积了!作为一个纯前端项目,就要把易用的优点完全发扬!
12 | [在线使用](https://madderscientist.github.io/noteDigger/)
13 | 视频演示(视频发布于更新节奏对齐之前) 14 | 点击封面跳转视频 15 | 16 | ## 使用流程 17 | 1. 在线or下载到本地,用主流现代浏览器打开(开发使用Chrome)。 18 | 2. 导入音频——文件-上传,或直接将音频拖拽进去! 19 | 3. 选择声道分析,或者导入之前分析的结果(只有选择音频之后才有导入之前结果的接口) 20 | 4. 根据频谱分析,开始绘制midi音符!调整音量,反复比对。 21 | 5. 导出为midi等,或者暂时导出项目(下次继续) 22 | 23 | ## 导入导出说明 24 | - 导出进度: 结果是.nd的二进制文件,保存分析结果(频谱图)和音符音轨。导入的时候并不会强制要求匹配原曲!(会根据文件名判断一下,但不强制) 25 | - 导出为midi: 有两个模式。模式二只保证能听,节拍默认4/4,bpm默认60,midi类型默认1(同步多音轨),时间精度和设置的精度一致(因此如果midi先导入再导出会有量化误差);模式一会根据小节线进行对齐(需要用户设置好小节线),可以直接用于制谱,算法概述见下面“节奏对齐”。由于midi协议规定第十轨用于打击乐,因此扒谱时旋律需要避开第十轨,可以设置一个空音轨占位。本应用没有设计避开第十轨,也没设计扒鼓点,因此扒谱时第十轨虽然听起来还是乐音,但导出为midi后会变成鼓点。 26 | - 导入midi: 将midi音符导入,只保证音轨、音符、音色能对应,音量默认127。如果导入后没有超过总音轨数,会在后面增加;否则会覆盖后面几轨(有提示)。 27 | 28 | ## 常规操作 29 | - 空格: 播放 30 | - **双击**时间轴: 从双击的位置开始播放 31 | - 在时间轴上拖拽: 设置重复区间 32 | - 在时间轴上拉动小节线: 设置小节bpm 33 | - 鼠标**中键**时间轴: 将时间设置到点击位置,播放状态保持上一刻 34 | - 鼠标**右键**时间轴(上半/下半): 具体设置重复时间/小节 35 | - 按住空白拖动: 在当前音轨绘制一个音符 36 | - 按住音符左半边拖动: 改变位置 37 | - 按住音符右半边拖动: 改变时长 38 | - Ctrl+点击音符: 多选音符 39 | - delete: 删除选中的音符 40 | - Ctrl+滚轮: 横向缩放 41 | - 按住**中键**拖拽、**触摸板**滑动: 移动视野 42 | 43 | ## 快捷键 44 | 只有在导入并分析音频之后才能使用这些快捷键 45 | - Ctrl+Z: 撤销(音轨状态的改变不会引发存档,且只记录16次历史) 46 | - Ctrl+Y: 重做 47 | - Ctrl+A: 全选当前音轨 48 | - Ctrl+Shift+A: 全选所有音轨 49 | - Ctrl+D: 取消选中 50 | - Ctrl+C: 复制选中的音符 51 | - Ctrl+X: 剪贴选中的音符 52 | - Ctrl+V: 粘贴到选中的音轨上(暂不实现跨页面粘贴) 53 | - Ctrl+B: 呼出/收回音轨面板 54 | - Shift+右键: 菜单,包含撤销/重做、复制/粘贴、反选当前轨、删除 55 | - ←↑→↓: 视野移动一格 56 | - PageUp、PageDown:向前翻页/向后翻页 57 | - Home:设置播放位置为0,播放状态保持上一刻 58 | 59 | ## 小细节 60 | - 滑动条,如果旁边有数字,点击就可以恢复初始值。 61 | - 多次点击“笔”右侧的选择工具,可以切换选择模式。(注意,只能选中当前音轨的音符) 62 | - 点击某个音符可以选中该轨。 63 | - 选择乐器时,展开下拉框并且按首字母可以快速跳转(浏览器下拉框自带)。 64 | - 音轨中,“闭眼”只是看不见,还是可以操作的;一般要搭配“锁定”使用,默认两者会联动。 65 | 66 | ## 支持的格式 67 | 推荐使用常见的mp3、wav文件;除此之外,视频类文件也可以使用,比如mp4、mov、m4v。 68 | 但是如下格式不支持(浏览器API不支持解析)(仅仅在Chrome浏览器尝试过): 69 | - aiff(苹果的音频格式) 70 | 71 | 对于ios的Safari浏览器,上音频文件也许有些困难。可以选择视频。(不过为什么要用触屏控制啊,根本没适配) 72 | 73 | ## 其他说明 74 | 分析-自动填充,原理是将大于阈值的标记出来,效果不堪入目……于是研究并引入了基于神经网络的扒谱(分析-人工智障扒谱),但是效果非常初级。如有想法欢迎call me。 75 | 76 | ## 关于节奏对齐 77 | 我一直以来都是扒数字谱的,所以没关注过节奏。但是只能用于数字谱这个应用也太弱了。所以加入了小节对齐功能。“丑话说在前面”,绘制音符大概是不可能对齐小节线了(但是导出midi的时候会对齐),**需要强迫症忍受一下**。
78 | 乐谱的单位是"x分音符",而音乐的单位是"秒"。如果要实现"小节对齐",单位要换成"x分音符"。整个程序时间轴一定要按照"秒"为单位,这是由频谱分析决定的;如果要实现制谱软件一样的对齐,那么音符绘制需要换成"x分音符"的对齐方式。这意味着在120bpm的小节下的音符,拉到60bpm的小节下,在以秒为尺度的时间轴下,音符会变长。wavetone就是这样处理的。
79 | 但是对着原曲扒谱,最好还是根据"秒"来绘制音符。用wavetone扒谱的体验中,我最讨厌的就是被"x分音符"限制。用秒可以保证和原曲完全贴合,使用很灵活。但是这样导出的midi就不能直接制谱。按照"x分音符"来绘制音符还会导致程序很难写。开发者和使用者都不快乐。
80 | 扒谱用秒为单位合适,而制谱用x分音符合适。为了跨越这个鸿沟,我决定这样设计程序:使用midi文件作为对外的桥梁,在我的程序内用秒为单位扒谱,导出为midi的时候根据小节进行四舍五入的量化,形成规整的midi用于制谱。具体实现是:在秒轴上加入小节轴,用户可以拖动小节轴的某个小节调节后面紧跟的bpm相同的小节。小节轴只提供视觉上的辅助,对于画音符没一点限制。
81 | 对齐算法有一定的限制,比如四分音符按照八分音符的划分对齐、八分音符按十六分音符的划分对齐……比如四分音符不可能在第三个16分音符开始,只可能在整数倍个8分音符的时长处开始。所以,绘制音符的时候到底可以偏差小节线多远心里有数了吧? 82 | 83 | ## 文件结构 84 | ``` 85 | │ app.js: 最重要的文件,主程序 86 | | beatBar.js: 节奏信息的稀疏数组存储 87 | │ channelDiv.js: 多音轨的UI界面类, 可拖拽列表 88 | │ contextMenu.js: 右键菜单类 89 | │ favicon.ico: 小图标 90 | │ index.html: 程序入口, 其js主要是按钮的onclick 91 | │ LICENSE 92 | │ midi.js: midi创建、解析类 93 | │ myRange.js: 横向滑动条的封装类 94 | │ README.md 95 | │ saver.js: 二进制保存相关 96 | │ siderMenu.js: 侧边栏菜单类 97 | │ snapshot.js: 快照类, 实现撤销和重做 98 | │ tinySynth.js: 合成器类, 负责播放音频 99 | | fakeAudio.js: 模拟了不会响的Audio,用于midi编辑器模式 100 | │ todo.md: 一些设计思路和权衡 101 | │ 102 | ├─dataProcess 103 | | │ analyser.js: 频域数据分析与简化 104 | | │ fft_real.js: 执行实数FFT获取频域数据 105 | | │ midiExport.js: 对绘制的音符进行近似以导出为足以制谱的midi 106 | | | 107 | | ├─AI 108 | | │ │ basicamt.js: 开启worker进行后台AMT 109 | | │ │ basicamt_44100.onnx: 神经网络模型 110 | | │ │ basicamt_worker.js: 新线程 111 | | │ │ 112 | | │ └─dist: onnxruntime打包 113 | | │ bundle.min.js 114 | | │ ort-wasm-simd.wasm 115 | | | 116 | | └─CQT 117 | | │ cqt.js: 开启worker进行后台CQT 118 | | │ cqt.wasm.js: emcc编译的胶水代码 119 | | │ cqt.wasm.wasm: emcc编译的wasm 120 | | │ cqt_wasm.cpp: wasm源文件 121 | | │ cqt_worker.js: 新线程 122 | | │ 123 | | └─.vscode 124 | | c_cpp_properties.json: 环境配置 125 | | tasks.json: emcc编译命令 126 | | 127 | ├─img 128 | │ github-mark-white.png 129 | │ logo-small.png 130 | │ logo.png 131 | │ logo_text.png 132 | │ 133 | └─style 134 | │ askUI.css: 达到类似效果 135 | │ channelDiv.css: 多音轨UI样式 136 | │ contextMenu.css: 右键菜单样式 137 | │ myRange.css: 包装滑动条 138 | │ siderMenu.css: 侧边菜单样式 139 | │ style.css: index中独立元素的样式 140 | │ 141 | └─icon: 从阿里图标库得到的icon 142 | iconfont.css 143 | iconfont.ttf 144 | iconfont.woff 145 | iconfont.woff2 146 | ``` 147 | 148 | ## 重要更新记录 149 | ### 2025 3 25 150 | 引入了“自动音乐转录”,即“AI扒谱”,导入音频后(或进入MIDI编辑器模式)在“分析”页面点击“人工智障扒谱”选项,一首两分半的曲子大概需要半分钟分析。由于追求低内存开销,我没保存音频数据,因此AI扒谱前要重新选择文件。
151 | 使用的模型是我毕设的一部分,设计与训练过程请查看[timbreAMT](https://github.com/madderscientist/timbreAMT)的basicamt文件夹,我称之为“音色无关转录”,即不会根据乐器种类分轨输出,但对大部分音色有适用性。对标的是basicPitch,效果接近且更加轻量,但无论是我的还是他的,结果都仅仅能听,而我的相比basicPitch优点在于集成在了noteDigger中,可以便捷地进行人工后处理,如删去多余音符、对齐节奏等。
152 | 为了支持人工后处理,有了前几次更新,最重要的是: 153 | 1. 音符力度用透明度体现,便于用户看清AI扒谱结果中重要的音符。在“设置”中可以关掉透明度。 154 | 2. 音轨的锁,用于锁定AI扒谱结果,用户可以在新的音轨中“描”一遍,相当于把AI扒谱结果当做频谱。 155 | 156 | ### 2024 8 29 157 | 引入了理论上更精确的CQT分析。非file协议时(不是双击html文件打开时),当STFT(默认的计算方法)计算完成会在后台自动开启CQT计算,CQT结果将与当前频谱融合(会发现突然频谱变了)。CQT计算非常慢,因此在后台计算以防阻塞,且用C++实现、编译为WASM以提速。
158 | 中途遇到很多坑,记录分布在/dataProcess/CQT的各个文件中,但效果其实并不值得这样的计算量。5分30秒的音频进行双声道CQT分析,需要45秒(从开启worker开始算),和直接进行js版的CQT用时差不多,加速了个寂寞。
159 | 关于CQT的研究,记录在[《CQT:从理论到代码实现》](https://zhuanlan.zhihu.com/p/716574483)。
160 | 此外尝试了“一边分析一边绘制频谱”,试图通过删除进度条达到感官上加速的效果。但是放在主线程造成严重卡顿,放弃。 161 | 162 | ### 2024 8 2 163 | 完成了issue2:不导入音频的midi编辑器。点击文件菜单下的“MIDI编辑器模式”就可以进入。
164 | 视野的宽度取决于最后一个音符,模仿的是[signal](https://signal.vercel.app/edit)。也尝试过自动增加视野,可以一直往右拉,但是这样在播放的时候,开启“自动翻页”会永远停不下来(翻一页就自动拓展宽度)。
165 | 扒谱框架下的midi编辑器还是有些反人类,因为绘制音符时的单位是时间而不是x分音符。不过也能用。
166 | 原理是实现了一个空壳的Audio,只有计时功能,没有发声功能。一些做法写在了todo.md上。 167 | 168 | ### 2024 2 22 169 | 加入了节拍对齐功能,使用逻辑是:扒谱界面提供视觉辅助,导出midi会自动对齐,以实现制谱友好。详细对齐的原理请参看“关于节奏对齐”板块和midiExport.js文件。
170 | 有一些细节:
171 | 1. 如果每个小节bpm都不一样(原曲的速度不稳,有波动),那导出midi前的对齐操作会以上一小节bpm为基准进行动态适应:先根据本小节的bpm量化音符为"x分音符",如果本小节bpm和上一小节的bpm差别在一定范围内,则再将"x分音符"的bpm设置全局量BPM;否则将全局BPM设置为当前小节的bpm。这个算法的要求是:的确要变速的前后bpm差异应该较大。
172 | 2. 在一个小节内,音符的近似方法: 173 | 174 | 1. 记一个四分音符的格数为aqt(因为音符的实际使用单位是格。这里隐含了一个时间到格数的变换),某时刻t对应音符长度为ntlen,小节开始时刻记为mt。首先获取音符时刻相对小节开头的位置nt=t-mt。(音符时刻:将一个音符拆分为开始时刻和结束时刻。一个音符可能跨好几个小节,因此这样处理最为合适) 175 | 2. 假设前提:时长长的音符的起点和终点的精度也低(精度这里指最小单位时长,低精度指单位时长对应的实际时间长)。因此近似精度accu采用自适应的方式:该音符可以用(ntlen/aqt)个四份音符表示,设其可以用一个(4*2^n)分音符近似,其中n满足:(1/2)^n<=ntlen/aqt<(1/2)^(n-1),则该音符的时长为aqt/(2^n),则精度设置为这个近似音符的一半:accu = aqt/(2^(n+1))。比如四份音符的精度是一个八分音符的时长。 176 | 3. 近似后的时刻为:round(nt/accu)*accu。同时设置一个最低精度:八分音符。因此accu=min(aqt/2, aqt/(2^(n+1))),其中(1/2)^n<=ntlen/aqt<(1/2)^(n-1)。 177 | 178 | 3. 小节信息如何存储、数据结构如何设计需要好好想想。大部分情况下(在原音频节奏稳定的情况下)只会变速几次,此时存变动时刻的bpm值就足矣。极端情况下每个小节都单独设置了bpm。如何设计数据结构能在两种情况下都取得较好的性能?使用稀疏数组。 179 | 180 | ### 2024 2 9 181 | 在今年完成了所有基本功能!本次更新了设置相关,简单地设计了调性分析的算法,已经完全可以用了!【随后在bilibil投稿了视频】 182 | 183 | ### 2024 2 8 184 | 文件系统已经完善!已经可以随心所欲导入导出保存啦!同时修复了一些小bug、完善了一些api。
185 | 界面上,本打算将文件相关选项放到logo上,但是侧边菜单似乎有些空了,于是就加入到侧边栏,而logo设置为刷新或开新界面(考察了其他网站的logo的用途)。同时给侧边菜单加入了“设置”和“分析”,但本次更新没做。
186 | midi相关操作来自[我的另一个项目](https://github.com/madderscientist/je_score_operator)的midi类。将用midi转的wav导入分析,再导入原midi,两者同步播放的感觉真好! 187 | 188 | ### 2024 2 5 189 | 已经能用于扒谱了!完成了midi和原曲的播放与同步,填补了扒谱过程最重要的一环。
190 | UI基本完成!将侧边栏、滑动条封装成了js类。在此基础上,设计了类似VScode的菜单,用于存放不常用的功能和界面;而顶部窄窄一条用于放置常用功能。
191 | 此外,完成了logo的设计。在2月4日的commit记录中(因为现在已经删除)可以看到设计的多种logo,最终选定了“在勺子里的音符”,这是一个被勺子dig出来的音符。其他思路可以概括为:“音符和铲子的组合”(logo2)、“埋在地里的音符”(logo5 logo6)、“像植物一样生长的八分音符”(logo8 logo10)、“音符和铲子结合”(logo12)。 192 | 193 | ### 2024 2 1 194 | 完成了多音轨、合成器和主线的整合,象征着midi系统的完成!
195 | 统一了UI风格;完善了快捷键功能;新增框选功能;修复了大部分bug。 196 | 197 | ### 2024 1 30 198 | 完成了midi合成器tinySynth.js,实现了128种音色的播放。只有演奏音符的作用,控制器一点没做。
199 | 原理是多个基础波形合成一个音色。波形参数来自 https://github.com/g200kg/webaudio-tinysynth ,因此程序设计也参考了它的设计。修改记录在todo.md中
200 | 对于reference的解析(作者注释一点没写,变量命名极为简单,因此主要是变量解释)存放于“./tone/解析.md”(文件夹已被删除,请去历史提交查看)。文件夹中还有tinySynth的测试页面。在下一次push时将删除tone文件夹。
201 | 这段时间内还完成了以下内容(全部记录在commit history的comments内): 202 | - 基本程序界面(三个画布:键盘、时频图、时间轴;UI界面:右键菜单、多音轨、滑动条) 203 | - 基本逻辑功能:音符交互绘制、快捷键以及模块的关联协同 204 | 205 | ### 2023 12 13 206 | 从11月14日开始造js版fft轮子起,时隔一个月第一次提交项目,因为项目逻辑日渐复杂,需要能及时回退。主要完成了频谱绘制、钢琴键盘绘制、数据处理三部分,并初步确定了程序的结构框架。
207 | 数据处理核心:实数FFT,编写于我《数字信号处理》刚刚学完FFT算法之时,针对本项目的应用场景做了专门的设计,即针对音频STFT做了适配,具体表现为:实数加速、数据预计算、空间预分配、共用数组。
208 | 由于整个项目还没搭建起来,因此不能测试NoteAnalyser类的数据处理效果。此类用于将频域数据进一步离散为音符强度数据。
209 | 关于程序结构有一版废案,在文件夹"deprecated"中,设计思路是解耦、插件化,废弃理由是根本解耦不了。因此现在的代码耦合成一坨了。这个文件夹将在下一次push时被删除,存活于历史提交之中。
210 | tone文件夹将存放我的合成器轮子,audioplaytest是我音频播放的实验文件夹,todo.md是部分设计思路。
211 | 2024/4/8补记:时频分析方法是STFT,但是面临时间和频率分辨率矛盾的问题,现在的分析精度只能到F#2。解决办法是用小波变换,或者更本质一点:用84个滤波器提取84个基准音以及其周围的频率的能量。这样能达到更高的频率分辨率和时间分辨率。但是现在的STFT用起来效果还可以,就不换了哈。 212 | -------------------------------------------------------------------------------- /beatBar.js: -------------------------------------------------------------------------------- 1 | // 本文件用于管理小节信息,实现了稀疏存储小节的数据结构 2 | class aMeasure { 3 | /** 4 | * 构造一个小节 5 | * @param {Number | aMeasure} beatNum 分子 几拍为一小节; 如果是aMeasure对象则复制构造 6 | * @param {Number} beatUnit 分母 几分音符是一拍 7 | * @param {Number} interval 一个小节的时间,单位ms 8 | */ 9 | constructor(beatNum = 4, beatUnit = 4, interval = 2000) { 10 | if (typeof beatNum === 'number') { 11 | this.beatNum = beatNum; 12 | this.beatUnit = beatUnit; 13 | this.interval = interval; 14 | } else { // 复制构造 15 | this.beatNum = beatNum.beatNum; 16 | this.beatUnit = beatNum.beatUnit; 17 | this.interval = beatNum.interval; 18 | } 19 | } 20 | static fromBpm(beatNum, beatUnit, bpm) { 21 | let interval = 60000 * beatNum / bpm; 22 | return new aMeasure(beatNum, beatUnit, interval); 23 | } 24 | copy(obj) { 25 | this.beatNum = obj.beatNum; 26 | this.beatUnit = obj.beatUnit; 27 | this.interval = obj.interval; 28 | return this; 29 | } 30 | // 不关注bpm,而关注interval。所以修改了beatNum会导致bpm变化 31 | get bpm() { 32 | return 60000 / this.interval * this.beatNum; 33 | } 34 | set bpm(value) { 35 | this.interval = 60000 * this.beatNum / value; 36 | } 37 | isEqual(other) { 38 | return this.interval === other.interval && this.beatNum === other.beatNum && this.beatUnit === other.beatUnit; 39 | } 40 | } 41 | 42 | // extended aMeasure 43 | class eMeasure extends aMeasure { 44 | /** 45 | * 构造一个有位置信息的小节 46 | * @param {Number | eMeasure} id 小节号 或 eMeasure对象(复制构造) 47 | * @param {Number} start 小节开始时间 单位ms 48 | * @param {Number | aMeasure} beatNum 49 | * @param {Number} beatUnit 50 | * @param {Number} interval 51 | */ 52 | constructor(id = 0, start = 0, beatNum, beatUnit, interval) { 53 | if(typeof id === 'number') { 54 | super(beatNum, beatUnit, interval); 55 | this.id = id; // 第几小节 56 | this.start = start; // 开始的时间 单位ms 57 | } else { 58 | super(id); 59 | this.id = id.id; 60 | this.start = id.start; 61 | } 62 | } 63 | /** 64 | * 基于某个小节构造一个新的小节 65 | * @param {eMeasure} base 同类型的小节 66 | * @param {Number} id 小节号 67 | * @param {aMeasure} measure 如果要修改值就传 否则参数同base 68 | * @returns 69 | */ 70 | static baseOn(base, id, measure = undefined) { 71 | return new eMeasure(id, (id - base.id) * base.interval + base.start, measure || base); 72 | } 73 | } 74 | 75 | class Beats extends Array { 76 | /** 77 | * 构造一个稀疏数组,只存储节奏变化 78 | * @param {Number} maxTime 乐曲时长 单位ms 79 | */ 80 | constructor(maxTime = 60000) { 81 | super(1); 82 | this.maxTime = maxTime; // 用于迭代 83 | this[0] = new eMeasure(0, 0); 84 | } 85 | /** 86 | * 找到当前小节模式的小节头 87 | * @param {Number} at 当前小节的时间或小节号 88 | * @param {Boolean} timeMode at是否表示毫秒时间 89 | * @returns {Number} 小节头在实际数组中的位置 90 | */ 91 | getBaseIndex(at, timeMode = false) { 92 | let attr = timeMode ? 'start' : 'id'; 93 | for (let i = this.length - 1; i >= 0; i--) { 94 | if (this[i][attr] <= at) return i; 95 | } return -1; 96 | } 97 | /** 98 | * 迭代器屏蔽了数组的稀疏性 如要连续取值,在元素多的时候效果比getMeasure(id)好 99 | * 注意传入的参数需要自行匹配好,否则后果未知 建议用this.iterator()代替此函数 100 | * @param {Number} index 开始的序号 101 | * @param {Number} baseAt 基于的eMeasure对象在实际数组中的位置 102 | * @returns next() 103 | */ 104 | [Symbol.iterator](index = 0, baseAt = 0) { 105 | return { 106 | next: () => { 107 | // 确定base 108 | let nextBase = this[baseAt + 1]; 109 | if (nextBase && nextBase.id === index) baseAt++; 110 | else nextBase = this[baseAt]; 111 | // 得到小节信息 112 | let value = eMeasure.baseOn(nextBase, index++); 113 | // 判断是否越界 114 | if (value.start >= this.maxTime) return { done: true }; 115 | return { 116 | value: value, 117 | done: false 118 | }; 119 | } 120 | }; 121 | } 122 | /** 123 | * 从任意位置开始的迭代器 124 | * @param {Number} at 位置 125 | * @param {Boolean} timeMode at是否表示毫秒时间 126 | * @returns 迭代器 127 | */ 128 | iterator(at, timeMode = false) { // 由于在绘制更新中使用,故没有复用getBaseIndex以加速运行 129 | let attr = timeMode ? 'start' : 'id'; 130 | for (let i = this.length - 1; i >= 0; i--) { 131 | if (this[i][attr] <= at) { 132 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 133 | return this[Symbol.iterator](id, i); 134 | } 135 | } return { 136 | next: () => ({ done: true }) 137 | } 138 | } 139 | /** 140 | * 根据小节号返回一个只读的小节 141 | * @param {Number} at 小节号或覆盖该时刻的小节 142 | * @param {Boolean} timeMode 传入的是否是时间 143 | * @returns {eMeasure} 小节信息,修改返回值不会影响原数组 如果越界则返回null 144 | */ 145 | getMeasure(at, timeMode = false) { 146 | let i = this.getBaseIndex(at, timeMode); 147 | if (i == -1) return null; 148 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 149 | let m = eMeasure.baseOn(this[i], id); 150 | if (m.start >= this.maxTime) return null; 151 | return m; 152 | } 153 | /** 154 | * 返回一个可以修改的对象。若修改返回值会影响原数组,修改后应调用this.check() 155 | * @param {Number} at 修改第几小节或覆盖该时间的小节 156 | * @param {aMeasure} measure 小节信息 157 | * @param {Boolean} timeMode at是否表示毫秒时间 158 | * @returns {eMeasure} 可以修改的对象 可以不传,通过修改返回值+check()来设置 如果越界则返回null 159 | */ 160 | setMeasure(at, measure = undefined, timeMode = false) { 161 | let i = this.getBaseIndex(at, timeMode); 162 | if (i == -1) return null; 163 | // 检查id是否存在 如果不存在就找到第一个data.id > id的位置插入 164 | let id = timeMode ? this[i].id + ((at - this[i].start) / this[i].interval) | 0 : at; 165 | if (this[i].id == id) { 166 | if (measure) this[i].copy(measure); 167 | return this[i]; 168 | } 169 | if (this[i].id < id) { 170 | // 不管是否重复 重复性的检查交给check 171 | let m = eMeasure.baseOn(this[i], id, measure); 172 | if (m.start >= this.maxTime) return null; 173 | this.splice(i + 1, 0, m); return m; 174 | } 175 | } 176 | 177 | /** 178 | * 整理小节信息: 179 | * 1. 合并前后参数一样的小节 180 | * 2. 校准每个小节的开始时间 181 | * 应该在添加、删除、修改数组元素后调用 需要手动调用 182 | */ 183 | check() { 184 | this[0].start = 0; 185 | this[0].id = 0; 186 | for (let i = 0, end = this.length - 1; i < end; i++) { 187 | if (this[i].start > this.maxTime) { 188 | this.splice(i); return; 189 | } 190 | if (this[i].isEqual(this[i + 1])) { 191 | this.splice(i + 1, 1); 192 | i--; end--; 193 | } else this[i + 1].start = (this[i + 1].id - this[i].id) * this[i].interval + this[i].start; 194 | } 195 | } 196 | /** 197 | * 删除一个小节 198 | * @param {Number} at 位置 199 | * @param {Boolean} timeMode at是否表示毫秒时间 200 | */ 201 | delete(at, timeMode = false) { 202 | let attr = timeMode ? 'start' : 'id'; 203 | for (let i = this.length - 1; i >= 0; i--) { 204 | if (this[i][attr] <= at) { 205 | // 如果只有一个小节,则删除小节头 206 | if (this[i + 1] && this[i].id === this[i + 1].id) { // this[i+1].id不用减1,因为已经减过了 207 | this.splice(i, 1); 208 | } break; 209 | } this[i].id--; // 后面的都前移一格 210 | } this.check(); 211 | } 212 | /** 213 | * 增加一个小节,小节属性同前一个小节 214 | * @param {Number} at 位置 215 | * @param {Boolean} timeMode at是否表示毫秒时间 216 | */ 217 | add(at, timeMode = false) { 218 | let attr = timeMode ? 'start' : 'id'; 219 | for (let i = this.length - 1; i >= 0; i--) { 220 | if (this[i][attr] <= at) break; 221 | this[i].id++; // 后面的都后移一格 222 | } this.check(); 223 | } 224 | /** 225 | * 拷贝数据 用户撤销恢复 226 | * @param {Beats} beatArray 227 | * @returns {Beats} this 228 | */ 229 | copy(beatArray) { 230 | this.length = beatArray.length; 231 | for (let i = beatArray.length - 1; i >= 0; i--) { 232 | this[i] = new eMeasure(beatArray[i]); 233 | } return this; 234 | } 235 | } -------------------------------------------------------------------------------- /channelDiv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 可拖动列表 3 | * @param {*} takeplace 占位符是否起作用,用于拖拽到最后还留有一段空间 4 | * @returns {HTMLDivElement} 一个可以拖动元素的ul 5 | */ 6 | function dragList(takeplace = true) { 7 | let list = document.createElement('ul'); 8 | list.classList.add('drag_list'); 9 | 10 | // 为了编写方便,占位符一直存在 11 | list.innerHTML = `
`; 12 | // 由于占位的存在,所以不能用原来的children获取li。对外屏蔽这个占位 13 | Object.defineProperty(list, 'children', { 14 | get: function () { // querySelectorAll 方法返回的是一个 NodeList 对象,而不是一个真正的数组 15 | return Array.from(this.querySelectorAll('li')); 16 | } 17 | }); 18 | 19 | let dragging = null; 20 | list.ondragstart = (e) => { 21 | dragging = e.target; 22 | setTimeout(() => { // 为了让拖拽的dom还保持原有样式,所以延迟添加moving样式 23 | e.target.classList.add('moving'); 24 | }, 0); 25 | e.dataTransfer.effectAllowed = 'move'; 26 | }; 27 | // 以下两个事件使用addEventListener,用于后续添加新的事件 28 | list.addEventListener('dragover', (e) => { 29 | e.preventDefault(); 30 | let target = e.target; 31 | // 防止因为拖拽到li的子元素而判断为不是拖拽到li 32 | while (target.nodeName !== 'LI') { 33 | target = target.parentNode; 34 | if (!target) return; 35 | } 36 | if (target != dragging) { 37 | const rect = target.getBoundingClientRect() 38 | let rectHalfPosition = rect.y + (rect.height >> 1); // 计算目标的中间在屏幕中的坐标 39 | if (e.clientY > rectHalfPosition) { // 在目标元素下方插入 40 | list.insertBefore(dragging, target.nextElementSibling); // 从下向上拖动 41 | } else { // 在目标元素上方插入 42 | list.insertBefore(dragging, target); 43 | } 44 | } 45 | }); 46 | list.addEventListener('dragend', (e) => { 47 | e.target.classList.remove('moving'); 48 | }); 49 | // 添加元素将用li包裹 50 | list.appendChild = function (node, at = -1) { 51 | let ITEM = document.createElement('li'); 52 | ITEM.draggable = true; 53 | ITEM.classList.add('drag_list-item'); 54 | ITEM.appendChild(node); 55 | if (at < 0) { 56 | this.insertBefore(ITEM, this.lastElementChild); 57 | } else { // 只关注li 58 | const liElements = this.querySelectorAll('li'); 59 | if (at >= 0 && at < liElements.length) { 60 | this.insertBefore(ITEM, liElements[at]); 61 | } else { 62 | this.insertBefore(ITEM, this.lastElementChild); 63 | } 64 | } 65 | }; 66 | list.clear = function () { 67 | const takeplace = this.lastElementChild; 68 | this.innerHTML = ''; 69 | Node.prototype.appendChild.call(this, takeplace); 70 | } 71 | // 动画的原理是,先用translate变到之前的位置,然后通过设置translate=none变回来。但是涉及动画时反复触发的问题,所以没做 72 | // transtition的时候获取的坐标是最终值,所以也不能中途转向 73 | return list; 74 | } 75 | 76 | // 为了可读性、隔离性、模块化,使用此类包裹HTMLDivElement 77 | class ChannelItem extends HTMLDivElement { 78 | /** 79 | * 实际并不使用构造函数 此函数只是表明有哪些属性可用 80 | */ 81 | constructor() { 82 | super(); 83 | this.nameDiv = null; 84 | this.instrumentDiv = null; 85 | this.lockButton = null; 86 | this.visibleButton = null; 87 | this.muteButton = null; 88 | // 用ChannelList.addChannel添加时会注入ch属性,存储了对应的合成器音轨 因为合成器是ChannelList的成员 89 | this.ch = null; 90 | } 91 | /** 92 | * 通过更改原型链实现构造 93 | * @param {String} name 94 | * @param {String} color 95 | * @returns {ChannelItem} 96 | */ 97 | static new(name = "channel", color = "red", instrument = "Piano", visible = true, mute = false, lock = false) { 98 | let tempDiv = document.createElement('div'); 99 | tempDiv.innerHTML = ` 100 |
101 |
102 |
${name}
103 |
104 | 105 | 106 | 107 |
108 |
${instrument}
109 |
`; // 可以通过设置style的--tab-color改颜色;data-tab-index用于显示序号 110 | let container = tempDiv.firstElementChild; // 不能用firstChild因为获取的是文本节点 111 | container.nameDiv = tempDiv.querySelector('.channel-Name'); 112 | container.instrumentDiv = tempDiv.querySelector('.channel-Instrument'); 113 | const buttons = tempDiv.querySelectorAll('.tab'); 114 | container.lockButton = buttons[0]; 115 | container.visibleButton = buttons[1]; 116 | container.muteButton = buttons[2]; 117 | container.lockButton.addEventListener('click', (e) => { 118 | e.stopPropagation(); 119 | container.lock = !container.lock; 120 | }); 121 | container.visibleButton.addEventListener('click', (e) => { 122 | e.stopPropagation(); 123 | container.visible = !container.visible; 124 | container.lock = !container.visible; // 先执行本函数的目的再联动lock 125 | }); 126 | container.muteButton.addEventListener('click', (e) => { 127 | e.stopPropagation(); 128 | container.mute = !container.mute; 129 | }); 130 | // 设置原型链为ChannelItem 131 | Object.setPrototypeOf(container, ChannelItem.prototype); 132 | container.lock = lock; 133 | container.visible = visible; 134 | container.mute = mute; 135 | return container; 136 | } 137 | get name() { 138 | return this.nameDiv.innerHTML; 139 | } 140 | set name(channelName) { 141 | this.nameDiv.innerHTML = channelName; 142 | } 143 | get instrument() { 144 | return this.instrumentDiv.innerHTML; 145 | } 146 | set instrument(instrument) { 147 | this.instrumentDiv.innerHTML = instrument; 148 | } 149 | get color() { 150 | return this.style.getPropertyValue('--tab-color'); 151 | } 152 | set color(color) { 153 | this.style.setProperty('--tab-color', color); 154 | } 155 | get lock() { 156 | return this.lockButton.dataset.state === 'lock'; 157 | } 158 | set lock(lock) { 159 | if (typeof lock !== 'boolean') lock = lock === "lock"; 160 | if (lock) { 161 | this.lockButton.dataset.state = 'lock'; 162 | this.lockButton.classList.remove('icon-unlock'); 163 | this.lockButton.classList.add('icon-lock'); 164 | } else { 165 | this.lockButton.dataset.state = 'unlock'; 166 | this.lockButton.classList.remove('icon-lock'); 167 | this.lockButton.classList.add('icon-unlock'); 168 | } 169 | this.dispatchEvent(new Event('lock', { bubbles: true })); 170 | } 171 | get visible() { // true为可见 172 | return this.visibleButton.dataset.state === 'visible'; 173 | } 174 | set visible(visible) { 175 | if (typeof visible !== 'boolean') visible = visible === "visible"; 176 | if (visible) { 177 | this.visibleButton.dataset.state = 'visible'; 178 | this.visibleButton.classList.remove('icon-eyeslash-fill'); 179 | this.visibleButton.classList.add('icon-eye-fill'); 180 | } else { 181 | this.visibleButton.dataset.state = 'invisible'; 182 | this.visibleButton.classList.remove('icon-eye-fill'); 183 | this.visibleButton.classList.add('icon-eyeslash-fill'); 184 | } 185 | this.dispatchEvent(new Event('visible', { bubbles: true })); 186 | } 187 | get mute() { // true为静音 188 | return this.muteButton.dataset.state === 'mute'; 189 | } 190 | set mute(mute) { 191 | if (typeof mute !== 'boolean') mute = mute === "mute"; 192 | if (mute) { 193 | this.muteButton.dataset.state = 'mute'; 194 | this.muteButton.classList.remove('icon-volume'); 195 | this.muteButton.classList.add('icon-close_volume'); 196 | } else { 197 | this.muteButton.dataset.state = 'nomute'; 198 | this.muteButton.classList.remove('icon-close_volume'); 199 | this.muteButton.classList.add('icon-volume'); 200 | } 201 | this.dispatchEvent(new Event('mute', { bubbles: true })); 202 | } 203 | /** 204 | * 从0开始,表示第几项;但显示时从1开始。需要外部维护 205 | */ 206 | get index() { 207 | return parseInt(this.dataset.tabIndex) - 1; 208 | } 209 | set index(index) { 210 | this.dataset.tabIndex = index + 1; 211 | } 212 | toJSON() { // 用于序列化,以实现撤销 213 | return { 214 | name: this.name, 215 | color: this.color, 216 | instrument: this.instrument, 217 | lock: this.lock, 218 | visible: this.visible, 219 | mute: this.mute 220 | }; 221 | } 222 | } 223 | 224 | /** 225 | * 依赖:contextMenu dragList ChannelItem tinySynth 226 | * 事件:(按发生的顺序排) 227 | * remove(detail):发生于删除之前、归还颜色之后 228 | * reorder(detail):发生于有序号变化时,最后一个ChannelItem的删除和新增不会触发 229 | */ 230 | class ChannelList extends EventTarget { 231 | // 颜色是对通道的特异性标识 232 | static colorList = [ 233 | "#FF4500", /*橙红色*/ "#FFD700", /*金色*/ "#32CD32", /*酸橙绿*/ "#00BFFF", /*深天蓝色*/ 234 | "#FF6347", /*番茄色*/ "#FF1493", /*深粉红色*/ "#7FFF00", /*查特酸橙绿*/ "#1E90FF", /*道奇蓝*/ 235 | "#FFA500", /*橙色*/ "#EE82EE", /*紫罗兰*/ "#ADFF2F", /*绿黄色*/ "#87CEFA", /*亮天蓝色*/ 236 | "#FF69B4", /*热情粉红色*/ "#00FA9A", /*中春绿色*/ "#FFB6C1", /*浅粉红色*/ "#20B2AA", /*浅海洋绿*/ 237 | 238 | "#FF8C00", /*深橙色*/ "#BA55D3", /*中紫罗兰色*/ "#9ACD32", /*黄绿色*/ "#4682B4", /*钢蓝色*/ 239 | "#8A2BE2", /*蓝紫色*/ "#5F9EA0", /*军蓝色*/ "#D2691E", /*巧克力色*/ "#6495ED", /*矢车菊蓝*/ 240 | "#DC143C", /*猩红色*/ "#00FFFF", /*青色*/ "#00008B", /*深蓝色*/ "#FF7F50" /*珊瑚色*/ 241 | ]; 242 | /** 243 | * 判断是否点击在channelItem上 244 | * 必须被
  • 包裹才能生效 因此耦合了dragList和dom结构 245 | * @param {HTMLElement} target 一般是e.target 246 | * @returns 如果点击在channelItem上,返回channelItem的container,否则返回null 247 | */ 248 | static whichItem(target) { 249 | const li = target.tagName === 'LI' ? target : target.closest('li'); 250 | if (li && li.firstElementChild.classList.contains('channel-Container')) { 251 | return li.firstElementChild; 252 | } else return null; 253 | } 254 | static judgeClick(e) { return ChannelList.whichItem(e.target); } 255 | /** 256 | * 初始画可拖拽音轨列表 257 | * @param {HTMLDivElement} div 258 | * @param {TinySynth} synthesizer 合成器实例 259 | */ 260 | constructor(div, synthesizer) { 261 | super(); 262 | this.synthesizer = synthesizer; 263 | this.colorMask = 0; // 用于表示哪些颜色已经被占用 264 | for (let i = 0; i < ChannelList.colorList.length; i++) { 265 | this.colorMask |= (1 << i); 266 | } 267 | const list = dragList(); 268 | list.addEventListener('dragend', this.updateRange.bind(this)); 269 | this.selected = null; 270 | list.addEventListener('click', e => { this.selectChannel(e.target); }); 271 | this.container = list; 272 | this.channel = []; // 由updateRange维护 作用是根据id快速定位ChannelItem 屏蔽了li的包裹 273 | this.addChannel(); // 默认有一个 274 | div.appendChild(list); 275 | // 右键菜单 276 | this.contextMenu = new ContextMenu([ 277 | // 在空白部分右击 278 | { 279 | name: '添加音轨', callback: () => { 280 | this.addChannel(); 281 | }, onshow: e => !ChannelList.whichItem(e.target) && this.colorMask 282 | }, { 283 | name: '全部隐藏', callback: () => { 284 | this.channel.forEach(ch => ch.visible = false); 285 | }, onshow: e => !ChannelList.whichItem(e.target), 286 | }, { 287 | name: '全部显示', callback: () => { 288 | this.channel.forEach(ch => ch.visible = true); 289 | }, onshow: e => !ChannelList.whichItem(e.target), 290 | }, 291 | { 292 | name: '全部静音', callback: () => { 293 | this.channel.forEach(ch => ch.mute = true); 294 | }, onshow: e => !ChannelList.whichItem(e.target), 295 | }, 296 | { 297 | name: '全部发声', callback: () => { 298 | this.channel.forEach(ch => ch.mute = false); 299 | }, onshow: e => !ChannelList.whichItem(e.target), 300 | }, 301 | // 在列表项上点击 302 | { 303 | name: '属性设置', callback: (e) => { 304 | let id = parseInt(ChannelList.whichItem(e.target).dataset.tabIndex) - 1; 305 | this.selectChannel(id); // 传id计算量小一点 306 | this.settingPannel(id); 307 | }, onshow: ChannelList.judgeClick 308 | }, { 309 | name: '在上方插入音轨', callback: (e) => { 310 | let id = parseInt(ChannelList.whichItem(e.target).dataset.tabIndex) - 1; 311 | this.addChannel(id); 312 | }, onshow: ChannelList.judgeClick 313 | }, { 314 | name: '在下方插入音轨', callback: (e) => { 315 | let id = parseInt(ChannelList.whichItem(e.target).dataset.tabIndex); 316 | this.addChannel(id); 317 | }, onshow: ChannelList.judgeClick 318 | }, { 319 | name: '删除该轨', callback: (e) => { 320 | this.removeChannel(ChannelList.whichItem(e.target)); 321 | }, onshow: ChannelList.judgeClick 322 | } 323 | ]); 324 | list.addEventListener('contextmenu', (e) => { 325 | e.preventDefault(); 326 | this.contextMenu.show(e); 327 | }); 328 | ChannelItem.prototype.toJSON = this._toJSON; 329 | } 330 | /** 331 | * 根据ui维护this.channel和channelItem.dataset.tabIndex 332 | * 如果发生变化会触发事件:reorder,detail为老顺序->新顺序的变换关系,类似状态转移矩阵 333 | * “新增在最后一个”和“删除最后一个”不会触发reorder 334 | */ 335 | updateRange() { 336 | let change = false; 337 | let children = Array.from(this.container.children); 338 | let indexMap = new Array(children.length + 1); // 防止重新分配空间 加一是为了兼容删除一个后的reorder 339 | this.synthesizer.channel = new Array(children.length); 340 | this.channel = new Array(children.length); 341 | for (let i = 0; i < children.length; i++) { 342 | const channel = children[i].firstElementChild; 343 | let oldIndex = channel.index; 344 | indexMap[oldIndex] = i; // 如果在at插入了新的ChannelItem,由于其序号已经对应好,所以不会触发change;且原来在at的元素被推到了(at+1),会覆盖其对indexMap[at]的更改 345 | if (oldIndex !== i) change = true; 346 | channel.dataset.tabIndex = i + 1; 347 | this.channel[i] = channel; 348 | this.synthesizer.channel[i] = channel.ch; 349 | } 350 | if (change) this.dispatchEvent(new CustomEvent("reorder", { 351 | detail: indexMap 352 | })); 353 | } 354 | 355 | /* 颜色管理 begin */ 356 | borrowColor() { 357 | for (let i = 0; i < ChannelList.colorList.length; i++) { 358 | if (this.colorMask & (1 << i)) { 359 | this.colorMask ^= (1 << i); 360 | return ChannelList.colorList[i]; 361 | } 362 | } return null; 363 | } 364 | borrowTheColor(color) { 365 | for (let i = 0; i < ChannelList.colorList.length; i++) { 366 | if (ChannelList.colorList[i] === color) { 367 | if (this.colorMask & (1 << i)) { 368 | this.colorMask ^= (1 << i); 369 | return color; 370 | } else return null; 371 | } 372 | } return null; 373 | } 374 | returnColor(color) { 375 | for (let i = 0; i < ChannelList.colorList.length; i++) { 376 | if (ChannelList.colorList[i] === color) { 377 | this.colorMask |= (1 << i); 378 | return; 379 | } 380 | } 381 | } 382 | /* 颜色管理 end */ 383 | /** 384 | * 增加一个channel,触发add事件,发生于插入之后 385 | * 然后可能会触发reorder事件,取决于是否插入最后一个 386 | * 最后触发added事件 387 | * @param {Number} at 插入音轨的序号 388 | * @returns {ChannelItem} 389 | */ 390 | addChannel(at = this.channel.length) { // 用于一个个添加 391 | if (!this.colorMask) { 392 | alert(`最多只能添加${ChannelList.colorList.length}个轨道!`); 393 | return; 394 | } 395 | const ch = ChannelItem.new('某音轨', this.borrowColor(), TinySynth.instrument[0]); 396 | ch.index = at; 397 | ch.ch = this.synthesizer.addChannel(at); 398 | this.container.appendChild(ch, at); 399 | ch.click(); 400 | this.dispatchEvent(new CustomEvent("add", { 401 | detail: ch 402 | })); 403 | this.updateRange(); 404 | this.dispatchEvent(new Event("added")); 405 | return ch; 406 | } 407 | /** 408 | * 删除一个channel,触发remove事件,发生于删除之前、归还颜色之后 409 | * 然后可能会触发reorder事件,取决于是否删除最后一个 410 | * 最后触发removed事件 411 | * remove事件必须在reorder之前,因为reorder会触发重新映射,之后就不能根据原有的索引删除音符了 412 | * 此外由于reorder的不稳定触发(会触发存档操作),使用时需要提前清除reorder的回调 413 | * @param {ChannelItem || Number} node 节点的序号或者节点或其子元素 414 | */ 415 | removeChannel(node) { 416 | const channel = typeof node === 'number' ? this.channel[node] : ChannelList.whichItem(node); 417 | if (!channel) return; 418 | this.returnColor(channel.color); 419 | this.dispatchEvent(new CustomEvent("remove", { 420 | detail: channel 421 | })); 422 | this.synthesizer.channel.splice(channel.index, 1); 423 | if (this.selected === channel) this.selected = null; 424 | // 之所以要parentNode是因为dragList中添加项会用
  • 包裹 425 | // 而this.channel[node]是channelItem(在上一次的updateRange中根据container.children赋值,赋值时使用了firstElementChild) 426 | channel.parentNode.remove(); 427 | this.updateRange() // 可能会触发reorder事件 428 | this.dispatchEvent(new Event("removed")); 429 | } 430 | /** 431 | * 设置选中的channel的样式 432 | * @param {ChannelItem || Number} node 节点的序号或者节点或其子元素 433 | * @returns {ChannelItem} 如果无该项则返回null 434 | */ 435 | selectChannel(node) { 436 | const channel = typeof node === 'number' ? this.channel[node] : ChannelList.whichItem(node); 437 | if (channel && channel !== this.selected) { 438 | if (this.selected) this.selected.classList.remove('selected'); 439 | this.selected = channel; 440 | channel.classList.add('selected'); 441 | return channel; 442 | } return null; 443 | } 444 | /** 445 | * 打开ch的设置面板 446 | * @param {Number} chid 音轨序号 447 | */ 448 | settingPannel(chid) { 449 | const ch = this.channel[chid]; 450 | let tempDiv = document.createElement('div'); 451 | tempDiv.innerHTML = ` 452 |
    453 |
    454 |
    音轨名:
    455 |
    音量: 
    456 |
    音色: 
    457 |
    458 |
    459 |
    `; 460 | const card = tempDiv.firstElementChild; 461 | const btns = card.getElementsByTagName('button'); 462 | card.addEventListener('keydown', (e) => { 463 | if (e.keyCode === 13) btns[1].click(); // 回车则点击btns[1] 464 | }); 465 | const close = () => { // 渐变消失 466 | card.style.opacity = 0; 467 | setTimeout(()=>card.remove(), 200); 468 | } 469 | btns[0].addEventListener('click', close); 470 | btns[1].addEventListener('click', () => { 471 | ch.name = inputs[0].value; 472 | ch.ch.volume = parseInt(inputs[1].value); 473 | let inst = parseInt(inputs[2].value); 474 | ch.ch.instrument = inst; 475 | ch.instrument = TinySynth.instrument[inst]; 476 | close(); 477 | }); 478 | const inputs = card.querySelectorAll('[name="ui-ask"]'); 479 | inputs[0].value = ch.name; 480 | inputs[1].value = ch.ch.volume; 481 | // 给select添加选项 482 | for (let i = 0; i < 128; i++) { 483 | const option = document.createElement('option'); 484 | option.value = i; 485 | option.innerHTML = TinySynth.instrument[i]; 486 | if (ch.ch.instrument === i) option.selected = true; 487 | inputs[2].appendChild(option); 488 | } 489 | document.body.insertBefore(card, document.body.firstChild); 490 | card.tabIndex = 0; 491 | card.focus(); 492 | } 493 | _toJSON() { 494 | return { // 篡改ChannelItem的原型方法,使保存的乐器是序号,并能保存音量 495 | name: this.name, 496 | color: this.color, 497 | lock: this.lock, 498 | visible: this.visible, 499 | mute: this.mute, 500 | instrument: this.ch.instrument, 501 | volume: Math.round(Math.sqrt(this.ch.out.gain.value * 16129)), 502 | selected: this.classList.contains('selected') ? 1 : undefined 503 | }; 504 | } 505 | /** 506 | * 从数组中创建列表,用于撤销 不会(不能)调用updateRange 507 | * @param {Array} array 508 | */ 509 | fromArray(array) { 510 | let len = array.length; 511 | if (len > ChannelList.colorList.length) { 512 | console.warn(`轨道数超过最大值${ChannelList.colorList.length}!将忽略多余的轨道。`); 513 | len = ChannelList.colorList.length; 514 | } 515 | this.synthesizer.channel.length = 0; 516 | this.container.clear(); 517 | this.channel = new Array(len); 518 | this.colorMask = 0xFFFF; 519 | let failed = 0x0000; 520 | for (let i = 0; i < len; i++) { 521 | const item = array[i]; 522 | let color = this.borrowTheColor(item.color); 523 | if (!color) { 524 | color = ''; 525 | failed &= (1 << i); 526 | } 527 | const ch = ChannelItem.new(item.name, color, TinySynth.instrument[item.instrument], item.visible, item.mute, item.lock); 528 | ch.ch = this.synthesizer.addChannel(i, item.instrument, item.volume * item.volume / 16129); 529 | this.channel[i] = ch; 530 | ch.dataset.tabIndex = i + 1; 531 | if (item.selected) ch.click(); 532 | this.container.appendChild(ch); 533 | } 534 | if (failed) { 535 | console.warn('颜色冲突或超出范围!将自动分配违规颜色。'); 536 | for (let i = 0; i < len; i++) { 537 | if (failed & (1 << i)) { 538 | this.channel[i].color = this.borrowColor(); 539 | } 540 | } 541 | } 542 | } 543 | } -------------------------------------------------------------------------------- /contextMenu.js: -------------------------------------------------------------------------------- 1 | class ContextMenu { 2 | /** 3 | * 创建菜单 4 | * @param {Array} items [{ 5 | * name: "菜单项", 6 | * callback: (e_father, e_self) => { // 点击菜单项时调用的函数,传参是(触发右键菜单的事件,点击本项的事件) 7 | * return false/true; 8 | * }, // 返回false(或不返回)表示删除菜单,返回true表示不删除菜单 9 | * onshow: function (e) { // 在菜单项显示前调用,传参是触发右键菜单的事件 10 | * // this指向菜单项对象,可以修改其属性 11 | * return true/false; 12 | * }, // 返回true/false控制本项是否显示 13 | * event: "click" // 确认触发本项的事件,默认是click 14 | * },...] 15 | * @param {Array} mustShow 如果菜单项为空,是否显示 16 | */ 17 | constructor(items = [], mustShow = false) { 18 | this.items = items; 19 | this.mustShow = mustShow; 20 | } 21 | 22 | addItem(name, callback, onshow = null, event = "click") { 23 | let existingItem = this.items.find(item => item.name === name); 24 | if (existingItem) existingItem.callback = callback; 25 | else this.items.push({ name: name, callback: callback, onshow: onshow, event: event }); 26 | } 27 | removeItem(name) { 28 | for (let i = 0; i < this.items.length; i++) { 29 | if (this.items[i].name === name) { 30 | this.items.splice(i, 1); 31 | break; 32 | } 33 | } 34 | } 35 | 36 | show(e) { 37 | const contextMenuCard = document.createElement('ul'); 38 | contextMenuCard.classList.add('contextMenuCard'); 39 | contextMenuCard.oncontextmenu = () => false; // 禁用右键菜单 40 | this.items.forEach(item => { 41 | if (item.onshow) if (!item.onshow(e)) return; 42 | const listItem = document.createElement('li'); 43 | listItem.innerHTML = item.name; // 从textContent改为innerHTML,可以使用html标签嵌套 44 | listItem.addEventListener(item.event || 'click', (e_self) => { 45 | if (!item.callback(e, e_self)) { 46 | contextMenuCard.onblur = null; // 如果没有这行,onblur会在contextMenuCard被item删除后再次触发删除,引发报错 47 | contextMenuCard.remove(); 48 | } 49 | }); 50 | contextMenuCard.appendChild(listItem); 51 | }); 52 | if (contextMenuCard.children.length === 0 && !this.mustShow) return; 53 | 54 | contextMenuCard.style.top = `${e.clientY}px`; 55 | contextMenuCard.style.left = `${e.clientX}px`; 56 | 57 | // 添加blur事件监听器 58 | contextMenuCard.tabIndex = -1; // 使元素可以接收焦点 59 | contextMenuCard.onblur = (e) => { 60 | // 如果在contextMenuCard内部点击,就不删除contextMenuCard 61 | if (e.relatedTarget && e.relatedTarget.classList.contains('contextMenuCard')) { 62 | e.stopPropagation(); 63 | return; 64 | } 65 | contextMenuCard.remove(); 66 | } 67 | setTimeout(() => { 68 | document.body.appendChild(contextMenuCard); 69 | // 使元素立即获取焦点(要设置css:focue属性:outline:none;) 70 | contextMenuCard.focus(); 71 | }, 0); // 延时是因为让show可以被mousedown事件调用(否则mousedown触发后再触发contextmenu将导致菜单消失) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /dataProcess/AI/basicamt.js: -------------------------------------------------------------------------------- 1 | function basicamt(audioChannel) { 2 | let timeDomain = new Float32Array(audioChannel.getChannelData(0)); 3 | let audioLen = timeDomain.length; 4 | // 求和。不求平均是因为模型内部有归一化 5 | if (audioChannel.numberOfChannels !== 1) { 6 | for (let i = 1; i < audioChannel.numberOfChannels; i++) { 7 | const channelData = audioChannel.getChannelData(i); 8 | for (let j = 0; j < audioLen; j++) timeDomain[j] += channelData[j]; 9 | } 10 | } 11 | return new Promise((resolve, reject) => { 12 | const basicamtWorker = new Worker("./dataProcess/AI/basicamt_worker.js"); 13 | basicamtWorker.onmessage = ({data}) => { 14 | if (data.type === 'error') { 15 | console.error(data.message); 16 | reject("疑似因为音频过长导致内存不足!"); 17 | basicamtWorker.terminate(); 18 | } 19 | resolve(data); // 返回的是音符事件 20 | basicamtWorker.terminate(); 21 | }; 22 | basicamtWorker.onerror = (e) => { 23 | console.error(e.message); 24 | reject(e); 25 | basicamtWorker.terminate(); 26 | }; 27 | basicamtWorker.postMessage(timeDomain, [timeDomain.buffer]); 28 | }); 29 | } -------------------------------------------------------------------------------- /dataProcess/AI/basicamt_44100.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/dataProcess/AI/basicamt_44100.onnx -------------------------------------------------------------------------------- /dataProcess/AI/basicamt_worker.js: -------------------------------------------------------------------------------- 1 | // const ort_folder = 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/'; 2 | // self.importScripts(ort_folder + 'ort.wasm.min.js'); 3 | // ort.env.wasm.wasmPaths = ort_folder; 4 | 5 | self.importScripts('./dist/bundle.min.js') 6 | ort.env.wasm.wasmPaths = './dist/'; 7 | 8 | const model = ort.InferenceSession.create( 9 | './basicamt_44100.onnx', // webgpu报错cat(但是没问题啊?),webgl不支持int64,所以只能用cpu 10 | ); 11 | 12 | self.onmessage = function ({data}) { 13 | const tensorInput = new ort.Tensor('float32', data, [1, 1, data.length]); 14 | model.then((m) => { 15 | return m.run({ audio: tensorInput }); 16 | }).then((results) => { 17 | const note_events = createNotes(results.onset, results.frame); 18 | self.postMessage(note_events); 19 | }).catch((e) => { 20 | // promise中的报错不会触发worker.onerror回调,即使这里throw了。所以只能用onmessage 21 | self.postMessage({ type: 'error', message: e.message }); 22 | }); 23 | }; 24 | 25 | function createNotes( 26 | onsetTensor, frameTensor, 27 | frame_thresh = 0.145, 28 | onset_thresh = 0.42, 29 | min_note_len = 6, 30 | energy_tol = 10, 31 | midi_offset = 24 32 | ) { 33 | // 模型中已经对onset和frame进行归一化了 34 | const raw_frameData = frameTensor.cpuData; 35 | const frameDim = frameTensor.dims; // [1, 84, frames] 36 | const raw_onsetData = onsetTensor.cpuData; 37 | const onsetDim = onsetTensor.dims; // [1, 84, frames] 38 | // 两个dim应该一样 39 | if (frameDim[1] !== onsetDim[1] || frameDim[2] !== onsetDim[2]) { 40 | throw new Error("frameDim[1] !== onsetDim[1] || frameDim[2] !== onsetDim[2]"); 41 | } 42 | const frameNum = frameDim[2]; 43 | const noteNum = frameDim[1]; 44 | 45 | const frameData = Array(noteNum); 46 | const onsetData = Array(noteNum); 47 | for (let i = 0; i < noteNum; i++) { 48 | // 和raw共享内存 49 | frameData[i] = new Float32Array(raw_frameData.buffer, i * frameNum * 4, frameNum); 50 | onsetData[i] = new Float32Array(raw_onsetData.buffer, i * frameNum * 4, frameNum); 51 | } 52 | 53 | get_infered_onsets(onsetData, frameData, 3); 54 | 55 | const peaks = findPeak(onsetData, onset_thresh); 56 | peaks.sort((a, b) => b[0] - a[0]); // 按照时间反过来排序 57 | 58 | const remaining_energy = Array(noteNum); // 复制一份frameData,用于修改数据 59 | for (let i = 0; i < noteNum; i++) remaining_energy[i] = new Float32Array(frameData[i]); 60 | 61 | const note_events = []; 62 | for (const [note_start_idx, freq_idx] of peaks) { 63 | // 如果剩下的距离不够放一个最短的音符,就跳过 64 | if (note_start_idx >= frameNum - min_note_len) continue; 65 | 66 | let note_end_idx = note_start_idx + 1; 67 | let k = 0; // 连续k个小于frame_thresh的帧 68 | const freqArray = remaining_energy[freq_idx]; 69 | // 向后搜索,连续energy_tol帧小于frame_thresh(或者到达最后一帧),就认为这个音符结束。目的是将分散的frames合并 70 | while (note_end_idx < frameNum && k < energy_tol) { 71 | if (freqArray[note_end_idx] < frame_thresh) k++; 72 | else k = 0; 73 | note_end_idx++; 74 | } 75 | note_end_idx -= k; // 回到音符结尾 76 | 77 | if (note_end_idx - note_start_idx < min_note_len) continue; // 跳过短音符 78 | freqArray.fill(0, note_start_idx, note_end_idx); // 将这个音符的frame清零 79 | 80 | // 认为半音不会同时出现,因为不能构成和弦 81 | if (freq_idx < noteNum - 1) 82 | remaining_energy[freq_idx + 1].fill(0, note_start_idx, note_end_idx); 83 | if (freq_idx > 0) 84 | remaining_energy[freq_idx - 1].fill(0, note_start_idx, note_end_idx); 85 | 86 | // 对frameData在start和end中间的求平均 87 | let sum = 0; 88 | for (let i = note_start_idx; i < note_end_idx; i++) 89 | sum += frameData[freq_idx][i]; 90 | 91 | note_events.push({ 92 | onset: note_start_idx, 93 | offset: note_end_idx, 94 | note: freq_idx + midi_offset, 95 | velocity: sum / (note_end_idx - note_start_idx) 96 | }); 97 | } 98 | 99 | // 不依赖onset,根据frames中的极大值找额外的音符 100 | const maxes = []; 101 | for (let n = 0; n < noteNum; n++) { 102 | const thisNote = frameData[n]; 103 | for (let t = 1; t < frameNum; t++) { 104 | if (thisNote[t] > frame_thresh) maxes.push([thisNote[t], n, t]); 105 | } 106 | } 107 | maxes.sort((a, b) => b[0] - a[0]); // 按照能量从大到小排序 108 | 109 | for (const [_, n, t] of maxes) { 110 | // 可能被前面的循环置零了 111 | if (remaining_energy[n][t] < frame_thresh) continue; 112 | // 后向搜索 113 | let note_end_idx = t + 1; 114 | let k = 0; 115 | const freqArray = remaining_energy[n]; 116 | while (note_end_idx < frameNum && k < energy_tol) { 117 | if (freqArray[note_end_idx] < frame_thresh) k++; 118 | else k = 0; 119 | note_end_idx++; 120 | } 121 | note_end_idx -= k; 122 | // 前向搜索 123 | let note_start_idx = t - 1; 124 | k = 0; 125 | while (note_start_idx > 0 && k < energy_tol) { 126 | if (freqArray[note_start_idx] < frame_thresh) k++; 127 | else k = 0; 128 | note_start_idx--; 129 | } 130 | note_start_idx += (k + 1); // 之前多减了1,而fill是左闭右开 131 | 132 | // 不管长度符不符合,都置零 133 | freqArray.fill(0, note_start_idx, note_end_idx); 134 | if (n < noteNum - 1) 135 | remaining_energy[n + 1].fill(0, note_start_idx, note_end_idx); 136 | if (n > 0) 137 | remaining_energy[n - 1].fill(0, note_start_idx, note_end_idx); 138 | 139 | 140 | if (note_end_idx - note_start_idx < min_note_len) continue; 141 | 142 | let sum = 0; 143 | for (let i = note_start_idx; i < note_end_idx; i++) 144 | sum += frameData[n][i]; 145 | 146 | note_events.push({ 147 | onset: note_start_idx, 148 | offset: note_end_idx, 149 | note: n + midi_offset, 150 | velocity: sum / (note_end_idx - note_start_idx) 151 | }); 152 | } 153 | return note_events; 154 | } 155 | 156 | /** 157 | * 从frame中推断新的onset 会修改传入的的onsets 158 | * @param {Array} onsets 159 | * @param {Array} frames 160 | * @param {number} n_diff 161 | */ 162 | function get_infered_onsets(onsets, frames, n_diff = 2) { 163 | const frameNum = frames[0].length; 164 | const noteNum = frames.length; 165 | const inffered_onsets = Array(noteNum); 166 | let infered_max = -1e10; // 用于归一化 167 | for (let n = 0; n < noteNum; n++) { 168 | const notetime = new Float32Array(frameNum); 169 | const thisFrame = frames[n]; 170 | for (let t = n_diff; t < frameNum; t++) { 171 | let min_diff = 1e10; 172 | // 对每个时间点求最小的差值 173 | for (let k = 1; k <= n_diff; k++) { 174 | let diff = thisFrame[t] - thisFrame[t - k]; 175 | if (diff < min_diff) min_diff = diff; 176 | } 177 | if (min_diff > infered_max) infered_max = min_diff; 178 | notetime[t] = min_diff; 179 | } 180 | inffered_onsets[n] = notetime 181 | } 182 | // 归一化 由于onset在模型内部已经归一化了,所以onset的最大值就是1 183 | for (let n = 0; n < noteNum; n++) { 184 | for (let t = 0; t < frameNum; t++) { 185 | let temp = inffered_onsets[n][t] / infered_max; 186 | if (temp > onsets[n][t]) onsets[n][t] = temp; 187 | } 188 | } 189 | } 190 | 191 | function findPeak(x2d, threshold = 0) { 192 | const H = x2d.length; 193 | const W = x2d[0].length - 1; 194 | let peak = []; 195 | for (let h = 0; h < H; h++) { 196 | const row = x2d[h]; 197 | let last_is_up = true; // 由于模型用的是sigmoid,所以全部大于零,所以第一个之前的导数一定大于零 198 | for (let w = 0; w < W; w++) { 199 | if (row[w] < threshold) continue; 200 | if (last_is_up) { 201 | if (row[w] > row[w + 1]) { // 下一个小于当前,说明当前是峰值 202 | peak.push([w, h]); 203 | last_is_up = false; 204 | } else if (row[w] == row[w + 1]) { 205 | let _w = w + 1; 206 | // 下一个等于当前,要看后面第一个非零导数是否小于零 207 | while (_w < W) { 208 | if (row[_w] == row[_w + 1]) _w++; 209 | else if (row[_w] < row[_w + 1]) break; 210 | else { // 后面变小了,说明当前是峰值 211 | last_is_up = false; 212 | peak.push([w, h]); 213 | w = _w; 214 | break; 215 | } 216 | } 217 | } 218 | } else { 219 | last_is_up = (row[w] < row[w + 1]); 220 | } 221 | } 222 | } return peak; 223 | } -------------------------------------------------------------------------------- /dataProcess/AI/dist/ort-wasm-simd.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/dataProcess/AI/dist/ort-wasm-simd.wasm -------------------------------------------------------------------------------- /dataProcess/CQT/.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Win32", 5 | "includePath": [ 6 | "${default}", 7 | "D:/PROGRAM/js_html/emsdk/upstream/emscripten/cache/sysroot/include" 8 | ], 9 | "defines": [ 10 | "_DEBUG", 11 | "UNICODE", 12 | "_UNICODE" 13 | ], 14 | "windowsSdkVersion": "10.0.22000.0", 15 | "cStandard": "c17", 16 | "cppStandard": "c++17" 17 | } 18 | ], 19 | "version": 4 20 | } -------------------------------------------------------------------------------- /dataProcess/CQT/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build wasm", 6 | "type": "shell", 7 | "windows": { 8 | "command": "emcc", 9 | "args": [ 10 | "--bind", // 使用embind 11 | "--no-entry", // 解决报错: undefined symbol: main 12 | "${workspaceFolder}\\cqt_wasm.cpp", 13 | "-o", 14 | "${workspaceFolder}\\cqt.wasm.js", // 很多依赖,自己写是不太可能的 15 | "-O3", 16 | "-s", 17 | "WASM=1", 18 | "-s", 19 | "ALLOW_MEMORY_GROWTH=1", // 不加就OOM 20 | ] 21 | }, 22 | "problemMatcher": [] 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /dataProcess/CQT/cqt.js: -------------------------------------------------------------------------------- 1 | // 开启CQT的Worker线程,因为CQT是耗时操作,所以放在Worker线程中 2 | function cqt(audioBuffer, tNum, channel, fmin) { 3 | var audioChannel; 4 | switch (channel) { 5 | case 0: audioChannel = [audioBuffer.getChannelData(0)]; break; 6 | case 1: audioChannel = [audioBuffer.getChannelData(audioBuffer.numberOfChannels - 1)]; break; 7 | case 2: { // L+R 8 | let length = audioBuffer.length; 9 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 10 | if (audioBuffer.numberOfChannels > 1) { 11 | let channelData = audioBuffer.getChannelData(1); 12 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] + channelData[i]) * 0.5; 13 | } audioChannel = [timeDomain]; break; 14 | } 15 | case 3: { // L-R 16 | let length = audioBuffer.length; 17 | const timeDomain = new Float32Array(audioBuffer.getChannelData(0)); 18 | if (audioBuffer.numberOfChannels > 1) { 19 | let channelData = audioBuffer.getChannelData(1); 20 | for (let i = 0; i < length; i++) timeDomain[i] = (timeDomain[i] - channelData[i]) * 0.5; 21 | } audioChannel = [timeDomain]; break; 22 | } 23 | default: { // cqt(L) + cqt(R) 24 | if (audioBuffer.numberOfChannels > 1) { 25 | audioChannel = [audioBuffer.getChannelData(0), audioBuffer.getChannelData(1)]; 26 | } else { 27 | audioChannel = [audioBuffer.getChannelData(0)]; 28 | } break; 29 | } 30 | } 31 | return new Promise((resolve, reject) => { 32 | const worker = new Worker("./dataProcess/CQT/cqt_worker.js"); 33 | worker.onerror = (e) => { 34 | reject(e); 35 | worker.terminate(); 36 | }; 37 | worker.onmessage = ({ data }) => { 38 | resolve(data); 39 | worker.terminate(); 40 | }; 41 | worker.postMessage({ 42 | audioChannel: audioChannel, 43 | sampleRate: audioBuffer.sampleRate, 44 | hop: Math.round(audioBuffer.sampleRate / tNum), 45 | fmin: fmin, 46 | }, audioChannel.map(x => x.buffer)); 47 | }); 48 | } -------------------------------------------------------------------------------- /dataProcess/CQT/cqt.wasm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/dataProcess/CQT/cqt.wasm.wasm -------------------------------------------------------------------------------- /dataProcess/CQT/cqt_wasm.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | using namespace emscripten; 7 | 8 | #ifndef M_PI 9 | #define M_PI 3.14159265358979323846 10 | #endif 11 | 12 | // 输入是Float32Array 13 | float* getFloatPtrFrom1XArray(val arr, uint32_t &len) { 14 | // as 报错: Uncaught BindingError: emval::as has unknown type y 15 | // 应该返回一个Number,Number对应的类型不包括int64_t,请查看emscripten::val的文档↓ 16 | // https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html?highlight=val#using-val-to-transliterate-javascript-to-c 17 | len = arr["length"].as(); 18 | float *ret = new float[len]; 19 | uintptr_t ptr = reinterpret_cast(ret); 20 | val module = val::global("Module"); 21 | module["HEAPF32"].call("set", arr, val(ptr / sizeof(float))); 22 | return ret; 23 | } 24 | // typed_memory_view不会复制数据,转换成js Array很快,但是释放必须由C++完成 25 | val get2XArray(float **arr, int d1_len, int d2_len){ 26 | val ret = val::array(); 27 | for(int i = 0; i < d1_len; i ++) 28 | ret.set(i, val(typed_memory_view(d2_len, arr[i]))); 29 | return ret; 30 | } 31 | 32 | 33 | /* 34 | 常数Q变换 CQT 类 35 | */ 36 | class CQT { 37 | private: 38 | uint16_t hop; 39 | uint8_t notes; 40 | uint8_t bins_per_octave; 41 | float** kernel_r; 42 | float** kernel_i; 43 | uint32_t* kernel_len; 44 | // 输出有关 45 | float** output; 46 | uint32_t output_length; 47 | public: 48 | CQT(uint16_t fs = 44100, uint16_t hop = 2205, float fmin = 32.7, uint8_t notes = 84, uint8_t bins_per_octave = 12, float filter_scale = 3): 49 | hop(hop), notes(notes), bins_per_octave(bins_per_octave), output(nullptr), output_length(0) { 50 | float Q = filter_scale / (pow(2, 1.0 / bins_per_octave) - 1); 51 | this->iniKernel(Q, fs, fmin); 52 | } 53 | 54 | ~CQT() { 55 | this->clearOutput(); 56 | for (uint16_t i = 0; i < this->notes; i++) { 57 | delete[] this->kernel_r[i]; 58 | delete[] this->kernel_i[i]; 59 | } 60 | delete[] this->kernel_r; 61 | delete[] this->kernel_i; 62 | delete[] this->kernel_len; 63 | } 64 | 65 | void clearOutput() { 66 | if(this->output != nullptr){ 67 | for(uint32_t i = 0; i < this->output_length; i++) 68 | delete[] this->output[i]; 69 | delete[] this->output; 70 | } 71 | this->output = nullptr; 72 | this->output_length = 0; 73 | } 74 | 75 | static float* blackmanHarris(uint16_t N) { 76 | float* window = new float[N]; 77 | const double temp = 2 * M_PI / (N - 1); 78 | float sum = 0; 79 | for (uint16_t n = 0; n < N; n++) { 80 | window[n] = 0.35875 - 0.48829 * cos(temp * n) + 0.14128 * cos(temp * 2 * n) - 0.01168 * cos(temp * 3 * n); 81 | sum += window[n]; 82 | } 83 | for (uint16_t n = 0; n < N; n++) window[n] /= sum; 84 | return window; 85 | } 86 | 87 | void iniKernel(float Q, uint16_t fs, float fmin) { 88 | float** kernel_r = this->kernel_r = new float*[this->notes]; 89 | float** kernel_i = this->kernel_i = new float*[this->notes]; 90 | uint32_t* kernel_len = this->kernel_len = new uint32_t[this->notes]; 91 | for (uint8_t i = 0; i < this->notes; i++) { 92 | const float freq = fmin * pow(2, float(i) / this->bins_per_octave); 93 | uint32_t len = kernel_len[i] = ceil(Q * fs / freq); 94 | if (len < this->hop) len = this->hop; 95 | float* temp_kernel_r = kernel_r[i] = new float[len]; 96 | float* temp_kernel_i = kernel_i[i] = new float[len]; 97 | float* window = CQT::blackmanHarris(len); 98 | const float omega = 2 * M_PI * freq / fs; 99 | const int64_t half_len = len >> 1; 100 | for (int64_t j = 0; j < len; j++) { 101 | const float angle = omega * (j - half_len); 102 | temp_kernel_r[j] = window[j] * cos(angle); 103 | temp_kernel_i[j] = window[j] * sin(angle); 104 | } 105 | delete[] window; 106 | } 107 | } 108 | 109 | /** 110 | * @param x 输入的音频信号 111 | * @param length 输入的音频信号长度 112 | * @param output 输出地址 113 | * @param output_length output的长度 114 | * @return 本次CQT时间长度 115 | * 吐过output为空,则output_length和返回值一样 116 | * 如果output不为空,说明output已经有内容了,在后面追加,返回值是追加的长度,output_length是总长度 117 | */ 118 | uint32_t _cqt(float* x, uint32_t length, float** &output, uint32_t &output_length) { 119 | uint32_t offset = this->hop >> 1; 120 | uint32_t _output_length = ceil(float(length - offset) / this->hop); 121 | float** _output = output; 122 | 123 | if (output != nullptr && output_length != 0) { 124 | // 说明output已经有内容了,在后面追加 125 | output = new float*[output_length + _output_length]; 126 | for (uint32_t i = 0; i < output_length; i++) output[i] = _output[i]; 127 | delete[] _output; 128 | _output = output + output_length; 129 | output_length += _output_length; 130 | } else { 131 | _output = output = new float*[_output_length]; 132 | output_length = _output_length; 133 | } 134 | 135 | uint32_t pointer = 0; 136 | for (; offset < length; offset += this->hop) { 137 | float* energy = _output[pointer++] = new float[this->notes]; 138 | for (uint8_t note = 0; note < this->notes; note++) { 139 | const float* kernel_r = this->kernel_r[note]; 140 | const float* kernel_i = this->kernel_i[note]; 141 | const uint32_t kernel_len = this->kernel_len[note]; 142 | float sum_r = 0; float sum_i = 0; 143 | const int32_t left = offset - (kernel_len >> 1); 144 | uint32_t right = length - left; 145 | if (right > kernel_len) right = kernel_len; 146 | for (uint32_t i = left >= 0 ? 0 : -left; i < right; i++) { 147 | const uint32_t index = i + left; 148 | if (index >= length) break; 149 | sum_r += x[index] * kernel_r[i]; 150 | sum_i += x[index] * kernel_i[i]; 151 | } energy[note] = sqrt(sum_r * sum_r + sum_i * sum_i) * 32; // 和STFT保持一致 152 | } 153 | } 154 | return _output_length; 155 | } 156 | // 暴露给js的cqt接口 157 | val cqt(val input) { 158 | // 将输入的js数组转换为c++数组 159 | uint32_t length = 0; 160 | float* x = getFloatPtrFrom1XArray(input, length); 161 | // 计算CQT 不清空之前的结果,在后面追加 162 | // 返回结果中,output已经被延长或分配内存了 163 | uint32_t cqt_length = this->_cqt(x, length, this->output, this->output_length); 164 | delete[] x; 165 | return get2XArray(this->output + this->output_length - cqt_length, cqt_length, this->notes); 166 | } 167 | }; 168 | 169 | EMSCRIPTEN_BINDINGS(module) { 170 | emscripten::class_("CQT") 171 | .constructor() 172 | .function("cqt", &CQT::cqt, emscripten::allow_raw_pointers()) 173 | .function("clearOutput", &CQT::clearOutput, emscripten::allow_raw_pointers()); 174 | } -------------------------------------------------------------------------------- /dataProcess/CQT/cqt_worker.js: -------------------------------------------------------------------------------- 1 | // 线程文件,利用WASM计算CQT 2 | // import Module from "./cqt.wasm.js"; // 不知道为什么一用就报错,明明nodejs里面是可以的 3 | // 浏览器可以直接script src引入 4 | self.importScripts("./cqt.wasm.js"); 5 | 6 | const CQT = new Promise((resolve) => { 7 | Module.onRuntimeInitialized = () => { 8 | resolve(Module.CQT); 9 | }; 10 | }); 11 | 12 | self.onmessage = async ({data}) => { 13 | let { audioChannel, sampleRate, hop, fmin } = data; 14 | const cqt = new (await CQT)(sampleRate, hop, fmin, 84, 12, 2.88); 15 | let cqtData = cqt.cqt(audioChannel[0]); 16 | // 复制构造,因为WASM的内存不能transfer,原因见下 17 | cqtData = cqtData.map(x => new Float32Array(x)); 18 | // 第二个通道 19 | if (audioChannel.length == 2) { 20 | cqt.clearOutput(); // 因为上面复制构造了,这里可以先清空了 21 | let cqtData2 = cqt.cqt(audioChannel[1]); 22 | for (let i = 0; i < cqtData.length; i++) { 23 | const temp1 = cqtData[i]; 24 | const temp2 = cqtData2[i]; 25 | for (let j = 0; j < cqtData[i].length; j++) 26 | temp1[j] = (temp1[j] + temp2[j]) * 0.5; 27 | } 28 | } 29 | // 第一个问题:self.postMessage(cqtData, cqtData.map(x => x.buffer));失败,因为重复transfer了一个buffer 30 | // 于是发现每个Float32Array.buffer都是同一个 31 | // 于是只传递一个buffer,但是报错Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': ArrayBuffer at index 0 is not detachable and could not be transferred. 32 | // 于是试着传递Module.HEAPF32.buffer,但是报错相同 33 | // 所以只能复制构造了 34 | self.postMessage(cqtData, [...cqtData.map(x => x.buffer), ...audioChannel.map(x => x.buffer)]); 35 | cqt.delete(); 36 | self.close(); 37 | }; 38 | -------------------------------------------------------------------------------- /dataProcess/analyser.js: -------------------------------------------------------------------------------- 1 | class FreqTable extends Float32Array { 2 | constructor(A4 = 440) { 3 | super(84); // 范围是C1-B7 4 | this.A4 = A4; 5 | } 6 | set A4(A4) { 7 | let Note4 = [ 8 | A4 * 0.5946035575013605, A4 * 0.6299605249474366, 9 | A4 * 0.6674199270850172, A4 * 0.7071067811865475, 10 | A4 * 0.7491535384383408, 11 | A4 * 0.7937005259840998, A4 * 0.8408964152537146, 12 | A4 * 0.8908987181403393, A4 * 0.9438743126816935, 13 | A4, A4 * 1.0594630943592953, 14 | A4 * 1.122462048309373 15 | ]; 16 | this.set(Note4.map(v => v / 8), 0); 17 | this.set(Note4.map(v => v / 4), 12); 18 | this.set(Note4.map(v => v / 2), 24); 19 | this.set(Note4, 36); 20 | this.set(Note4.map(v => v * 2), 48); 21 | this.set(Note4.map(v => v * 4), 60); 22 | this.set(Note4.map(v => v * 8), 72); 23 | } 24 | get A4() { 25 | return this[45]; 26 | } 27 | } 28 | 29 | class NoteAnalyser { // 负责解析频谱数据 30 | /** 31 | * @param {Number} df FFT的频率分辨率 32 | * @param {FreqTable || Number} freq 频率表(将被引用)或中央A的频率 33 | */ 34 | constructor(df, freq) { 35 | this.df = df; 36 | if (typeof freq === 'number') { 37 | this.freqTable = new FreqTable(freq); 38 | } else { 39 | this.freqTable = freq; 40 | } this.updateRange(); 41 | } 42 | set A4(freq) { 43 | this.freqTable.A4 = freq; 44 | this.updateRange(); 45 | } 46 | get A4() { 47 | return this.freqTable.A4; 48 | } 49 | updateRange() { 50 | let at = Array.from(this.freqTable.map((value) => Math.round(value / this.df))); 51 | at.push(Math.round((this.freqTable[this.freqTable.length - 1] * 1.059463) / this.df)) 52 | const range = new Float32Array(84); // 第i个区间的终点 53 | for (let i = 0; i < at.length - 1; i++) { 54 | range[i] = (at[i] + at[i + 1]) / 2; 55 | } this.rangeTable = range; 56 | } 57 | /** 58 | * 从频谱提取音符的频谱 原理是区间内求和 59 | * @param {Float32Array} real 实部 60 | * @param {Float32Array} imag 虚部 61 | * @returns {Float32Array} 音符的幅度谱 数据很小 62 | */ 63 | analyse(real, imag) { 64 | const noteAm = new Float32Array(84); 65 | let at = this.rangeTable[0] | 0; 66 | for (let i = 0; i < this.rangeTable.length; i++) { 67 | let end = this.rangeTable[i]; 68 | if (at == end) { // 如果相等则就算一次 乘法比幂运算快 69 | noteAm[i] = real[at] * real[at] + imag[at] * imag[at]; 70 | } else { 71 | for (; at < end; at++) { 72 | noteAm[i] += real[at] * real[at] + imag[at] * imag[at]; 73 | } 74 | if (at == end) { // end是整数,需要对半分 75 | let a2 = (real[end] * real[end] + imag[end] * imag[end]) / 2; 76 | noteAm[i] += a2; 77 | if (i < noteAm.length - 1) noteAm[i + 1] += a2; 78 | } 79 | } 80 | // FFT的结果需要除以N才是DTFT的结果 由于结果太小,统一放大10倍 经验得到再乘700可在0~255得到较好效果 81 | noteAm[i] = Math.sqrt(noteAm[i]) * 16 / real.length; 82 | } return noteAm; 83 | } 84 | /** 85 | * 调性分析,原理是音符能量求和 86 | * @param {Array} noteTable 87 | * @returns {Array} 调性和音符的能量 88 | */ 89 | static Tonality(noteTable) { 90 | let energy = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 91 | for (const atime of noteTable) { 92 | energy[0] += atime[0] ** 2 + atime[12] ** 2 + atime[24] ** 2 + atime[36] ** 2 + atime[48] ** 2 + atime[60] ** 2 + atime[72] ** 2; 93 | energy[1] += atime[1] ** 2 + atime[13] ** 2 + atime[25] ** 2 + atime[37] ** 2 + atime[49] ** 2 + atime[61] ** 2 + atime[73] ** 2; 94 | energy[2] += atime[2] ** 2 + atime[14] ** 2 + atime[26] ** 2 + atime[38] ** 2 + atime[50] ** 2 + atime[62] ** 2 + atime[74] ** 2; 95 | energy[3] += atime[3] ** 2 + atime[15] ** 2 + atime[27] ** 2 + atime[39] ** 2 + atime[51] ** 2 + atime[63] ** 2 + atime[75] ** 2; 96 | energy[4] += atime[4] ** 2 + atime[16] ** 2 + atime[28] ** 2 + atime[40] ** 2 + atime[52] ** 2 + atime[64] ** 2 + atime[76] ** 2; 97 | energy[5] += atime[5] ** 2 + atime[17] ** 2 + atime[29] ** 2 + atime[41] ** 2 + atime[53] ** 2 + atime[65] ** 2 + atime[77] ** 2; 98 | energy[6] += atime[6] ** 2 + atime[18] ** 2 + atime[30] ** 2 + atime[42] ** 2 + atime[54] ** 2 + atime[66] ** 2 + atime[78] ** 2; 99 | energy[7] += atime[7] ** 2 + atime[19] ** 2 + atime[31] ** 2 + atime[43] ** 2 + atime[55] ** 2 + atime[67] ** 2 + atime[79] ** 2; 100 | energy[8] += atime[8] ** 2 + atime[20] ** 2 + atime[32] ** 2 + atime[44] ** 2 + atime[56] ** 2 + atime[68] ** 2 + atime[80] ** 2; 101 | energy[9] += atime[9] ** 2 + atime[21] ** 2 + atime[33] ** 2 + atime[45] ** 2 + atime[57] ** 2 + atime[69] ** 2 + atime[81] ** 2; 102 | energy[10] += atime[10] ** 2 + atime[22] ** 2 + atime[34] ** 2 + atime[46] ** 2 + atime[58] ** 2 + atime[70] ** 2 + atime[82] ** 2; 103 | energy[11] += atime[11] ** 2 + atime[23] ** 2 + atime[35] ** 2 + atime[47] ** 2 + atime[59] ** 2 + atime[71] ** 2 + atime[83] ** 2; 104 | } 105 | // notes根据最大值归一化 106 | let max = Math.max(...energy); 107 | energy = energy.map((num) => num / max); 108 | // 找到最大的前7个音符 109 | const sortedIndices = energy.map((num, index) => index) 110 | .sort((a, b) => energy[b] - energy[a]) 111 | .slice(0, 7); 112 | sortedIndices.sort((a, b) => a - b); 113 | // 判断调性 114 | let tonality = sortedIndices.map((num) => { 115 | return num.toString(16); 116 | }).join(''); 117 | switch (tonality) { 118 | case '024579b': tonality = 'C'; break; 119 | case '013568a': tonality = 'C#'; break; 120 | case '124679b': tonality = 'D'; break; 121 | case '023578a': tonality = 'Eb'; break; 122 | case '134689b': tonality = 'E'; break; 123 | case '024579a': tonality = 'F'; break; 124 | case '13568ab': tonality = 'Gb'; break; 125 | case '024679b': tonality = 'G'; break; 126 | case '013578a': tonality = 'Ab'; break; 127 | case '124689b': tonality = 'A'; break; 128 | case '023579a': tonality = 'Bb'; break; 129 | case '13468ab': tonality = 'B'; break; 130 | default: tonality = 'Unknown'; break; 131 | } return [tonality, energy]; 132 | } 133 | /** 134 | * 标记大于阈值的音符 135 | * @param {Array} noteTable 时频图 136 | * @param {Number} threshold 阈值 137 | * @param {Number} from 138 | * @param {Number} to 139 | * @returns {Array} {x1,x2,y,ch,selected} 140 | */ 141 | static autoFill(noteTable, threshold, from = 0, to = 0) { 142 | let notes = []; 143 | let lastAt = new Uint16Array(noteTable[0].length).fill(65535); 144 | let time = from; // 迭代器指示 145 | if (!to || to > noteTable.length) to = noteTable.length; 146 | for (; time < to; time++) { 147 | const t = noteTable[time]; 148 | for (let i = 0; i < lastAt.length; i++) { 149 | let now = t[i] < threshold; // 现在不达标 150 | if (lastAt[i] != 65535) { 151 | if (now) { 152 | notes.push({ // 上一次有但是这次没有 153 | y: i, 154 | x1: lastAt[i], 155 | x2: time, 156 | ch: -1, selected: false 157 | }); lastAt[i] = 65535; 158 | } 159 | } else if (!now) lastAt[i] = time; // 上次没有这次有 160 | } 161 | } 162 | // 扫尾 163 | for (let i = 0; i < lastAt.length; i++) { 164 | if (lastAt[i] != 65535) notes.push({ 165 | y: i, 166 | x1: lastAt[i], 167 | x2: time, 168 | ch: -1, selected: false 169 | }); 170 | } return notes; 171 | } 172 | } -------------------------------------------------------------------------------- /dataProcess/fft_real.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 目前我写的最快的实数FFT。为音乐频谱分析设计 3 | */ 4 | class realFFT { 5 | /** 6 | * 位反转数组 最大支持2^16点 7 | * @param {Number} N 2的正整数幂 8 | * @returns {Uint16Array} 位反转序列 9 | */ 10 | static reverseBits(N) { 11 | const reverseBits = new Uint16Array(N); // 实际N最大2^15 12 | reverseBits[0] = 0; 13 | // 计算位数 14 | let bits = 15; 15 | while ((1 << bits) > N) bits--; 16 | // 由于是实数FFT,偶次为实部,奇次为虚部,故最终结果要乘2,所以不是16-bits 17 | bits = 15 - bits; 18 | for (let i = 1; i < N; i++) { 19 | // 基于二分法的位翻转 20 | let r = ((i & 0xaaaa) >> 1) | ((i & 0x5555) << 1); 21 | r = ((r & 0xcccc) >> 2) | ((r & 0x3333) << 2); 22 | r = ((r & 0xf0f0) >> 4) | ((r & 0x0f0f) << 4); 23 | reverseBits[i] = ((r >> 8) | (r << 8)) >> bits; 24 | } return reverseBits; 25 | } 26 | /** 27 | * 复数乘法 28 | * @param {Number} a 第一个数的实部 29 | * @param {Number} b 第一个数的虚部 30 | * @param {Number} c 第二个数的实部 31 | * @param {Number} d 第二个数的虚部 32 | * @returns {Array} [实部, 虚部] 33 | */ 34 | static ComplexMul(a = 0, b = 0, c = 0, d = 0) { 35 | return [a * c - b * d, a * d + b * c]; 36 | } 37 | /** 38 | * 计算复数的幅度 39 | * @param {Float32Array} r 实部数组 40 | * @param {Float32Array} i 虚部数组 41 | * @returns {Float32Array} 幅度 42 | */ 43 | static ComplexAbs(r, i, l) { 44 | l = l || r.length; 45 | const ABS = new Float32Array(l); 46 | for (let j = 0; j < l; j++) { 47 | ABS[j] = Math.sqrt(r[j] * r[j] + i[j] * i[j]); 48 | } return ABS; 49 | } 50 | 51 | /** 52 | * 53 | * @param {Number} N 要做几点的实数FFT 54 | */ 55 | constructor(N) { 56 | this.ini(N); 57 | this.bufferr = new Float32Array(this.N); 58 | this.bufferi = new Float32Array(this.N); 59 | this.Xr = new Float32Array(this.N); 60 | this.Xi = new Float32Array(this.N); 61 | } 62 | /** 63 | * 预计算常量 64 | * @param {Number} N 2的正整数次幂 65 | */ 66 | ini(N) { 67 | // 确定FFT长度 68 | N = Math.pow(2, Math.ceil(Math.log2(N)) - 1); 69 | this.N = N; // 存的是实际FFT的点数 70 | // 位反转预计算 实际做N/2的FFT 71 | this.reverseBits = realFFT.reverseBits(N); 72 | // 旋转因子预计算 仍然需要N点的,但是只取前一半 73 | this._Wr = new Float32Array(Array.from({ length: N }, (_, i) => Math.cos(Math.PI / N * i))); 74 | this._Wi = new Float32Array(Array.from({ length: N }, (_, i) => -Math.sin(Math.PI / N * i))); 75 | } 76 | /** 77 | * 78 | * @param {Float32Array} input 输入 79 | * @param {Number} offset 偏移量 80 | * @returns [实部, 虚部] 81 | */ 82 | fft(input, offset = 0) { 83 | // 偶数次和奇数次组合并计算第一层 84 | for (let i = 0, ii = 1, offseti = offset + 1; i < this.N; i += 2, ii += 2) { 85 | let xr1 = input[this.reverseBits[i] + offset] || 0; 86 | let xi1 = input[this.reverseBits[i] + offseti] || 0; 87 | let xr2 = input[this.reverseBits[ii] + offset] || 0; 88 | let xi2 = input[this.reverseBits[ii] + offseti] || 0; 89 | this.bufferr[i] = xr1 + xr2; 90 | this.bufferi[i] = xi1 + xi2; 91 | this.bufferr[ii] = xr1 - xr2; 92 | this.bufferi[ii] = xi1 - xi2; 93 | } 94 | for (let groupNum = this.N >> 2, groupMem = 2; groupNum; groupNum >>= 1) { 95 | // groupNum: 组数;groupMem:一组里有几个蝶形结构,同时也是一个蝶形结构两个元素的序号差值 96 | // groupNum: N/4, N/8, ..., 1 97 | // groupMem: 2, 4, ..., N/2 98 | // W's base: 4, 8, ..., N 99 | // W's base desired: 2N 100 | // times to k: N/2, N/4 --> equals to 2*groupNum (W_base*k_times=W_base_desired) 101 | // offset between groups: 4, 8, ..., N --> equals to 2*groupMem 102 | let groupOffset = groupMem << 1; 103 | for (let mem = 0, k = 0, dk = groupNum << 1; mem < groupMem; mem++, k += dk) { 104 | let [Wr, Wi] = [this._Wr[k], this._Wi[k]]; 105 | for (let gn = mem; gn < this.N; gn += groupOffset) { 106 | let gn2 = gn + groupMem; 107 | let [gwr, gwi] = realFFT.ComplexMul(this.bufferr[gn2], this.bufferi[gn2], Wr, Wi); 108 | this.Xr[gn] = this.bufferr[gn] + gwr; 109 | this.Xi[gn] = this.bufferi[gn] + gwi; 110 | this.Xr[gn2] = this.bufferr[gn] - gwr; 111 | this.Xi[gn2] = this.bufferi[gn] - gwi; 112 | } 113 | } 114 | [this.bufferr, this.bufferi, this.Xr, this.Xi] = [this.Xr, this.Xi, this.bufferr, this.bufferi]; 115 | groupMem = groupOffset; 116 | } 117 | // 合并为实数FFT的结果 118 | this.Xr[0] = this.bufferi[0] + this.bufferr[0]; 119 | this.Xi[0] = 0; 120 | for (let k = 1, Nk = this.N - 1; Nk; k++, Nk--) { 121 | let [Ir, Ii] = realFFT.ComplexMul(this.bufferi[k] + this.bufferi[Nk], this.bufferr[Nk] - this.bufferr[k], this._Wr[k], this._Wi[k]); 122 | this.Xr[k] = (this.bufferr[k] + this.bufferr[Nk] + Ir) * 0.5; 123 | this.Xi[k] = (this.bufferi[k] - this.bufferi[Nk] + Ii) * 0.5; 124 | } 125 | return [this.Xr, this.Xi]; 126 | } 127 | } -------------------------------------------------------------------------------- /dataProcess/midiExport.js: -------------------------------------------------------------------------------- 1 | var _midiExport = { 2 | UI() { 3 | let tempDiv = document.createElement('div'); 4 | tempDiv.innerHTML = ` 5 |
    6 |
    7 |
    8 |
    9 |
    10 |
    11 |
    `; 12 | const card = tempDiv.firstElementChild; 13 | const close = () => { card.remove(); }; 14 | const btns = card.querySelectorAll('button'); 15 | btns[0].onclick = () => { 16 | const midi = _midiExport.beatAlign(); 17 | bSaver.saveArrayBuffer(midi.export(1), midi.name + '.mid'); 18 | close(); 19 | }; 20 | btns[1].onclick = () => { 21 | const midi = _midiExport.keepTime(); 22 | bSaver.saveArrayBuffer(midi.export(1), midi.name + '.mid'); 23 | close(); 24 | }; 25 | btns[2].onclick = close; 26 | document.body.insertBefore(card, document.body.firstChild); 27 | card.tabIndex = 0; 28 | card.focus(); 29 | }, 30 | /** 31 | * 100%听感还原扒谱结果,但节奏是乱的 32 | */ 33 | keepTime() { 34 | const newMidi = new midi(60, [4, 4], Math.round(1000 / app.dt), [], app.AudioPlayer.name); 35 | const mts = []; 36 | for (const ch of app.synthesizer.channel) { 37 | let mt = newMidi.addTrack(); 38 | mt.addEvent(midiEvent.instrument(0, ch.instrument)); 39 | mt._volume = ch.volume; 40 | mts.push(mt); 41 | } 42 | for (const nt of app.MidiAction.midi) { 43 | const midint = nt.y + 24; 44 | let v = mts[nt.ch]._volume; 45 | if (nt.v) v = Math.min(127, v * nt.v / 127); 46 | mts[nt.ch].addEvent(midiEvent.note(nt.x1, nt.x2 - nt.x1, midint, v)); 47 | } return newMidi; 48 | }, 49 | beatAlign() { 50 | // 初始化midi 51 | let begin = app.BeatBar.beats[0]; 52 | let lastbpm = begin.bpm; // 用于自适应bpm 53 | const newMidi = new midi(lastbpm, [begin.beatNum, begin.beatUnit], 480, [], app.AudioPlayer.name); 54 | const mts = []; 55 | for (const ch of app.synthesizer.channel) { 56 | let mt = newMidi.addTrack(); 57 | mt.addEvent(midiEvent.instrument(0, ch.instrument)); 58 | mt._volume = ch.volume; 59 | mts.push(mt); 60 | } 61 | // 将每个音符拆分为两个时刻 62 | const Midis = app.MidiAction.midi; 63 | const mlen = Midis.length << 1; 64 | const moment = new Array(mlen); 65 | for (let i = 0, j = 0; i < mlen; j++) { 66 | const nt = Midis[j]; 67 | let duration = nt.x2 - nt.x1; 68 | let midint = nt.y + 24; 69 | let v = mts[nt.ch]._volume; 70 | if (nt.v) v = Math.min(127, v * nt.v / 127); 71 | moment[i++] = new midiEvent({ 72 | _d: duration, 73 | ticks: nt.x1, 74 | code: 0x9, 75 | value: [midint, v], 76 | _ch: nt.ch 77 | }, true); 78 | moment[i++] = new midiEvent({ 79 | _d: duration, 80 | ticks: nt.x2, 81 | code: 0x9, 82 | value: [midint, 0], 83 | _ch: nt.ch 84 | }, true); 85 | } moment.sort((a,b) => a.ticks - b.ticks); 86 | // 对每个小节进行对齐 87 | let m_i = 0; // moment的指针 88 | let tickNow = 0; // 维护总时长 89 | for(const measure of app.BeatBar.beats) { 90 | if(m_i == mlen) break; 91 | 92 | //== 判断bpm是否变化 假设小节之间bpm相关性很强 ==// 93 | const bpmnow = measure.bpm; 94 | if(Math.abs(bpmnow - lastbpm) > lastbpm * 0.065) { 95 | mts[0].events.push(midiEvent.tempo(tickNow, bpmnow * 4 / measure.beatUnit)); 96 | } lastbpm = bpmnow; 97 | 98 | //== 对齐音符 ==// 99 | const begin = measure.start / app.dt; // 转换为以“格”为单位 100 | const end = (measure.interval + measure.start) / app.dt; 101 | // 一个八音符的格数 102 | const aot = measure.interval * measure.beatUnit / (measure.beatNum * 8 * app.dt); 103 | while(m_i < mlen) { 104 | const n = moment[m_i]; 105 | if(n.ticks > end) break; // 给下一小节 106 | const threshold = n._d / 2; 107 | let accuracy = aot; 108 | while(accuracy > threshold) accuracy /= 2; 109 | n.ticks = tickNow + ((Math.round((n.ticks - begin) / accuracy) * newMidi.tick * accuracy / aot) >> 1); 110 | mts[n._ch].events.push(n); 111 | m_i++; 112 | } tickNow += newMidi.tick * measure.beatNum * 4 / measure.beatUnit; 113 | } return newMidi; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /fakeAudio.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 模拟没有声音、时长可变的Audio。模拟了: 3 | * 设置currentTime跳转播放位置 4 | * 设置playbackRate改变播放速度 5 | * play()和pause()控制播放 6 | * 到duration后自动停止,触发onended 7 | * duration改变后,触发ondurationchange 8 | * 构造后,下一个时刻触发ondurationchange和onloadeddata 9 | */ 10 | function FakeAudio(duration = Infinity) { 11 | this.readyState = 4; 12 | this.paused = true; 13 | this.volume = 0; // 废物属性 14 | this.loop = false; // 是否循环。和下面的_loop不一样 15 | this._currentTime = 0; 16 | this._duration = duration; 17 | this._playbackRate = 1; 18 | this._loop = 0; 19 | this._beginTime = 0; 20 | this._lastTime = 0; 21 | this.onended = Function.prototype; 22 | this.onloadeddata = Function.prototype; 23 | this.ondurationchange = Function.prototype; 24 | const update = (t) => { 25 | let dt = t - this._beginTime; 26 | this._currentTime = this._lastTime + dt * this._playbackRate / 1000; 27 | if (this._currentTime >= this._duration) { 28 | if (this.loop) { 29 | this.currentTime = 0; 30 | } else { 31 | this.pause(); 32 | this.onended(); 33 | return; 34 | } 35 | } 36 | this._loop = requestAnimationFrame(update); 37 | }; 38 | this.pause = () => { 39 | cancelAnimationFrame(this._loop); 40 | this._lastTime = this._currentTime; 41 | this.paused = true; 42 | } 43 | this.play = () => { 44 | if (this._currentTime >= this._duration) this._lastTime = this._currentTime = 0; 45 | this._beginTime = document.timeline.currentTime; 46 | this._loop = requestAnimationFrame(update); 47 | this.paused = false; 48 | } 49 | Object.defineProperty(this, 'currentTime', { 50 | get: function () { return this._currentTime; }, 51 | set: function (t) { 52 | if (t < 0) t = 0; 53 | if (t > this._duration) t = this._duration; 54 | this._lastTime = this._currentTime = t; 55 | this._beginTime = document.timeline.currentTime; 56 | } 57 | }); 58 | Object.defineProperty(this, 'playbackRate', { 59 | get: function () { return this._playbackRate; }, 60 | set: function (r) { 61 | this._playbackRate = r; 62 | this.currentTime = this._currentTime; 63 | } 64 | }); 65 | Object.defineProperty(this, 'duration', { 66 | get: function () { return this._duration; }, 67 | set: function (d) { 68 | if (d < 0) return; 69 | this._duration = d; 70 | this.ondurationchange(); 71 | } 72 | }); 73 | // 给设置handler留时间 74 | setTimeout(() => { 75 | this.ondurationchange(); 76 | this.onloadeddata(); 77 | }, 0); 78 | } -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/favicon.ico -------------------------------------------------------------------------------- /img/bilibili-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/img/bilibili-white.png -------------------------------------------------------------------------------- /img/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/img/github-mark-white.png -------------------------------------------------------------------------------- /img/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/img/logo-small.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/img/logo.png -------------------------------------------------------------------------------- /img/logo_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/img/logo_text.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | noteDigger~在线扒谱 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
    44 | 45 |
    46 |
    47 | 速度 48 |
    49 |
    50 | 显示 51 |
    52 |
    53 |
    54 |
    音量
    55 |
    56 |
    57 | 音符 58 |
    59 |
    60 | 音频 61 |
    62 |
    63 |
    64 | 65 | 66 |
    67 | 68 | 71 |
    72 |
    73 | 74 |
    75 |
    76 | 77 |
    78 | 79 | 80 | 81 | 82 | 83 | 84 |
    85 | 86 |
    87 | 88 |
    89 |
    90 | 91 |
    92 |
    93 |
    94 | 95 | 96 |
    97 |
    98 | 99 | 100 |
    101 |
    102 |
    103 |
    104 |
    105 |
    106 |
    107 | 108 |
    109 |
      110 |
    • 导入音频
    • 111 |
    • 导入midi
    • 112 |
    • MIDI编辑器模式
    • 113 |
    • 导出当前进度
    • 114 |
    • 导出为midi
    • 115 |
    • 116 | 117 | 118 |
    • 119 |
    120 | 121 |
    122 |

    EQ设置(dB)

    123 |
    124 | 请先上传音频! 125 |
    126 |
    127 | 128 |
      129 |
    • 调性分析
    • 130 |
    • 自动填充
      在新轨道中
      131 |
      132 | 黑红 133 |
      134 |
      135 | 136 | 137 |
      138 |
    • 139 |
    • 人工智障扒谱
    • 140 | ( ̄ε(# ̄) 别的还没做 141 |
    142 | 143 |
      144 |
    • 宽度
    • 145 |
    • 高度
    • 146 |
    • 遮罩厚度
    • 147 |
    • 148 | 精准设置重复区间 149 |
      150 | ~ 151 |
      152 | 153 |
    • 154 |
    • 显示音名
    • 155 |
    • 透明度表示强度
    • 156 |
    157 |
    158 | 563 | 609 | 610 | -------------------------------------------------------------------------------- /myRange.js: -------------------------------------------------------------------------------- 1 | class myRange extends HTMLInputElement { 2 | /** 3 | * 设置原型并初始化 4 | * @param {HTMLInputElement} ele 5 | * @returns {myRange} 6 | */ 7 | static new(ele) { 8 | Object.setPrototypeOf(ele, myRange.prototype); 9 | myRange.prototype.init.call(ele); 10 | return ele; 11 | } 12 | /** 13 | * 设置一个容器 14 | * 执行了构造函数的内容 15 | */ 16 | init() { 17 | this.default = super.value; // 默认值在html中设置 18 | this.container = document.createElement('div'); 19 | this.container.classList.add('myrange'); 20 | this.insertAdjacentElement('beforebegin', this.container); 21 | // 将当前元素插入到容器中 22 | this.container.appendChild(this); 23 | this.addEventListener('click', this.blur); // 极速取消焦点 防止空格触发 24 | } 25 | set value(v) { 26 | super.value = v; 27 | this.dispatchEvent(new Event('input')); 28 | } 29 | get value() { 30 | return super.value; 31 | } 32 | reset() { 33 | this.value = this.default; 34 | return this; // 可以链式调用,比如let r = myRange.new(document.querySelector('input')).reset(); 35 | } 36 | } 37 | 38 | class LableRange extends myRange { 39 | static new(ele) { 40 | Object.setPrototypeOf(ele, LableRange.prototype); 41 | LableRange.prototype.init.call(ele); 42 | return ele; 43 | } 44 | /** 45 | * 添加一个容器、标签 46 | */ 47 | init() { 48 | super.init(); 49 | this.container.classList.add('labelrange'); 50 | // 设置标签显示当前值 51 | this.label = document.createElement('span'); 52 | this.label.className = "thelabel"; 53 | this.insertAdjacentElement('afterend', this.label); 54 | // 设置label的宽度固定为range的最大值的宽度 55 | if (!this.max) this.max = 100; 56 | let maxStepStr = (this.max - this.step).toFixed(10).replace(/\.?0+$/, ''); // 限制小数位数并去除末尾的零 57 | let len = Math.max(this.max.toString().length, maxStepStr.length); 58 | this.label.style.width = `${len}ch`; 59 | this.addEventListener('input', () => { 60 | this.updateLabel(); // 【不直接传函数,可以篡改this.updateLabel】 61 | }); 62 | // 标签的另一个作用:重置range的值 63 | this.label.addEventListener('click', this.reset.bind(this)); 64 | // 【没有初始化label,需要用户手动调用reset()】 65 | } 66 | updateLabel() { 67 | this.label.textContent = super.value; 68 | } 69 | } 70 | 71 | class hideLableRange extends myRange { 72 | static _expand = 16; // 和css有关 滑块的宽度 73 | static new(ele) { 74 | Object.setPrototypeOf(ele, hideLableRange.prototype); 75 | hideLableRange.prototype.init.call(ele); 76 | return ele; 77 | } 78 | /** 79 | * 添加一个容器、标签 80 | */ 81 | init() { 82 | super.init(); 83 | this.container.classList.add('hidelabelrange'); 84 | // 设置标签显示当前值 85 | this.label = document.createElement('span'); 86 | this.label.className = "thelabel"; 87 | this.insertAdjacentElement('afterend', this.label); 88 | this.addEventListener('input', () => { 89 | this.updateLabel(); // 【不直接传函数,可以篡改this.updateLabel】 90 | }); 91 | // 标签的另一个作用:重置range的值 92 | this.label.addEventListener('click', this.reset.bind(this)); 93 | // 【没有初始化label,需要用户手动调用reset()】 94 | // 滑动时显示label 95 | this.label.style.display = 'none'; 96 | this.addEventListener('focus', function () { 97 | this.label.style.display = 'block'; 98 | }); 99 | this.addEventListener('blur', function () { 100 | this.label.style.display = 'none'; 101 | }); 102 | this.addEventListener('input', this.labelPosition); 103 | } 104 | updateLabel() { 105 | this.label.textContent = super.value; 106 | } 107 | labelPosition() { 108 | let rangeRect = this.getBoundingClientRect(); 109 | let rangeWidth = rangeRect.width - hideLableRange._expand; 110 | this.label.style.left = `${((this.value - this.min) / (this.max - this.min)) * rangeWidth + (hideLableRange._expand >> 1)}px`; 111 | } 112 | } -------------------------------------------------------------------------------- /saver.js: -------------------------------------------------------------------------------- 1 | /* 示例 2 | function save() { 3 | let B = bSaver.Float32Mat2Buffer(b); 4 | let A = bSaver.Object2Buffer(a); 5 | bSaver.saveArrayBuffer(bSaver.combineArrayBuffers( 6 | [B, A] 7 | ), "test.nd"); 8 | } 9 | var result; 10 | function parse() { 11 | let input = document.createElement("input"); 12 | input.type = "file"; 13 | input.onchange = function() { 14 | let file = input.files[0]; 15 | bSaver.readBinary(file, (arrayBuffer)=>{ 16 | let [B, o] = bSaver.Buffer2Float32Mat(arrayBuffer, 0); 17 | let [A, o2] = bSaver.Buffer2Object(arrayBuffer, o); 18 | result = [A,B]; 19 | console.log(result); 20 | }) 21 | } input.click(); 22 | } 23 | */ 24 | // 保存和读取二进制数据的工具 25 | // 每个数据段开头会有Uint32(可能多个)的长度信息, 用于保存该数据段的长度 26 | window.bSaver = { 27 | /** 28 | * 将二维的Float32Array的Array转为可解析的一维ArrayBuffer 29 | * 要求每个Float32Array的长度相同 30 | * @param {Array} Float32Mat 31 | * @returns {ArrayBuffer} 二进制数组 开头有两个Uint32的长度信息, 用于保存每个Float32Array的长度和Float32Mat的长度 32 | */ 33 | Float32Mat2Buffer(Float32Mat) { 34 | // 先保存两个维度的长度: 每个Float32Array的长度和Float32Mat的长度 35 | const lengthArray = new Uint32Array([Float32Mat[0].length, Float32Mat.length]); 36 | let offset = lengthArray.byteLength; 37 | let bn = Float32Mat[0].byteLength; 38 | const finalArrayBuffer = new ArrayBuffer(offset + bn * Float32Mat.length); 39 | new Uint32Array(finalArrayBuffer, 0, 2).set(lengthArray); 40 | // 再将每个Float32Array的数据拷贝到finalArrayBuffer中 41 | for (const floatArray of Float32Mat) { 42 | new Float32Array(finalArrayBuffer, offset).set(floatArray); 43 | offset += bn; 44 | } return finalArrayBuffer; 45 | }, 46 | /** 47 | * 解析Float32Mat2Buffer得到的二进制数组为二维的Float32Array的Array 48 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 49 | * @param {Number} offset 读取的byte偏移量 50 | * @returns {[Array, Number]} 解析后的二维Float32Array数组和读取结束后的byte偏移量 51 | */ 52 | Buffer2Float32Mat(arrayBuffer, offset = 0) { 53 | offset = Math.ceil(offset / 4) << 2; // offset变为4的倍数 54 | let lengthArray = new Uint32Array(arrayBuffer, offset, 2); 55 | offset += lengthArray.byteLength; 56 | let [n, N] = lengthArray; 57 | const mergedFloatArray = new Float32Array(arrayBuffer, offset, n * N); 58 | const Float32Mat = new Array(N); 59 | for (let i = 0, j = 0; i < N; i++, j += n) { 60 | Float32Mat[i] = mergedFloatArray.subarray(j, j + n); 61 | } return [Float32Mat, offset + mergedFloatArray.byteLength]; 62 | }, 63 | /** 64 | * 将字符串转为可解析的二进制数组 65 | * @param {String} str 字符串 66 | * @returns {ArrayBuffer} 二进制数组 开头有一个Uint32的长度信息记录BinaryData的长度 67 | */ 68 | String2Buffer(str) { 69 | const jsonBinaryData = new TextEncoder().encode(str); 70 | // 用一个Uint32Array保存jsonBinaryData的长度 71 | const lengthArray = new Uint32Array([jsonBinaryData.byteLength]); 72 | const finalArrayBuffer = new ArrayBuffer(lengthArray.byteLength + jsonBinaryData.byteLength); 73 | new Uint32Array(finalArrayBuffer, 0, 1).set(lengthArray); 74 | new Uint8Array(finalArrayBuffer, lengthArray.byteLength).set(new Uint8Array(jsonBinaryData)); 75 | return finalArrayBuffer; 76 | }, 77 | /** 78 | * 解析String2Buffer得到的二进制数组为字符串 79 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 80 | * @param {Number} offset 读取的byte偏移量 81 | * @returns {[String, Number]} 解析后的对象和读取结束后的byte偏移量 82 | */ 83 | Buffer2String(arrayBuffer, offset = 0) { 84 | offset = Math.ceil(offset / 4) << 2; // offset变为4的倍数 85 | const lengthArray = new Uint32Array(arrayBuffer, offset, 1); 86 | offset += lengthArray.byteLength; 87 | const strBinaryData = new Uint8Array(arrayBuffer, offset, lengthArray[0]); 88 | const str = new TextDecoder().decode(strBinaryData); 89 | return [str, offset + strBinaryData.byteLength]; 90 | }, 91 | /** 92 | * 将一个可以被JSON.stringify的对象转为可解析的二进制数组 93 | * @param {Object} obj 可以被JSON.stringify的对象 94 | * @returns {ArrayBuffer} 二进制数组 开头有一个Uint32的长度信息记录jsonBinaryData的长度 95 | */ 96 | Object2Buffer(obj) { 97 | return this.String2Buffer(JSON.stringify(obj)); 98 | }, 99 | /** 100 | * 解析Object2Buffer得到的二进制数组为一个可以被JSON.stringify的对象 101 | * @param {ArrayBuffer} arrayBuffer 待解析的二进制数组 102 | * @param {Number} offset 读取的byte偏移量 103 | * @returns {[Object, Number]} 解析后的对象和读取结束后的byte偏移量 104 | */ 105 | Buffer2Object(arrayBuffer, offset = 0) { 106 | let [jsonString, o] = this.Buffer2String(arrayBuffer, offset); 107 | return [JSON.parse(jsonString), o]; 108 | }, 109 | /** 110 | * 合并多个ArrayBuffer为一个ArrayBuffer 111 | * 会按4byte对齐每一个ArrayBuffer的起始位置 112 | * @param {Array} arrayBuffers ArrayBuffer的数组 113 | * @returns {ArrayBuffer} 合并后的ArrayBuffer 114 | */ 115 | combineArrayBuffers(arrayBuffers) { 116 | let totalByteLength = 0; 117 | const lengthArray = arrayBuffers.map((arrayBuffer) => { 118 | // 用4字节对齐,因为开头是Uint32的长度信息: start offset of Uint32Array should be a multiple of 4 119 | let len4 = Math.ceil(arrayBuffer.byteLength / 4) << 2; 120 | totalByteLength += len4; 121 | return len4; 122 | }); 123 | const combinedArrayBuffer = new ArrayBuffer(totalByteLength); 124 | for(let i = 0, offset = 0; i < arrayBuffers.length; i++) { 125 | new Uint8Array(combinedArrayBuffer, offset).set(new Uint8Array(arrayBuffers[i])); 126 | offset += lengthArray[i]; 127 | } return combinedArrayBuffer; 128 | }, 129 | /** 130 | * 将二进制数组保存为文件 131 | * @param {ArrayBuffer} arrayBuffer 待保存的二进制数组 132 | * @param {String} filename 保存的文件名 133 | */ 134 | saveArrayBuffer(arrayBuffer, filename) { 135 | // 创建一个 Blob 对象,将合并后的 ArrayBuffer 保存为二进制文件 136 | const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' }); 137 | // 创建一个临时 URL,用于下载文件 138 | const downloadUrl = URL.createObjectURL(blob); 139 | // 创建一个虚拟的下载链接并触发点击事件 140 | const downloadLink = document.createElement('a'); 141 | downloadLink.href = downloadUrl; 142 | downloadLink.download = filename; 143 | downloadLink.click(); 144 | // 释放临时 URL 对象 145 | URL.revokeObjectURL(downloadUrl); 146 | }, 147 | // 读取文件为ArrayBuffer 148 | readBinary(file, callback) { 149 | const fileReader = new FileReader(); 150 | fileReader.onload = (e) => { 151 | callback(e.target.result); 152 | }; fileReader.readAsArrayBuffer(file); 153 | } 154 | }; -------------------------------------------------------------------------------- /siderMenu.js: -------------------------------------------------------------------------------- 1 | class SiderContent extends HTMLDivElement { 2 | static new(ele, minWidth) { 3 | Object.setPrototypeOf(ele, SiderContent.prototype); 4 | SiderContent.prototype.init.call(ele, minWidth); 5 | return ele; 6 | } 7 | init(minWidth) { 8 | this.resize = this._resize.bind(this); 9 | this.mouseup = this._mouseup.bind(this); 10 | this.mousedown = this._mousedown.bind(this); 11 | 12 | this.classList.add('siderContent'); 13 | this.minWidth = minWidth; 14 | this.judge = (minWidth >> 1) + this.getBoundingClientRect().left; 15 | this.style.width = minWidth + 'px'; 16 | 17 | const bar = document.createElement('div'); 18 | bar.className = 'siderBar'; 19 | this.insertAdjacentElement('afterend', bar); 20 | bar.addEventListener('mousedown', this.mousedown); 21 | this.bar = bar; 22 | } 23 | _mousedown(e) { 24 | if (e.button) return; 25 | document.addEventListener('mousemove', this.resize); 26 | document.addEventListener('mouseup', this.mouseup); 27 | } 28 | _resize(e) { 29 | if (e.clientX < this.judge) this.display = 'none'; 30 | else { 31 | let rect = this.getBoundingClientRect(); 32 | let w = e.clientX - rect.left; 33 | if (w < this.minWidth) return; 34 | // 触发刷新 35 | this.width = w + 'px'; 36 | this.display = 'block'; 37 | } 38 | } 39 | _mouseup() { 40 | document.removeEventListener('mousemove', this.resize); 41 | document.removeEventListener('mouseup', this.mouseup); 42 | this.bar.blur(); 43 | window.dispatchEvent(new Event("resize")); // 触发app.resize 44 | } 45 | get display() { 46 | return this.style.display; 47 | } 48 | // 设置display可以触发刷新 因为app.resize绑定在window.onresize上 49 | set display(state) { 50 | if (this.style.display != state) { 51 | this.style.display = state; 52 | window.dispatchEvent(new Event("resize")); 53 | } 54 | } 55 | get width() { 56 | return this.style.width; 57 | } 58 | set width(w) { 59 | if (this.style.width != w) { 60 | this.style.width = w; 61 | window.dispatchEvent(new Event("resize")); 62 | } 63 | } 64 | } 65 | 66 | class SiderMenu extends HTMLDivElement { 67 | /** 68 | * 构造tabMenu和container 69 | * @param {HTMLDivElement} menu 存放tab的 样式: .siderTabs 每一个tab: .siderTab 70 | * @param {HTMLDivElement} container 展示具体内容的 样式: .siderContent 拖动条: .siderBar 每一个子内容都会加上siderItem类 71 | * @param {Number} minWidth 展示具体内容的最小宽度 72 | * @returns 73 | */ 74 | static new(menu, container, minWidth) { 75 | Object.setPrototypeOf(menu, SiderMenu.prototype); 76 | SiderMenu.prototype.init.call(menu, container, minWidth); 77 | return menu; 78 | } 79 | init(box, minWidth) { 80 | this.classList.add('siderTabs'); 81 | this.container = SiderContent.new(box, minWidth); 82 | box.display = 'none'; 83 | this.tabClick = this._tabClick.bind(this); 84 | this.tabs = []; 85 | } 86 | /** 87 | * 添加一个菜单项及其内容 88 | * @param {String} name tab的名字 89 | * @param {String} tabClass tab的类名 用空格分隔 90 | * @param {HTMLElement} dom tab对应的内容 91 | * @param {Boolean} selected 是否默认选中 92 | * @returns {HTMLDivElement} 添加的tab 93 | */ 94 | add(name, tabClass, dom, selected = false) { 95 | const tab = document.createElement('div'); 96 | tab.className = 'siderTab'; 97 | tab.classList.add(...tabClass.split(' ')); 98 | tab.dataset.name = name; 99 | 100 | this.container.appendChild(dom); 101 | dom.classList.add('siderItem'); 102 | dom.style.display = 'none'; 103 | tab.item = dom; 104 | 105 | tab.addEventListener('click', this.tabClick); 106 | this.appendChild(tab); 107 | if (this.tabs.push(tab) == 1) { 108 | tab.classList.add('selected'); 109 | dom.style.display = 'block'; 110 | } else if (selected) this.select(tab); 111 | return tab; 112 | } 113 | /** 114 | * 选中一个标签 115 | * @param {HTMLDivElement || Number} tab 116 | * @returns {HTMLDivElement} 选择的标签 117 | */ 118 | select(tab) { 119 | if (typeof tab == 'number') tab = this.tabs[tab]; 120 | if (!tab) return; 121 | for (const t of this.tabs) { 122 | t.classList.remove('selected'); 123 | t.item.style.display = 'none'; 124 | } 125 | tab.classList.add('selected'); 126 | tab.item.style.display = 'block'; 127 | return tab; 128 | } 129 | /** 130 | * 控制面板的显示 131 | * @param {Boolean} ifshow 是否显示面板 132 | */ 133 | show(ifshow = true) { 134 | this.container.display = ifshow ? 'block' : 'none'; 135 | } 136 | // 绑定给tab用,不应该用户被调用 137 | _tabClick(e) { 138 | const tab = e.target; 139 | if (tab.classList.contains('selected')) { // 如果显示的就是tab的,则隐藏 140 | // 用style.dispaly是读取,用.display = 是为了刷新 141 | this.container.display = this.container.style.display == 'none' ? 'block' : 'none'; 142 | } else { // 否则只显示tab的 143 | for (const t of this.tabs) { 144 | t.classList.remove('selected'); 145 | t.item.style.display = 'none'; 146 | } 147 | tab.classList.add('selected'); 148 | this.container.display = 'block'; 149 | } 150 | tab.item.style.display = 'block'; 151 | tab.blur(); 152 | } 153 | } -------------------------------------------------------------------------------- /snapshot.js: -------------------------------------------------------------------------------- 1 | // 基于快照的撤销重做数据结构 2 | // 为了不改变数组大小减小开销,使用循环队列 3 | class Snapshot extends Array { 4 | /** 5 | * 新建快照栈 6 | * @param {Number} maxLen 快照历史数 7 | * @param {*} iniState 初始状态 8 | */ 9 | constructor(maxLen, iniState = '') { 10 | super(maxLen); 11 | // 模型位置 从1开始计数 12 | this.now = 1; 13 | this.size = 1; 14 | this[0] = iniState; 15 | // 实际位置 16 | this.pointer = 0; 17 | } 18 | /** 19 | * 增加快照。在当前时间点上延展新的分支,并抛弃老的分支 20 | * @param {*} snapshot 快照 建议是JSON字符串 21 | */ 22 | add(snapshot) { 23 | if (this.now < this.length) this.size = ++this.now; // 没满 24 | this.pointer = (this.pointer + 1) % this.length; // 目标位置,直接覆盖 25 | this[this.pointer] = snapshot; 26 | } 27 | /** 28 | * 回到上一个快照状态,相当于撤销 29 | * @returns 上一刻的快照。如果无法回退则返回null 30 | */ 31 | undo() { 32 | if (this.now <= 1) return null; 33 | this.now--; 34 | this.pointer = (this.pointer + this.length - 1) % this.length; 35 | return this[this.pointer]; 36 | } 37 | /** 38 | * 重新回到下一个状态,相当于重做 39 | * @returns 下一刻的快照。如果下一状态则返回null 40 | */ 41 | redo() { 42 | if (this.now >= this.size) return null; 43 | this.now++; 44 | this.pointer = (this.pointer + 1) % this.length; 45 | return this[this.pointer]; 46 | } 47 | /** 48 | * 查看上一个快照状态,相当于撤销但不改变当前状态 49 | * @returns 上一个状态的快照。如果无法回退则返回null 50 | */ 51 | lastState() { 52 | if (this.now <= 1) return null; 53 | return this[(this.pointer + this.length - 1) % this.length]; 54 | } 55 | /** 56 | * 查看下一个快照状态,相当于重做但不改变当前状态 57 | * @returns 下一个状态的快照。如果下一状态则返回null 58 | */ 59 | nextState() { 60 | if (this.now >= this.size) return null; 61 | return this[(this.pointer + 1) % this.length]; 62 | } 63 | nowState() { 64 | return this[this.pointer]; 65 | } 66 | } -------------------------------------------------------------------------------- /style/askUI.css: -------------------------------------------------------------------------------- 1 | /* 依赖:style.css中的.card和.hvCenter*/ 2 | .request-cover { 3 | position: fixed; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | background-color: #6c6c6c78; 9 | z-index: 99; 10 | opacity: 1; 11 | transition: opacity 0.2s ease-in-out; 12 | } 13 | 14 | .request-cover .card { 15 | position: fixed; 16 | width: auto; 17 | height: auto; 18 | background-color: rgb(37, 38, 45);; 19 | padding: 1em; 20 | overflow: hidden; 21 | color: rgb(235, 235, 235); 22 | } 23 | 24 | .request-cover .title { 25 | font-size: 1.2em; 26 | font-weight: bold; 27 | color: white; 28 | } 29 | 30 | .card.hvCenter .layout { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | padding: 10px 8px 0 8px; 35 | margin: 0.2em; 36 | background: inherit; 37 | color: inherit; 38 | } 39 | 40 | .request-cover .card.hvCenter button { 41 | margin: 0 0.25em; 42 | border: none; 43 | border-radius: 0.2em; 44 | padding: 0.5em 1em; 45 | cursor: pointer; 46 | outline: none; 47 | color: inherit; 48 | } 49 | .request-cover .ui-confirm { 50 | flex: 1; 51 | background: rgb(60, 87, 221); 52 | color: white; 53 | } 54 | .request-cover .ui-cancel { 55 | flex: 1; 56 | background-color: #2e3039; 57 | color: white; 58 | } 59 | 60 | .card.hvCenter input, 61 | .card.hvCenter select { 62 | flex: 1; 63 | background: inherit; 64 | border: 1px solid rgb(54, 58, 69); 65 | border-radius: 0.25em; 66 | color: inherit; 67 | margin: 0 0.8em; 68 | padding-left: 0.6em; 69 | } 70 | 71 | /* 进度条 */ 72 | .porgress-track { 73 | display: block; 74 | width: 16em; 75 | height: 1em; 76 | border-radius: 2em; 77 | background-color: #1e1f24; 78 | overflow: hidden; 79 | } 80 | 81 | .porgress-value { 82 | width: 0; 83 | height: inherit; 84 | background-color: #761fc8; 85 | } -------------------------------------------------------------------------------- /style/channelDiv.css: -------------------------------------------------------------------------------- 1 | /* 可拖拽的列表 */ 2 | .drag_list { 3 | --bg-color: var(--theme-middle); 4 | --li-hover: var(--theme-light); 5 | } 6 | .drag_list .takeplace { 7 | height: 8em; 8 | } 9 | /* 列表本体 */ 10 | ul.drag_list { 11 | height: 100%; 12 | list-style: none; 13 | background-color: var(--bg-color); 14 | padding: 0; 15 | margin: 0; 16 | overflow: auto; 17 | } 18 | ul.drag_list::-webkit-scrollbar { 19 | width: 12px; 20 | } 21 | ul.drag_list::-webkit-scrollbar-thumb { 22 | background-color: rgb(50, 53, 62); 23 | border: 3px solid rgb(37, 38, 45); 24 | border-radius: 6px; 25 | } 26 | ul.drag_list::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { 27 | background-color: rgb(37, 38, 45); 28 | } 29 | 30 | ul.drag_list li.drag_list-item { 31 | width: 100%; 32 | transition: 0.3s; 33 | } 34 | ul.drag_list li.moving { 35 | position: relative; 36 | } 37 | ul.drag_list li.moving::before { 38 | content: ""; 39 | position: absolute; 40 | z-index: 2; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | background-color: var(--bg-color); 46 | border: 0.125em dashed #ccc; 47 | border-radius: 0.3em; /* 和列表项保持一致 */ 48 | margin: 0 0.6em; 49 | } 50 | 51 | /* 列表项 */ 52 | .channel-Container { 53 | position: relative; 54 | background-color: transparent; 55 | border-radius: 0.32em; 56 | margin: 0.6em; 57 | border-left: 0.5em solid; 58 | border-color: var(--tab-color); 59 | padding: 0.4em; 60 | } 61 | .channel-Container:hover { 62 | background-color: var(--li-hover); 63 | } 64 | .channel-Container.selected { 65 | background-color: var(--li-hover); 66 | } 67 | /* 序号 */ 68 | .channel-Container::after { 69 | content: attr(data-tab-index); 70 | position: absolute; 71 | bottom: 0.32em; 72 | right: 0.32em; 73 | background-color: transparent; 74 | font-size: 0.5em; 75 | color: #a5abba; 76 | z-index: 1; 77 | } 78 | .channel-Container .upper { 79 | display: flex; 80 | flex-direction: row; 81 | align-items: center; 82 | } 83 | /* 音轨名 */ 84 | .channel-Name { 85 | overflow: hidden; /* 隐藏超出容器的内容 */ 86 | white-space: nowrap; /* 不换行 */ 87 | text-overflow: ellipsis;/* 超出部分用省略号表示 */ 88 | flex: 1; 89 | font-size: 1em; 90 | font-weight: bold; 91 | color: white; 92 | cursor: pointer; 93 | } 94 | /* 快捷按钮 */ 95 | .channel-Tab { 96 | display: flex; /* 消除子block之间的间隙 */ 97 | flex: 0 1 auto; 98 | } 99 | .upper .tab { 100 | border-radius: 50%; 101 | width: 1.8em; 102 | height: 1.8em; 103 | text-align: center; 104 | display: inline; 105 | background-color: transparent; 106 | border: none; 107 | color: var(--tab-color); 108 | } 109 | .channel-Container .tab:hover { 110 | background-color: #363944; 111 | } 112 | /* 乐器选择 */ 113 | .channel-Instrument { 114 | display: inline-block; 115 | font-size: 0.9em; 116 | color: white; 117 | overflow: hidden; 118 | } -------------------------------------------------------------------------------- /style/contextMenu.css: -------------------------------------------------------------------------------- 1 | .contextMenuCard { 2 | min-width: 100px; 3 | padding: 3px 5px; 4 | margin: 2px; 5 | background-color: #ffffff; 6 | border: 1px solid #dadce0; 7 | border-radius: 4px; 8 | box-shadow: 1px 1px 2px #878787; 9 | position: fixed; 10 | user-select: none; 11 | z-index: 100; 12 | } 13 | 14 | .contextMenuCard:focus { 15 | outline: none; 16 | } 17 | 18 | .contextMenuCard li { 19 | list-style: none; 20 | margin: 2px 0px; 21 | cursor: pointer; 22 | min-height: 26px; 23 | padding: 0px 8px; 24 | color: black; 25 | } 26 | 27 | .contextMenuCard li:hover { 28 | background-color: #f5f5f5; 29 | } -------------------------------------------------------------------------------- /style/icon/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 4420000 */ 3 | src: url('iconfont.woff2?t=1742105746978') format('woff2'), 4 | url('iconfont.woff?t=1742105746978') format('woff'), 5 | url('iconfont.ttf?t=1742105746978') format('truetype'); 6 | } 7 | 8 | .iconfont { 9 | font-family: "iconfont" !important; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .icon-lock:before { 17 | content: "\e76f"; 18 | } 19 | 20 | .icon-unlock:before { 21 | content: "\e879"; 22 | } 23 | 24 | .icon-file:before { 25 | content: "\e63d"; 26 | } 27 | 28 | .icon-analysis:before { 29 | content: "\e600"; 30 | } 31 | 32 | .icon-pageTurns:before { 33 | content: "\e6d5"; 34 | } 35 | 36 | .icon-repeat:before { 37 | content: "\e628"; 38 | } 39 | 40 | .icon-mixer:before { 41 | content: "\e660"; 42 | } 43 | 44 | .icon-setting:before { 45 | content: "\e673"; 46 | } 47 | 48 | .icon-list:before { 49 | content: "\e603"; 50 | } 51 | 52 | .icon-pen-l:before { 53 | content: "\e64d"; 54 | } 55 | 56 | .icon-select:before { 57 | content: "\ea60"; 58 | } 59 | 60 | .icon-range:before { 61 | content: "\e63c"; 62 | } 63 | 64 | .icon-eyeslash-fill:before { 65 | content: "\e7aa"; 66 | } 67 | 68 | .icon-eye-fill:before { 69 | content: "\e7ab"; 70 | } 71 | 72 | .icon-close_volume:before { 73 | content: "\e6a0"; 74 | } 75 | 76 | .icon-volume:before { 77 | content: "\e6a3"; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /style/icon/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/style/icon/iconfont.ttf -------------------------------------------------------------------------------- /style/icon/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/style/icon/iconfont.woff -------------------------------------------------------------------------------- /style/icon/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madderscientist/noteDigger/065e813d0e40d5f4943a3ff890eb9285ec8313c9/style/icon/iconfont.woff2 -------------------------------------------------------------------------------- /style/myRange.css: -------------------------------------------------------------------------------- 1 | .myrange { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | display: inline-flex; 6 | align-items: center; 7 | color: inherit; 8 | } 9 | 10 | .myrange span { 11 | color: inherit; 12 | cursor: pointer; 13 | user-select: none; 14 | } 15 | 16 | /* === 各浏览器中统一滑动条 === */ 17 | .myrange input[type="range"] { 18 | -webkit-appearance: none; 19 | appearance: none; 20 | margin: 0 5px 0 0; 21 | padding: 0; 22 | outline: none; 23 | border: none; 24 | background: rgb(60, 87, 221); 25 | height: 6px; 26 | border-radius: 10px; 27 | transform: translateY(1px); 28 | } 29 | 30 | .myrange input[type="range"]::-webkit-slider-thumb { 31 | -webkit-appearance: none; 32 | background: rgb(60, 87, 221); 33 | width: 16px; 34 | height: 16px; 35 | border: none; 36 | border-radius: 50%; 37 | box-shadow: 0px 3px 6px 0px rgba(255, 255, 255, 0.15); 38 | } 39 | 40 | .myrange input[type="range"]::-moz-range-thumb { 41 | background: rgb(60, 87, 221); 42 | width: 16px; 43 | height: 16px; 44 | border: none; 45 | border-radius: 50%; 46 | box-shadow: 0px 3px 6px 0px rgba(255, 255, 255, 0.15); 47 | } 48 | /* 解决Firefox中虚线显示在周围的问题 */ 49 | .myrange input[type="range"]::-moz-focus-outer { 50 | border: 0; 51 | } 52 | 53 | .myrange input[type="range"]:active::-webkit-slider-thumb { 54 | box-shadow: 0px 5px 10px -2px rgba(0, 0, 0, 0.3); 55 | } 56 | /* === 滑动条end === */ 57 | 58 | /* 隐藏数值的滑动条 */ 59 | .hidelabelrange { 60 | position: relative; 61 | } 62 | 63 | .hidelabelrange span.thelabel { 64 | position: absolute; 65 | z-index: 1; 66 | padding: 3px 6px; 67 | background-color: #373943; 68 | box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.15); 69 | color: white; 70 | font-size: 12px; 71 | border-radius: 4px; 72 | top: 7px; 73 | transform: translateX(-50%) translateY(50%); 74 | } 75 | 76 | .hidelabelrange span.thelabel::after { 77 | content: ''; 78 | position: absolute; 79 | z-index: -1; 80 | width: 10px; 81 | height: 10px; 82 | background-color: #373943; 83 | top: -3px; 84 | left: 50%; 85 | transform: translateX(-50%) rotate(45deg); 86 | } 87 | 88 | .fullRange { 89 | flex: 1; 90 | width: 100%; 91 | input { 92 | width: 100%; 93 | } 94 | } -------------------------------------------------------------------------------- /style/siderMenu.css: -------------------------------------------------------------------------------- 1 | .siderTabs { 2 | --tab-width: 48px; 3 | width: var(--tab-width); 4 | height: 100%; 5 | background-color: var(--theme-dark); 6 | position: relative; 7 | z-index: 1; 8 | } 9 | 10 | .siderTab { 11 | color: var(--theme-text); 12 | width: var(--tab-width); 13 | height: var(--tab-width); 14 | position: relative; 15 | cursor: pointer; 16 | } 17 | 18 | /* 图标位置与大小 */ 19 | .siderTab::before { 20 | position: absolute; 21 | top: 50%; 22 | left: 50%; 23 | transform: translate(-50%, -50%); 24 | font-size: calc(var(--tab-width) * 0.5); 25 | } 26 | 27 | .siderTab.selected { 28 | color: white; 29 | background-color: var(--theme-middle); 30 | border-left: white solid 2px; 31 | } 32 | .siderTab.selected { 33 | color: white; 34 | background-color: var(--theme-middle); 35 | border-left: white solid 2px; 36 | } 37 | 38 | 39 | .siderTab:hover { 40 | color: white; 41 | } 42 | 43 | .siderTab::after { 44 | content: attr(data-name); 45 | font-size: calc(var(--tab-width) * 0.25); 46 | color: var(--theme-text); 47 | background-color: var(--theme-light); 48 | white-space: nowrap; 49 | padding: 4px 8px; 50 | position: absolute; 51 | z-index: 3; 52 | top: calc(var(--tab-width) * 0.5); 53 | left: calc(var(--tab-width) + 2px); 54 | transform: translateY(-50%); 55 | border-radius: 4px; 56 | border: var(--theme-dark) solid 2px; 57 | display: none; 58 | } 59 | .siderTab:hover::after { 60 | display: block; 61 | } 62 | 63 | /* 展示内容 */ 64 | .siderContent { 65 | background-color: transparent; 66 | width: 206px; 67 | height: 100%; 68 | overflow: hidden; 69 | } 70 | 71 | .siderBar { 72 | opacity: 0; 73 | transition: 0.3s; 74 | width: 4px; 75 | margin-left: -2px; 76 | margin-right: -2px; 77 | height: 100%; 78 | background-color: royalblue; 79 | cursor: ew-resize; 80 | user-select: none; 81 | position: relative; 82 | z-index: 2; 83 | } 84 | 85 | .siderBar:hover { 86 | opacity: 1; 87 | } 88 | 89 | .siderContent .siderItem { 90 | background-color: var(--theme-middle); 91 | width: 100%; 92 | height: 100%; 93 | } 94 | 95 | /* siderItem不提供内边距,用paddingbox类实现 */ 96 | .paddingbox { 97 | box-sizing: border-box; 98 | padding: 0.4em 0.8em; 99 | overflow: auto; 100 | } 101 | 102 | .siderItem h3 { 103 | margin: 0; 104 | padding: 0; 105 | } -------------------------------------------------------------------------------- /style/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-light: #2e3039; 3 | --theme-middle: #25262d; 4 | --theme-dark: #1e1f24; 5 | --theme-text: #8e95a6; 6 | } 7 | 8 | * { 9 | margin: 0; 10 | padding: 0; 11 | user-select: none; 12 | } 13 | 14 | html, 15 | body { 16 | height: 100%; 17 | font-size: 16px; 18 | color: var(--theme-text); 19 | background-color: var(--theme-dark); 20 | overflow: hidden; 21 | } 22 | 23 | @media (max-width: 890px) { 24 | .top-logo { 25 | display: none; 26 | } 27 | } 28 | 29 | canvas { 30 | display: block; 31 | margin: 0; 32 | border: none; 33 | } 34 | 35 | ul { 36 | list-style: none; 37 | } 38 | 39 | button { 40 | background-color: var(--theme-dark); 41 | border: none; 42 | cursor: pointer; 43 | color: var(--theme-text); 44 | font-size: 16px; 45 | } 46 | 47 | /* flex */ 48 | .f { 49 | display: flex; 50 | } 51 | 52 | .fc { 53 | display: flex; 54 | flex-direction: column; 55 | } 56 | 57 | .fr { 58 | display: flex; 59 | flex-direction: row; 60 | } 61 | 62 | /* width full */ 63 | .wf { 64 | width: 100%; 65 | } 66 | 67 | .dragIn::before { 68 | content: "Drag and drop your file here"; 69 | display: flex; 70 | justify-content: center; 71 | align-items: center; 72 | position: absolute; 73 | z-index: 98; 74 | width: 100%; 75 | height: 100%; 76 | background-color: #1e1f24bb; 77 | border: var(--theme-text) 3px dashed; 78 | box-sizing: border-box; 79 | font-size: 2em; 80 | } 81 | 82 | .card { 83 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); 84 | border-radius: 10px; 85 | } 86 | 87 | .hvCenter { 88 | top: 50%; 89 | left: 50%; 90 | transform: translate(-50%, -50%); 91 | } 92 | 93 | #scrollbar-track { 94 | margin: 0; 95 | padding: 0; 96 | border: none; 97 | position: relative; 98 | height: 1em; 99 | background-color: rgb(37, 38, 45); 100 | } 101 | 102 | #scrollbar-thumb { 103 | margin: 0; 104 | padding: 0; 105 | border: none; 106 | position: absolute; 107 | height: 100%; 108 | width: 50px; 109 | background-color: rgb(69, 72, 81); 110 | cursor: ew-resize; 111 | } 112 | 113 | #play-btn { 114 | background-color: var(--theme-middle); 115 | color: var(--theme-text); 116 | border: none; 117 | border-right: var(--theme-dark) solid 3px; 118 | position: relative; 119 | font-size: 0.8em; 120 | cursor: pointer; 121 | } 122 | 123 | .flexfull { 124 | flex: 1; 125 | /* 下面这行必须加 不然被画布撑开了就缩不回去了 */ 126 | overflow: hidden; 127 | } 128 | 129 | .tools { 130 | background-color: transparent; 131 | display: flex; 132 | justify-content: space-between; 133 | align-items: center; 134 | padding: 2px; 135 | position: relative; 136 | z-index: 1; 137 | } 138 | 139 | .top-logo { 140 | height: 36px; 141 | padding: 0 8px; 142 | cursor: pointer; 143 | } 144 | 145 | /* 可以套在外面的盒子,盒子中可以放属性名 */ 146 | .rangeBox { 147 | height: 1em; 148 | line-height: 1em; 149 | display: flex; 150 | flex-direction: row; 151 | align-items: center; 152 | justify-content: left; 153 | margin: 0.25em; 154 | } 155 | 156 | /* 工具选择 */ 157 | .switch-bar { 158 | display: inline-block; 159 | background-color: transparent; 160 | /* 消除因为换行和缩进带来的间隔 */ 161 | white-space: nowrap; 162 | font-size: 0; 163 | } 164 | 165 | .switch-bar button { 166 | color: white; 167 | padding: 0.5em 0.6em; 168 | border-radius: 0; 169 | background: rgb(50, 53, 62); 170 | position: relative; 171 | z-index: 0; 172 | /* 恢复默认大小 */ 173 | font-size: 16px; 174 | } 175 | 176 | .switch-bar button:first-child { 177 | border-top-left-radius: 5px; 178 | border-bottom-left-radius: 5px; 179 | } 180 | 181 | .switch-bar button:last-child { 182 | border-top-right-radius: 5px; 183 | border-bottom-right-radius: 5px; 184 | } 185 | 186 | .switch-bar .selected { 187 | background-color: rgb(60, 87, 221); 188 | } 189 | /* 下方标签 */ 190 | .labeled { 191 | position: relative; 192 | } 193 | .labeled::after { 194 | content: attr(data-tooltip); 195 | font-size: 12px; 196 | color: var(--theme-text); 197 | background-color: var(--theme-light); 198 | white-space: pre; 199 | padding: 4px 8px; 200 | position: absolute; 201 | z-index: 2; 202 | bottom: 0; 203 | left: 50%; 204 | transform: translateY(105%) translateX(-50%); 205 | border-radius: 4px; 206 | border: var(--theme-dark) solid 2px; 207 | display: none; 208 | } 209 | .labeled:hover::after { 210 | display: block; 211 | } 212 | 213 | /* 用列表组织的菜单 */ 214 | .btn-ul li { 215 | margin: 0 -0.3em; 216 | padding: 0.6em; 217 | border-radius: 4px; 218 | } 219 | .btn-ul li:hover { 220 | background-color: var(--theme-light); 221 | color: white; 222 | } 223 | .btn-ul button { 224 | padding: 0.25em 0.6em; 225 | margin-bottom: 0.3em; 226 | border-radius: 6px; 227 | border: black solid 2px; 228 | } 229 | .btn-ul button:hover { 230 | color: white; 231 | } 232 | .btn-ul button:active { 233 | background-color: black; 234 | } 235 | li textarea { 236 | width: calc(100% - 0.5em); 237 | height: 100%; 238 | padding: 0.3em; 239 | border: none; 240 | border-radius: 6px; 241 | background-color: var(--theme-dark); 242 | color: var(--theme-text); 243 | resize: vertical; 244 | } 245 | 246 | /* EQ控制面板 */ 247 | #EQcontrol { 248 | display: flex; 249 | flex-direction: column; 250 | align-items: center; 251 | margin-bottom: 1em; 252 | } 253 | #EQcontrol h5 { /* 频率值 */ 254 | margin: 0.4em 0 0 0; 255 | padding: 0; 256 | } 257 | #EQcontrol .myrange { 258 | width: 100%; 259 | margin: 0 0 0.3em 0; 260 | } 261 | #EQcontrol input { 262 | width: 100%; 263 | } 264 | 265 | /* 漂亮的滑动条 */ 266 | .niceScroll { 267 | overflow: auto; 268 | } 269 | .niceScroll::-webkit-scrollbar { 270 | width: 12px; 271 | } 272 | .niceScroll::-webkit-scrollbar-thumb { 273 | background-color: rgb(50, 53, 62); 274 | border: 3px solid rgb(37, 38, 45); 275 | border-radius: 6px; 276 | } 277 | .niceScroll::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { 278 | background-color: rgb(37, 38, 45); 279 | } 280 | 281 | /* 分析面板 */ 282 | .tonalityResult { 283 | width: 100%; 284 | border-left: white 1px solid; 285 | } 286 | 287 | .tonalityResult div { 288 | color: var(--theme-dark); 289 | height: 1em; 290 | font-size: 1em; 291 | line-height: 1em; 292 | border-top-right-radius: 4px; 293 | border-bottom-right-radius: 4px; 294 | } 295 | 296 | /* 设置面板 */ 297 | #settingPannel button { 298 | margin: 0; 299 | } 300 | #settingPannel li { 301 | display: flex; 302 | flex-direction: row; 303 | justify-content: center; 304 | align-items: center; 305 | flex-wrap: wrap; 306 | position: relative; 307 | } 308 | #settingPannel li::after { 309 | content: attr(data-value); 310 | position: absolute; 311 | right: 0; 312 | bottom: 0; 313 | font-size: 10px; 314 | } 315 | #settingPannel li button:first-of-type { 316 | font-family: monospace; 317 | margin-right: 0.2em; 318 | } 319 | #settingPannel li button:last-of-type { 320 | font-family: monospace; 321 | margin-left: 0.2em; 322 | } 323 | #repeatRange { 324 | display: flex; 325 | flex-wrap: nowrap; 326 | align-items: center; 327 | justify-content: center; 328 | width: 100%; 329 | margin: 0.4em 0; 330 | } 331 | #repeatRange input[type="text"] { 332 | width: 46%; 333 | border-radius: 4px; 334 | padding-left: 4px; 335 | border: none; 336 | border: black solid 1px; 337 | font-size: 0.9em; 338 | color: var(--theme-text); 339 | background: var(--theme-dark); 340 | } 341 | #repeatRange input[type="text"]:focus { 342 | color: white; 343 | } 344 | 345 | canvas.selecting { 346 | cursor: ew-resize; 347 | } -------------------------------------------------------------------------------- /tinySynth.js: -------------------------------------------------------------------------------- 1 | // 是https://github.com/g200kg/webaudio-tinysynth的精简版 2 | class TinySynth { 3 | static soundFont = {}; // 最终是填补了默认值的TinySynth.wave 4 | static defaultWave = { g: 0, w: "sine", t: 1, f: 0, v: 0.5, a: 0, h: 0.01, d: 0.01, s: 0, r: 0.05, p: 1, q: 1, k: 0 }; 5 | static instrument = [ 6 | /* 1-8 : Piano */ "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavi", 7 | /* 9-16 : Chromatic Perc */ "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", 8 | /* 17-24 : Organ */ "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", 9 | /* 25-32 : Guitar */ "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", 10 | /* 33-40 : Bass */ "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", 11 | /* 41-48 : Strings */ "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", 12 | /* 49-56 : Ensamble */ "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", 13 | /* 57-64 : Brass */ "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", 14 | /* 65-72 : Reed */ "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet", 15 | /* 73-80 : Pipe */ "Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", 16 | /* 81-88 : SynthLead */ "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", 17 | /* 89-96 : SynthPad */ "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", 18 | /* 97-104 : FX */ "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", 19 | /* 105-112 : Ethnic */ "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", "Shanai", 20 | /* 113-120 : Percussive */ "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", 21 | /* 121-128 : SE */ "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot", 22 | ]; 23 | static wave = [ 24 | /* 1-8 : Piano */ 25 | [{ w: "sine", v: .4, d: 0.7, r: 0.1 }, { w: "triangle", v: 3, d: 0.7, s: 0.1, g: 1, a: 0.01, k: -1.2 }], 26 | [{ w: "triangle", v: 0.4, d: 0.7, r: 0.1 }, { w: "triangle", v: 4, t: 3, d: 0.4, s: 0.1, g: 1, k: -1, a: 0.01 }], 27 | [{ w: "sine", d: 0.7, r: 0.1 }, { w: "triangle", v: 4, f: 2, d: 0.5, s: 0.5, g: 1, k: -1 }], 28 | [{ w: "sine", d: 0.7, v: 0.2 }, { w: "triangle", v: 4, t: 3, f: 2, d: 0.3, g: 1, k: -1, a: 0.01, s: 0.5 }], 29 | [{ w: "sine", v: 0.35, d: 0.7 }, { w: "sine", v: 3, t: 7, f: 1, d: 1, s: 1, g: 1, k: -.7 }], 30 | [{ w: "sine", v: 0.35, d: 0.7 }, { w: "sine", v: 8, t: 7, f: 1, d: 0.5, s: 1, g: 1, k: -.7 }], 31 | [{ w: "sawtooth", v: 0.34, d: 2 }, { w: "sine", v: 8, f: 0.1, d: 2, s: 1, r: 2, g: 1 }], 32 | [{ w: "triangle", v: 0.34, d: 1.5 }, { w: "square", v: 6, f: 0.1, d: 1.5, s: 0.5, r: 2, g: 1 }], 33 | /* 9-16 : Chromatic Perc*/ 34 | [{ w: "sine", d: 0.3, r: 0.3 }, { w: "sine", v: 7, t: 11, d: 0.03, g: 1 }], 35 | [{ w: "sine", d: 0.3, r: 0.3 }, { w: "sine", v: 11, t: 6, d: 0.2, s: 0.4, g: 1 }], 36 | [{ w: "sine", v: 0.2, d: 0.3, r: 0.3 }, { w: "sine", v: 11, t: 5, d: 0.1, s: 0.4, g: 1 }], 37 | [{ w: "sine", v: 0.2, d: 0.6, r: 0.6 }, { w: "triangle", v: 11, t: 5, f: 1, s: 0.5, g: 1 }], 38 | [{ w: "sine", v: 0.3, d: 0.2, r: 0.2 }, { w: "sine", v: 6, t: 5, d: 0.02, g: 1 }], 39 | [{ w: "sine", v: 0.3, d: 0.2, r: 0.2 }, { w: "sine", v: 7, t: 11, d: 0.03, g: 1 }], 40 | [{ w: "sine", v: 0.2, d: 1, r: 1 }, { w: "sine", v: 11, t: 3.5, d: 1, r: 1, g: 1 }], 41 | [{ w: "triangle", v: 0.2, d: 0.5, r: 0.2 }, { w: "sine", v: 6, t: 2.5, d: 0.2, s: 0.1, r: 0.2, g: 1 }], 42 | /* 17-24 : Organ */ 43 | [{ w: "w9999", v: 0.22, s: 0.9 }, { w: "w9999", v: 0.22, t: 2, f: 2, s: 0.9 }], 44 | [{ w: "w9999", v: 0.2, s: 1 }, { w: "sine", v: 11, t: 6, f: 2, s: 0.1, g: 1, h: 0.006, r: 0.002, d: 0.002 }, { w: "w9999", v: 0.2, t: 2, f: 1, h: 0, s: 1 }], 45 | [{ w: "w9999", v: 0.2, d: 0.1, s: 0.9 }, { w: "w9999", v: 0.25, t: 4, f: 2, s: 0.5 }], 46 | [{ w: "w9999", v: 0.3, a: 0.04, s: 0.9 }, { w: "w9999", v: 0.2, t: 8, f: 2, a: 0.04, s: 0.9 }], 47 | [{ w: "sine", v: 0.2, a: 0.02, d: 0.05, s: 1 }, { w: "sine", v: 6, t: 3, f: 1, a: 0.02, d: 0.05, s: 1, g: 1 }], 48 | [{ w: "triangle", v: 0.2, a: 0.02, d: 0.05, s: 0.8 }, { w: "square", v: 7, t: 3, f: 1, d: 0.05, s: 1.5, g: 1 }], 49 | [{ w: "square", v: 0.2, a: 0.02, d: 0.2, s: 0.5 }, { w: "square", v: 1, d: 0.03, s: 2, g: 1 }], 50 | [{ w: "square", v: 0.2, a: 0.02, d: 0.1, s: 0.8 }, { w: "square", v: 1, a: 0.3, d: 0.1, s: 2, g: 1 }], 51 | /* 25-32 : Guitar */ 52 | [{ w: "sine", v: 0.3, d: 0.5, f: 1 }, { w: "triangle", v: 5, t: 3, f: -1, d: 1, s: 0.1, g: 1 }], 53 | [{ w: "sine", v: 0.4, d: 0.6, f: 1 }, { w: "triangle", v: 12, t: 3, d: 0.6, s: 0.1, g: 1, f: -1 }], 54 | [{ w: "triangle", v: 0.3, d: 1, f: 1 }, { w: "triangle", v: 6, f: -1, d: 0.4, s: 0.5, g: 1, t: 3 }], 55 | [{ w: "sine", v: 0.3, d: 1, f: -1 }, { w: "triangle", v: 11, f: 1, d: 0.4, s: 0.5, g: 1, t: 3 }], 56 | [{ w: "sine", v: 0.4, d: 0.1, r: 0.01 }, { w: "sine", v: 7, g: 1 }], 57 | [{ w: "triangle", v: 0.4, d: 1, f: 1 }, { w: "square", v: 4, f: -1, d: 1, s: 0.7, g: 1 }],//[{w:"triangle",v:0.35,d:1,f:1,},{w:"square",v:7,f:-1,d:0.3,s:0.5,g:1,}], 58 | [{ w: "triangle", v: 0.35, d: 1, f: 1 }, { w: "square", v: 7, f: -1, d: 0.3, s: 0.5, g: 1 }],//[{w:"triangle",v:0.4,d:1,f:1,},{w:"square",v:4,f:-1,d:1,s:0.7,g:1,}],//[{w:"triangle",v:0.4,d:1,},{w:"square",v:4,f:2,d:1,s:0.7,g:1,}], 59 | [{ w: "sine", v: 0.2, t: 1.5, a: 0.005, h: 0.2, d: 0.6 }, { w: "sine", v: 11, t: 5, f: 2, d: 1, s: 0.5, g: 1 }], 60 | /* 33-40 : Bass */ 61 | [{ w: "sine", d: 0.3 }, { w: "sine", v: 4, t: 3, d: 1, s: 1, g: 1 }], 62 | [{ w: "sine", d: 0.3 }, { w: "sine", v: 4, t: 3, d: 1, s: 1, g: 1 }], 63 | [{ w: "w9999", d: 0.3, v: 0.7, s: 0.5 }, { w: "sawtooth", v: 1.2, d: 0.02, s: 0.5, g: 1, h: 0, r: 0.02 }], 64 | [{ w: "sine", d: 0.3 }, { w: "sine", v: 4, t: 3, d: 1, s: 1, g: 1 }], 65 | [{ w: "triangle", v: 0.3, t: 2, d: 1 }, { w: "triangle", v: 15, t: 2.5, d: 0.04, s: 0.1, g: 1 }], 66 | [{ w: "triangle", v: 0.3, t: 2, d: 1 }, { w: "triangle", v: 15, t: 2.5, d: 0.04, s: 0.1, g: 1 }], 67 | [{ w: "triangle", d: 0.7 }, { w: "square", v: 0.4, t: 0.5, f: 1, d: 0.2, s: 10, g: 1 }], 68 | [{ w: "triangle", d: 0.7 }, { w: "square", v: 0.4, t: 0.5, f: 1, d: 0.2, s: 10, g: 1 }], 69 | /* 41-48 : Strings */ 70 | [{ w: "sawtooth", v: 0.4, a: 0.1, d: 11 }, { w: "sine", v: 5, d: 11, s: 0.2, g: 1 }], 71 | [{ w: "sawtooth", v: 0.4, a: 0.1, d: 11 }, { w: "sine", v: 5, d: 11, s: 0.2, g: 1 }], 72 | [{ w: "sawtooth", v: 0.4, a: 0.1, d: 11 }, { w: "sine", v: 5, t: 0.5, d: 11, s: 0.2, g: 1 }], 73 | [{ w: "sawtooth", v: 0.4, a: 0.1, d: 11 }, { w: "sine", v: 5, t: 0.5, d: 11, s: 0.2, g: 1 }], 74 | [{ w: "sine", v: 0.4, a: 0.1, d: 11 }, { w: "sine", v: 6, f: 2.5, d: 0.05, s: 1.1, g: 1 }], 75 | [{ w: "sine", v: 0.3, d: 0.1, r: 0.1 }, { w: "square", v: 4, t: 3, d: 1, s: 0.2, g: 1 }], 76 | [{ w: "sine", v: 0.3, d: 0.5, r: 0.5 }, { w: "sine", v: 7, t: 2, f: 2, d: 1, r: 1, g: 1 }], 77 | [{ w: "triangle", v: 0.6, h: 0.03, d: 0.3, r: 0.3, t: 0.5 }, { w: "n0", v: 8, t: 1.5, d: 0.08, r: 0.08, g: 1 }], 78 | /* 49-56 : Ensamble */ 79 | [{ w: "sawtooth", v: 0.3, a: 0.03, s: 0.5 }, { w: "sawtooth", v: 0.2, t: 2, f: 2, d: 1, s: 2 }], 80 | [{ w: "sawtooth", v: 0.3, f: -2, a: 0.03, s: 0.5 }, { w: "sawtooth", v: 0.2, t: 2, f: 2, d: 1, s: 2 }], 81 | [{ w: "sawtooth", v: 0.2, a: 0.02, s: 1 }, { w: "sawtooth", v: 0.2, t: 2, f: 2, a: 1, d: 1, s: 1 }], 82 | [{ w: "sawtooth", v: 0.2, a: 0.02, s: 1 }, { w: "sawtooth", v: 0.2, f: 2, a: 0.02, d: 1, s: 1 }], 83 | [{ w: "triangle", v: 0.3, a: 0.03, s: 1 }, { w: "sine", v: 3, t: 5, f: 1, d: 1, s: 1, g: 1 }], 84 | [{ w: "sine", v: 0.4, a: 0.03, s: 0.9 }, { w: "sine", v: 1, t: 2, f: 3, d: 0.03, s: 0.2, g: 1 }], 85 | [{ w: "triangle", v: 0.6, a: 0.05, s: 0.5 }, { w: "sine", v: 1, f: 0.8, d: 0.2, s: 0.2, g: 1 }], 86 | [{ w: "square", v: 0.15, a: 0.01, d: 0.2, r: 0.2, t: 0.5, h: 0.03 }, { w: "square", v: 4, f: 0.5, d: 0.2, r: 11, a: 0.01, g: 1, h: 0.02 }, { w: "square", v: 0.15, t: 4, f: 1, a: 0.02, d: 0.15, r: 0.15, h: 0.03 }, { g: 3, w: "square", v: 4, f: -0.5, a: 0.01, h: 0.02, d: 0.15, r: 11 }], 87 | /* 57-64 : Brass */ 88 | [{ w: "square", v: 0.2, a: 0.01, d: 1, s: 0.6, r: 0.04 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 89 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.5, r: 0.08 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 90 | [{ w: "square", v: 0.2, a: 0.04, d: 1, s: 0.4, r: 0.08 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 91 | [{ w: "square", v: 0.15, a: 0.04, s: 1 }, { w: "sine", v: 2, d: 0.1, g: 1 }], 92 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.5, r: 0.08 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 93 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.6, r: 0.08 }, { w: "sine", v: 1, f: 0.2, d: 0.1, s: 4, g: 1 }], 94 | [{ w: "square", v: 0.2, a: 0.02, d: 0.5, s: 0.7, r: 0.08 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 95 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.5, r: 0.08 }, { w: "sine", v: 1, d: 0.1, s: 4, g: 1 }], 96 | /* 65-72 : Reed */ 97 | [{ w: "square", v: 0.2, a: 0.02, d: 2, s: 0.6 }, { w: "sine", v: 2, d: 1, g: 1 }], 98 | [{ w: "square", v: 0.2, a: 0.02, d: 2, s: 0.6 }, { w: "sine", v: 2, d: 1, g: 1 }], 99 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.6 }, { w: "sine", v: 2, d: 1, g: 1 }], 100 | [{ w: "square", v: 0.2, a: 0.02, d: 1, s: 0.6 }, { w: "sine", v: 2, d: 1, g: 1 }], 101 | [{ w: "sine", v: 0.4, a: 0.02, d: 0.7, s: 0.5 }, { w: "square", v: 5, t: 2, d: 0.2, s: 0.5, g: 1 }], 102 | [{ w: "sine", v: 0.3, a: 0.05, d: 0.2, s: 0.8 }, { w: "sawtooth", v: 6, f: 0.1, d: 0.1, s: 0.3, g: 1 }], 103 | [{ w: "sine", v: 0.3, a: 0.03, d: 0.2, s: 0.4 }, { w: "square", v: 7, f: 0.2, d: 1, s: 0.1, g: 1 }], 104 | [{ w: "square", v: 0.2, a: 0.05, d: 0.1, s: 0.8 }, { w: "square", v: 4, d: 0.1, s: 1.1, g: 1 }], 105 | /* 73-80 : Pipe */ 106 | [{ w: "sine", a: 0.02, d: 2 }, { w: "sine", v: 6, t: 2, d: 0.04, g: 1 }], 107 | [{ w: "sine", v: 0.7, a: 0.03, d: 0.4, s: 0.4 }, { w: "sine", v: 4, t: 2, f: 0.2, d: 0.4, g: 1 }], 108 | [{ w: "sine", v: 0.7, a: 0.02, d: 0.4, s: 0.6 }, { w: "sine", v: 3, t: 2, d: 0, s: 1, g: 1 }], 109 | [{ w: "sine", v: 0.4, a: 0.06, d: 0.3, s: 0.3 }, { w: "sine", v: 7, t: 2, d: 0.2, s: 0.2, g: 1 }], 110 | [{ w: "sine", a: 0.02, d: 0.3, s: 0.3 }, { w: "sawtooth", v: 3, t: 2, d: 0.3, g: 1 }], 111 | [{ w: "sine", v: 0.4, a: 0.02, d: 2, s: 0.1 }, { w: "sawtooth", v: 8, t: 2, f: 1, d: 0.5, g: 1 }], 112 | [{ w: "sine", v: 0.7, a: 0.03, d: 0.5, s: 0.3 }, { w: "sine", v: 0.003, t: 0, f: 4, d: 0.1, s: 0.002, g: 1 }], 113 | [{ w: "sine", v: 0.7, a: 0.02, d: 2 }, { w: "sine", v: 1, t: 2, f: 1, d: 0.02, g: 1 }], 114 | /* 81-88 : SynthLead */ 115 | [{ w: "square", v: 0.3, d: 1, s: 0.5 }, { w: "square", v: 1, f: 0.2, d: 1, s: 0.5, g: 1 }], 116 | [{ w: "sawtooth", v: 0.3, d: 2, s: 0.5 }, { w: "square", v: 2, f: 0.1, s: 0.5, g: 1 }], 117 | [{ w: "triangle", v: 0.5, a: 0.05, d: 2, s: 0.6 }, { w: "sine", v: 4, t: 2, g: 1 }], 118 | [{ w: "triangle", v: 0.3, a: 0.01, d: 2, s: 0.3 }, { w: "sine", v: 22, t: 2, f: 1, d: 0.03, s: 0.2, g: 1 }], 119 | [{ w: "sawtooth", v: 0.3, d: 1, s: 0.5 }, { w: "sine", v: 11, t: 11, a: 0.2, d: 0.05, s: 0.3, g: 1 }], 120 | [{ w: "sine", v: 0.3, a: 0.06, d: 1, s: 0.5 }, { w: "sine", v: 7, f: 1, d: 1, s: 0.2, g: 1 }], 121 | [{ w: "sawtooth", v: 0.3, a: 0.03, d: 0.7, s: 0.3, r: 0.2 }, { w: "sawtooth", v: 0.3, t: 0.75, d: 0.7, a: 0.1, s: 0.3, r: 0.2 }], 122 | [{ w: "triangle", v: 0.3, a: 0.01, d: 0.7, s: 0.5 }, { w: "square", v: 5, t: 0.5, d: 0.7, s: 0.5, g: 1 }], 123 | /* 89-96 : SynthPad */ 124 | [{ w: "triangle", v: 0.3, a: 0.02, d: 0.3, s: 0.3, r: 0.3 }, { w: "square", v: 3, t: 4, f: 1, a: 0.02, d: 0.1, s: 1, g: 1 }, { w: "triangle", v: 0.08, t: 0.5, a: 0.1, h: 0, d: 0.1, s: 0.5, r: 0.1, b: 0, c: 0 }], 125 | [{ w: "sine", v: 0.3, a: 0.05, d: 1, s: 0.7, r: 0.3 }, { w: "sine", v: 2, f: 1, d: 0.3, s: 1, g: 1 }], 126 | [{ w: "square", v: 0.3, a: 0.03, d: 0.5, s: 0.3, r: 0.1 }, { w: "square", v: 4, f: 1, a: 0.03, d: 0.1, g: 1 }], 127 | [{ w: "triangle", v: 0.3, a: 0.08, d: 1, s: 0.3, r: 0.1 }, { w: "square", v: 2, f: 1, d: 0.3, s: 0.3, g: 1, t: 4, a: 0.08 }], 128 | [{ w: "sine", v: 0.3, a: 0.05, d: 1, s: 0.3, r: 0.1 }, { w: "sine", v: 0.1, t: 2.001, f: 1, d: 1, s: 50, g: 1 }], 129 | [{ w: "triangle", v: 0.3, a: 0.03, d: 0.7, s: 0.3, r: 0.2 }, { w: "sine", v: 12, t: 7, f: 1, d: 0.5, s: 1.7, g: 1 }], 130 | [{ w: "sine", v: 0.3, a: 0.05, d: 1, s: 0.3, r: 0.1 }, { w: "sawtooth", v: 22, t: 6, d: 0.06, s: 0.3, g: 1 }], 131 | [{ w: "triangle", v: 0.3, a: 0.05, d: 11, r: 0.3 }, { w: "triangle", v: 1, d: 1, s: 8, g: 1 }], 132 | /* 97-104 : FX */ 133 | [{ w: "sawtooth", v: 0.3, d: 4, s: 0.8, r: 0.1 }, { w: "square", v: 1, t: 2, f: 8, a: 1, d: 1, s: 1, r: 0.1, g: 1 }], 134 | [{ w: "triangle", v: 0.3, d: 1, s: 0.5, t: 0.8, a: 0.2, p: 1.25, q: 0.2 }, { w: "sawtooth", v: 0.2, a: 0.2, d: 0.3, s: 1, t: 1.2, p: 1.25, q: 0.2 }], 135 | [{ w: "sine", v: 0.3, d: 1, s: 0.3 }, { w: "square", v: 22, t: 11, d: 0.5, s: 0.1, g: 1 }], 136 | [{ w: "sawtooth", v: 0.3, a: 0.04, d: 1, s: 0.8, r: 0.1 }, { w: "square", v: 1, t: 0.5, d: 1, s: 2, g: 1 }], 137 | [{ w: "triangle", v: 0.3, d: 1, s: 0.3 }, { w: "sine", v: 22, t: 6, d: 0.6, s: 0.05, g: 1 }], 138 | [{ w: "sine", v: 0.6, a: 0.1, d: 0.05, s: 0.4 }, { w: "sine", v: 5, t: 5, f: 1, d: 0.05, s: 0.3, g: 1 }], 139 | [{ w: "sine", a: 0.1, d: 0.05, s: 0.4, v: 0.8 }, { w: "sine", v: 5, t: 5, f: 1, d: 0.05, s: 0.3, g: 1 }], 140 | [{ w: "square", v: 0.3, a: 0.1, d: 0.1, s: 0.4 }, { w: "square", v: 1, f: 1, d: 0.3, s: 0.1, g: 1 }], 141 | /* 105-112 : Ethnic */ 142 | [{ w: "sawtooth", v: 0.3, d: 0.5, r: 0.5 }, { w: "sawtooth", v: 11, t: 5, d: 0.05, g: 1 }], 143 | [{ w: "square", v: 0.3, d: 0.2, r: 0.2 }, { w: "square", v: 7, t: 3, d: 0.05, g: 1 }], 144 | [{ w: "triangle", d: 0.2, r: 0.2 }, { w: "square", v: 9, t: 3, d: 0.1, r: 0.1, g: 1 }], 145 | [{ w: "triangle", d: 0.3, r: 0.3 }, { w: "square", v: 6, t: 3, d: 1, r: 1, g: 1 }], 146 | [{ w: "triangle", v: 0.4, d: 0.2, r: 0.2 }, { w: "square", v: 22, t: 12, d: 0.1, r: 0.1, g: 1 }], 147 | [{ w: "sine", v: 0.25, a: 0.02, d: 0.05, s: 0.8 }, { w: "square", v: 1, t: 2, d: 0.03, s: 11, g: 1 }], 148 | [{ w: "sine", v: 0.3, a: 0.05, d: 11 }, { w: "square", v: 7, t: 3, f: 1, s: 0.7, g: 1 }], 149 | [{ w: "square", v: 0.3, a: 0.05, d: 0.1, s: 0.8 }, { w: "square", v: 4, d: 0.1, s: 1.1, g: 1 }], 150 | /* 113-120 : Percussive */ 151 | [{ w: "sine", v: 0.4, d: 0.3, r: 0.3 }, { w: "sine", v: 7, t: 9, d: 0.1, r: 0.1, g: 1 }], 152 | [{ w: "sine", v: 0.7, d: 0.1, r: 0.1 }, { w: "sine", v: 22, t: 7, d: 0.05, g: 1 }], 153 | [{ w: "sine", v: 0.6, d: 0.15, r: 0.15 }, { w: "square", v: 11, t: 3.2, d: 0.1, r: 0.1, g: 1 }], 154 | [{ w: "sine", v: 0.8, d: 0.07, r: 0.07 }, { w: "square", v: 11, t: 7, r: 0.01, g: 1 }], 155 | [{ w: "triangle", v: 0.7, t: 0.5, d: 0.2, r: 0.2, p: 0.95 }, { w: "n0", v: 9, g: 1, d: 0.2, r: 0.2 }], 156 | [{ w: "sine", v: 0.7, d: 0.1, r: 0.1, p: 0.9 }, { w: "square", v: 14, t: 2, d: 0.005, r: 0.005, g: 1 }], 157 | [{ w: "square", d: 0.15, r: 0.15, p: 0.5 }, { w: "square", v: 4, t: 5, d: 0.001, r: 0.001, g: 1 }], 158 | [{ w: "n1", v: 0.3, a: 1, s: 1, d: 0.15, r: 0, t: 0.5 }], 159 | /* 121-128 : SE */ 160 | [{ w: "sine", t: 12.5, d: 0, r: 0, p: 0.5, v: 0.3, h: 0.2, q: 0.5 }, { g: 1, w: "sine", v: 1, t: 2, d: 0, r: 0, s: 1 }, { g: 1, w: "n0", v: 0.2, t: 2, a: 0.6, h: 0, d: 0.1, r: 0.1, b: 0, c: 0 }], 161 | [{ w: "n0", v: 0.2, a: 0.05, h: 0.02, d: 0.02, r: 0.02 }], 162 | [{ w: "n0", v: 0.4, a: 1, d: 1, t: 0.25 }], 163 | [{ w: "sine", v: 0.3, a: 0.1, d: 1, s: 0.5 }, { w: "sine", v: 4, t: 0, f: 1.5, d: 1, s: 1, r: 0.1, g: 1 }, { g: 1, w: "sine", v: 4, t: 0, f: 2, a: 0.6, h: 0, d: 0.1, s: 1, r: 0.1, b: 0, c: 0 }], 164 | [{ w: "square", v: 0.3, t: 0.25, d: 11, s: 1 }, { w: "square", v: 12, t: 0, f: 8, d: 1, s: 1, r: 11, g: 1 }], 165 | [{ w: "n0", v: 0.4, t: 0.5, a: 1, d: 11, s: 1, r: 0.5 }, { w: "square", v: 1, t: 0, f: 14, d: 1, s: 1, r: 11, g: 1 }], 166 | [{ w: "sine", t: 0, f: 1221, a: 0.2, d: 1, r: 0.25, s: 1 }, { g: 1, w: "n0", v: 3, t: 0.5, d: 1, s: 1, r: 1 }], 167 | [{ w: "sine", d: 0.4, r: 0.4, p: 0.1, t: 2.5, v: 1 }, { w: "n0", v: 12, t: 2, d: 1, r: 1, g: 1 }], 168 | ]; 169 | /** 170 | * 填充音色默认参数 171 | * @param {Object} options 配置选项 172 | * @param {Number} [options.g=0] - output destination 0=final output / n=FM to specified osc即将FM效果应用在第几个osc上 173 | * @param {String} [options.w="sine"] - wave type 波形类型 sine/square/sawtooth/triangle/w9999 174 | * @param {Number} [options.t=1] - tune factor according to note# 175 | * @param {Number} [options.f=0] - delta频率 在基频上面加的 f' = f0*t+f 176 | * @param {Number} [options.v=0.5] - volume 音量 0~1 177 | * @param {Number} [options.a=0] - attack time in seconds 178 | * @param {Number} [options.h=0.01] - hold time in seconds 179 | * @param {Number} [options.d=0.01] - decay time in seconds 180 | * @param {Number} [options.s=0] - sustain level 声音在按键持续按下期间的音量 181 | * @param {Number} [options.r=0.05] - release time in seconds 182 | * @param {Number} [options.p=1] - pitch bend 频率变化因数(乘) 183 | * @param {Number} [options.q=1] - pitch bend speed factor in seconds 从freq到freq*p所需秒数 184 | * @param {Number} [options.k=0] - volume key tracking factor 在真实的乐器中,音量往往会随着音高的变化而变化 185 | */ 186 | static initSoundFont({ g, w, t, f, v, a, h, d, s, r, p, q, k } = TinySynth.defaultWave) { 187 | // 默认的波形参数,用于填充每个基本波中缺失的默认参数 188 | const defp = { g: g, w: w, t: t, f: f, v: v, a: a, h: h, d: d, s: s, r: r, p: p, q: q, k: k }; 189 | for (let i = 0; i < TinySynth.instrument.length; i++) { 190 | // 用的是复制,目的是防止修改TinySynth.wave。此函数可多次调用,改变全局音色 191 | TinySynth.soundFont[TinySynth.instrument[i]] = Array.from(TinySynth.wave[i], (v) => Object.assign({}, defp, v)); 192 | } console.log("音色库初始化完毕"); 193 | } 194 | static initOneSoundFont(id, { g, w, t, f, v, a, h, d, s, r, p, q, k } = TinySynth.defaultWave) { 195 | const defp = { g: g, w: w, t: t, f: f, v: v, a: a, h: h, d: d, s: s, r: r, p: p, q: q, k: k }; 196 | TinySynth.soundFont[TinySynth.instrument[id]] = Array.from(TinySynth.wave[id], (v) => Object.assign({}, defp, v)); 197 | } 198 | static midi_instrument(id) { 199 | return TinySynth.soundFont[TinySynth.instrument[id]]; 200 | } 201 | constructor(actx = new AudioContext(), loadAll = false) { 202 | if (loadAll) { 203 | TinySynth.initSoundFont(); 204 | Object.defineProperty(this, "instrument", { 205 | set: function (id) { this._instrument = id; }, 206 | get: function () { return this._instrument; } 207 | }); console.log("模式: 初始化所有音色"); 208 | } else { 209 | Object.defineProperty(this, "instrument", { 210 | set: function (id) { 211 | const name = TinySynth.instrument[id]; 212 | if (!TinySynth.soundFont[name]) TinySynth.initOneSoundFont(id); 213 | this._instrument = id; 214 | }, 215 | get: function () { return this._instrument; } 216 | }); console.log("模式: 运行时加载音色"); // 初始时大约能小3M运行内存 217 | } 218 | this.channel = []; // 维护一个数组,用于存放所有的channel。如果要改变顺序需要外部更改 219 | this.notes = []; // 存放所有正在playing的note 220 | this.instrument = 0; 221 | this.audioContext = actx; 222 | const check = () => { 223 | this.checkStop(); 224 | window.requestAnimationFrame(check); 225 | } 226 | window.requestAnimationFrame(check); 227 | } 228 | get audioContext() { 229 | return this.actx; 230 | } 231 | set audioContext(actx) { 232 | this.actx = actx; 233 | this.out = this.actx.createGain(); // 总音量 234 | this.comp = this.actx.createDynamicsCompressor(); 235 | this.out.connect(this.comp); 236 | this.comp.connect(actx.destination); 237 | for (const ch of this.channel) { 238 | ch.out = actx.createGain(); 239 | } 240 | // 不在默认波形中的波形集合 241 | this.wave = { "w9999": actx.createPeriodicWave(new Float32Array(5), new Float32Array([0, 9, 9, 9, 9])) }; 242 | // 噪声 243 | var blen = this.actx.sampleRate >> 1; 244 | this.noiseBuf = { 245 | n0: this.actx.createBuffer(1, blen, this.actx.sampleRate), 246 | n1: this.actx.createBuffer(1, blen, this.actx.sampleRate) 247 | }; 248 | let dn = this.noiseBuf.n0.getChannelData(0); 249 | let dr = this.noiseBuf.n1.getChannelData(0); 250 | for (let i = 0; i < blen; i++) { 251 | dn[i] = Math.random() * 2 - 1;// 范围[-1, 1],白噪声 252 | } 253 | // 生成一个包含64*2个不同频率的正弦波的音频缓冲区 254 | for (let jj = 0; jj < 64; ++jj) { 255 | const r1 = Math.random() * 10 + 1; 256 | const r2 = Math.random() * 10 + 1; 257 | for (let i = 0; i < blen; ++i) { 258 | // 频率是r1和r2 259 | let dd = Math.sin((i / blen) * 2 * Math.PI * 440 * r1) * Math.sin((i / blen) * 2 * Math.PI * 440 * r2); 260 | dr[i] += dd / 8; 261 | } 262 | } 263 | } 264 | /** 265 | * 平方律,根据增益获取音量,正常范围0~127 266 | */ 267 | get volume() { 268 | return Math.round(Math.sqrt(this.out.gain.value * 16129)); 269 | } 270 | /** 271 | * 平方律,根据音量设置增益 272 | * @param {Number} v 自然数音量 273 | */ 274 | set volume(v) { 275 | this.out.gain.value = v * v / 16129; 276 | } 277 | /** 278 | * 创建一个节点 279 | * @param {Number} at 插入在native channel的位置,undefined表示最后,负数表示倒数 280 | * @returns {Object} {out: GainNode} 281 | */ 282 | addChannel(at = this.channel.length, instrument = 0, gain = 1) { 283 | if (!this.channel) return null; // 防止此函数返回的obj调用 284 | const out = this.actx.createGain(); 285 | const ch = {out: out}; 286 | out.gain.value = gain; 287 | out.connect(this.out); 288 | Object.setPrototypeOf(ch, this); 289 | ch.instrument = instrument; // 触发setter 290 | this.channel.splice(at, 0, ch); 291 | return ch; 292 | } 293 | /** 294 | * 播放声音 295 | * @param {Object} options 音符播放参数 296 | * @param {Number} [options.id] - channel的id,如果不传或违规则用自身 决定了音色 297 | * @param {Number} [options.f=440] - 发生频率 298 | * @param {Number} [options.v=127] - 力度,最大127 会按平方律变为音量 299 | * @param {Number} [options.t=0] - 发声时间(秒) 如果小于零则在this.actx.currentTime基础上加其绝对值 300 | * @param {Number} [options.last=9999] - 持续时间(秒) 301 | * @returns {Object} note = {ch, end, gain, release} 302 | */ 303 | play({ id, f = 440, v = 127, t = 0, last = 9999 } = {}) { 304 | if (last <= 0) return; 305 | const ch = id === void 0 ? this : (this.channel && this.channel[id] ? this.channel[id] : this); 306 | const instrument = TinySynth.soundFont[TinySynth.instrument[ch.instrument]]; 307 | const osc = new Array(instrument.length); 308 | const gain = new Array(instrument.length); 309 | const freq = new Array(instrument.length); 310 | const release = new Array(instrument.length); 311 | if (t < 0) t = this.actx.currentTime - t; 312 | else t = t < this.actx.currentTime ? this.actx.currentTime : t; 313 | // 共用的变量 314 | let out, A_rate, volume, o; 315 | for (let i = 0; i < instrument.length; i++) { 316 | const p = instrument[i]; 317 | if (p.g == 0) { // 0表明是发声的 318 | out = ch.out; 319 | A_rate = v * v / 16129; // 平方律设置归一化振幅 320 | freq[i] = f * p.t + p.f; 321 | } else if (p.g > 0) { // FM调制 322 | if (osc[p.g - 1].frequency) { 323 | out = osc[p.g - 1].frequency; 324 | A_rate = freq[p.g - 1]; 325 | } else { // 如果是噪声,则osc是一个bufferSource,没有frequency属性 326 | out = osc[p.g - 1].playbackRate; 327 | A_rate = freq[p.g - 1] / 440; 328 | } 329 | freq[i] = freq[p.g - 1] * p.t + p.f; 330 | } else { // AM调制 331 | out = gain[-p.g - 1].gain; 332 | A_rate = 1; 333 | } 334 | // 振荡器 波形 335 | if (p.w[0] == 'n') { // 噪声 336 | o = this.actx.createBufferSource(); 337 | o.buffer = this.noiseBuf[p.w]; 338 | o.loop = true; 339 | o.playbackRate.value = freq[i] / 440; 340 | if (p.p != 1) o.playbackRate.setTargetAtTime(freq[i] * p.p / 440, t, p.q); 341 | } else { 342 | o = this.actx.createOscillator(); 343 | o.frequency.value = freq[i]; 344 | if (p.p != 1) o.frequency.setTargetAtTime(freq[i] * p.p, t, p.q) 345 | if (p.w[0] == 'w') o.setPeriodicWave(this.wave[p.w]); 346 | else o.type = p.w; 347 | } osc[i] = o; 348 | 349 | volume = A_rate * p.v; 350 | if (p.k) volume *= Math.pow(f / 261.6, p.k); // 261.6是中央C的频率 k一般是负数,表示音越高,音量越小 351 | release[i] = p.r; 352 | 353 | const g = this.actx.createGain(); 354 | if (p.a) { // 包络的A 355 | g.gain.value = 0; 356 | g.gain.setValueAtTime(0, t); 357 | g.gain.linearRampToValueAtTime(volume, t + p.a); 358 | } else g.gain.setValueAtTime(volume, t); 359 | // 包络的H、D和S 360 | g.gain.setTargetAtTime(p.s * volume, t + p.a + p.h, p.d); 361 | gain[i] = g; 362 | 363 | o.connect(g); g.connect(out); o.start(t); 364 | } 365 | const note = { // 用于停止 366 | ch: ch, 367 | end: t + last, // end表示结束时间,恒大于零,如果小于零表示已经停止(手动停止),不需要再次停止 368 | gain: gain, osc: osc, 369 | release: release 370 | }; 371 | this.notes.push(note); 372 | return note; 373 | } 374 | stop(nt, t = 0) { 375 | if (t < 0) t = this.actx.currentTime - t; 376 | else t = t < this.actx.currentTime ? this.actx.currentTime : t; 377 | let promises = nt.osc.map((osc, i) => { 378 | return new Promise(resolve => { 379 | osc.onended = resolve; 380 | nt.gain[i].gain.cancelScheduledValues(t); 381 | // 包络的R 382 | nt.gain[i].gain.setTargetAtTime(0, t, nt.release[i]); 383 | osc.stop(t + nt.release[i]); 384 | nt.gain[i].gain.cancelScheduledValues(t + nt.release[i]); 385 | }); 386 | }); 387 | Promise.all(promises).then(() => { 388 | nt.end = -1; // 标记为已经停止 389 | }); // 在所有osc都结束后的操作 390 | } 391 | checkStop() { // 自动回收 一直开启 392 | const t = this.actx.currentTime; 393 | for (let i = this.notes.length - 1; i >= 0; i--) { 394 | const nt = this.notes[i]; 395 | if (nt.end < t) { 396 | if (nt.end > 0) this.stop(nt); // 手动停止则end<0但仍然留在notes中,不需要再次停止,直接删除 397 | this.notes.splice(i, 1); 398 | } 399 | } 400 | } 401 | stopAll() { 402 | for (let i = this.notes.length - 1; i >= 0; i--) { 403 | this.stop(this.notes[i]); 404 | } this.notes.length = 0; 405 | } 406 | } -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # 在线扒谱应用设计 2 | 1. 获取时域数据(Web Audio API 解码上传的音频文件)【done】 3 | 2. 获取频域信息(FFT类)【done】 4 | 采样率设置为44100,取8192点的实数FFT,分析范围:C1-B7,但点数限制只能区分F#2以上的音符。 5 | 3. 特征提取:提取84个音符的幅度。 6 | 粗糙地实现了。思路是只在需要的频率附近查找。 7 | 面临三个问题: 8 | 3.1. 频谱泄露如何处理 9 | 3.2. 最高到22050Hz,但是音符最高3950Hz,取到C8,即只需777点。后面的是否保留? 10 | 3.3. 有的音乐中心频率不是正好440Hz,是否需要自适应?和频谱泄露处理有关。 11 | ——目前的解决方案:在相邻音周围求平方和。因为有频谱泄露,所以需要收集泄露的频谱的能量。而频谱泄露是对称的,所以相邻音中间的频谱对半分。自适应不实现,因为上述解决方案对音不准有一定的适应能力(音乐高适应性越强,但低音容易出现误判)。每次处理以音符为单位,只搜索周围的能量,所以后面的没用到,随垃圾回收而释放。 12 | 4. 画图。交互 13 | todo。面临问题: 14 | 4.1. 实时刷新还是一次画完?——选择实时刷新,用无限画布的思路 15 | 4.2. 幅度达到多少认为完全可信?——手调吧。设置一个参数。 16 | 功能:是否自动跟随? 17 | 5. 播放音乐和midi 18 | todo。问题很多,主要是前端的midi播放。在后文列举。 19 | 20 | ## 边角功能: 21 | 文件拖拽上传。done 22 | 自动识别音符?比如利用频谱后面的内容,分辨出谐波 23 | 24 | ## 画图要点 25 | 无限画布 https://blog.csdn.net/shulianghan/article/details/120863626 26 | 27 | ## 键盘画图 28 | C1对应24 全部按照midi协议编号。 29 | 以一个八度作为最小绘制单元,目前实现的绘图有所冗余,性能未达最优,但是懒得改了。 30 | 31 | ## midi可视化创建 32 | 关键是如何响应鼠标操作!用状态代替动作 33 | 描述一个midi音符:音高,起始,终止,是否选中 34 | 35 | 效果描述: 36 | 鼠标按下: 37 | - 如果按在空白则新建音符,进入调整时长模式 38 | - 如果按在音符上: 39 | ctrl是否按下? 40 | - 按下:选择多个 41 | - 没按下:是否已经选择了多个? 42 | - 是:鼠标抬起的时候,如果之前有拖动则什么也不做,否则仅选中当前那个 43 | - 否:仅选一个 44 | 判断点击位置: 45 | - 前一半:后面的是拖动模式 46 | - 后一半:后面的是调整时长模式 47 | 无论是那种模式,都支持音高上的调整 48 | 49 | 如何添加音符? 50 | 1. 点击的时候确认位置,已经添加进去了。 51 | 2. 设置这个音符为选中,模式是调整时长。 52 | 53 | 选中有两个方案: 54 | 1. 设置一个选中列表,存选中的midi音符对象的地址 55 | 有一个难点:绘制的时候如何让选中的不绘制两次? 56 | 2. 每个音符设置一个选中状态 57 | 有一个难点:每次调整音符状态的时候,都需要遍历所有音符 58 | 59 | 结论是:两者结合,空间换时间。 60 | 61 | 播放如何同步? 62 | 63 | ## 多音轨 64 | 此功能似乎用得不多。 65 | wavetone中,每个音轨之间不存在明显的界限,而signal中,只有选中的音轨可以点击音符。 66 | 我觉得前者适合,可以加一个mute、visible选项控制音轨以达到signal中的效果 67 | 数据结构? 68 | ### midi音符的结构 69 | 两个方案:所有轨都在一个数组中,和每轨一个数组,或者……两者结合 70 | - 所有轨都在一个数组:可以一次遍历实现音符拾取,绘制也只要一次遍历 71 | - 每轨一个数组:可以方便地实现单轨样式的应用,更改音轨顺序容易 72 | - 两者结合:维护较为麻烦 73 | 需要实现的功能: 74 | - 撤销重做: 用一个数组合适 75 | - 单音轨播放(静音、乐器):其实也是一个数组简单,因为播放的时候只需要维护一次遍历 76 | - 多音轨拖拽:音符拾取是单音轨简单。拖拽影响的是selected数组,两者平局 77 | 综上,存放音符还是单个数组合适。要实现以上功能,只需要给音符加一个channel属性,而每个音轨的设置需要维护一个数组。 78 | 79 | ### 音轨的结构 80 | 音轨的添加采用动态添加的方式还是静态?wavetone是静态,最大16。我做动态吧。 81 | 动态音轨涉及很多ui的东西:音轨的位置(设计为可拖拽排序)、属性设置 82 | 需要暴露的接口: 83 | - 音轨变化事件(顺序、个数):用于触发存档点,目前考虑封装成Event 84 | - 音轨状态(颜色、当前选中) 85 | - 序列化音轨、根据音轨参数数组创建音轨:用于实现音轨的快照 86 | ChannelList的音轨列表及其属性似乎不需要暴露 87 | 下一步推进的关键: 88 | MidiPlayer!需要成为ChannelList和ChannelItem的公共可访问对象,然后完成ui和数据的绑定。ChannelItem的instrument是否需要用序号代替? 89 | 耦合关系: 90 | MidiAction监听ChannelList的事件,而ChannelList不监听MidiAction,但受其控制 91 | - ChannelList->音轨变化(顺序、个数)->MidiAction&MidiPlayer 92 | 如何传递这个变化?改变顺序用reorder事件,删除用remove事件,添加似乎不涉及midi音符的操作。【修正:新增channel也需要事件,用于存档】 93 | 删除一个channel时,先触发remove,再触发reorder,remove事件用于删除midi列表对应通道的音符,reorder用于更改剩下的音符的音轨序号 94 | 由于reorder只在序号发生变化时触发,如果是最后一个删除或添加就不会触发,这意味着对此事件监听不能响应所有变化,那如何设置存档点?存档单独注册一个reorder的监听,add/remove前先取消注册,由add/remove自行设置存档,操作结束再注册回来,防止两次存档。 95 | - ChannelList->音轨音量改变->MidiPlayer 96 | - ChannelList<-选中音符<-MidiAction 97 | 98 | ### 撤销 99 | 本想改了什么存什么(以节省内存),但是没存的会丢失当前信息,所以必须midi和channel都存快照。 100 | 101 | ### 绘制 102 | 使用多音轨后,绘制逻辑需要改变 103 | 重叠:序号越低的音轨图层越上 & 选中的音轨置于顶层?——还是不要后者了。后者可以通过移动音轨实现 104 | 由于scroll相比刷新是稀疏的,可以维护一个“视野中的音符”列表insight,更新时机: 105 | 1. channelDiv的reorder 106 | 2. midi的增删移动改变长度。由于都会调用且最后调用changeNoteY,所以只需要在changeNoteY中调用 107 | 3. scroll2 108 | 4. deleteNote 109 | 5. ctrlZ、ctrlY 110 | 为了实现小序号音轨在上层,insight是一个列表的列表,每个列表中是一个音轨的视野内的音符。绘制的时候,倒序遍历绘制,同时查询是否显示。 111 | 112 | ## 音符播放技术要点 113 | 参考 https://github.com/g200kg/webaudio-tinysynth 完成了精简版的合成器,相比原版,有如下变化: 114 | - 抽象为类,音色变成static属性。 115 | - 用animationFrame而非timeInterval实现了音符检查与停止。 116 | - 为了契合“动态音轨”的设计,合成器中以channel为单位组织数组,而非原版以audioNode为单位。 117 | - 每个音轨的原型都是合成器,故可以单独拿出来使用。 118 | - 没有做成midi的形式,音符频率依赖外部传参 119 | - 没有实现通道的调制、左右声道、混响。 120 | - 没有做鼓的音色。 121 | 122 | ## 音频播放 123 | 使用