Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986f26b8ed | ||
|
|
d557ac6386 | ||
|
|
5023ea69d3 | ||
|
|
0599f6b665 | ||
|
|
2debfb171c | ||
|
|
ac904cb09f | ||
|
|
8e89b08571 | ||
|
|
6a98ea0f21 | ||
|
|
903e83dbcc | ||
|
|
a4e0b45953 | ||
|
|
ffd662fb38 | ||
|
|
43f0554796 | ||
|
|
ebb7fc4433 | ||
|
|
cf1c7410ab | ||
|
|
5bc4855264 | ||
|
|
a650c2f7ec | ||
|
|
283da7e1a9 | ||
|
|
292fa25d5e | ||
|
|
0eaab6aeed | ||
|
|
7a48942f1c | ||
|
|
6c15c3d8f1 | ||
|
|
d2b7986cb9 | ||
|
|
3f90f4857f | ||
|
|
5553614fdf | ||
|
|
8a87534c5d | ||
|
|
322f8eb98b | ||
|
|
a3078bb2fc | ||
|
|
bd1b4aeb6e | ||
|
|
f2a97f4f10 | ||
|
|
fca9e874f3 | ||
|
|
bd5593c3ed |
+2
-2
@@ -11,9 +11,9 @@ COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
ENV KARAOKE_MAKER_CONFIG=/config/config.toml \
|
||||
ENV SONGTRACE_CONFIG=/config/config.toml \
|
||||
TORCH_HOME=/data/models \
|
||||
XDG_CACHE_HOME=/data/models/cache
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["karaoke-maker", "serve"]
|
||||
CMD ["songtrace", "serve"]
|
||||
|
||||
@@ -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/>.
|
||||
@@ -1,17 +1,22 @@
|
||||
# karaoke-maker
|
||||
# songtrace
|
||||
|
||||
Turn a song and its lyrics into a playable UltraStar chart.
|
||||
|
||||
A CLI and minimal web UI that turns a song — title, artist, lyrics you supply,
|
||||
and a YouTube URL or local video file — into a playable
|
||||
[UltraStar](https://usdx.eu/format/)-format song folder for
|
||||
[Melody Mania](https://melodymania.net/), a commercial UltraStar Deluxe fork.
|
||||
[UltraStar](https://usdx.eu/format/)-format song folder. Also compatible with
|
||||
[Melody Mania](https://melodymania.org/).
|
||||
|
||||

|
||||
|
||||
The pipeline:
|
||||
|
||||
1. **acquire** — download audio/video via `yt-dlp` (or use a local video file)
|
||||
2. **separate** — split vocals/instrumental with Demucs
|
||||
3. **transcribe** — send the vocal stem to your own intranet OpenAI-compatible
|
||||
Whisper server, as a sanity check that the supplied lyrics match the audio
|
||||
and to locate vocal sections
|
||||
3. **transcribe** — send the vocal stem to your own intranet Whisper server
|
||||
(OpenAI-compatible, or [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)),
|
||||
as a sanity check that the supplied lyrics match the audio and to locate
|
||||
vocal sections
|
||||
4. **align** — forced-align the *supplied* reference lyrics against the vocal
|
||||
stem with wav2vec2 (torchaudio's MMS_FA) to get word timings
|
||||
5. **pitch** — track pitch on the vocal stem with torchcrepe
|
||||
@@ -26,7 +31,7 @@ output.
|
||||
**The output is a best-effort draft**, not a finished song. Every run ends by
|
||||
printing the low-confidence regions it found (e.g.
|
||||
`3 low-confidence notes near 0:42–0:47`) — open the result in an UltraStar
|
||||
editor such as [Yass](http://www.yass-hq.de/) or
|
||||
editor such as [Yass](https://www.yass-along.com/) or
|
||||
[USDX](https://usdx.eu/) and touch those spots up before playing.
|
||||
|
||||
## Configuration
|
||||
@@ -34,8 +39,8 @@ editor such as [Yass](http://www.yass-hq.de/) or
|
||||
Config is TOML. Resolution order:
|
||||
|
||||
1. `--config <path>` passed explicitly on the command line
|
||||
2. the `KARAOKE_MAKER_CONFIG` environment variable
|
||||
3. `~/.config/karaoke-maker/config.toml`
|
||||
2. the `SONGTRACE_CONFIG` environment variable
|
||||
3. `~/.config/songtrace/config.toml`
|
||||
|
||||
Copy the example and fill in your Whisper server:
|
||||
|
||||
@@ -45,8 +50,15 @@ $EDITOR config.toml # set whisper_url at minimum
|
||||
```
|
||||
|
||||
See `config.example.toml` for every available field (`whisper_url`,
|
||||
`whisper_api_key`, `whisper_model`, `output_dir`, `work_dir`, `creator`,
|
||||
`demucs_model`, `crepe_model`, `device`, `bind_host`, `port`).
|
||||
`whisper_api_key`, `whisper_model`, `whisper_api`, `output_dir`, `work_dir`,
|
||||
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`).
|
||||
|
||||
`whisper_api` selects the wire format: `"openai"` (default) speaks the
|
||||
OpenAI-compatible `POST {whisper_url}/audio/transcriptions` API; `"asr-webservice"`
|
||||
speaks [onerahmet/openai-whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice)'s
|
||||
`POST {whisper_url}/asr` API instead (point `whisper_url` at that server's base
|
||||
URL with no `/v1` suffix — `whisper_model`/`whisper_api_key` are unused for
|
||||
this backend, since the model is fixed by how that server was started).
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -63,7 +75,7 @@ container recreation.
|
||||
Run the CLI inside the container instead of the web UI:
|
||||
|
||||
```sh
|
||||
docker compose run --rm karaoke-maker karaoke-maker make \
|
||||
docker compose run --rm songtrace songtrace make \
|
||||
--title "Song Title" --artist "Artist Name" \
|
||||
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
|
||||
```
|
||||
@@ -77,17 +89,17 @@ installed on the host; `docker-compose.yml` requests one GPU via the
|
||||
|
||||
```sh
|
||||
pip install -e .
|
||||
karaoke-maker make --help
|
||||
songtrace make --help
|
||||
```
|
||||
|
||||
```
|
||||
karaoke-maker make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
|
||||
songtrace make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
|
||||
(--youtube URL | --video PATH) \
|
||||
[--genre GENRE] [--year YEAR] [--language LANGUAGE] [--cover COVER_IMAGE] \
|
||||
[--bpm BPM] [--force-from STAGE] [--config CONFIG]
|
||||
```
|
||||
|
||||
Run the web UI instead with `karaoke-maker serve [--config CONFIG]`.
|
||||
Run the web UI instead with `songtrace serve [--config CONFIG]`.
|
||||
|
||||
## Re-running a stage
|
||||
|
||||
@@ -98,9 +110,24 @@ stage and everything after it, reusing the cached output of everything
|
||||
before it:
|
||||
|
||||
```sh
|
||||
karaoke-maker make --title "..." --artist "..." --lyrics lyrics.txt \
|
||||
songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
|
||||
--youtube https://youtu.be/... --force-from align
|
||||
```
|
||||
|
||||
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
|
||||
`align`, `pitch`, `notes`, `grid`, `package`.
|
||||
|
||||
## Regenerating the web UI screenshot
|
||||
|
||||
`scripts/screenshot_webui.py` seeds the real app with a fixed set of jobs
|
||||
covering every status and screenshots it to `docs/images/webui.png`:
|
||||
|
||||
```sh
|
||||
pip install -e '.[dev]'
|
||||
playwright install chromium
|
||||
python scripts/screenshot_webui.py
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[AGPLv3](LICENSE)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
whisper_url = "http://whisper.intranet:9000/v1"
|
||||
# whisper_api_key = ""
|
||||
# whisper_model = "whisper-1"
|
||||
# whisper_api = "openai" # or "asr-webservice" for onerahmet/openai-whisper-asr-webservice
|
||||
# (set whisper_url without the /v1 suffix, e.g. "http://whisper.intranet:9000")
|
||||
output_dir = "/data/output"
|
||||
work_dir = "/data/work"
|
||||
creator = "brad"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
karaoke-maker:
|
||||
songtrace:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
+5
-4
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "karaoke-maker"
|
||||
name = "songtrace"
|
||||
version = "0.1.0"
|
||||
description = "Generate UltraStar/Melody Mania karaoke songs from a video and reference lyrics"
|
||||
requires-python = ">=3.11"
|
||||
license = "AGPL-3.0-or-later"
|
||||
dependencies = [
|
||||
"yt-dlp",
|
||||
"demucs",
|
||||
@@ -22,10 +23,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
dev = ["pytest", "playwright"]
|
||||
|
||||
[project.scripts]
|
||||
karaoke-maker = "karaoke_maker.cli:main"
|
||||
songtrace = "songtrace.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
@@ -35,7 +36,7 @@ build-backend = "setuptools.build_meta"
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
karaoke_maker = ["static/*", "static/fonts/*"]
|
||||
songtrace = ["static/*", "static/fonts/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regenerate docs/images/webui.png against the current web UI.
|
||||
|
||||
Seeds the real app (via create_app's fake-runner seam, same as tests/test_web.py)
|
||||
with a fixed set of jobs covering every status, then screenshots it.
|
||||
|
||||
One-time setup: pip install -e '.[dev]' && playwright install chromium
|
||||
Run: python scripts/screenshot_webui.py
|
||||
"""
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import PipelineError, PipelineResult, song_slug
|
||||
from songtrace.web import create_app
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT_IMAGE = REPO_ROOT / "docs" / "images" / "webui.png"
|
||||
STAGES = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
|
||||
DEFAULT_LYRICS = "[verse]\n...\n[chorus]\n...\n"
|
||||
|
||||
# public domain (16th century English folk song, author unknown)
|
||||
GREENSLEEVES_LYRICS = """\
|
||||
Alas, my love, you do me wrong
|
||||
To cast me off discourteously
|
||||
For I have loved you well and long
|
||||
Delighting in your company
|
||||
|
||||
Greensleeves was all my joy
|
||||
Greensleeves was my delight
|
||||
Greensleeves was my heart of gold
|
||||
And who but my lady Greensleeves
|
||||
|
||||
I have been ready at your hand
|
||||
To grant whatever you would crave
|
||||
I have both waged life and land
|
||||
Your love and good will for to have
|
||||
|
||||
Greensleeves was all my joy
|
||||
Greensleeves was my delight
|
||||
Greensleeves was my heart of gold
|
||||
And who but my lady Greensleeves
|
||||
"""
|
||||
|
||||
# order matters: the app has one worker thread, so each job runs in submission
|
||||
# order. "running" must be submitted after the done/failed ones complete and
|
||||
# before "queued", so it's the one still in progress when "queued" arrives.
|
||||
SONGS = [
|
||||
("Every Single Night", "Fiona Apple", "done",
|
||||
{"warnings": ["1 low-confidence note near 2:41–2:44"]}),
|
||||
("This Year", "The Mountain Goats", "done", {"warnings": []}),
|
||||
("Pink Moon", "Nick Drake", "done",
|
||||
{"warnings": ["2 low-confidence notes near 0:38–0:41",
|
||||
"1 low-confidence note near 1:22–1:24"]}),
|
||||
("Landslide", "Fleetwood Mac", "done",
|
||||
{"warnings": ["1 low-confidence note near 0:52–0:55"]}),
|
||||
("Greensleeves", "Traditional", "done",
|
||||
{"lyrics": GREENSLEEVES_LYRICS,
|
||||
"warnings": ["1 low-confidence note near 0:45–0:47",
|
||||
"1 line dropped (not sung) near \"for i have loved\"",
|
||||
"missing lyrics near 2:10–2:18"],
|
||||
"review": {
|
||||
"lines": [
|
||||
# Pitches are the real Greensleeves melody (E natural minor, as
|
||||
# arranged by Gilbert DeBenedetti, "Level 4 - Flowing"), converted
|
||||
# to UltraStar semitones (0 = C4 = MIDI 60): E5=16 F#5=18 G5=19
|
||||
# A5=21 B4=11 D5=14.
|
||||
{"index": 0, "text": "Alas, my love, you do me wrong",
|
||||
"dropped": False,
|
||||
"notes": [{"start": 10.0, "end": 10.4, "pitch": 16, "low_confidence": False},
|
||||
{"start": 10.4, "end": 10.6, "pitch": 18, "low_confidence": False},
|
||||
{"start": 10.6, "end": 11.2, "pitch": 19, "low_confidence": False},
|
||||
{"start": 11.2, "end": 11.5, "pitch": 21, "low_confidence": False},
|
||||
{"start": 11.5, "end": 11.7, "pitch": 19, "low_confidence": False},
|
||||
{"start": 11.7, "end": 11.9, "pitch": 18, "low_confidence": False},
|
||||
{"start": 11.9, "end": 12.9, "pitch": 14, "low_confidence": False}],
|
||||
"words": [{"text": "Alas,", "start": 10.0, "end": 10.4},
|
||||
{"text": "my", "start": 10.4, "end": 10.6},
|
||||
{"text": "love,", "start": 10.6, "end": 11.2},
|
||||
{"text": "you", "start": 11.2, "end": 11.5},
|
||||
{"text": "do", "start": 11.5, "end": 11.7},
|
||||
{"text": "me", "start": 11.7, "end": 11.9},
|
||||
{"text": "wrong", "start": 11.9, "end": 12.9}]},
|
||||
{"index": 1, "text": "To cast me off discourteously",
|
||||
"dropped": False,
|
||||
"notes": [{"start": 44.0, "end": 44.3, "pitch": 11, "low_confidence": False},
|
||||
{"start": 44.3, "end": 44.6, "pitch": 16, "low_confidence": False},
|
||||
{"start": 44.6, "end": 44.8, "pitch": 18, "low_confidence": False},
|
||||
{"start": 44.8, "end": 45.0, "pitch": 19, "low_confidence": False},
|
||||
{"start": 45.0, "end": 45.4, "pitch": 18, "low_confidence": True}],
|
||||
"words": [{"text": "To", "start": 44.0, "end": 44.3},
|
||||
{"text": "cast", "start": 44.3, "end": 44.6},
|
||||
{"text": "me", "start": 44.6, "end": 44.8},
|
||||
{"text": "off", "start": 44.8, "end": 45.0},
|
||||
{"text": "discourteously", "start": 45.0, "end": 45.4}]},
|
||||
{"index": 2, "text": "For I have loved you well and long",
|
||||
"dropped": True, "notes": [], "words": []},
|
||||
],
|
||||
"missing_spans": [{"start": 130.0, "end": 138.0}],
|
||||
}}),
|
||||
("Such Great Heights", "The Postal Service", "failed",
|
||||
{"error": "yt-dlp: unable to download video data: HTTP Error 403: Forbidden"}),
|
||||
("Time Escaping", "Big Thief", "running", {}),
|
||||
("Vampire in the Corner", "Magdalena Bay", "queued", {}),
|
||||
]
|
||||
SELECTED = "Greensleeves"
|
||||
|
||||
running_event = threading.Event()
|
||||
|
||||
|
||||
def make_runner():
|
||||
specs = {title: (kind, extra) for title, _, kind, extra in SONGS}
|
||||
|
||||
def runner(inp, cfg, log=print, force_from=None, stages=None):
|
||||
kind, extra = specs[inp.title]
|
||||
if kind == "done":
|
||||
for s in STAGES:
|
||||
log(f"[{s}] running")
|
||||
if "review" in extra:
|
||||
work = cfg.work_dir / song_slug(inp)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
notes = [
|
||||
{**n, "duration": n["end"] - n["start"], "text": "~",
|
||||
"line_index": line["index"]}
|
||||
for line in extra["review"]["lines"] for n in line["notes"]
|
||||
]
|
||||
(work / "notes_sec.json").write_text(json.dumps({
|
||||
"notes": notes, "breaks": [], "warnings": extra["warnings"]}))
|
||||
(work / "align_meta.json").write_text(json.dumps({
|
||||
"dropped_lines": [line["index"] for line in extra["review"]["lines"]
|
||||
if line["dropped"]],
|
||||
"missing_spans": [[s["start"], s["end"]]
|
||||
for s in extra["review"]["missing_spans"]],
|
||||
}))
|
||||
(work / "alignment.json").write_text(json.dumps([
|
||||
{"word": w["text"], "start": w["start"], "end": w["end"],
|
||||
"score": 0.9, "line_index": line["index"]}
|
||||
for line in extra["review"]["lines"] for w in line.get("words", [])
|
||||
]))
|
||||
out = cfg.output_dir / f"{inp.artist} - {inp.title}"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
return PipelineResult(output_dir=out, warnings=extra["warnings"])
|
||||
if kind == "failed":
|
||||
log("[acquire] running")
|
||||
raise PipelineError(extra["error"])
|
||||
for s in ["acquire", "separate", "transcribe", "align"]:
|
||||
log(f"[{s}] running")
|
||||
running_event.wait()
|
||||
return PipelineResult(output_dir=cfg.output_dir / "x", warnings=[])
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
def start_server(tmp_path: Path) -> str:
|
||||
cfg = Config(whisper_url="http://localhost:9000/v1", output_dir=tmp_path / "out",
|
||||
work_dir=tmp_path / "work", bind_host="127.0.0.1", port=8811)
|
||||
app = create_app(cfg, runner=make_runner())
|
||||
server = uvicorn.Server(uvicorn.Config(app, host=cfg.bind_host, port=cfg.port,
|
||||
log_level="warning"))
|
||||
threading.Thread(target=server.run, daemon=True).start()
|
||||
return f"http://{cfg.bind_host}:{cfg.port}"
|
||||
|
||||
|
||||
def seed_jobs(base_url: str) -> None:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
for title, artist, kind, extra in SONGS:
|
||||
resp = client.post(f"{base_url}/api/jobs", data={
|
||||
"title": title, "artist": artist,
|
||||
"lyrics": extra.get("lyrics", DEFAULT_LYRICS),
|
||||
"youtube_url": "https://youtu.be/00000000000"})
|
||||
resp.raise_for_status()
|
||||
job_id = resp.json()["id"]
|
||||
if kind == "queued":
|
||||
continue
|
||||
for _ in range(200):
|
||||
job = client.get(f"{base_url}/api/jobs/{job_id}").json()
|
||||
if kind == "running" and job["stage"] == "align":
|
||||
break
|
||||
if job["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def capture(base_url: str) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
page.goto(base_url, wait_until="networkidle")
|
||||
page.get_by_text(SELECTED, exact=True).click()
|
||||
page.wait_for_timeout(300)
|
||||
OUT_IMAGE.parent.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=str(OUT_IMAGE))
|
||||
browser.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_url = start_server(Path(tmp))
|
||||
time.sleep(1)
|
||||
seed_jobs(base_url)
|
||||
capture(base_url)
|
||||
print(f"wrote {OUT_IMAGE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,43 +0,0 @@
|
||||
import difflib
|
||||
import json
|
||||
|
||||
from ..lyrics import RefLyrics, alignment_token
|
||||
from ..pipeline import Ctx, Stage
|
||||
from ..whisper_client import TranscriptSegment, transcribe
|
||||
|
||||
|
||||
def vocal_sections(segments, min_gap: float = 6.0, pad: float = 1.0):
|
||||
sections = []
|
||||
for s in sorted(segments, key=lambda s: s.start):
|
||||
if sections and s.start - sections[-1][1] < min_gap:
|
||||
sections[-1][1] = max(sections[-1][1], s.end)
|
||||
else:
|
||||
sections.append([s.start, s.end])
|
||||
return [(max(0.0, a - pad), b + pad) for a, b in sections]
|
||||
|
||||
|
||||
def _tokens(text: str) -> list[str]:
|
||||
return [t for w in text.split() if (t := alignment_token(w))]
|
||||
|
||||
|
||||
def lyrics_similarity(segments, ref: RefLyrics) -> float:
|
||||
heard = [t for s in segments for t in _tokens(s.text)]
|
||||
expected = [t for w in ref.words if (t := alignment_token(w))]
|
||||
return difflib.SequenceMatcher(a=heard, b=expected).ratio()
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
segments = transcribe(ctx.cfg, ctx.work / "vocals.wav", ctx.inp.language)
|
||||
(ctx.work / "transcript.json").write_text(json.dumps(
|
||||
[{"start": s.start, "end": s.end, "text": s.text} for s in segments]))
|
||||
|
||||
|
||||
def transcribe_stage() -> Stage:
|
||||
return Stage(name="transcribe",
|
||||
outputs=lambda ctx: [ctx.work / "transcript.json"],
|
||||
run=_run)
|
||||
|
||||
|
||||
def load_transcript(ctx: Ctx) -> list[TranscriptSegment]:
|
||||
data = json.loads((ctx.work / "transcript.json").read_text())
|
||||
return [TranscriptSegment(**d) for d in data]
|
||||
@@ -1,39 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
from .languages import LANGUAGES
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
def transcribe(cfg: Config, audio_path: Path, language: str | None = None,
|
||||
client: httpx.Client | None = None) -> list[TranscriptSegment]:
|
||||
headers = {}
|
||||
if cfg.whisper_api_key:
|
||||
headers["Authorization"] = f"Bearer {cfg.whisper_api_key}"
|
||||
data = {"model": cfg.whisper_model, "response_format": "verbose_json"}
|
||||
if language:
|
||||
entry = LANGUAGES.get(language.lower())
|
||||
data["language"] = entry.iso if entry else language
|
||||
own = client is None
|
||||
client = client or httpx.Client(timeout=600)
|
||||
try:
|
||||
with audio_path.open("rb") as f:
|
||||
resp = client.post(
|
||||
f"{cfg.whisper_url.rstrip('/')}/audio/transcriptions",
|
||||
headers=headers, data=data,
|
||||
files={"file": (audio_path.name, f, "audio/wav")})
|
||||
resp.raise_for_status()
|
||||
return [TranscriptSegment(s["start"], s["end"], s["text"].strip())
|
||||
for s in resp.json().get("segments", [])]
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
|
||||
def load_wav(path: Path) -> tuple[torch.Tensor, int]:
|
||||
"""Load a WAV file as a (channels, samples) float32 tensor.
|
||||
|
||||
Reads via soundfile rather than torchaudio.load, whose default backend
|
||||
now requires the separate torchcodec package.
|
||||
"""
|
||||
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
||||
return torch.from_numpy(data.T).contiguous(), sr
|
||||
@@ -16,7 +16,7 @@ def _positive_float(value: str) -> float:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="karaoke-maker")
|
||||
parser = argparse.ArgumentParser(prog="songtrace")
|
||||
parser.add_argument("--version", action="store_true")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
@@ -72,7 +72,7 @@ def _cmd_make(args) -> int:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.version:
|
||||
print(f"karaoke-maker {version('karaoke-maker')}")
|
||||
print(f"songtrace {version('songtrace')}")
|
||||
return 0
|
||||
if args.command == "make":
|
||||
return _cmd_make(args)
|
||||
@@ -9,9 +9,10 @@ class Config:
|
||||
whisper_url: str
|
||||
whisper_api_key: str | None = None
|
||||
whisper_model: str = "whisper-1"
|
||||
whisper_api: str = "openai"
|
||||
output_dir: Path = Path("output")
|
||||
work_dir: Path = Path("work")
|
||||
creator: str = "karaoke-maker"
|
||||
creator: str = "songtrace"
|
||||
demucs_model: str = "htdemucs_ft"
|
||||
crepe_model: str = "full"
|
||||
device: str = "cuda"
|
||||
@@ -24,8 +25,8 @@ _PATH_FIELDS = {"output_dir", "work_dir"}
|
||||
|
||||
def load_config(path: Path | None = None) -> Config:
|
||||
if path is None:
|
||||
env = os.environ.get("KARAOKE_MAKER_CONFIG")
|
||||
path = Path(env) if env else Path.home() / ".config/karaoke-maker/config.toml"
|
||||
env = os.environ.get("SONGTRACE_CONFIG")
|
||||
path = Path(env) if env else Path.home() / ".config/songtrace/config.toml"
|
||||
data = tomllib.loads(Path(path).read_text())
|
||||
known = {f.name for f in fields(Config)}
|
||||
unknown = set(data) - known
|
||||
@@ -23,6 +23,7 @@ class TimedNote:
|
||||
pitch: int # UltraStar semitone (0 = C4 = MIDI 60)
|
||||
text: str # display text incl. leading space / "~"
|
||||
low_confidence: bool
|
||||
line_index: int
|
||||
|
||||
|
||||
def hz_to_midi_float(hz: float) -> float:
|
||||
@@ -78,21 +79,31 @@ def syllabify(word: str, language: str | None) -> list[str]:
|
||||
return parts or [word]
|
||||
|
||||
|
||||
def build_notes(rows: list[dict], times, f0, conf, language: str | None
|
||||
def build_notes(rows: list[dict], times, f0, conf, language: str | None,
|
||||
dropped_lines=(), missing_spans=(), ref_lines=None
|
||||
) -> tuple[list[TimedNote], list[float], list[str]]:
|
||||
notes: list[TimedNote] = []
|
||||
breaks: list[float] = []
|
||||
prev_line, prev_end = None, 0.0
|
||||
prev_line, prev_end, last_seen_line = None, 0.0, None
|
||||
for row in rows:
|
||||
if prev_line is not None and row["line_index"] > prev_line:
|
||||
# last_seen_line tracks every row (skipped or not) so a run of
|
||||
# skipped rows can't trigger a duplicate break on the next row of
|
||||
# the same still-unseen line; prev_line/prev_end only advance on
|
||||
# rows that actually emit notes, so a skipped row can't corrupt
|
||||
# line_initial or break timing for its own line's later words.
|
||||
if last_seen_line is not None and row["line_index"] > last_seen_line:
|
||||
breaks.append((prev_end + row["start"]) / 2)
|
||||
last_seen_line = row["line_index"]
|
||||
line_initial = row["line_index"] != prev_line
|
||||
if any(a <= row["start"] < b for a, b in missing_spans):
|
||||
continue
|
||||
prev_line, prev_end = row["line_index"], row["end"]
|
||||
segs = segment_span(times, f0, conf, row["start"], row["end"])
|
||||
if not segs:
|
||||
pitch = notes[-1].pitch if notes else 0
|
||||
notes.append(_note(row["start"], row["end"] - row["start"], pitch,
|
||||
_text(row["word"], 0, line_initial), True))
|
||||
_text(row["word"], 0, line_initial), True,
|
||||
row["line_index"]))
|
||||
continue
|
||||
low = row["score"] < LOW_SCORE
|
||||
sylls = syllabify(row["word"], language)
|
||||
@@ -100,14 +111,16 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None
|
||||
segs = _subdivide(segs, len(sylls))
|
||||
for i, (s, e, midi) in enumerate(segs):
|
||||
text = _text(sylls[i], i, line_initial) if i < len(sylls) else "~"
|
||||
notes.append(_note(s, e - s, round(midi) - 60, text, low))
|
||||
notes.append(_note(s, e - s, round(midi) - 60, text, low,
|
||||
row["line_index"]))
|
||||
_deoverlap(notes)
|
||||
return notes, breaks, _warnings(notes)
|
||||
return notes, breaks, _warnings(notes, dropped_lines, missing_spans, ref_lines or [])
|
||||
|
||||
|
||||
def _note(start: float, duration: float, pitch: int, text: str,
|
||||
low: bool) -> TimedNote:
|
||||
return TimedNote(float(start), float(duration), int(pitch), text, low)
|
||||
low: bool, line_index: int) -> TimedNote:
|
||||
return TimedNote(float(start), float(duration), int(pitch), text, low,
|
||||
int(line_index))
|
||||
|
||||
|
||||
def _deoverlap(notes: list[TimedNote]) -> None:
|
||||
@@ -153,7 +166,8 @@ def _fmt(t: float) -> str:
|
||||
return f"{int(t // 60)}:{int(t % 60):02d}"
|
||||
|
||||
|
||||
def _warnings(notes: list[TimedNote]) -> list[str]:
|
||||
def _warnings(notes: list[TimedNote], dropped_lines=(), missing_spans=(),
|
||||
ref_lines=()) -> list[str]:
|
||||
warnings, run = [], []
|
||||
for n in list(notes) + [None]:
|
||||
if n is not None and n.low_confidence:
|
||||
@@ -164,4 +178,21 @@ def _warnings(notes: list[TimedNote]) -> list[str]:
|
||||
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
|
||||
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
|
||||
run = []
|
||||
for group in _consecutive_runs(dropped_lines):
|
||||
n = len(group)
|
||||
text = " ".join(ref_lines[group[0]]) if group[0] < len(ref_lines) else ""
|
||||
warnings.append(
|
||||
f"{n} line{'' if n == 1 else 's'} dropped (not sung) near \"{text}\"")
|
||||
for a, b in missing_spans:
|
||||
warnings.append(f"missing lyrics near {_fmt(a)}–{_fmt(b)}")
|
||||
return warnings
|
||||
|
||||
|
||||
def _consecutive_runs(indices) -> list[list[int]]:
|
||||
runs = []
|
||||
for i in sorted(indices):
|
||||
if runs and i == runs[-1][-1] + 1:
|
||||
runs[-1].append(i)
|
||||
else:
|
||||
runs.append([i])
|
||||
return runs
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..lyrics import RefLyrics, alignment_token, normalize_lyrics
|
||||
from ..lyrics import alignment_token, normalize_lyrics
|
||||
from ..pipeline import Ctx, PipelineError, Stage
|
||||
from .transcribe import load_transcript, lyrics_similarity, vocal_sections
|
||||
from .transcribe import classify_lyrics_match, load_transcript, lyrics_similarity, vocal_sections
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
@@ -23,7 +23,9 @@ def force_align_section(vocals_path: Path, start: float, end: float,
|
||||
import torchaudio
|
||||
from torchaudio.pipelines import MMS_FA as bundle
|
||||
|
||||
waveform, sr = torchaudio.load(str(vocals_path))
|
||||
from ..audio_io import load_wav
|
||||
|
||||
waveform, sr = load_wav(vocals_path)
|
||||
waveform = waveform.mean(0, keepdim=True)
|
||||
lo, hi = int(start * sr), min(int(end * sr), waveform.shape[1])
|
||||
waveform = torchaudio.functional.resample(
|
||||
@@ -40,16 +42,15 @@ def force_align_section(vocals_path: Path, start: float, end: float,
|
||||
sum(t.score for t in s) / len(s)) for s in spans]
|
||||
|
||||
|
||||
def _assign_lines_to_sections(ref: RefLyrics, sections) -> list[list[int]]:
|
||||
"""Split lyric lines across vocal sections proportionally to section duration."""
|
||||
def _assign_lines_to_sections(line_indices: list[int], sections) -> list[list[int]]:
|
||||
"""Split matched lyric line indices across vocal sections proportionally to section duration."""
|
||||
total = sum(b - a for a, b in sections) or 1.0
|
||||
n_lines = len(ref.lines)
|
||||
n = len(line_indices)
|
||||
groups, elapsed, start = [], 0.0, 0
|
||||
for i, (a, b) in enumerate(sections):
|
||||
elapsed += b - a
|
||||
stop = n_lines if i == len(sections) - 1 else min(
|
||||
n_lines, round(n_lines * elapsed / total))
|
||||
groups.append(list(range(start, max(stop, start))))
|
||||
stop = n if i == len(sections) - 1 else min(n, round(n * elapsed / total))
|
||||
groups.append(line_indices[start:max(stop, start)])
|
||||
start = max(stop, start)
|
||||
return groups
|
||||
|
||||
@@ -97,7 +98,7 @@ def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
ref = normalize_lyrics(ctx.inp.lyrics_text)
|
||||
segments = load_transcript(ctx)
|
||||
segments = sorted(load_transcript(ctx), key=lambda s: s.start)
|
||||
sim = lyrics_similarity(segments, ref)
|
||||
if sim < SIMILARITY_THRESHOLD:
|
||||
raise PipelineError(
|
||||
@@ -105,15 +106,21 @@ def _run(ctx: Ctx) -> None:
|
||||
"wrong lyrics or wrong recording?")
|
||||
# normalised so an inverted span can never collapse a section onto one instant
|
||||
sections = [(min(a, b), max(a, b)) for a, b in vocal_sections(segments)]
|
||||
dropped_lines, missing_spans = classify_lyrics_match(segments, ref)
|
||||
matched = [i for i in range(len(ref.lines)) if i not in set(dropped_lines)]
|
||||
vocals = ctx.work / "vocals.wav"
|
||||
rows = []
|
||||
for sec, line_idxs in zip(sections, _assign_lines_to_sections(ref, sections)):
|
||||
for sec, line_idxs in zip(sections, _assign_lines_to_sections(matched, sections)):
|
||||
words = [(w, li) for li in line_idxs for w in ref.lines[li]]
|
||||
if words:
|
||||
rows.extend(_align_section(vocals, sec, words, ctx.inp.language,
|
||||
ctx.cfg.device))
|
||||
_enforce_monotonic(rows)
|
||||
(ctx.work / "alignment.json").write_text(json.dumps(rows))
|
||||
(ctx.work / "align_meta.json").write_text(json.dumps({
|
||||
"dropped_lines": dropped_lines,
|
||||
"missing_spans": [list(s) for s in missing_spans],
|
||||
}))
|
||||
|
||||
|
||||
def align_stage() -> Stage:
|
||||
@@ -3,6 +3,7 @@ from dataclasses import asdict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..lyrics import normalize_lyrics
|
||||
from ..notes import build_notes
|
||||
from ..pipeline import Ctx, Stage
|
||||
|
||||
@@ -10,8 +11,15 @@ from ..pipeline import Ctx, Stage
|
||||
def _run(ctx: Ctx) -> None:
|
||||
rows = json.loads((ctx.work / "alignment.json").read_text())
|
||||
data = np.load(ctx.work / "pitch.npz")
|
||||
meta_path = ctx.work / "align_meta.json"
|
||||
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
|
||||
dropped_lines = meta.get("dropped_lines", [])
|
||||
missing_spans = [tuple(s) for s in meta.get("missing_spans", [])]
|
||||
ref_lines = normalize_lyrics(ctx.inp.lyrics_text).lines
|
||||
notes, breaks, warnings = build_notes(
|
||||
rows, data["times"], data["f0"], data["conf"], ctx.inp.language)
|
||||
rows, data["times"], data["f0"], data["conf"], ctx.inp.language,
|
||||
dropped_lines=dropped_lines, missing_spans=missing_spans,
|
||||
ref_lines=ref_lines)
|
||||
(ctx.work / "notes_sec.json").write_text(json.dumps({
|
||||
"notes": [asdict(n) for n in notes],
|
||||
"breaks": breaks,
|
||||
@@ -9,7 +9,9 @@ def track_pitch(vocals_path: Path, model: str, device: str):
|
||||
import torchaudio
|
||||
import torchcrepe
|
||||
|
||||
waveform, sr = torchaudio.load(str(vocals_path))
|
||||
from ..audio_io import load_wav
|
||||
|
||||
waveform, sr = load_wav(vocals_path)
|
||||
waveform = waveform.mean(0, keepdim=True)
|
||||
waveform = torchaudio.functional.resample(waveform, sr, 16000)
|
||||
f0, periodicity = torchcrepe.predict(
|
||||
@@ -0,0 +1,100 @@
|
||||
import difflib
|
||||
import json
|
||||
|
||||
from ..lyrics import RefLyrics, alignment_token
|
||||
from ..pipeline import Ctx, Stage
|
||||
from ..whisper_client import TranscriptSegment, transcribe
|
||||
|
||||
|
||||
def vocal_sections(segments, min_gap: float = 6.0, pad: float = 1.0):
|
||||
sections = []
|
||||
for s in sorted(segments, key=lambda s: s.start):
|
||||
if sections and s.start - sections[-1][1] < min_gap:
|
||||
sections[-1][1] = max(sections[-1][1], s.end)
|
||||
else:
|
||||
sections.append([s.start, s.end])
|
||||
return [(max(0.0, a - pad), b + pad) for a, b in sections]
|
||||
|
||||
|
||||
def _tokens(text: str) -> list[str]:
|
||||
return [t for w in text.split() if (t := alignment_token(w))]
|
||||
|
||||
|
||||
def match_lyrics(segments, ref: RefLyrics):
|
||||
heard, seg_of = [], []
|
||||
for si, s in enumerate(segments):
|
||||
for t in _tokens(s.text):
|
||||
heard.append(t)
|
||||
seg_of.append(si)
|
||||
pairs = [(t, li) for w, li in zip(ref.words, ref.word_line_index)
|
||||
if (t := alignment_token(w))]
|
||||
expected = [t for t, _ in pairs]
|
||||
line_of = [li for _, li in pairs]
|
||||
matcher = difflib.SequenceMatcher(a=heard, b=expected)
|
||||
return matcher, seg_of, line_of
|
||||
|
||||
|
||||
def lyrics_similarity(segments, ref: RefLyrics) -> float:
|
||||
matcher, _, _ = match_lyrics(segments, ref)
|
||||
return matcher.ratio()
|
||||
|
||||
|
||||
def classify_lyrics_match(segments, ref: RefLyrics, pad: float = 1.0
|
||||
) -> tuple[list[int], list[tuple[float, float]]]:
|
||||
"""Split reference lines into matched vs. dropped, and flag sung spans no
|
||||
reference line explains.
|
||||
|
||||
dropped_lines: reference line indices covered only by 'insert' opcodes
|
||||
(present in the lyrics, never heard). missing_spans: the padded, merged
|
||||
bounds of the heard segments behind 'delete' opcodes (heard, but matching
|
||||
no reference word) — scoped tightly to the unmatched audio itself, not
|
||||
the whole vocal section it falls in, so matched lines sharing a section
|
||||
with an ad-lib keep their notes.
|
||||
"""
|
||||
matcher, seg_of, line_of = match_lyrics(segments, ref)
|
||||
covered = [False] * len(line_of)
|
||||
for tag, _, _, bj1, bj2 in matcher.get_opcodes():
|
||||
if tag in ("equal", "replace"):
|
||||
for j in range(bj1, bj2):
|
||||
covered[j] = True
|
||||
by_line = {}
|
||||
for j, li in enumerate(line_of):
|
||||
by_line.setdefault(li, []).append(covered[j])
|
||||
dropped_lines = sorted(li for li, cov in by_line.items() if not any(cov))
|
||||
|
||||
spans = []
|
||||
for tag, ai1, ai2, _, _ in matcher.get_opcodes():
|
||||
if tag != "delete":
|
||||
continue
|
||||
sis = [seg_of[i] for i in range(ai1, ai2)]
|
||||
start = max(0.0, min(segments[si].start for si in sis) - pad)
|
||||
end = max(segments[si].end for si in sis) + pad
|
||||
spans.append((start, end))
|
||||
return dropped_lines, _merge_spans(spans)
|
||||
|
||||
|
||||
def _merge_spans(spans: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
merged: list[list[float]] = []
|
||||
for a, b in sorted(spans):
|
||||
if merged and a <= merged[-1][1]:
|
||||
merged[-1][1] = max(merged[-1][1], b)
|
||||
else:
|
||||
merged.append([a, b])
|
||||
return [tuple(s) for s in merged]
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
segments = transcribe(ctx.cfg, ctx.work / "vocals.wav", ctx.inp.language)
|
||||
(ctx.work / "transcript.json").write_text(json.dumps(
|
||||
[{"start": s.start, "end": s.end, "text": s.text} for s in segments]))
|
||||
|
||||
|
||||
def transcribe_stage() -> Stage:
|
||||
return Stage(name="transcribe",
|
||||
outputs=lambda ctx: [ctx.work / "transcript.json"],
|
||||
run=_run)
|
||||
|
||||
|
||||
def load_transcript(ctx: Ctx) -> list[TranscriptSegment]:
|
||||
data = json.loads((ctx.work / "transcript.json").read_text())
|
||||
return [TranscriptSegment(**d) for d in data]
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>karaoke-maker</title>
|
||||
<title>songtrace</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
@@ -194,6 +194,9 @@ body {
|
||||
}
|
||||
|
||||
#song-form { display: grid; gap: 18px; }
|
||||
.song-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.song-form-main { display: grid; gap: 18px; align-content: start; }
|
||||
.song-form-lyrics textarea { height: 100%; flex: 1 1 auto; }
|
||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field > span {
|
||||
@@ -220,7 +223,6 @@ input:not([type="file"]):hover { border-bottom-color: var(--text-muted); }
|
||||
input:not([type="file"]):focus { border-bottom-color: var(--aqua); }
|
||||
|
||||
textarea {
|
||||
min-height: 8.5rem;
|
||||
padding: 11px 13px;
|
||||
line-height: 1.6;
|
||||
background: var(--panel);
|
||||
@@ -229,6 +231,7 @@ textarea {
|
||||
}
|
||||
textarea:hover { border-color: var(--text-muted); }
|
||||
textarea:focus { border-color: var(--aqua); }
|
||||
.song-form-lyrics textarea { min-height: 8.5rem; }
|
||||
|
||||
input[type="file"] {
|
||||
font-size: 0.82rem;
|
||||
@@ -316,20 +319,28 @@ button { cursor: pointer; }
|
||||
}
|
||||
#review h3 span { color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
#warnings { display: flex; flex-direction: column; gap: 1px; }
|
||||
.warn-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
#lyrics-review { display: flex; flex-direction: column; gap: 10px; }
|
||||
.review-line { padding: 8px 12px; background: var(--panel); border: 1px solid var(--border); }
|
||||
.review-line-text { display: block; font-size: 0.9rem; }
|
||||
.review-line-dropped { opacity: 0.55; }
|
||||
.review-line-dropped .review-line-text { text-decoration: line-through; }
|
||||
.review-line-note { display: block; margin-top: 4px; font-size: 0.72rem; color: var(--text-muted); }
|
||||
.review-line-timeline { position: relative; margin-top: 4px; }
|
||||
.review-lane { position: absolute; top: 0; bottom: 0; }
|
||||
.review-lane-alt { background: var(--panel-raised); }
|
||||
.review-word-row { position: relative; height: 18px; }
|
||||
.review-word { position: absolute; top: 0; white-space: nowrap; font-size: 0.9rem; }
|
||||
.review-roll { position: relative; display: block; width: 100%; height: 20px; margin-top: 4px; }
|
||||
.review-note { fill: var(--aqua); }
|
||||
.review-note-low { fill: var(--warn); }
|
||||
.review-missing {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warn);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
.warn-row + .warn-row { margin-top: 6px; }
|
||||
.warn-icon { color: var(--warn); }
|
||||
.warn-count { font-size: 0.85rem; }
|
||||
.warn-range { justify-self: end; font-family: var(--font-mono); font-size: 0.82rem; color: var(--text-muted); }
|
||||
|
||||
.review-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--fail); }
|
||||
|
||||
@@ -464,6 +475,11 @@ button { cursor: pointer; }
|
||||
#stage-track { margin-left: 0; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.song-form-grid { grid-template-columns: 1fr; }
|
||||
.song-form-lyrics textarea { height: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -473,7 +489,7 @@ button { cursor: pointer; }
|
||||
|
||||
<aside id="sidebar">
|
||||
<div class="brand">
|
||||
<h1 class="wordmark"><span>KARAOKE</span> <span class="second">MAKER</span></h1>
|
||||
<h1 class="wordmark"><span>SONG</span> <span class="second">TRACE</span></h1>
|
||||
<div class="kana" aria-hidden="true">カラオケ</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -486,29 +502,33 @@ button { cursor: pointer; }
|
||||
<section id="new-song">
|
||||
<h2>NEW SONG</h2>
|
||||
<form id="song-form">
|
||||
<div class="pair">
|
||||
<label class="field"><span>Title</span><input name="title" required></label>
|
||||
<label class="field"><span>Artist</span><input name="artist" required></label>
|
||||
</div>
|
||||
<label class="field"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
|
||||
<details>
|
||||
<summary>Advanced</summary>
|
||||
<div class="stack">
|
||||
<div class="song-form-grid">
|
||||
<div class="song-form-main">
|
||||
<div class="pair">
|
||||
<label class="field"><span>Title</span><input name="title" required></label>
|
||||
<label class="field"><span>Artist</span><input name="artist" required></label>
|
||||
</div>
|
||||
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
|
||||
<label class="field"><span>Video file</span><input type="file" name="video"></label>
|
||||
<p class="keep-note" data-keep="video" hidden></p>
|
||||
<div class="pair">
|
||||
<label class="field"><span>Genre</span><input name="genre"></label>
|
||||
<label class="field"><span>Year</span><input name="year" type="number"></label>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<label class="field"><span>Language</span><input name="language"></label>
|
||||
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
|
||||
</div>
|
||||
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
|
||||
<p class="keep-note" data-keep="cover" hidden></p>
|
||||
<details>
|
||||
<summary>Advanced</summary>
|
||||
<div class="stack">
|
||||
<div class="pair">
|
||||
<label class="field"><span>Genre</span><input name="genre"></label>
|
||||
<label class="field"><span>Year</span><input name="year" type="number"></label>
|
||||
</div>
|
||||
<div class="pair">
|
||||
<label class="field"><span>Language</span><input name="language"></label>
|
||||
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
|
||||
</div>
|
||||
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
|
||||
<p class="keep-note" data-keep="cover" hidden></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<label class="field song-form-lyrics"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary">CREATE SONG</button>
|
||||
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
|
||||
@@ -522,7 +542,7 @@ button { cursor: pointer; }
|
||||
<h3 id="review-title"></h3>
|
||||
<p id="review-lineage" hidden></p>
|
||||
<p id="review-error" class="review-error" hidden></p>
|
||||
<div id="warnings"><p class="empty-note">no songs queued yet — nothing to review</p></div>
|
||||
<div id="lyrics-review"><p class="empty-note">no songs queued yet — nothing to review</p></div>
|
||||
<div id="output-path"></div>
|
||||
<div id="review-actions"></div>
|
||||
<details>
|
||||
@@ -561,8 +581,6 @@ let selectedId = null;
|
||||
let STAGES = [];
|
||||
let pollTimer = null;
|
||||
|
||||
const WARN_RE = /^(\d+)\s+low-confidence notes?\s+near\s+(\d+:\d{2})–(\d+:\d{2})/;
|
||||
|
||||
const STATE = {
|
||||
queued: { glyph: "○", cls: "st-queued" },
|
||||
running: { glyph: "◈", cls: "st-running" },
|
||||
@@ -777,44 +795,151 @@ function emptyNote(text) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderWarnings(job) {
|
||||
const box = document.getElementById("warnings");
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function fmtTime(t) {
|
||||
const s = Math.round(t);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function missingSpanNode(span) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "review-missing";
|
||||
p.textContent = `♪ unmatched singing, ${fmtTime(span.start)}–${fmtTime(span.end)}`;
|
||||
return p;
|
||||
}
|
||||
|
||||
function pianoRoll(notes, minPitch, span, t0, t1) {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", "review-roll");
|
||||
svg.setAttribute("viewBox", "0 0 100 20");
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
const dur = Math.max(t1 - t0, 0.001);
|
||||
for (const n of notes) {
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
const x = ((n.start - t0) / dur) * 100;
|
||||
const w = Math.max(((n.end - n.start) / dur) * 100, 0.5);
|
||||
const y = 18 - ((n.pitch - minPitch) / span) * 16;
|
||||
rect.setAttribute("x", x.toFixed(2));
|
||||
rect.setAttribute("width", w.toFixed(2));
|
||||
rect.setAttribute("y", y.toFixed(2));
|
||||
rect.setAttribute("height", "2");
|
||||
rect.setAttribute("class", n.low_confidence ? "review-note review-note-low" : "review-note");
|
||||
svg.append(rect);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Lays words out along a shared time axis (like a karaoke lyric line) so
|
||||
// each word's notes render directly beneath it instead of as one flat strip.
|
||||
function lineTimeline(words, notes, minPitch, span) {
|
||||
const t0 = words[0].start;
|
||||
const t1 = words[words.length - 1].end;
|
||||
const dur = Math.max(t1 - t0, 0.001);
|
||||
const box = document.createElement("div");
|
||||
box.className = "review-line-timeline";
|
||||
words.forEach((w, i) => {
|
||||
const lane = document.createElement("div");
|
||||
lane.className = "review-lane" + (i % 2 ? " review-lane-alt" : "");
|
||||
lane.style.left = `${((w.start - t0) / dur) * 100}%`;
|
||||
lane.style.width = `${Math.max(((w.end - w.start) / dur) * 100, 0.5)}%`;
|
||||
box.append(lane);
|
||||
});
|
||||
const wordsRow = document.createElement("div");
|
||||
wordsRow.className = "review-word-row";
|
||||
for (const w of words) {
|
||||
const label = document.createElement("span");
|
||||
label.className = "review-word";
|
||||
label.style.left = `${((w.start - t0) / dur) * 100}%`;
|
||||
label.textContent = w.text;
|
||||
wordsRow.append(label);
|
||||
}
|
||||
box.append(wordsRow);
|
||||
if (notes.length) box.append(pianoRoll(notes, minPitch, span, t0, t1));
|
||||
return box;
|
||||
}
|
||||
|
||||
function lineNode(line, minPitch, span) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "review-line" + (line.dropped ? " review-line-dropped" : "");
|
||||
if (line.dropped) {
|
||||
const text = document.createElement("span");
|
||||
text.className = "review-line-text";
|
||||
text.textContent = line.text;
|
||||
row.append(text);
|
||||
const note = document.createElement("span");
|
||||
note.className = "review-line-note";
|
||||
note.textContent = "not sung — dropped";
|
||||
row.append(note);
|
||||
return row;
|
||||
}
|
||||
if (line.words.length) {
|
||||
row.append(lineTimeline(line.words, line.notes, minPitch, span));
|
||||
return row;
|
||||
}
|
||||
const text = document.createElement("span");
|
||||
text.className = "review-line-text";
|
||||
text.textContent = line.text;
|
||||
row.append(text);
|
||||
if (line.notes.length) {
|
||||
const t0 = line.notes[0].start;
|
||||
const t1 = Math.max(...line.notes.map(n => n.end));
|
||||
row.append(pianoRoll(line.notes, minPitch, span, t0, t1));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildLyricsReview(data) {
|
||||
const allNotes = data.lines.flatMap(l => l.notes);
|
||||
const pitches = allNotes.map(n => n.pitch);
|
||||
const minPitch = pitches.length ? Math.min(...pitches) : 0;
|
||||
const maxPitch = pitches.length ? Math.max(...pitches) : 0;
|
||||
const span = Math.max(1, maxPitch - minPitch);
|
||||
const spans = [...data.missing_spans].sort((a, b) => a.start - b.start);
|
||||
const nodes = [];
|
||||
let spanIdx = 0;
|
||||
for (const line of data.lines) {
|
||||
const lineStart = line.notes.length ? Math.min(...line.notes.map(n => n.start)) : null;
|
||||
while (spanIdx < spans.length && lineStart !== null && spans[spanIdx].start < lineStart) {
|
||||
nodes.push(missingSpanNode(spans[spanIdx]));
|
||||
spanIdx++;
|
||||
}
|
||||
nodes.push(lineNode(line, minPitch, span));
|
||||
}
|
||||
while (spanIdx < spans.length) {
|
||||
nodes.push(missingSpanNode(spans[spanIdx]));
|
||||
spanIdx++;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
async function renderLyricsReview(job) {
|
||||
const box = document.getElementById("lyrics-review");
|
||||
if (job.status === "failed") {
|
||||
delete box.dataset.jobId;
|
||||
box.replaceChildren(emptyNote("job failed — nothing to review"));
|
||||
return;
|
||||
}
|
||||
if (job.status !== "done") {
|
||||
delete box.dataset.jobId;
|
||||
box.replaceChildren(emptyNote("waiting for this song to finish…"));
|
||||
return;
|
||||
}
|
||||
if (!job.warnings.length) {
|
||||
box.replaceChildren(emptyNote("no low-confidence regions — nothing to review"));
|
||||
if (box.dataset.jobId === job.id) return;
|
||||
const resp = await fetch(`/api/jobs/${job.id}/review`);
|
||||
if (job.id !== selectedId) return;
|
||||
if (!resp.ok) {
|
||||
box.replaceChildren(emptyNote("no review data available"));
|
||||
return;
|
||||
}
|
||||
const rows = job.warnings.map(w => {
|
||||
const m = WARN_RE.exec(w);
|
||||
const row = document.createElement("div");
|
||||
row.className = "warn-row";
|
||||
if (m) {
|
||||
row.setAttribute("data-count", m[1]);
|
||||
row.setAttribute("data-start", m[2]);
|
||||
row.setAttribute("data-end", m[3]);
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "warn-icon";
|
||||
icon.textContent = "⚠";
|
||||
const count = document.createElement("span");
|
||||
count.className = "warn-count";
|
||||
count.textContent = `${m[1]} note${m[1] === "1" ? "" : "s"}`;
|
||||
const range = document.createElement("span");
|
||||
range.className = "warn-range";
|
||||
range.textContent = `${m[2]}–${m[3]}`;
|
||||
row.append(icon, count, range);
|
||||
} else {
|
||||
row.textContent = w;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
box.replaceChildren(...rows);
|
||||
const data = await resp.json();
|
||||
if (job.id !== selectedId) return;
|
||||
box.dataset.jobId = job.id;
|
||||
if (!data.lines.length) {
|
||||
box.replaceChildren(emptyNote("no lyrics to review"));
|
||||
return;
|
||||
}
|
||||
box.replaceChildren(...buildLyricsReview(data));
|
||||
}
|
||||
|
||||
async function copyPath(path, button) {
|
||||
@@ -969,7 +1094,7 @@ async function refreshDetail() {
|
||||
reviewTitle.replaceChildren(`${job.title} `, artist);
|
||||
reviewError.hidden = !job.error;
|
||||
reviewError.textContent = job.error ?? "";
|
||||
renderWarnings(job);
|
||||
renderLyricsReview(job);
|
||||
renderOutputPath(job);
|
||||
renderLineage(job);
|
||||
renderActions(job);
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
@@ -15,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import rebuild as rebuild_policy
|
||||
from .config import Config
|
||||
from .lyrics import normalize_lyrics
|
||||
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
|
||||
song_slug)
|
||||
|
||||
@@ -303,6 +305,38 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
"video_filename": inp.video_path.name if inp.video_path else None,
|
||||
"cover_filename": inp.cover_path.name if inp.cover_path else None}
|
||||
|
||||
@app.get("/api/jobs/{job_id}/review")
|
||||
def get_review(job_id: str):
|
||||
job = _finished_job(job_id)
|
||||
if job.status != "done":
|
||||
raise HTTPException(404)
|
||||
work = cfg.work_dir / song_slug(job.inp)
|
||||
notes_file = work / "notes_sec.json"
|
||||
if not notes_file.exists():
|
||||
raise HTTPException(404)
|
||||
notes_data = json.loads(notes_file.read_text())
|
||||
meta_file = work / "align_meta.json"
|
||||
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
|
||||
dropped = set(meta.get("dropped_lines", []))
|
||||
missing_spans = [{"start": s, "end": e}
|
||||
for s, e in meta.get("missing_spans", [])]
|
||||
align_file = work / "alignment.json"
|
||||
align_rows = json.loads(align_file.read_text()) if align_file.exists() else []
|
||||
words_by_line: dict[int, list] = {}
|
||||
for r in align_rows:
|
||||
words_by_line.setdefault(r["line_index"], []).append(
|
||||
{"text": r["word"], "start": r["start"], "end": r["end"]})
|
||||
ref = normalize_lyrics(job.inp.lyrics_text)
|
||||
by_line: dict[int, list] = {}
|
||||
for n in notes_data["notes"]:
|
||||
by_line.setdefault(n["line_index"], []).append({
|
||||
"start": n["start"], "end": n["start"] + n["duration"],
|
||||
"pitch": n["pitch"], "low_confidence": n["low_confidence"]})
|
||||
lines = [{"index": i, "text": " ".join(words), "dropped": i in dropped,
|
||||
"notes": by_line.get(i, []), "words": words_by_line.get(i, [])}
|
||||
for i, words in enumerate(ref.lines)]
|
||||
return {"lines": lines, "missing_spans": missing_spans}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
from .languages import LANGUAGES
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
def transcribe(cfg: Config, audio_path: Path, language: str | None = None,
|
||||
client: httpx.Client | None = None) -> list[TranscriptSegment]:
|
||||
headers = {}
|
||||
if cfg.whisper_api_key:
|
||||
headers["Authorization"] = f"Bearer {cfg.whisper_api_key}"
|
||||
iso_language = None
|
||||
if language:
|
||||
entry = LANGUAGES.get(language.lower())
|
||||
iso_language = entry.iso if entry else language
|
||||
|
||||
own = client is None
|
||||
client = client or httpx.Client(timeout=600)
|
||||
try:
|
||||
with audio_path.open("rb") as f:
|
||||
if cfg.whisper_api == "asr-webservice":
|
||||
# onerahmet/openai-whisper-asr-webservice: fixed /asr endpoint,
|
||||
# file field is "audio_file", and language/output are query
|
||||
# params rather than form fields - there is no "model" param,
|
||||
# the model is fixed by how that server was started.
|
||||
params = {"output": "json"}
|
||||
if iso_language:
|
||||
params["language"] = iso_language
|
||||
resp = client.post(
|
||||
f"{cfg.whisper_url.rstrip('/')}/asr",
|
||||
headers=headers, params=params,
|
||||
files={"audio_file": (audio_path.name, f, "audio/wav")})
|
||||
else:
|
||||
data = {"model": cfg.whisper_model, "response_format": "verbose_json"}
|
||||
if iso_language:
|
||||
data["language"] = iso_language
|
||||
resp = client.post(
|
||||
f"{cfg.whisper_url.rstrip('/')}/audio/transcriptions",
|
||||
headers=headers, data=data,
|
||||
files={"file": (audio_path.name, f, "audio/wav")})
|
||||
resp.raise_for_status()
|
||||
return [TranscriptSegment(s["start"], s["end"], s["text"].strip())
|
||||
for s in resp.json().get("segments", [])]
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import Ctx, SongInput
|
||||
from karaoke_maker.stages import acquire
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import Ctx, SongInput
|
||||
from songtrace.stages import acquire
|
||||
|
||||
def make_ctx(tmp_path, **kw):
|
||||
inp = SongInput(title="T", artist="A", lyrics_text="x", **kw)
|
||||
@@ -46,7 +46,7 @@ def test_outputs_are_meta_and_audio(tmp_path):
|
||||
|
||||
def test_requires_exactly_one_source(tmp_path):
|
||||
import pytest
|
||||
from karaoke_maker.pipeline import PipelineError
|
||||
from songtrace.pipeline import PipelineError
|
||||
ctx = make_ctx(tmp_path)
|
||||
with pytest.raises(PipelineError):
|
||||
acquire.acquire_stage().run(ctx)
|
||||
@@ -73,7 +73,7 @@ def test_classify_no_thumbnail(tmp_path):
|
||||
|
||||
def test_classify_no_video_raises(tmp_path):
|
||||
import pytest
|
||||
from karaoke_maker.pipeline import PipelineError
|
||||
from songtrace.pipeline import PipelineError
|
||||
(tmp_path / "source_video.jpg").write_bytes(b"t")
|
||||
with pytest.raises(PipelineError):
|
||||
acquire._classify_downloads(tmp_path)
|
||||
|
||||
+27
-4
@@ -2,10 +2,10 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.lyrics import normalize_lyrics
|
||||
from karaoke_maker.pipeline import Ctx, PipelineError, SongInput
|
||||
from karaoke_maker.stages import align
|
||||
from songtrace.config import Config
|
||||
from songtrace.lyrics import normalize_lyrics
|
||||
from songtrace.pipeline import Ctx, PipelineError, SongInput
|
||||
from songtrace.stages import align
|
||||
|
||||
LYRICS = "it's one two three\nfour five six seven eight"
|
||||
|
||||
@@ -137,3 +137,26 @@ def test_words_split_across_sections(tmp_path, monkeypatch):
|
||||
check_invariants(rows, LYRICS)
|
||||
assert rows[3]["end"] <= 5.0
|
||||
assert rows[4]["start"] >= 29.0
|
||||
|
||||
|
||||
def test_align_drops_lines_never_sung(tmp_path, monkeypatch):
|
||||
lyrics = "it's one two three\nfour five six seven eight\nnine ten eleven twelve"
|
||||
transcript = [{"start": 1.0, "end": 4.0,
|
||||
"text": "it's one two three four five six seven eight"}]
|
||||
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
|
||||
assert {r["line_index"] for r in rows} == {0, 1}
|
||||
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
|
||||
assert meta["dropped_lines"] == [2]
|
||||
assert meta["missing_spans"] == []
|
||||
|
||||
|
||||
def test_align_flags_missing_span_for_unmatched_singing(tmp_path, monkeypatch):
|
||||
lyrics = "it's one two three\nfour five six seven eight"
|
||||
transcript = [
|
||||
{"start": 1.0, "end": 4.0, "text": "it's one two three four five six seven eight"},
|
||||
{"start": 20.0, "end": 23.0, "text": "completely unrelated ad lib bridge"},
|
||||
]
|
||||
run_align(tmp_path, monkeypatch, lyrics, transcript)
|
||||
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
|
||||
assert meta["dropped_lines"] == []
|
||||
assert meta["missing_spans"] == [[19.0, 24.0]]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from songtrace.audio_io import load_wav
|
||||
|
||||
|
||||
def test_load_wav_mono(tmp_path):
|
||||
path = tmp_path / "mono.wav"
|
||||
data = np.linspace(-0.5, 0.5, 1600, dtype=np.float32)
|
||||
sf.write(path, data, 16000)
|
||||
waveform, sr = load_wav(path)
|
||||
assert sr == 16000
|
||||
assert waveform.shape == (1, 1600)
|
||||
assert np.allclose(waveform[0].numpy(), data, atol=1e-4)
|
||||
|
||||
|
||||
def test_load_wav_stereo(tmp_path):
|
||||
path = tmp_path / "stereo.wav"
|
||||
left = np.linspace(-0.5, 0.5, 800, dtype=np.float32)
|
||||
right = np.linspace(0.5, -0.5, 800, dtype=np.float32)
|
||||
sf.write(path, np.stack([left, right], axis=1), 16000)
|
||||
waveform, sr = load_wav(path)
|
||||
assert sr == 16000
|
||||
assert waveform.shape == (2, 800)
|
||||
assert np.allclose(waveform[0].numpy(), left, atol=1e-4)
|
||||
assert np.allclose(waveform[1].numpy(), right, atol=1e-4)
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
from karaoke_maker.cli import main
|
||||
from songtrace.cli import main
|
||||
|
||||
def test_version(capsys):
|
||||
assert main(["--version"]) == 0
|
||||
assert "karaoke-maker" in capsys.readouterr().out
|
||||
assert "songtrace" in capsys.readouterr().out
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
import karaoke_maker.cli as cli
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import PipelineError, PipelineResult
|
||||
import songtrace.cli as cli
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import PipelineError, PipelineResult
|
||||
|
||||
|
||||
def setup_cfg(monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from karaoke_maker.config import Config, load_config
|
||||
from songtrace.config import Config, load_config
|
||||
|
||||
|
||||
def test_load_minimal(tmp_path):
|
||||
@@ -24,7 +24,7 @@ def test_paths_and_overrides(tmp_path):
|
||||
def test_env_var(tmp_path, monkeypatch):
|
||||
cfg_file = tmp_path / "c.toml"
|
||||
cfg_file.write_text('whisper_url = "http://env/v1"\n')
|
||||
monkeypatch.setenv("KARAOKE_MAKER_CONFIG", str(cfg_file))
|
||||
monkeypatch.setenv("SONGTRACE_CONFIG", str(cfg_file))
|
||||
assert load_config().whisper_url == "http://env/v1"
|
||||
|
||||
|
||||
|
||||
+9
-9
@@ -4,27 +4,27 @@ import shutil
|
||||
import pytest
|
||||
|
||||
requires_full = pytest.mark.skipif(
|
||||
not os.environ.get("KARAOKE_E2E"),
|
||||
reason="set KARAOKE_E2E=1 (needs GPU, ffmpeg, whisper server, network)")
|
||||
not os.environ.get("SONGTRACE_E2E"),
|
||||
reason="set SONGTRACE_E2E=1 (needs GPU, ffmpeg, whisper server, network)")
|
||||
|
||||
@requires_full
|
||||
def test_full_pipeline(tmp_path):
|
||||
from karaoke_maker.config import load_config
|
||||
from karaoke_maker.pipeline import SongInput, run_pipeline
|
||||
from songtrace.config import load_config
|
||||
from songtrace.pipeline import SongInput, run_pipeline
|
||||
assert shutil.which("ffmpeg"), "ffmpeg required"
|
||||
cfg = load_config()
|
||||
cfg.output_dir = tmp_path / "out"
|
||||
cfg.work_dir = tmp_path / "work"
|
||||
inp = SongInput(
|
||||
title=os.environ["KARAOKE_E2E_TITLE"],
|
||||
artist=os.environ["KARAOKE_E2E_ARTIST"],
|
||||
lyrics_text=open(os.environ["KARAOKE_E2E_LYRICS"]).read(),
|
||||
youtube_url=os.environ.get("KARAOKE_E2E_URL"),
|
||||
title=os.environ["SONGTRACE_E2E_TITLE"],
|
||||
artist=os.environ["SONGTRACE_E2E_ARTIST"],
|
||||
lyrics_text=open(os.environ["SONGTRACE_E2E_LYRICS"]).read(),
|
||||
youtube_url=os.environ.get("SONGTRACE_E2E_URL"),
|
||||
)
|
||||
result = run_pipeline(inp, cfg)
|
||||
txts = list(result.output_dir.glob("*.txt"))
|
||||
assert len(txts) == 1
|
||||
from karaoke_maker.ultrastar import get_header, parse_ultrastar
|
||||
from songtrace.ultrastar import get_header, parse_ultrastar
|
||||
song = parse_ultrastar(txts[0].read_text(encoding="utf-8"))
|
||||
assert get_header(song, "BPM") and get_header(song, "GAP")
|
||||
assert len(song.body) > 20
|
||||
|
||||
+5
-5
@@ -2,11 +2,11 @@ import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.grid import scale_bpm, to_ultrastar
|
||||
from karaoke_maker.pipeline import Ctx, PipelineError, SongInput
|
||||
from karaoke_maker.stages import all_stages, grid as grid_stage_mod
|
||||
from karaoke_maker.ultrastar import (
|
||||
from songtrace.config import Config
|
||||
from songtrace.grid import scale_bpm, to_ultrastar
|
||||
from songtrace.pipeline import Ctx, PipelineError, SongInput
|
||||
from songtrace.stages import all_stages, grid as grid_stage_mod
|
||||
from songtrace.ultrastar import (
|
||||
LineBreak, Note, get_header, parse_ultrastar,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from karaoke_maker.lyrics import normalize_lyrics, alignment_token
|
||||
from songtrace.lyrics import normalize_lyrics, alignment_token
|
||||
|
||||
RAW = """[Verse 1]
|
||||
It's one two three
|
||||
|
||||
+56
-4
@@ -2,13 +2,13 @@ import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.notes import (
|
||||
from songtrace.config import Config
|
||||
from songtrace.notes import (
|
||||
MIN_NOTE_SEC, TimedNote, build_notes, hz_to_ultrastar, segment_span,
|
||||
syllabify,
|
||||
)
|
||||
from karaoke_maker.pipeline import Ctx, SongInput
|
||||
from karaoke_maker.stages import all_stages, notes as notes_stage_mod
|
||||
from songtrace.pipeline import Ctx, SongInput
|
||||
from songtrace.stages import all_stages, notes as notes_stage_mod
|
||||
|
||||
|
||||
def flat_pitch(duration=10.0, hz=261.63): # C4
|
||||
@@ -295,3 +295,55 @@ def test_notes_stage_writes_json(tmp_path):
|
||||
|
||||
def test_notes_stage_registered():
|
||||
assert "notes" in [s.name for s in all_stages()]
|
||||
|
||||
|
||||
def test_build_notes_skips_notes_within_missing_span():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "skip", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
|
||||
]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English",
|
||||
missing_spans=[(4.5, 6.0)])
|
||||
assert [n.text for n in notes] == ["keep"]
|
||||
|
||||
|
||||
def test_warnings_report_dropped_lines():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||
ref_lines = [["keep"], ["the", "outro"]]
|
||||
_, _, warnings = build_notes(rows, times, f0, conf, "English",
|
||||
dropped_lines=[1], ref_lines=ref_lines)
|
||||
assert warnings == ['1 line dropped (not sung) near "the outro"']
|
||||
|
||||
|
||||
def test_warnings_report_missing_spans():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||
_, _, warnings = build_notes(rows, times, f0, conf, "English",
|
||||
missing_spans=[(81.0, 89.5)])
|
||||
assert warnings == ["missing lyrics near 1:21–1:29"]
|
||||
|
||||
|
||||
def test_build_notes_skipped_row_mid_line_does_not_corrupt_spacing_or_breaks():
|
||||
# "gone" falls inside a missing span and is skipped; "away" is the same
|
||||
# line's next word and must still get its leading space, and the break
|
||||
# before the next line must use the last *emitted* word's end (3.4, from
|
||||
# "away"), not the skipped row's end (2.4).
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "gone", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "away", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "next", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
|
||||
]
|
||||
notes, breaks, _ = build_notes(rows, times, f0, conf, "English",
|
||||
missing_spans=[(1.9, 2.5)])
|
||||
assert [n.text for n in notes] == ["keep", " away", "next"]
|
||||
assert breaks == [(3.4 + 5.0) / 2]
|
||||
|
||||
def test_notes_carry_line_index():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[0].line_index == 3
|
||||
|
||||
@@ -4,12 +4,12 @@ from pathlib import Path
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import (
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import (
|
||||
STAGE_ORDER, Ctx, PipelineError, SongInput, run_pipeline, song_slug,
|
||||
)
|
||||
from karaoke_maker.stages import all_stages, package
|
||||
from karaoke_maker.ultrastar import get_header, parse_ultrastar
|
||||
from songtrace.stages import all_stages, package
|
||||
from songtrace.ultrastar import get_header, parse_ultrastar
|
||||
|
||||
SONG_TXT = ("#TITLE:T\n#ARTIST:A\n#MP3:A - T.mp3\n#COVER:A - T.jpg\n"
|
||||
"#VIDEO:A - T.webm\n#BPM:200\n#GAP:0\n: 0 1 0 la\nE\n")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import SongInput, Stage, run_pipeline, song_slug
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import SongInput, Stage, run_pipeline, song_slug
|
||||
|
||||
def make_cfg(tmp_path):
|
||||
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import numpy as np
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import Ctx, SongInput
|
||||
from karaoke_maker.stages import pitch
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import Ctx, SongInput
|
||||
from songtrace.stages import pitch
|
||||
|
||||
|
||||
def test_pitch_stage_writes_npz(tmp_path, monkeypatch):
|
||||
|
||||
@@ -3,9 +3,9 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, SongInput
|
||||
from karaoke_maker.rebuild import (FIELD_STAGE, changed_fields, plan,
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import STAGE_ORDER, PipelineError, SongInput
|
||||
from songtrace.rebuild import (FIELD_STAGE, changed_fields, plan,
|
||||
relocate_work, superseded_output)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import Ctx, SongInput
|
||||
from karaoke_maker.stages import separate
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import Ctx, SongInput
|
||||
from songtrace.stages import separate
|
||||
|
||||
|
||||
def test_separate_writes_stems(tmp_path, monkeypatch):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from karaoke_maker.lyrics import normalize_lyrics
|
||||
from karaoke_maker.whisper_client import TranscriptSegment
|
||||
from karaoke_maker.stages.transcribe import lyrics_similarity, vocal_sections
|
||||
from songtrace.lyrics import normalize_lyrics
|
||||
from songtrace.whisper_client import TranscriptSegment
|
||||
from songtrace.stages.transcribe import classify_lyrics_match, lyrics_similarity, vocal_sections
|
||||
|
||||
def seg(s, e, t=""):
|
||||
return TranscriptSegment(start=s, end=e, text=t)
|
||||
@@ -26,3 +26,41 @@ def test_similarity_low_for_wrong_lyrics():
|
||||
ref = normalize_lyrics("completely different words here entirely")
|
||||
segs = [seg(0, 2, "it's one two three four five six seven eight")]
|
||||
assert lyrics_similarity(segs, ref) < 0.4
|
||||
|
||||
def test_classify_lyrics_match_flags_extra_reference_line():
|
||||
ref = normalize_lyrics("it's one two three\nfour five six seven eight\nnine ten eleven")
|
||||
segs = [seg(1, 4, "it's one two three four five six seven eight")]
|
||||
dropped, missing = classify_lyrics_match(segs, ref)
|
||||
assert dropped == [2]
|
||||
assert missing == []
|
||||
|
||||
def test_classify_lyrics_match_flags_unmatched_singing():
|
||||
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
|
||||
segs = [seg(1, 4, "it's one two three four five six seven eight"),
|
||||
seg(20, 23, "completely unrelated ad lib bridge")]
|
||||
dropped, missing = classify_lyrics_match(segs, ref)
|
||||
assert dropped == []
|
||||
assert missing == [(19.0, 24.0)]
|
||||
|
||||
def test_classify_lyrics_match_keeps_partially_matched_lines():
|
||||
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
|
||||
# line 1 heard with one word garbled ("five" -> "fife"): still a replace/equal
|
||||
# match, so the line stays matched rather than dropped
|
||||
segs = [seg(1, 4, "it's one two three four fife six seven eight")]
|
||||
dropped, _ = classify_lyrics_match(segs, ref)
|
||||
assert dropped == []
|
||||
|
||||
def test_classify_lyrics_match_scopes_missing_span_to_adlib_not_whole_section():
|
||||
# matched line and an unmatched ad-lib close enough together that
|
||||
# vocal_sections would merge them into one section; missing_spans must
|
||||
# stay tight around the ad-lib so the matched line keeps its notes.
|
||||
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
|
||||
segs = [seg(1, 4, "it's one two three four five six seven eight"),
|
||||
seg(6, 8, "completely unrelated ad lib")]
|
||||
sections = vocal_sections(segs)
|
||||
assert sections == [(0.0, 9.0)] # confirms the two segments do merge
|
||||
dropped, missing = classify_lyrics_match(segs, ref)
|
||||
assert dropped == []
|
||||
assert missing == [(5.0, 9.0)]
|
||||
for a, b in missing:
|
||||
assert not (a <= 1.0 < b) # matched line's start (1.0) untouched
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from karaoke_maker.ultrastar import (
|
||||
from songtrace.ultrastar import (
|
||||
Note, LineBreak, parse_ultrastar, serialize_ultrastar,
|
||||
get_header, format_bpm, parse_bpm,
|
||||
)
|
||||
|
||||
+193
-11
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -6,9 +7,9 @@ import time
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, PipelineResult, song_slug
|
||||
from karaoke_maker.web import STAGE_LINE, create_app
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import STAGE_ORDER, PipelineError, PipelineResult, song_slug
|
||||
from songtrace.web import STAGE_LINE, create_app
|
||||
|
||||
|
||||
def make_client(tmp_path, runner):
|
||||
@@ -190,7 +191,7 @@ def test_cover_upload_kept_after_success(tmp_path):
|
||||
|
||||
def test_cleanup_uploads_removes_an_inherited_upload_dir(tmp_path):
|
||||
"""A rebuild's video may live under its parent's id, not its own."""
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
from songtrace.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
parent = cfg.work_dir / "uploads" / "parent"
|
||||
@@ -201,7 +202,7 @@ def test_cleanup_uploads_removes_an_inherited_upload_dir(tmp_path):
|
||||
|
||||
|
||||
def test_cleanup_uploads_ignores_paths_outside_the_upload_root(tmp_path):
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
from songtrace.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
outside = tmp_path / "somewhere" / "keep.mp4"
|
||||
@@ -212,7 +213,7 @@ def test_cleanup_uploads_ignores_paths_outside_the_upload_root(tmp_path):
|
||||
|
||||
|
||||
def test_cleanup_uploads_tolerates_a_missing_file(tmp_path):
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
from songtrace.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
_cleanup_uploads(cfg, "child", cfg.work_dir / "uploads" / "child" / "gone.mp4")
|
||||
@@ -340,13 +341,13 @@ def test_fonts_are_served(tmp_path):
|
||||
|
||||
|
||||
def test_font_licence_is_bundled():
|
||||
from karaoke_maker.web import STATIC
|
||||
from songtrace.web import STATIC
|
||||
licence = (STATIC / "fonts" / "OFL.txt").read_text(encoding="utf-8", errors="replace")
|
||||
assert "SIL OPEN FONT LICENSE" in licence.upper()
|
||||
|
||||
|
||||
def _index_html():
|
||||
from karaoke_maker.web import STATIC
|
||||
from songtrace.web import STATIC
|
||||
return (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -359,6 +360,32 @@ def test_index_has_required_hooks():
|
||||
assert f'name="{name}"' in html, name
|
||||
|
||||
|
||||
def test_video_source_fields_are_outside_advanced_details():
|
||||
html = _index_html()
|
||||
form_start = html.index('id="song-form"')
|
||||
details_start = html.index("<details>", form_start)
|
||||
before = html[form_start:details_start]
|
||||
assert 'name="youtube_url"' in before
|
||||
assert 'name="video"' in before
|
||||
after = html[details_start:]
|
||||
for name in ("genre", "year", "language", "bpm", "cover"):
|
||||
assert f'name="{name}"' in after
|
||||
|
||||
|
||||
def test_song_form_lyrics_uses_a_two_column_grid():
|
||||
css = _page_css(_index_html())
|
||||
assert re.search(r"\.song-form-grid\s*\{[^}]*grid-template-columns:\s*1fr 1fr",
|
||||
css, re.S)
|
||||
|
||||
|
||||
def test_song_form_grid_stacks_on_narrow_screens():
|
||||
css = _page_css(_index_html())
|
||||
assert re.search(
|
||||
r"@media \(max-width: 720px\)\s*\{[^}]*\.song-form-grid\s*\{[^}]*"
|
||||
r"grid-template-columns:\s*1fr\s*;",
|
||||
css, re.S)
|
||||
|
||||
|
||||
def test_index_makes_no_external_requests():
|
||||
import re as _re
|
||||
html = _index_html()
|
||||
@@ -383,10 +410,53 @@ def test_index_respects_reduced_motion():
|
||||
assert "prefers-reduced-motion" in _index_html()
|
||||
|
||||
|
||||
def test_index_parses_warnings_with_endash():
|
||||
def test_index_has_lyrics_review_hook():
|
||||
html = _index_html()
|
||||
assert "–" in html, "warning parser must handle the en-dash separator"
|
||||
assert "data-start" in html and "data-end" in html
|
||||
assert 'id="lyrics-review"' in html
|
||||
assert 'id="warnings"' not in html
|
||||
|
||||
|
||||
def test_index_review_uses_svg_not_canvas():
|
||||
html = _index_html()
|
||||
assert "createElementNS" in html
|
||||
assert "<canvas" not in html
|
||||
|
||||
|
||||
def test_index_review_fetches_review_endpoint():
|
||||
html = _index_html()
|
||||
assert "/review" in html
|
||||
|
||||
|
||||
def test_index_lyrics_review_uses_endash_for_time_ranges():
|
||||
html = _index_html()
|
||||
body = _fn_body(html, "missingSpanNode")
|
||||
assert "–" in body
|
||||
|
||||
|
||||
def test_index_review_lays_out_words_in_time():
|
||||
"""Words must be positioned by their sung timing (percentage-based), not
|
||||
packed as plain flow text, so notes can line up under their own word."""
|
||||
html = _index_html()
|
||||
body = _fn_body(html, "lineTimeline")
|
||||
assert "review-word" in body
|
||||
assert "style.left" in body
|
||||
|
||||
|
||||
def test_lyrics_review_cache_clears_on_job_status_change():
|
||||
"""Regression: switching between done/failed/running jobs must clear the
|
||||
cache so a previously-viewed done job re-fetches when re-selected."""
|
||||
html = _index_html()
|
||||
body = _fn_body(html, "renderLyricsReview")
|
||||
# The "failed" branch must clear the cache before rendering the placeholder
|
||||
assert re.search(
|
||||
r'if\s*\(\s*job\.status\s*===\s*"failed"\s*\)\s*\{[^}]*'
|
||||
r'delete\s+box\.dataset\.jobId',
|
||||
body, re.S), "failed branch must delete cache key"
|
||||
# The "not done" (queued/running) branch must clear the cache before rendering
|
||||
assert re.search(
|
||||
r'if\s*\(\s*job\.status\s*!==\s*"done"\s*\)\s*\{[^}]*'
|
||||
r'delete\s+box\.dataset\.jobId',
|
||||
body, re.S), "not done branch must delete cache key"
|
||||
|
||||
|
||||
def test_index_has_copy_path_control():
|
||||
@@ -958,3 +1028,115 @@ def test_a_plain_job_never_removes_anything(tmp_path):
|
||||
stray.mkdir(parents=True)
|
||||
make_done_job(client, title="T2")
|
||||
assert stray.exists()
|
||||
|
||||
|
||||
def review_runner_factory(notes, dropped_lines=None, missing_spans=None, warnings=None,
|
||||
words=None):
|
||||
def runner(inp, cfg, log=print, force_from=None, stages=None):
|
||||
work = cfg.work_dir / song_slug(inp)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
(work / "notes_sec.json").write_text(json.dumps({
|
||||
"notes": notes, "breaks": [], "warnings": warnings or []}))
|
||||
if dropped_lines is not None or missing_spans is not None:
|
||||
(work / "align_meta.json").write_text(json.dumps({
|
||||
"dropped_lines": dropped_lines or [],
|
||||
"missing_spans": missing_spans or []}))
|
||||
if words is not None:
|
||||
(work / "alignment.json").write_text(json.dumps(words))
|
||||
out = cfg.output_dir / song_slug(inp)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
return PipelineResult(output_dir=out, warnings=warnings or [])
|
||||
return runner
|
||||
|
||||
|
||||
def test_review_endpoint_groups_notes_by_line(tmp_path):
|
||||
notes = [
|
||||
{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
|
||||
"low_confidence": False, "line_index": 0},
|
||||
{"start": 5.0, "duration": 0.3, "pitch": 2, "text": "there",
|
||||
"low_confidence": True, "line_index": 1},
|
||||
]
|
||||
client, _ = make_client(tmp_path, review_runner_factory(
|
||||
notes, dropped_lines=[2], missing_spans=[[8.0, 9.5]]))
|
||||
resp = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "hi\nthere\ngone",
|
||||
"youtube_url": "http://y"})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "done")
|
||||
body = client.get(f"/api/jobs/{job_id}/review").json()
|
||||
assert body["lines"] == [
|
||||
{"index": 0, "text": "hi", "dropped": False,
|
||||
"notes": [{"start": 1.0, "end": 1.4, "pitch": 0, "low_confidence": False}],
|
||||
"words": []},
|
||||
{"index": 1, "text": "there", "dropped": False,
|
||||
"notes": [{"start": 5.0, "end": 5.3, "pitch": 2, "low_confidence": True}],
|
||||
"words": []},
|
||||
{"index": 2, "text": "gone", "dropped": True, "notes": [], "words": []},
|
||||
]
|
||||
assert body["missing_spans"] == [{"start": 8.0, "end": 9.5}]
|
||||
|
||||
|
||||
def test_review_endpoint_includes_per_word_timing(tmp_path):
|
||||
notes = [
|
||||
{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
|
||||
"low_confidence": False, "line_index": 0},
|
||||
{"start": 5.0, "duration": 0.3, "pitch": 2, "text": "there",
|
||||
"low_confidence": True, "line_index": 1},
|
||||
]
|
||||
words = [
|
||||
{"word": "hi", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "there", "start": 5.0, "end": 5.3, "score": 0.9, "line_index": 1},
|
||||
]
|
||||
client, _ = make_client(tmp_path, review_runner_factory(
|
||||
notes, dropped_lines=[2], missing_spans=[[8.0, 9.5]], words=words))
|
||||
resp = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "hi\nthere\ngone",
|
||||
"youtube_url": "http://y"})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "done")
|
||||
body = client.get(f"/api/jobs/{job_id}/review").json()
|
||||
assert body["lines"][0]["words"] == [{"text": "hi", "start": 1.0, "end": 1.4}]
|
||||
assert body["lines"][1]["words"] == [{"text": "there", "start": 5.0, "end": 5.3}]
|
||||
assert body["lines"][2]["dropped"] is True
|
||||
assert body["lines"][2]["words"] == []
|
||||
|
||||
|
||||
def test_review_of_a_failed_job_is_404(tmp_path):
|
||||
def bad_runner(*a, **kw):
|
||||
raise PipelineError("boom")
|
||||
client, _ = make_client(tmp_path, bad_runner)
|
||||
resp = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "failed")
|
||||
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404
|
||||
|
||||
|
||||
def test_review_of_a_running_job_is_409(tmp_path):
|
||||
release = threading.Event()
|
||||
|
||||
def blocking(inp, cfg, log=print, force_from=None, stages=None):
|
||||
release.wait(5)
|
||||
return PipelineResult(output_dir=cfg.output_dir / "X")
|
||||
|
||||
client, _ = make_client(tmp_path, blocking)
|
||||
resp = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
|
||||
job_id = resp.json()["id"]
|
||||
try:
|
||||
wait_status(client, job_id, "running")
|
||||
assert client.get(f"/api/jobs/{job_id}/review").status_code == 409
|
||||
finally:
|
||||
release.set()
|
||||
wait_status(client, job_id, "done")
|
||||
|
||||
|
||||
def test_review_of_an_unknown_job_is_404(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
assert client.get("/api/jobs/nope/review").status_code == 404
|
||||
|
||||
|
||||
def test_review_missing_when_pipeline_never_wrote_notes(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
job_id = make_done_job(client)
|
||||
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404
|
||||
|
||||
+24
-2
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.whisper_client import TranscriptSegment, transcribe
|
||||
from songtrace.config import Config
|
||||
from songtrace.whisper_client import TranscriptSegment, transcribe
|
||||
|
||||
def test_transcribe_parses_segments(tmp_path):
|
||||
audio = tmp_path / "v.wav"
|
||||
@@ -36,3 +36,25 @@ def test_transcribe_maps_language_to_iso_code(tmp_path):
|
||||
transcribe(cfg, audio, language="Japanese", client=client)
|
||||
assert 'name="language"' in seen["body"]
|
||||
assert "\r\n\r\nja\r\n" in seen["body"]
|
||||
|
||||
|
||||
def test_transcribe_asr_webservice_hits_fixed_asr_endpoint(tmp_path):
|
||||
audio = tmp_path / "v.wav"
|
||||
audio.write_bytes(b"RIFF")
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["body"] = request.read().decode("utf-8", errors="replace")
|
||||
return httpx.Response(200, json={
|
||||
"text": "hello world",
|
||||
"segments": [{"start": 0.5, "end": 2.0, "text": " hello world"}],
|
||||
})
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
cfg = Config(whisper_url="http://w:9000", whisper_api="asr-webservice")
|
||||
segs = transcribe(cfg, audio, language="Japanese", client=client)
|
||||
assert segs == [TranscriptSegment(start=0.5, end=2.0, text="hello world")]
|
||||
assert seen["url"] == "http://w:9000/asr?output=json&language=ja"
|
||||
assert 'name="audio_file"' in seen["body"]
|
||||
assert 'name="model"' not in seen["body"]
|
||||
|
||||
Reference in New Issue
Block a user