Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5806f832e | ||
|
|
b111dbeb20 | ||
|
|
fb44d0151e | ||
|
|
dd8934a5cf | ||
|
|
25a8a228fe | ||
|
|
34f878713a | ||
|
|
a87d8e182d | ||
|
|
db528fbe8d | ||
|
|
d46c87b3f1 | ||
|
|
e6db616290 | ||
|
|
619050e331 | ||
|
|
8907844958 | ||
|
|
91ad0fbd3a | ||
|
|
3a57b589f7 | ||
|
|
1345d0121e | ||
|
|
b506de6147 | ||
|
|
b3b9b9ee4b | ||
|
|
c21a09ed93 | ||
|
|
d357fc5b93 | ||
|
|
be7590efd1 | ||
|
|
bed7a135e9 | ||
|
|
781f7a326e | ||
|
|
7eee4593c5 | ||
|
|
619804348e | ||
|
|
ae0be499ae | ||
|
|
09624efb61 | ||
|
|
f40df98973 | ||
|
|
7d7055bad4 | ||
|
|
d35c18a2cc | ||
|
|
200c9347d5 | ||
|
|
1633bc9645 | ||
|
|
0687c47c59 | ||
|
|
f6a0aef045 | ||
|
|
a5ee6837f4 | ||
|
|
e0c7eb9464 | ||
|
|
be4883660a | ||
|
|
e6fa6afe87 | ||
|
|
c02c307377 | ||
|
|
a38461deec | ||
|
|
eed2d51128 | ||
|
|
735513d048 | ||
|
|
128d4670ef | ||
|
|
1ce3f85425 | ||
|
|
bf141fbdf4 | ||
|
|
944aabd50c | ||
|
|
34219f9167 | ||
|
|
c0ab6f0da5 | ||
|
|
e033330834 | ||
|
|
d8064b2665 | ||
|
|
24b202ebe6 | ||
|
|
eadb225fef | ||
|
|
eed25da2c3 | ||
|
|
dfd57a69cb | ||
|
|
b1ab765121 | ||
|
|
28d07cc520 | ||
|
|
395f92e1db | ||
|
|
dba985d539 | ||
|
|
d593720ca4 | ||
|
|
f867a7b4d0 | ||
|
|
519bc4b2db | ||
|
|
819e09a733 | ||
|
|
ae4e2e0c87 | ||
|
|
062db175e6 | ||
|
|
abeb524b35 | ||
|
|
90a78464c5 | ||
|
|
b534f24bb5 | ||
|
|
22d3594d0f | ||
|
|
c1a010785b | ||
|
|
ef41c0ef29 | ||
|
|
19037477e0 | ||
|
|
e7738be810 | ||
|
|
222c5c3ff3 | ||
|
|
b21536c4e2 | ||
|
|
f3253bd921 | ||
|
|
3d7e7e3f12 | ||
|
|
45f6887f93 | ||
|
|
699ff3314c | ||
|
|
312c9d794a | ||
|
|
504bc34889 | ||
|
|
19a4f6e1ee | ||
|
|
5a9e342a17 | ||
|
|
419704d534 | ||
|
|
c00c859fd4 | ||
|
|
3fc665f3f5 | ||
|
|
67f26f047e |
+19
@@ -0,0 +1,19 @@
|
||||
# torch pip wheels bundle their own CUDA runtime libs; only the host NVIDIA
|
||||
# driver is needed (exposed via the nvidia container runtime), so a plain
|
||||
# Python base is sufficient — no CUDA base image required.
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
ENV SONGTRACE_CONFIG=/config/config.toml \
|
||||
TORCH_HOME=/data/models \
|
||||
XDG_CACHE_HOME=/data/models/cache
|
||||
|
||||
EXPOSE 8000
|
||||
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/>.
|
||||
@@ -0,0 +1,175 @@
|
||||
# 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. 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 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
|
||||
6. **notes** — turn word timings + pitch into UltraStar notes
|
||||
7. **grid** — quantise notes to a beat grid
|
||||
8. **package** — write the final song folder (`.txt` + `.mp3` + video + cover)
|
||||
|
||||
The shipped `.mp3` is the full original mix (vocals included) — the
|
||||
separated stems are used for analysis only and are not included in the
|
||||
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](https://www.yass-along.com/) or
|
||||
[USDX](https://usdx.eu/) and touch those spots up before playing.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is TOML. Resolution order:
|
||||
|
||||
1. `--config <path>` passed explicitly on the command line
|
||||
2. the `SONGTRACE_CONFIG` environment variable
|
||||
3. `~/.config/songtrace/config.toml`
|
||||
|
||||
Copy the example and fill in your Whisper server:
|
||||
|
||||
```sh
|
||||
cp config.example.toml config.toml
|
||||
$EDITOR config.toml # set whisper_url at minimum
|
||||
```
|
||||
|
||||
See `config.example.toml` for every available field (`whisper_url`,
|
||||
`whisper_api_key`, `whisper_model`, `whisper_api`, `output_dir`, `work_dir`,
|
||||
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`,
|
||||
`ytdlp_clients`, `ytdlp_cookies`, `ytdlp_impersonate`,
|
||||
`ytdlp_pot_provider_url`).
|
||||
|
||||
`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
|
||||
|
||||
```sh
|
||||
cp config.example.toml config.toml
|
||||
$EDITOR config.toml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The web UI is then at `http://localhost:8000`. Downloaded model weights
|
||||
(Demucs, wav2vec2, ...) are cached under `./data` on the host so they survive
|
||||
container recreation.
|
||||
|
||||
Run the CLI inside the container instead of the web UI:
|
||||
|
||||
```sh
|
||||
docker compose run --rm songtrace songtrace make \
|
||||
--title "Song Title" --artist "Artist Name" \
|
||||
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
|
||||
```
|
||||
|
||||
GPU passthrough requires the [NVIDIA Container
|
||||
Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/)
|
||||
installed on the host; `docker-compose.yml` requests one GPU via the
|
||||
`nvidia` device driver.
|
||||
|
||||
## Bare-metal
|
||||
|
||||
```sh
|
||||
pip install -e .
|
||||
songtrace make --help
|
||||
```
|
||||
|
||||
```
|
||||
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 `songtrace serve [--config CONFIG]`.
|
||||
|
||||
## Re-running a stage
|
||||
|
||||
Each stage caches its output under `work_dir`. If a later stage needs
|
||||
another pass (e.g. you want to retry alignment with different lyrics, or
|
||||
pitch tracking looks off), re-run with `--force-from <stage>` to redo that
|
||||
stage and everything after it, reusing the cached output of everything
|
||||
before it:
|
||||
|
||||
```sh
|
||||
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`.
|
||||
|
||||
## YouTube download failures
|
||||
|
||||
`HTTP Error 403: Forbidden` from the acquire stage means YouTube served the
|
||||
page but refused the media URL. In rough order of likelihood:
|
||||
|
||||
1. **yt-dlp is stale.** YouTube changes its defences every few weeks and the
|
||||
version baked into the image does not. Hotfix a running container, then
|
||||
rebuild so it sticks:
|
||||
|
||||
```sh
|
||||
docker compose exec songtrace pip install -U 'yt-dlp[default,curl-cffi,deno]'
|
||||
docker compose restart songtrace
|
||||
docker compose build --pull songtrace # permanent
|
||||
```
|
||||
|
||||
2. **Missing JS runtime.** yt-dlp needs `yt-dlp-ejs` plus a JavaScript engine
|
||||
to solve YouTube's player challenges; without them it silently falls back to
|
||||
formats that 403. The `[default,curl-cffi,deno]` extras in `pyproject.toml`
|
||||
cover this. Check with
|
||||
`docker compose exec songtrace yt-dlp -v --simulate <url>` — the debug
|
||||
header must show a `JS runtimes:` line that is not `none`.
|
||||
|
||||
3. **The IP is rate-limited or flagged.** Datacenter and VPN ranges get
|
||||
throttled hardest. Try from another network before changing config.
|
||||
|
||||
4. **A PO token is required.** If the log says so explicitly, run
|
||||
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
|
||||
as a sidecar, install its plugin into the image, and point
|
||||
`ytdlp_pot_provider_url` at it.
|
||||
|
||||
`ytdlp_clients` retries the download against each listed player client in
|
||||
turn, so a client that YouTube has broken is skipped automatically.
|
||||
`ytdlp_cookies` (a Netscape cookie jar, for age-gated videos) and
|
||||
`ytdlp_impersonate` (a browser TLS fingerprint, e.g. `"chrome"`) are the other
|
||||
two levers. Do not use a cookie jar from an account you care about — YouTube
|
||||
bans for automated access.
|
||||
|
||||
Failed downloads leave the work dir intact, so re-running the job resumes
|
||||
rather than starting over.
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,20 @@
|
||||
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"
|
||||
# demucs_model = "htdemucs_ft"
|
||||
# crepe_model = "full"
|
||||
# device = "cuda"
|
||||
bind_host = "0.0.0.0"
|
||||
# port = 8000
|
||||
|
||||
# YouTube downloads. Each player client is tried in order until one works;
|
||||
# "default" means whatever yt-dlp currently picks for itself.
|
||||
# ytdlp_clients = ["default", "tv"]
|
||||
# ytdlp_cookies = "/config/youtube-cookies.txt" # Netscape cookie jar
|
||||
# ytdlp_impersonate = "chrome" # TLS fingerprint to present
|
||||
# ytdlp_pot_provider_url = "http://bgutil-provider:4416"
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
songtrace:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./config.toml:/config/config.toml:ro
|
||||
- ./data:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
+13
-4
@@ -1,10 +1,16 @@
|
||||
[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",
|
||||
# yt-dlp declares no runtime deps of its own; the extras are what make
|
||||
# YouTube work. "default" pulls yt-dlp-ejs (JS challenge solving) and the
|
||||
# requests/urllib3 handlers, "deno" ships the JS runtime that executes it,
|
||||
# and "curl-cffi" provides the --impersonate targets. Without them YouTube
|
||||
# extraction degrades to HTTP 403 on the media URLs.
|
||||
"yt-dlp[default,curl-cffi,deno]",
|
||||
"demucs",
|
||||
"torch",
|
||||
"torchaudio",
|
||||
@@ -22,10 +28,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"]
|
||||
@@ -34,5 +40,8 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
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,16 +0,0 @@
|
||||
import argparse
|
||||
from importlib.metadata import version
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="karaoke-maker")
|
||||
parser.add_argument("--version", action="store_true")
|
||||
parser.add_subparsers(dest="command")
|
||||
return parser
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.version:
|
||||
print(f"karaoke-maker {version('karaoke-maker')}")
|
||||
return 0
|
||||
build_parser().print_help()
|
||||
return 0
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
|
||||
from .config import load_config
|
||||
from .pipeline import STAGE_ORDER, PipelineError, SongInput, run_pipeline
|
||||
|
||||
|
||||
def _positive_float(value: str) -> float:
|
||||
bpm = float(value)
|
||||
if not math.isfinite(bpm) or bpm <= 0:
|
||||
raise argparse.ArgumentTypeError(f"--bpm must be a finite value > 0, got {value!r}")
|
||||
return bpm
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="songtrace")
|
||||
parser.add_argument("--version", action="store_true")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
make = sub.add_parser("make", help="build a karaoke song folder")
|
||||
make.add_argument("--title", required=True)
|
||||
make.add_argument("--artist", required=True)
|
||||
make.add_argument("--lyrics", required=True, type=Path)
|
||||
make.add_argument("--youtube")
|
||||
make.add_argument("--video", type=Path)
|
||||
make.add_argument("--genre")
|
||||
make.add_argument("--year", type=int)
|
||||
make.add_argument("--language")
|
||||
make.add_argument("--cover", type=Path)
|
||||
make.add_argument("--bpm", type=_positive_float)
|
||||
make.add_argument("--force-from", choices=STAGE_ORDER)
|
||||
make.add_argument("--config", type=Path)
|
||||
|
||||
serve = sub.add_parser("serve", help="run the web UI")
|
||||
serve.add_argument("--config", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def _cmd_make(args) -> int:
|
||||
if bool(args.youtube) == bool(args.video):
|
||||
print("error: provide exactly one of --youtube / --video", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
lyrics_text = args.lyrics.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
print(f"error: could not read --lyrics file: {e}", file=sys.stderr)
|
||||
return 2
|
||||
except UnicodeDecodeError as e:
|
||||
print(f"error: --lyrics file is not valid UTF-8 text: {e}", file=sys.stderr)
|
||||
return 2
|
||||
cfg = load_config(args.config)
|
||||
inp = SongInput(
|
||||
title=args.title, artist=args.artist,
|
||||
lyrics_text=lyrics_text,
|
||||
youtube_url=args.youtube, video_path=args.video,
|
||||
genre=args.genre, year=args.year, language=args.language,
|
||||
cover_path=args.cover, bpm_override=args.bpm)
|
||||
try:
|
||||
result = run_pipeline(inp, cfg, force_from=args.force_from)
|
||||
except PipelineError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"done: {result.output_dir}")
|
||||
for w in result.warnings:
|
||||
print(f" review: {w}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.version:
|
||||
print(f"songtrace {version('songtrace')}")
|
||||
return 0
|
||||
if args.command == "make":
|
||||
return _cmd_make(args)
|
||||
if args.command == "serve":
|
||||
from .web import serve
|
||||
return serve(load_config(args.config))
|
||||
build_parser().print_help()
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field, fields
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
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 = "songtrace"
|
||||
demucs_model: str = "htdemucs_ft"
|
||||
crepe_model: str = "full"
|
||||
device: str = "cuda"
|
||||
bind_host: str = "127.0.0.1"
|
||||
port: int = 8000
|
||||
# YouTube player clients to try, in order, until one yields a download.
|
||||
# "default" means yt-dlp's own choice; see its extractor-args docs.
|
||||
ytdlp_clients: list[str] = field(default_factory=lambda: ["default", "tv"])
|
||||
ytdlp_cookies: Path | None = None
|
||||
ytdlp_impersonate: str | None = None
|
||||
ytdlp_pot_provider_url: str | None = None
|
||||
|
||||
|
||||
_PATH_FIELDS = {"output_dir", "work_dir", "ytdlp_cookies"}
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> Config:
|
||||
if path is None:
|
||||
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
|
||||
if unknown:
|
||||
raise ValueError(f"unknown config keys: {', '.join(sorted(unknown))}")
|
||||
for key in _PATH_FIELDS & set(data):
|
||||
data[key] = Path(data[key])
|
||||
return Config(**data)
|
||||
@@ -0,0 +1,58 @@
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .ultrastar import LineBreak, Note, UltraStarSong, format_bpm
|
||||
|
||||
|
||||
def detect_bpm(instrumental_path: Path) -> float:
|
||||
import librosa
|
||||
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True)
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
return float(np.atleast_1d(tempo)[0])
|
||||
|
||||
|
||||
def scale_bpm(bpm: float) -> float:
|
||||
"""Fold a tempo into UltraStar's usual [200, 400) file-BPM range."""
|
||||
if not math.isfinite(bpm) or bpm <= 0:
|
||||
raise ValueError(f"cannot scale bpm {bpm}")
|
||||
while bpm < 200:
|
||||
bpm *= 2
|
||||
while bpm >= 400:
|
||||
bpm /= 2
|
||||
return bpm
|
||||
|
||||
|
||||
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
|
||||
headers: list[tuple[str, str]]) -> UltraStarSong:
|
||||
if not notes:
|
||||
raise ValueError("no notes to place on the beat grid")
|
||||
gap_ms = round(notes[0]["start"] * 1000)
|
||||
spb = 60 / (4 * file_bpm)
|
||||
|
||||
def beat(t: float) -> int:
|
||||
return round((t - gap_ms / 1000) / spb)
|
||||
|
||||
# a line break at the same instant as a note belongs before it
|
||||
events = [(t, 0, None) for t in breaks] + [(n["start"], 1, n) for n in notes]
|
||||
events.sort(key=lambda e: e[:2])
|
||||
body: list[Note | LineBreak] = []
|
||||
# notes.py can only keep notes apart in seconds; quantising re-collides them,
|
||||
# so the cursor keeps the emitted beats strictly ordered and non-overlapping
|
||||
cursor = -math.inf
|
||||
for t, is_note, n in events:
|
||||
if not is_note:
|
||||
b = max(beat(t), cursor)
|
||||
body.append(LineBreak(beat=b))
|
||||
cursor = b
|
||||
else:
|
||||
b = max(beat(t), cursor)
|
||||
length = max(1, beat(t + n["duration"]) - beat(t))
|
||||
body.append(Note(kind=":", beat=b, length=length, pitch=n["pitch"],
|
||||
text=n["text"]))
|
||||
cursor = b + length
|
||||
out_headers = list(headers)
|
||||
out_headers.append(("BPM", format_bpm(file_bpm)))
|
||||
out_headers.append(("GAP", str(gap_ms)))
|
||||
return UltraStarSong(headers=out_headers, body=body)
|
||||
@@ -0,0 +1,19 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Language:
|
||||
iso: str # whisper / num2words language code
|
||||
pyphen: str | None # pyphen dictionary code, None if no dictionary exists
|
||||
|
||||
|
||||
LANGUAGES: dict[str, Language] = {
|
||||
"english": Language("en", "en_US"),
|
||||
"german": Language("de", "de_DE"),
|
||||
"spanish": Language("es", "es"),
|
||||
"french": Language("fr", "fr"),
|
||||
"italian": Language("it", "it"),
|
||||
"portuguese": Language("pt", "pt"),
|
||||
"dutch": Language("nl", "nl_NL"),
|
||||
"japanese": Language("ja", None),
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from num2words import num2words
|
||||
|
||||
from .languages import LANGUAGES
|
||||
|
||||
SECTION_RE = re.compile(r"^\s*[\[(].*[\])]\s*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefLyrics:
|
||||
lines: list[list[str]]
|
||||
words: list[str]
|
||||
word_line_index: list[int]
|
||||
|
||||
|
||||
def normalize_lyrics(text: str) -> RefLyrics:
|
||||
lines = []
|
||||
for raw in text.splitlines():
|
||||
if not raw.strip() or SECTION_RE.match(raw):
|
||||
continue
|
||||
lines.append(raw.split())
|
||||
words, idx = [], []
|
||||
for i, line in enumerate(lines):
|
||||
words.extend(line)
|
||||
idx.extend([i] * len(line))
|
||||
return RefLyrics(lines, words, idx)
|
||||
|
||||
|
||||
def alignment_token(word: str, language: str | None = None) -> str:
|
||||
entry = LANGUAGES.get((language or "english").lower())
|
||||
lang = entry.iso if entry else "en"
|
||||
|
||||
def expand(m):
|
||||
try:
|
||||
return " " + num2words(int(m[0]), lang=lang) + " "
|
||||
except NotImplementedError:
|
||||
return " " + num2words(int(m[0])) + " "
|
||||
|
||||
word = word.replace("’", "'").replace("ʼ", "'")
|
||||
word = re.sub(r"\d+", expand, word)
|
||||
word = word.lower()
|
||||
word = re.sub(r"[^a-z'À-ɏЀ-ӿ ]", "", word)
|
||||
return re.sub(r"\s+", "", word)
|
||||
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .pipeline import PipelineError
|
||||
|
||||
|
||||
def ffmpeg(*args: str) -> None:
|
||||
result = subprocess.run(["ffmpeg", "-y", *args], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise PipelineError(f"ffmpeg failed: {result.stderr[-2000:]}")
|
||||
|
||||
|
||||
def ffprobe_codec(path: Path) -> tuple[str, str]:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", str(path)],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise PipelineError(f"ffprobe failed on {path}")
|
||||
data = json.loads(result.stdout)
|
||||
video = next((s for s in data["streams"] if s["codec_type"] == "video"), {})
|
||||
return data["format"]["format_name"], video.get("codec_name", "")
|
||||
@@ -0,0 +1,198 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pyphen
|
||||
|
||||
from .languages import LANGUAGES
|
||||
|
||||
CONF_MASK = 0.5 # pitch frames below this are unvoiced
|
||||
LOW_SCORE = 0.5 # word alignment score below this -> low_confidence
|
||||
SPLIT_SEMITONES = 0.8
|
||||
MIN_SEG_SEC = 0.05
|
||||
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
|
||||
HOP_SEC = 0.01 # pitch stage hop
|
||||
|
||||
_SYLL_SEP = "\0" # sentinel so literal hyphens survive syllabification
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedNote:
|
||||
start: float
|
||||
duration: float
|
||||
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:
|
||||
return 69 + 12 * math.log2(hz / 440.0)
|
||||
|
||||
|
||||
def hz_to_ultrastar(hz: float) -> int:
|
||||
return round(hz_to_midi_float(hz)) - 60
|
||||
|
||||
|
||||
def segment_span(times, f0, conf, start: float, end: float
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Voiced pitch segments within [start, end) as (start, end, median_midi)."""
|
||||
mask = (times >= start) & (times < end) & (conf >= CONF_MASK) & (f0 > 0)
|
||||
t = times[mask]
|
||||
if len(t) == 0:
|
||||
return []
|
||||
midi = 69 + 12 * np.log2(f0[mask] / 440.0)
|
||||
segs: list[tuple[float, float, float]] = []
|
||||
|
||||
def emit(a: int, b: int) -> None:
|
||||
if float(t[b - 1]) - float(t[a]) + HOP_SEC >= MIN_SEG_SEC:
|
||||
segs.append((float(t[a]), float(t[b - 1]) + HOP_SEC,
|
||||
float(np.median(midi[a:b]))))
|
||||
|
||||
seg_start, dev_start = 0, None
|
||||
for i in range(1, len(t)):
|
||||
stable_end = i if dev_start is None else dev_start
|
||||
if abs(midi[i] - np.median(midi[seg_start:stable_end])) >= SPLIT_SEMITONES:
|
||||
# only split once the deviation has held for MIN_SEG_SEC, so that
|
||||
# short tracker glitches don't fragment a sustained note
|
||||
if dev_start is None:
|
||||
dev_start = i
|
||||
elif t[i] - t[dev_start] + HOP_SEC >= MIN_SEG_SEC:
|
||||
emit(seg_start, dev_start)
|
||||
seg_start, dev_start = dev_start, None
|
||||
else:
|
||||
dev_start = None
|
||||
emit(seg_start, len(t))
|
||||
return segs
|
||||
|
||||
|
||||
def syllabify(word: str, language: str | None) -> list[str]:
|
||||
entry = LANGUAGES.get((language or "english").lower())
|
||||
code = entry.pyphen if entry else "en_US"
|
||||
if code is None:
|
||||
return [word]
|
||||
try:
|
||||
dic = pyphen.Pyphen(lang=code)
|
||||
except KeyError:
|
||||
return [word]
|
||||
parts = [p for p in dic.inserted(word, hyphen=_SYLL_SEP).split(_SYLL_SEP) if p]
|
||||
return parts or [word]
|
||||
|
||||
|
||||
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, last_seen_line = None, 0.0, None
|
||||
for row in rows:
|
||||
# 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,
|
||||
row["line_index"]))
|
||||
continue
|
||||
low = row["score"] < LOW_SCORE
|
||||
sylls = syllabify(row["word"], language)
|
||||
if len(segs) < len(sylls):
|
||||
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,
|
||||
row["line_index"]))
|
||||
_deoverlap(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, line_index: int) -> TimedNote:
|
||||
return TimedNote(float(start), float(duration), int(pitch), text, low,
|
||||
int(line_index))
|
||||
|
||||
|
||||
def _deoverlap(notes: list[TimedNote]) -> None:
|
||||
"""Give every note a positive length without letting notes collide.
|
||||
|
||||
Notes are grown to MIN_NOTE_SEC (alignment hands us zero-length words, and
|
||||
subdividing a short word by syllable makes very short slices) but only into
|
||||
space the next note isn't using; where there is no space at all the rest of
|
||||
the song is nudged later.
|
||||
"""
|
||||
limits = [math.inf] * len(notes)
|
||||
for i in range(len(notes) - 2, -1, -1):
|
||||
limits[i] = (notes[i + 1].start if notes[i + 1].start > notes[i].start
|
||||
else limits[i + 1])
|
||||
cursor = -math.inf
|
||||
for note, limit in zip(notes, limits):
|
||||
note.start = max(note.start, cursor)
|
||||
room = limit - note.start
|
||||
note.duration = (min(max(note.duration, MIN_NOTE_SEC), room)
|
||||
if room > 0 else MIN_NOTE_SEC)
|
||||
cursor = note.start + note.duration
|
||||
|
||||
|
||||
def _text(syllable: str, index: int, line_initial: bool) -> str:
|
||||
return syllable if index or line_initial else " " + syllable
|
||||
|
||||
|
||||
def _subdivide(segs, target: int):
|
||||
"""Cut segments into equal slices until there is one per syllable."""
|
||||
parts = [1] * len(segs)
|
||||
while sum(parts) < target:
|
||||
i = max(range(len(segs)),
|
||||
key=lambda j: (segs[j][1] - segs[j][0]) / parts[j])
|
||||
parts[i] += 1
|
||||
out = []
|
||||
for (s, e, midi), n in zip(segs, parts):
|
||||
step = (e - s) / n
|
||||
out.extend((s + k * step, s + (k + 1) * step, midi) for k in range(n))
|
||||
return out
|
||||
|
||||
|
||||
def _fmt(t: float) -> str:
|
||||
return f"{int(t // 60)}:{int(t % 60):02d}"
|
||||
|
||||
|
||||
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:
|
||||
run.append(n)
|
||||
elif run:
|
||||
first, last = run[0], run[-1]
|
||||
warnings.append(
|
||||
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
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .config import Config
|
||||
|
||||
class PipelineError(Exception):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class SongInput:
|
||||
title: str
|
||||
artist: str
|
||||
lyrics_text: str
|
||||
youtube_url: str | None = None
|
||||
video_path: Path | None = None
|
||||
genre: str | None = None
|
||||
year: int | None = None
|
||||
language: str | None = None
|
||||
cover_path: Path | None = None # local file or URL already fetched by front-end
|
||||
bpm_override: float | None = None
|
||||
|
||||
@dataclass
|
||||
class Stage:
|
||||
name: str
|
||||
outputs: Callable[["Ctx"], list[Path]]
|
||||
run: Callable[["Ctx"], None]
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
inp: SongInput
|
||||
cfg: Config
|
||||
work: Path # per-song work dir (created)
|
||||
log: Callable[[str], None]
|
||||
|
||||
@dataclass
|
||||
class PipelineResult:
|
||||
output_dir: Path
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
STAGE_ORDER = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
|
||||
|
||||
def song_slug(inp: SongInput) -> str:
|
||||
name = f"{inp.artist} - {inp.title}"
|
||||
return name.replace("/", "").replace("\0", "")
|
||||
|
||||
def read_warnings(work: Path) -> list[str]:
|
||||
notes_file = work / "notes_sec.json"
|
||||
if not notes_file.exists():
|
||||
return []
|
||||
return json.loads(notes_file.read_text()).get("warnings", [])
|
||||
|
||||
def _real_stages() -> list[Stage]:
|
||||
from . import stages
|
||||
return stages.all_stages()
|
||||
|
||||
def run_pipeline(inp: SongInput, cfg: Config, log=print,
|
||||
force_from: str | None = None,
|
||||
stages: list[Stage] | None = None) -> PipelineResult:
|
||||
if stages is None:
|
||||
stages = _real_stages()
|
||||
names = [s.name for s in stages]
|
||||
if force_from is not None and force_from not in names:
|
||||
raise PipelineError(f"unknown stage {force_from!r}, expected one of {names}")
|
||||
work = cfg.work_dir / song_slug(inp)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
ctx = Ctx(inp=inp, cfg=cfg, work=work, log=log)
|
||||
forcing = False
|
||||
for stage in stages:
|
||||
if stage.name == force_from:
|
||||
forcing = True
|
||||
if not forcing and all(p.exists() for p in stage.outputs(ctx)):
|
||||
log(f"[{stage.name}] cached, skipping")
|
||||
continue
|
||||
log(f"[{stage.name}] running")
|
||||
stage.run(ctx)
|
||||
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp),
|
||||
warnings=read_warnings(work))
|
||||
@@ -0,0 +1,73 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
from .pipeline import STAGE_ORDER, PipelineError, SongInput, song_slug
|
||||
|
||||
# The earliest stage that reads each SongInput field. Editing a field means every
|
||||
# stage from here onward is stale. Adding a field to SongInput without adding it
|
||||
# here would let an edit invalidate nothing at all, so tests pin this to the
|
||||
# dataclass.
|
||||
FIELD_STAGE = {
|
||||
"youtube_url": "acquire",
|
||||
"video_path": "acquire",
|
||||
"language": "transcribe",
|
||||
"lyrics_text": "align",
|
||||
"title": "grid",
|
||||
"artist": "grid",
|
||||
"genre": "grid",
|
||||
"year": "grid",
|
||||
"bpm_override": "grid",
|
||||
"cover_path": "package",
|
||||
}
|
||||
|
||||
|
||||
def changed_fields(old: SongInput, new: SongInput) -> list[str]:
|
||||
return [f for f in FIELD_STAGE if getattr(old, f) != getattr(new, f)]
|
||||
|
||||
|
||||
def plan(old: SongInput, new: SongInput) -> str | None:
|
||||
"""The stage to restart from, or None when nothing changed.
|
||||
|
||||
None is not "do nothing": run_pipeline with no force_from resumes at the
|
||||
first stage whose outputs are missing, which is exactly what a rerun wants.
|
||||
"""
|
||||
changed = changed_fields(old, new)
|
||||
if not changed:
|
||||
return None
|
||||
return min((FIELD_STAGE[f] for f in changed), key=STAGE_ORDER.index)
|
||||
|
||||
|
||||
def relocate_work(old: SongInput, new: SongInput, cfg: Config) -> bool:
|
||||
"""Move the cached work dir when title/artist changed its slug.
|
||||
|
||||
Nothing inside a work dir depends on the slug except song.txt, which `grid`
|
||||
rewrites — and FIELD_STAGE sends title/artist edits to `grid`. So a retitle
|
||||
costs a rename plus grid and package, not the whole GPU pipeline.
|
||||
"""
|
||||
old_dir = cfg.work_dir / song_slug(old)
|
||||
new_dir = cfg.work_dir / song_slug(new)
|
||||
if old_dir == new_dir or not old_dir.exists():
|
||||
return False
|
||||
if new_dir.exists():
|
||||
raise PipelineError(
|
||||
f"a work folder already exists for {song_slug(new)!r}; "
|
||||
"rename or remove it before rebuilding")
|
||||
new_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(old_dir), str(new_dir))
|
||||
return True
|
||||
|
||||
|
||||
def superseded_output(old_dir: str | None, new_dir: str,
|
||||
output_root: Path) -> Path | None:
|
||||
"""The previous build's folder, when the rebuild replaced it under a new slug.
|
||||
|
||||
Returns None unless the path is strictly inside output_root, so a bad job
|
||||
record cannot turn the caller's removal into an arbitrary delete.
|
||||
"""
|
||||
if not old_dir:
|
||||
return None
|
||||
old, new = Path(old_dir).resolve(), Path(new_dir).resolve()
|
||||
if old == new or output_root.resolve() not in old.parents:
|
||||
return None
|
||||
return old
|
||||
@@ -0,0 +1,14 @@
|
||||
from ..pipeline import Stage
|
||||
from .acquire import acquire_stage
|
||||
from .align import align_stage
|
||||
from .grid import grid_stage
|
||||
from .notes import notes_stage
|
||||
from .package import package_stage
|
||||
from .pitch import pitch_stage
|
||||
from .separate import separate_stage
|
||||
from .transcribe import transcribe_stage
|
||||
|
||||
|
||||
def all_stages() -> list[Stage]:
|
||||
return [acquire_stage(), separate_stage(), transcribe_stage(), align_stage(),
|
||||
pitch_stage(), notes_stage(), grid_stage(), package_stage()]
|
||||
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import Config
|
||||
from ..media import ffmpeg
|
||||
from ..pipeline import Ctx, PipelineError, Stage
|
||||
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"}
|
||||
# yt-dlp's in-progress scratch files, which a failed attempt leaves behind.
|
||||
PARTIAL_EXTENSIONS = {".part", ".ytdl", ".temp"}
|
||||
|
||||
|
||||
def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
|
||||
files = [p for p in dest.glob("source_video.*")
|
||||
if p.suffix.lower() not in PARTIAL_EXTENSIONS]
|
||||
thumbs = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS]
|
||||
videos = [p for p in files if p.suffix.lower() not in IMAGE_EXTENSIONS]
|
||||
if not videos:
|
||||
raise PipelineError("yt-dlp produced no video file")
|
||||
video = max(videos, key=lambda p: p.stat().st_size)
|
||||
return video, thumbs[0] if thumbs else None
|
||||
|
||||
|
||||
class _YdlLog:
|
||||
"""Routes yt-dlp's own messages into the job log, minus progress noise."""
|
||||
|
||||
def __init__(self, log):
|
||||
self._log = log
|
||||
|
||||
def debug(self, msg: str) -> None:
|
||||
if not msg.startswith("[debug] "):
|
||||
self._log(msg)
|
||||
|
||||
def warning(self, msg: str) -> None:
|
||||
self._log(msg)
|
||||
|
||||
error = warning
|
||||
|
||||
|
||||
def _ydl_opts(cfg: Config, dest: Path, client: str, log) -> dict:
|
||||
extractor_args: dict[str, dict[str, list[str]]] = {}
|
||||
if client != "default":
|
||||
extractor_args["youtube"] = {"player_client": [client]}
|
||||
if cfg.ytdlp_pot_provider_url:
|
||||
extractor_args["youtubepot-bgutilhttp"] = {
|
||||
"base_url": [cfg.ytdlp_pot_provider_url]}
|
||||
opts = {
|
||||
"outtmpl": str(dest / "source_video.%(ext)s"),
|
||||
"format": "bestvideo*+bestaudio/best",
|
||||
"writethumbnail": True,
|
||||
"noplaylist": True,
|
||||
"noprogress": True,
|
||||
"logger": _YdlLog(log),
|
||||
"retries": 10,
|
||||
"fragment_retries": 10,
|
||||
"extractor_retries": 5,
|
||||
"extractor_args": extractor_args,
|
||||
}
|
||||
if cfg.ytdlp_cookies:
|
||||
opts["cookiefile"] = str(cfg.ytdlp_cookies)
|
||||
if cfg.ytdlp_impersonate:
|
||||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||
opts["impersonate"] = ImpersonateTarget.from_str(cfg.ytdlp_impersonate)
|
||||
return opts
|
||||
|
||||
|
||||
def download_youtube(url: str, dest: Path, cfg: Config,
|
||||
log=print) -> tuple[Path, Path | None]:
|
||||
import yt_dlp
|
||||
clients = list(cfg.ytdlp_clients) or ["default"]
|
||||
last: Exception | None = None
|
||||
for client in clients:
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(_ydl_opts(cfg, dest, client, log)) as ydl:
|
||||
ydl.extract_info(url, download=True)
|
||||
return _classify_downloads(dest)
|
||||
except Exception as e:
|
||||
last = e
|
||||
log(f"yt-dlp player_client={client} failed: {e}")
|
||||
raise PipelineError(
|
||||
f"yt-dlp {yt_dlp.version.__version__} could not download {url} after "
|
||||
f"trying player_client={', '.join(clients)}. Last error: {last}. "
|
||||
"YouTube blocks change often: upgrade yt-dlp, then see the yt-dlp "
|
||||
"options in config.toml (cookies, impersonation, PO token provider).")
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
if bool(ctx.inp.youtube_url) == bool(ctx.inp.video_path):
|
||||
raise PipelineError("provide exactly one of youtube_url / video_path")
|
||||
thumb = None
|
||||
if ctx.inp.youtube_url:
|
||||
video, thumb = download_youtube(ctx.inp.youtube_url, ctx.work,
|
||||
ctx.cfg, ctx.log)
|
||||
else:
|
||||
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
|
||||
shutil.copyfile(ctx.inp.video_path, video)
|
||||
ffmpeg("-i", str(video), "-vn", "-ac", "2", "-ar", "44100",
|
||||
str(ctx.work / "audio.wav"))
|
||||
meta = {"video_file": video.name, "thumbnail": thumb.name if thumb else None}
|
||||
(ctx.work / "meta.json").write_text(json.dumps(meta))
|
||||
|
||||
|
||||
def acquire_stage() -> Stage:
|
||||
return Stage(
|
||||
name="acquire",
|
||||
outputs=lambda ctx: [ctx.work / "meta.json", ctx.work / "audio.wav"],
|
||||
run=_run,
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..lyrics import alignment_token, normalize_lyrics
|
||||
from ..pipeline import Ctx, PipelineError, Stage
|
||||
from .transcribe import classify_lyrics_match, load_transcript, lyrics_similarity, vocal_sections
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
_model_cache = {}
|
||||
|
||||
|
||||
def _get_model(device: str):
|
||||
if device not in _model_cache:
|
||||
from torchaudio.pipelines import MMS_FA as bundle
|
||||
_model_cache[device] = bundle.get_model().to(device)
|
||||
return _model_cache[device]
|
||||
|
||||
|
||||
def force_align_section(vocals_path: Path, start: float, end: float,
|
||||
tokens: list[str], device: str) -> list[tuple[float, float, float]]:
|
||||
import torch
|
||||
import torchaudio
|
||||
from torchaudio.pipelines import MMS_FA as bundle
|
||||
|
||||
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(
|
||||
waveform[:, lo:hi], sr, bundle.sample_rate)
|
||||
# MMS_FA only models a-z and apostrophe; anything else becomes the star token.
|
||||
vocab = bundle.get_dict()
|
||||
tokens = ["".join(c if c in vocab else "*" for c in t) for t in tokens]
|
||||
with torch.inference_mode():
|
||||
emission, _ = _get_model(device)(waveform.to(device))
|
||||
spans = bundle.get_aligner()(emission[0], bundle.get_tokenizer()(tokens))
|
||||
frame = (hi - lo) / sr / emission.shape[1]
|
||||
return [(start + s[0].start * frame,
|
||||
start + s[-1].end * frame,
|
||||
sum(t.score for t in s) / len(s)) for s in spans]
|
||||
|
||||
|
||||
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 = len(line_indices)
|
||||
groups, elapsed, start = [], 0.0, 0
|
||||
for i, (a, b) in enumerate(sections):
|
||||
elapsed += b - a
|
||||
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
|
||||
|
||||
|
||||
def _interpolate(rows: list[dict], lo_bound: float, hi_bound: float) -> None:
|
||||
"""Give rows left without times evenly spaced spans between their aligned neighbours."""
|
||||
i = 0
|
||||
while i < len(rows):
|
||||
if rows[i]["start"] is not None:
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j < len(rows) and rows[j]["start"] is None:
|
||||
j += 1
|
||||
lo = rows[i - 1]["end"] if i else lo_bound
|
||||
hi = max(rows[j]["start"] if j < len(rows) else hi_bound, lo)
|
||||
step = (hi - lo) / (j - i)
|
||||
for k in range(i, j):
|
||||
rows[k]["start"] = lo + (k - i) * step
|
||||
rows[k]["end"] = rows[k]["start"] + step
|
||||
i = j
|
||||
|
||||
|
||||
def _enforce_monotonic(rows: list[dict]) -> None:
|
||||
prev = 0.0
|
||||
for r in rows:
|
||||
r["start"] = max(r["start"], prev)
|
||||
r["end"] = prev = max(r["end"], r["start"])
|
||||
|
||||
|
||||
def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str, int]],
|
||||
language: str | None, device: str) -> list[dict]:
|
||||
tokens = [alignment_token(w, language) for w, _ in words]
|
||||
alignable = [t for t in tokens if t]
|
||||
aligned = iter(force_align_section(vocals, sec[0], sec[1], alignable, device)
|
||||
if alignable else [])
|
||||
rows, blank = [], (None, None, 0.0)
|
||||
for (word, line_index), token in zip(words, tokens):
|
||||
start, end, score = next(aligned, blank) if token else blank
|
||||
rows.append({"word": word, "start": start, "end": end,
|
||||
"score": score, "line_index": line_index})
|
||||
_interpolate(rows, sec[0], sec[1])
|
||||
return rows
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
ref = normalize_lyrics(ctx.inp.lyrics_text)
|
||||
segments = sorted(load_transcript(ctx), key=lambda s: s.start)
|
||||
sim = lyrics_similarity(segments, ref)
|
||||
if sim < SIMILARITY_THRESHOLD:
|
||||
raise PipelineError(
|
||||
f"lyrics don't match audio (similarity {sim:.2f}) — "
|
||||
"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(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:
|
||||
return Stage(name="align",
|
||||
outputs=lambda ctx: [ctx.work / "alignment.json"],
|
||||
run=_run)
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .. import grid
|
||||
from ..pipeline import Ctx, PipelineError, Stage, song_slug
|
||||
from ..ultrastar import serialize_ultrastar
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
data = json.loads((ctx.work / "notes_sec.json").read_text())
|
||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||
if ctx.inp.bpm_override is not None:
|
||||
bpm = ctx.inp.bpm_override
|
||||
else:
|
||||
try:
|
||||
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
|
||||
except ValueError:
|
||||
raise PipelineError("could not detect a usable tempo - is the audio silent?")
|
||||
slug = song_slug(ctx.inp)
|
||||
video_ext = Path(meta["video_file"]).suffix
|
||||
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
|
||||
if ctx.inp.language:
|
||||
headers.append(("LANGUAGE", ctx.inp.language))
|
||||
if ctx.inp.genre:
|
||||
headers.append(("GENRE", ctx.inp.genre))
|
||||
if ctx.inp.year:
|
||||
headers.append(("YEAR", str(ctx.inp.year)))
|
||||
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
|
||||
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
|
||||
try:
|
||||
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers)
|
||||
except ValueError:
|
||||
raise PipelineError("no notes were generated - the lyrics may not match the audio")
|
||||
(ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
|
||||
|
||||
|
||||
def grid_stage() -> Stage:
|
||||
return Stage(name="grid",
|
||||
outputs=lambda ctx: [ctx.work / "song.txt"],
|
||||
run=_run)
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..lyrics import normalize_lyrics
|
||||
from ..notes import build_notes
|
||||
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,
|
||||
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,
|
||||
"warnings": warnings,
|
||||
}))
|
||||
|
||||
|
||||
def notes_stage() -> Stage:
|
||||
return Stage(name="notes",
|
||||
outputs=lambda ctx: [ctx.work / "notes_sec.json"],
|
||||
run=_run)
|
||||
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from ..media import ffmpeg, ffprobe_codec
|
||||
from ..pipeline import Ctx, Stage, song_slug
|
||||
from ..ultrastar import parse_ultrastar, serialize_ultrastar
|
||||
|
||||
GOOD_VIDEO = {("webm", "vp9"), ("webm", "vp8"),
|
||||
("mov,mp4,m4a,3gp,3g2,mj2", "h264")}
|
||||
|
||||
|
||||
def prepare_cover(src: Path, dest_jpg: Path) -> None:
|
||||
img = Image.open(src).convert("RGB")
|
||||
side = min(img.size)
|
||||
left = (img.width - side) // 2
|
||||
top = (img.height - side) // 2
|
||||
img = img.crop((left, top, left + side, top + side))
|
||||
if side > 800:
|
||||
img = img.resize((800, 800))
|
||||
img.save(dest_jpg, "JPEG", quality=90)
|
||||
|
||||
|
||||
def _set_header(txt_path: Path, key: str, value: str | None) -> None:
|
||||
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
|
||||
replaced = False
|
||||
headers = []
|
||||
for k, v in song.headers:
|
||||
if k.upper() == key.upper():
|
||||
replaced = True
|
||||
if value is not None:
|
||||
headers.append((key, value))
|
||||
else:
|
||||
headers.append((k, v))
|
||||
if value is not None and not replaced:
|
||||
headers.append((key, value))
|
||||
song.headers = headers
|
||||
txt_path.write_text(serialize_ultrastar(song), encoding="utf-8")
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
slug = song_slug(ctx.inp)
|
||||
out = ctx.cfg.output_dir / slug
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
txt = out / f"{slug}.txt"
|
||||
shutil.copyfile(ctx.work / "song.txt", txt)
|
||||
ffmpeg("-i", str(ctx.work / "audio.wav"), "-b:a", "320k",
|
||||
str(out / f"{slug}.mp3"))
|
||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||
src_video = ctx.work / meta["video_file"]
|
||||
fmt, codec = ffprobe_codec(src_video)
|
||||
if (fmt, codec) in GOOD_VIDEO:
|
||||
shutil.copyfile(src_video, out / f"{slug}{src_video.suffix}")
|
||||
else:
|
||||
ffmpeg("-i", str(src_video), "-c:v", "libx264", "-crf", "20",
|
||||
"-preset", "medium", "-c:a", "aac", str(out / f"{slug}.mp4"))
|
||||
_set_header(txt, "VIDEO", f"{slug}.mp4")
|
||||
cover_src = ctx.inp.cover_path
|
||||
if cover_src is None and meta.get("thumbnail"):
|
||||
cover_src = ctx.work / meta["thumbnail"]
|
||||
cover_ok = False
|
||||
if cover_src and Path(cover_src).exists():
|
||||
try:
|
||||
prepare_cover(Path(cover_src), out / f"{slug}.jpg")
|
||||
cover_ok = True
|
||||
except UnidentifiedImageError:
|
||||
ctx.log("cover file is not a valid image; dropping #COVER header")
|
||||
else:
|
||||
ctx.log("no cover available; dropping #COVER header")
|
||||
if not cover_ok:
|
||||
_set_header(txt, "COVER", None)
|
||||
(ctx.work / "package.done").write_text("")
|
||||
|
||||
|
||||
def package_stage() -> Stage:
|
||||
def outputs(ctx: Ctx):
|
||||
return [ctx.work / "package.done"]
|
||||
return Stage(name="package", outputs=outputs, run=_run)
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..pipeline import Ctx, Stage
|
||||
|
||||
|
||||
def track_pitch(vocals_path: Path, model: str, device: str):
|
||||
import torchaudio
|
||||
import torchcrepe
|
||||
|
||||
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(
|
||||
waveform, 16000, hop_length=160, model=model,
|
||||
fmin=50.0, fmax=1100.0, device=device,
|
||||
return_periodicity=True, batch_size=512)
|
||||
f0 = f0[0].cpu().numpy()
|
||||
conf = periodicity[0].cpu().numpy()
|
||||
times = np.arange(len(f0)) * 0.01
|
||||
return times, f0, conf
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
times, f0, conf = track_pitch(ctx.work / "vocals.wav",
|
||||
ctx.cfg.crepe_model, ctx.cfg.device)
|
||||
np.savez(ctx.work / "pitch.npz", times=times, f0=f0, conf=conf)
|
||||
|
||||
|
||||
def pitch_stage() -> Stage:
|
||||
return Stage(name="pitch",
|
||||
outputs=lambda ctx: [ctx.work / "pitch.npz"],
|
||||
run=_run)
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
|
||||
from ..pipeline import Ctx, Stage
|
||||
|
||||
|
||||
def separate_audio(audio_path: Path, model: str, device: str):
|
||||
from demucs.api import Separator
|
||||
separator = Separator(model=model, device=device)
|
||||
_, stems = separator.separate_audio_file(audio_path)
|
||||
return stems
|
||||
|
||||
|
||||
def _run(ctx: Ctx) -> None:
|
||||
stems = separate_audio(ctx.work / "audio.wav",
|
||||
ctx.cfg.demucs_model, ctx.cfg.device)
|
||||
vocals = stems["vocals"]
|
||||
others = [t for name, t in stems.items() if name != "vocals"]
|
||||
instrumental = sum(others[1:], start=others[0])
|
||||
sf.write(ctx.work / "vocals.wav", vocals.cpu().numpy().T, 44100)
|
||||
sf.write(ctx.work / "instrumental.wav", instrumental.cpu().numpy().T, 44100)
|
||||
|
||||
|
||||
def separate_stage() -> Stage:
|
||||
return Stage(
|
||||
name="separate",
|
||||
outputs=lambda ctx: [ctx.work / "vocals.wav", ctx.work / "instrumental.wav"],
|
||||
run=_run,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-LightItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Regular.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Italic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Medium.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-MediumItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-SemiBold.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-SemiBoldItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Bold.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-BoldItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
NOTE_RE = re.compile(r"^([:*FRG]) (-?\d+) (\d+) (-?\d+) (.*)$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
kind: str
|
||||
beat: int
|
||||
length: int
|
||||
pitch: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineBreak:
|
||||
beat: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UltraStarSong:
|
||||
headers: list[tuple[str, str]]
|
||||
body: list[Note | LineBreak]
|
||||
|
||||
|
||||
def parse_ultrastar(text: str) -> UltraStarSong:
|
||||
headers, body = [], []
|
||||
for line in text.splitlines():
|
||||
line = line.rstrip("\r")
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
key, _, value = line[1:].partition(":")
|
||||
headers.append((key, value))
|
||||
elif line.startswith("E"):
|
||||
break
|
||||
elif line.startswith("-"):
|
||||
nums = line[1:].split()
|
||||
body.append(LineBreak(beat=int(nums[0])))
|
||||
else:
|
||||
m = NOTE_RE.match(line)
|
||||
if m:
|
||||
body.append(Note(m[1], int(m[2]), int(m[3]), int(m[4]), m[5]))
|
||||
return UltraStarSong(headers, body)
|
||||
|
||||
|
||||
def serialize_ultrastar(song: UltraStarSong) -> str:
|
||||
out = [f"#{k}:{v}" for k, v in song.headers]
|
||||
for item in song.body:
|
||||
if isinstance(item, LineBreak):
|
||||
out.append(f"- {item.beat}")
|
||||
else:
|
||||
out.append(f"{item.kind} {item.beat} {item.length} {item.pitch} {item.text}")
|
||||
out.append("E")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def get_header(song: UltraStarSong, key: str) -> str | None:
|
||||
for k, v in song.headers:
|
||||
if k.upper() == key.upper():
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def format_bpm(bpm: float) -> str:
|
||||
if bpm == int(bpm):
|
||||
return str(int(bpm))
|
||||
return f"{bpm:.2f}".replace(".", ",")
|
||||
|
||||
|
||||
def parse_bpm(value: str) -> float:
|
||||
return float(value.replace(",", "."))
|
||||
@@ -0,0 +1,417 @@
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
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, read_warnings,
|
||||
run_pipeline, song_slug)
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
status: str = "queued"
|
||||
stage: str | None = None
|
||||
log: list[str] = field(default_factory=list)
|
||||
output_dir: str | None = None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
inp: SongInput | None = None
|
||||
rebuilt_from: str | None = None
|
||||
force_stage: str | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: float | None = None
|
||||
finished_at: float | None = None
|
||||
|
||||
|
||||
def _safe_filename(name: str | None, default: str) -> str:
|
||||
safe = Path(name).name if name else ""
|
||||
return safe if safe not in ("", ".", "..") else default
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, dest: Path) -> None:
|
||||
with dest.open("wb") as f:
|
||||
await run_in_threadpool(shutil.copyfileobj, upload.file, f)
|
||||
|
||||
|
||||
async def _store_uploads(cfg: Config, job_id: str, video: UploadFile | None,
|
||||
cover: UploadFile | None) -> tuple[Path | None, Path | None]:
|
||||
updir = cfg.work_dir / "uploads" / job_id
|
||||
paths = []
|
||||
for upload, default in ((video, "video"), (cover, "cover")):
|
||||
if not (upload and upload.filename):
|
||||
paths.append(None)
|
||||
continue
|
||||
updir.mkdir(parents=True, exist_ok=True)
|
||||
dest = updir / _safe_filename(upload.filename, default)
|
||||
await _save_upload(upload, dest)
|
||||
paths.append(dest)
|
||||
return paths[0], paths[1]
|
||||
|
||||
|
||||
def _cleanup_uploads(cfg: Config, job_id: str, video_path: Path | None) -> None:
|
||||
"""Drop the video upload a finished job no longer needs.
|
||||
|
||||
Covers are left behind: they are small, and a rebuild inherits cover_path, so
|
||||
removing one would silently strip #COVER from the rebuilt song. A rebuild can
|
||||
also inherit its parent's upload, so the directory is not always uploads/<job_id>.
|
||||
Never raises — the caller has already marked the job done.
|
||||
"""
|
||||
uploads = (cfg.work_dir / "uploads").resolve()
|
||||
dirs = {uploads / job_id}
|
||||
if video_path is not None:
|
||||
target = Path(video_path)
|
||||
parent = target.resolve().parent
|
||||
if parent.parent == uploads:
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
dirs.add(parent)
|
||||
for d in dirs:
|
||||
try:
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _discover_jobs(cfg: Config) -> list[Job]:
|
||||
discovered = []
|
||||
if not cfg.work_dir.exists():
|
||||
return discovered
|
||||
for sidecar in cfg.work_dir.glob("*/input.json"):
|
||||
try:
|
||||
data = json.loads(sidecar.read_text())
|
||||
for key in ("video_path", "cover_path"):
|
||||
if data.get(key) is not None:
|
||||
data[key] = Path(data[key])
|
||||
inp = SongInput(**data)
|
||||
slug = song_slug(inp)
|
||||
out = cfg.output_dir / slug
|
||||
done = out.exists() and any(out.iterdir())
|
||||
mtime = sidecar.stat().st_mtime
|
||||
warnings = read_warnings(sidecar.parent) if done else []
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
discovered.append(Job(
|
||||
id=uuid.uuid4().hex, title=inp.title, artist=inp.artist, inp=inp,
|
||||
status="done" if done else "failed",
|
||||
output_dir=str(out) if done else None,
|
||||
warnings=warnings,
|
||||
error=None if done else "interrupted before completion",
|
||||
created_at=mtime, finished_at=mtime))
|
||||
discovered.sort(key=lambda j: j.created_at)
|
||||
return discovered
|
||||
|
||||
|
||||
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
jobs: dict[str, Job] = {}
|
||||
order: list[str] = []
|
||||
lock = threading.Lock()
|
||||
todo: queue.Queue[str] = queue.Queue()
|
||||
|
||||
for job in _discover_jobs(cfg):
|
||||
jobs[job.id] = job
|
||||
order.append(job.id)
|
||||
|
||||
def _prepare_rebuild(job: Job, log) -> str | None:
|
||||
"""Relocate cached work and report the stage this rebuild restarts from."""
|
||||
if job.force_stage:
|
||||
log(f"[rebuild] restarting from {job.force_stage} (forced rebuild from scratch)")
|
||||
return job.force_stage
|
||||
if not job.rebuilt_from:
|
||||
return None
|
||||
with lock:
|
||||
parent = jobs.get(job.rebuilt_from)
|
||||
if parent is None:
|
||||
return None
|
||||
force_from = rebuild_policy.plan(parent.inp, job.inp)
|
||||
changed = rebuild_policy.changed_fields(parent.inp, job.inp)
|
||||
rebuild_policy.relocate_work(parent.inp, job.inp, cfg)
|
||||
log(f"[rebuild] restarting from {force_from or 'the first incomplete stage'}"
|
||||
f" ({', '.join(changed) if changed else 'nothing'} changed)")
|
||||
return force_from
|
||||
|
||||
def _drop_superseded_output(job: Job, log) -> None:
|
||||
if not job.rebuilt_from:
|
||||
return
|
||||
with lock:
|
||||
parent = jobs.get(job.rebuilt_from)
|
||||
if parent is None or parent.status != "done":
|
||||
return
|
||||
old = rebuild_policy.superseded_output(parent.output_dir, job.output_dir,
|
||||
cfg.output_dir)
|
||||
if old is None:
|
||||
return
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
log(f"[rebuild] removed superseded {old.name}")
|
||||
|
||||
def _write_sidecar(job: Job) -> None:
|
||||
work = cfg.work_dir / song_slug(job.inp)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
data = asdict(job.inp)
|
||||
for key in ("video_path", "cover_path"):
|
||||
if data[key] is not None:
|
||||
data[key] = str(data[key])
|
||||
(work / "input.json").write_text(json.dumps(data))
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
job_id = todo.get()
|
||||
with lock:
|
||||
job = jobs[job_id]
|
||||
job.status = "running"
|
||||
job.started_at = time.time()
|
||||
|
||||
def log(msg: str, job=job):
|
||||
with lock:
|
||||
job.log.append(msg)
|
||||
if m := STAGE_LINE.match(msg):
|
||||
job.stage = m[1]
|
||||
|
||||
try:
|
||||
force_from = _prepare_rebuild(job, log)
|
||||
_write_sidecar(job)
|
||||
result = runner(job.inp, cfg, log=log, force_from=force_from)
|
||||
with lock:
|
||||
job.output_dir = str(result.output_dir)
|
||||
job.warnings = list(result.warnings)
|
||||
_drop_superseded_output(job, log)
|
||||
with lock:
|
||||
job.status = "done"
|
||||
# uploads survive a failure so the job stays rerunnable; acquire
|
||||
# has already copied the video into the work dir by now.
|
||||
_cleanup_uploads(cfg, job_id, job.inp.video_path)
|
||||
except BaseException as e:
|
||||
with lock:
|
||||
job.status = "failed"
|
||||
job.error = str(e)
|
||||
finally:
|
||||
with lock:
|
||||
job.finished_at = time.time()
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _enqueue(job_id: str, inp: SongInput, rebuilt_from: str | None = None,
|
||||
force_stage: str | None = None) -> dict:
|
||||
job = Job(id=job_id, title=inp.title, artist=inp.artist, inp=inp,
|
||||
rebuilt_from=rebuilt_from, force_stage=force_stage)
|
||||
with lock:
|
||||
jobs[job_id] = job
|
||||
order.append(job_id)
|
||||
todo.put(job_id)
|
||||
return {"id": job_id}
|
||||
|
||||
def _finished_job(job_id: str) -> Job:
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
if job.status in ("queued", "running"):
|
||||
raise HTTPException(409, "job is still in progress")
|
||||
return job
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC / "index.html")
|
||||
|
||||
@app.post("/api/jobs", status_code=201)
|
||||
async def submit(title: str = Form(...), artist: str = Form(...),
|
||||
lyrics: str = Form(...), youtube_url: str | None = Form(None),
|
||||
genre: str | None = Form(None), year: int | None = Form(None),
|
||||
language: str | None = Form(None), bpm: float | None = Form(None),
|
||||
video: UploadFile | None = File(None),
|
||||
cover: UploadFile | None = File(None)):
|
||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||
language or None)
|
||||
has_video = bool(video and video.filename)
|
||||
if bool(youtube_url) == has_video:
|
||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||
if bpm is not None and (not math.isfinite(bpm) or bpm <= 0):
|
||||
raise HTTPException(400, f"bpm must be a finite value > 0, got {bpm!r}")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
video_path, cover_path = await _store_uploads(cfg, job_id, video, cover)
|
||||
|
||||
inp = SongInput(title=title, artist=artist, lyrics_text=lyrics,
|
||||
youtube_url=youtube_url, video_path=video_path,
|
||||
genre=genre, year=year, language=language,
|
||||
cover_path=cover_path, bpm_override=bpm)
|
||||
return _enqueue(job_id, inp)
|
||||
|
||||
@app.post("/api/jobs/{job_id}/rerun", status_code=201)
|
||||
def rerun(job_id: str):
|
||||
parent = _finished_job(job_id)
|
||||
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id)
|
||||
|
||||
@app.post("/api/jobs/{job_id}/rebuild-full", status_code=201)
|
||||
def rebuild_full(job_id: str):
|
||||
parent = _finished_job(job_id)
|
||||
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id,
|
||||
force_stage=STAGE_ORDER[0])
|
||||
|
||||
def _require_source(parent_inp: SongInput, inp: SongInput,
|
||||
force_from: str | None) -> None:
|
||||
"""Refuse a rebuild that will rerun acquire against an upload we no longer have."""
|
||||
if inp.video_path is None or inp.video_path.exists():
|
||||
return
|
||||
if force_from != "acquire":
|
||||
# acquire is cached under the old slug until the worker renames it
|
||||
for slug in {song_slug(parent_inp), song_slug(inp)}:
|
||||
work = cfg.work_dir / slug
|
||||
if (work / "meta.json").exists() and (work / "audio.wav").exists():
|
||||
return
|
||||
raise HTTPException(400, "the original video upload is no longer available; "
|
||||
"attach the video file again")
|
||||
|
||||
@app.post("/api/jobs/{job_id}/rebuild", status_code=201)
|
||||
async def rebuild(job_id: str, title: str = Form(...), artist: str = Form(...),
|
||||
lyrics: str = Form(...), youtube_url: str | None = Form(None),
|
||||
genre: str | None = Form(None), year: int | None = Form(None),
|
||||
language: str | None = Form(None), bpm: float | None = Form(None),
|
||||
video: UploadFile | None = File(None),
|
||||
cover: UploadFile | None = File(None)):
|
||||
parent = _finished_job(job_id)
|
||||
# a cleared field arrives as "" from some clients; "" and None must not
|
||||
# look like different inputs to plan()
|
||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||
language or None)
|
||||
has_video = bool(video and video.filename)
|
||||
if youtube_url and has_video:
|
||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||
if bpm is not None and (not math.isfinite(bpm) or bpm <= 0):
|
||||
raise HTTPException(400, f"bpm must be a finite value > 0, got {bpm!r}")
|
||||
|
||||
new_id = uuid.uuid4().hex
|
||||
video_path, cover_path = await _store_uploads(cfg, new_id, video, cover)
|
||||
if not youtube_url and not has_video:
|
||||
youtube_url, video_path = parent.inp.youtube_url, parent.inp.video_path
|
||||
if cover_path is None:
|
||||
cover_path = parent.inp.cover_path
|
||||
|
||||
inp = SongInput(title=title, artist=artist, lyrics_text=lyrics,
|
||||
youtube_url=youtube_url, video_path=video_path,
|
||||
genre=genre, year=year, language=language,
|
||||
cover_path=cover_path, bpm_override=bpm)
|
||||
_require_source(parent.inp, inp, rebuild_policy.plan(parent.inp, inp))
|
||||
return _enqueue(new_id, inp, rebuilt_from=job_id)
|
||||
|
||||
@app.get("/api/stages")
|
||||
def list_stages():
|
||||
return {"stages": list(STAGE_ORDER)}
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs():
|
||||
with lock:
|
||||
return [{"id": j.id, "title": j.title, "artist": j.artist,
|
||||
"status": j.status, "stage": j.stage,
|
||||
"created_at": j.created_at, "started_at": j.started_at,
|
||||
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from}
|
||||
for j in (jobs[i] for i in reversed(order))]
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: str):
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
return {"id": job.id, "title": job.title, "artist": job.artist,
|
||||
"status": job.status, "stage": job.stage,
|
||||
"log": job.log[-100:], "output_dir": job.output_dir,
|
||||
"warnings": list(job.warnings), "error": job.error,
|
||||
"created_at": job.created_at, "started_at": job.started_at,
|
||||
"finished_at": job.finished_at, "rebuilt_from": job.rebuilt_from}
|
||||
|
||||
@app.get("/api/jobs/{job_id}/input")
|
||||
def get_input(job_id: str):
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
inp = job.inp
|
||||
# keys are the form's field names so the page can prefill by name;
|
||||
# paths are reduced to filenames, which is all the browser can use.
|
||||
return {"title": inp.title, "artist": inp.artist, "lyrics": inp.lyrics_text,
|
||||
"youtube_url": inp.youtube_url, "genre": inp.genre, "year": inp.year,
|
||||
"language": inp.language, "bpm": inp.bpm_override,
|
||||
"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.delete("/api/jobs/{job_id}", status_code=204)
|
||||
def delete_job(job_id: str):
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
if job.status in ("queued", "running"):
|
||||
raise HTTPException(409, "job is still in progress")
|
||||
slug = song_slug(job.inp)
|
||||
if any(other.status in ("queued", "running") and song_slug(other.inp) == slug
|
||||
for other_id, other in jobs.items() if other_id != job_id):
|
||||
raise HTTPException(409, "another job for this song is in progress")
|
||||
jobs.pop(job_id, None)
|
||||
if job_id in order:
|
||||
order.remove(job_id)
|
||||
shutil.rmtree(cfg.work_dir / slug, ignore_errors=True)
|
||||
shutil.rmtree(cfg.output_dir / slug, ignore_errors=True)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def serve(cfg: Config) -> int:
|
||||
import uvicorn
|
||||
uvicorn.run(create_app(cfg), host=cfg.bind_host, port=cfg.port)
|
||||
return 0
|
||||
@@ -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()
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
#TITLE:Sanctuary
|
||||
#ARTIST:Utada Hikaru
|
||||
#LANGUAGE:English
|
||||
#YEAR:2009
|
||||
#CREATOR:Subkei
|
||||
#GENRE:J-Pop
|
||||
#COVER:Utada Hikaru - Sanctuary [CO].jpg
|
||||
#MP3:Utada Hikaru - Sanctuary.mp3
|
||||
#VIDEO:Utada Hikaru - Sanctuary.mp4
|
||||
#BPM:217,98
|
||||
#GAP:4880
|
||||
#MEDLEYSTARTBEAT:2471
|
||||
#MEDLEYENDBEAT:2990
|
||||
: 0 3 16 ah
|
||||
: 5 3 20 ah
|
||||
: 11 3 23 ah
|
||||
: 17 3 16 ah
|
||||
: 22 28 15 ah
|
||||
: 51 2 16 ah
|
||||
: 54 31 16 ~
|
||||
- 95
|
||||
F 113 3 18 wonk
|
||||
F 117 3 21 uoy
|
||||
F 121 3 21 naht
|
||||
F 125 3 21 snoit
|
||||
F 129 3 21 cef
|
||||
F 133 3 18 fa
|
||||
F 137 3 18 erom
|
||||
F 144 2 18 deen
|
||||
F 147 4 18 I
|
||||
- 180
|
||||
: 425 3 16 In
|
||||
: 429 3 21 you
|
||||
: 433 2 20 ~
|
||||
: 436 4 16 ~
|
||||
: 441 2 18 and
|
||||
: 445 4 20 I
|
||||
: 451 4 18 ~
|
||||
- 459
|
||||
: 461 3 16 there's
|
||||
: 465 3 16 a
|
||||
: 469 5 20 new
|
||||
: 475 4 23 land
|
||||
: 481 4 16 ~
|
||||
: 487 27 15 ~
|
||||
: 515 4 16 ~
|
||||
: 521 17 16 ~
|
||||
: 539 1 18 ~
|
||||
: 541 3 20 ~
|
||||
- 547
|
||||
: 549 1 16 An
|
||||
: 551 2 18 ~
|
||||
: 556 3 18 gels
|
||||
: 560 4 18 in
|
||||
: 565 7 20 flight
|
||||
: 573 2 18 ~
|
||||
- 576
|
||||
F 578 2 18 wonk
|
||||
F 581 3 21 uoy
|
||||
F 585 3 21 naht
|
||||
F 589 3 21 snoit
|
||||
F 593 3 21 cef
|
||||
F 597 3 18 fa
|
||||
F 601 3 18 erom
|
||||
F 608 2 18 deen
|
||||
F 611 3 18 I
|
||||
- 615
|
||||
: 617 1 16 My
|
||||
: 620 1 21 sanc
|
||||
: 624 2 20 tu
|
||||
: 628 4 16 a
|
||||
: 634 8 18 ry
|
||||
- 646
|
||||
: 648 3 16 my
|
||||
: 653 1 21 sanc
|
||||
: 657 2 20 tu
|
||||
: 661 3 16 a
|
||||
: 665 4 16 ry
|
||||
: 670 2 18 ~
|
||||
F 674 4 13 yeah
|
||||
- 680
|
||||
: 682 2 16 Where
|
||||
: 686 1 21 fears
|
||||
: 689 1 20 ~
|
||||
: 691 5 16 ~
|
||||
: 697 1 18 and
|
||||
* 701 4 20 lies
|
||||
* 707 4 18 ~
|
||||
- 715
|
||||
: 717 3 16 melt
|
||||
: 721 3 16 a
|
||||
: 725 4 20 way
|
||||
: 732 4 23 ~
|
||||
: 737 3 16 ~
|
||||
: 742 28 15 ~
|
||||
: 771 3 16 ~
|
||||
: 775 19 16 ~
|
||||
: 795 1 18 ~
|
||||
: 797 3 20 ~
|
||||
- 804
|
||||
: 806 1 16 Mu
|
||||
: 808 2 18 ~
|
||||
: 812 3 18 sic
|
||||
: 817 3 18 will
|
||||
: 822 7 20 tie
|
||||
: 830 2 18 ~
|
||||
- 833
|
||||
F 835 1 18 wonk
|
||||
F 837 3 21 uoy
|
||||
F 841 3 21 naht
|
||||
F 845 3 21 snoit
|
||||
F 849 3 21 cef
|
||||
F 853 3 18 fa
|
||||
F 857 3 18 erom
|
||||
F 864 2 18 deen
|
||||
F 867 3 18 I
|
||||
- 871
|
||||
: 873 2 16 What's
|
||||
: 877 1 21 left
|
||||
: 881 1 20 of
|
||||
: 884 3 16 me
|
||||
: 888 1 16 ~
|
||||
: 890 7 18 ~
|
||||
- 903
|
||||
: 905 2 16 What's
|
||||
: 909 1 21 left
|
||||
: 913 1 20 of
|
||||
: 916 3 16 me
|
||||
: 921 2 16 ~
|
||||
: 925 3 18 ~
|
||||
: 933 24 13 now?
|
||||
- 986
|
||||
: 1069 3 13 I
|
||||
: 1074 6 16 watch
|
||||
: 1082 3 16 you
|
||||
: 1087 9 18 ~
|
||||
- 1102
|
||||
: 1105 3 18 fast
|
||||
: 1109 3 20 ~
|
||||
* 1117 2 13 a
|
||||
* 1122 5 16 sleep
|
||||
: 1129 3 18 ~
|
||||
: 1133 15 16 ~
|
||||
- 1162
|
||||
: 1189 3 20 All
|
||||
: 1193 6 21 ~
|
||||
: 1201 3 18 I
|
||||
: 1205 6 20 ~
|
||||
: 1213 5 16 fear
|
||||
: 1220 20 13 ~
|
||||
- 1246
|
||||
: 1249 2 11 means
|
||||
: 1255 7 13 no
|
||||
: 1266 5 18 thing
|
||||
: 1273 41 20 ~
|
||||
- 1319
|
||||
: 1321 2 16 In
|
||||
: 1325 2 21 you
|
||||
: 1328 2 20 ~
|
||||
: 1332 3 16 ~
|
||||
: 1337 2 18 and
|
||||
: 1341 4 20 I
|
||||
: 1347 4 18 ~
|
||||
- 1355
|
||||
: 1357 3 16 there's
|
||||
: 1361 3 16 a
|
||||
* 1365 5 20 new
|
||||
: 1371 4 23 land
|
||||
: 1377 4 16 ~
|
||||
: 1383 27 15 ~
|
||||
: 1411 4 16 ~
|
||||
: 1417 17 16 ~
|
||||
: 1435 1 18 ~
|
||||
: 1437 3 20 ~
|
||||
- 1442
|
||||
: 1444 1 16 An
|
||||
: 1446 2 18 ~
|
||||
: 1452 3 18 gels
|
||||
: 1457 3 18 in
|
||||
: 1462 6 20 flight
|
||||
: 1469 2 18 ~
|
||||
- 1472
|
||||
F 1474 1 18 wonk
|
||||
F 1476 3 21 uoy
|
||||
F 1480 3 21 naht
|
||||
F 1484 3 21 snoit
|
||||
F 1488 3 21 cef
|
||||
F 1492 3 18 fa
|
||||
F 1496 3 18 erom
|
||||
F 1503 2 18 deen
|
||||
F 1506 3 18 I
|
||||
- 1511
|
||||
: 1513 1 16 My
|
||||
: 1516 1 21 sanc
|
||||
: 1520 2 20 tu
|
||||
: 1524 4 16 a
|
||||
: 1530 8 18 ry
|
||||
- 1541
|
||||
: 1543 3 16 my
|
||||
: 1548 1 21 sanc
|
||||
: 1552 2 20 tu
|
||||
: 1556 3 16 a
|
||||
: 1560 4 16 ry
|
||||
: 1565 2 18 ~
|
||||
F 1570 3 13 yeah
|
||||
- 1575
|
||||
: 1577 2 16 Where
|
||||
* 1581 1 21 fears
|
||||
* 1584 1 20 ~
|
||||
: 1586 5 16 ~
|
||||
: 1592 1 18 and
|
||||
: 1596 4 20 lies
|
||||
: 1602 4 18 ~
|
||||
- 1611
|
||||
: 1613 3 16 melt
|
||||
: 1617 3 16 a
|
||||
: 1621 4 20 way
|
||||
: 1628 4 23 ~
|
||||
: 1633 3 16 ~
|
||||
: 1638 27 15 ~
|
||||
: 1666 4 16 ~
|
||||
: 1672 18 16 ~
|
||||
: 1691 1 18 ~
|
||||
: 1693 3 20 ~
|
||||
- 1699
|
||||
: 1701 1 16 Mu
|
||||
: 1703 2 18 ~
|
||||
: 1707 3 18 sic
|
||||
: 1712 3 18 will
|
||||
: 1717 7 20 tie
|
||||
: 1725 2 18 ~
|
||||
- 1728
|
||||
F 1730 2 18 wonk
|
||||
F 1733 3 21 uoy
|
||||
F 1737 3 21 naht
|
||||
F 1741 3 21 snoit
|
||||
F 1745 3 21 cef
|
||||
F 1749 3 18 fa
|
||||
F 1753 3 18 erom
|
||||
F 1760 2 18 deen
|
||||
F 1763 3 18 I
|
||||
- 1767
|
||||
: 1769 2 16 What's
|
||||
: 1773 1 21 left
|
||||
: 1777 1 20 of
|
||||
: 1780 3 16 me
|
||||
: 1785 1 16 ~
|
||||
: 1787 7 18 ~
|
||||
- 1798
|
||||
: 1800 2 16 What's
|
||||
: 1804 1 21 left
|
||||
* 1808 1 20 of
|
||||
* 1811 3 16 me
|
||||
: 1815 1 16 ~
|
||||
: 1817 7 18 ~
|
||||
- 1831
|
||||
F 1834 2 21 snwod
|
||||
F 1837 2 20 dna
|
||||
F 1841 3 18 spu
|
||||
F 1845 3 20 y
|
||||
F 1849 3 20 nam
|
||||
F 1853 3 18 os
|
||||
- 1870
|
||||
: 1897 3 20 My
|
||||
* 1901 2 20 heart's
|
||||
: 1904 1 18 a
|
||||
: 1906 3 20 bat
|
||||
: 1910 3 21 tle
|
||||
: 1914 5 21 ground
|
||||
- 1926
|
||||
F 1929 2 16 snoit
|
||||
F 1932 6 16 om
|
||||
F 1941 2 18 e
|
||||
F 1945 2 18 ~
|
||||
F 1948 2 18 eurt
|
||||
F 1952 2 18 deen
|
||||
F 1955 2 16 I
|
||||
- 1971
|
||||
F 1987 3 18 wonk
|
||||
F 1991 3 21 uoy
|
||||
F 1995 3 21 naht
|
||||
F 1999 3 21 snoit
|
||||
F 2003 3 21 cef
|
||||
F 2007 3 18 fa
|
||||
F 2011 3 18 erom
|
||||
F 2018 2 18 deen
|
||||
F 2021 3 18 I
|
||||
- 2038
|
||||
F 2059 2 16 snoit
|
||||
F 2062 6 16 om
|
||||
F 2070 3 18 e
|
||||
F 2075 2 18 ~
|
||||
F 2078 2 18 eurt
|
||||
F 2082 2 18 deen
|
||||
F 2085 2 16 I
|
||||
- 2116
|
||||
: 2219 3 13 You
|
||||
: 2224 6 16 show
|
||||
: 2232 3 16 me
|
||||
: 2237 7 18 ~
|
||||
- 2252
|
||||
: 2255 3 18 how
|
||||
: 2259 3 20 ~
|
||||
: 2267 2 13 to
|
||||
: 2272 5 16 see
|
||||
- 2282
|
||||
* 2284 3 13 That
|
||||
* 2289 6 16 no
|
||||
* 2297 3 16 thing
|
||||
* 2302 5 18 ~
|
||||
- 2313
|
||||
: 2316 3 13 is
|
||||
: 2321 3 16 whole
|
||||
: 2325 2 15 ~
|
||||
: 2328 2 13 and
|
||||
: 2331 1 13 ~
|
||||
: 2333 2 15 ~
|
||||
- 2338
|
||||
: 2340 3 20 no
|
||||
: 2344 6 21 ~
|
||||
: 2352 3 18 ~
|
||||
: 2356 6 20 ~
|
||||
: 2364 5 16 thing
|
||||
: 2371 12 13 ~
|
||||
- 2395
|
||||
: 2399 2 11 is
|
||||
: 2403 9 13 bro
|
||||
: 2416 5 18 ken
|
||||
: 2423 41 20 ~
|
||||
- 2470
|
||||
: 2472 2 16 In
|
||||
* 2476 2 21 you
|
||||
* 2479 2 20 ~
|
||||
* 2483 3 16 ~
|
||||
: 2488 2 18 and
|
||||
: 2492 4 20 I
|
||||
: 2499 3 18 ~
|
||||
- 2506
|
||||
: 2508 3 16 There's
|
||||
: 2512 3 16 a
|
||||
: 2516 5 20 new
|
||||
: 2522 4 23 land
|
||||
: 2528 4 16 ~
|
||||
: 2534 27 15 ~
|
||||
: 2562 4 16 ~
|
||||
: 2568 17 16 ~
|
||||
: 2586 1 18 ~
|
||||
: 2588 3 20 ~
|
||||
- 2593
|
||||
: 2595 1 16 An
|
||||
: 2597 2 18 ~
|
||||
: 2603 3 18 gels
|
||||
: 2608 3 18 in
|
||||
: 2613 6 20 flight
|
||||
: 2620 2 18 ~
|
||||
- 2623
|
||||
F 2625 2 18 wonk
|
||||
F 2628 3 21 uoy
|
||||
F 2632 3 21 naht
|
||||
F 2636 3 21 snoit
|
||||
F 2640 3 21 cef
|
||||
F 2644 3 18 fa
|
||||
F 2648 3 18 erom
|
||||
F 2655 2 18 deen
|
||||
F 2658 2 18 I
|
||||
- 2661
|
||||
: 2663 2 16 My
|
||||
: 2668 1 21 sanc
|
||||
: 2672 1 20 tu
|
||||
: 2675 4 16 a
|
||||
: 2681 7 18 ry
|
||||
- 2693
|
||||
: 2695 3 16 My
|
||||
: 2700 1 21 sanc
|
||||
: 2704 2 20 tu
|
||||
: 2708 3 16 a
|
||||
: 2712 4 16 ry
|
||||
: 2717 2 18 ~
|
||||
F 2721 4 13 yeah
|
||||
- 2726
|
||||
: 2728 2 16 Where
|
||||
: 2732 1 21 fears
|
||||
: 2735 1 20 ~
|
||||
: 2737 5 16 ~
|
||||
: 2743 1 18 and
|
||||
: 2747 4 20 lies
|
||||
: 2754 3 18 ~
|
||||
- 2761
|
||||
: 2763 3 16 melt
|
||||
: 2767 3 16 a
|
||||
: 2771 4 20 way
|
||||
: 2778 4 23 ~
|
||||
: 2783 4 16 ~
|
||||
: 2789 27 15 ~
|
||||
: 2817 4 16 ~
|
||||
: 2823 17 16 ~
|
||||
: 2841 1 18 ~
|
||||
: 2843 3 20 ~
|
||||
- 2849
|
||||
: 2851 1 16 Mu
|
||||
: 2853 2 18 ~
|
||||
: 2857 3 18 sic
|
||||
* 2862 3 18 will
|
||||
* 2868 7 20 tie
|
||||
: 2877 2 18 ~
|
||||
- 2880
|
||||
F 2882 1 18 wonk
|
||||
F 2884 3 21 uoy
|
||||
F 2888 3 21 naht
|
||||
F 2892 3 21 snoit
|
||||
F 2896 3 21 cef
|
||||
F 2900 3 18 fa
|
||||
F 2904 3 18 erom
|
||||
F 2911 2 18 deen
|
||||
F 2914 2 18 I
|
||||
- 2917
|
||||
: 2919 2 16 What's
|
||||
: 2923 1 21 left
|
||||
: 2927 1 20 of
|
||||
: 2930 3 16 me
|
||||
: 2935 1 16 ~
|
||||
: 2937 7 18 ~
|
||||
- 2949
|
||||
: 2951 2 16 What's
|
||||
: 2955 1 21 left
|
||||
* 2959 1 20 of
|
||||
* 2962 3 16 me
|
||||
* 2967 2 16 ~
|
||||
: 2971 3 18 ~
|
||||
: 2979 12 13 now?
|
||||
- 3005
|
||||
: 3026 2 11 uh
|
||||
: 3029 2 13 ~
|
||||
: 3032 3 16 ~
|
||||
: 3036 8 15 ~
|
||||
: 3045 4 18 ~
|
||||
: 3051 4 16 ~
|
||||
: 3056 3 15 ~
|
||||
: 3060 3 13 ~
|
||||
- 3066
|
||||
: 3068 1 16 My
|
||||
: 3070 2 18 ~
|
||||
: 3074 12 18 fears
|
||||
- 3096
|
||||
: 3100 1 16 my
|
||||
: 3102 2 18 ~
|
||||
: 3107 3 18 lies
|
||||
: 3111 2 16 ~
|
||||
: 3114 1 13 ~
|
||||
- 3129
|
||||
* 3164 3 16 melt
|
||||
* 3169 3 16 a
|
||||
* 3173 4 20 way
|
||||
* 3179 4 23 ~
|
||||
* 3185 3 16 ~
|
||||
* 3190 27 15 ~
|
||||
* 3218 3 16 ~
|
||||
* 3223 19 16 ~
|
||||
- 3248
|
||||
: 3250 4 18 ah
|
||||
: 3256 126 20 ~
|
||||
- 3392
|
||||
F 3410 1 18 wonk
|
||||
F 3412 3 21 uoy
|
||||
F 3416 3 21 naht
|
||||
F 3420 3 21 snoit
|
||||
F 3424 3 21 cef
|
||||
F 3428 3 18 fa
|
||||
F 3432 3 18 erom
|
||||
F 3439 2 18 deen
|
||||
F 3442 3 18 I
|
||||
E
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
#TITLE:Sweet Child O´Mine
|
||||
#ARTIST:Guns N' Roses
|
||||
#LANGUAGE:English
|
||||
#GENRE:Rock
|
||||
#YEAR:1987
|
||||
#MP3:Guns N' Roses - Sweet Child O´Mine.mp3
|
||||
#COVER:Guns N' Roses - Sweet Child O´Mine.jpg
|
||||
#VIDEO:Guns N' Roses - Sweet Child O´Mine.webm
|
||||
#CREATOR:sanicsip
|
||||
#FIXER:Pacayaka
|
||||
#BPM:256
|
||||
#GAP:49673
|
||||
#VIDEOGAP:0,3
|
||||
#START:13
|
||||
#MEDLEYSTARTBEAT:1564
|
||||
#MEDLEYENDBEAT:2057
|
||||
: 0 3 8 She's
|
||||
: 5 2 8 got
|
||||
: 8 1 8 a
|
||||
: 13 2 6 smile
|
||||
: 16 8 8 ~
|
||||
: 25 1 6 that
|
||||
: 27 2 5 it
|
||||
: 32 7 6 seems
|
||||
: 41 1 8 to
|
||||
: 45 1 5 me
|
||||
: 47 5 4 ~
|
||||
: 53 1 3 ~
|
||||
- 55
|
||||
: 57 1 1 re
|
||||
: 60 7 6 minds
|
||||
: 69 2 5 me
|
||||
: 73 1 1 of
|
||||
: 76 1 1 child
|
||||
: 79 9 6 ~
|
||||
: 91 5 6 hood
|
||||
- 99
|
||||
: 101 3 6 mem
|
||||
: 105 4 5 o
|
||||
: 111 6 3 ries
|
||||
: 118 1 1 ~
|
||||
- 120
|
||||
: 122 3 1 where
|
||||
: 126 2 4 eve
|
||||
: 129 5 6 ~
|
||||
: 135 2 5 ~
|
||||
: 138 1 1 ry
|
||||
: 142 2 4 thing
|
||||
: 145 4 6 ~
|
||||
: 150 2 5 was
|
||||
: 154 1 1 as
|
||||
: 158 2 5 fresh
|
||||
: 161 4 6 ~
|
||||
: 166 3 5 as
|
||||
: 171 1 1 the
|
||||
: 174 5 6 bright
|
||||
: 181 5 6 blue
|
||||
: 190 8 6 sky
|
||||
: 200 5 5 ~
|
||||
- 222
|
||||
: 258 4 8 Now
|
||||
: 264 4 8 and
|
||||
: 271 10 8 then
|
||||
: 283 2 6 when
|
||||
: 287 2 5 I
|
||||
: 291 3 6 see
|
||||
: 295 1 5 her
|
||||
: 297 1 6 ~
|
||||
: 299 2 8 ~
|
||||
: 304 8 5 face
|
||||
- 313
|
||||
: 315 3 6 she
|
||||
: 319 3 8 ~
|
||||
: 324 3 6 takes
|
||||
: 330 2 5 me
|
||||
: 334 1 1 a
|
||||
: 338 2 3 way
|
||||
: 341 3 6 ~
|
||||
: 345 2 5 to
|
||||
: 349 3 1 that
|
||||
- 354
|
||||
: 356 3 6 spe
|
||||
: 360 3 6 cial
|
||||
: 364 2 8 ~
|
||||
: 369 1 6 place
|
||||
: 371 4 5 ~
|
||||
: 377 2 5 and
|
||||
: 381 1 6 if
|
||||
: 384 2 5 I
|
||||
: 389 3 6 stare
|
||||
: 393 2 5 ~
|
||||
: 398 3 6 too
|
||||
: 402 2 5 ~
|
||||
: 405 4 6 long
|
||||
: 410 1 5 ~
|
||||
- 415
|
||||
: 417 1 1 I'd
|
||||
: 420 1 1 prob
|
||||
: 423 1 6 a
|
||||
: 425 2 6 bly
|
||||
: 429 5 6 break
|
||||
: 436 5 6 down
|
||||
: 442 2 6 and
|
||||
* 445 1 8 ~
|
||||
: 450 7 6 cry
|
||||
: 458 8 5 ~
|
||||
- 483
|
||||
: 512 3 6 Whoah
|
||||
: 517 24 8 ~
|
||||
: 543 3 10 oh
|
||||
: 548 2 8 ooh
|
||||
: 551 3 8 whoah
|
||||
: 555 1 6 ~
|
||||
: 559 3 6 sweet
|
||||
: 563 1 5 ~
|
||||
: 567 2 5 child
|
||||
: 570 3 6 ~
|
||||
: 575 1 6 o'
|
||||
: 578 8 6 mine
|
||||
: 587 12 5 ~
|
||||
- 616
|
||||
: 643 1 6 Whoah
|
||||
: 645 11 8 ~
|
||||
: 659 7 6 oh
|
||||
: 668 7 5 oh
|
||||
: 677 2 5 oh
|
||||
: 680 3 6 ~
|
||||
: 684 3 5 ~
|
||||
: 689 5 1 sweet
|
||||
: 697 7 3 love
|
||||
: 706 2 5 of
|
||||
: 711 26 1 mine
|
||||
- 771
|
||||
: 1041 5 8 She's
|
||||
: 1048 6 8 got
|
||||
: 1056 2 6 eyes
|
||||
: 1059 7 8 ~
|
||||
: 1068 3 6 of
|
||||
: 1073 1 5 the
|
||||
: 1076 3 6 blu
|
||||
: 1080 3 6 est
|
||||
: 1084 2 8 ~
|
||||
: 1089 11 3 skies
|
||||
: 1102 5 1 as
|
||||
: 1110 5 6 if
|
||||
: 1118 7 6 they
|
||||
- 1128
|
||||
: 1130 4 6 thought
|
||||
: 1135 2 5 of
|
||||
: 1140 2 5 rain
|
||||
: 1143 4 6 ~
|
||||
: 1148 4 5 ~
|
||||
- 1162
|
||||
* 1171 4 8 I
|
||||
: 1176 2 5 hate
|
||||
: 1179 3 6 ~
|
||||
: 1184 2 6 to
|
||||
: 1187 3 5 ~
|
||||
: 1192 2 6 look
|
||||
: 1196 10 6 in
|
||||
: 1208 3 6 to
|
||||
: 1213 6 5 those
|
||||
: 1221 2 5 eyes
|
||||
: 1224 7 6 ~
|
||||
: 1233 4 6 and
|
||||
- 1239
|
||||
: 1241 4 6 see
|
||||
: 1246 1 5 ~
|
||||
: 1249 3 1 an
|
||||
: 1253 1 3 ounce
|
||||
: 1256 5 6 ~
|
||||
: 1262 1 5 ~
|
||||
: 1265 2 1 of
|
||||
: 1269 15 1 pain
|
||||
- 1294
|
||||
: 1301 3 6 Her
|
||||
: 1305 2 6 hair
|
||||
: 1308 5 8 ~
|
||||
: 1314 2 8 re
|
||||
: 1318 6 8 minds
|
||||
: 1326 4 8 me
|
||||
: 1331 3 6 of
|
||||
: 1335 2 5 a
|
||||
: 1339 6 6 warm
|
||||
: 1347 2 6 safe
|
||||
: 1350 3 8 ~
|
||||
: 1355 6 5 place
|
||||
- 1362
|
||||
: 1364 6 1 where
|
||||
: 1372 3 6 as
|
||||
: 1376 2 5 ~
|
||||
: 1380 2 1 a
|
||||
: 1384 2 5 child
|
||||
: 1387 7 6 ~
|
||||
* 1395 3 8 I'd
|
||||
: 1399 5 6 hide
|
||||
: 1405 1 5 ~
|
||||
: 1408 11 1 ~
|
||||
- 1428
|
||||
: 1431 1 6 and
|
||||
: 1433 2 8 ~
|
||||
: 1437 2 5 pray
|
||||
: 1440 3 6 ~
|
||||
: 1445 3 6 for
|
||||
: 1449 2 5 the
|
||||
: 1453 7 6 thun
|
||||
: 1461 6 6 der
|
||||
- 1471
|
||||
: 1473 4 6 and
|
||||
: 1478 2 5 the
|
||||
: 1482 1 5 rain
|
||||
: 1484 6 6 ~
|
||||
- 1491
|
||||
: 1493 1 5 to
|
||||
: 1496 8 6 qui
|
||||
: 1505 2 5 et
|
||||
: 1509 2 1 ly
|
||||
: 1514 1 5 pass
|
||||
: 1516 6 6 ~
|
||||
* 1526 2 8 me
|
||||
: 1529 5 6 by
|
||||
: 1535 1 5 ~
|
||||
: 1538 10 1 ~
|
||||
- 1558
|
||||
: 1566 3 6 Whoah
|
||||
: 1570 19 8 ~
|
||||
: 1591 3 10 oh
|
||||
: 1595 3 8 ooh
|
||||
: 1600 2 8 whoah
|
||||
: 1603 1 6 ~
|
||||
: 1607 4 6 sweet
|
||||
: 1612 1 5 ~
|
||||
: 1615 3 5 child
|
||||
: 1620 2 6 ~
|
||||
: 1623 1 6 o'
|
||||
: 1629 7 6 mine
|
||||
: 1637 11 5 ~
|
||||
- 1658
|
||||
: 1675 2 12 Whoah
|
||||
: 1678 20 13 ~
|
||||
: 1699 1 12 ~
|
||||
: 1701 5 13 ~
|
||||
: 1711 1 8 oh
|
||||
: 1713 4 10 ~
|
||||
: 1719 7 8 oh
|
||||
: 1727 3 8 oh
|
||||
: 1731 3 10 ~
|
||||
: 1736 1 8 ~
|
||||
- 1737
|
||||
: 1739 3 6 Sweet
|
||||
: 1743 1 5 ~
|
||||
: 1748 7 6 love
|
||||
* 1757 2 8 of
|
||||
: 1761 4 6 mine
|
||||
: 1766 1 5 ~
|
||||
: 1770 16 1 ~
|
||||
- 1803
|
||||
* 1829 8 8 Oh
|
||||
* 1842 4 10 oh
|
||||
* 1850 4 13 oh
|
||||
* 1858 2 13 oh
|
||||
* 1861 5 15 ~
|
||||
* 1867 1 13 ~
|
||||
- 1869
|
||||
: 1871 3 13 Sweet
|
||||
: 1875 1 10 ~
|
||||
: 1880 1 10 child
|
||||
: 1882 3 13 ~
|
||||
: 1886 1 10 ~
|
||||
: 1888 2 8 o'
|
||||
: 1892 2 8 mine
|
||||
: 1895 5 10 ~
|
||||
: 1901 2 13 ~
|
||||
: 1905 2 10 ~
|
||||
: 1909 9 8 ~
|
||||
- 1924
|
||||
: 1926 2 8 Ooh
|
||||
: 1929 4 10 ~
|
||||
: 1934 2 8 ~
|
||||
: 1937 3 5 ~
|
||||
: 1942 3 8 yeah
|
||||
: 1951 1 8 yeah
|
||||
: 1953 5 10 ~
|
||||
: 1959 2 8 ~
|
||||
- 1962
|
||||
: 1964 1 8 Ooh
|
||||
: 1966 7 10 ~
|
||||
: 1974 7 8 ~
|
||||
: 1982 7 6 ~
|
||||
: 1991 2 5 ooh
|
||||
: 1994 4 6 ~
|
||||
: 1999 1 5 ~
|
||||
- 2002
|
||||
: 2004 3 1 sweet
|
||||
: 2010 9 3 love
|
||||
: 2021 1 5 of
|
||||
: 2025 1 3 mine
|
||||
: 2027 1 5 ~
|
||||
: 2029 2 3 ~
|
||||
: 2032 25 1 ~
|
||||
- 2091
|
||||
: 3176 4 3 Where
|
||||
: 3181 3 1 do
|
||||
: 3185 2 -2 we
|
||||
: 3190 8 -4 go?
|
||||
- 3204
|
||||
: 3207 1 1 Where
|
||||
: 3209 4 3 ~
|
||||
: 3214 2 1 do
|
||||
: 3217 2 -2 we
|
||||
: 3221 6 -4 go
|
||||
: 3229 8 -6 now?
|
||||
- 3239
|
||||
: 3241 3 3 Where
|
||||
: 3245 3 1 do
|
||||
: 3249 2 -2 we
|
||||
: 3253 11 -4 go?
|
||||
- 3274
|
||||
: 3282 1 -4 Ooh
|
||||
: 3284 4 -2 ~
|
||||
: 3289 3 -4 oh
|
||||
: 3293 3 -2 ~
|
||||
: 3297 3 -4 oh
|
||||
: 3301 1 -6 ~
|
||||
- 3304
|
||||
: 3306 2 -2 Where
|
||||
: 3309 2 -4 do
|
||||
: 3312 2 -6 we
|
||||
: 3316 4 -2 go
|
||||
: 3321 4 -4 ~
|
||||
: 3326 6 -2 ~?
|
||||
- 3342
|
||||
: 3355 5 -4 Oh
|
||||
: 3362 7 -2 ~
|
||||
: 3370 2 -2 where
|
||||
: 3373 2 -2 do
|
||||
: 3376 2 -2 we
|
||||
: 3380 3 -4 go
|
||||
: 3384 1 -6 ~
|
||||
: 3387 13 -9 now?
|
||||
- 3410
|
||||
: 3430 1 -6 Where
|
||||
: 3433 1 -2 ~
|
||||
: 3435 2 -2 do
|
||||
: 3439 2 1 we
|
||||
: 3443 3 -4 go
|
||||
: 3447 8 -2 ~?
|
||||
- 3465
|
||||
: 3474 7 3 Ooh
|
||||
: 3483 12 1 oh
|
||||
: 3497 2 3 where
|
||||
: 3500 2 1 do
|
||||
: 3503 2 -2 we
|
||||
: 3507 6 -4 go
|
||||
: 3514 13 -6 now?
|
||||
- 3537
|
||||
: 3558 1 -4 ay
|
||||
: 3560 6 -2 ~
|
||||
: 3567 1 -4 ay
|
||||
: 3569 5 -2 ~
|
||||
: 3575 1 -4 ay
|
||||
: 3577 5 -2 ~
|
||||
: 3583 1 -4 ay
|
||||
: 3585 5 -2 ~
|
||||
: 3591 1 -4 ay
|
||||
: 3593 6 -2 ~
|
||||
: 3600 1 -4 ay
|
||||
: 3602 5 -2 ~
|
||||
: 3608 2 -4 ay
|
||||
: 3611 2 -2 ~
|
||||
- 3614
|
||||
: 3616 4 10 Oh
|
||||
: 3623 1 13 where
|
||||
: 3625 1 15 ~
|
||||
: 3627 3 13 do
|
||||
: 3631 3 10 we
|
||||
: 3635 6 8 go
|
||||
: 3643 5 6 now?
|
||||
- 3649
|
||||
* 3651 2 6 ah
|
||||
* 3655 2 10 ah
|
||||
* 3659 2 13 ah
|
||||
* 3663 2 10 ah
|
||||
* 3666 1 8 ah
|
||||
* 3668 2 10 ah
|
||||
* 3673 2 8 ah
|
||||
* 3676 2 6 oh
|
||||
* 3680 2 8 ah
|
||||
* 3683 1 6 oh
|
||||
- 3684
|
||||
: 3686 1 8 where
|
||||
: 3688 2 10 ~
|
||||
: 3691 2 8 do
|
||||
: 3695 2 6 we
|
||||
: 3699 1 8 go
|
||||
: 3701 11 10 ~?
|
||||
- 3719
|
||||
* 3722 2 13 ah
|
||||
* 3725 12 15 ~
|
||||
* 3738 8 13 ~
|
||||
* 3748 1 10 oh
|
||||
* 3752 2 15 where
|
||||
* 3756 2 13 do
|
||||
* 3760 2 10 we
|
||||
* 3764 7 8 go
|
||||
* 3772 10 6 now?
|
||||
- 3792
|
||||
: 3816 3 10 Where
|
||||
: 3821 2 10 do
|
||||
: 3825 2 13 we
|
||||
: 3829 1 8 go
|
||||
: 3831 4 10 ~
|
||||
: 3837 1 13 ~
|
||||
: 3841 2 10 ~
|
||||
: 3845 1 13 ~
|
||||
: 3849 1 10 ~
|
||||
: 3851 1 8 ~
|
||||
: 3853 1 10 ~
|
||||
: 3855 1 8 ~
|
||||
: 3857 1 10 ~
|
||||
: 3859 1 8 ~
|
||||
: 3861 2 10 ~
|
||||
: 3865 2 8 ~
|
||||
: 3868 3 6 ~
|
||||
: 3872 3 8 ~
|
||||
: 3876 2 6 ~?
|
||||
- 3879
|
||||
: 3881 2 10 where
|
||||
: 3884 1 8 do
|
||||
: 3886 1 10 ~
|
||||
: 3888 2 10 we
|
||||
: 3892 3 8 go
|
||||
: 3897 1 6 ~
|
||||
: 3901 11 3 now?
|
||||
- 3922
|
||||
: 3943 1 8 Where
|
||||
: 3945 2 10 ~
|
||||
: 3949 2 8 do
|
||||
: 3953 2 6 we
|
||||
: 3957 2 10 go
|
||||
: 3960 2 8 ~
|
||||
: 3963 10 10 ~?
|
||||
- 3983
|
||||
: 3990 8 10 Whoah
|
||||
: 4000 3 13 ~
|
||||
: 4004 3 10 ~
|
||||
: 4008 3 10 where
|
||||
: 4012 3 13 do
|
||||
: 4017 2 10 we
|
||||
: 4021 5 8 go
|
||||
: 4028 10 6 now?
|
||||
- 4039
|
||||
* 4041 3 15 no
|
||||
* 4046 3 13 no
|
||||
* 4051 4 10 no
|
||||
* 4057 3 9 no
|
||||
* 4062 3 8 no
|
||||
* 4067 3 6 no
|
||||
* 4072 9 3 no
|
||||
- 4087
|
||||
: 4090 3 8 sweet
|
||||
: 4094 2 10 ~
|
||||
: 4101 3 8 child
|
||||
: 4105 2 6 ~
|
||||
: 4109 7 3 ~
|
||||
- 4122
|
||||
: 4124 3 10 sweet
|
||||
: 4128 1 8 ~
|
||||
: 4130 3 10 ~
|
||||
: 4137 3 10 child
|
||||
: 4142 2 13 ~
|
||||
: 4145 4 10 ~
|
||||
: 4150 2 13 ~
|
||||
: 4153 4 10 ~
|
||||
: 4158 1 8 ~
|
||||
: 4160 2 10 ~
|
||||
: 4163 2 8 ~
|
||||
: 4166 3 6 ~
|
||||
: 4171 2 10 ~
|
||||
: 4175 2 13 ~
|
||||
: 4179 3 10 ~
|
||||
: 4183 4 13 ~
|
||||
: 4188 4 15 ~
|
||||
: 4194 2 13 ~
|
||||
: 4199 1 10 ~
|
||||
- 4200
|
||||
: 4202 1 8 o'
|
||||
: 4204 2 10 ~
|
||||
: 4208 2 8 mine
|
||||
: 4211 2 6 ~
|
||||
: 4214 46 3 ~
|
||||
: 4262 33 1 ~
|
||||
: 4297 49 3 ~
|
||||
E
|
||||
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
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)
|
||||
cfg = Config(whisper_url="http://x/v1", work_dir=tmp_path)
|
||||
work = tmp_path / "A - T"
|
||||
work.mkdir()
|
||||
return Ctx(inp=inp, cfg=cfg, work=work, log=lambda m: None)
|
||||
|
||||
def test_local_video(tmp_path, monkeypatch):
|
||||
src = tmp_path / "in.mp4"
|
||||
src.write_bytes(b"fake")
|
||||
calls = []
|
||||
monkeypatch.setattr(acquire, "ffmpeg", lambda *a: calls.append(a))
|
||||
ctx = make_ctx(tmp_path, video_path=src)
|
||||
acquire.acquire_stage().run(ctx)
|
||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||
assert meta["video_file"] == "source_video.mp4"
|
||||
assert (ctx.work / "source_video.mp4").read_bytes() == b"fake"
|
||||
assert any("audio.wav" in str(a[-1]) for a in calls)
|
||||
|
||||
def test_youtube(tmp_path, monkeypatch):
|
||||
def fake_dl(url, dest, cfg, log):
|
||||
video = dest / "source_video.webm"
|
||||
video.write_bytes(b"v")
|
||||
thumb = dest / "source_video.jpg"
|
||||
thumb.write_bytes(b"t")
|
||||
return video, thumb
|
||||
monkeypatch.setattr(acquire, "download_youtube", fake_dl)
|
||||
monkeypatch.setattr(acquire, "ffmpeg", lambda *a: None)
|
||||
ctx = make_ctx(tmp_path, youtube_url="https://yt/x")
|
||||
acquire.acquire_stage().run(ctx)
|
||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||
assert meta["video_file"] == "source_video.webm"
|
||||
assert meta["thumbnail"] == "source_video.jpg"
|
||||
|
||||
def test_outputs_are_meta_and_audio(tmp_path):
|
||||
"""_require_source in web.py hardcodes these filenames; pin them here."""
|
||||
ctx = make_ctx(tmp_path, youtube_url="https://yt/x")
|
||||
outputs = acquire.acquire_stage().outputs(ctx)
|
||||
assert [p.name for p in outputs] == ["meta.json", "audio.wav"]
|
||||
|
||||
def test_requires_exactly_one_source(tmp_path):
|
||||
import pytest
|
||||
from songtrace.pipeline import PipelineError
|
||||
ctx = make_ctx(tmp_path)
|
||||
with pytest.raises(PipelineError):
|
||||
acquire.acquire_stage().run(ctx)
|
||||
|
||||
def test_classify_ordinary_webm_and_jpg(tmp_path):
|
||||
(tmp_path / "source_video.webm").write_bytes(b"v")
|
||||
(tmp_path / "source_video.jpg").write_bytes(b"t")
|
||||
video, thumb = acquire._classify_downloads(tmp_path)
|
||||
assert video.name == "source_video.webm"
|
||||
assert thumb.name == "source_video.jpg"
|
||||
|
||||
def test_classify_unexpected_thumbnail_extension(tmp_path):
|
||||
(tmp_path / "source_video.webm").write_bytes(b"v")
|
||||
(tmp_path / "source_video.avif").write_bytes(b"t")
|
||||
video, thumb = acquire._classify_downloads(tmp_path)
|
||||
assert video.name == "source_video.webm"
|
||||
assert thumb.name == "source_video.avif"
|
||||
|
||||
def test_classify_no_thumbnail(tmp_path):
|
||||
(tmp_path / "source_video.mp4").write_bytes(b"v")
|
||||
video, thumb = acquire._classify_downloads(tmp_path)
|
||||
assert video.name == "source_video.mp4"
|
||||
assert thumb is None
|
||||
|
||||
def test_classify_no_video_raises(tmp_path):
|
||||
import pytest
|
||||
from songtrace.pipeline import PipelineError
|
||||
(tmp_path / "source_video.jpg").write_bytes(b"t")
|
||||
with pytest.raises(PipelineError):
|
||||
acquire._classify_downloads(tmp_path)
|
||||
|
||||
def test_classify_picks_largest_video_when_multiple(tmp_path):
|
||||
(tmp_path / "source_video.f137.mp4").write_bytes(b"small")
|
||||
(tmp_path / "source_video.mp4").write_bytes(b"much larger contents here")
|
||||
video, _ = acquire._classify_downloads(tmp_path)
|
||||
assert video.name == "source_video.mp4"
|
||||
|
||||
def test_classify_ignores_partials_left_by_a_failed_attempt(tmp_path):
|
||||
(tmp_path / "source_video.webm").write_bytes(b"v")
|
||||
(tmp_path / "source_video.f137.mp4.part").write_bytes(b"a much bigger partial")
|
||||
(tmp_path / "source_video.f251.webm.ytdl").write_bytes(b"resume state")
|
||||
video, _ = acquire._classify_downloads(tmp_path)
|
||||
assert video.name == "source_video.webm"
|
||||
|
||||
def test_ydl_opts_default_client_adds_no_player_client(tmp_path):
|
||||
cfg = Config(whisper_url="http://x/v1")
|
||||
opts = acquire._ydl_opts(cfg, tmp_path, "default", print)
|
||||
assert "youtube" not in opts["extractor_args"]
|
||||
assert opts["retries"] and opts["fragment_retries"] and opts["extractor_retries"]
|
||||
|
||||
def test_ydl_opts_named_client_and_pot_provider(tmp_path):
|
||||
cfg = Config(whisper_url="http://x/v1",
|
||||
ytdlp_pot_provider_url="http://pot:4416",
|
||||
ytdlp_cookies=tmp_path / "cookies.txt")
|
||||
opts = acquire._ydl_opts(cfg, tmp_path, "tv", print)
|
||||
assert opts["extractor_args"]["youtube"] == {"player_client": ["tv"]}
|
||||
assert opts["extractor_args"]["youtubepot-bgutilhttp"] == {
|
||||
"base_url": ["http://pot:4416"]}
|
||||
assert opts["cookiefile"] == str(tmp_path / "cookies.txt")
|
||||
|
||||
def _fake_yt_dlp(monkeypatch, behaviour):
|
||||
"""Install a stub yt_dlp whose download calls `behaviour(opts)`."""
|
||||
import sys
|
||||
import types
|
||||
seen = []
|
||||
|
||||
class FakeYoutubeDL:
|
||||
def __init__(self, opts):
|
||||
self.opts = opts
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
def extract_info(self, url, download):
|
||||
seen.append(self.opts["extractor_args"].get("youtube"))
|
||||
behaviour(self.opts)
|
||||
|
||||
mod = types.ModuleType("yt_dlp")
|
||||
mod.YoutubeDL = FakeYoutubeDL
|
||||
mod.version = types.SimpleNamespace(__version__="2026.08.19")
|
||||
monkeypatch.setitem(sys.modules, "yt_dlp", mod)
|
||||
return seen
|
||||
|
||||
def test_download_falls_through_to_the_next_client(tmp_path, monkeypatch):
|
||||
def behaviour(opts):
|
||||
client = opts["extractor_args"].get("youtube")
|
||||
if client is None:
|
||||
raise RuntimeError("HTTP Error 403: Forbidden")
|
||||
(tmp_path / "source_video.webm").write_bytes(b"v")
|
||||
seen = _fake_yt_dlp(monkeypatch, behaviour)
|
||||
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
|
||||
logged = []
|
||||
video, thumb = acquire.download_youtube("https://yt/x", tmp_path, cfg,
|
||||
logged.append)
|
||||
assert video.name == "source_video.webm"
|
||||
assert thumb is None
|
||||
assert seen == [None, {"player_client": ["tv"]}]
|
||||
assert any("403" in m for m in logged)
|
||||
|
||||
def test_download_reports_version_and_clients_when_all_fail(tmp_path, monkeypatch):
|
||||
import pytest
|
||||
from songtrace.pipeline import PipelineError
|
||||
def behaviour(opts):
|
||||
raise RuntimeError("HTTP Error 403: Forbidden")
|
||||
_fake_yt_dlp(monkeypatch, behaviour)
|
||||
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
|
||||
with pytest.raises(PipelineError) as e:
|
||||
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
|
||||
assert "2026.08.19" in str(e.value)
|
||||
assert "default, tv" in str(e.value)
|
||||
assert "403" in str(e.value)
|
||||
|
||||
def test_download_with_no_configured_clients_still_tries_default(tmp_path, monkeypatch):
|
||||
seen = _fake_yt_dlp(
|
||||
monkeypatch, lambda opts: (tmp_path / "source_video.mp4").write_bytes(b"v"))
|
||||
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=[])
|
||||
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
|
||||
assert seen == [None]
|
||||
|
||||
def test_ydl_log_drops_debug_lines():
|
||||
logged = []
|
||||
log = acquire._YdlLog(logged.append)
|
||||
log.debug("[debug] internal detail")
|
||||
log.debug("[youtube] Extracting URL")
|
||||
log.warning("nsig extraction failed")
|
||||
log.error("HTTP Error 403: Forbidden")
|
||||
assert logged == ["[youtube] Extracting URL", "nsig extraction failed",
|
||||
"HTTP Error 403: Forbidden"]
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from songtrace.config import Config
|
||||
from songtrace.lyrics import normalize_lyrics
|
||||
from songtrace.pipeline import Ctx, PipelineError, SongInput
|
||||
from songtrace.stages import align
|
||||
|
||||
LYRICS = "she's got a smile\nthat it seems to me"
|
||||
|
||||
|
||||
def make_ctx(tmp_path, lyrics=LYRICS, transcript=None):
|
||||
work = tmp_path / "w"
|
||||
work.mkdir(exist_ok=True)
|
||||
if transcript is None:
|
||||
transcript = [{"start": 1.0, "end": 4.0,
|
||||
"text": "she's got a smile that it seems to me"}]
|
||||
(work / "transcript.json").write_text(json.dumps(transcript))
|
||||
inp = SongInput(title="T", artist="A", lyrics_text=lyrics)
|
||||
return Ctx(inp=inp, cfg=Config(whisper_url="u", device="cpu"),
|
||||
work=work, log=lambda m: None)
|
||||
|
||||
|
||||
def fake_align(vocals_path, start, end, tokens, device):
|
||||
step = (end - start) / len(tokens)
|
||||
return [(start + i * step, start + (i + 1) * step, 0.9)
|
||||
for i in range(len(tokens))]
|
||||
|
||||
|
||||
def run_align(tmp_path, monkeypatch, lyrics=LYRICS, transcript=None):
|
||||
monkeypatch.setattr(align, "force_align_section", fake_align)
|
||||
ctx = make_ctx(tmp_path, lyrics, transcript)
|
||||
align.align_stage().run(ctx)
|
||||
return json.loads((ctx.work / "alignment.json").read_text())
|
||||
|
||||
|
||||
def check_invariants(rows, lyrics):
|
||||
ref = normalize_lyrics(lyrics)
|
||||
assert [r["word"] for r in rows] == ref.words
|
||||
assert [r["line_index"] for r in rows] == ref.word_line_index
|
||||
prev = 0.0
|
||||
for r in rows:
|
||||
assert r["start"] is not None and r["end"] is not None
|
||||
assert prev <= r["start"] <= r["end"]
|
||||
prev = r["end"]
|
||||
|
||||
|
||||
def test_alignment_covers_all_words(tmp_path, monkeypatch):
|
||||
rows = run_align(tmp_path, monkeypatch)
|
||||
assert [r["word"] for r in rows] == \
|
||||
["she's", "got", "a", "smile", "that", "it", "seems", "to", "me"]
|
||||
assert rows[0]["line_index"] == 0 and rows[-1]["line_index"] == 1
|
||||
starts = [r["start"] for r in rows]
|
||||
assert starts == sorted(starts)
|
||||
|
||||
|
||||
def test_mismatched_lyrics_fail(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(align, "force_align_section", fake_align)
|
||||
ctx = make_ctx(tmp_path, lyrics="totally unrelated nonsense words banana")
|
||||
with pytest.raises(PipelineError, match="similarity"):
|
||||
align.align_stage().run(ctx)
|
||||
|
||||
|
||||
def test_unalignable_word_interpolated(tmp_path, monkeypatch):
|
||||
lyrics = "she's got ... smile\nthat it seems to me"
|
||||
rows = run_align(tmp_path, monkeypatch, lyrics)
|
||||
dots = next(r for r in rows if r["word"] == "...")
|
||||
assert dots["score"] == 0.0
|
||||
prev = next(r for r in rows if r["word"] == "got")
|
||||
nxt = next(r for r in rows if r["word"] == "smile")
|
||||
assert prev["end"] <= dots["start"] <= dots["end"] <= nxt["start"] + 1e-6
|
||||
check_invariants(rows, lyrics)
|
||||
|
||||
|
||||
def test_leading_unalignable_word(tmp_path, monkeypatch):
|
||||
lyrics = "... got a smile\nthat it seems to me"
|
||||
rows = run_align(tmp_path, monkeypatch, lyrics)
|
||||
assert rows[0]["word"] == "..." and rows[0]["score"] == 0.0
|
||||
assert rows[0]["start"] >= 0.0
|
||||
assert rows[0]["end"] <= rows[1]["start"] + 1e-6
|
||||
check_invariants(rows, lyrics)
|
||||
|
||||
|
||||
def test_trailing_unalignable_word(tmp_path, monkeypatch):
|
||||
lyrics = "she's got a smile\nthat it seems to ..."
|
||||
rows = run_align(tmp_path, monkeypatch, lyrics)
|
||||
assert rows[-1]["word"] == "..." and rows[-1]["score"] == 0.0
|
||||
assert rows[-2]["end"] <= rows[-1]["start"]
|
||||
check_invariants(rows, lyrics)
|
||||
|
||||
|
||||
def test_consecutive_unalignable_words(tmp_path, monkeypatch):
|
||||
lyrics = "she's got ... ... smile\nthat it seems to me"
|
||||
rows = run_align(tmp_path, monkeypatch, lyrics)
|
||||
dots = [r for r in rows if r["word"] == "..."]
|
||||
assert len(dots) == 2
|
||||
assert all(r["score"] == 0.0 for r in dots)
|
||||
assert dots[0]["end"] <= dots[1]["start"]
|
||||
check_invariants(rows, lyrics)
|
||||
|
||||
|
||||
def test_all_words_unalignable_in_section(tmp_path, monkeypatch):
|
||||
lyrics = "... ...\n... ..."
|
||||
transcript = [{"start": 1.0, "end": 4.0, "text": "..."}]
|
||||
monkeypatch.setattr(align, "force_align_section", fake_align)
|
||||
ctx = make_ctx(tmp_path, lyrics, transcript)
|
||||
align.align_stage().run(ctx)
|
||||
rows = json.loads((ctx.work / "alignment.json").read_text())
|
||||
check_invariants(rows, lyrics)
|
||||
assert all(r["score"] == 0.0 for r in rows)
|
||||
assert rows[-1]["end"] <= 5.0
|
||||
|
||||
|
||||
def test_non_chronological_transcript(tmp_path, monkeypatch):
|
||||
transcript = [{"start": 30.0, "end": 33.0, "text": "that it seems to me"},
|
||||
{"start": 1.0, "end": 4.0, "text": "she's got a smile"}]
|
||||
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
|
||||
check_invariants(rows, LYRICS)
|
||||
assert len({r["start"] for r in rows}) == len(rows)
|
||||
assert rows[3]["end"] <= 5.0
|
||||
assert rows[4]["start"] >= 29.0
|
||||
|
||||
|
||||
def test_inverted_section_does_not_collapse(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(align, "vocal_sections", lambda segments: [(29.0, 5.0)])
|
||||
rows = run_align(tmp_path, monkeypatch)
|
||||
check_invariants(rows, LYRICS)
|
||||
assert len({r["start"] for r in rows}) == len(rows)
|
||||
assert rows[0]["start"] >= 5.0 and rows[-1]["end"] <= 29.0
|
||||
|
||||
|
||||
def test_words_split_across_sections(tmp_path, monkeypatch):
|
||||
transcript = [{"start": 1.0, "end": 4.0, "text": "she's got a smile"},
|
||||
{"start": 30.0, "end": 33.0, "text": "that it seems to me"}]
|
||||
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
|
||||
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 = "she's got a smile\nthat it seems to me\nbut then it fades away"
|
||||
transcript = [{"start": 1.0, "end": 4.0,
|
||||
"text": "she's got a smile that it seems to me"}]
|
||||
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 = "she's got a smile\nthat it seems to me"
|
||||
transcript = [
|
||||
{"start": 1.0, "end": 4.0, "text": "she's got a smile that it seems to me"},
|
||||
{"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
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import songtrace.cli as cli
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import PipelineError, PipelineResult
|
||||
|
||||
|
||||
def setup_cfg(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(cli, "load_config",
|
||||
lambda path=None: Config(whisper_url="u"))
|
||||
|
||||
def test_make_success(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("la la")
|
||||
captured = {}
|
||||
def fake_run(inp, cfg, log=print, force_from=None, stages=None):
|
||||
captured["inp"] = inp
|
||||
captured["force_from"] = force_from
|
||||
return PipelineResult(output_dir=tmp_path / "out", warnings=["w1"])
|
||||
monkeypatch.setattr(cli, "run_pipeline", fake_run)
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y",
|
||||
"--force-from", "align"])
|
||||
assert rc == 0
|
||||
assert captured["inp"].lyrics_text == "la la"
|
||||
assert captured["force_from"] == "align"
|
||||
out = capsys.readouterr().out
|
||||
assert "w1" in out and str(tmp_path / "out") in out
|
||||
|
||||
def test_make_pipeline_error(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("x")
|
||||
def boom(*a, **kw):
|
||||
raise PipelineError("lyrics don't match")
|
||||
monkeypatch.setattr(cli, "run_pipeline", boom)
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y"])
|
||||
assert rc == 1
|
||||
assert "lyrics don't match" in capsys.readouterr().err
|
||||
|
||||
def test_make_requires_one_source(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("x")
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics)])
|
||||
assert rc != 0
|
||||
|
||||
def test_make_rejects_zero_bpm(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("x")
|
||||
try:
|
||||
cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y",
|
||||
"--bpm", "0"])
|
||||
assert False, "expected SystemExit"
|
||||
except SystemExit as e:
|
||||
assert e.code != 0
|
||||
assert "--bpm" in capsys.readouterr().err
|
||||
|
||||
def test_make_rejects_negative_bpm(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("x")
|
||||
try:
|
||||
cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y",
|
||||
"--bpm", "-120"])
|
||||
assert False, "expected SystemExit"
|
||||
except SystemExit as e:
|
||||
assert e.code != 0
|
||||
assert "--bpm" in capsys.readouterr().err
|
||||
|
||||
def test_make_reports_missing_lyrics_file(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("run_pipeline must not run with an unreadable lyrics file")
|
||||
monkeypatch.setattr(cli, "run_pipeline", boom)
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(tmp_path / "missing.txt"), "--youtube", "http://y"])
|
||||
assert rc != 0
|
||||
assert "--lyrics" in capsys.readouterr().err
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file permissions")
|
||||
def test_make_reports_unreadable_lyrics_file(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_text("x")
|
||||
lyrics.chmod(0o000)
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("run_pipeline must not run with an unreadable lyrics file")
|
||||
monkeypatch.setattr(cli, "run_pipeline", boom)
|
||||
try:
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y"])
|
||||
finally:
|
||||
lyrics.chmod(0o644)
|
||||
assert rc != 0
|
||||
assert "--lyrics" in capsys.readouterr().err
|
||||
|
||||
def test_make_reports_undecodable_lyrics_file(tmp_path, monkeypatch, capsys):
|
||||
setup_cfg(monkeypatch, tmp_path)
|
||||
lyrics = tmp_path / "l.txt"
|
||||
lyrics.write_bytes(b"\xff\xfe\x00bad")
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("run_pipeline must not run with undecodable lyrics")
|
||||
monkeypatch.setattr(cli, "run_pipeline", boom)
|
||||
rc = cli.main(["make", "--title", "T", "--artist", "A",
|
||||
"--lyrics", str(lyrics), "--youtube", "http://y"])
|
||||
assert rc != 0
|
||||
assert "--lyrics" in capsys.readouterr().err
|
||||
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from songtrace.config import Config, load_config
|
||||
|
||||
|
||||
def test_load_minimal(tmp_path):
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text('whisper_url = "http://w:9000/v1"\n')
|
||||
cfg = load_config(cfg_file)
|
||||
assert cfg.whisper_url == "http://w:9000/v1"
|
||||
assert cfg.demucs_model == "htdemucs_ft"
|
||||
|
||||
|
||||
def test_paths_and_overrides(tmp_path):
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text(
|
||||
'whisper_url = "http://w/v1"\noutput_dir = "/srv/songs"\nport = 9999\n'
|
||||
)
|
||||
cfg = load_config(cfg_file)
|
||||
assert cfg.output_dir == Path("/srv/songs")
|
||||
assert cfg.port == 9999
|
||||
|
||||
|
||||
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("SONGTRACE_CONFIG", str(cfg_file))
|
||||
assert load_config().whisper_url == "http://env/v1"
|
||||
|
||||
|
||||
def test_unknown_key_rejected(tmp_path):
|
||||
cfg_file = tmp_path / "c.toml"
|
||||
cfg_file.write_text('whisper_url = "x"\nwhipser_model = "typo"\n')
|
||||
with pytest.raises(ValueError, match="whipser_model"):
|
||||
load_config(cfg_file)
|
||||
|
||||
|
||||
def test_missing_file(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config(tmp_path / "nope.toml")
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
requires_full = pytest.mark.skipif(
|
||||
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 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["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 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
|
||||
@@ -0,0 +1,259 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def note(start, duration, pitch=0, text="la"):
|
||||
return {"start": start, "duration": duration, "pitch": pitch, "text": text,
|
||||
"low_confidence": False}
|
||||
|
||||
|
||||
def assert_ordered(body):
|
||||
"""Beats never go backwards and no two notes share a beat."""
|
||||
cursor = None
|
||||
for item in body:
|
||||
if cursor is not None:
|
||||
assert item.beat >= cursor
|
||||
cursor = item.beat + (0 if isinstance(item, LineBreak) else item.length)
|
||||
notes = [i for i in body if isinstance(i, Note)]
|
||||
for a, b in zip(notes, notes[1:]):
|
||||
assert a.beat + a.length <= b.beat
|
||||
assert all(n.length >= 1 for n in notes)
|
||||
|
||||
|
||||
def test_scale_bpm():
|
||||
assert scale_bpm(128.0) == 256.0
|
||||
assert scale_bpm(64.0) == 256.0
|
||||
assert scale_bpm(250.0) == 250.0
|
||||
assert scale_bpm(450.0) == 225.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bpm", [0.0, -120.0, float("inf"), float("nan")])
|
||||
def test_scale_bpm_rejects_unusable_tempo(bpm):
|
||||
with pytest.raises(ValueError):
|
||||
scale_bpm(bpm)
|
||||
|
||||
|
||||
def test_to_ultrastar_rejects_empty_notes():
|
||||
with pytest.raises(ValueError):
|
||||
to_ultrastar([], [], 240.0, [])
|
||||
|
||||
|
||||
def test_to_ultrastar_conversion():
|
||||
notes = [note(2.0, 0.5, 3, "la"), note(2.6, 0.25, 5, " la")]
|
||||
song = to_ultrastar(notes, breaks=[2.55], file_bpm=240.0,
|
||||
headers=[("TITLE", "T")])
|
||||
# spb = 60/(4*240) = 0.0625s; gap = 2000ms
|
||||
assert get_header(song, "GAP") == "2000"
|
||||
assert get_header(song, "BPM") == "240"
|
||||
assert song.headers[0] == ("TITLE", "T")
|
||||
n0, br, n1 = song.body
|
||||
assert n0 == Note(kind=":", beat=0, length=8, pitch=3, text="la")
|
||||
assert br == LineBreak(beat=9) # (2.55-2.0)/0.0625 = 8.8 -> 9
|
||||
assert n1.beat == 10 and n1.length == 4
|
||||
|
||||
|
||||
def test_min_length_one():
|
||||
song = to_ultrastar([note(1.0, 0.001, 0, "x")], [], 240.0, [])
|
||||
assert song.body[0].length == 1
|
||||
|
||||
|
||||
def test_fractional_bpm_header():
|
||||
song = to_ultrastar([note(0.0, 1.0)], [], 232.5, [])
|
||||
assert get_header(song, "BPM") == "232,50"
|
||||
|
||||
|
||||
def test_beat_cursor_separates_notes_quantised_onto_one_beat():
|
||||
notes = [note(1.0, 0.01, 0, "a"), note(1.01, 0.01, 1, "b"),
|
||||
note(1.02, 0.01, 2, "c")]
|
||||
song = to_ultrastar(notes, [], 240.0, []) # all three round to beat 0
|
||||
assert [n.beat for n in song.body] == [0, 1, 2]
|
||||
assert [n.length for n in song.body] == [1, 1, 1]
|
||||
assert_ordered(song.body)
|
||||
|
||||
|
||||
def test_beat_cursor_preserves_onsets_that_do_not_collide():
|
||||
notes = [note(0.0, 0.5), note(1.0, 0.5), note(2.0, 0.5)]
|
||||
song = to_ultrastar(notes, [], 240.0, [])
|
||||
assert [n.beat for n in song.body] == [0, 16, 32]
|
||||
|
||||
|
||||
def test_line_break_pushed_past_the_preceding_note():
|
||||
notes = [note(0.0, 0.56), note(0.57, 0.1)]
|
||||
song = to_ultrastar(notes, [0.51], 240.0, []) # break rounds to beat 8
|
||||
n0, br, n1 = song.body
|
||||
assert n0.beat + n0.length == 9
|
||||
assert br == LineBreak(beat=9)
|
||||
assert n1.beat >= br.beat
|
||||
assert_ordered(song.body)
|
||||
|
||||
|
||||
def test_line_break_on_a_note_start_precedes_that_note():
|
||||
"""No gap across a line boundary puts the break exactly on the next onset."""
|
||||
notes = [note(0.0, 0.5, 0, "end"), note(0.5, 0.5, 2, "New")]
|
||||
song = to_ultrastar(notes, [0.5], 240.0, [])
|
||||
assert song.body[1] == LineBreak(beat=8)
|
||||
assert [n.text for n in song.body if isinstance(n, Note)] == ["end", "New"]
|
||||
assert_ordered(song.body)
|
||||
|
||||
|
||||
def test_line_break_not_after_the_following_note():
|
||||
notes = [note(0.0, 0.02, 0, "a"), note(0.03, 0.02, 1, "b")]
|
||||
song = to_ultrastar(notes, [0.025], 240.0, [])
|
||||
br = song.body[1]
|
||||
assert isinstance(br, LineBreak)
|
||||
assert br.beat <= song.body[2].beat
|
||||
assert_ordered(song.body)
|
||||
|
||||
|
||||
def sung_words(rng):
|
||||
"""Words cut into equal syllable slices, the way build_notes emits them."""
|
||||
notes, breaks, t = [], [], 1.7
|
||||
for i in range(60):
|
||||
word = float(rng.uniform(0.05, 0.6))
|
||||
sylls = int(rng.integers(1, 4))
|
||||
for k in range(sylls):
|
||||
notes.append(note(t + k * word / sylls, word / sylls,
|
||||
int(rng.integers(-12, 12))))
|
||||
t += word + float(rng.uniform(0.0, 0.4))
|
||||
if i % 5 == 4:
|
||||
breaks.append(t - 0.05)
|
||||
return notes, breaks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bpm", [200.0, 300.0, 399.0])
|
||||
def test_no_overlap_on_realistic_input(bpm):
|
||||
rng = np.random.default_rng(7)
|
||||
moved = 0
|
||||
for _ in range(20):
|
||||
notes, breaks = sung_words(rng)
|
||||
song = to_ultrastar(notes, breaks, bpm, [])
|
||||
assert len(song.body) == len(notes) + len(breaks)
|
||||
assert song.body[0].beat == 0
|
||||
assert_ordered(song.body)
|
||||
spb = 60 / (4 * bpm)
|
||||
emitted = [n for n in song.body if isinstance(n, Note)]
|
||||
moved += sum(1 for n, src in zip(emitted, notes)
|
||||
if n.beat != round((src["start"] - notes[0]["start"]) / spb))
|
||||
assert moved > 0 # otherwise the fuzz never exercises the beat cursor
|
||||
|
||||
|
||||
def write_work(work, video="source_video.webm"):
|
||||
work.mkdir()
|
||||
(work / "notes_sec.json").write_text(json.dumps({
|
||||
"notes": [note(1.0, 0.5, 0, "la"), note(2.0, 0.5, 2, "la")],
|
||||
"breaks": [1.75],
|
||||
"warnings": [],
|
||||
}))
|
||||
(work / "meta.json").write_text(json.dumps(
|
||||
{"video_file": video, "thumbnail": "source_video.jpg"}))
|
||||
|
||||
|
||||
def make_ctx(work, **kwargs):
|
||||
inp = SongInput(title="Song", artist="Band", lyrics_text="x", **kwargs)
|
||||
return Ctx(inp=inp, cfg=Config(whisper_url="u", creator="me"), work=work,
|
||||
log=lambda m: None)
|
||||
|
||||
|
||||
def test_grid_stage_writes_song_txt(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
write_work(work)
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
|
||||
ctx = make_ctx(work, language="English", genre="Pop", year=1999)
|
||||
stage = grid_stage_mod.grid_stage()
|
||||
assert stage.outputs(ctx) == [work / "song.txt"]
|
||||
stage.run(ctx)
|
||||
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
||||
assert [k for k, _ in song.headers] == [
|
||||
"TITLE", "ARTIST", "LANGUAGE", "GENRE", "YEAR", "CREATOR",
|
||||
"MP3", "COVER", "VIDEO", "BPM", "GAP"]
|
||||
assert get_header(song, "BPM") == "240" # 120 doubled into range
|
||||
assert get_header(song, "GAP") == "1000"
|
||||
assert get_header(song, "MP3") == "Band - Song.mp3"
|
||||
assert get_header(song, "COVER") == "Band - Song.jpg"
|
||||
assert get_header(song, "VIDEO") == "Band - Song.webm"
|
||||
assert [n.text for n in song.body if isinstance(n, Note)] == ["la", "la"]
|
||||
assert_ordered(song.body)
|
||||
|
||||
|
||||
def test_grid_stage_omits_optional_headers(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
write_work(work, video="source_video.mp4")
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 210.0)
|
||||
ctx = make_ctx(work)
|
||||
grid_stage_mod.grid_stage().run(ctx)
|
||||
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
||||
assert [k for k, _ in song.headers] == [
|
||||
"TITLE", "ARTIST", "CREATOR", "MP3", "COVER", "VIDEO", "BPM", "GAP"]
|
||||
assert get_header(song, "VIDEO") == "Band - Song.mp4"
|
||||
assert get_header(song, "CREATOR") == "me"
|
||||
|
||||
|
||||
def test_grid_stage_uses_bpm_override(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
write_work(work)
|
||||
|
||||
def boom(path):
|
||||
raise AssertionError("detect_bpm must not run with an override")
|
||||
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
|
||||
ctx = make_ctx(work, bpm_override=333.0)
|
||||
grid_stage_mod.grid_stage().run(ctx)
|
||||
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
||||
assert get_header(song, "BPM") == "333"
|
||||
|
||||
|
||||
def test_grid_stage_honours_zero_bpm_override(tmp_path, monkeypatch):
|
||||
"""bpm_override=0.0 must not be mistaken for 'not provided' (falsy-zero bug)."""
|
||||
work = tmp_path / "w"
|
||||
write_work(work)
|
||||
|
||||
def boom(path):
|
||||
raise AssertionError("detect_bpm must not run with an explicit override")
|
||||
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
|
||||
captured = {}
|
||||
|
||||
def fake_to_ultrastar(notes, breaks, file_bpm, headers):
|
||||
captured["bpm"] = file_bpm
|
||||
return to_ultrastar(notes, breaks, 240.0, headers)
|
||||
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "to_ultrastar", fake_to_ultrastar)
|
||||
ctx = make_ctx(work, bpm_override=0.0)
|
||||
grid_stage_mod.grid_stage().run(ctx)
|
||||
assert captured["bpm"] == 0.0
|
||||
|
||||
|
||||
def test_grid_stage_reports_unusable_tempo_as_pipeline_error(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
write_work(work)
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 0.0)
|
||||
ctx = make_ctx(work)
|
||||
with pytest.raises(PipelineError, match="tempo"):
|
||||
grid_stage_mod.grid_stage().run(ctx)
|
||||
|
||||
|
||||
def test_grid_stage_reports_no_notes_as_pipeline_error(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
work.mkdir()
|
||||
(work / "notes_sec.json").write_text(json.dumps(
|
||||
{"notes": [], "breaks": [], "warnings": []}))
|
||||
(work / "meta.json").write_text(json.dumps(
|
||||
{"video_file": "source_video.webm", "thumbnail": "source_video.jpg"}))
|
||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
|
||||
ctx = make_ctx(work)
|
||||
with pytest.raises(PipelineError, match="no notes"):
|
||||
grid_stage_mod.grid_stage().run(ctx)
|
||||
|
||||
|
||||
def test_grid_stage_registered():
|
||||
names = [s.name for s in all_stages()]
|
||||
assert names[-1] == "package" and names[-2] == "grid"
|
||||
@@ -0,0 +1,40 @@
|
||||
from songtrace.lyrics import normalize_lyrics, alignment_token
|
||||
|
||||
RAW = """[Verse 1]
|
||||
She's got a smile
|
||||
|
||||
[Chorus]
|
||||
Whoa, sweet child o' mine
|
||||
Take me down to 7 streets
|
||||
"""
|
||||
|
||||
|
||||
def test_normalize_strips_sections_and_blanks():
|
||||
ref = normalize_lyrics(RAW)
|
||||
assert ref.lines == [
|
||||
["She's", "got", "a", "smile"],
|
||||
["Whoa,", "sweet", "child", "o'", "mine"],
|
||||
["Take", "me", "down", "to", "7", "streets"],
|
||||
]
|
||||
assert ref.words[0] == "She's"
|
||||
assert ref.word_line_index == [0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]
|
||||
|
||||
|
||||
def test_alignment_token():
|
||||
assert alignment_token("Whoa,") == "whoa"
|
||||
assert alignment_token("o'") == "o'"
|
||||
assert alignment_token("7") == "seven"
|
||||
assert alignment_token("...") == ""
|
||||
|
||||
|
||||
def test_alignment_token_no_internal_whitespace():
|
||||
assert alignment_token("21") == "twentyone"
|
||||
assert alignment_token("7th") == "seventh"
|
||||
|
||||
|
||||
def test_alignment_token_hyphenated_word():
|
||||
assert alignment_token("well-known") == "wellknown"
|
||||
|
||||
|
||||
def test_alignment_token_curly_apostrophe():
|
||||
assert alignment_token("don’t") == "don't"
|
||||
@@ -0,0 +1,349 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from songtrace.config import Config
|
||||
from songtrace.notes import (
|
||||
MIN_NOTE_SEC, TimedNote, build_notes, hz_to_ultrastar, segment_span,
|
||||
syllabify,
|
||||
)
|
||||
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
|
||||
times = np.arange(0, duration, 0.01)
|
||||
return times, np.full_like(times, hz), np.full_like(times, 0.9)
|
||||
|
||||
|
||||
def assert_no_overlap(notes):
|
||||
for a, b in zip(notes, notes[1:]):
|
||||
assert a.start < b.start
|
||||
assert a.start + a.duration <= b.start
|
||||
assert all(n.duration > 0 for n in notes)
|
||||
|
||||
|
||||
def test_hz_to_ultrastar():
|
||||
assert hz_to_ultrastar(261.63) == 0 # C4
|
||||
assert hz_to_ultrastar(440.0) == 9 # A4
|
||||
assert hz_to_ultrastar(220.0) == -3 # A3
|
||||
|
||||
|
||||
def test_hz_to_ultrastar_rejects_non_positive():
|
||||
with pytest.raises(ValueError):
|
||||
hz_to_ultrastar(0.0)
|
||||
with pytest.raises(ValueError):
|
||||
hz_to_ultrastar(-100.0)
|
||||
|
||||
|
||||
def test_segment_span_single_flat():
|
||||
times, f0, conf = flat_pitch()
|
||||
segs = segment_span(times, f0, conf, 1.0, 2.0)
|
||||
assert len(segs) == 1
|
||||
s, e, midi = segs[0]
|
||||
assert abs(midi - 60.0) < 0.1
|
||||
assert abs(s - 1.0) < 0.02 and abs(e - 2.0) < 0.02
|
||||
|
||||
|
||||
def test_segment_span_splits_on_jump():
|
||||
times = np.arange(0, 2.0, 0.01)
|
||||
f0 = np.where(times < 1.0, 261.63, 329.63) # C4 -> E4
|
||||
conf = np.full_like(times, 0.9)
|
||||
segs = segment_span(times, f0, conf, 0.0, 2.0)
|
||||
assert len(segs) == 2
|
||||
assert abs(segs[0][2] - 60.0) < 0.2 and abs(segs[1][2] - 64.0) < 0.2
|
||||
assert abs(segs[0][1] - segs[1][0]) < 1e-6 # contiguous
|
||||
|
||||
|
||||
def test_segment_span_masks_low_conf():
|
||||
times, f0, conf = flat_pitch()
|
||||
conf[:] = 0.1
|
||||
assert segment_span(times, f0, conf, 0.0, 1.0) == []
|
||||
|
||||
|
||||
def test_segment_span_ignores_brief_pitch_glitch():
|
||||
times, f0, conf = flat_pitch(duration=1.0)
|
||||
f0[50:52] = 523.25 # 2-frame octave blip
|
||||
conf = np.full_like(times, 0.9)
|
||||
segs = segment_span(times, f0, conf, 0.0, 1.0)
|
||||
assert len(segs) == 1
|
||||
|
||||
|
||||
def test_segment_span_drops_sub_minimum_segments():
|
||||
times, f0, conf = flat_pitch(duration=1.0)
|
||||
assert segment_span(times, f0, conf, 0.5, 0.52) == []
|
||||
|
||||
|
||||
def test_syllabify():
|
||||
assert syllabify("reminds", "English") == ["re", "minds"]
|
||||
assert syllabify("a", "English") == ["a"]
|
||||
assert syllabify("xyzzy", None) == ["xyzzy"]
|
||||
assert syllabify("She's", "English") == ["She's"]
|
||||
|
||||
|
||||
def test_syllabify_preserves_word_characters():
|
||||
assert "".join(syllabify("well-known", "English")) == "well-known"
|
||||
assert "".join(syllabify("beautiful", "English")) == "beautiful"
|
||||
|
||||
|
||||
def test_syllabify_language_without_pyphen_dict_is_not_mishandled_as_english():
|
||||
# Japanese is a known language (e.g. to whisper_client) but has no pyphen
|
||||
# dictionary; it must not silently fall back to English hyphenation rules.
|
||||
assert syllabify("beautiful", "Japanese") == ["beautiful"]
|
||||
assert syllabify("beautiful", "English") == ["beau", "ti", "ful"]
|
||||
|
||||
|
||||
def test_build_notes_basic_line():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
|
||||
{"word": "got", "start": 1.6, "end": 2.0, "score": 0.9, "line_index": 0},
|
||||
{"word": "Now", "start": 5.0, "end": 5.5, "score": 0.9, "line_index": 1},
|
||||
]
|
||||
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[0].text == "She's" # line-initial: no leading space
|
||||
assert notes[1].text == " got" # mid-line: leading space
|
||||
assert notes[2].text == "Now" # new line: no leading space
|
||||
assert len(breaks) == 1 and 2.0 <= breaks[0] <= 5.0
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_notes_melisma_continuation():
|
||||
times = np.arange(0, 3.0, 0.01)
|
||||
f0 = np.where(times < 1.0, 261.63, 329.63)
|
||||
conf = np.full_like(times, 0.9)
|
||||
rows = [{"word": "mine", "start": 0.2, "end": 2.8, "score": 0.9, "line_index": 0}]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["mine", "~"]
|
||||
assert notes[0].pitch != notes[1].pitch
|
||||
|
||||
|
||||
def test_build_notes_unvoiced_word_flagged():
|
||||
times, f0, conf = flat_pitch()
|
||||
conf[:] = 0.0
|
||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[0].low_confidence and len(warnings) == 1
|
||||
assert warnings[0] == "1 low-confidence note near 0:01–0:01"
|
||||
|
||||
|
||||
def test_build_notes_low_score_word_flagged():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.1, "line_index": 0}]
|
||||
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
|
||||
assert all(n.low_confidence for n in notes) and len(warnings) == 1
|
||||
|
||||
|
||||
def test_build_notes_unvoiced_word_inherits_previous_pitch():
|
||||
times, f0, conf = flat_pitch(hz=440.0) # A4 -> 9, not the fallback 0
|
||||
conf[300:] = 0.0
|
||||
rows = [
|
||||
{"word": "one", "start": 1.0, "end": 2.0, "score": 0.9, "line_index": 0},
|
||||
{"word": "two", "start": 4.0, "end": 4.5, "score": 0.9, "line_index": 0},
|
||||
]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[1].pitch == notes[0].pitch == 9
|
||||
assert notes[1].low_confidence and not notes[0].low_confidence
|
||||
|
||||
|
||||
def test_build_notes_unvoiced_first_word_uses_zero_pitch():
|
||||
times, f0, conf = flat_pitch()
|
||||
conf[:] = 0.0
|
||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[0].pitch == 0
|
||||
|
||||
|
||||
def test_build_notes_multisyllable_word_emits_syllables_only():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "reminds", "start": 1.0, "end": 1.8, "score": 0.9, "line_index": 0},
|
||||
{"word": "reminds", "start": 2.0, "end": 2.8, "score": 0.9, "line_index": 0},
|
||||
]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["re", "minds", " re", "minds"]
|
||||
|
||||
|
||||
def test_build_notes_more_syllables_than_segments():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": "beautiful", "start": 1.0, "end": 1.9, "score": 0.9,
|
||||
"line_index": 0}]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["beau", "ti", "ful"]
|
||||
assert all(abs(n.duration - notes[0].duration) < 1e-6 for n in notes)
|
||||
for a, b in zip(notes, notes[1:]):
|
||||
assert abs(a.start + a.duration - b.start) < 1e-6
|
||||
|
||||
|
||||
def test_build_notes_subdivides_the_widest_segment_first():
|
||||
times = np.arange(0, 2.0, 0.01)
|
||||
f0 = np.where(times < 1.1, 329.63, 261.63) # 0.1s E4 then 0.9s C4 in-word
|
||||
conf = np.full_like(times, 0.9)
|
||||
rows = [{"word": "beautiful", "start": 1.0, "end": 2.0, "score": 0.9,
|
||||
"line_index": 0}]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["beau", "ti", "ful"]
|
||||
assert [n.pitch for n in notes] == [4, 0, 0] # the 0.9s segment split
|
||||
assert abs(notes[0].duration - 0.1) < 1e-6
|
||||
assert abs(notes[1].duration - notes[2].duration) < 1e-6
|
||||
|
||||
|
||||
def test_build_notes_zero_duration_row_gets_minimum_length():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "...", "start": 1.0, "end": 1.0, "score": 0.0, "line_index": 0},
|
||||
{"word": "go", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
|
||||
]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert notes[0].duration >= MIN_NOTE_SEC
|
||||
assert_no_overlap(notes)
|
||||
|
||||
|
||||
def test_build_notes_repeated_zero_duration_rows_stay_separate():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [{"word": w, "start": 1.0, "end": 1.0, "score": 0.0, "line_index": 0}
|
||||
for w in ("...", "...", "...", "...")]
|
||||
rows.append({"word": "go", "start": 1.0, "end": 1.5, "score": 0.9,
|
||||
"line_index": 0})
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert len(notes) == 5
|
||||
assert_no_overlap(notes)
|
||||
|
||||
|
||||
def test_build_notes_short_multisyllable_word_does_not_overlap():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "reminds", "start": 8.92, "end": 8.99, "score": 0.9,
|
||||
"line_index": 0},
|
||||
{"word": "me", "start": 8.99, "end": 9.20, "score": 0.9, "line_index": 0},
|
||||
]
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["re", "minds", " me"]
|
||||
assert_no_overlap(notes)
|
||||
|
||||
|
||||
def test_build_notes_never_overlaps_on_dense_input():
|
||||
rng = np.random.default_rng(11)
|
||||
times = np.arange(0, 40.0, 0.01)
|
||||
words = ["She's", "got", "a", "beautiful", "smile", "reminds", "me",
|
||||
"everything", "I", "lost", "yesterday", "oh"]
|
||||
for _ in range(8):
|
||||
f0 = 220.0 * 2 ** (rng.integers(0, 12, len(times)) // 3 / 12.0)
|
||||
conf = np.where(rng.random(len(times)) < 0.9, 0.9, 0.1)
|
||||
rows, t = [], 0.5
|
||||
for i in range(80):
|
||||
dur = float(rng.uniform(0.06, 0.55))
|
||||
rows.append({"word": words[i % len(words)], "start": t,
|
||||
"end": t + dur, "score": 0.9, "line_index": i // 6})
|
||||
t += dur + float(rng.uniform(0.0, 0.15))
|
||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert_no_overlap(notes)
|
||||
|
||||
|
||||
def test_build_notes_starts_are_non_decreasing():
|
||||
times = np.arange(0, 12.0, 0.01)
|
||||
f0 = 220.0 * 2 ** (np.floor(times * 2) % 5 / 12.0) # stepped melody
|
||||
conf = np.where((times % 3.0) < 2.6, 0.9, 0.1) # unvoiced gaps
|
||||
words = ["She's", "got", "a", "beautiful", "smile", "that",
|
||||
"reminds", "me", "of", "everything", "I", "lost"]
|
||||
rows = []
|
||||
for i, w in enumerate(words):
|
||||
dur = 0.8 if i % 3 else 0.07 # every third word sung very fast
|
||||
rows.append({"word": w, "start": 0.5 + i * 0.9, "end": 0.5 + i * 0.9 + dur,
|
||||
"score": 0.9 if i % 5 else 0.2, "line_index": i // 4})
|
||||
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
|
||||
assert len(notes) >= len(words)
|
||||
starts = [n.start for n in notes]
|
||||
assert starts == sorted(starts)
|
||||
assert breaks == sorted(breaks) and len(breaks) == 2
|
||||
assert_no_overlap(notes)
|
||||
assert warnings and all("low-confidence note" in w for w in warnings)
|
||||
|
||||
|
||||
def test_build_notes_line_initial_after_break_has_no_space():
|
||||
times, f0, conf = flat_pitch()
|
||||
rows = [
|
||||
{"word": "one", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
|
||||
{"word": "two", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 1},
|
||||
{"word": "three", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 1},
|
||||
]
|
||||
notes, breaks, _ = build_notes(rows, times, f0, conf, "English")
|
||||
assert [n.text for n in notes] == ["one", "two", " three"]
|
||||
assert breaks == [1.7]
|
||||
|
||||
|
||||
def test_notes_stage_writes_json(tmp_path):
|
||||
work = tmp_path / "w"
|
||||
work.mkdir()
|
||||
times, f0, conf = flat_pitch()
|
||||
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
|
||||
(work / "alignment.json").write_text(json.dumps([
|
||||
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
|
||||
{"word": "gone", "start": 3.0, "end": 3.5, "score": 0.9, "line_index": 1},
|
||||
]))
|
||||
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x", language="English"),
|
||||
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
|
||||
stage = notes_stage_mod.notes_stage()
|
||||
assert stage.outputs(ctx) == [work / "notes_sec.json"]
|
||||
stage.run(ctx)
|
||||
data = json.loads((work / "notes_sec.json").read_text())
|
||||
assert [n["text"] for n in data["notes"]] == ["She's", "gone"]
|
||||
assert data["breaks"] == [2.25]
|
||||
assert data["warnings"] == []
|
||||
assert TimedNote(**data["notes"][0]).pitch == 0
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import (
|
||||
STAGE_ORDER, Ctx, PipelineError, SongInput, run_pipeline, song_slug,
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
def make_ctx(tmp_path, cover=None):
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
(work / "song.txt").write_text(SONG_TXT)
|
||||
(work / "audio.wav").write_bytes(b"w")
|
||||
(work / "source_video.webm").write_bytes(b"v")
|
||||
(work / "meta.json").write_text(json.dumps(
|
||||
{"video_file": "source_video.webm", "thumbnail": None}))
|
||||
inp = SongInput(title="T", artist="A", lyrics_text="x", cover_path=cover)
|
||||
cfg = Config(whisper_url="u", output_dir=tmp_path / "out", work_dir=tmp_path)
|
||||
return Ctx(inp=inp, cfg=cfg, work=work, log=lambda m: None)
|
||||
|
||||
|
||||
def make_cover(tmp_path):
|
||||
p = tmp_path / "c.png"
|
||||
Image.new("RGB", (640, 480), "red").save(p)
|
||||
return p
|
||||
|
||||
|
||||
def make_pipeline_inputs(tmp_path, cover=None):
|
||||
"""Set up inputs at the exact work-dir path run_pipeline() would use."""
|
||||
cfg = Config(whisper_url="u", output_dir=tmp_path / "out",
|
||||
work_dir=tmp_path / "work_root")
|
||||
inp = SongInput(title="T", artist="A", lyrics_text="x", cover_path=cover)
|
||||
work = cfg.work_dir / song_slug(inp)
|
||||
work.mkdir(parents=True)
|
||||
(work / "song.txt").write_text(SONG_TXT)
|
||||
(work / "audio.wav").write_bytes(b"w")
|
||||
(work / "source_video.webm").write_bytes(b"v")
|
||||
(work / "meta.json").write_text(json.dumps(
|
||||
{"video_file": "source_video.webm", "thumbnail": None}))
|
||||
return inp, cfg
|
||||
|
||||
|
||||
def test_package_copies_compatible_video(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T"
|
||||
assert (out / "A - T.txt").exists()
|
||||
assert (out / "A - T.mp3").exists()
|
||||
assert (out / "A - T.webm").read_bytes() == b"v"
|
||||
img = Image.open(out / "A - T.jpg")
|
||||
assert img.width == img.height
|
||||
|
||||
|
||||
def test_package_reencodes_and_rewrites_video_header(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("avi", "mpeg4"))
|
||||
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T"
|
||||
song = parse_ultrastar((out / "A - T.txt").read_text())
|
||||
assert get_header(song, "VIDEO") == "A - T.mp4"
|
||||
# VIDEO keeps its original slot, ahead of BPM/GAP
|
||||
keys = [k for k, _ in song.headers]
|
||||
assert keys.index("VIDEO") < keys.index("BPM") < keys.index("GAP")
|
||||
|
||||
|
||||
def test_package_no_cover_drops_header(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
ctx = make_ctx(tmp_path, cover=None)
|
||||
package.package_stage().run(ctx)
|
||||
song = parse_ultrastar((tmp_path / "out" / "A - T" / "A - T.txt").read_text())
|
||||
assert get_header(song, "COVER") is None
|
||||
|
||||
|
||||
def test_package_uses_work_thumbnail_when_no_cover_path(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
ctx = make_ctx(tmp_path, cover=None)
|
||||
make_cover(tmp_path).rename(ctx.work / "thumb.png")
|
||||
meta = json.loads((ctx.work / "meta.json").read_text())
|
||||
meta["thumbnail"] = "thumb.png"
|
||||
(ctx.work / "meta.json").write_text(json.dumps(meta))
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T"
|
||||
assert (out / "A - T.jpg").exists()
|
||||
song = parse_ultrastar((out / "A - T.txt").read_text())
|
||||
assert get_header(song, "COVER") == "A - T.jpg"
|
||||
|
||||
|
||||
def test_package_outputs_done_marker():
|
||||
ctx_outputs = package.package_stage().outputs
|
||||
cfg = Config(whisper_url="u", output_dir=Path("out"), work_dir=Path("work"))
|
||||
inp = SongInput(title="T", artist="A", lyrics_text="x")
|
||||
ctx = Ctx(inp=inp, cfg=cfg, work=Path("work"), log=lambda m: None)
|
||||
assert ctx_outputs(ctx) == [Path("work/package.done")]
|
||||
|
||||
|
||||
def test_package_corrupt_cover_degrades_to_no_cover(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
bad_cover = tmp_path / "bad.jpg"
|
||||
bad_cover.write_bytes(b"not an image")
|
||||
ctx = make_ctx(tmp_path, cover=bad_cover)
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T"
|
||||
assert not (out / "A - T.jpg").exists()
|
||||
song = parse_ultrastar((out / "A - T.txt").read_text())
|
||||
assert get_header(song, "COVER") is None
|
||||
|
||||
|
||||
def test_package_partial_failure_is_not_cached(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
|
||||
def boom(p):
|
||||
raise PipelineError("ffprobe failed")
|
||||
|
||||
monkeypatch.setattr(package, "ffprobe_codec", boom)
|
||||
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
||||
with pytest.raises(PipelineError):
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T"
|
||||
assert (out / "A - T.txt").exists()
|
||||
assert (out / "A - T.mp3").exists()
|
||||
assert not (out / "A - T.webm").exists()
|
||||
assert not package.package_stage().outputs(ctx)[0].exists()
|
||||
|
||||
|
||||
def test_package_success_is_cached_by_pipeline(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
inp, cfg = make_pipeline_inputs(tmp_path, cover=make_cover(tmp_path))
|
||||
stage = package.package_stage()
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
|
||||
logs = []
|
||||
run_pipeline(inp, cfg, log=logs.append, stages=[stage])
|
||||
assert any("[package] cached, skipping" in m for m in logs)
|
||||
|
||||
|
||||
def test_package_retry_after_partial_failure_completes_folder(tmp_path, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_ffprobe(p):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise PipelineError("ffprobe failed")
|
||||
return ("webm", "vp9")
|
||||
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", flaky_ffprobe)
|
||||
inp, cfg = make_pipeline_inputs(tmp_path, cover=make_cover(tmp_path))
|
||||
stage = package.package_stage()
|
||||
with pytest.raises(PipelineError):
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
|
||||
out = cfg.output_dir / "A - T"
|
||||
assert (out / "A - T.txt").exists() and (out / "A - T.mp3").exists()
|
||||
assert not (out / "A - T.webm").exists()
|
||||
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
|
||||
assert (out / "A - T.webm").exists()
|
||||
assert (out / "A - T.jpg").exists()
|
||||
|
||||
|
||||
def test_slash_in_title_is_stripped_from_folder_and_filenames(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
|
||||
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
|
||||
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
|
||||
ctx.inp.title = "T/2"
|
||||
package.package_stage().run(ctx)
|
||||
out = tmp_path / "out" / "A - T2"
|
||||
assert (out / "A - T2.txt").exists()
|
||||
assert (out / "A - T2.mp3").exists()
|
||||
|
||||
|
||||
def test_all_stages_matches_stage_order():
|
||||
assert [s.name for s in all_stages()] == STAGE_ORDER
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import SongInput, Stage, read_warnings, run_pipeline, song_slug
|
||||
|
||||
def make_cfg(tmp_path):
|
||||
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
|
||||
work_dir=tmp_path / "work")
|
||||
|
||||
def make_inp():
|
||||
return SongInput(title="T/est", artist="A", lyrics_text="la la")
|
||||
|
||||
def fake_stage(name, calls):
|
||||
def outputs(ctx):
|
||||
return [ctx.work / f"{name}.done"]
|
||||
def run(ctx):
|
||||
calls.append(name)
|
||||
(ctx.work / f"{name}.done").write_text("x")
|
||||
return Stage(name=name, outputs=outputs, run=run)
|
||||
|
||||
def test_slug_strips_slash():
|
||||
assert song_slug(make_inp()) == "A - Test"
|
||||
|
||||
def test_runs_all_stages_then_skips_cached(tmp_path):
|
||||
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
|
||||
stages = [fake_stage("s1", calls), fake_stage("s2", calls)]
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
|
||||
assert calls == ["s1", "s2"]
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
|
||||
assert calls == ["s1", "s2"]
|
||||
|
||||
def test_force_from_reruns_tail(tmp_path):
|
||||
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
|
||||
stages = [fake_stage("s1", calls), fake_stage("s2", calls), fake_stage("s3", calls)]
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
|
||||
calls.clear()
|
||||
run_pipeline(inp, cfg, log=lambda m: None, stages=stages, force_from="s2")
|
||||
assert calls == ["s2", "s3"]
|
||||
|
||||
def test_warnings_surfaced(tmp_path):
|
||||
cfg, inp = make_cfg(tmp_path), make_inp()
|
||||
def run(ctx):
|
||||
(ctx.work / "notes_sec.json").write_text(
|
||||
json.dumps({"notes": [], "breaks": [], "warnings": ["w1"]}))
|
||||
(ctx.work / "n.done").write_text("x")
|
||||
st = Stage(name="n", outputs=lambda ctx: [ctx.work / "n.done"], run=run)
|
||||
result = run_pipeline(inp, cfg, log=lambda m: None, stages=[st])
|
||||
assert result.warnings == ["w1"]
|
||||
|
||||
def test_read_warnings_is_empty_without_a_notes_file(tmp_path):
|
||||
assert read_warnings(tmp_path) == []
|
||||
|
||||
def test_read_warnings_reads_the_notes_file(tmp_path):
|
||||
(tmp_path / "notes_sec.json").write_text(json.dumps({"warnings": ["w1", "w2"]}))
|
||||
assert read_warnings(tmp_path) == ["w1", "w2"]
|
||||
@@ -0,0 +1,19 @@
|
||||
import numpy as np
|
||||
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):
|
||||
work = tmp_path / "w"
|
||||
work.mkdir()
|
||||
times = np.arange(0, 1.0, 0.01)
|
||||
f0 = np.full_like(times, 220.0)
|
||||
conf = np.full_like(times, 0.9)
|
||||
monkeypatch.setattr(pitch, "track_pitch", lambda p, m, d: (times, f0, conf))
|
||||
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x"),
|
||||
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
|
||||
pitch.pitch_stage().run(ctx)
|
||||
data = np.load(work / "pitch.npz")
|
||||
assert np.allclose(data["f0"], 220.0)
|
||||
assert data["times"].shape == data["conf"].shape
|
||||
@@ -0,0 +1,126 @@
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def song(**kw):
|
||||
base = dict(title="T", artist="A", lyrics_text="la", youtube_url="http://y")
|
||||
return SongInput(**{**base, **kw})
|
||||
|
||||
|
||||
def cfg_for(tmp_path):
|
||||
return Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_every_song_input_field_is_mapped():
|
||||
"""A field added to SongInput without a FIELD_STAGE entry would make edits to
|
||||
it invalidate nothing, silently building from stale intermediates."""
|
||||
actual = {f.name for f in dataclasses.fields(SongInput)}
|
||||
assert actual - set(FIELD_STAGE) == set()
|
||||
assert set(FIELD_STAGE) - actual == set()
|
||||
|
||||
|
||||
def test_every_mapped_stage_is_a_real_stage():
|
||||
assert set(FIELD_STAGE.values()) <= set(STAGE_ORDER)
|
||||
|
||||
|
||||
def test_no_change_means_no_force_from():
|
||||
assert plan(song(), song()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value,stage", [
|
||||
("youtube_url", "http://other", "acquire"),
|
||||
("video_path", Path("/tmp/v.mp4"), "acquire"),
|
||||
("language", "de", "transcribe"),
|
||||
("lyrics_text", "different words", "align"),
|
||||
("title", "Other", "grid"),
|
||||
("artist", "Other", "grid"),
|
||||
("genre", "rock", "grid"),
|
||||
("year", 1999, "grid"),
|
||||
("bpm_override", 123.0, "grid"),
|
||||
("cover_path", Path("/tmp/c.jpg"), "package"),
|
||||
])
|
||||
def test_single_field_maps_to_its_stage(field, value, stage):
|
||||
assert plan(song(), song(**{field: value})) == stage
|
||||
|
||||
|
||||
def test_earliest_stage_wins_when_several_change():
|
||||
new = song(lyrics_text="different", year=1999, language="de")
|
||||
assert plan(song(), new) == "transcribe"
|
||||
|
||||
|
||||
def test_changed_fields_lists_what_moved():
|
||||
assert sorted(changed_fields(song(), song(year=1999, genre="rock"))) == \
|
||||
["genre", "year"]
|
||||
|
||||
|
||||
def test_slug_fields_invalidate_no_later_than_the_stage_that_writes_the_slug():
|
||||
"""song.txt embeds the slug in #MP3/#COVER/#VIDEO and is written by `grid`,
|
||||
so a title/artist edit must rerun grid or earlier for relocate_work to be safe."""
|
||||
for field in ("title", "artist"):
|
||||
assert STAGE_ORDER.index(FIELD_STAGE[field]) <= STAGE_ORDER.index("grid")
|
||||
|
||||
|
||||
def test_relocate_moves_the_work_dir_when_the_slug_changes(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
src = cfg.work_dir / "A - T"
|
||||
src.mkdir(parents=True)
|
||||
(src / "audio.wav").write_bytes(b"x")
|
||||
assert relocate_work(song(), song(title="Fixed"), cfg) is True
|
||||
assert not src.exists()
|
||||
assert (cfg.work_dir / "A - Fixed" / "audio.wav").read_bytes() == b"x"
|
||||
|
||||
|
||||
def test_relocate_is_a_noop_when_the_slug_is_unchanged(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
src = cfg.work_dir / "A - T"
|
||||
src.mkdir(parents=True)
|
||||
assert relocate_work(song(), song(year=1999), cfg) is False
|
||||
assert src.exists()
|
||||
|
||||
|
||||
def test_relocate_is_a_noop_when_there_is_nothing_to_move(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
assert relocate_work(song(), song(title="Fixed"), cfg) is False
|
||||
|
||||
|
||||
def test_relocate_refuses_to_clobber_an_existing_work_dir(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
(cfg.work_dir / "A - T").mkdir(parents=True)
|
||||
(cfg.work_dir / "A - Fixed").mkdir(parents=True)
|
||||
with pytest.raises(PipelineError, match="already exists"):
|
||||
relocate_work(song(), song(title="Fixed"), cfg)
|
||||
assert (cfg.work_dir / "A - T").exists()
|
||||
|
||||
|
||||
def test_superseded_output_names_the_replaced_folder(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root / "A - Old"), str(root / "A - New"), root) == \
|
||||
(root / "A - Old").resolve()
|
||||
|
||||
|
||||
def test_superseded_output_is_none_when_the_slug_did_not_change(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root / "A - T"), str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_is_none_without_a_previous_build(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(None, str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_refuses_a_path_outside_the_output_root(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(tmp_path / "elsewhere"), str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_refuses_the_output_root_itself(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root), str(root / "A - T"), root) is None
|
||||
@@ -0,0 +1,26 @@
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from songtrace.config import Config
|
||||
from songtrace.pipeline import Ctx, SongInput
|
||||
from songtrace.stages import separate
|
||||
|
||||
|
||||
def test_separate_writes_stems(tmp_path, monkeypatch):
|
||||
work = tmp_path / "w"
|
||||
work.mkdir()
|
||||
(work / "audio.wav").write_bytes(b"")
|
||||
stems = {
|
||||
"vocals": torch.ones(2, 100) * 0.5,
|
||||
"drums": torch.ones(2, 100) * 0.1,
|
||||
"bass": torch.ones(2, 100) * 0.1,
|
||||
"other": torch.ones(2, 100) * 0.1,
|
||||
}
|
||||
monkeypatch.setattr(separate, "separate_audio", lambda p, m, d: stems)
|
||||
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x"),
|
||||
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
|
||||
separate.separate_stage().run(ctx)
|
||||
vocals, sr = sf.read(work / "vocals.wav")
|
||||
assert sr == 44100 and vocals.shape == (100, 2)
|
||||
instrumental, _ = sf.read(work / "instrumental.wav")
|
||||
assert np.allclose(instrumental, 0.3, atol=1e-4)
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
|
||||
def test_vocal_sections_merges_close_segments():
|
||||
sections = vocal_sections([seg(2, 5), seg(6, 9), seg(30, 33)])
|
||||
assert sections == [(1.0, 10.0), (29.0, 34.0)]
|
||||
|
||||
def test_vocal_sections_handles_out_of_order_segments():
|
||||
sections = vocal_sections([seg(30, 33), seg(1, 4)])
|
||||
assert sections == [(0.0, 5.0), (29.0, 34.0)]
|
||||
|
||||
def test_vocal_sections_keeps_contained_segment():
|
||||
sections = vocal_sections([seg(1, 10), seg(3, 5)])
|
||||
assert sections == [(0.0, 11.0)]
|
||||
|
||||
def test_similarity_high_for_matching_lyrics():
|
||||
ref = normalize_lyrics("she's got a smile\nthat it seems to me")
|
||||
segs = [seg(0, 2, "She's got a smile"), seg(2, 4, "that it seems to me")]
|
||||
assert lyrics_similarity(segs, ref) > 0.9
|
||||
|
||||
def test_similarity_low_for_wrong_lyrics():
|
||||
ref = normalize_lyrics("completely different words here entirely")
|
||||
segs = [seg(0, 2, "she's got a smile that it seems to me")]
|
||||
assert lyrics_similarity(segs, ref) < 0.4
|
||||
|
||||
def test_classify_lyrics_match_flags_extra_reference_line():
|
||||
ref = normalize_lyrics("she's got a smile\nthat it seems to me\nbut then it fades")
|
||||
segs = [seg(1, 4, "she's got a smile that it seems to me")]
|
||||
dropped, missing = classify_lyrics_match(segs, ref)
|
||||
assert dropped == [2]
|
||||
assert missing == []
|
||||
|
||||
def test_classify_lyrics_match_flags_unmatched_singing():
|
||||
ref = normalize_lyrics("she's got a smile\nthat it seems to me")
|
||||
segs = [seg(1, 4, "she's got a smile that it seems to me"),
|
||||
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("she's got a smile\nthat it seems to me")
|
||||
# line 1 heard with one word garbled ("it" -> "bit"): still a replace/equal
|
||||
# match, so the line stays matched rather than dropped
|
||||
segs = [seg(1, 4, "she's got a smile that bit seems to me")]
|
||||
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("she's got a smile\nthat it seems to me")
|
||||
segs = [seg(1, 4, "she's got a smile that it seems to me"),
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
from songtrace.ultrastar import (
|
||||
Note, LineBreak, parse_ultrastar, serialize_ultrastar,
|
||||
get_header, format_bpm, parse_bpm,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
def load(name):
|
||||
return (FIXTURES / name).read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
def test_parse_headers_and_notes():
|
||||
song = parse_ultrastar(load("sanctuary.txt"))
|
||||
assert get_header(song, "artist") == "Utada Hikaru"
|
||||
assert parse_bpm(get_header(song, "BPM")) == 217.98
|
||||
first = song.body[0]
|
||||
assert first == Note(kind=":", beat=0, length=3, pitch=16, text="ah")
|
||||
assert any(isinstance(x, LineBreak) and x.beat == 95 for x in song.body)
|
||||
assert any(n.kind == "F" for n in song.body if isinstance(n, Note))
|
||||
|
||||
def test_note_text_keeps_leading_space():
|
||||
song = parse_ultrastar(load("sanctuary.txt"))
|
||||
notes = [n for n in song.body if isinstance(n, Note)]
|
||||
assert notes[1].text == " ah"
|
||||
|
||||
def test_negative_pitch():
|
||||
song = parse_ultrastar(load("sweet_child.txt"))
|
||||
assert any(n.pitch == -9 for n in song.body if isinstance(n, Note))
|
||||
|
||||
def test_round_trip():
|
||||
for name in ("sweet_child.txt", "sanctuary.txt"):
|
||||
song = parse_ultrastar(load(name))
|
||||
again = parse_ultrastar(serialize_ultrastar(song))
|
||||
assert again == song
|
||||
|
||||
def test_format_bpm():
|
||||
assert format_bpm(256.0) == "256"
|
||||
assert format_bpm(217.98) == "217,98"
|
||||
+1360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from songtrace.config import Config
|
||||
from songtrace.whisper_client import TranscriptSegment, transcribe
|
||||
|
||||
def test_transcribe_parses_segments(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["auth"] = request.headers.get("authorization")
|
||||
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/v1", whisper_api_key="sekrit")
|
||||
segs = transcribe(cfg, audio, client=client)
|
||||
assert segs == [TranscriptSegment(start=0.5, end=2.0, text="hello world")]
|
||||
assert seen["url"] == "http://w:9000/v1/audio/transcriptions"
|
||||
assert seen["auth"] == "Bearer sekrit"
|
||||
|
||||
|
||||
def test_transcribe_maps_language_to_iso_code(tmp_path):
|
||||
audio = tmp_path / "v.wav"
|
||||
audio.write_bytes(b"RIFF")
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["body"] = request.read().decode("utf-8", errors="replace")
|
||||
return httpx.Response(200, json={"segments": []})
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
cfg = Config(whisper_url="http://w:9000/v1")
|
||||
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