nonmetal commited on
Commit
eb34860
·
0 Parent(s):

AnimeScore release

Browse files
Files changed (9) hide show
  1. .gitattributes +35 -0
  2. README.md +88 -0
  3. app.py +81 -0
  4. config.json +17 -0
  5. example_inference.py +99 -0
  6. model.safetensors +3 -0
  7. modeling_animescore.py +234 -0
  8. requirements.txt +6 -0
  9. sanity_test.py +86 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ja
4
+ license: mit
5
+ tags:
6
+ - audio
7
+ - speech
8
+ - preference
9
+ - anime
10
+ library_name: transformers
11
+ pipeline_tag: audio-classification
12
+ ---
13
+
14
+ # AnimeScore
15
+
16
+ Try the interactive demo: [AnimeScore Demo Space](https://huggingface.co/spaces/spellbrush/animescore-demo).
17
+
18
+ A learned scorer for anime-like speech style.
19
+ Given an audio clip, it returns a scalar score; higher is more anime-like.
20
+
21
+ This is the official Huggingface model repository for the paper "[AnimeScore: A Preference-Based Dataset and Framework for Evaluating Anime-Like Speech Style](https://arxiv.org/abs/2603.11482)".
22
+
23
+ For more details, please visit our [GitHub Repository](https://github.com/sizigi/animescore).
24
+
25
+ ## Checkpoint
26
+
27
+ We release the HuBERT-based model, which achieved the best performance among the backbones we evaluated (pairwise accuracy 82.4%, AUC 0.908).
28
+
29
+ | File | Size | Notes |
30
+ |---|---:|---|
31
+ | `model.safetensors` | ~9 MB | Released head weights |
32
+ | `config.json` | — | Model config |
33
+ | `modeling_animescore.py` | — | Custom modeling code (loaded via `trust_remote_code=True`) |
34
+
35
+ ## How to use
36
+
37
+ ```bash
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ ```python
42
+ import torch, torchaudio
43
+ from transformers import AutoModel
44
+
45
+ device = "cuda" if torch.cuda.is_available() else "cpu"
46
+ model = AutoModel.from_pretrained(
47
+ "spellbrush/animescore",
48
+ trust_remote_code=True,
49
+ ).eval().to(device)
50
+
51
+ wav, sr = torchaudio.load("sample.wav")
52
+ if wav.size(0) > 1:
53
+ wav = wav.mean(0, keepdim=True) # mono
54
+ if sr != 16000:
55
+ wav = torchaudio.functional.resample(wav, sr, 16000)
56
+
57
+ with torch.no_grad():
58
+ s = model.score(wav.to(device)).item()
59
+ print(f"AnimeScore: {s:.3f}")
60
+ ```
61
+
62
+ Pairwise probability:
63
+
64
+ ```python
65
+ sa = model.score(wav_a.to(device))
66
+ sb = model.score(wav_b.to(device))
67
+ p_a_gt_b = torch.sigmoid(sa - sb).item()
68
+ ```
69
+
70
+ CLI: `python example_inference.py --ckpt . --wav sample.wav`
71
+
72
+ or deploy this directory as a HuggingFace Space (SDK = `gradio`).
73
+
74
+ ## Citation
75
+
76
+ ```bibtex
77
+ @inproceedings{park2026animescore,
78
+ title = {AnimeScore: A Preference-Based Dataset and Framework for
79
+ Evaluating Anime-Like Speech Style},
80
+ author = {Park, Joonyong and Li, Jerry},
81
+ booktitle = {Interspeech},
82
+ year = {2026}
83
+ }
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT License.
app.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio demo for AnimeScore: audio in -> anime-likeness score out."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ import torch
8
+ import torchaudio
9
+
10
+ HERE = Path(__file__).resolve().parent
11
+ sys.path.insert(0, str(HERE))
12
+
13
+ from modeling_animescore import AnimeScoreConfig, AnimeScoreRankNet
14
+ from safetensors.torch import load_file
15
+
16
+
17
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
18
+
19
+
20
+ def _build_model() -> AnimeScoreRankNet:
21
+ cfg = AnimeScoreConfig.from_json_file(str(HERE / "config.json"))
22
+ model = AnimeScoreRankNet(cfg).to(DEVICE).eval()
23
+ sd = load_file(str(HERE / "model.safetensors"))
24
+ missing, unexpected = model.load_state_dict(sd, strict=False)
25
+ if [m for m in missing if not m.startswith("ssl.")]:
26
+ raise RuntimeError(f"unexpected missing head keys: {missing}")
27
+ if unexpected:
28
+ raise RuntimeError(f"unexpected keys in safetensors: {unexpected}")
29
+ return model
30
+
31
+
32
+ MODEL = _build_model()
33
+ TARGET_SR = MODEL.config.target_sr
34
+
35
+
36
+ def _read_audio(path: str):
37
+ """Load audio to a [channels, frames] float32 tensor and its sample rate.
38
+
39
+ Uses soundfile (self-contained libsndfile) first so the demo does not depend
40
+ on torchaudio's optional torchcodec/ffmpeg backend; falls back to
41
+ torchaudio.load for the rare format libsndfile cannot decode.
42
+ """
43
+ try:
44
+ import soundfile as sf
45
+ data, sr = sf.read(path, dtype="float32", always_2d=True) # [frames, ch]
46
+ return torch.from_numpy(data.T).contiguous(), sr
47
+ except Exception:
48
+ wav, sr = torchaudio.load(path)
49
+ return wav.to(torch.float32), sr
50
+
51
+
52
+ def _load_wav_to_tensor(path: str) -> torch.Tensor:
53
+ wav, sr = _read_audio(path)
54
+ if wav.size(0) > 1:
55
+ wav = wav.mean(0, keepdim=True)
56
+ if sr != TARGET_SR:
57
+ wav = torchaudio.functional.resample(wav, sr, TARGET_SR)
58
+ return wav.to(DEVICE)
59
+
60
+
61
+ def predict(audio):
62
+ if audio is None:
63
+ return "—"
64
+ wav = _load_wav_to_tensor(audio)
65
+ with torch.no_grad():
66
+ score = MODEL.score(wav).item()
67
+ return f"{score:.4f}"
68
+
69
+
70
+ with gr.Blocks(title="AnimeScore") as demo:
71
+ gr.Markdown("# AnimeScore\n\nScore a speech clip for anime-likeness. Higher = more anime-like.")
72
+ audio_in = gr.Audio(sources=["upload", "microphone"], type="filepath", label="Audio")
73
+ run = gr.Button("Score", variant="primary")
74
+ score_out = gr.Textbox(label="AnimeScore", interactive=False)
75
+
76
+ run.click(predict, inputs=audio_in, outputs=score_out)
77
+ audio_in.change(predict, inputs=audio_in, outputs=score_out)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ demo.queue().launch()
config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "animescore_ranknet",
3
+ "architectures": ["AnimeScoreRankNet"],
4
+ "auto_map": {
5
+ "AutoConfig": "modeling_animescore.AnimeScoreConfig",
6
+ "AutoModel": "modeling_animescore.AnimeScoreRankNet"
7
+ },
8
+ "ssl_backbone": "facebook/hubert-base-ls960",
9
+ "ssl_feat_dim": 768,
10
+ "use_layer_mixing_last_k": 4,
11
+ "lstm_hidden": 256,
12
+ "lstm_layers": 1,
13
+ "mlp_hidden": 256,
14
+ "dropout": 0.1,
15
+ "target_sr": 16000,
16
+ "torch_dtype": "float32"
17
+ }
example_inference.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal inference example for the AnimeScore release.
2
+
3
+ Loads the model from a local directory (or HuggingFace Hub) and scores
4
+ either a single wav or a directory of wavs.
5
+
6
+ Usage:
7
+ # single wav
8
+ python example_inference.py --ckpt . --wav path/to/audio.wav
9
+
10
+ # batch over a directory
11
+ python example_inference.py --ckpt . --dir path/to/wavs --csv out.csv
12
+
13
+ # pairwise probability A > B
14
+ python example_inference.py --ckpt . --pair a.wav b.wav
15
+ """
16
+
17
+ import argparse
18
+ import os
19
+ from pathlib import Path
20
+
21
+ import torch
22
+ import torchaudio
23
+ from transformers import AutoModel
24
+
25
+
26
+ def _read_audio(path: str):
27
+ """Load audio to a [channels, frames] float32 tensor and its sample rate.
28
+
29
+ Prefers soundfile (self-contained libsndfile) so this does not depend on
30
+ torchaudio's optional torchcodec/ffmpeg backend; falls back to
31
+ torchaudio.load for the rare format libsndfile cannot decode.
32
+ """
33
+ try:
34
+ import soundfile as sf
35
+ data, sr = sf.read(path, dtype="float32", always_2d=True) # [frames, ch]
36
+ return torch.from_numpy(data.T).contiguous(), sr
37
+ except Exception:
38
+ wav, sr = torchaudio.load(path)
39
+ return wav.to(torch.float32), sr
40
+
41
+
42
+ def load_wav(path: str, target_sr: int = 16000) -> torch.Tensor:
43
+ wav, sr = _read_audio(path)
44
+ if wav.size(0) > 1:
45
+ wav = wav.mean(dim=0, keepdim=True)
46
+ if sr != target_sr:
47
+ wav = torchaudio.functional.resample(wav, sr, target_sr)
48
+ return wav.squeeze(0)
49
+
50
+
51
+ def score_paths(model, paths, device):
52
+ scores = []
53
+ for p in paths:
54
+ wav = load_wav(p, model.config.target_sr).unsqueeze(0).to(device)
55
+ s = model.score(wav).item()
56
+ scores.append(s)
57
+ return scores
58
+
59
+
60
+ def main():
61
+ ap = argparse.ArgumentParser()
62
+ ap.add_argument("--ckpt", required=True, help="HF repo id or local directory")
63
+ ap.add_argument("--wav", help="single wav path")
64
+ ap.add_argument("--dir", help="directory of wavs to score")
65
+ ap.add_argument("--pair", nargs=2, metavar=("A", "B"), help="two wavs for pairwise prob")
66
+ ap.add_argument("--csv", default="", help="optional output CSV when using --dir")
67
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
68
+ args = ap.parse_args()
69
+
70
+ model = AutoModel.from_pretrained(args.ckpt, trust_remote_code=True).eval().to(args.device)
71
+
72
+ if args.wav:
73
+ s = score_paths(model, [args.wav], args.device)[0]
74
+ print(f"{args.wav}\tanimescore={s:.4f}")
75
+
76
+ if args.dir:
77
+ paths = sorted(str(p) for p in Path(args.dir).glob("*.wav"))
78
+ scores = score_paths(model, paths, args.device)
79
+ if args.csv:
80
+ with open(args.csv, "w") as f:
81
+ f.write("path,animescore\n")
82
+ for p, s in zip(paths, scores):
83
+ f.write(f"{p},{s:.6f}\n")
84
+ print(f"wrote {len(paths)} rows to {args.csv}")
85
+ else:
86
+ for p, s in zip(paths, scores):
87
+ print(f"{p}\t{s:.4f}")
88
+
89
+ if args.pair:
90
+ a, b = args.pair
91
+ sa, sb = score_paths(model, [a, b], args.device)
92
+ p_a_gt_b = torch.sigmoid(torch.tensor(sa - sb)).item()
93
+ print(f"score({a}) = {sa:.4f}")
94
+ print(f"score({b}) = {sb:.4f}")
95
+ print(f"P({os.path.basename(a)} > {os.path.basename(b)}) = {p_a_gt_b:.4f}")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3450c34915c68572e38de15594ab35944ce73bf9cd100a89e392298ec1cf2af7
3
+ size 8936708
modeling_animescore.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AnimeScore RankNet — HuggingFace-compatible release.
3
+
4
+ Architecture:
5
+ audio (16 kHz mono)
6
+ -> frozen SSL encoder (HuBERT-base, last 4 hidden states)
7
+ -> softmax-weighted layer mix
8
+ -> BiLSTM(256, 1 layer)
9
+ -> mean pool over time
10
+ -> MLP (LayerNorm -> Linear 512->256 -> GELU -> Linear 256->1)
11
+ -> scalar anime-likeness score s(x)
12
+
13
+ Pairwise interpretation:
14
+ P(a is more anime-like than b) = sigmoid(s_a - s_b)
15
+
16
+ The SSL encoder is loaded from the HuggingFace Hub at model-init time
17
+ (`config.ssl_backbone`, default `facebook/hubert-base-ls960`) and is NOT
18
+ included in the released weights. The released safetensors contains only
19
+ the trainable head: layer mixer + BiLSTM + MLP (~9 MB).
20
+
21
+ Reference paper:
22
+ Joonyong Park and Jerry Li, "AnimeScore: A Preference-Based Dataset and
23
+ Framework for Evaluating Anime-Like Speech Style," Interspeech 2026.
24
+ """
25
+
26
+ import os
27
+ from typing import List, Optional
28
+
29
+ import torch
30
+ import torch.nn as nn
31
+ from huggingface_hub import hf_hub_download
32
+ from safetensors.torch import load_file
33
+ from transformers import AutoModel, PretrainedConfig, PreTrainedModel
34
+
35
+
36
+ class AnimeScoreConfig(PretrainedConfig):
37
+ model_type = "animescore_ranknet"
38
+
39
+ def __init__(
40
+ self,
41
+ ssl_backbone: str = "facebook/hubert-base-ls960",
42
+ ssl_feat_dim: int = 768,
43
+ use_layer_mixing_last_k: int = 4,
44
+ lstm_hidden: int = 256,
45
+ lstm_layers: int = 1,
46
+ mlp_hidden: int = 256,
47
+ dropout: float = 0.1,
48
+ target_sr: int = 16000,
49
+ **kwargs,
50
+ ):
51
+ super().__init__(**kwargs)
52
+ self.ssl_backbone = ssl_backbone
53
+ self.ssl_feat_dim = ssl_feat_dim
54
+ self.use_layer_mixing_last_k = int(use_layer_mixing_last_k)
55
+ self.lstm_hidden = int(lstm_hidden)
56
+ self.lstm_layers = int(lstm_layers)
57
+ self.mlp_hidden = int(mlp_hidden)
58
+ self.dropout = float(dropout)
59
+ self.target_sr = int(target_sr)
60
+
61
+
62
+ class LayerMixing(nn.Module):
63
+ def __init__(self, n_layers: int):
64
+ super().__init__()
65
+ self.alpha = nn.Parameter(torch.zeros(n_layers))
66
+
67
+ def forward(self, hidden_states: List[torch.Tensor]) -> torch.Tensor:
68
+ w = torch.softmax(self.alpha, dim=0)
69
+ out = w[0] * hidden_states[0]
70
+ for i in range(1, len(hidden_states)):
71
+ out = out + w[i] * hidden_states[i]
72
+ return out
73
+
74
+
75
+ class AnimeScoreRankNet(PreTrainedModel):
76
+ config_class = AnimeScoreConfig
77
+ main_input_name = "input_values"
78
+
79
+ def __init__(self, config: AnimeScoreConfig):
80
+ super().__init__(config)
81
+ self.ssl = AutoModel.from_pretrained(config.ssl_backbone)
82
+ self.ssl.config.output_hidden_states = True
83
+ for p in self.ssl.parameters():
84
+ p.requires_grad = False
85
+
86
+ if config.use_layer_mixing_last_k > 1:
87
+ self.layer_mixer = LayerMixing(config.use_layer_mixing_last_k)
88
+ else:
89
+ self.layer_mixer = None
90
+
91
+ self.bilstm = nn.LSTM(
92
+ input_size=config.ssl_feat_dim,
93
+ hidden_size=config.lstm_hidden,
94
+ num_layers=config.lstm_layers,
95
+ batch_first=True,
96
+ bidirectional=True,
97
+ )
98
+
99
+ out_dim = 2 * config.lstm_hidden
100
+ self.mlp = nn.Sequential(
101
+ nn.LayerNorm(out_dim),
102
+ nn.Dropout(config.dropout),
103
+ nn.Linear(out_dim, config.mlp_hidden),
104
+ nn.GELU(),
105
+ nn.Dropout(config.dropout),
106
+ nn.Linear(config.mlp_hidden, 1),
107
+ )
108
+
109
+ def _extract_feats(self, wav_16k: torch.Tensor) -> torch.Tensor:
110
+ out = self.ssl(input_values=wav_16k, output_hidden_states=True)
111
+ if self.layer_mixer is not None and out.hidden_states is not None:
112
+ last_k = out.hidden_states[-self.config.use_layer_mixing_last_k:]
113
+ return self.layer_mixer(list(last_k))
114
+ return out.last_hidden_state
115
+
116
+ @torch.no_grad()
117
+ def score(self, wav_16k: torch.Tensor) -> torch.Tensor:
118
+ """Return scalar anime-likeness score per waveform.
119
+
120
+ Args:
121
+ wav_16k: float32 tensor of shape [B, T], 16 kHz mono, values in [-1, 1].
122
+
123
+ Returns:
124
+ Tensor of shape [B] with raw RankNet scores.
125
+ Pairwise prob: P(a > b) = sigmoid(score(a) - score(b)).
126
+ """
127
+ feats = self._extract_feats(wav_16k)
128
+ z, _ = self.bilstm(feats)
129
+ zbar = z.mean(dim=1)
130
+ return self.mlp(zbar).squeeze(-1)
131
+
132
+ def forward(
133
+ self,
134
+ input_values: torch.Tensor,
135
+ attention_mask: Optional[torch.Tensor] = None,
136
+ ):
137
+ feats = self._extract_feats(input_values)
138
+ z, _ = self.bilstm(feats)
139
+ zbar = z.mean(dim=1)
140
+ s = self.mlp(zbar).squeeze(-1)
141
+ return {"score": s}
142
+
143
+ # ------------------------------------------------------------------
144
+ # Custom loader.
145
+ #
146
+ # The released checkpoint holds ONLY the trainable head (layer mixer +
147
+ # BiLSTM + MLP, ~9 MB). The frozen SSL backbone (HuBERT-base) is restored
148
+ # from its own Hub repo inside __init__.
149
+ #
150
+ # We override `from_pretrained` so the canonical one-liner
151
+ # AutoModel.from_pretrained("spellbrush/animescore", trust_remote_code=True)
152
+ # works in a single call: build the full model on a real device (loading the
153
+ # real pretrained HuBERT in __init__), then overlay the head weights with
154
+ # strict=False. This intentionally bypasses transformers' meta-device
155
+ # fast-init, which would otherwise (a) crash on transformers>=5 — the
156
+ # backbone is itself loaded via from_pretrained inside __init__ — and
157
+ # (b) silently re-initialize the frozen backbone with random weights on
158
+ # transformers 4.x, yielding NaN/garbage scores.
159
+ # ------------------------------------------------------------------
160
+ _HEAD_WEIGHT_NAMES = ("model.safetensors", "pytorch_model.safetensors")
161
+
162
+ @classmethod
163
+ def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
164
+ config = kwargs.pop("config", None)
165
+ torch_dtype = kwargs.pop("torch_dtype", None)
166
+ # Hub-resolution kwargs we honor; everything else transformers/AutoModel
167
+ # may pass (device_map, low_cpu_mem_usage, attn_implementation, ...) is
168
+ # intentionally ignored — this loader runs eagerly on a real device.
169
+ hub_keys = (
170
+ "cache_dir", "force_download", "resume_download", "proxies",
171
+ "local_files_only", "token", "use_auth_token", "revision",
172
+ "subfolder", "trust_remote_code",
173
+ )
174
+ hub_kwargs = {k: kwargs[k] for k in hub_keys if k in kwargs}
175
+
176
+ if config is None:
177
+ config, _ = AnimeScoreConfig.from_pretrained(
178
+ pretrained_model_name_or_path,
179
+ return_unused_kwargs=True,
180
+ **hub_kwargs,
181
+ )
182
+
183
+ # Build the full model on CPU; __init__ loads the real pretrained HuBERT.
184
+ model = cls(config, *model_args)
185
+
186
+ # Locate and overlay the head weights.
187
+ weights_path = cls._resolve_head_weights(
188
+ str(pretrained_model_name_or_path), hub_kwargs
189
+ )
190
+ state_dict = load_file(weights_path)
191
+ missing, unexpected = model.load_state_dict(state_dict, strict=False)
192
+ head_missing = [m for m in missing if not m.startswith("ssl.")]
193
+ if head_missing:
194
+ raise RuntimeError(f"missing head keys after load: {head_missing}")
195
+ if unexpected:
196
+ raise RuntimeError(f"unexpected keys in checkpoint: {unexpected}")
197
+
198
+ if isinstance(torch_dtype, torch.dtype):
199
+ model = model.to(torch_dtype)
200
+ return model.eval()
201
+
202
+ @classmethod
203
+ def _resolve_head_weights(cls, path, hub_kwargs):
204
+ if os.path.isfile(path):
205
+ return path
206
+ if os.path.isdir(path):
207
+ for name in cls._HEAD_WEIGHT_NAMES:
208
+ candidate = os.path.join(path, name)
209
+ if os.path.isfile(candidate):
210
+ return candidate
211
+ raise OSError(
212
+ f"None of {cls._HEAD_WEIGHT_NAMES} found in directory '{path}'."
213
+ )
214
+ # Treat `path` as a Hub repo id and download the head weights.
215
+ token = hub_kwargs.get("token", hub_kwargs.get("use_auth_token"))
216
+ dl_kwargs = {
217
+ k: hub_kwargs[k]
218
+ for k in ("cache_dir", "force_download", "proxies",
219
+ "local_files_only", "revision", "subfolder")
220
+ if k in hub_kwargs
221
+ }
222
+ last_err = None
223
+ for name in cls._HEAD_WEIGHT_NAMES:
224
+ try:
225
+ return hf_hub_download(repo_id=path, filename=name, token=token, **dl_kwargs)
226
+ except Exception as err: # fall through to the next candidate name
227
+ last_err = err
228
+ raise OSError(
229
+ f"Could not fetch head weights {cls._HEAD_WEIGHT_NAMES} from '{path}': {last_err}"
230
+ )
231
+
232
+
233
+ AnimeScoreConfig.register_for_auto_class("AutoConfig")
234
+ AnimeScoreRankNet.register_for_auto_class("AutoModel")
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch>=2.0
2
+ transformers>=4.40
3
+ torchaudio>=2.0
4
+ safetensors>=0.4
5
+ soundfile>=0.12
6
+ gradio>=4.0
sanity_test.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sanity check for the AnimeScore HuBERT release.
2
+
3
+ What it verifies:
4
+ 1. modeling_animescore.AnimeScoreRankNet can be built from config.json.
5
+ 2. model.safetensors loads with zero missing/unexpected non-SSL keys.
6
+ 3. A forward pass on a 3-second random waveform runs and returns a finite scalar.
7
+ 4. (Optional) If --wav is given, prints the AnimeScore for that file.
8
+
9
+ Usage:
10
+ python sanity_test.py
11
+ python sanity_test.py --wav path/to/clip.wav
12
+ """
13
+
14
+ import argparse
15
+ import math
16
+ import os
17
+ import sys
18
+
19
+ import torch
20
+ from safetensors.torch import load_file
21
+
22
+
23
+ def main():
24
+ ap = argparse.ArgumentParser()
25
+ ap.add_argument("--wav", help="Optional: score this wav file as a real-data check.")
26
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
27
+ args = ap.parse_args()
28
+
29
+ here = os.path.dirname(os.path.abspath(__file__))
30
+ sys.path.insert(0, here)
31
+
32
+ from modeling_animescore import AnimeScoreConfig, AnimeScoreRankNet
33
+
34
+ print("[1/4] Building model from config.json...")
35
+ cfg_path = os.path.join(here, "config.json")
36
+ if not os.path.exists(cfg_path):
37
+ raise FileNotFoundError(cfg_path)
38
+ cfg = AnimeScoreConfig.from_json_file(cfg_path)
39
+ model = AnimeScoreRankNet(cfg).to(args.device).eval()
40
+ print(f" backbone = {cfg.ssl_backbone}")
41
+ n_head = sum(p.numel() for n, p in model.named_parameters() if not n.startswith("ssl."))
42
+ n_ssl = sum(p.numel() for n, p in model.named_parameters() if n.startswith("ssl."))
43
+ print(f" ssl = {n_ssl/1e6:.2f} M, head = {n_head/1e6:.2f} M")
44
+
45
+ print("[2/4] Loading head weights from model.safetensors...")
46
+ sd = load_file(os.path.join(here, "model.safetensors"))
47
+ missing, unexpected = model.load_state_dict(sd, strict=False)
48
+ head_missing = [m for m in missing if not m.startswith("ssl.")]
49
+ if head_missing:
50
+ raise RuntimeError(f"head keys missing after load: {head_missing}")
51
+ if unexpected:
52
+ raise RuntimeError(f"unexpected keys in safetensors: {unexpected}")
53
+ print(f" loaded {len(sd)} head tensors, 0 missing, 0 unexpected.")
54
+
55
+ print("[3/4] Forward pass on 3 s of random audio...")
56
+ wav = torch.randn(1, 16000 * 3).to(args.device)
57
+ with torch.no_grad():
58
+ s = model.score(wav).item()
59
+ if not math.isfinite(s):
60
+ raise RuntimeError(f"non-finite score: {s}")
61
+ print(f" score = {s:+.4f} (random audio; value is uninformative, just non-NaN check)")
62
+
63
+ if args.wav:
64
+ print(f"[4/4] Scoring real audio: {args.wav}")
65
+ import torchaudio
66
+ try:
67
+ import soundfile as sf
68
+ data, sr = sf.read(args.wav, dtype="float32", always_2d=True)
69
+ wav = torch.from_numpy(data.T).contiguous()
70
+ except Exception:
71
+ wav, sr = torchaudio.load(args.wav)
72
+ if wav.size(0) > 1:
73
+ wav = wav.mean(0, keepdim=True)
74
+ if sr != cfg.target_sr:
75
+ wav = torchaudio.functional.resample(wav, sr, cfg.target_sr)
76
+ with torch.no_grad():
77
+ s = model.score(wav.to(args.device)).item()
78
+ print(f" AnimeScore({os.path.basename(args.wav)}) = {s:+.4f}")
79
+ else:
80
+ print("[4/4] (skipped) Pass --wav to score a real audio file.")
81
+
82
+ print("\nAll sanity checks passed.")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()