Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a1bb4809 | ||
|
|
c1368505c2 | ||
|
|
d484d6f1d2 | ||
|
|
6f5e273c6c | ||
|
|
4272189bca | ||
|
|
936641e13c |
@@ -0,0 +1,6 @@
|
||||
deb.debian.org
|
||||
security.debian.org
|
||||
pypi.org
|
||||
files.pythonhosted.org
|
||||
download.pytorch.org
|
||||
download-r2.pytorch.org
|
||||
@@ -0,0 +1,83 @@
|
||||
name: tests
|
||||
|
||||
# `pull_request_target` reads this file from the base branch, so a pull
|
||||
# request cannot change what runs or where (including `runs-on:`). The run's
|
||||
# ref is therefore the base branch, and the checkout has to name the head.
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# No Docker socket, and egress limited to .dejima/allowed-domains on main.
|
||||
runs-on: dejima-confined
|
||||
|
||||
# Node for actions/checkout; Debian bookworm's python3 is the 3.11 the
|
||||
# Dockerfile ships.
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# Inline rather than a script in the repository: the files come from the
|
||||
# branch under test, so a script would be missing from older branches.
|
||||
- name: Check the hosts this job fetches from are reachable
|
||||
run: |
|
||||
set -u
|
||||
bad=""
|
||||
for host in deb.debian.org security.debian.org pypi.org \
|
||||
files.pythonhosted.org download.pytorch.org \
|
||||
download-r2.pytorch.org; do
|
||||
resolved=""
|
||||
for attempt in 1 2 3 4; do
|
||||
getent hosts "$host" >/dev/null 2>&1 && { resolved=yes; break; }
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$resolved" ]; then
|
||||
echo "no $host - does not resolve"; bad="$bad $host(dns)"; continue
|
||||
fi
|
||||
# Any HTTP answer proves the connection; curl reports 000 for none.
|
||||
code="$(curl -sS -o /dev/null --max-time 20 --retry 2 -w '%{http_code}' "https://$host/" 2>/dev/null || true)"
|
||||
case "${code:-000}" in
|
||||
000) echo "no $host - resolves, but nothing answered over https"
|
||||
bad="$bad $host(net)" ;;
|
||||
*) echo "ok $host (http $code)" ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$bad" ] && exit 0
|
||||
{
|
||||
echo
|
||||
echo "Unreachable:$bad"
|
||||
echo
|
||||
echo "The confined tier reaches only what .dejima/allowed-domains on"
|
||||
echo "main declares, rendered onto the runner separately from the"
|
||||
echo "merge. On the runner:"
|
||||
echo " sudo dejima-ci-allowlist show - is the name listed?"
|
||||
echo " sudo dejima-ci-allowlist check - is a change pending?"
|
||||
echo " sudo dejima-ci-allowlist render - apply it now"
|
||||
echo "A (dns) failure is the resolver; a (net) one is the packet"
|
||||
echo "filter, which sudo dejima-ci-firewall reapplies."
|
||||
echo
|
||||
echo "download.pytorch.org redirects its wheels to"
|
||||
echo "download-r2.pytorch.org, so both have to be allowed."
|
||||
} >&2
|
||||
exit 1
|
||||
|
||||
- name: Install Python
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 python3-venv
|
||||
python3 -m venv /opt/venv
|
||||
echo /opt/venv/bin >> "$GITHUB_PATH"
|
||||
|
||||
# The CPU build first: torch from PyPI is the CUDA build, several GB of
|
||||
# libraries no test uses. The install below then finds torch satisfied.
|
||||
- name: Install CPU-only torch
|
||||
run: pip install --retries 5 torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
- name: Install songtrace
|
||||
run: pip install --retries 5 -e '.[dev]'
|
||||
|
||||
- run: pytest
|
||||
@@ -6,6 +6,11 @@ import numpy as np
|
||||
from ..models import on_release
|
||||
from ..pipeline import Ctx, Stage
|
||||
|
||||
# Separated vocal stems keep faint bleed that crepe still finds a pitch in, so
|
||||
# frames this far below the song's loud vocal level count as silence. Relative
|
||||
# rather than absolute, so it holds however loud the song was mastered.
|
||||
SILENCE_BELOW_LOUD_DB = 25.0
|
||||
|
||||
|
||||
@on_release
|
||||
def unload_model() -> None:
|
||||
@@ -18,6 +23,11 @@ def unload_model() -> None:
|
||||
delattr(torchcrepe.infer, attr)
|
||||
|
||||
|
||||
def mute_silence(conf: np.ndarray, loudness: np.ndarray) -> None:
|
||||
"""Zero the confidence of frames quiet enough to be bleed, in place."""
|
||||
conf[loudness < np.quantile(loudness, 0.9) - SILENCE_BELOW_LOUD_DB] = 0.0
|
||||
|
||||
|
||||
def track_pitch(vocals_path: Path, model: str, device: str):
|
||||
import torchaudio
|
||||
import torchcrepe
|
||||
@@ -33,6 +43,7 @@ def track_pitch(vocals_path: Path, model: str, device: str):
|
||||
return_periodicity=True, batch_size=512)
|
||||
f0 = f0[0].cpu().numpy()
|
||||
conf = periodicity[0].cpu().numpy()
|
||||
mute_silence(conf, torchcrepe.loudness.a_weighted(waveform, 16000, 160)[0].cpu().numpy())
|
||||
times = np.arange(len(f0)) * 0.01
|
||||
return times, f0, conf
|
||||
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
#TITLE:Placeholder Chart
|
||||
#ARTIST:Test Artist
|
||||
#LANGUAGE:English
|
||||
#YEAR:2000
|
||||
#CREATOR:songtrace tests
|
||||
#GENRE:Test
|
||||
#COVER:Test Artist - Placeholder Chart [CO].jpg
|
||||
#MP3:Test Artist - Placeholder Chart.mp3
|
||||
#VIDEO:Test Artist - Placeholder Chart.mp4
|
||||
#BPM:217,98
|
||||
#GAP:4880
|
||||
#MEDLEYSTARTBEAT:113
|
||||
#MEDLEYENDBEAT:240
|
||||
: 0 3 16 ah
|
||||
: 5 3 20 ah
|
||||
: 11 3 23 la
|
||||
: 17 3 16 la
|
||||
: 22 28 15 ah
|
||||
: 51 2 16 doo
|
||||
: 54 31 16 ~
|
||||
- 95
|
||||
F 113 3 18 la
|
||||
F 117 3 21 la
|
||||
F 121 3 21 la
|
||||
* 125 3 21 doo
|
||||
* 129 12 19 ~
|
||||
- 150
|
||||
: 155 4 12 Na
|
||||
R 160 4 0 na
|
||||
G 165 4 0 na
|
||||
: 170 6 14 la
|
||||
- 190
|
||||
: 194 3 16 La
|
||||
: 198 3 18 la
|
||||
* 202 10 20 ah
|
||||
E
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
#TITLE:Café Placeholder
|
||||
#ARTIST:Tést Artist
|
||||
#LANGUAGE:English
|
||||
#GENRE:Test
|
||||
#YEAR:2000
|
||||
#MP3:Tést Artist - Café Placeholder.mp3
|
||||
#CREATOR:songtrace tests
|
||||
#BPM:256
|
||||
#GAP:49673
|
||||
: 0 4 2 Doo
|
||||
: 6 4 4 doo
|
||||
: 12 8 5 laé
|
||||
- 28 30
|
||||
: 32 4 0 Na
|
||||
: 38 4 -2 na
|
||||
* 44 12 -9 ah
|
||||
- 64
|
||||
F 70 4 3 La
|
||||
: 76 6 -5 la
|
||||
: 84 10 -9 ~
|
||||
E
|
||||
+5
-5
@@ -78,7 +78,7 @@ 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"]
|
||||
assert syllabify("It's", "English") == ["It's"]
|
||||
|
||||
|
||||
def test_syllabify_preserves_word_characters():
|
||||
@@ -96,12 +96,12 @@ def test_syllabify_language_without_pyphen_dict_is_not_mishandled_as_english():
|
||||
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": "It'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[0].text == "It'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
|
||||
@@ -278,7 +278,7 @@ def test_notes_stage_writes_json(tmp_path):
|
||||
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": "It'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"),
|
||||
@@ -287,7 +287,7 @@ def test_notes_stage_writes_json(tmp_path):
|
||||
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 [n["text"] for n in data["notes"]] == ["It's", "gone"]
|
||||
assert data["breaks"] == [2.25]
|
||||
assert data["warnings"] == []
|
||||
assert TimedNote(**data["notes"][0]).pitch == 0
|
||||
|
||||
@@ -17,3 +17,21 @@ def test_pitch_stage_writes_npz(tmp_path, monkeypatch):
|
||||
data = np.load(work / "pitch.npz")
|
||||
assert np.allclose(data["f0"], 220.0)
|
||||
assert data["times"].shape == data["conf"].shape
|
||||
|
||||
|
||||
def test_mute_silence_drops_bleed_but_keeps_quiet_singing():
|
||||
# 60 frames of singing around -30 dB, one quiet sung frame at -50 dB, and
|
||||
# 40 frames of bleed at -60 dB: only the bleed is more than 25 dB below
|
||||
# the song's loud level.
|
||||
loudness = np.array([-30.0] * 60 + [-50.0] + [-60.0] * 40)
|
||||
conf = np.full_like(loudness, 0.8)
|
||||
pitch.mute_silence(conf, loudness)
|
||||
assert (conf[:61] == 0.8).all()
|
||||
assert (conf[61:] == 0.0).all()
|
||||
|
||||
|
||||
def test_mute_silence_is_relative_to_the_songs_level():
|
||||
loudness = np.array([-45.0] * 60 + [-75.0] * 40)
|
||||
conf = np.full_like(loudness, 0.8)
|
||||
pitch.mute_silence(conf, loudness)
|
||||
assert (conf[:60] == 0.8).all() and (conf[60:] == 0.0).all()
|
||||
|
||||
@@ -10,8 +10,8 @@ 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"
|
||||
song = parse_ultrastar(load("chart_crlf.txt"))
|
||||
assert get_header(song, "artist") == "Test Artist"
|
||||
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")
|
||||
@@ -19,16 +19,16 @@ def test_parse_headers_and_notes():
|
||||
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"))
|
||||
song = parse_ultrastar(load("chart_crlf.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"))
|
||||
song = parse_ultrastar(load("chart_latin1.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"):
|
||||
for name in ("chart_latin1.txt", "chart_crlf.txt"):
|
||||
song = parse_ultrastar(load(name))
|
||||
again = parse_ultrastar(serialize_ultrastar(song))
|
||||
assert again == song
|
||||
|
||||
Reference in New Issue
Block a user