PolyMeter — slim main: landing chooser + mobile app + notation editor
Clean, dependency-light front page. Only three things ship here: - index.html — two-button landing: Mobile -> mobile.html, Desktop -> pm_e-2.html - mobile.html — touch-first PWA (+ mobile-sessions.html practice journal) - pm_e-2.html — engraved-notation editor build.sh/deploy.sh trimmed to just these; deploy mirrors dist/ to the web root with rsync --delete. README/CLAUDE.md rewritten for the slim scope. The full project (PM_E-1 editor, embeddable widget, all hardware form-factor pages, Pico firmware editions, the Rust port, and the KiCad/SPICE hardware design) is preserved on the `concepts` branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5ab2096fc4
30 changed files with 4999 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Build output — assembled from index.html + assets/ by build.sh
|
||||
dist/
|
||||
tools/
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file guides Claude Code (claude.ai/code) when working in this repository.
|
||||
|
||||
This is the **slim `main` branch** of VARASYS PolyMeter — a polymetric groove-trainer /
|
||||
metronome. Only three things ship here:
|
||||
|
||||
- `index.html` — the landing chooser: two buttons, **Mobile** → `mobile.html` and
|
||||
**Desktop** → `pm_e-2.html`.
|
||||
- `mobile.html` — the touch-first phone/tablet **PWA** (+ `mobile-sessions.html`, its practice
|
||||
journal). Installable, works offline via `mobile-sw.js` + `manifest.webmanifest`.
|
||||
- `pm_e-2.html` — the engraved-notation editor.
|
||||
|
||||
The **full project** (the PM_E-1 editor, the embeddable widget, every hardware form-factor page,
|
||||
the Pico **firmware** editions, the **Rust** port, and the **KiCad/SPICE hardware** design) lives
|
||||
on the **`concepts`** branch. Pull anything back from there if it needs to return to the front page.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
./build.sh # assemble the self-contained pages into dist/ (git-ignored)
|
||||
./deploy.sh # build, stamp version, mirror dist/ to the Caddy web root, smoke-test
|
||||
```
|
||||
|
||||
There's no test suite on this branch (the track-format conformance suite lives on `concepts`).
|
||||
|
||||
## Build system
|
||||
|
||||
Every deployed page is **one self-contained `.html` file, zero runtime dependencies** — no
|
||||
framework, no CDN, no audio samples (all voices are synthesized in Web Audio). Pages share code
|
||||
through build markers that `build.sh` resolves:
|
||||
|
||||
- `/*@BUILD:include:src/<file>@*/` inlines a shared partial (`engine.js`, `setlists.js`,
|
||||
`base.css`, `chrome.js`, `header.html`/`footer.html`, `notation.js`, `midiout.js`).
|
||||
- `@BUILD:favicon@` / `@BUILD:logo-*@` / `@BUILD:bravura@` inline base64 blobs from `assets/`.
|
||||
|
||||
`build.sh` asserts no `@BUILD:` markers survive. **`dist/` is generated and git-ignored — never
|
||||
edit it by hand.** Edit the source `*.html` in the repo root and the partials in `src/`;
|
||||
`deploy.sh` always builds first, then mirrors `dist/` to the web root with `rsync --delete`
|
||||
(so anything no longer built is removed from the live site).
|
||||
|
||||
State (set lists, practice log, theme) lives in `localStorage`; nothing is uploaded — share
|
||||
links encode everything in the URL hash (`#p=` patch, `#sl=` base64url set list). Source files
|
||||
keep an `APP_VERSION` placeholder; only the deployed copy is stamped (from `VERSION`).
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3 (`LICENSE`).
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# VARASYS PolyMeter
|
||||
|
||||
A **polymetric groove trainer & metronome**. Stack as many "meter lanes" as you like —
|
||||
each its own little metronome with a grouping, subdivision, drum voice and a per‑step
|
||||
pattern with accents. Layering lanes produces polymeter and true ratio polyrhythm.
|
||||
|
||||
**Live:** https://metronome.varasys.io · **Source:** https://codeberg.org/VARASYS/metronome
|
||||
|
||||
## What ships here
|
||||
|
||||
The landing page (`/`) is a simple chooser with two doors:
|
||||
|
||||
- **Mobile** → `mobile.html` — the touch‑first phone/tablet app (tap a beat, set the tempo,
|
||||
practice). It's an installable **PWA** that works fully offline, with a practice journal
|
||||
(`mobile-sessions.html`).
|
||||
- **Desktop** → `pm_e-2.html` — the engraved‑notation editor: build rhythms on a staff with
|
||||
full keyboard control. Best on a large screen.
|
||||
|
||||
Every **deployed page is a single, self‑contained `.html` file** with **zero runtime
|
||||
dependencies** — no framework, no CDN, nothing fetched at runtime. `build.sh` inlines a shared
|
||||
engine, the seed set lists, base styling and the brand assets (`assets/`) into each page. Every
|
||||
voice is **synthesized** in Web Audio (no audio samples). State (set lists, the practice log,
|
||||
theme) lives in `localStorage`; share links encode everything in the URL hash — nothing is
|
||||
uploaded.
|
||||
|
||||
## Build & deploy
|
||||
|
||||
```sh
|
||||
./build.sh # assemble the self-contained pages into dist/ (git-ignored)
|
||||
./deploy.sh # build, stamp version, mirror dist/ to the Caddy web root, smoke-test
|
||||
```
|
||||
|
||||
## The `concepts` branch
|
||||
|
||||
This `main` branch is intentionally lean. The **full project** — the PM_E‑1 editor, the
|
||||
embeddable widget, the whole gallery of hardware **form‑factor concepts**, the Raspberry Pi
|
||||
Pico **firmware** editions, the **Rust** port, and the **KiCad/SPICE hardware** design — lives
|
||||
on the [`concepts`](https://codeberg.org/VARASYS/metronome/src/branch/concepts) branch. It can
|
||||
be promoted back to the front page at any time.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL v3](./LICENSE) © VARASYS.
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.0.68
|
||||
1
assets/bravura.woff2.b64
Normal file
1
assets/bravura.woff2.b64
Normal file
File diff suppressed because one or more lines are too long
1
assets/favicon.b64
Normal file
1
assets/favicon.b64
Normal file
|
|
@ -0,0 +1 @@
|
|||
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzFDMjgzRiIvPjxwYXRoIGQ9Ik0xMiA2aDhsNCAyMUg4eiIgZmlsbD0iIzBBQjNGNyIvPjxwYXRoIGQ9Ik0xNiAyNEwxOS42IDkiIHN0cm9rZT0iIzFDMjgzRiIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjxyZWN0IHg9IjE2LjQiIHk9IjEzLjIiIHdpZHRoPSI0LjQiIGhlaWdodD0iMi44IiByeD0iMC42IiB0cmFuc2Zvcm09InJvdGF0ZSgtMTMgMTguNiAxNC42KSIgZmlsbD0iIzFDMjgzRiIvPjwvc3ZnPgo=
|
||||
BIN
assets/icon-180.png
Normal file
BIN
assets/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/icon-192.png
Normal file
BIN
assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/icon-512.png
Normal file
BIN
assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
1
assets/logo-dark.b64
Normal file
1
assets/logo-dark.b64
Normal file
File diff suppressed because one or more lines are too long
1
assets/logo-light.b64
Normal file
1
assets/logo-light.b64
Normal file
File diff suppressed because one or more lines are too long
1
assets/logo-side-dark.b64
Normal file
1
assets/logo-side-dark.b64
Normal file
File diff suppressed because one or more lines are too long
1
assets/logo-side-light.b64
Normal file
1
assets/logo-side-light.b64
Normal file
File diff suppressed because one or more lines are too long
49
build.sh
Executable file
49
build.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# Assemble the deployed single-file pages from source + shared partials + assets/.
|
||||
#
|
||||
# Each page is a source that shares code via markers:
|
||||
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS, header/footer/chrome)
|
||||
# @BUILD:favicon@ / @BUILD:logo-*@ / @BUILD:bravura@ inline base64 assets
|
||||
# This resolves them so each built page in dist/ is one self-contained file
|
||||
# (zero deps, works fully offline). deploy.sh runs this first. dist/ is generated —
|
||||
# don't edit or commit it.
|
||||
#
|
||||
# NOTE: this is the slim `main` branch — only the landing chooser, the mobile app
|
||||
# (+ its practice journal) and the pm_e-2 notation editor ship. The full
|
||||
# multi-form-factor project (all device pages, firmware, Rust, hardware) lives on
|
||||
# the `concepts` branch.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
rm -rf dist && mkdir -p dist # start clean so no stale artifact survives into deploy's rsync --delete
|
||||
|
||||
python3 - <<'PY'
|
||||
import pathlib, re
|
||||
A = pathlib.Path("assets")
|
||||
|
||||
def build(name):
|
||||
src = pathlib.Path(name).read_text()
|
||||
# 1) inline shared partials (function-replacement: no backslash/group interpretation)
|
||||
src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/",
|
||||
lambda m: pathlib.Path(m.group(1)).read_text().rstrip("\n"), src)
|
||||
# 2) inline base64 assets (all voices are synthesized — no audio samples)
|
||||
src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-dark@", (A / "logo-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-side-dark@", (A / "logo-side-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-side-light@", (A / "logo-side-light.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:bravura@", (A / "bravura.woff2.b64").read_text().strip()) # SMuFL music font subset (PM_E-2 notation)
|
||||
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
|
||||
out = pathlib.Path("dist") / name
|
||||
out.write_text(src)
|
||||
return out.stat().st_size
|
||||
|
||||
for name in ("index.html", "mobile.html", "mobile-sessions.html", "pm_e-2.html"):
|
||||
print("built %s (%dKB)" % (name, build(name) // 1024))
|
||||
|
||||
# PWA support files for mobile.html (the phone/tablet app): manifest, service worker, icons.
|
||||
for f in ("manifest.webmanifest", "mobile-sw.js"):
|
||||
pathlib.Path("dist/" + f).write_text(pathlib.Path(f).read_text())
|
||||
for f in ("icon-192.png", "icon-512.png", "icon-180.png"):
|
||||
pathlib.Path("dist/" + f).write_bytes((A / f).read_bytes())
|
||||
print("copied PWA files (manifest.webmanifest, mobile-sw.js, icon-{192,512,180}.png)")
|
||||
PY
|
||||
63
deploy.sh
Executable file
63
deploy.sh
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy the metronome to the Caddy web root that serves
|
||||
# https://metronome.varasys.io
|
||||
#
|
||||
# Caddy config: /var/lib/caddy/Caddyfile (metronome.varasys.io:8443 block)
|
||||
# Bind-mount: /etc/containers/systemd/caddy.container
|
||||
#
|
||||
# The web root is bind-mounted read-only into the Caddy container and served by
|
||||
# file_server, which picks up changes immediately — so a plain file copy is all
|
||||
# that's needed (no container restart). The web root is mirrored from dist/ with
|
||||
# `rsync --delete`, so anything no longer built is removed from the live site.
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEST_DIR="/var/lib/caddy/www/metronome"
|
||||
DIST_DIR="$SRC_DIR/dist"
|
||||
|
||||
[[ -f "$SRC_DIR/index.html" ]] || { echo "error: $SRC_DIR/index.html not found" >&2; exit 1; }
|
||||
[[ -d "$DEST_DIR" ]] || { echo "error: web root $DEST_DIR is missing — is Caddy set up?" >&2; exit 1; }
|
||||
|
||||
# Assemble the self-contained pages (inlines assets/ into dist/). dist/ is git-ignored.
|
||||
"$SRC_DIR/build.sh"
|
||||
[[ -f "$DIST_DIR/index.html" ]] || { echo "error: build did not produce $DIST_DIR/index.html" >&2; exit 1; }
|
||||
|
||||
# --- compute build version ---------------------------------------------------
|
||||
# Formal build: clean tree on a commit tagged v<VERSION> -> "X.Y.Z"
|
||||
# Dev build: anything else -> "X.Y.Z-dev.<utc-ts>.<sha>[.dirty]"
|
||||
VER="$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo 0.0.0)"
|
||||
cd "$SRC_DIR"
|
||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
tag="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
|
||||
dirty=""; [[ -n "$(git status --porcelain 2>/dev/null)" ]] && dirty=".dirty"
|
||||
if [[ "$tag" == "v$VER" && -z "$dirty" ]]; then
|
||||
BUILD="$VER" # formal release
|
||||
else
|
||||
BUILD="$VER-dev.$(date -u +%Y%m%dT%H%M%SZ).g$(git rev-parse --short HEAD 2>/dev/null || echo nogit)$dirty"
|
||||
fi
|
||||
else
|
||||
BUILD="$VER-dev.$(date -u +%Y%m%dT%H%M%SZ)" # not a git checkout
|
||||
fi
|
||||
|
||||
# stamp the version into the built copies only (source keeps the APP_VERSION placeholder)
|
||||
for f in index.html mobile.html mobile-sessions.html pm_e-2.html; do
|
||||
[[ -f "$DIST_DIR/$f" ]] || continue
|
||||
tmp="$(mktemp)"
|
||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$tmp"
|
||||
mv "$tmp" "$DIST_DIR/$f"
|
||||
done
|
||||
|
||||
# Mirror dist/ -> web root, deleting anything that's no longer built (old pages, firmware, …)
|
||||
rsync -a --delete "$DIST_DIR/" "$DEST_DIR/"
|
||||
echo "deployed v$BUILD -> $DEST_DIR"
|
||||
for f in index.html mobile.html mobile-sessions.html pm_e-2.html; do
|
||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||
done
|
||||
|
||||
# Smoke test: Caddy serves on :8443 with tls internal; resolve the host
|
||||
# to localhost so SNI matches the site block.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
code=$(curl -sk --resolve metronome.varasys.io:8443:127.0.0.1 \
|
||||
https://metronome.varasys.io:8443/ -o /dev/null -w '%{http_code}' || echo "??")
|
||||
echo "smoke test: metronome.varasys.io -> HTTP $code"
|
||||
fi
|
||||
69
index.html
Normal file
69
index.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PolyMeter</title>
|
||||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||
<script>
|
||||
(function(){ try{
|
||||
var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||||
--card:#161b22; --card-bd:#2a313c; --cyan:#0AB3F7; }
|
||||
:root[data-theme="light"]{ --bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||||
--card:#ffffff; --card-bd:#d2dae4; --cyan:#0AB3F7; }
|
||||
html,body{ height:100%; }
|
||||
body{ margin:0; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
|
||||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; }
|
||||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||
main{ min-height:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:clamp(20px,4vmin,40px); padding:max(24px,env(safe-area-inset-top)) 22px max(24px,env(safe-area-inset-bottom)); text-align:center; }
|
||||
.logo{ height:clamp(34px,7vmin,56px); width:auto; }
|
||||
.tagline{ color:var(--muted); font-size:clamp(13px,2.4vmin,17px); letter-spacing:.01em; margin:-6px 0 0; max-width:30ch; line-height:1.4; }
|
||||
.choices{ display:flex; flex-wrap:wrap; gap:clamp(16px,3vmin,28px); justify-content:center; width:100%; max-width:620px; }
|
||||
.choice{ flex:1 1 240px; min-width:0; max-width:300px; text-decoration:none; color:var(--txt);
|
||||
background:linear-gradient(180deg, rgba(127,139,154,.07), transparent), var(--card);
|
||||
border:1px solid var(--card-bd); border-radius:18px; padding:clamp(22px,4vmin,34px) 20px;
|
||||
display:flex; flex-direction:column; align-items:center; gap:10px;
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.18); transition:transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
|
||||
.choice:hover, .choice:focus-visible{ border-color:var(--cyan); transform:translateY(-3px); box-shadow:0 12px 30px rgba(10,179,247,.18); outline:none; }
|
||||
.choice:active{ transform:translateY(0); }
|
||||
.choice .ic{ font-size:clamp(34px,7vmin,52px); line-height:1; }
|
||||
.choice .lbl{ font-size:clamp(19px,3.4vmin,26px); font-weight:700; letter-spacing:.01em; }
|
||||
.choice .sub{ font-size:clamp(11px,2vmin,13px); color:var(--muted); line-height:1.4; }
|
||||
footer{ color:var(--muted); font-size:12px; line-height:1.7; }
|
||||
footer a{ color:var(--link); text-decoration:none; }
|
||||
footer a:hover{ text-decoration:underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img class="logo logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS PolyMeter" />
|
||||
<img class="logo logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS PolyMeter" />
|
||||
<p class="tagline">A polymetric groove trainer & metronome. Pick how you want to play.</p>
|
||||
<div class="choices">
|
||||
<a class="choice" href="/mobile.html">
|
||||
<span class="ic" aria-hidden="true">📱</span>
|
||||
<span class="lbl">Mobile</span>
|
||||
<span class="sub">Touch-first phone & tablet app — tap a beat, set the tempo, practice. Installable, works offline.</span>
|
||||
</a>
|
||||
<a class="choice" href="/pm_e-2.html">
|
||||
<span class="ic" aria-hidden="true">🎼</span>
|
||||
<span class="lbl">Desktop</span>
|
||||
<span class="sub">Engraved-notation editor — build rhythms on a staff with full keyboard control. Best on a big screen.</span>
|
||||
</a>
|
||||
</div>
|
||||
<footer>
|
||||
VARASYS PolyMeter · <a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">source on Codeberg</a>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
18
manifest.webmanifest
Normal file
18
manifest.webmanifest
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "VARASYS PolyMeter",
|
||||
"short_name": "PolyMeter",
|
||||
"description": "Polymetric groove-trainer & metronome — touch-first, full-screen.",
|
||||
"id": "/mobile.html",
|
||||
"start_url": "/mobile.html?standalone=1",
|
||||
"scope": "/mobile",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "fullscreen"],
|
||||
"orientation": "any",
|
||||
"background_color": "#05070a",
|
||||
"theme_color": "#0b0d11",
|
||||
"categories": ["music", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
214
mobile-sessions.html
Normal file
214
mobile-sessions.html
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>VARASYS PolyMeter — Practice sessions</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
|
||||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||
<script>
|
||||
(function(){ try{
|
||||
var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{
|
||||
--bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||||
--cyan:#0AB3F7; --amber:#ffd166; --row:#0e1218;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --row:#f4f7fb;
|
||||
}
|
||||
html,body{ height:100%; }
|
||||
body{ margin:0; min-height:100%; color:var(--txt); background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
|
||||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; overscroll-behavior-y:none; }
|
||||
.wrap{ max-width:760px; margin:0 auto;
|
||||
padding:max(10px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(28px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left)); }
|
||||
header{ display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:5; padding:8px 0 10px;
|
||||
background:linear-gradient(180deg, var(--bg1) 70%, transparent); }
|
||||
.back{ flex:0 0 auto; display:flex; align-items:center; gap:6px; text-decoration:none; color:var(--txt);
|
||||
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); border-radius:10px; padding:9px 13px; font-size:15px; }
|
||||
header h1{ flex:1 1 auto; font-size:18px; margin:0; }
|
||||
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||
font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
|
||||
|
||||
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
|
||||
.seclabel{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:6px 2px 8px; }
|
||||
|
||||
/* current-track aggregate (compare across sessions) */
|
||||
#trackAgg .ta-head{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:4px; }
|
||||
#taSel{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
|
||||
border-radius:10px; padding:9px 8px; font-size:16px; font-weight:600; }
|
||||
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
|
||||
.stat b{ color:var(--txt); }
|
||||
|
||||
table{ width:100%; border-collapse:collapse; font-size:14px; }
|
||||
th,td{ text-align:left; padding:8px 8px; border-bottom:1px solid var(--panel-bd); }
|
||||
th{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; }
|
||||
td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
|
||||
tr:nth-child(even) td{ background:var(--row); }
|
||||
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; }
|
||||
|
||||
/* session list — collapsible */
|
||||
details.sess{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; margin-bottom:12px; overflow:hidden; }
|
||||
details.sess > summary{ list-style:none; cursor:pointer; padding:13px 14px; display:flex; flex-direction:column; gap:3px; }
|
||||
details.sess > summary::-webkit-details-marker{ display:none; }
|
||||
summary .when{ font-size:15px; font-weight:600; display:flex; align-items:center; gap:9px; }
|
||||
summary .when::before{ content:"▸"; color:var(--muted); font-size:13px; transition:transform .15s; }
|
||||
details[open] > summary .when::before{ transform:rotate(90deg); }
|
||||
summary .sstat{ font-size:12px; color:var(--muted); padding-left:22px; }
|
||||
summary .sstat b{ color:var(--txt); }
|
||||
.sbody{ padding:2px 14px 14px; }
|
||||
.sbody .brow{ display:flex; justify-content:flex-end; margin-bottom:10px; }
|
||||
.del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
|
||||
.del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
|
||||
textarea.note{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px;
|
||||
padding:10px; font-family:inherit; font-size:14px; resize:vertical; min-height:46px; margin-bottom:12px; }
|
||||
|
||||
.empty{ text-align:center; color:var(--muted); padding:60px 16px; }
|
||||
.empty .big{ font-size:46px; opacity:.5; }
|
||||
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
|
||||
.foot img{ height:16px; vertical-align:middle; opacity:.85; }
|
||||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<a class="back" href="/mobile.html">‹ Metronome</a>
|
||||
<h1>Practice sessions</h1>
|
||||
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme">◐</div>
|
||||
</header>
|
||||
|
||||
<div id="trackAgg" class="card" style="display:none">
|
||||
<div class="seclabel">This track — across all sessions</div>
|
||||
<div class="ta-head"><select id="taSel"></select></div>
|
||||
<div class="stat" id="taStat"></div>
|
||||
<table id="taTable"></table>
|
||||
</div>
|
||||
|
||||
<div class="seclabel" id="listLabel" style="display:none">Sessions</div>
|
||||
<div id="list"></div>
|
||||
|
||||
<div class="foot">
|
||||
<img class="logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" />
|
||||
PolyMeter <span id="appVersion"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const LS_SESSIONS = "metronome.sessions", LS_CURTRACK = "metronome.curtrack";
|
||||
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
|
||||
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
|
||||
function lsGetRaw(k){ try{ return localStorage.getItem(k)||""; }catch(e){ return ""; } }
|
||||
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
|
||||
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
|
||||
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||||
function whenLong(ms){ const d=new Date(ms);
|
||||
const wd=d.toLocaleDateString(undefined,{weekday:"short"}), mo=d.toLocaleDateString(undefined,{month:"short"});
|
||||
const t=d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"});
|
||||
return wd+" "+mo+" "+d.getDate()+" at "+t; } // "Fri Jun 16 at 2:46 PM"
|
||||
function whenShort(ms){ const d=new Date(ms);
|
||||
return d.toLocaleDateString(undefined,{month:"short",day:"numeric"})+" · "+d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}); }
|
||||
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+"–"+hi; }
|
||||
|
||||
/* per-track aggregate of one session's segments → one row per track */
|
||||
function aggregate(seg){
|
||||
const by={};
|
||||
(seg||[]).forEach((s)=>{ const k=s.name||"(unnamed)"; const a=by[k]||(by[k]={name:k,sec:0,plays:0,bpms:[]}); a.sec+=s.sec; a.plays++; if(s.bpm) a.bpms.push(s.bpm); });
|
||||
return Object.values(by).sort((x,y)=>y.sec-x.sec);
|
||||
}
|
||||
/* one track across all sessions → one row per session that included it (newest first) */
|
||||
function trackRows(name){
|
||||
const out=[];
|
||||
lsGet(LS_SESSIONS,[]).forEach((s)=>{
|
||||
let sec=0, plays=0, bpms=[];
|
||||
(s.segments||[]).forEach((g)=>{ if((g.name||"(unnamed)")===name){ sec+=g.sec; plays++; if(g.bpm) bpms.push(g.bpm); } });
|
||||
if(sec>0) out.push({ at:s.at, sec, plays, bpms });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function allTrackNames(){ const set=new Set(); lsGet(LS_SESSIONS,[]).forEach((s)=>(s.segments||[]).forEach((g)=>set.add(g.name||"(unnamed)"))); return [...set].sort((a,b)=>a.localeCompare(b)); }
|
||||
|
||||
/* ----- top: current-track comparison across sessions ----- */
|
||||
let selTrack=null;
|
||||
function renderTrackAgg(){
|
||||
const names=allTrackNames();
|
||||
if(!names.length){ $("trackAgg").style.display="none"; return; }
|
||||
$("trackAgg").style.display="";
|
||||
const cur=lsGetRaw(LS_CURTRACK).replace(/^"|"$/g,""); // stored JSON-encoded string
|
||||
if(selTrack===null) selTrack = names.includes(cur) ? cur : names[0];
|
||||
if(!names.includes(selTrack)) selTrack=names[0];
|
||||
// selector
|
||||
const sel=$("taSel"); sel.innerHTML="";
|
||||
names.forEach((n)=>{ const o=document.createElement("option"); o.value=n; o.textContent=n+(n===cur?" (current)":""); sel.appendChild(o); });
|
||||
sel.value=selTrack;
|
||||
// stats + per-session table
|
||||
const rows=trackRows(selTrack);
|
||||
const totSec=rows.reduce((a,r)=>a+r.sec,0), totPlays=rows.reduce((a,r)=>a+r.plays,0), allb=rows.flatMap((r)=>r.bpms);
|
||||
$("taStat").innerHTML = rows.length+" session"+(rows.length===1?"":"s")+" · total <b>"+fmt(totSec)+"</b> · "+totPlays+" play"+(totPlays===1?"":"s")+" · "+bpmRange(allb)+" bpm";
|
||||
$("taTable").innerHTML =
|
||||
'<thead><tr><th>Session</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||||
(rows.length ? rows.map((r)=>'<tr><td>'+esc(whenShort(r.at))+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")
|
||||
: '<tr><td colspan="4" style="color:var(--muted)">No sessions for this track yet.</td></tr>')+
|
||||
'</tbody>';
|
||||
}
|
||||
$("taSel").addEventListener("change",(e)=>{ selTrack=e.target.value; renderTrackAgg(); });
|
||||
|
||||
/* ----- session list (collapsible) ----- */
|
||||
function renderList(){
|
||||
const sessions=lsGet(LS_SESSIONS,[]);
|
||||
const list=$("list"); list.innerHTML="";
|
||||
if(!sessions.length){
|
||||
$("listLabel").style.display="none";
|
||||
list.innerHTML='<div class="empty"><div class="big">𝄞</div><p>No practice sessions yet.<br>On the metronome, press <b>Practice</b> to start a session, then <b>Stop</b> when you\'re done — it\'ll be saved here.</p></div>';
|
||||
return;
|
||||
}
|
||||
$("listLabel").style.display="";
|
||||
sessions.forEach((s)=>{
|
||||
const rows=aggregate(s.segments), practiced=rows.reduce((a,r)=>a+r.sec,0);
|
||||
const d=document.createElement("details"); d.className="sess";
|
||||
d.innerHTML =
|
||||
'<summary><span class="when">'+esc(whenLong(s.at))+'</span>'+
|
||||
'<span class="sstat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</span></summary>'+
|
||||
'<div class="sbody">'+
|
||||
'<div class="brow"><button class="del">Delete session</button></div>'+
|
||||
'<textarea class="note" placeholder="Add a note about this session — what you worked on, how it felt…">'+esc(s.note||"")+'</textarea>'+
|
||||
'<table><thead><tr><th>Track</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||||
rows.map((r)=>'<tr><td>'+esc(r.name)+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")+
|
||||
'</tbody><tfoot><tr><td>Total</td><td class="num">'+fmt(practiced)+'</td><td class="num">'+rows.reduce((a,r)=>a+r.plays,0)+'</td><td class="num"></td></tr></tfoot></table>'+
|
||||
'</div>';
|
||||
d.querySelector(".note").addEventListener("input",(e)=>saveNote(s.at, e.target.value));
|
||||
d.querySelector(".del").addEventListener("click",()=>{
|
||||
if(!confirm("Delete this practice session? This can't be undone.")) return;
|
||||
lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
|
||||
});
|
||||
list.appendChild(d);
|
||||
});
|
||||
}
|
||||
let saveTimer=null;
|
||||
function saveNote(at, text){
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer=setTimeout(()=>{ const arr=lsGet(LS_SESSIONS,[]); const it=arr.find((x)=>x.at===at); if(it){ it.note=text; lsSet(LS_SESSIONS,arr); } }, 300);
|
||||
}
|
||||
|
||||
function render(){ renderTrackAgg(); renderList(); }
|
||||
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
52
mobile-sw.js
Normal file
52
mobile-sw.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* Service worker for the PolyMeter mobile app (mobile.html).
|
||||
*
|
||||
* Deliberately minimal and non-intrusive: it only manages its OWN app-shell URLs
|
||||
* (the page, manifest, icons). For every other request it does NOT call
|
||||
* respondWith(), so the rest of the site behaves exactly as if no SW existed.
|
||||
*
|
||||
* Strategy for the shell: network-first, fall back to cache. The page is a single
|
||||
* self-contained file that is version-stamped on deploy, so when the device is
|
||||
* online it always gets the freshest build; offline it still launches from cache.
|
||||
*/
|
||||
const CACHE = "polymeter-mobile-v2";
|
||||
const SHELL = [
|
||||
"/mobile.html",
|
||||
"/mobile-sessions.html",
|
||||
"/manifest.webmanifest",
|
||||
"/icon-192.png",
|
||||
"/icon-512.png",
|
||||
"/icon-180.png",
|
||||
];
|
||||
const SHELL_PATHS = new Set(SHELL);
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
self.skipWaiting();
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).catch(() => {}));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Treat any navigation to /mobile.html (with or without ?standalone=1 etc.) as the shell.
|
||||
const path = url.pathname;
|
||||
if (!SHELL_PATHS.has(path)) return; // not ours — let the browser handle it
|
||||
|
||||
e.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res && res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(path, copy)); }
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(path).then((hit) => hit || caches.match("/mobile.html")))
|
||||
);
|
||||
});
|
||||
951
mobile.html
Normal file
951
mobile.html
Normal file
|
|
@ -0,0 +1,951 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
|
||||
<title>VARASYS PolyMeter — Mobile</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
|
||||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||
|
||||
<script>
|
||||
window.EMBED = /[?&]embed=1/.test(location.search);
|
||||
if (window.EMBED) document.documentElement.dataset.embed = "1";
|
||||
</script>
|
||||
<script>
|
||||
(function(){ try{
|
||||
var p = localStorage.getItem("metronome.theme");
|
||||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||||
</script>
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{
|
||||
--bg1:#12151c; --bg2:#05070a;
|
||||
--txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||||
--cyan:#0AB3F7; --amber:#ffd166;
|
||||
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55); --poly:#bb8cff; --staff:rgba(199,208,219,.17);
|
||||
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--bg1:#eef3f9; --bg2:#cfd9e6;
|
||||
--txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
|
||||
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45); --poly:#7a3df0; --staff:rgba(28,40,63,.15);
|
||||
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
|
||||
}
|
||||
html,body{ height:100%; }
|
||||
body{ margin:0; overflow:hidden; color:var(--txt);
|
||||
background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2));
|
||||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
touch-action:manipulation; overscroll-behavior:none; }
|
||||
/* Content is capped to --maxw and centered, so phone→tablet is the SAME layout,
|
||||
just larger (no flex re-flow). Generous margins; even more in full-screen. */
|
||||
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column; align-items:center; --maxw:600px; --pad:14px;
|
||||
padding:max(12px,env(safe-area-inset-top)) max(var(--pad),env(safe-area-inset-right))
|
||||
max(12px,env(safe-area-inset-bottom)) max(var(--pad),env(safe-area-inset-left)); }
|
||||
#top, #mid{ width:100%; max-width:var(--maxw); }
|
||||
:fullscreen #app, html:fullscreen #app{ --pad:30px; padding-top:max(24px,env(safe-area-inset-top)); padding-bottom:max(18px,env(safe-area-inset-bottom)); }
|
||||
@media (display-mode:standalone){ #app{ --pad:26px; padding-top:max(20px,env(safe-area-inset-top)); padding-bottom:max(16px,env(safe-area-inset-bottom)); } }
|
||||
|
||||
/* ---- top ---- */
|
||||
/* the logo + header-icon row is always a full-width bar at the very top, in
|
||||
both orientations — it never joins the side-by-side landscape header flow */
|
||||
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:11px; }
|
||||
#brandrow{ flex:0 0 auto; display:flex; align-items:center; gap:10px; width:100%; }
|
||||
#logoLink{ display:inline-flex; opacity:.9; }
|
||||
.brandlogo{ height:clamp(21px,4.2vmin,30px); width:auto; display:block; }
|
||||
.hicons{ display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||||
.hicons .icon{ width:36px; height:36px; font-size:16px; }
|
||||
.sels{ width:100%; display:flex; gap:8px; align-items:flex-end; }
|
||||
.sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||
.sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; }
|
||||
.sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:10px 8px; font-size:15px; }
|
||||
.trow{ display:flex; align-items:center; gap:10px; }
|
||||
.vol{ width:100%; display:flex; align-items:center; gap:12px; color:var(--muted); min-width:0; }
|
||||
.vol input{ flex:1 1 auto; min-width:0; accent-color:var(--cyan); }
|
||||
.dyn{ flex:0 0 auto; font-family:Georgia,"Times New Roman",serif; font-style:italic; font-weight:700; font-size:17px; color:var(--muted); line-height:1; }
|
||||
.icon{ flex:0 0 auto; width:42px; height:42px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||
font-size:18px; line-height:1; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
|
||||
.icon:active{ background:rgba(127,139,154,.30); }
|
||||
|
||||
/* ---- middle: pulse + track panel + lanes + transport, centered as one block ---- */
|
||||
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:clamp(18px,4.2vmin,34px); padding-top:clamp(8px,1.8vmin,16px); }
|
||||
#stage{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; width:100%; }
|
||||
/* tempo plate: [TAP] [big BPM] [thumbwheel] — flashes on the beat */
|
||||
#pulse{ position:relative; width:100%; padding:clamp(8px,1.8vmin,14px); border-radius:16px;
|
||||
display:flex; flex-direction:row; align-items:stretch; gap:clamp(8px,2vmin,16px);
|
||||
border:1px solid var(--ring); background:linear-gradient(180deg, rgba(127,139,154,.06), transparent);
|
||||
transition:box-shadow .12s ease-out, border-color .12s ease-out, background .12s ease-out; }
|
||||
#pulse.hit{ border-color:var(--cyan); box-shadow:0 0 22px var(--glow); background:rgba(10,179,247,.07); }
|
||||
#pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 26px var(--aglow); background:rgba(255,209,102,.08); }
|
||||
.tapbtn{ flex:0 0 auto; align-self:stretch; min-width:clamp(58px,16vmin,100px); border-radius:12px;
|
||||
background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); color:var(--txt);
|
||||
font-size:clamp(13px,2.8vmin,18px); font-weight:600; letter-spacing:.14em; cursor:pointer;
|
||||
transition:box-shadow .1s ease-out, border-color .1s ease-out, color .1s ease-out;
|
||||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||
/* TAP button glows in time with the beat (rides the #pulse flash) */
|
||||
#pulse.hit .tapbtn{ border-color:var(--cyan); color:var(--cyan);
|
||||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06), 0 0 16px var(--glow), inset 0 0 12px var(--glow); }
|
||||
#pulse.hit.acc .tapbtn{ border-color:var(--amber); color:var(--amber);
|
||||
box-shadow:0 3px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06), 0 0 18px var(--aglow), inset 0 0 14px var(--aglow); }
|
||||
.tapbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||
#bpm{ flex:1 1 auto; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:.82; cursor:pointer; min-width:0; }
|
||||
#bpmNum{ font-size:clamp(44px,15vmin,120px); font-weight:800; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
|
||||
#bpmlab{ font-size:clamp(9px,1.8vmin,13px); letter-spacing:.2em; text-transform:uppercase; color:var(--muted); margin-top:.5em; opacity:.85; }
|
||||
#bpmIn{ display:none; flex:1 1 auto; min-width:0; text-align:center; font:inherit; font-size:clamp(40px,13vmin,108px); font-weight:800;
|
||||
background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none; font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
|
||||
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
|
||||
/* thumbwheel encoder — drag up/down to scrape the tempo */
|
||||
#wheel{ flex:0 0 auto; align-self:stretch; width:clamp(30px,7.5vmin,46px); border-radius:11px; cursor:ns-resize; touch-action:none; overflow:hidden;
|
||||
border:1px solid var(--btn-bd);
|
||||
background:repeating-linear-gradient(to bottom, rgba(0,0,0,.22) 0 1px, transparent 1px 5px),
|
||||
linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0) 18%, rgba(255,255,255,.16) 50%, rgba(0,0,0,0) 82%, rgba(0,0,0,.55)),
|
||||
linear-gradient(to right, rgba(0,0,0,.18), rgba(255,255,255,.10) 50%, rgba(0,0,0,.18)),
|
||||
var(--field-bg);
|
||||
box-shadow:inset 0 10px 9px -8px rgba(0,0,0,.75), inset 0 -10px 9px -8px rgba(0,0,0,.75), inset 0 0 4px rgba(0,0,0,.3); }
|
||||
#wheel:active{ border-color:var(--cyan); }
|
||||
#meterline{ font-size:clamp(12px,2.1vmin,16px); color:var(--muted); text-align:center; min-height:1.2em; letter-spacing:.02em; }
|
||||
|
||||
/* ---- editable lanes (scroll if many) + track panel below ---- */
|
||||
#detail{ flex:0 1 auto; width:100%; display:flex; flex-direction:column; gap:8px; padding:2px 0; min-height:0; }
|
||||
#lanes{ display:flex; flex-direction:column; gap:6px; max-height:34vh; overflow-y:auto; }
|
||||
#trackpanel{ width:100%; background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:10px; padding:8px 11px; display:flex; flex-direction:column; gap:8px; font-size:12px; color:var(--muted); }
|
||||
#trackpanel .tp-row{ display:flex; align-items:center; gap:8px 18px; flex-wrap:nowrap; }
|
||||
#trackpanel label{ display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
#trackpanel .tp-chk{ color:var(--txt); }
|
||||
#trackpanel .tp-chk input{ width:18px; height:18px; accent-color:var(--cyan); flex:0 0 auto; }
|
||||
#trackpanel .tp-loop{ color:var(--muted); }
|
||||
#trackpanel input[type=number]{ width:46px; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; -moz-appearance:textfield; }
|
||||
#trackpanel input[type=number]::-webkit-outer-spin-button, #trackpanel input[type=number]::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
|
||||
#trackpanel select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:5px 6px; font-size:13px; }
|
||||
#trackpanel .tp-sub{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; white-space:nowrap; color:var(--muted); }
|
||||
#trackpanel .tp-sub.off{ display:none; }
|
||||
#trackpanel .tp-str{ display:flex; gap:6px; }
|
||||
#trackpanel .tp-str input{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:7px; padding:6px 8px; font-family:"Courier New",monospace; font-size:11px; }
|
||||
#trackpanel .tp-btn{ flex:0 0 auto; background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd); border-radius:7px; padding:0 12px; font-size:12px; cursor:pointer; }
|
||||
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
|
||||
.lane{ display:flex; align-items:center; gap:8px; }
|
||||
/* dim only the label + pads of a muted lane, so the mute toggle stays crisp */
|
||||
.lane.off .lmeta, .lane.off .pads{ opacity:.45; }
|
||||
.lmute{ flex:0 0 auto; width:clamp(28px,5.4vmin,36px); height:clamp(28px,5.4vmin,36px); border-radius:8px; padding:0;
|
||||
display:flex; align-items:center; justify-content:center; cursor:pointer;
|
||||
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--cyan); }
|
||||
.lmute:active{ background:rgba(127,139,154,.22); }
|
||||
.lmute.muted{ color:var(--muted); background:transparent; }
|
||||
.lmute svg{ width:62%; height:62%; }
|
||||
.lane.poly .lmute{ color:var(--poly); }
|
||||
.lane.poly .lmute.muted{ color:var(--muted); }
|
||||
.lmeta{ flex:0 0 auto; width:36%; max-width:168px; min-width:94px; display:flex; align-items:center; gap:5px; text-align:left;
|
||||
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px;
|
||||
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
|
||||
.lmeta .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.lmeta .lg{ flex:0 0 auto; color:var(--muted); }
|
||||
.lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; }
|
||||
.lmeta .polybadge{ flex:0 0 auto; color:var(--poly); font-weight:700; letter-spacing:.01em; }
|
||||
.lane.poly .lmeta{ border-left:3px solid var(--poly); padding-left:6px; }
|
||||
.lmeta .rh-host{ flex:0 0 auto; display:flex; align-items:center; padding:2px 3px; margin:-2px -1px; border-radius:5px; cursor:pointer; }
|
||||
.lmeta .rh-host:active{ background:rgba(127,139,154,.22); }
|
||||
/* graphic note-value picker */
|
||||
.noterow{ display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.noterow .notebtn{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:56px; padding:9px 6px;
|
||||
background:var(--field-bg); border:1px solid var(--field-bd); border-radius:10px; color:var(--txt); cursor:pointer; }
|
||||
.noterow .notebtn.active{ border-color:var(--cyan); box-shadow:0 0 0 1px var(--cyan); }
|
||||
.noterow .notebtn .rhythm{ height:22px; width:auto; }
|
||||
.noterow .notebtn small{ font-size:10px; color:var(--muted); letter-spacing:.02em; }
|
||||
/* beats line up in columns across lanes; sub-beats sit inside the beat cell, smaller */
|
||||
.pads{ flex:1 1 auto; display:flex; gap:7px; overflow-x:auto; padding-bottom:2px; min-width:0; align-items:center; }
|
||||
.beatcell{ flex:1 1 0; min-width:0; display:flex; gap:2px; align-items:center; }
|
||||
.pad{ flex:1 1 0; min-width:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
|
||||
.pad.beat{ height:clamp(20px,3.8vmin,28px); border-radius:5px; }
|
||||
.pad.sub{ height:clamp(11px,2.3vmin,16px); border-radius:3px; }
|
||||
.pad.gs{ border-color:var(--amber); }
|
||||
.pad.on{ background:var(--cyan); }
|
||||
.pad.acc{ background:var(--amber); }
|
||||
.pad.ghost{ background:var(--cyan); opacity:.42; }
|
||||
.lane.poly .pad.on{ background:var(--poly); }
|
||||
.lane.poly .pad.ghost{ background:var(--poly); opacity:.42; }
|
||||
.pad.cur{ outline:2px solid var(--txt); outline-offset:-1px; box-shadow:0 0 8px var(--glow); }
|
||||
.addlane{ align-self:flex-start; background:transparent; border:1px dashed var(--chip-bd); color:var(--muted); border-radius:8px; padding:5px 11px; font-size:12px; cursor:pointer; }
|
||||
.chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; }
|
||||
.chip.feat{ font-size:clamp(10px,1.8vmin,13px); color:var(--muted); background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:7px; padding:3px 8px; white-space:nowrap; }
|
||||
.chip.feat.r{ border-color:var(--cyan); color:var(--cyan); } .chip.feat.g{ border-color:var(--amber); color:var(--amber); }
|
||||
|
||||
/* ---- transport: tempo row (−10/−/+/+10) then nav+play row (prev/play/practice/next) ---- */
|
||||
/* grows to fill freed space; SHRINKS (rather than the page scrolling) when the panel expands */
|
||||
#transport{ flex:0 1 auto; min-height:0; max-height:clamp(200px,46vh,310px); margin-top:auto; display:flex; flex-direction:column; align-items:stretch; width:100%; padding-top:6px; gap:clamp(5px,1.2vmin,9px); }
|
||||
.tgrid{ display:grid; width:100%; flex:1 1 auto; min-height:0; gap:clamp(7px,1.7vmin,14px);
|
||||
grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-rows:1fr 1fr; grid-template-areas:"dn10 dn up up10" "prev play prac next"; }
|
||||
.tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
|
||||
border-radius:13px; height:auto; min-height:66px; font-size:clamp(16px,4vmin,27px); cursor:pointer;
|
||||
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; }
|
||||
.journal{ flex:0 0 auto; width:100%; height:clamp(30px,6vmin,42px); border-radius:11px; cursor:pointer;
|
||||
background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px;
|
||||
display:flex; align-items:center; justify-content:center; gap:8px; }
|
||||
.journal.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; }
|
||||
.journal .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; display:none; }
|
||||
.journal.rec .dotrec{ display:inline-block; }
|
||||
.tbtn small{ font-size:clamp(8px,1.5vmin,11px); letter-spacing:.1em; color:inherit; opacity:.85; }
|
||||
.tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||
#bDn10{grid-area:dn10} #bPrev{grid-area:prev} #bNext{grid-area:next} #bUp10{grid-area:up10}
|
||||
#bDown{grid-area:dn} #bPlay{grid-area:play} #bPrac{grid-area:prac} #bUp{grid-area:up}
|
||||
.tbtn.play{ background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3; }
|
||||
.tbtn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
|
||||
.tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; }
|
||||
.tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
|
||||
|
||||
/* landscape (phone AND tablet): header stays a full-width column (logo+icons
|
||||
row, then volume row); the body becomes a 2-column grid — tempo + transport
|
||||
on the left, selector + settings + lanes on the right */
|
||||
@media (orientation:landscape){
|
||||
#app{ --maxw:1060px; }
|
||||
/* fr columns (not 40%/60%) so the column gap is subtracted before sizing —
|
||||
percentages + gap summed past 100% and overhung the right edge */
|
||||
#mid{ display:grid; align-items:stretch; gap:clamp(12px,2.6vh,22px) clamp(16px,3vw,38px); padding-top:0;
|
||||
grid-template-columns:minmax(0,2fr) minmax(0,3fr); grid-template-rows:auto auto 1fr auto;
|
||||
grid-template-areas:"stage sels" "stage panel" "stage detail" "transport detail"; }
|
||||
#stage, .sels, #trackpanel, #detail, #transport{ min-width:0; }
|
||||
#stage{ grid-area:stage; align-self:center; justify-content:center; }
|
||||
.sels{ grid-area:sels; align-self:start; }
|
||||
#trackpanel{ grid-area:panel; align-self:start; }
|
||||
#detail{ grid-area:detail; align-self:stretch; width:auto; max-width:none; max-height:none; min-height:0; overflow-y:auto; }
|
||||
#transport{ grid-area:transport; align-self:end; max-height:none; margin-top:0; }
|
||||
.tbtn{ min-height:0; height:clamp(34px,10vmin,54px); }
|
||||
}
|
||||
|
||||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||
|
||||
/* ---- bottom sheet (lane editor) ---- */
|
||||
#scrim{ position:fixed; inset:0; background:rgba(0,0,0,.55); opacity:0; pointer-events:none; transition:opacity .2s; z-index:40; }
|
||||
#scrim.open{ opacity:1; pointer-events:auto; }
|
||||
#laneSheet, #trackSheet, #saveSheet, #noteSheet, #shareSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:88vh; overflow-y:auto;
|
||||
background:var(--panel-bg); border-top:1px solid var(--panel-bd); border-radius:18px 18px 0 0; transform:translateY(110%);
|
||||
transition:transform .26s cubic-bezier(.2,.8,.2,1);
|
||||
padding:12px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
|
||||
#laneSheet.open, #trackSheet.open, #saveSheet.open, #noteSheet.open, #shareSheet.open{ transform:none; }
|
||||
#laneSheet .grab, #trackSheet .grab, #saveSheet .grab, #noteSheet .grab, #shareSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
|
||||
#laneSheet h2, #trackSheet h2, #saveSheet h2, #noteSheet h2, #shareSheet h2{ margin:0 0 10px; font-size:16px; }
|
||||
#laneSheet label, #trackSheet label, #saveSheet label, #shareSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
|
||||
#laneSheet select, #trackSheet select, #saveSheet select,
|
||||
#laneSheet input[type=text], #trackSheet input[type=text], #trackSheet input[type=number], #saveSheet input[type=text], #shareSheet input[type=text]{
|
||||
width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
|
||||
#laneSheet .lrow, #trackSheet .lrow, #saveSheet .lrow, #shareSheet .lrow{ display:flex; gap:12px; margin-top:10px; flex-wrap:wrap; align-items:center; }
|
||||
#laneSheet .half, #trackSheet .half{ display:block; flex:1 1 120px; margin:0; }
|
||||
#laneSheet .chk, #trackSheet .chk{ display:flex; align-items:center; gap:8px; font-size:14px; color:var(--txt); margin-top:16px; }
|
||||
#laneSheet .chk input, #trackSheet .chk input{ width:20px; height:20px; accent-color:var(--cyan); flex:0 0 auto; }
|
||||
#trackSheet .lrow.off{ display:none; }
|
||||
.lfoot{ display:flex; justify-content:space-between; align-items:center; margin-top:18px; }
|
||||
.lbtn{ cursor:pointer; color:var(--txt); background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); border-radius:10px; padding:10px 16px; font-size:14px; }
|
||||
.lbtn.danger{ background:transparent; color:#ff7a7a; border-color:#ff7a7a; }
|
||||
.seg{ display:flex; gap:8px; margin-bottom:6px; }
|
||||
.seg button{ flex:1 1 0; padding:9px; border:1px solid var(--field-bd); background:var(--field-bg); color:var(--muted); border-radius:9px; font-size:14px; cursor:pointer; }
|
||||
.seg button.active{ border-color:var(--cyan); color:var(--txt); }
|
||||
.seclbl{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:4px 0 8px; }
|
||||
.savemsg{ font-size:12px; color:#5fd08a; align-self:center; }
|
||||
.liblbl{ font-size:12px; color:var(--muted); margin:14px 0 4px; }
|
||||
.libhint{ font-size:12px; color:var(--muted); padding:6px 2px; line-height:1.4; }
|
||||
.librow{ display:flex; align-items:center; gap:4px; padding:4px 0; border-bottom:1px solid var(--panel-bd); }
|
||||
.librow.active .libname{ color:var(--cyan); font-weight:600; }
|
||||
.libname{ flex:1 1 auto; min-width:0; text-align:left; background:transparent; border:none; color:var(--txt); font-size:14px; padding:7px 2px; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ibtn{ flex:0 0 auto; width:32px; height:32px; border-radius:7px; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); font-size:13px; cursor:pointer; }
|
||||
.ibtn:disabled{ opacity:.3; }
|
||||
|
||||
/* ---- help tour (coachmarks) ---- */
|
||||
#tour{ position:fixed; inset:0; z-index:200; display:none; }
|
||||
#tour.open{ display:block; }
|
||||
#tourHole{ position:absolute; border-radius:12px; box-shadow:0 0 0 9999px rgba(0,0,0,.66); border:2px solid var(--cyan); transition:all .2s ease; pointer-events:none; }
|
||||
#tourBox{ position:absolute; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:14px; box-shadow:0 14px 44px rgba(0,0,0,.5); }
|
||||
#tourBox h3{ margin:0 0 6px; font-size:15px; }
|
||||
#tourBox p{ margin:0 0 12px; font-size:13px; color:var(--muted); line-height:1.45; }
|
||||
#tourBox .trow{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.tdots{ font-size:12px; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<div id="top">
|
||||
<div id="brandrow">
|
||||
<a id="logoLink" href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener" title="VARASYS PolyMeter — source on Codeberg"><img class="brandlogo logo-dark" src="data:image/png;base64,@BUILD:logo-side-dark@" alt="VARASYS PolyMeter" /><img class="brandlogo logo-light" src="data:image/png;base64,@BUILD:logo-side-light@" alt="VARASYS PolyMeter" /></a>
|
||||
<div class="hicons" id="utilrow">
|
||||
<div class="icon" id="shareBtn" title="Share / paste" aria-label="Share or paste"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M8 7l4-4 4 4"/><path d="M5 12v8h14v-8"/></svg></div>
|
||||
<div class="icon" id="helpBtn" title="Help" aria-label="Help">?</div>
|
||||
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme">◐</div>
|
||||
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen">⛶</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vol" id="volrow"><span class="dyn" aria-hidden="true">p</span><input id="vol" type="range" min="0" max="100" value="85" aria-label="Volume" /><span class="dyn" aria-hidden="true">f</span></div>
|
||||
</div>
|
||||
|
||||
<div id="mid">
|
||||
<div id="stage">
|
||||
<div id="pulse">
|
||||
<button id="bTapBtn" class="tapbtn" title="Tap tempo">TAP</button>
|
||||
<div id="bpm"><span id="bpmNum">120</span><span id="bpmlab">BPM</span></div>
|
||||
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
|
||||
<div id="wheel" title="Drag to set tempo" aria-label="Tempo wheel"></div>
|
||||
</div>
|
||||
<div id="meterline"></div>
|
||||
</div>
|
||||
<div class="sels">
|
||||
<label class="sel"><span>Set list</span><select id="slSel"></select></label>
|
||||
<label class="sel"><span>Track</span><select id="trkSel"></select></label>
|
||||
<div class="icon" id="saveBtn" title="Save & library" aria-label="Save and library"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M5 4 H15 L19 8 V20 H5 Z"/><path d="M8.5 4 V8 H13.5 V4"/><rect x="8" y="13" width="8" height="7"/></svg></div>
|
||||
</div>
|
||||
<div id="trackpanel">
|
||||
<div class="tp-row">
|
||||
<label class="tp-chk"><input type="checkbox" id="ipRepeat" /> Repeat</label>
|
||||
<label class="tp-chk"><input type="checkbox" id="ipRamp" /> Tempo ramp</label>
|
||||
<label class="tp-chk"><input type="checkbox" id="ipGap" /> Practice gaps</label>
|
||||
</div>
|
||||
<div class="tp-sub off" id="ipRepeatRow">Play <input id="ipBars" type="number" inputmode="numeric" min="1" max="999" /> bars, then <select id="ipEnd"><option value="stop">stop</option><option value="next">next track</option><option value="prev">prev track</option></select></div>
|
||||
<div class="tp-sub off" id="ipRampRow"><input id="ipRampStart" type="number" min="30" max="300" /> → +<input id="ipRampAmt" type="number" min="1" max="50" /> bpm / <input id="ipRampEvery" type="number" min="1" max="64" /> bars</div>
|
||||
<div class="tp-sub off" id="ipGapRow"><input id="ipGapPlay" type="number" min="1" max="32" /> play / <input id="ipGapMute" type="number" min="1" max="32" /> mute bars</div>
|
||||
</div>
|
||||
<div id="detail">
|
||||
<div id="lanes"></div>
|
||||
</div>
|
||||
<div id="transport">
|
||||
<div class="tgrid">
|
||||
<button class="tbtn" id="bDn10" title="Tempo −10">−10</button>
|
||||
<button class="tbtn" id="bDown" title="Tempo −1">−</button>
|
||||
<button class="tbtn" id="bUp" title="Tempo +1">+</button>
|
||||
<button class="tbtn" id="bUp10" title="Tempo +10">+10</button>
|
||||
<button class="tbtn" id="bPrev" title="Previous track">⏮</button>
|
||||
<button class="tbtn play" id="bPlay" title="Play / Stop">▶<small>PLAY</small></button>
|
||||
<button class="tbtn prac" id="bPrac" title="Practice — logs your time to the practice log">⦿<small>PRACTICE</small></button>
|
||||
<button class="tbtn" id="bNext" title="Next track">⏭</button>
|
||||
</div>
|
||||
<button id="bJournal" class="journal"><span class="dotrec"></span><span id="jText">Journal →</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- lane editor sheet -->
|
||||
<div id="scrim"></div>
|
||||
<div id="laneSheet">
|
||||
<div class="grab"></div>
|
||||
<h2>Edit lane</h2>
|
||||
<label for="lsSound">Sound</label><select id="lsSound"></select>
|
||||
<label for="lsGroup">Grouping — beats per bar (e.g. 4, or 2+2+3)</label><input id="lsGroup" type="text" inputmode="text" autocomplete="off" />
|
||||
<label>Note value</label><div id="lsNotes" class="noterow"></div>
|
||||
<div class="lrow">
|
||||
<label class="chk"><input type="checkbox" id="lsPoly" /> Polymeter</label>
|
||||
<label class="chk"><input type="checkbox" id="lsMute" /> Mute lane</label>
|
||||
</div>
|
||||
<label for="lsGain">Lane volume <span id="lsGainVal" style="color:var(--txt)">0 dB</span></label>
|
||||
<input id="lsGain" type="range" min="-18" max="6" step="1" style="width:100%;accent-color:var(--cyan)" />
|
||||
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
|
||||
</div>
|
||||
|
||||
<!-- share sheet: share a track or set list as a link, or paste a string to load -->
|
||||
<div id="shareSheet">
|
||||
<div class="grab"></div>
|
||||
<h2>Share</h2>
|
||||
<div class="seg" id="shareSeg"><button data-k="p" class="active">This track</button><button data-k="sl">This set list</button></div>
|
||||
<label>Shareable link</label>
|
||||
<input id="shareLink" type="text" readonly onfocus="this.select()" />
|
||||
<div class="lrow"><button id="shareCopy" class="lbtn">Copy link</button><button id="shareCopyT" class="lbtn">Copy text</button><span id="shareMsg" class="savemsg"></span></div>
|
||||
<label for="sharePaste">Or paste a track string / link to load</label>
|
||||
<input id="sharePaste" type="text" autocomplete="off" placeholder="v1;t120;kick:4;… or a #p=/#sl= link" />
|
||||
<div class="lfoot"><button id="shareLoad" class="lbtn">Load</button><button id="shareDone" class="lbtn">Done</button></div>
|
||||
</div>
|
||||
|
||||
<!-- save & library sheet -->
|
||||
<div id="saveSheet">
|
||||
<div class="grab"></div>
|
||||
<h2>Save & library</h2>
|
||||
<div class="seclbl">Save current track</div>
|
||||
<label for="saveName">Track name</label>
|
||||
<input id="saveName" type="text" autocomplete="off" />
|
||||
<label for="saveTo">Save to set list</label>
|
||||
<select id="saveTo"></select>
|
||||
<input id="saveNewName" type="text" autocomplete="off" placeholder="New set list name" style="display:none;margin-top:8px" />
|
||||
<div class="lrow">
|
||||
<button id="saveUpd" class="lbtn">Update</button>
|
||||
<button id="saveNew" class="lbtn">Save as new track</button>
|
||||
<span id="saveMsg" class="savemsg"></span>
|
||||
</div>
|
||||
<div class="seclbl" style="margin-top:20px">Manage library</div>
|
||||
<div id="libBody"></div>
|
||||
<div class="lfoot"><span></span><button id="saveDone" class="lbtn">Done</button></div>
|
||||
</div>
|
||||
|
||||
<!-- guided help tour -->
|
||||
<div id="tour">
|
||||
<div id="tourHole"></div>
|
||||
<div id="tourBox">
|
||||
<h3 id="tourTitle"></h3><p id="tourText"></p>
|
||||
<div class="trow"><span class="tdots" id="tourDots"></span>
|
||||
<span><button id="tourSkip" class="lbtn" style="padding:8px 12px">Skip</button>
|
||||
<button id="tourPrev" class="lbtn" style="padding:8px 12px">Back</button>
|
||||
<button id="tourNext" class="lbtn" style="padding:8px 14px">Next</button></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const LS_SESSIONS="metronome.sessions", LS_SETLISTS="metronome.setlists", LS_STATE="metronome.mobile.state", LS_TOURED="metronome.mobile.toured", LS_CURTRACK="metronome.curtrack";
|
||||
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
|
||||
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
|
||||
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||||
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
|
||||
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
|
||||
|
||||
/* ========================= ENGINE ============================================ */
|
||||
const SAMPLES = {};
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state={ bpm:120, volume:0.85, running:false };
|
||||
let meters=[];
|
||||
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
|
||||
let segBars=0, segBarCount=0, pendingAdvance=false, curEnd=null, curRep=null;
|
||||
let masterBeat=0, masterBeatTime=0, muteWindows=[];
|
||||
|
||||
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
|
||||
function advanceMaster(ahead){
|
||||
const mbpb=masterBeatsPerBar();
|
||||
while(masterBeatTime<ahead){
|
||||
if(masterBeat%mbpb===0){
|
||||
const barIndex=Math.floor(masterBeat/mbpb);
|
||||
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
|
||||
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
|
||||
segBarCount=barIndex;
|
||||
if(segBars>0&&barIndex>=segBars&&!pendingAdvance&&curEnd!=null){ pendingAdvance=true; } // loop (null) keeps playing
|
||||
}
|
||||
masterBeat++; masterBeatTime+=60/state.bpm;
|
||||
}
|
||||
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
|
||||
}
|
||||
function scheduler(){
|
||||
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
|
||||
advanceMaster(ahead);
|
||||
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
|
||||
if(pendingAdvance){ pendingAdvance=false; setTimeout(handleEnd,0); }
|
||||
}
|
||||
function handleEnd(){ // fires after segBars bars when an end action is set (loop = no action)
|
||||
if(curEnd==="stop"){ if(sessionActive&&state.running) pauseTrack(); else stopAudio(); }
|
||||
else if(typeof curEnd==="number") gotoItem(idx+curEnd,true); // +1 next track, -1 prev track
|
||||
}
|
||||
|
||||
/* ========================= PLAYER ============================================= */
|
||||
let setlist=null, idx=0, slKey="", transientTitle=null, savedLists=[];
|
||||
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
|
||||
function currentName(){ return setlist ? (setlist.items[idx].name||"") : ""; }
|
||||
|
||||
function buildMeters(lanes){
|
||||
return (lanes||[]).map(c=>{
|
||||
const p=parseGroups(c.groupsStr);
|
||||
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
|
||||
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),orns:(c.orns||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
|
||||
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0,_padEls:null,_lastPad:-1};
|
||||
});
|
||||
}
|
||||
function snapshotLanes(){ return meters.map(m=>({groupsStr:m.groupsStr,stepsPerBeat:m.stepsPerBeat,sound:m.sound,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice(),poly:m.poly,swing:m.swing,enabled:m.enabled,gainDb:m.gainDb})); }
|
||||
function currentPatch(){ return setupToPatch({bpm:state.bpm,volume:state.volume,lanes:snapshotLanes(),trainer,ramp,bars:segBars,end:curEnd,rep:curRep}); }
|
||||
function loadSetup(s){
|
||||
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
|
||||
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
|
||||
segBars=s.bars||0; segBarCount=0; curEnd=s.end; curRep=s.rep;
|
||||
setBpm(s.bpm||120);
|
||||
meters=buildMeters(s.lanes); laneSig=null;
|
||||
}
|
||||
|
||||
function unlockAudio(){
|
||||
try{ if(navigator.audioSession) navigator.audioSession.type="playback"; }catch(e){}
|
||||
try{ const b=audioCtx.createBuffer(1,1,audioCtx.sampleRate), s=audioCtx.createBufferSource(); s.buffer=b; s.connect(audioCtx.destination); s.start(0); }catch(e){}
|
||||
}
|
||||
let runStartAt=0;
|
||||
function startAudio(){
|
||||
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true; runStartAt=Date.now();
|
||||
if(ramp.on) setBpm(ramp.startBpm);
|
||||
const t0=audioCtx.currentTime+0.08;
|
||||
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
|
||||
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1;
|
||||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
|
||||
requestWake();
|
||||
}
|
||||
function stopMetronome(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters){ m.currentStep=-1; } }
|
||||
function stopAudio(){ stopMetronome(); releaseWake(); renderAll(); }
|
||||
function startRun(){ startAudio(); renderAll(); }
|
||||
|
||||
/* sessions */
|
||||
let sessionActive=false, session=null, trackSegStart=null, lastSaved=false;
|
||||
function startTrack(){
|
||||
if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; }
|
||||
startAudio(); trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; renderAll(); renderSessionBar();
|
||||
}
|
||||
function recordSegment(){ if(!trackSegStart||!session) return; const sec=(Date.now()-trackSegStart.at)/1000;
|
||||
if(sec>=3) session.segments.push({name:trackSegStart.name,at:trackSegStart.at,sec,bpm:state.bpm}); trackSegStart=null; }
|
||||
function pauseTrack(){ recordSegment(); stopMetronome(); releaseWake(); renderAll(); renderSessionBar(); }
|
||||
function endSession(){
|
||||
if(state.running){ recordSegment(); stopMetronome(); releaseWake(); }
|
||||
if(session){ const endedAt=Date.now(), clockSec=(endedAt-session.at)/1000;
|
||||
if(session.segments.length && clockSec>=5){ const arr=lsGet(LS_SESSIONS,[]); arr.unshift({at:session.at,endedAt,clockSec,note:"",segments:session.segments}); lsSet(LS_SESSIONS,arr); lastSaved=true; } }
|
||||
session=null; sessionActive=false; trackSegStart=null; renderAll(); renderSessionBar();
|
||||
}
|
||||
function play(){ if(sessionActive) endSession(); else if(state.running) stopAudio(); else startRun(); }
|
||||
function practice(){ if(sessionActive&&state.running){ pauseTrack(); } else { if(state.running&&!sessionActive) stopMetronome(); startTrack(); } }
|
||||
|
||||
function gotoItem(i,keepPlaying){
|
||||
if(!setlist||!setlist.items.length) return;
|
||||
const n=setlist.items.length; idx=((i%n)+n)%n;
|
||||
const wasRunning=state.running||keepPlaying;
|
||||
if(state.running){ if(sessionActive) recordSegment(); stopMetronome(); }
|
||||
loadSetup(setlist.items[idx]);
|
||||
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
|
||||
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
|
||||
}
|
||||
function loadSetlistObj(sl){
|
||||
if(state.running&&sessionActive) recordSegment();
|
||||
const wasRunning=state.running; if(wasRunning) stopMetronome();
|
||||
setlist=sl; idx=0; loadSetup(sl.items[0]);
|
||||
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
|
||||
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
|
||||
}
|
||||
|
||||
let taps=[];
|
||||
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
|
||||
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
|
||||
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
|
||||
|
||||
/* ========================= EDITABLE LANES ==================================== */
|
||||
let laneSig=null, editLaneIdx=0;
|
||||
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
|
||||
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
|
||||
function padClass(m,k){ const spb=m.stepsPerBeat, isBeat=(k%spb===0), gs=isBeat&&m.groupStarts.has(k/spb), lvl=m.beatsOn[k]|0;
|
||||
return "pad "+(isBeat?"beat":"sub")+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }
|
||||
// Effective note value a lane actually plays: reduce the subdivision grid to the
|
||||
// largest note that lands on every active hit (so a triplet grid that only plays
|
||||
// the beat shows a quarter, not a triplet). gcd(stepsPerBeat, all active offsets).
|
||||
function gcd(a,b){ a=Math.abs(a); b=Math.abs(b); while(b){ const t=a%b; a=b; b=t; } return a; }
|
||||
function laneNoteValue(m){
|
||||
const spb=m.stepsPerBeat; let g=spb, any=false;
|
||||
for(let k=0;k<m.beatsOn.length;k++){ if((m.beatsOn[k]|0)>0){ any=true; g=gcd(g,k); } }
|
||||
if(!any) return 1;
|
||||
return Math.max(1, spb/g);
|
||||
}
|
||||
// Small engraved rhythm figure (1=quarter, 2=8ths, 3=triplet, 4=16ths, 5/6/7=tuplets). SVG.
|
||||
function rhythmSVG(n){
|
||||
n=Math.max(1,n|0);
|
||||
const baseY=15, topY=6, stemH=(baseY-topY-0.5).toFixed(1);
|
||||
const head=(cx)=>'<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>';
|
||||
const stem=(sx)=>'<rect x="'+(sx-0.45).toFixed(2)+'" y="'+topY+'" width="0.9" height="'+stemH+'"/>';
|
||||
const beam=(x0,x1,y)=>'<rect x="'+x0.toFixed(2)+'" y="'+y+'" width="'+(x1-x0).toFixed(2)+'" height="1.7"/>';
|
||||
const beams = n===1?0 : (n<=3?1:2), tup = (n===3||n>=5)?n:null;
|
||||
const LEFT=3, GAP = n<=2?8 : n<=4?6.5 : 5.5, W=Math.round(LEFT*2 + (n-1)*GAP + 4);
|
||||
let g="", first=0, last=0;
|
||||
for(let i=0;i<n;i++){ const cx=LEFT+i*GAP, sx=cx+2.0; if(i===0) first=sx; last=sx; g+=head(cx)+stem(sx); }
|
||||
if(beams>=1) g+=beam(first-0.45,last+0.45,topY);
|
||||
if(beams>=2) g+=beam(first-0.45,last+0.45,topY+2.6);
|
||||
if(tup) g+='<text x="'+(W/2).toFixed(1)+'" y="3.6" font-size="6" text-anchor="middle" font-style="italic" stroke="none">'+tup+'</text>';
|
||||
return '<svg class="rhythm" viewBox="0 0 '+W+' 18" width="'+W+'" height="16" fill="currentColor" aria-hidden="true">'+g+'</svg>';
|
||||
}
|
||||
function laneMetaHTML(m){ const eff=laneNoteValue(m);
|
||||
const ref=(meters[0]?meters[0].beatsPerBar:m.beatsPerBar);
|
||||
const poly=m.poly?"<span class='polybadge' title='polyrhythm — "+m.beatsPerBar+" over "+ref+"'>↻"+m.beatsPerBar+":"+ref+"</span>":"";
|
||||
return "<span class='ln-name'>"+esc(m.sound)+"</span><span class='rh-host' title='Note value'>"+rhythmSVG(eff)+"</span><span class='lg'>"+esc(m.groupsStr)+"</span>"+poly; }
|
||||
function setLaneMeta(m){ if(!m._meta) return; m._meta.innerHTML=laneMetaHTML(m); }
|
||||
// speaker glyphs for the inline per-lane mute toggle
|
||||
const SPK_ON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M16.5 8.5a5 5 0 0 1 0 7"/></svg>';
|
||||
const SPK_OFF='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9v6h4l5 4V5L8 9H4z"/><path d="M17 9.5l4 5M21 9.5l-4 5"/></svg>';
|
||||
function toggleLaneMute(i){ const m=meters[i]; if(!m) return; m.enabled=!m.enabled; laneSig=null; renderAll(); saveState(); }
|
||||
function buildLanes(){
|
||||
const box=$("lanes"); box.innerHTML="";
|
||||
meters.forEach((m,i)=>{
|
||||
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off")+(m.poly?" poly":"");
|
||||
const mute=document.createElement("button"); mute.className="lmute"+(m.enabled?"":" muted");
|
||||
mute.title=m.enabled?"Mute lane":"Unmute lane"; mute.setAttribute("aria-label",mute.title);
|
||||
mute.innerHTML=m.enabled?SPK_ON:SPK_OFF; mute.onclick=(e)=>{ e.stopPropagation(); toggleLaneMute(i); };
|
||||
const meta=document.createElement("button"); meta.className="lmeta"; m._meta=meta; m._idx=i; setLaneMeta(m);
|
||||
meta.onclick=()=>openLaneSheet(i);
|
||||
const pads=document.createElement("div"); pads.className="pads";
|
||||
const spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1;
|
||||
for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell";
|
||||
for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; }
|
||||
pads.appendChild(cell); }
|
||||
lane.appendChild(mute); lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
|
||||
});
|
||||
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
|
||||
renderPadLevels();
|
||||
}
|
||||
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return;
|
||||
m._padEls.forEach((p,k)=>{ if(p) p.className=padClass(m,k); }); }); }
|
||||
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; p.className=padClass(m,k);
|
||||
setLaneMeta(m); // note value can change as hits are added/removed
|
||||
saveState(); }
|
||||
|
||||
/* ---- graphic note-value picker (tap a lane's rhythm icon, or use it in the lane sheet) ---- */
|
||||
const NOTE_OPTS=[1,2,3,4,6];
|
||||
function noteName(n){ return {1:"quarter",2:"eighth",3:"triplet",4:"sixteenth",6:"sextuplet"}[n]||(n+"-tuplet"); }
|
||||
function renderNoteOpts(box, cur, pick){ if(!box) return; box.innerHTML="";
|
||||
NOTE_OPTS.forEach(n=>{ const b=el("button","notebtn"+(n===cur?" active":"")); b.innerHTML=rhythmSVG(n)+"<small>"+noteName(n)+"</small>"; b.onclick=()=>pick(n); box.appendChild(b); }); }
|
||||
function setLaneSub(i,n){ const m=meters[i]; if(!m) return;
|
||||
rebuildLane(i,{groupsStr:m.groupsStr,stepsPerBeat:n,swing:m.swing,poly:m.poly,enabled:m.enabled,sound:m.sound,gainDb:m.gainDb,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
|
||||
laneSig=null; renderAll(); saveState(); }
|
||||
function refreshLaneSheetNotes(){ const m=meters[editLaneIdx]; if(!m) return;
|
||||
renderNoteOpts($("lsNotes"), m.stepsPerBeat, (n)=>{ setLaneSub(editLaneIdx,n); refreshLaneSheetNotes(); }); }
|
||||
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
|
||||
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
|
||||
function rebuildLane(i,cfg){
|
||||
const p=parseGroups(cfg.groupsStr), spb=Math.max(1,cfg.stepsPerBeat||1), steps=p.beatsPerBar*spb;
|
||||
let on=cfg.beatsOn, orns=cfg.orns||[];
|
||||
if(!on||on.length!==steps){ on=Array.from({length:steps},(_,k)=>((k%spb)===0&&p.groupStarts.has(k/spb))?2:1); orns=on.map(()=>0); }
|
||||
const old=meters[i]||{};
|
||||
meters[i]=Object.assign(old,{groupsStr:cfg.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
|
||||
stepsPerBeat:spb,sound:cfg.sound,beatsOn:on,orns:orns,poly:!!cfg.poly,swing:!!cfg.swing,enabled:cfg.enabled!==false,gainDb:cfg.gainDb||0,
|
||||
currentStep:-1,_padEls:null,_lastPad:-1});
|
||||
}
|
||||
function addLane(){ meters.push(buildMeters([{groupsStr:"4",stepsPerBeat:1,sound:"beep",beatsOn:[2,1,1,1],orns:[0,0,0,0],poly:false,swing:false,enabled:true,gainDb:0}])[0]); laneSig=null; renderAll(); saveState(); }
|
||||
|
||||
/* lane settings sheet */
|
||||
(function(){ const sel=$("lsSound"); VOICES.forEach(([k,lab])=>{ const o=document.createElement("option"); o.value=k; o.textContent=lab; sel.appendChild(o); }); })();
|
||||
function gainLabel(db){ return (db>0?"+":"")+db+" dB"; }
|
||||
function openLaneSheet(i){ editLaneIdx=i; const m=meters[i]; if(!m) return;
|
||||
$("lsSound").value=m.sound; $("lsGroup").value=m.groupsStr; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
|
||||
$("lsGain").value=m.gainDb||0; $("lsGainVal").textContent=gainLabel(m.gainDb||0); refreshLaneSheetNotes();
|
||||
$("saveSheet").classList.remove("open"); $("shareSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
|
||||
function closeSheets(){ ["laneSheet","saveSheet","shareSheet","scrim"].forEach(id=>$(id).classList.remove("open")); }
|
||||
const closeLaneSheet=closeSheets;
|
||||
function applyLane(){ const m=meters[editLaneIdx]; if(!m) return;
|
||||
let grp=($("lsGroup").value||"").trim()||"4";
|
||||
rebuildLane(editLaneIdx,{groupsStr:grp,stepsPerBeat:m.stepsPerBeat,swing:m.swing,
|
||||
poly:$("lsPoly").checked,enabled:!$("lsMute").checked,sound:$("lsSound").value,gainDb:parseInt($("lsGain").value,10)||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
|
||||
laneSig=null; renderAll(); saveState(); refreshLaneSheetNotes(); }
|
||||
["lsSound","lsPoly","lsMute"].forEach(id=>$(id).addEventListener("change",applyLane));
|
||||
$("lsGain").addEventListener("input",()=>{ const m=meters[editLaneIdx]; if(!m) return; m.gainDb=parseInt($("lsGain").value,10)||0; $("lsGainVal").textContent=gainLabel(m.gainDb); saveState(); }); // live, no rebuild
|
||||
$("lsGroup").addEventListener("change",applyLane);
|
||||
$("lsDone").onclick=closeLaneSheet;
|
||||
$("lsDel").onclick=()=>{ if(meters.length<=1){ closeLaneSheet(); return; } meters.splice(editLaneIdx,1); laneSig=null; renderAll(); saveState(); closeLaneSheet(); };
|
||||
$("scrim").onclick=closeSheets;
|
||||
|
||||
/* ---- inline track panel: repeat/end, ramp, gap, copy/paste string (above lanes) ---- */
|
||||
function clampInt(v,lo,hi,def){ v=parseInt(v,10); if(isNaN(v)) return def; return Math.max(lo,Math.min(hi,v)); }
|
||||
function flashIp(msg){ $("ipMsg").textContent=msg; setTimeout(()=>{ if($("ipMsg").textContent===msg) $("ipMsg").textContent=""; },1600); }
|
||||
function buildTrackPanel(){
|
||||
if(document.activeElement&&document.activeElement.closest&&document.activeElement.closest("#trackpanel")) return; // don't fight the user mid-edit
|
||||
const rep=segBars>0;
|
||||
$("ipRepeat").checked=rep; $("ipRepeatRow").classList.toggle("off",!rep);
|
||||
$("ipBars").value=segBars||4;
|
||||
$("ipEnd").value = curEnd==="stop"?"stop":(curEnd===-1?"prev":"next");
|
||||
$("ipRamp").checked=ramp.on; $("ipGap").checked=trainer.on;
|
||||
$("ipRampStart").value=ramp.startBpm||80; $("ipRampAmt").value=ramp.amount||5; $("ipRampEvery").value=ramp.everyBars||4;
|
||||
$("ipGapPlay").value=trainer.playBars||2; $("ipGapMute").value=trainer.muteBars||2;
|
||||
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
|
||||
}
|
||||
function applyTrackPanel(){
|
||||
if($("ipRepeat").checked){ segBars=Math.max(1,parseInt($("ipBars").value,10)||4); const e=$("ipEnd").value; curEnd = e==="stop"?"stop":(e==="prev"?-1:1); }
|
||||
else { segBars=0; curEnd=null; } // no Repeat = loop forever
|
||||
$("ipRepeatRow").classList.toggle("off",!$("ipRepeat").checked);
|
||||
ramp.on=$("ipRamp").checked; ramp.startBpm=clampInt($("ipRampStart").value,30,300,80); ramp.amount=clampInt($("ipRampAmt").value,1,50,5); ramp.everyBars=clampInt($("ipRampEvery").value,1,64,4);
|
||||
trainer.on=$("ipGap").checked; trainer.playBars=clampInt($("ipGapPlay").value,1,32,2); trainer.muteBars=clampInt($("ipGapMute").value,1,32,2);
|
||||
$("ipRampRow").classList.toggle("off",!ramp.on); $("ipGapRow").classList.toggle("off",!trainer.on);
|
||||
saveState();
|
||||
}
|
||||
["ipRepeat","ipBars","ipEnd","ipRamp","ipGap","ipRampStart","ipRampAmt","ipRampEvery","ipGapPlay","ipGapMute"].forEach(id=>$(id).addEventListener("change",applyTrackPanel));
|
||||
|
||||
/* ---- save & library: user set lists/tracks (same store + format as the editor) ---- */
|
||||
function userSetlists(){ return lsGet(LS_SETLISTS,[]); }
|
||||
function saveUserSetlists(a){ lsSet(LS_SETLISTS,a); savedLists=a; }
|
||||
function curSetupObj(){ return { bpm:state.bpm, lanes:snapshotLanes(), trainer:{...trainer}, ramp:{...ramp}, countMs:0, bars:segBars, rep:curRep, end:curEnd }; }
|
||||
function flashSave(msg){ $("saveMsg").textContent=msg; setTimeout(()=>{ if($("saveMsg").textContent===msg) $("saveMsg").textContent=""; },1800); }
|
||||
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls) e.className=cls; if(txt!=null) e.textContent=txt; return e; }
|
||||
function ibtn(label,fn,dis){ const b=el("button","ibtn",label); b.disabled=!!dis; b.onclick=(e)=>{ e.stopPropagation(); fn(); }; return b; }
|
||||
|
||||
function selectUserList(i){ const arr=userSetlists(); if(!arr[i]) return; slKey="s"+i; transientTitle=null;
|
||||
loadSetlistObj({title:arr[i].title,items:(arr[i].items||[]).map(it=>({...it}))}); renderLibrary(); }
|
||||
function selectUserTrack(i,j){ slKey="s"+i; transientTitle=null; savedLists=userSetlists();
|
||||
setlist={title:savedLists[i].title,items:savedLists[i].items.map(it=>({...it}))}; idx=Math.max(0,Math.min(j,setlist.items.length-1));
|
||||
loadSetup(setlist.items[idx]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
|
||||
|
||||
function doSaveAsNew(){
|
||||
const name=($("saveName").value||"My track").trim(); const arr=userSetlists(); let i;
|
||||
if($("saveTo").value==="__new"){ const t=($("saveNewName").value||"My set list").trim(); arr.push({title:t,description:"",items:[]}); i=arr.length-1; }
|
||||
else { i=+$("saveTo").value.slice(1); if(!arr[i]) return; }
|
||||
arr[i].items.push({name, ...curSetupObj()}); saveUserSetlists(arr);
|
||||
selectUserTrack(i, arr[i].items.length-1); $("saveNewName").value=""; buildSaveTo(); renderLibrary(); flashSave("Saved ✓");
|
||||
}
|
||||
function doUpdate(){
|
||||
if(slKey[0]!=="s") return; const arr=userSetlists(), i=+slKey.slice(1); if(!arr[i]||!arr[i].items[idx]) return;
|
||||
const oldName=arr[i].items[idx].name||"this track"; const nm=($("saveName").value||oldName).trim();
|
||||
if(!confirm('Overwrite "'+oldName+'" with the current settings?')) return;
|
||||
arr[i].items[idx]={name:nm, ...curSetupObj()}; saveUserSetlists(arr);
|
||||
setlist.items[idx]={name:nm, ...curSetupObj()}; lastCur=null; buildTrackOptions(); renderInfo(); renderLibrary(); flashSave("Updated ✓");
|
||||
}
|
||||
function moveList(i,dir){ const arr=userSetlists(), j=i+dir; if(j<0||j>=arr.length) return; const t=arr[i]; arr[i]=arr[j]; arr[j]=t; saveUserSetlists(arr);
|
||||
if(slKey==="s"+i) slKey="s"+j; else if(slKey==="s"+j) slKey="s"+i; buildSetlistOptions(); renderLibrary(); saveState(); }
|
||||
function moveTrack(i,j,dir){ const arr=userSetlists(), sl=arr[i], k=j+dir; if(!sl||k<0||k>=sl.items.length) return; const t=sl.items[j]; sl.items[j]=sl.items[k]; sl.items[k]=t; saveUserSetlists(arr);
|
||||
if(slKey==="s"+i){ if(idx===j) idx=k; else if(idx===k) idx=j; setlist.items=sl.items.map(it=>({...it})); buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
|
||||
function renameList(i){ const arr=userSetlists(); if(!arr[i]) return; const n=prompt("Rename set list:",arr[i].title||""); if(n==null) return; arr[i].title=n.trim()||arr[i].title; saveUserSetlists(arr);
|
||||
if(slKey==="s"+i&&setlist) setlist.title=arr[i].title; buildSetlistOptions(); renderLibrary(); saveState(); }
|
||||
function renameTrack(i,j){ const arr=userSetlists(); if(!arr[i]||!arr[i].items[j]) return; const n=prompt("Rename track:",arr[i].items[j].name||""); if(n==null) return; arr[i].items[j].name=n.trim()||arr[i].items[j].name; saveUserSetlists(arr);
|
||||
if(slKey==="s"+i){ setlist.items[j].name=arr[i].items[j].name; if(idx===j){ lastCur=null; } buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
|
||||
function deleteTrack(i,j){ const arr=userSetlists(), sl=arr[i]; if(!sl||!sl.items[j]) return; if(!confirm('Delete track "'+(sl.items[j].name||"")+'"?')) return; sl.items.splice(j,1); saveUserSetlists(arr);
|
||||
if(slKey==="s"+i){ if(!sl.items.length){ deleteListResolved(i); return; } if(idx>=sl.items.length) idx=sl.items.length-1; else if(idx>j) idx--; selectUserTrack(i,idx); } renderLibrary(); }
|
||||
function deleteList(i){ const arr=userSetlists(); if(!arr[i]) return; if(!confirm('Delete set list "'+(arr[i].title||"")+'" and all its tracks?')) return; deleteListResolved(i); }
|
||||
function deleteListResolved(i){ const arr=userSetlists(); arr.splice(i,1); saveUserSetlists(arr);
|
||||
if(slKey==="s"+i){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
|
||||
else if(slKey[0]==="s"){ const k=+slKey.slice(1); if(k>i) slKey="s"+(k-1); buildSetlistOptions(); }
|
||||
buildSaveTo(); renderLibrary(); }
|
||||
function newList(){ const n=prompt("New set list name:","My set list"); if(n==null) return; const arr=userSetlists(); arr.push({title:n.trim()||"My set list",description:"",items:[]}); saveUserSetlists(arr); buildSaveTo(); renderLibrary(); }
|
||||
|
||||
function buildSaveTo(){ savedLists=userSetlists(); const sel=$("saveTo"); sel.innerHTML="";
|
||||
savedLists.forEach((sl,i)=>sel.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")")));
|
||||
sel.appendChild(opt("__new","+ New set list…"));
|
||||
sel.value = slKey[0]==="s" ? slKey : (savedLists.length?"s0":"__new");
|
||||
$("saveNewName").style.display = sel.value==="__new" ? "block":"none"; }
|
||||
$("saveTo").onchange=()=>{ $("saveNewName").style.display = $("saveTo").value==="__new" ? "block":"none"; };
|
||||
|
||||
function renderLibrary(){ savedLists=userSetlists(); const box=$("libBody"); box.innerHTML="";
|
||||
box.appendChild(el("div","liblbl","Your set lists"));
|
||||
if(!savedLists.length) box.appendChild(el("div","libhint","None yet — “Save as new track” creates one."));
|
||||
savedLists.forEach((sl,i)=>{ const row=el("div","librow"+(slKey==="s"+i?" active":""));
|
||||
const nm=el("button","libname",(sl.title||"set list")+" ("+(sl.items?sl.items.length:0)+")"); nm.onclick=()=>selectUserList(i); row.appendChild(nm);
|
||||
row.appendChild(ibtn("↑",()=>moveList(i,-1),i===0)); row.appendChild(ibtn("↓",()=>moveList(i,1),i===savedLists.length-1));
|
||||
row.appendChild(ibtn("✎",()=>renameList(i))); row.appendChild(ibtn("✕",()=>deleteList(i))); box.appendChild(row); });
|
||||
const addL=el("button","addlane","+ New set list"); addL.onclick=newList; box.appendChild(addL);
|
||||
if(slKey[0]==="s"){ const i=+slKey.slice(1), sl=savedLists[i]; if(sl){
|
||||
box.appendChild(el("div","liblbl","Tracks in “"+(sl.title||"set list")+"”"));
|
||||
sl.items.forEach((it,j)=>{ const row=el("div","librow"+(idx===j?" active":""));
|
||||
const nm=el("button","libname",(j+1)+". "+(it.name||"track")); nm.onclick=()=>{ gotoItem(j,state.running); renderLibrary(); }; row.appendChild(nm);
|
||||
row.appendChild(ibtn("↑",()=>moveTrack(i,j,-1),j===0)); row.appendChild(ibtn("↓",()=>moveTrack(i,j,1),j===sl.items.length-1));
|
||||
row.appendChild(ibtn("✎",()=>renameTrack(i,j))); row.appendChild(ibtn("✕",()=>deleteTrack(i,j))); box.appendChild(row); });
|
||||
const addT=el("button","addlane","+ Add current track here"); addT.onclick=()=>{ $("saveTo").value="s"+i; doSaveAsNew(); }; box.appendChild(addT);
|
||||
}} else { box.appendChild(el("div","libhint","This set list is built-in (read-only). “Save as new track” copies your edits into one of your own set lists.")); }
|
||||
}
|
||||
function openSaveSheet(){
|
||||
$("saveName").value=currentName()||"My track"; buildSaveTo();
|
||||
const upd=$("saveUpd"); if(slKey[0]==="s"){ upd.style.display=""; upd.textContent='Update “'+(currentName()||"track")+'”'; } else upd.style.display="none";
|
||||
$("saveMsg").textContent=""; renderLibrary();
|
||||
$("laneSheet").classList.remove("open"); $("shareSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
|
||||
$("saveNew").onclick=doSaveAsNew; $("saveUpd").onclick=doUpdate; $("saveDone").onclick=closeSheets; $("saveBtn").onclick=openSaveSheet;
|
||||
|
||||
/* ---- share: a track or set list as a link, or paste a string to load ---- */
|
||||
function setlistToCode(sl){ return b64u(JSON.stringify({t:sl.title||"",d:sl.description||"",i:(sl.items||[]).map(it=>({n:it.name,p:setupToPatch(it)}))})); }
|
||||
function shareSetlistObj(){ return {title:setlist?setlist.title:"Set list", description:"", items:(setlist?setlist.items:[]).map((it,i)=> i===idx?{name:it.name, ...curSetupObj()}:it)}; }
|
||||
let shareKind="p";
|
||||
function shareText(){ return shareKind==="p" ? currentPatch() : setlistToCode(shareSetlistObj()); }
|
||||
function shareUrl(){ return location.origin+location.pathname+"#"+shareKind+"="+(shareKind==="p"?encodeURIComponent(currentPatch()):setlistToCode(shareSetlistObj())); }
|
||||
function refreshShare(){ $("shareLink").value=shareUrl(); $("shareSeg").querySelectorAll("button").forEach(b=>b.classList.toggle("active",b.dataset.k===shareKind)); }
|
||||
function flashShare(m){ $("shareMsg").textContent=m; setTimeout(()=>{ if($("shareMsg").textContent===m) $("shareMsg").textContent=""; },1600); }
|
||||
function copyText(s, ok){ if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(ok,()=>{}); } else { const t=$("shareLink"); t.value=s; t.select(); try{ document.execCommand("copy"); ok(); }catch(e){} refreshShare(); } }
|
||||
function openShareSheet(){ shareKind="p"; refreshShare(); $("sharePaste").value=""; $("shareMsg").textContent="";
|
||||
["laneSheet","saveSheet"].forEach(id=>$(id).classList.remove("open")); $("scrim").classList.add("open"); $("shareSheet").classList.add("open"); }
|
||||
$("shareSeg").querySelectorAll("button").forEach(b=>b.onclick=()=>{ shareKind=b.dataset.k; refreshShare(); });
|
||||
$("shareCopy").onclick=()=>copyText(shareUrl(),()=>flashShare("Link copied ✓"));
|
||||
$("shareCopyT").onclick=()=>copyText(shareText(),()=>flashShare("Copied ✓"));
|
||||
$("shareLoad").onclick=()=>{ const v=($("sharePaste").value||"").trim(); if(!v){ flashShare("Paste a string or link"); return; }
|
||||
if(loadFromHash(/[#?&](p|sl)=/.test(v)?v:("#p="+v))){ closeSheets(); } else flashShare("✗ not valid"); };
|
||||
$("shareDone").onclick=closeSheets;
|
||||
$("shareBtn").onclick=openShareSheet;
|
||||
|
||||
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
|
||||
function opt(v,t){ const o=document.createElement("option"); o.value=v; o.textContent=t; return o; }
|
||||
function og(label){ const g=document.createElement("optgroup"); g.label=label; return g; }
|
||||
function buildSetlistOptions(){
|
||||
savedLists=lsGet(LS_SETLISTS,[]);
|
||||
const sel=$("slSel"); sel.innerHTML="";
|
||||
if(slKey===""){ sel.appendChild(opt("", transientTitle||"Loaded")); }
|
||||
const g1=og("Built-in"); BUILTIN.forEach((sl,i)=>g1.appendChild(opt("b"+i, sl.title+" ("+sl.items.length+")"))); sel.appendChild(g1);
|
||||
if(savedLists.length){ const g2=og("Your set lists"); savedLists.forEach((sl,i)=>g2.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"))); sel.appendChild(g2); }
|
||||
sel.value=slKey;
|
||||
}
|
||||
function buildTrackOptions(){ const sel=$("trkSel"); sel.innerHTML="";
|
||||
if(setlist) setlist.items.forEach((it,i)=>sel.appendChild(opt(String(i),(i+1)+". "+(it.name||("Track "+(i+1)))))); sel.value=String(idx); }
|
||||
$("slSel").onchange=(e)=>{ const v=e.target.value; let sl=null;
|
||||
if(v[0]==="b") sl=BUILTIN[+v.slice(1)]; else if(v[0]==="s"){ const s=savedLists[+v.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
|
||||
if(sl){ slKey=v; transientTitle=null; loadSetlistObj(sl); } };
|
||||
$("trkSel").onchange=(e)=>{ gotoItem(+e.target.value, state.running); };
|
||||
|
||||
/* ========================= RENDER ============================================ */
|
||||
let lastCur=null;
|
||||
function renderInfo(){
|
||||
if(!editingBpm) $("bpmNum").textContent=state.bpm;
|
||||
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
|
||||
const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet(LS_CURTRACK,nm); }
|
||||
const sig=laneSignature(); if(sig!==laneSig){ laneSig=sig; buildLanes(); } else renderPadLevels();
|
||||
buildTrackPanel(); updateStatus();
|
||||
}
|
||||
function endLabel(){ if(curEnd==null) return "loop"; if(curEnd==="stop") return "stop"; if(curEnd===1) return "next";
|
||||
if(typeof curEnd==="number"&&curEnd>0) return "+"+curEnd; return String(curEnd); }
|
||||
function updateStatus(){ const m=meters[0]; let s="";
|
||||
if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); }
|
||||
if(state.running&&m){ const bar=segBars>0?((m.currentBar|0)%segBars+1):((m.currentBar|0)+1);
|
||||
s+=" · bar "+bar+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000); }
|
||||
else if(segBars>0){ s+=" · "+segBars+" bars"; }
|
||||
$("meterline").textContent=s; }
|
||||
function renderTransport(){
|
||||
const onAny=sessionActive||state.running;
|
||||
$("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>"); $("bPlay").classList.toggle("on",onAny);
|
||||
const pr=state.running&&sessionActive;
|
||||
$("bPrac").innerHTML=(pr?"❚❚<small>PAUSE</small>":"⦿<small>PRACTICE</small>"); $("bPrac").classList.toggle("on",pr); }
|
||||
function renderSessionBar(){ const bar=$("bJournal"), n=lsGet(LS_SESSIONS,[]).length; // the Journal button doubles as live session status
|
||||
if(sessionActive){ bar.classList.add("rec"); const segs=session.segments.length+(trackSegStart?1:0);
|
||||
$("jText").textContent="Recording "+fmt((Date.now()-session.at)/1000)+" · "+segs+" track"+(segs===1?"":"s"); }
|
||||
else { bar.classList.remove("rec"); $("jText").textContent=(lastSaved?"✓ saved · ":"")+"Journal"+(n?(" ("+n+")"):"")+" →"; } }
|
||||
function renderAll(){ renderInfo(); renderTransport(); saveState(); }
|
||||
|
||||
let lastBeatKey=-1, pulseTimer=null;
|
||||
function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc");
|
||||
clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); }
|
||||
let lastTimeUpd=0;
|
||||
function draw(){
|
||||
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
|
||||
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
|
||||
renderPadPlayheads();
|
||||
const m=meters[0];
|
||||
if(state.running&&m&&m.currentStep>=0){ const beat=Math.floor(m.currentStep/m.stepsPerBeat);
|
||||
if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat; if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } } }
|
||||
const t=performance.now();
|
||||
if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); }
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ========================= BPM: tap=tap-tempo · hold=type · drag=scrub ======== */
|
||||
let editingBpm=false;
|
||||
function openBpmEdit(){ editingBpm=true; const i=$("bpmIn"); $("bpm").style.display="none"; i.style.display="block"; i.value=state.bpm; i.focus(); i.select(); }
|
||||
function closeBpmEdit(commit){ const i=$("bpmIn"); if(commit){ const v=parseInt(i.value,10); if(v){ ramp.on=false; setBpm(v); } } editingBpm=false; i.style.display="none"; $("bpm").style.display=""; renderAll(); }
|
||||
$("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } });
|
||||
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
|
||||
$("bTapBtn").onclick=tapTempo; // TAP button (left of the BPM) — tap to set
|
||||
$("bpm").onclick=()=>{ if(!editingBpm) openBpmEdit(); }; // tap the number to type
|
||||
(function(){ const w=$("wheel"); let dragging=false, startY=0, startBpm=120; // thumbwheel (right) — drag to scrub
|
||||
w.addEventListener("pointerdown",(e)=>{ dragging=true; startY=e.clientY; startBpm=state.bpm; w.setPointerCapture(e.pointerId); e.preventDefault(); });
|
||||
w.addEventListener("pointermove",(e)=>{ if(!dragging) return; ramp.on=false; setBpm(startBpm+(startY-e.clientY)*0.5); renderAll(); });
|
||||
w.addEventListener("pointerup",(e)=>{ dragging=false; try{w.releasePointerCapture(e.pointerId);}catch(_){} });
|
||||
w.addEventListener("pointercancel",()=>{ dragging=false; });
|
||||
})();
|
||||
|
||||
/* ========================= PERSIST / RESTORE STATE =========================== */
|
||||
let saveTimer=null;
|
||||
function saveState(){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{ try{
|
||||
lsSet(LS_STATE,{slKey,transientTitle,idx,name:currentName(),patch:currentPatch(),volume:state.volume}); }catch(e){} },350); }
|
||||
function restoreState(){
|
||||
const st=lsGet(LS_STATE,null); if(!st||!st.patch) return false;
|
||||
try{
|
||||
if(st.volume!=null) state.volume=st.volume;
|
||||
savedLists=lsGet(LS_SETLISTS,[]); let sl=null, key=st.slKey;
|
||||
if(key&&key[0]==="b") sl=BUILTIN[+key.slice(1)];
|
||||
else if(key&&key[0]==="s"){ const s=savedLists[+key.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
|
||||
if(sl){ slKey=key; transientTitle=null; setlist=sl; idx=Math.max(0,Math.min(st.idx||0, sl.items.length-1)); }
|
||||
else { slKey=""; transientTitle=st.transientTitle||st.name||"Restored"; setlist={title:transientTitle,items:[{name:st.name||"Track",...patchToSetup(st.patch)}]}; idx=0; }
|
||||
loadSetup(patchToSetup(st.patch)); // active setup carries the user's edits + tempo
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
|
||||
/* ========================= HASH SHARE-LINK LOADING =========================== */
|
||||
function loadFromHash(text){
|
||||
let payload=text, kind=null; const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
|
||||
if(m){ kind=m[1]; payload=m[2]; } try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||
try{
|
||||
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){ const sl=codeToSetlist(payload); if(!sl.items.length) throw 0;
|
||||
slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true; }
|
||||
const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0;
|
||||
slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
|
||||
/* ========================= HELP TOUR ========================================= */
|
||||
const TOUR=[
|
||||
{sel:"#brandrow,#volrow", title:"Controls", text:"The ↑ share menu, ? to replay this tour, ◐ light/dark theme, ⛶ full screen, and the full-width volume slider (soft p → loud f)."},
|
||||
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, the TAP button to tap it out, or drag the wheel up/down to scrub."},
|
||||
{sel:".sels", title:"Pick what to play", text:"Choose a set list and the track within it. Tracks are your practice items — name them for whatever you're working on, even if two share the same beat."},
|
||||
{sel:"#saveBtn", title:"Save & library", text:"Save the current track — “Save as new”, or “Update” one of yours. The same sheet is your library: make set lists and rename / reorder / delete tracks. It all lives with the full editor too."},
|
||||
{sel:"#trackpanel", title:"Track settings", text:"Optional per-track extras: Repeat for N bars then stop / next / prev track, a tempo ramp, and practice gaps."},
|
||||
{sel:"#lanes", title:"Edit the beat", text:"Each lane is a row of pads that blink on the beat — tap a pad to cycle rest → beat → accent → ghost. The speaker button at the left of each lane mutes/unmutes it. Tap a lane's label to set its note value (eighths, triplets, sixteenths…), sound, grouping or polymeter. “+ Add lane” for more."},
|
||||
{sel:"#bDn10,#bDown,#bUp,#bUp10", title:"Nudge the tempo", text:"Step the BPM up or down while it keeps playing: −10 / −1 / +1 / +10. Great for settling on a comfortable speed or pushing it faster as you improve."},
|
||||
{sel:"#bPrev,#bNext", title:"Previous / next track", text:"⏮ and ⏭ move to the previous or next track in the current set list. If the metronome is running it carries straight on into the new track."},
|
||||
{sel:"#bPrac", title:"Practice = a timed session", text:"Play just runs the metronome. Practice times your playing and logs it (not audio): it starts a session clock and Play becomes Stop — start/pause each track, then Stop to save the session."},
|
||||
{sel:"#bJournal", title:"Practice journal", text:"Opens your saved practice sessions — notes and a per-track breakdown across days. While Practice is recording, this shows the live session timer."},
|
||||
];
|
||||
let tstep=0;
|
||||
function startTour(){ tstep=0; $("tour").classList.add("open"); showTour(); }
|
||||
function endTour(){ $("tour").classList.remove("open"); lsSet(LS_TOURED,1); }
|
||||
function showTour(){
|
||||
while(tstep<TOUR.length && !document.querySelector(TOUR[tstep].sel)) tstep++;
|
||||
if(tstep>=TOUR.length){ endTour(); return; }
|
||||
const s=TOUR[tstep], pad=6, hole=$("tourHole");
|
||||
// sel may match several elements (e.g. a row of buttons) — highlight their union
|
||||
let r=null; document.querySelectorAll(s.sel).forEach(el=>{ const b=el.getBoundingClientRect();
|
||||
r = r ? {left:Math.min(r.left,b.left),top:Math.min(r.top,b.top),right:Math.max(r.right,b.right),bottom:Math.max(r.bottom,b.bottom)} : {left:b.left,top:b.top,right:b.right,bottom:b.bottom}; });
|
||||
r.width=r.right-r.left; r.height=r.bottom-r.top;
|
||||
hole.style.left=(r.left-pad)+"px"; hole.style.top=(r.top-pad)+"px"; hole.style.width=(r.width+2*pad)+"px"; hole.style.height=(r.height+2*pad)+"px";
|
||||
$("tourTitle").textContent=s.title; $("tourText").textContent=s.text; $("tourDots").textContent=(tstep+1)+" / "+TOUR.length;
|
||||
$("tourPrev").style.visibility=tstep?"visible":"hidden"; $("tourNext").textContent=(tstep===TOUR.length-1)?"Done":"Next";
|
||||
const box=$("tourBox"), bw=Math.min(290, innerWidth-24); box.style.width=bw+"px"; box.style.left="0px"; box.style.top="-9999px";
|
||||
const bh=box.offsetHeight;
|
||||
const left=Math.max(12, Math.min(r.left, innerWidth-bw-12));
|
||||
const top=(r.bottom+12+bh < innerHeight) ? r.bottom+12 : Math.max(12, r.top-12-bh);
|
||||
box.style.left=left+"px"; box.style.top=top+"px";
|
||||
}
|
||||
$("tourNext").onclick=()=>{ if(tstep>=TOUR.length-1) endTour(); else { tstep++; showTour(); } };
|
||||
$("tourPrev").onclick=()=>{ if(tstep>0){ tstep--; showTour(); } };
|
||||
$("tourSkip").onclick=endTour;
|
||||
$("helpBtn").onclick=startTour;
|
||||
addEventListener("resize",()=>{ if($("tour").classList.contains("open")) showTour(); });
|
||||
|
||||
/* ========================= WIRING ============================================ */
|
||||
$("bPlay").onclick=play; $("bPrac").onclick=practice;
|
||||
$("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running);
|
||||
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
|
||||
$("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10);
|
||||
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; saveState(); };
|
||||
$("bJournal").addEventListener("click",()=>{ if(!sessionActive) location.href="/mobile-sessions.html"; }); // mid-session: just shows the live timer
|
||||
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
|
||||
/* ========================= FULLSCREEN + WAKE LOCK ============================ */
|
||||
const docEl=document.documentElement;
|
||||
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen, exitFS=document.exitFullscreen||document.webkitExitFullscreen;
|
||||
const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
|
||||
let wakeLock=null;
|
||||
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} }
|
||||
function releaseWake(){ try{ wakeLock&&wakeLock.release(); }catch(e){} wakeLock=null; }
|
||||
function toggleFS(){ if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} } else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} } }
|
||||
$("fsBtn").onclick=toggleFS;
|
||||
if(window.EMBED) $("fsBtn").style.display="none";
|
||||
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
|
||||
|
||||
/* PWA */
|
||||
let deferredPrompt=null;
|
||||
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; });
|
||||
if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
|
||||
addEventListener("beforeunload",(e)=>{ if(sessionActive){ e.preventDefault(); e.returnValue=""; } });
|
||||
|
||||
/* keyboard (desktop testing) */
|
||||
addEventListener("keydown",(e)=>{ const tag=(e.target&&e.target.tagName)||""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||||
const k=e.key;
|
||||
if(k===" "||e.code==="Space"){ e.preventDefault(); play(); }
|
||||
else if(k==="p"||k==="P"){ e.preventDefault(); practice(); }
|
||||
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
|
||||
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
|
||||
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
|
||||
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
|
||||
else if(k==="f"||k==="F") toggleFS();
|
||||
});
|
||||
|
||||
/* ========================= INIT ============================================== */
|
||||
if(location.hash && /(p|sl)=/.test(location.hash)) loadFromHash(location.hash);
|
||||
else restoreState();
|
||||
if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
|
||||
buildSetlistOptions(); buildTrackOptions();
|
||||
$("vol").value=Math.round(state.volume*100); if(masterGain) masterGain.gain.value=state.volume;
|
||||
renderAll(); renderSessionBar();
|
||||
requestAnimationFrame(draw);
|
||||
if(!window.EMBED && !lsGet(LS_TOURED,0)) setTimeout(()=>{ if(!$("tour").classList.contains("open")) startTour(); }, 700);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1833
pm_e-2.html
Normal file
1833
pm_e-2.html
Normal file
File diff suppressed because it is too large
Load diff
128
src/base.css
Normal file
128
src/base.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* Shared base — inlined into BOTH index.html and player.html by build.sh.
|
||||
Box-sizing reset, the VARASYS brand palette, and the common type stack.
|
||||
(Page-specific colours/layout live in each page's own <style>.) */
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--cyan: #0AB3F7; /* VARASYS brand cyan */
|
||||
--navy: #1C283F; /* VARASYS brand navy */
|
||||
}
|
||||
body {
|
||||
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- VARASYS brand lockup (the official logo PNGs already bake in the
|
||||
"Simplifying Complexity" tagline — dark variant for dark themes, light for light) ---- */
|
||||
.brand { display:inline-flex; align-items:center; flex:0 0 auto; text-decoration:none; }
|
||||
.brand-logo { height:34px; width:auto; display:block; }
|
||||
.brand-light { display:none; }
|
||||
:root[data-theme="light"] .brand-dark { display:none; }
|
||||
:root[data-theme="light"] .brand-light { display:block; }
|
||||
/* on-device silkscreen brand (official logo image, tagline included) — used in device brandrows */
|
||||
.dev-lock { display:inline-flex; align-items:center; }
|
||||
.dev-logo { display:block; width:auto; height:20px; }
|
||||
.site-head { width:100%; max-width:980px; margin:0 auto; display:flex; align-items:center;
|
||||
justify-content:space-between; gap:10px 16px; flex-wrap:wrap; }
|
||||
.head-left { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.page-name { font-size:13px; color:var(--muted,#7f8b9a); letter-spacing:.02em; }
|
||||
.page-name b { color:var(--txt,#c7d0db); }
|
||||
.site-nav { display:flex; align-items:center; gap:14px; font-size:13px; }
|
||||
.site-nav a { color:var(--muted,#7f8b9a); text-decoration:none; }
|
||||
.site-nav a:hover { color:var(--txt,#c7d0db); }
|
||||
.site-nav a.here { color:var(--cyan); }
|
||||
.tbtn { background:transparent; color:var(--muted,#7f8b9a); border:1px solid var(--panel-bd,#2a313c);
|
||||
border-radius:8px; padding:3px 9px; font-size:14px; line-height:1; cursor:pointer; }
|
||||
.tbtn:hover { color:var(--txt,#c7d0db); }
|
||||
/* embed mode: pages opened with ?embed=1 strip all site chrome (the widget only).
|
||||
The flag is set on <html> in a head pre-paint script (no flash). */
|
||||
[data-embed] .site-head, [data-embed] .site-foot { display:none !important; }
|
||||
[data-embed] body { padding:10px !important; }
|
||||
|
||||
/* ---- bill-of-materials table (info pages) ---- */
|
||||
.sub { color:var(--muted,#7f8b9a); font-size:13px; line-height:1.5; }
|
||||
.bom { width:100%; border-collapse:collapse; font-size:12px; margin-top:8px; }
|
||||
.bom th, .bom td { text-align:left; padding:5px 6px; border-bottom:1px solid var(--panel-bd,#2a313c); vertical-align:top; }
|
||||
.bom th { color:var(--muted,#7f8b9a); font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:.05em; }
|
||||
.bom th.q, .bom th.c, .bom td.q, .bom td.c { text-align:right; white-space:nowrap; }
|
||||
.bom td.q, .bom td.c { color:var(--muted,#7f8b9a); }
|
||||
.bom .grp td { color:var(--cyan); font-weight:700; font-size:10px; text-transform:uppercase; letter-spacing:.07em; padding-top:11px; }
|
||||
.bom .part { color:var(--txt,#c7d0db); }
|
||||
.bom .part .spec { color:var(--muted,#7f8b9a); font-weight:400; }
|
||||
.bom tr.total td { font-weight:700; color:var(--txt,#c7d0db); border-top:2px solid var(--panel-bd,#2a313c); border-bottom:none; padding-top:8px; }
|
||||
|
||||
/* ---- shared site footer ---- */
|
||||
.site-foot { width:100%; max-width:980px; margin:40px auto 0; font-size:12px; color:var(--muted,#7f8b9a);
|
||||
text-align:center; display:flex; align-items:center; justify-content:center; gap:8px; flex-wrap:wrap; }
|
||||
.site-foot a { color:var(--muted,#7f8b9a); }
|
||||
.site-foot a:hover { color:var(--txt,#c7d0db); }
|
||||
.site-foot .dot { opacity:.5; }
|
||||
|
||||
/* ---- expandable "Spec & BOM" disclosure (merged onto each form-factor page) ---- */
|
||||
details.spec { width:100%; max-width:760px; margin:18px auto 0; border:1px solid var(--panel-bd,#2a313c);
|
||||
border-radius:12px; background:var(--panel-bg,#161b22); }
|
||||
details.spec > summary { cursor:pointer; padding:13px 16px; font-weight:600; font-size:14px; color:var(--txt,#c7d0db);
|
||||
list-style:none; display:flex; align-items:center; gap:9px; }
|
||||
details.spec > summary::-webkit-details-marker { display:none; }
|
||||
details.spec > summary::before { content:"▸"; color:var(--muted,#7f8b9a); transition:transform .15s; }
|
||||
details.spec[open] > summary::before { transform:rotate(90deg); }
|
||||
details.spec .spec-body { padding:2px 16px 16px; }
|
||||
[data-embed] details.spec { display:none !important; }
|
||||
|
||||
/* ---- per-form-factor page: the description above the expandable spec ---- */
|
||||
.about { width:100%; max-width:760px; margin:20px auto 0; color:var(--muted,#7f8b9a); font-size:14px; line-height:1.62; }
|
||||
.about h2 { color:var(--txt,#c7d0db); font-size:18px; margin:0 0 4px; }
|
||||
.about .ff-tags { display:flex; gap:8px; flex-wrap:wrap; margin:6px 0 10px; }
|
||||
.about .ff-tags span { font-size:11px; color:var(--muted,#7f8b9a); border:1px solid var(--panel-bd,#2a313c); border-radius:999px; padding:2px 9px; }
|
||||
.about .ff-tags .hw { color:var(--cyan); border-color:rgba(10,179,247,.45); }
|
||||
.about p { margin:0 0 10px; max-width:64ch; }
|
||||
/* page-only chrome (description, dimensioned views, loading panel) — hidden when embedded */
|
||||
[data-embed] .pageonly { display:none !important; }
|
||||
|
||||
/* ---- per-form-factor page: visible title + summary (plain view) ---- */
|
||||
.ff-title { font-size:20px; margin:6px 0 2px; text-align:center; color:var(--txt,#c7d0db); }
|
||||
.ff-sum { max-width:60ch; margin:0 auto; text-align:center; color:var(--muted,#7f8b9a); font-size:13.5px; line-height:1.55; }
|
||||
[data-embed] .ff-title, [data-embed] .ff-sum { display:none !important; }
|
||||
/* link from the lean device page out to its info page (specs / dimensions / BOM) */
|
||||
.ff-link { text-align:center; margin:16px auto 0; font-size:13px; }
|
||||
.ff-link a { font-weight:600; }
|
||||
|
||||
/* ---- info-<device>.html: the embedded live widget at the top of the spec page ---- */
|
||||
.infoview { width:100%; max-width:760px; margin:18px auto 0; border:1px solid var(--panel-bd,#2a313c);
|
||||
border-radius:14px; overflow:hidden; background:var(--field-bg,#0e1116); }
|
||||
.iv-bar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 12px;
|
||||
border-bottom:1px solid var(--panel-bd,#2a313c); font-size:12px; color:var(--muted,#7f8b9a); }
|
||||
.iv-bar b { color:var(--txt,#c7d0db); }
|
||||
.infoview iframe { display:block; width:100%; height:440px; border:0; background:var(--field-bg,#0e1116); transition:height .15s; }
|
||||
|
||||
/* ---- per-device program I/O box (plain view) ---- */
|
||||
.progbox { width:100%; max-width:620px; margin:14px auto 0; display:flex; align-items:center; gap:9px; flex-wrap:wrap;
|
||||
padding:9px 12px; border:1px solid var(--panel-bd,#2a313c); border-radius:11px; background:var(--panel-bg,#161b22); }
|
||||
.progbox > label { flex:0 0 auto; font-size:10px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted,#7f8b9a); }
|
||||
.progbox input { flex:1; min-width:160px; background:var(--field-bg,#0e1116); color:var(--txt,#c7d0db);
|
||||
border:1px solid var(--field-bd,#2a313c); border-radius:8px; padding:8px 10px; font-family:"Courier New",monospace; font-size:12.5px; }
|
||||
.progbox input.err { border-color:#c0392b; }
|
||||
.progbox button { flex:0 0 auto; background:var(--field-bg,#0e1116); color:var(--txt,#c7d0db); border:1px solid var(--field-bd,#2a313c);
|
||||
border-radius:8px; padding:8px 12px; font-size:13px; cursor:pointer; }
|
||||
.progbox button.primary { background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; border-color:transparent; font-weight:600; }
|
||||
.progbox button:hover { border-color:var(--cyan); }
|
||||
.progbox-msg { flex:1 1 100%; font-size:11.5px; color:var(--muted,#7f8b9a); min-height:1em; }
|
||||
.progbox-msg.ok { color:#5fd08a; } .progbox-msg.bad { color:#ff8a7a; }
|
||||
[data-embed] .progbox { display:none !important; }
|
||||
|
||||
/* ---- shared dimensioned schematic views (front + top/side with inch dims) ---- */
|
||||
.dview { width:100%; max-width:540px; margin:16px auto 0; }
|
||||
.dview .cap { text-align:center; font-size:11px; color:var(--muted,#7f8b9a); margin:0 0 8px; }
|
||||
.drow { display:flex; align-items:stretch; gap:6px; }
|
||||
.dvy { flex:0 0 15px; writing-mode:vertical-rl; transform:rotate(180deg); display:flex; align-items:center; justify-content:center;
|
||||
font-size:9px; color:var(--muted,#7f8b9a); letter-spacing:.03em; white-space:nowrap; border-right:1px solid var(--panel-bd,#2a313c); }
|
||||
.dvx { text-align:center; font-size:9px; color:var(--muted,#7f8b9a); letter-spacing:.03em;
|
||||
border-top:1px solid var(--panel-bd,#2a313c); padding-top:3px; margin:3px 0 0 17px; }
|
||||
.dschem { flex:1; min-width:0; position:relative; border:1px solid #33363c; border-radius:8px;
|
||||
background:radial-gradient(rgba(255,255,255,.02) .5px, transparent .6px) 0 0/3px 3px, linear-gradient(160deg,#26282d,#15161a);
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 6px 16px rgba(0,0,0,.4); overflow:hidden; }
|
||||
.dschem .scap { position:absolute; left:7px; top:5px; font-size:8px; color:var(--silk,#aab2bc); letter-spacing:.08em; text-transform:uppercase; opacity:.7; }
|
||||
.dschem .ctl { position:absolute; border-radius:50%; background:radial-gradient(circle at 38% 32%,#cfd6dd,#6c7480 60%,#3b424c); box-shadow:0 1px 3px rgba(0,0,0,.5); }
|
||||
.dschem .scr { position:absolute; border-radius:4px; background:#06080c; box-shadow:inset 0 0 8px rgba(0,0,0,.7); }
|
||||
.dschem .jk { position:absolute; width:12px; height:12px; border-radius:50%; border:2px solid #5b6470; background:radial-gradient(circle at 40% 34%,#333a44,#07090c 72%); }
|
||||
.dschem .jk.u { width:14px; height:6px; border-radius:3px; }
|
||||
.dschem .jl { position:absolute; font-size:7px; color:var(--silk,#aab2bc); letter-spacing:.03em; text-transform:uppercase; opacity:.85; text-align:center; line-height:1.1; }
|
||||
22
src/chrome.js
Normal file
22
src/chrome.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/* Shared site chrome — assembled into every page by build.sh.
|
||||
Wires the header theme toggle (system - light - dark, shared "metronome.theme")
|
||||
and stamps the footer version. Expects a global APP_VERSION declared earlier in
|
||||
the page (deploy.sh rewrites that line) and the header/footer markup present. */
|
||||
(function () {
|
||||
var byId = function (id) { return document.getElementById(id); };
|
||||
var THEMES = ["system", "light", "dark"];
|
||||
function eff(p) { return p === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p; }
|
||||
function pref() { try { var p = localStorage.getItem("metronome.theme"); return (p === "light" || p === "dark" || p === "system") ? p : "system"; } catch (e) { return "system"; } }
|
||||
function apply(p) {
|
||||
try { localStorage.setItem("metronome.theme", p); } catch (e) {}
|
||||
document.documentElement.dataset.theme = eff(p);
|
||||
var b = byId("themeBtn"); if (b) { b.textContent = p === "system" ? "◐" : p === "light" ? "☀" : "☾"; b.title = "Theme: " + p + " (system → light → dark)"; }
|
||||
}
|
||||
function init() {
|
||||
try { var v = byId("appVersion"); if (v && typeof APP_VERSION !== "undefined") v.textContent = "v" + APP_VERSION.replace(/^v/, ""); } catch (e) {}
|
||||
var btn = byId("themeBtn"); if (btn) btn.onclick = function () { apply(THEMES[(THEMES.indexOf(pref()) + 1) % THEMES.length]); };
|
||||
apply(pref());
|
||||
}
|
||||
try { matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () { if (pref() === "system") apply("system"); }); } catch (e) {}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init();
|
||||
})();
|
||||
294
src/engine.js
Normal file
294
src/engine.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/* =========================================================================
|
||||
SHARED ENGINE — inlined into every page by build.sh.
|
||||
Audio voices (all SYNTHESIZED — no samples), the Web Audio look-ahead
|
||||
scheduler primitives (PORTS TO FIRMWARE), and the share-language codec.
|
||||
Each host supplies its own state globals (state, meters, ramp, trainer,
|
||||
segBars, masterBeat…), its own setBpm, advanceMaster and scheduler().
|
||||
Conventions (kept close to what people already know): GM drum names +
|
||||
note-number aliases, drum-tab step patterns (X accent / x normal / g ghost /
|
||||
. - _ rest), subdivisions /2 /3 /4, Euclidean (k,n) shorthand, per-lane gain
|
||||
in dB (@<db>), and ~ = polymeter.
|
||||
========================================================================= */
|
||||
const LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
|
||||
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
|
||||
let audioCtx = null, masterGain = null, noiseBuf = null, schedulerTimer = null;
|
||||
|
||||
|
||||
// --- grouping: "2+2+3" → groups / beatsPerBar / group-start indices ---
|
||||
function parseGroups(str) {
|
||||
const parts = String(str).split(/[^0-9]+/).map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 12);
|
||||
let total = 0; const groups = [];
|
||||
for (const p of parts) { if (total + p > 12) break; groups.push(p); total += p; }
|
||||
if (!groups.length) groups.push(4);
|
||||
const beatsPerBar = groups.reduce((a, b) => a + b, 0);
|
||||
const groupStarts = new Set(); let acc = 0;
|
||||
for (const g of groups) { groupStarts.add(acc); acc += g; }
|
||||
return { groups, beatsPerBar, groupStarts };
|
||||
}
|
||||
|
||||
// --- audio context ---
|
||||
function ensureAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = audioCtx.createGain();
|
||||
masterGain.gain.value = state.volume;
|
||||
masterGain.connect(audioCtx.destination);
|
||||
}
|
||||
|
||||
// --- shared noise buffer ---
|
||||
function getNoise() {
|
||||
if (!noiseBuf) {
|
||||
const n = Math.floor(audioCtx.sampleRate * 1.0);
|
||||
noiseBuf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
|
||||
const d = noiseBuf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return noiseBuf;
|
||||
}
|
||||
|
||||
// --- synthesized GM-style voices (level = velocity) ---
|
||||
function ampEnv(time, peak, dur, attack) {
|
||||
const g = audioCtx.createGain();
|
||||
peak = Math.max(0.0003, peak);
|
||||
g.gain.setValueAtTime(0.0001, time);
|
||||
g.gain.exponentialRampToValueAtTime(peak, time + (attack || 0.001));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
||||
return g;
|
||||
}
|
||||
function tone(time, type, f0, f1, dur) {
|
||||
const o = audioCtx.createOscillator(); o.type = type;
|
||||
o.frequency.setValueAtTime(f0, time);
|
||||
if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), time + Math.min(dur, 0.09));
|
||||
o.start(time); o.stop(time + dur + 0.02); return o;
|
||||
}
|
||||
function noiseSrc(time, dur) { const s = audioCtx.createBufferSource(); s.buffer = getNoise(); s.start(time); s.stop(time + dur + 0.02); return s; }
|
||||
function filt(type, freq, q) { const f = audioCtx.createBiquadFilter(); f.type = type; f.frequency.value = freq; if (q) f.Q.value = q; return f; }
|
||||
function v_tone(time, level, type, f0, f1, dur, peak) { const o = tone(time, type, f0, f1, dur), g = ampEnv(time, peak * level, dur, 0.002); o.connect(g); g.connect(masterGain); }
|
||||
function v_noise(time, level, fType, freq, q, dur, peak, attack) { const n = noiseSrc(time, dur), f = filt(fType, freq, q), g = ampEnv(time, peak * level, dur, attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||||
// 6 detuned square oscillators → bandpass + highpass = the classic 808/909 metallic hi-hat/cymbal timbre.
|
||||
function metalHat(time, level, dur, hpFreq, peak) {
|
||||
const fund = 40, ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
|
||||
const bp = filt("bandpass", 10000, 0.8), hp = filt("highpass", hpFreq, 0), g = ampEnv(time, peak * level, dur, 0.001);
|
||||
ratios.forEach((r) => { const o = audioCtx.createOscillator(); o.type = "square"; o.frequency.value = fund * r; o.start(time); o.stop(time + dur + 0.02); o.connect(bp); });
|
||||
bp.connect(hp); hp.connect(g); g.connect(masterGain);
|
||||
}
|
||||
|
||||
// --- voice table + sample aliases ---
|
||||
const DRUMS = {
|
||||
beep: (t, l) => v_tone(t, l, "square", l >= 1 ? 1600 : 1100, 0, 0.04, 0.5),
|
||||
kick: (t, l) => v_tone(t, l, "sine", 150, 50, 0.18, 1.0),
|
||||
snare: (t, l) => { v_tone(t, l, "triangle", 190, 140, 0.12, 0.45); v_noise(t, l, "highpass", 1500, 0, 0.2, 0.8); },
|
||||
rim: (t, l) => { const o = tone(t, "square", 1700, 0, 0.04), bp = filt("bandpass", 1700, 4), g = ampEnv(t, 0.6 * l, 0.04); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
clap: (t, l) => { const bp = filt("bandpass", 1200, 1.4); bp.connect(masterGain); [0, 0.012, 0.024].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 2 ? 0.5 : 0.85) * l, 0.06); n.connect(e); e.connect(bp); }); },
|
||||
hatClosed: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.045, 0.5),
|
||||
hatOpen: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.32, 0.45, 0.002),
|
||||
ride: (t, l) => { v_noise(t, l, "bandpass", 6000, 0.8, 0.4, 0.32, 0.002); v_tone(t, l, "square", 5200, 0, 0.1, 0.13); },
|
||||
crash: (t, l) => v_noise(t, l, "highpass", 4000, 0, 0.8, 0.5, 0.002),
|
||||
tomLow: (t, l) => v_tone(t, l, "sine", 150, 100, 0.25, 0.9),
|
||||
tomMid: (t, l) => v_tone(t, l, "sine", 220, 150, 0.23, 0.9),
|
||||
tomHigh: (t, l) => v_tone(t, l, "sine", 300, 210, 0.20, 0.9),
|
||||
tambourine:(t, l) => v_noise(t, l, "highpass", 8000, 0, 0.12, 0.5),
|
||||
cowbell: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
woodblock: (t, l) => v_tone(t, l, "triangle", 1800, 1500, 0.06, 0.8),
|
||||
claves: (t, l) => v_tone(t, l, "sine", 2500, 0, 0.045, 0.85),
|
||||
jamblock: (t, l) => { const o = tone(t, "square", 2600, 2000, 0.045), bp = filt("bandpass", 2000, 6), g = ampEnv(t, 0.8 * l, 0.045); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
// --- electronic drum-machine voices (synthesized — these machines ARE synths in reality) ---
|
||||
kick808: (t, l) => { v_tone(t, l, "sine", 120, 45, 0.7, 1.0); v_noise(t, l * 0.5, "highpass", 2000, 0, 0.008, 0.4, 0.001); }, // long boom + click
|
||||
snare808: (t, l) => { v_tone(t, l, "triangle", 178, 168, 0.16, 0.4); v_tone(t, l, "triangle", 331, 320, 0.12, 0.18); v_noise(t, l, "highpass", 1000, 0, 0.16, 0.7); },
|
||||
clap808: (t, l) => { const bp = filt("bandpass", 1100, 1.3); bp.connect(masterGain); [0, 0.01, 0.02, 0.032].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 3 ? 0.5 : 0.85) * l, 0.05); n.connect(e); e.connect(bp); }); },
|
||||
hat808: (t, l) => metalHat(t, l, 0.045, 7000, 0.4),
|
||||
openHat808: (t, l) => metalHat(t, l, 0.34, 7000, 0.38),
|
||||
cowbell808: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
tom808: (t, l) => v_tone(t, l, "sine", 120, 78, 0.34, 0.9),
|
||||
kick909: (t, l) => { v_tone(t, l, "sine", 110, 46, 0.26, 1.0); v_tone(t, l, "triangle", 280, 60, 0.035, 0.5); v_noise(t, l * 0.6, "highpass", 3000, 0, 0.01, 0.5, 0.001); }, // punchy + click
|
||||
snare909: (t, l) => { v_tone(t, l, "triangle", 190, 162, 0.09, 0.28); v_noise(t, l, "highpass", 1200, 0, 0.2, 0.85); },
|
||||
clap909: (t, l) => { const bp = filt("bandpass", 1000, 1.0); bp.connect(masterGain); [0, 0.009, 0.018].forEach((d) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, 0.6 * l, 0.05); n.connect(e); e.connect(bp); }); const tail = noiseSrc(t + 0.018, 0.2), te = ampEnv(t + 0.018, 0.35 * l, 0.2, 0.001); tail.connect(te); te.connect(bp); },
|
||||
hat909: (t, l) => metalHat(t, l, 0.05, 9000, 0.4),
|
||||
ride909: (t, l) => { metalHat(t, l, 0.5, 6000, 0.3); v_noise(t, l, "bandpass", 7000, 0.7, 0.18, 0.18, 0.002); },
|
||||
crash909: (t, l) => { metalHat(t, l, 0.9, 5000, 0.34); v_noise(t, l, "highpass", 4000, 0, 0.9, 0.4, 0.002); },
|
||||
};
|
||||
const VOICES = [
|
||||
["beep", "beep"], ["kick", "kick"], ["snare", "snare"], ["rim", "rim/stick"], ["clap", "clap"],
|
||||
["hatClosed", "hat closed"], ["hatOpen", "hat open"], ["ride", "ride"], ["crash", "crash"],
|
||||
["tomLow", "tom low"], ["tomMid", "tom mid"], ["tomHigh", "tom high"], ["tambourine", "tambourine"],
|
||||
["cowbell", "cowbell"], ["woodblock", "wood block"], ["claves", "claves"], ["jamblock", "jam block"],
|
||||
["kick808", "808 kick"], ["snare808", "808 snare"], ["clap808", "808 clap"], ["hat808", "808 hat"], ["openHat808", "808 open hat"], ["cowbell808", "808 cowbell"], ["tom808", "808 tom"],
|
||||
["kick909", "909 kick"], ["snare909", "909 snare"], ["clap909", "909 clap"], ["hat909", "909 hat"], ["ride909", "909 ride"], ["crash909", "909 crash"],
|
||||
];
|
||||
// Default kit points the friendly GM names at the punchier 808/909 renders
|
||||
// (samples removed — those synth voices sound better). Pick "kick808"/"kick909"
|
||||
// etc. explicitly for a specific machine flavour.
|
||||
const KIT_ALIAS = {
|
||||
kick: "kick909", snare: "snare909", clap: "clap909",
|
||||
hatClosed: "hat909", hatOpen: "openHat808", ride: "ride909", crash: "crash909",
|
||||
cowbell: "cowbell808",
|
||||
};
|
||||
// General-MIDI percussion note numbers → our voice names (so MIDI/DAW users can type numbers)
|
||||
const GM_NUM = {
|
||||
35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare",
|
||||
41: "tomLow", 42: "hatClosed", 43: "tomLow", 44: "hatClosed", 45: "tomMid",
|
||||
46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash", 50: "tomHigh",
|
||||
51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves",
|
||||
76: "woodblock", 77: "woodblock",
|
||||
};
|
||||
// Euclidean / Bjorklund-class even distribution: k hits over n steps, rotated by rot
|
||||
function euclid(k, n, rot) {
|
||||
n = Math.max(1, n | 0); k = Math.max(0, Math.min(n, k | 0)); rot = (((rot | 0) % n) + n) % n;
|
||||
const r = []; for (let i = 0; i < n; i++) { const j = (i + rot) % n; r.push(((j * k) % n) < k ? 1 : 0); }
|
||||
return r;
|
||||
}
|
||||
function playInstrument(type, time, level) {
|
||||
(DRUMS[KIT_ALIAS[type] || type] || DRUMS[type] || DRUMS.beep)(time, level);
|
||||
}
|
||||
|
||||
// --- scheduler primitives (PORTS TO FIRMWARE) ---
|
||||
function masterBeatsPerBar() { return meters.length ? meters[0].beatsPerBar : 4; }
|
||||
|
||||
function isMutedAt(t) { return muteWindows.some((w) => t >= w.start && t < w.end); }
|
||||
|
||||
// --- per-step scheduling + dynamics ---
|
||||
function scheduleMeterTick(m, time) {
|
||||
const spb = m.stepsPerBeat;
|
||||
const barLen = m.beatsPerBar * spb;
|
||||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
||||
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
|
||||
if (!m.enabled || isMutedAt(time)) return;
|
||||
const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost
|
||||
if (!lvl) return;
|
||||
const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter)
|
||||
playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin);
|
||||
// opt-in per-hit hook (a page may define onMeterHit to e.g. emit MIDI out to external gear);
|
||||
// (sound name, audio-context time of the hit, dynamic level 1/2/3). No-op on pages that don't set it.
|
||||
if (typeof onMeterHit === "function") onMeterHit(m.sound, time, lvl);
|
||||
}
|
||||
|
||||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||||
|
||||
// --- step duration (poly / swing / straight) ---
|
||||
function laneStepDur(m, tick) {
|
||||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm (no swing)
|
||||
const beat = 60 / state.bpm;
|
||||
if (m.swing && m.stepsPerBeat % 2 === 0) { // swing even subdivisions (8ths, 16ths): long–short pairs
|
||||
const pairDur = beat / (m.stepsPerBeat / 2);
|
||||
return ((tick % m.stepsPerBeat) % 2) === 0 ? SWING_RATIO * pairDur : (1 - SWING_RATIO) * pairDur;
|
||||
}
|
||||
return beat / m.stepsPerBeat; // straight: shared even grid
|
||||
}
|
||||
|
||||
// --- pattern cell codec: char ⇄ (level, ornament) ---
|
||||
// level: 0 rest / 1 normal / 2 accent / 3 ghost. ornament: 0 none / 1 flam / 2 drag / 3 roll.
|
||||
// Ornaments use new letters, UPPER-case = accented hit, lower-case = normal hit (case carries the
|
||||
// dynamic so it stays orthogonal): f/F flam · d/D drag · z/Z roll. Ghosted ornaments aren't expressible.
|
||||
function patCell(ch) {
|
||||
switch (ch) {
|
||||
case "X": return [2, 0];
|
||||
case "x": case "1": return [1, 0];
|
||||
case "g": return [3, 0];
|
||||
case "f": return [1, 1]; case "F": return [2, 1];
|
||||
case "d": return [1, 2]; case "D": return [2, 2];
|
||||
case "z": return [1, 3]; case "Z": return [2, 3];
|
||||
default: return [0, 0]; // . - _ / anything else = rest
|
||||
}
|
||||
}
|
||||
function cellCh(lvl, orn) {
|
||||
if (orn === 1) return lvl >= 2 ? "F" : "f";
|
||||
if (orn === 2) return lvl >= 2 ? "D" : "d";
|
||||
if (orn === 3) return lvl >= 2 ? "Z" : "z";
|
||||
return lvl === 3 ? "g" : lvl >= 2 ? "X" : lvl >= 1 ? "x" : ".";
|
||||
}
|
||||
|
||||
// --- share-language codec: config ⇄ lane token ---
|
||||
function laneCfgToStr(c) {
|
||||
let s = c.sound + ":" + c.groupsStr;
|
||||
const spb = c.stepsPerBeat || 1;
|
||||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
|
||||
const orn = c.orns || []; // per-step ornament (flam/drag/roll), parallel to beatsOn
|
||||
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
||||
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
|
||||
const isDefault = !anyOrn && on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v, i) => cellCh(v | 0, orn[i] | 0)).join("");
|
||||
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
||||
if (c.poly) s += "~";
|
||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||
return s;
|
||||
}
|
||||
function laneStrToCfg(tok) {
|
||||
let poly = false, disabled = false, gainDb = 0;
|
||||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||||
const at = tok.indexOf("@"); if (at >= 0) { gainDb = parseFloat(tok.slice(at + 1)) || 0; tok = tok.slice(0, at); } // @<db> gain
|
||||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||||
if (/^\d+$/.test(sound) && GM_NUM[sound]) sound = GM_NUM[sound]; // GM note number → name
|
||||
// Euclidean shorthand (k,n[,rot]) — replaces an explicit =pattern
|
||||
let eucK = null, eucN = null, eucRot = 0;
|
||||
const em = rest.match(/\((\d+)(?:,(\d+))?(?:,(\d+))?\)/);
|
||||
if (em) { eucK = +em[1]; eucN = em[2] != null ? +em[2] : null; eucRot = em[3] != null ? +em[3] : 0; rest = rest.slice(0, em.index) + rest.slice(em.index + em[0].length); }
|
||||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||||
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
||||
let beatsOn, orns;
|
||||
if (eucK != null) { // k hits spread evenly; first hit accented
|
||||
let n = eucN || (bpb * sub);
|
||||
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
||||
let first = true;
|
||||
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
||||
orns = beatsOn.map(() => 0); // euclid hits carry no ornament
|
||||
} else if (pattern != null) {
|
||||
// pattern cells: per-step (level, ornament) — X accent, x/1 normal, g ghost, f/F flam, d/D drag,
|
||||
// z/Z roll, . - _ / anything else = rest. See patCell().
|
||||
const cells = pattern.split("").map(patCell);
|
||||
beatsOn = cells.map((c) => c[0]);
|
||||
orns = cells.map((c) => c[1]);
|
||||
} else {
|
||||
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
||||
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||
orns = beatsOn.map(() => 0);
|
||||
}
|
||||
if (!DRUMS[sound]) sound = "beep";
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, orns, poly, swing, enabled: !disabled, gainDb };
|
||||
}
|
||||
|
||||
// --- share-language codec: patch ⇄ setup ---
|
||||
function setupToPatch(s) {
|
||||
const parts = ["v1", "t" + s.bpm];
|
||||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||||
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||||
if (s.bars > 0) parts.push("b" + s.bars);
|
||||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||
if (s.end != null) { // per-track playback flow (default = loop forever)
|
||||
if (s.rep != null && s.rep > 1) parts.push("rep=" + s.rep); // cycles before end fires (1 = default, omitted)
|
||||
parts.push("end=" + (s.end === "stop" ? "stop" : s.end === 1 ? "next" : s.end > 0 ? "+" + s.end : String(s.end)));
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
function patchToSetup(str) {
|
||||
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], rep: null, end: null, trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||||
for (let tok of String(str).split(";")) {
|
||||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
|
||||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||||
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||||
else if (tok.startsWith("rep=")) s.rep = parseInt(tok.slice(4), 10) || 1; // playback flow: cycles before end fires
|
||||
else if (tok.startsWith("end=")) { const v = tok.slice(4); s.end = v === "stop" ? "stop" : v === "next" ? 1 : (parseInt(v, 10) || 0); } // stop | next(+1) | relative goto ±N
|
||||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||||
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
|
||||
else if (tok.startsWith("t")) s.bpm = Math.max(5, Math.min(300, parseInt(tok.slice(1), 10) || 120)); // clamp like the firmware
|
||||
}
|
||||
if (!s.lanes.length) s.lanes.push(laneStrToCfg("beep:4")); // a patch always has >=1 lane (match the firmware default)
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- base64url(JSON) set-list codec ---
|
||||
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
||||
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
|
||||
|
||||
|
||||
function codeToSetlist(code) {
|
||||
const o = JSON.parse(unb64u(code));
|
||||
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
|
||||
}
|
||||
8
src/footer.html
Normal file
8
src/footer.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!-- Shared site footer — assembled into every page by build.sh. -->
|
||||
<footer class="site-foot">
|
||||
<span>VARASYS · Simplifying Complexity</span>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">source</a>
|
||||
<span class="dot">·</span>
|
||||
<span id="appVersion">v0.0.1-dev</span>
|
||||
</footer>
|
||||
14
src/header.html
Normal file
14
src/header.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!-- Shared site header — assembled into every page by build.sh.
|
||||
Brand goes to Concepts (the landing); nav: Concepts / Editor / Embed / Theme. -->
|
||||
<header class="site-head">
|
||||
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
|
||||
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
|
||||
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
|
||||
</a>
|
||||
<nav class="site-nav">
|
||||
<a href="/">Concepts</a>
|
||||
<a href="/editor.html">Editor</a>
|
||||
<a href="/embed.html">Embed</a>
|
||||
<button id="themeBtn" class="tbtn" title="toggle light / dark theme">☀</button>
|
||||
</nav>
|
||||
</header>
|
||||
73
src/midiout.js
Normal file
73
src/midiout.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* MIDI OUT — send the groove as MIDI to external gear (a drum module / e-kit), in sync with
|
||||
playback, plus an optional 24-PPQN MIDI clock + Start/Stop so the gear's tempo locks to the editor.
|
||||
Shared by editor.html + pm_e-2.html (one copy → no drift).
|
||||
|
||||
Relies on page globals: $, audioCtx, state, _midiOutputs(), _ensureMidi(), _isDevicePort().
|
||||
The page wires three transport hooks: midiOutStart(t0) in start(), midiOutStop() in stop(),
|
||||
midiOutClock(aheadTime) at the end of scheduler(); engine.js calls onMeterHit() per scheduled hit. */
|
||||
const SOUND_GM = {
|
||||
kick: 36, kick808: 36, kick909: 36, snare: 38, snare808: 38, snare909: 38,
|
||||
clap: 39, clap808: 39, clap909: 39, rim: 37,
|
||||
hatClosed: 42, hat808: 42, hat909: 42, hatOpen: 46, openHat808: 46,
|
||||
ride: 51, ride909: 51, crash: 49, crash909: 49,
|
||||
tomLow: 41, tom808: 45, tomMid: 45, tomHigh: 48, tambourine: 54,
|
||||
cowbell: 56, cowbell808: 56, woodblock: 76, jamblock: 76, claves: 75, beep: 37,
|
||||
};
|
||||
let _midiOutOn = false, _midiClkNext = 0;
|
||||
|
||||
function _midiOutTarget() {
|
||||
const sel = $("midiOutSel"); if (!sel) return null;
|
||||
return _midiOutputs().find((o) => o.id === sel.value) || null;
|
||||
}
|
||||
function populateMidiOutPorts() {
|
||||
const sel = $("midiOutSel"); if (!sel) return;
|
||||
const outs = _midiOutputs(), prev = sel.value;
|
||||
sel.innerHTML = outs.length
|
||||
? outs.map((o) => `<option value="${o.id}">${(o.name || "output").slice(0, 22)}</option>`).join("")
|
||||
: `<option value="">(no MIDI outputs)</option>`;
|
||||
if (outs.some((o) => o.id === prev)) sel.value = prev; // keep prior choice
|
||||
else { const ext = outs.find((o) => !_isDevicePort(o)); sel.value = ((ext || outs[0] || {}).id) || ""; } // prefer external gear
|
||||
}
|
||||
// schedule `bytes` at audio-context time `audioTime`, converted to the Web-MIDI (performance.now) clock
|
||||
function _midiOutSendAt(bytes, audioTime) {
|
||||
const out = _midiOutTarget(); if (!out) return;
|
||||
const ts = (typeof audioCtx !== "undefined" && audioCtx)
|
||||
? performance.now() + (audioTime - audioCtx.currentTime) * 1000 : performance.now();
|
||||
try { out.send(bytes, ts); } catch (_) {}
|
||||
}
|
||||
// engine.js per-hit hook: GM drum Note-On (ch10) at the hit's audio time, then a Note-Off 60 ms later
|
||||
function onMeterHit(sound, time, lvl) {
|
||||
if (!_midiOutOn) return;
|
||||
const note = SOUND_GM[sound]; if (note == null) return;
|
||||
const vel = lvl === 2 ? 112 : lvl === 3 ? 45 : 90; // accent / ghost / normal
|
||||
_midiOutSendAt([0x99, note, vel], time);
|
||||
_midiOutSendAt([0x89, note, 0], time + 0.06);
|
||||
}
|
||||
function _clockOn() { const c = $("midiClkChk"); return _midiOutOn && !!c && c.checked; }
|
||||
function midiOutStart(t0) { if (_clockOn()) { _midiOutSendAt([0xFA], t0); _midiClkNext = t0; } } // MIDI Start
|
||||
function midiOutStop() {
|
||||
if (_clockOn() && typeof audioCtx !== "undefined" && audioCtx) _midiOutSendAt([0xFC], audioCtx.currentTime); // MIDI Stop
|
||||
}
|
||||
// schedule 24-PPQN clock ticks up to `aheadTime` (called from the page's look-ahead scheduler)
|
||||
function midiOutClock(aheadTime) {
|
||||
if (!_clockOn() || !state.running || typeof audioCtx === "undefined" || !audioCtx) return;
|
||||
const tickDur = (60 / state.bpm) / 24;
|
||||
let guard = 0;
|
||||
while (_midiClkNext < aheadTime && guard++ < 512) { _midiOutSendAt([0xF8], _midiClkNext); _midiClkNext += tickDur; }
|
||||
if (_midiClkNext < aheadTime) _midiClkNext = aheadTime; // never starve-loop if tickDur is tiny
|
||||
}
|
||||
function updateMidiOutBtn() {
|
||||
const b = $("midiOutBtn"), sel = $("midiOutSel"), clk = $("midiClkWrap"); if (!b) return;
|
||||
b.style.color = _midiOutOn ? "#7ab8ff" : "var(--muted)";
|
||||
b.style.borderColor = _midiOutOn ? "#7ab8ff" : "var(--edge)";
|
||||
if (sel) sel.hidden = !_midiOutOn;
|
||||
if (clk) clk.hidden = !_midiOutOn;
|
||||
}
|
||||
async function toggleMidiOut() {
|
||||
if (_midiOutOn) { midiOutStop(); _midiOutOn = false; updateMidiOutBtn(); return; }
|
||||
if (!(await _ensureMidi())) return alert("Driving external MIDI gear needs the Web MIDI API — use Chrome, Edge, or Firefox.");
|
||||
populateMidiOutPorts();
|
||||
if (!_midiOutputs().length) return alert("No MIDI output ports found. Connect your drum module / e-kit (USB-MIDI) and try again.");
|
||||
_midiOutOn = true; updateMidiOutBtn();
|
||||
if (state.running && typeof audioCtx !== "undefined" && audioCtx) midiOutStart(audioCtx.currentTime + 0.05); // armed mid-play → Start now
|
||||
}
|
||||
403
src/notation.js
Normal file
403
src/notation.js
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
/* =========================================================================
|
||||
PM_E-2 NOTATION ENGINE — inlined into pm_e-2.html by build.sh.
|
||||
Engraves a groove as a 5-line drum staff onto a 2D <canvas>, using the
|
||||
Bravura SMuFL music font (subset inlined via @BUILD:bravura@). The draw API
|
||||
is deliberately IMMEDIATE-MODE and mirrors embedded-graphics (drawGlyph /
|
||||
line / rect) so the layout math ports near-mechanically to the device
|
||||
(rust/pm-ui). Pure view over a normalized model — no engine.js internals.
|
||||
|
||||
model = {
|
||||
name, bpm, playing, phase, // phase 0..1 across the master bar (playhead)
|
||||
lanes: [ { sound, groups:[Int], sub, swing, poly, muted,
|
||||
levels:[0..3], orns:[0..3] } ] // levels: rest/normal/accent/ghost
|
||||
}
|
||||
========================================================================= */
|
||||
const NOTATION = (() => {
|
||||
// SMuFL codepoints (resolved from glyphnames.json by tools/bravura/subset.py — keep in sync).
|
||||
const GLYPH = {
|
||||
clef: 0xe069,
|
||||
black: 0xe0a4, x: 0xe0a9, circleX: 0xe0b3, half: 0xe0a3, whole: 0xe0a2,
|
||||
parenL: 0xe0f5, parenR: 0xe0f6,
|
||||
flag8U: 0xe240, flag8D: 0xe241, flag16U: 0xe242, flag16D: 0xe243,
|
||||
restW: 0xe4e3, restH: 0xe4e4, restQ: 0xe4e5, rest8: 0xe4e6, rest16: 0xe4e7,
|
||||
accentA: 0xe4a0, accentB: 0xe4a1, dot: 0xe1e7,
|
||||
sig: [0xe080, 0xe081, 0xe082, 0xe083, 0xe084, 0xe085, 0xe086, 0xe087, 0xe088, 0xe089],
|
||||
sigPlus: 0xe08c, sigCommon: 0xe08a, sigCut: 0xe08b,
|
||||
graceAcc: 0xe560, graceSlash: 0xe564,
|
||||
trem1: 0xe220, trem2: 0xe221, trem3: 0xe222, buzz: 0xe22a,
|
||||
};
|
||||
const chr = (cp) => String.fromCodePoint(cp);
|
||||
|
||||
// Voice -> staff position. `p` = half-staff-spaces below the TOP line (top line p=0, each line/space
|
||||
// step = 1; bottom line p=8). `head` = notehead glyph; `up` = stem direction (hands up / feet down).
|
||||
// PAS-style drum key; refined visually in the browser.
|
||||
function voice(name) {
|
||||
const s = name || "";
|
||||
const F = (p, head, up) => ({ p, head, up });
|
||||
if (s.startsWith("kick")) return F(7, "black", false); // bass drum (feet, stem down)
|
||||
if (s.startsWith("snare") || s.startsWith("clap") || s.startsWith("rim")) return F(3, "black", true);
|
||||
if (s.startsWith("openHat") || s.startsWith("hatOpen") || s.startsWith("hat")) return F(-1, "x", true);
|
||||
if (s.startsWith("ride")) return F(0, "x", true);
|
||||
if (s.startsWith("crash")) return F(-3, "x", true);
|
||||
if (s.startsWith("tomHigh")) return F(1, "black", true);
|
||||
if (s.startsWith("tomMid") || s === "tom808") return F(2, "black", true);
|
||||
if (s.startsWith("tomLow") || s.startsWith("tom")) return F(5, "black", true);
|
||||
if (s.startsWith("cowbell")) return F(-1, "circleX", true);
|
||||
if (s.startsWith("claves") || s.startsWith("woodblock") || s.startsWith("jamblock")) return F(1, "x", true);
|
||||
if (s.startsWith("tambourine")) return F(-2, "x", true);
|
||||
return F(3, "black", true);
|
||||
}
|
||||
|
||||
function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a || 1; }
|
||||
function lcm(a, b) { return a / gcd(a, b) * b; }
|
||||
|
||||
// --- palette ---
|
||||
// The notation panel is engraved like paper: dark ink on a WHITE background, theme-independent
|
||||
// (matches print sheet music and reads in both page themes).
|
||||
function palette() {
|
||||
return {
|
||||
ink: "#161a1f", // staff lines, noteheads, stems, beams
|
||||
faint: "#aeb6c0", // box outlines / gridlines / poly-row baseline
|
||||
accent: "#c0392b", // accents (notehead tint + > mark) — reads on white
|
||||
ghost: "#7c8794", // ghost notes
|
||||
play: "#2b7fff", // playhead
|
||||
bg: "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
function draw(canvas, model, opts) {
|
||||
const view = (model && model.view) || "staff";
|
||||
if (view === "tubs") return drawTUBS(canvas, model);
|
||||
if (view === "konnakol") return drawKonnakol(canvas, model);
|
||||
opts = opts || {};
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const pal = palette();
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const S = opts.staffSpace || 11; // staff space in px
|
||||
const em = 4 * S; // SMuFL: 1 em = 4 staff spaces
|
||||
const y0 = (p) => staffTop + p * (S / 2); // staff-position -> y
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
const glyph = (name, x, p, color, scale) => {
|
||||
const cp = typeof name === "number" ? name : GLYPH[name];
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = (em * (scale || 1)) + "px Bravura";
|
||||
// SMuFL glyphs sit on the baseline = the reference staff line; fillText baseline aligns there.
|
||||
ctx.fillText(chr(cp), x, y0(p));
|
||||
};
|
||||
const line = (x1, yy1, x2, yy2, color, w) => {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = w || 1;
|
||||
ctx.beginPath(); ctx.moveTo(x1, yy1); ctx.lineTo(x2, yy2); ctx.stroke();
|
||||
};
|
||||
|
||||
// geometry + model
|
||||
const m = 14;
|
||||
const clefW = em * 0.6;
|
||||
const x1 = W - m;
|
||||
const staffTop = 56;
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
const onStaff = lanes.filter((l) => !l.poly); // first non-poly lane = master (defines the meter)
|
||||
|
||||
const groups = (onStaff[0] && onStaff[0].groups && onStaff[0].groups.length) ? onStaff[0].groups : [4];
|
||||
const beats = groups.reduce((a, b) => a + b, 0) || 4;
|
||||
// time signature: additive numerator (2+2+3) for grouped meters; PM's beat is the quarter -> denom 4.
|
||||
const tsDigit = em * 0.4;
|
||||
const numParts = groups.length > 1 ? groups : [beats];
|
||||
const numGlyphs = numParts.reduce((a, n) => a + String(n).length, 0) + (numParts.length - 1);
|
||||
const tsX = m + clefW;
|
||||
const tsW = Math.max(numGlyphs, 1) * tsDigit;
|
||||
// notes start clear of the time signature, or at a shared gutter (so all views' beats line up)
|
||||
const x0 = model.gutter != null ? model.gutter : tsX + tsW + 14;
|
||||
const barW = Math.max(1, x1 - x0);
|
||||
|
||||
// ---- header: name + BPM ----
|
||||
ctx.textAlign = "left";
|
||||
ctx.font = "600 15px system-ui, sans-serif";
|
||||
ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(model.name || "", m, 26);
|
||||
ctx.textAlign = "right";
|
||||
ctx.font = "700 18px 'Courier New', monospace";
|
||||
ctx.fillStyle = pal.accent;
|
||||
ctx.fillText((model.bpm | 0) + " BPM", x1, 26);
|
||||
ctx.textAlign = "center";
|
||||
|
||||
// ---- staff + barlines + clef + time signature ----
|
||||
for (let i = 0; i < 5; i++) line(m, staffTop + i * S, x1, staffTop + i * S, pal.ink, 1);
|
||||
line(m, staffTop, m, staffTop + 4 * S, pal.ink, 1.5);
|
||||
line(x1, staffTop, x1, staffTop + 4 * S, pal.ink, 1.5);
|
||||
glyph("clef", m + clefW * 0.5, 4, pal.ink); // percussion clef centered on middle line (p=4)
|
||||
|
||||
drawTimeSig(tsX, tsW, numParts, tsDigit);
|
||||
|
||||
// ---- time grid: lcm of ALL lanes' step counts (incl. polyrhythm `~` lanes) → the right common
|
||||
// time scale so every voice, including cross-rhythms, sits on ONE staff at aligned columns ----
|
||||
let res = 1;
|
||||
for (const l of lanes) res = lcm(res, l.levels.length);
|
||||
res = Math.max(res, 1);
|
||||
const beamable = res / Math.max(beats, 1) >= 2; // bar has subdivisions → beam within beats
|
||||
ctx.font = em + "px Bravura";
|
||||
const headHalf = ctx.measureText(chr(GLYPH.black)).width / 2; // real notehead half-width → stems touch it
|
||||
|
||||
// beaming state per stem direction (carry previous column's stem x within a beat)
|
||||
let upPrev = null, dnPrev = null;
|
||||
const upTip = staffTop - S * 2.6, dnTip = staffTop + 4 * S + S * 2.6;
|
||||
|
||||
for (let c = 0; c < res; c++) {
|
||||
const cx = x0 + (c + 0.5) * barW / res;
|
||||
const beat = Math.floor(c * beats / res);
|
||||
let up = null, dn = null; // {loP, hiP, sub2} accumulators per direction
|
||||
|
||||
for (const l of lanes) {
|
||||
const steps = l.levels.length;
|
||||
if ((c * steps) % res !== 0) continue; // no note for this lane at this column
|
||||
const si = (c * steps / res) | 0;
|
||||
const lvl = l.levels[si] | 0;
|
||||
if (!lvl) continue;
|
||||
const orn = (l.orns && l.orns[si]) | 0;
|
||||
const vc = voice(l.sound);
|
||||
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
|
||||
// ghost = parenthesized notehead
|
||||
if (lvl === 3) glyph("parenL", cx - S * 0.85, vc.p, color);
|
||||
const head = vc.head === "x" ? "x" : vc.head === "circleX" ? "circleX" : "black";
|
||||
glyph(head, cx, vc.p, color);
|
||||
if (lvl === 3) glyph("parenR", cx + S * 0.85, vc.p, color);
|
||||
|
||||
// accent mark above/below the staff edge
|
||||
if (lvl === 2) glyph(vc.up ? "accentA" : "accentB", cx, vc.up ? -2 : 10, color, 0.8);
|
||||
|
||||
// ornaments: flam = slashed grace note up-left; roll = tremolo strokes on the stem
|
||||
if (orn === 1) glyph("graceSlash", cx - S * 1.4, vc.p - 0.5, color, 0.7);
|
||||
else if (orn === 2) { glyph("graceSlash", cx - S * 1.9, vc.p - 0.5, color, 0.7); glyph("graceSlash", cx - S * 1.1, vc.p - 0.5, color, 0.7); }
|
||||
else if (orn === 3) glyph("trem3", cx + (vc.up ? S * 0.55 : -S * 0.55), vc.p + (vc.up ? -2 : 2), color, 0.8);
|
||||
|
||||
// ledger lines
|
||||
for (let lp = -2; lp >= vc.p; lp -= 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
|
||||
for (let lp = 10; lp <= vc.p; lp += 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
|
||||
|
||||
const sub2 = beamable;
|
||||
const acc = vc.up ? (up = up || { loP: -99, hiP: 99, sub2: false }) : (dn = dn || { loP: -99, hiP: 99, sub2: false });
|
||||
acc.loP = Math.max(acc.loP, vc.p); // lowest (largest p)
|
||||
acc.hiP = Math.min(acc.hiP, vc.p); // highest (smallest p)
|
||||
acc.sub2 = acc.sub2 || sub2;
|
||||
}
|
||||
|
||||
// shared up-stem (hands): right side of head, from lowest head up past the highest
|
||||
if (up) {
|
||||
const sx = cx + headHalf;
|
||||
const top = Math.min(upTip, y0(up.hiP) - S * 1.2);
|
||||
line(sx, y0(up.loP), sx, top, pal.ink, 1.4);
|
||||
if (up.sub2 && upPrev && upPrev.beat === beat) line(upPrev.x, top, sx, top, pal.ink, 3);
|
||||
else if (up.sub2) {} // first of a beam group; flag drawn only if it stays solo (handled below)
|
||||
upPrev = up.sub2 ? { x: sx, beat, y: top } : null;
|
||||
} else upPrev = null;
|
||||
// shared down-stem (feet): left side
|
||||
if (dn) {
|
||||
const sx = cx - headHalf;
|
||||
const bot = Math.max(dnTip, y0(dn.loP) + S * 1.2);
|
||||
line(sx, y0(dn.hiP), sx, bot, pal.ink, 1.4);
|
||||
if (dn.sub2 && dnPrev && dnPrev.beat === beat) line(dnPrev.x, bot, sx, bot, pal.ink, 3);
|
||||
dnPrev = dn.sub2 ? { x: sx, beat, y: bot } : null;
|
||||
} else dnPrev = null;
|
||||
}
|
||||
|
||||
// ---- tuplet number: the common subdivision per beat (3=triplet, 6=sextuplet, 5, 7…). For beamed
|
||||
// groups the modern convention is just the numeral over the beam (no bracket). ----
|
||||
const isPow2 = (n) => n > 0 && (n & (n - 1)) === 0;
|
||||
const tupN = Math.round(res / Math.max(1, beats));
|
||||
if (tupN >= 3 && !isPow2(tupN)) {
|
||||
ctx.fillStyle = pal.ink; ctx.textAlign = "center";
|
||||
ctx.font = "italic 600 13px Georgia, 'Times New Roman', serif";
|
||||
const ty = upTip - S * 0.7;
|
||||
for (let b = 0; b < beats; b++) ctx.fillText(String(tupN), x0 + (b + 0.5) * barW / beats, ty);
|
||||
}
|
||||
|
||||
// ---- playhead ----
|
||||
// `phase` is the master-bar fraction (0..1). Noteheads sit at column CENTERS ((c+0.5)/res), so
|
||||
// shift the line by half a cell to land exactly on the note at its onset instead of leading it.
|
||||
if (model.playing && model.phase != null) {
|
||||
const pf = Math.max(0, Math.min(1, model.phase + 0.5 / res));
|
||||
const px = x0 + pf * barW;
|
||||
ctx.save(); ctx.globalAlpha = 0.55;
|
||||
line(px, staffTop - S, px, staffTop + 4 * S + S, pal.play, 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- hit map for on-staff editing (all in CSS px) ----
|
||||
// Each on-staff lane exposes its staff row (p) + column geometry; the page maps a click to the
|
||||
// nearest voice row (y) and the column (x) → (laneIndex, step). `idx` indexes model.lanes/meters.
|
||||
canvas._hit = {
|
||||
kind: "staff", staffTop, S, x0, barW,
|
||||
lanes: lanes.map((l) => ({ idx: l.idx, p: voice(l.sound).p, steps: l.levels.length })),
|
||||
};
|
||||
|
||||
// time signature: numerator (additive parts joined by timeSigPlus) over a quarter-note denominator,
|
||||
// each row centered within the reserved width `tw`. Defined here so it closes over glyph()/em/pal.
|
||||
function drawTimeSig(tx, tw, parts, dw) {
|
||||
drawSigRow(tx, tw, 2, parts, dw); // numerator in the upper half of the staff
|
||||
drawSigRow(tx, tw, 6, [4], dw); // denominator (PM beat = quarter) in the lower half
|
||||
}
|
||||
function drawSigRow(tx, tw, p, parts, dw) {
|
||||
const seq = [];
|
||||
parts.forEach((n, i) => {
|
||||
if (i) seq.push(GLYPH.sigPlus);
|
||||
String(n).split("").forEach((d) => seq.push(GLYPH.sig[+d]));
|
||||
});
|
||||
let xx = tx + (tw - seq.length * dw) / 2 + dw / 2;
|
||||
for (const cp of seq) { glyph(cp, xx, p, pal.ink, 0.92); xx += dw; }
|
||||
}
|
||||
}
|
||||
|
||||
function drawPolyRow(ctx, glyph, line, pal, l, x0, x1, py, S) {
|
||||
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
|
||||
ctx.fillText(l.sound + " ~", x0, py - S * 1.6); ctx.textAlign = "center";
|
||||
line(x0, py, x1, py, pal.faint, 1);
|
||||
const steps = l.levels.length, barW = x1 - x0;
|
||||
const vc = voice(l.sound);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const lvl = l.levels[i] | 0; if (!lvl) continue;
|
||||
const cx = x0 + (i + 0.5) * barW / steps;
|
||||
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
const head = vc.head === "x" ? "x" : "black";
|
||||
ctx.fillStyle = color; ctx.font = (4 * S) + "px Bravura";
|
||||
ctx.fillText(chr(vc.head === "x" ? GLYPH.x : GLYPH.black), cx, py);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared setup for the alternate views ----
|
||||
function begin(canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
||||
if (canvas.width !== W * dpr || canvas.height !== H * dpr) { canvas.width = W * dpr; canvas.height = H * dpr; }
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
return { ctx, W, H, pal: palette() };
|
||||
}
|
||||
function header(ctx, W, model, pal) {
|
||||
ctx.textAlign = "left"; ctx.font = "600 15px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(model.name || "", 14, 26);
|
||||
ctx.textAlign = "right"; ctx.font = "700 18px 'Courier New', monospace"; ctx.fillStyle = pal.accent;
|
||||
ctx.fillText((model.bpm | 0) + " BPM", W - 14, 26);
|
||||
ctx.textAlign = "center";
|
||||
}
|
||||
// Son/rumba clave fingerprint: split the bar in half, count hits each side → 2-3 or 3-2.
|
||||
function claveLabel(l) {
|
||||
if (!/^clave/.test(l.sound || "")) return "";
|
||||
const n = l.levels.length, h = n >> 1;
|
||||
if (!h) return "(clave)";
|
||||
const a = l.levels.slice(0, h).filter((v) => v > 0).length;
|
||||
const b = l.levels.slice(h).filter((v) => v > 0).length;
|
||||
return a === 2 && b === 3 ? "(2-3)" : a === 3 && b === 2 ? "(3-2)" : "(clave)";
|
||||
}
|
||||
|
||||
// ---- TUBS (Time Unit Box System): rows = voices, columns = time units, filled boxes = hits ----
|
||||
// All bar-sharing lanes are drawn on ONE common time grid (lcm of their step counts) so every
|
||||
// column lines up vertically across rows; a coarser lane just fills every Nth cell. Polymeter
|
||||
// lanes keep their own spacing across the full width (that IS the cross-rhythm).
|
||||
function drawTUBS(canvas, model) {
|
||||
const { ctx, W, H, pal } = begin(canvas);
|
||||
header(ctx, W, model, pal);
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
if (!lanes.length) { canvas._hit = null; return; }
|
||||
const m = 14, x0 = model.gutter != null ? model.gutter : m + 96, x1 = W - m, gw = Math.max(1, x1 - x0);
|
||||
const top = 42, bot = H - 12;
|
||||
const rowH = Math.min(40, Math.max(20, (bot - top) / lanes.length));
|
||||
|
||||
// common grid = lcm of ALL lanes' step counts so columns line up; each lane draws ONE box per
|
||||
// REAL step at its grid column → aligned AND each box is a clickable step.
|
||||
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length);
|
||||
res = Math.max(res, 1);
|
||||
const cw = gw / res, bs = Math.max(11, Math.min(rowH - 8, cw - 3, 30));
|
||||
const master = lanes.find((l) => !l.poly) || lanes[0];
|
||||
const mg = master.groups && master.groups.length ? master.groups : [4];
|
||||
const mbeats = mg.reduce((a, b) => a + b, 0) || 1;
|
||||
const starts = new Set(); let acc = 0; for (const g of mg) { starts.add(acc); acc += g; }
|
||||
const yA = top - 4, yB = top + lanes.length * rowH;
|
||||
|
||||
// beat / group dividers (group starts brighter)
|
||||
for (let b = 0; b <= mbeats; b++) {
|
||||
const lx = x0 + (b * res / mbeats) * cw;
|
||||
ctx.strokeStyle = (b < mbeats && starts.has(b)) || b === 0 || b === mbeats ? pal.ghost : pal.faint;
|
||||
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, yA); ctx.lineTo(lx, yB); ctx.stroke();
|
||||
}
|
||||
// playhead column (aligned to box centers like the staff)
|
||||
if (model.playing && model.phase != null) {
|
||||
const px = x0 + Math.max(0, Math.min(1, model.phase + 0.5 / res)) * gw;
|
||||
ctx.save(); ctx.globalAlpha = 0.5; ctx.strokeStyle = pal.play; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(px, yA); ctx.lineTo(px, yB); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
lanes.forEach((l, r) => {
|
||||
const cy = top + r * rowH + rowH / 2;
|
||||
ctx.textAlign = "left"; ctx.font = "12px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
|
||||
ctx.fillText(l.sound + (l.poly ? " ~" : "") + (claveLabel(l) ? " " + claveLabel(l) : ""), m, cy + 4);
|
||||
const steps = l.levels.length, span = res / steps;
|
||||
rows.push({ idx: l.idx, steps, span });
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const cx = x0 + (i * span + 0.5) * cw, lvl = l.levels[i] | 0;
|
||||
ctx.strokeStyle = pal.faint; ctx.lineWidth = 1; ctx.strokeRect(cx - bs / 2, cy - bs / 2, bs, bs);
|
||||
if (lvl > 0) {
|
||||
ctx.fillStyle = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
|
||||
const p = bs * 0.2; ctx.fillRect(cx - bs / 2 + p, cy - bs / 2 + p, bs - 2 * p, bs - 2 * p);
|
||||
const orn = (l.orns && l.orns[i]) | 0; // flam/drag/roll marker inside the box
|
||||
if (orn) { ctx.fillStyle = pal.bg; ctx.font = "700 9px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText(orn === 1 ? "f" : orn === 2 ? "d" : "z", cx, cy + 3.5); ctx.textAlign = "left"; }
|
||||
}
|
||||
}
|
||||
});
|
||||
// hit map for editing: click a box to cycle dynamic; Shift-click cycles ornament
|
||||
canvas._hit = { kind: "tubs", x0, cw, top, rowH, rows };
|
||||
}
|
||||
|
||||
// ---- Konnakol: spoken-rhythm syllables (solkattu) for the master lane's subdivision ----
|
||||
const SOLKATTU = {
|
||||
1: ["ta"], 2: ["ta", "ka"], 3: ["ta", "ki", "ta"], 4: ["ta", "ka", "di", "mi"],
|
||||
5: ["ta", "ka", "ta", "ki", "ta"], 6: ["ta", "ki", "ta", "ta", "ki", "ta"],
|
||||
7: ["ta", "ka", "ta", "ki", "ta", "ki", "ta"], 8: ["ta", "ka", "di", "mi", "ta", "ka", "ju", "nu"],
|
||||
};
|
||||
function drawKonnakol(canvas, model) {
|
||||
const { ctx, W, H, pal } = begin(canvas);
|
||||
canvas._hit = null;
|
||||
header(ctx, W, model, pal);
|
||||
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
|
||||
const m0 = lanes.find((l) => !l.poly) || lanes[0];
|
||||
if (!m0) return;
|
||||
const groups = m0.groups && m0.groups.length ? m0.groups : [4];
|
||||
const beats = groups.reduce((a, b) => a + b, 0) || 1;
|
||||
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length); // common (finest) grid
|
||||
const sub = Math.max(1, Math.round(res / beats)); // nadai = subdivisions per beat
|
||||
const bols = SOLKATTU[sub] || SOLKATTU[4];
|
||||
const m = 14, x0 = model.gutter != null ? model.gutter : m, x1 = W - m, gw = x1 - x0, colW = gw / (beats * sub), cy = H / 2;
|
||||
const starts = new Set(); let acc = 0; for (const g of groups) { starts.add(acc); acc += g; }
|
||||
ctx.textAlign = "center";
|
||||
for (let b = 0; b < beats; b++) {
|
||||
const gs = starts.has(b), sam = b === 0;
|
||||
ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = pal.faint;
|
||||
ctx.fillText(sam ? "X" : gs ? "O" : "·", x0 + (b * sub + 0.5) * colW, cy - 22); // sam / anga / beat
|
||||
for (let s = 0; s < sub; s++) {
|
||||
const idx = b * sub + s, cx = x0 + (idx + 0.5) * colW;
|
||||
ctx.font = (s === 0 ? "600 " : "") + "16px system-ui, sans-serif";
|
||||
ctx.fillStyle = sam && s === 0 ? pal.accent : s === 0 ? pal.ink : pal.ghost;
|
||||
ctx.fillText(bols[s % bols.length], cx, cy + 6);
|
||||
}
|
||||
}
|
||||
for (let b = 0; b <= beats; b++) { // beat/group dividers
|
||||
const lx = x0 + b * sub * colW;
|
||||
ctx.strokeStyle = starts.has(b) || b === 0 || b === beats ? pal.ghost : pal.faint;
|
||||
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, cy - 30); ctx.lineTo(lx, cy + 22); ctx.stroke();
|
||||
}
|
||||
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
|
||||
ctx.fillText("tala " + groups.join("+") + " · nadai " + sub + " · X=sam O=anga", m, H - 12);
|
||||
}
|
||||
|
||||
return { draw, GLYPH, voice };
|
||||
})();
|
||||
42
src/setlists.js
Normal file
42
src/setlists.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Seed set lists — baked into every page; each item is authored in the share language
|
||||
// (so this also exercises the parser). Two curated lists:
|
||||
// Styles — full, genre-true grooves; good as backing tracks to jam/solo over.
|
||||
// Practice — drills for learning to PLAY those styles (isolations, independence,
|
||||
// dynamics, polyrhythms, tempo/gap tools).
|
||||
const SEED_SETLISTS = [
|
||||
{ title: "Styles", description: "Full genre grooves — load one, press play, and jam over it. Tap pads to reshape the feel.", items: [
|
||||
["Rock", "t116;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=Xxxxxxxx"],
|
||||
["Pop (16ths)", "t104;kick:4/4=X.....x...X.....;snare:4/4=....X.......X...;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Funk", "t100;kick:4/4=X..x..X...x.X...;snare:4/4=....X..g.g..X.g.;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Disco", "t120;kick:4;clap:4=.X.X;hatClosed:4/2=X.X.X.X.;hatOpen:4/2=.x.x.x.x"],
|
||||
["Motown backbeat", "t132;kick:4/2=X..x.X..;snare:4/2=..X...X.;tambourine:4/2=xxxxxxxx"],
|
||||
["Blues shuffle (12/8)", "t84;ride:4/3=X.xX.xX.xX.x;snare:4/3=...X.....X..;kick:4/3=X.....X....."],
|
||||
["Jazz swing", "t150;ride:4/3=X.xX.xX.xX.x;hatClosed:4=.x.x;kick:4=x.x.@-9"],
|
||||
["Bossa nova", "t128;rim:4/4=..x.x...x..x..x.;kick:4/2=X..X.X..;hatClosed:4/2=xxxxxxxx"],
|
||||
["Samba (2/4)", "t102;tomLow:2/4=x...X...;hatClosed:2/4=xxxxxxxx;woodblock:2/4=X.xx.xX."],
|
||||
["Reggae one-drop", "t74;kick:4=..X.;rim:4=..X.;hatClosed:4/2=.x.x.x.x"],
|
||||
["Afrobeat", "t108;cowbell:4/4=x.xx.xx.x.xx.xx.;kick:4/4=X..x..X..x..X...;snare:4/4=....g..X..g..X.g;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
|
||||
["Hip-hop (boom bap)", "t88;kick808:4/4=X.....x....X....;snare808:4/4=....X.......X...;hat808:4/4=x.x.x.x.x.x.x.x."],
|
||||
["Metal driving", "t168;kick:4/2=XxXxXxXx;snare:4/2=..X...X.;hatClosed:4/2=Xxxxxxxx;crash:4=X..."],
|
||||
["6/8 ballad", "t66;kick:3+3=X..X..;snare:3+3=...X..;hatClosed:3+3/2=xxxxxxxxxxxx"],
|
||||
["7/8 (2+2+3)", "t130;kick:2+2+3=X..X..X;snare:2+2+3=..X..X.;hatClosed:2+2+3/2"],
|
||||
["5/4 (3+2)", "t112;kick:3+2=X..X.;snare:3+2=..X..;hatClosed:3+2/2"],
|
||||
] },
|
||||
{ title: "Practice", description: "Drills to learn the styles - isolations, independence, dynamics, polyrhythms, and tempo / gap tools.", items: [
|
||||
["Rock beat - quarter hats", "t80;kick:4=X.X.;snare:4=.X.X;hatClosed:4=xxxx"],
|
||||
["Rock beat - 8th hats", "t96;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=xxxxxxxx"],
|
||||
["Backbeat + ghost notes", "t90;kick:4/4=X.....x...X.....;snare:4/4=..g.X.g.g.g.X.g.;hatClosed:4/2=xxxxxxxx"],
|
||||
["16th-note hand control", "t84;kick:4=X..X;snare:4/4=x.xxX.xxx.xxX.xx"],
|
||||
["Shuffle feel (triplets)", "t92;hatClosed:4/3=X.xX.xX.xX.x;snare:4/3=...X.....X..;kick:4/3=X.....X....."],
|
||||
["Jazz ride - spang-a-lang", "t140;ride:4/3=X.xX.xX.xX.x;hatClosed:4=.x.x;kick:4=x.x.@-9"],
|
||||
["Bossa independence", "t120;rim:4/4=..x.x...x..x..x.;kick:4/2=X..X.X..;hatClosed:4=.x.x"],
|
||||
["Linear funk", "t96;kick:4/4=X..x..X.....x...;snare:4/4=....X......X..g.;hatClosed:4/4=x.x.x.xxx.x.x.x."],
|
||||
["Accent / ghost dynamics", "t88;snare:4/4=X.g.x.g.X.g.x.g.;hatClosed:4/2=xxxxxxxx;kick:4=X..X"],
|
||||
["Double-bass workout", "t120;kick:4/4=xxxxxxxxxxxxxxxx;snare:4=.X.X;crash:4=X..."],
|
||||
["Independence: 3 over 4", "t96;kick:4;hatClosed:4/2=xxxxxxxx;cowbell:3~"],
|
||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||
["Tempo builder 80 up", "t80;kick:4=X.X.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx;rmp80/4/4"],
|
||||
["Gap trainer (play 2 / rest 2)", "t100;kick:4/2=X...Xx..;snare:4/2=..X...X.;hatClosed:4/2=xxxxxxxx;tr2/2"],
|
||||
] },
|
||||
];
|
||||
Loading…
Reference in a new issue