JacobLinCool commited on
Commit
4bfd79c
·
verified ·
1 Parent(s): 743951a

Complete the sampled kit provenance

Browse files
scripts/build_taiko_bank.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["numpy<2.1", "scipy", "soundfile"]
4
+ # ///
5
+ """Build SoftChart's redistributable arcade Taiko one-shot bank.
6
+
7
+ The input directory must contain the pinned CC0 Freesound HQ OGG previews
8
+ listed in ``SOURCES``. This script is intentionally networkless: it verifies
9
+ every source hash, renders deterministic mono PCM16 assets, and writes a
10
+ provenance manifest. Commercial-game reference audio is never an input.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import math
19
+ import wave
20
+ from dataclasses import dataclass
21
+ from fractions import Fraction
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import soundfile as sf
26
+ from scipy.signal import butter, resample_poly, sosfilt
27
+
28
+
29
+ SAMPLE_RATE = 44_100
30
+ PACK_URL = "https://freesound.org/people/sazanami12/packs/45781/"
31
+ CC0_URL = "https://creativecommons.org/publicdomain/zero/1.0/"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SourceSpec:
36
+ sound_id: int
37
+ label: str
38
+ sha256: str
39
+ preview_url: str
40
+
41
+
42
+ SOURCES = {
43
+ 856550: SourceSpec(856550, "center-soft", "78ed9dea56102480811e90a9653c08fe20a0eca5793c9c936f1c2592042b9a46", "https://cdn.freesound.org/previews/856/856550_17110092-hq.ogg"),
44
+ 856552: SourceSpec(856552, "edge-soft", "85600ed1ddfab1c56fe07268f5fd68073706da8cd9a2f0cf7f16bde30bb8bb3e", "https://cdn.freesound.org/previews/856/856552_17110092-hq.ogg"),
45
+ 856557: SourceSpec(856557, "rim-right", "6c24ee738d5995121fa85a432223a2afadb585f2a67b253747e2db0b3796bca0", "https://cdn.freesound.org/previews/856/856557_17110092-hq.ogg"),
46
+ 856558: SourceSpec(856558, "rim-left", "5ac219e4254f27c4a3b5b581f5c67e7c72fe91fa3d0fb2e03d021793febc1872", "https://cdn.freesound.org/previews/856/856558_17110092-hq.ogg"),
47
+ 856559: SourceSpec(856559, "rim-both-loud", "840a559e5d979e483cec2afb64e4f71a27d31c753c1bb138395303c5bd0d8253", "https://cdn.freesound.org/previews/856/856559_17110092-hq.ogg"),
48
+ }
49
+
50
+
51
+ def _sha256(path: Path) -> str:
52
+ digest = hashlib.sha256()
53
+ with path.open("rb") as handle:
54
+ for block in iter(lambda: handle.read(1 << 20), b""):
55
+ digest.update(block)
56
+ return digest.hexdigest()
57
+
58
+
59
+ def _load_source(source_dir: Path, sound_id: int) -> np.ndarray:
60
+ spec = SOURCES[sound_id]
61
+ path = source_dir / f"{sound_id}.ogg"
62
+ if not path.is_file():
63
+ raise FileNotFoundError(f"missing pinned CC0 source: {path}")
64
+ actual_hash = _sha256(path)
65
+ if actual_hash != spec.sha256:
66
+ raise ValueError(
67
+ f"source hash mismatch for {path.name}: {actual_hash} != {spec.sha256}"
68
+ )
69
+ audio, sample_rate = sf.read(path, dtype="float64", always_2d=True)
70
+ if sample_rate != SAMPLE_RATE or audio.shape[1] != 2:
71
+ raise ValueError(f"{path.name} must decode as 44.1 kHz stereo")
72
+ mono = np.mean(audio, axis=1)
73
+ peak = float(np.max(np.abs(mono)))
74
+ onset_candidates = np.flatnonzero(np.abs(mono) >= peak * 10.0 ** (-30.0 / 20.0))
75
+ if len(onset_candidates) == 0:
76
+ raise ValueError(f"{path.name} has no measurable onset")
77
+ start = max(0, int(onset_candidates[0]) - int(round(0.001 * SAMPLE_RATE)))
78
+ mono = mono[start:].copy()
79
+ mono -= float(np.mean(mono[: min(100, len(mono))]))
80
+ return mono
81
+
82
+
83
+ def _highpass(signal: np.ndarray, cutoff_hz: float) -> np.ndarray:
84
+ sos = butter(3, cutoff_hz, btype="highpass", fs=SAMPLE_RATE, output="sos")
85
+ return sosfilt(sos, signal)
86
+
87
+
88
+ def _pitch(signal: np.ndarray, factor: float) -> np.ndarray:
89
+ ratio = Fraction(1.0 / factor).limit_denominator(1_000)
90
+ return resample_poly(signal, ratio.numerator, ratio.denominator)
91
+
92
+
93
+ def _fit(signal: np.ndarray, frames: int) -> np.ndarray:
94
+ if len(signal) < frames:
95
+ return np.pad(signal, (0, frames - len(signal)))
96
+ return signal[:frames].copy()
97
+
98
+
99
+ def _normalize(signal: np.ndarray, peak: float = 1.0) -> np.ndarray:
100
+ measured = float(np.max(np.abs(signal)))
101
+ if measured <= 1e-12:
102
+ raise ValueError("cannot normalize a silent sample")
103
+ return signal * (peak / measured)
104
+
105
+
106
+ def _smooth_ramp(frames: int, seconds: float) -> np.ndarray:
107
+ position = np.arange(frames, dtype=np.float64) / SAMPLE_RATE
108
+ unit = np.clip(position / seconds, 0.0, 1.0)
109
+ return 0.06 + 0.94 * (unit * unit * (3.0 - 2.0 * unit))
110
+
111
+
112
+ def _tail_fade(signal: np.ndarray, start_seconds: float, end_seconds: float) -> np.ndarray:
113
+ start = int(round(start_seconds * SAMPLE_RATE))
114
+ end = min(len(signal), int(round(end_seconds * SAMPLE_RATE)))
115
+ if not 0 <= start < end <= len(signal):
116
+ raise ValueError("invalid tail fade")
117
+ envelope = np.ones(len(signal), dtype=np.float64)
118
+ envelope[start:end] = 0.5 + 0.5 * np.cos(np.linspace(0.0, np.pi, end - start))
119
+ envelope[end:] = 0.0
120
+ return signal * envelope
121
+
122
+
123
+ def _transient(source: dict[int, np.ndarray]) -> np.ndarray:
124
+ rim = _highpass(_pitch(source[856559], 2.15), 450.0)
125
+ search = rim[: int(round(0.100 * SAMPLE_RATE))]
126
+ peak = int(np.argmax(np.abs(search)))
127
+ start = max(0, peak - int(round(0.002 * SAMPLE_RATE)))
128
+ return _normalize(_fit(rim[start:], int(round(0.040 * SAMPLE_RATE))))
129
+
130
+
131
+ def _don_body(source: dict[int, np.ndarray], sound_id: int, frames: int,
132
+ ramp_seconds: float) -> np.ndarray:
133
+ body = _fit(_normalize(_highpass(source[sound_id], 35.0)), frames)
134
+ return body * _smooth_ramp(frames, ramp_seconds)
135
+
136
+
137
+ def _make_don(source: dict[int, np.ndarray], sound_id: int,
138
+ fade_start: float, fade_end: float) -> np.ndarray:
139
+ frames = int(round(0.830 * SAMPLE_RATE))
140
+ body = _don_body(source, sound_id, frames, 0.060)
141
+ hit = body + 0.15 * _fit(_transient(source), frames)
142
+ hit = np.tanh(_tail_fade(hit, fade_start, fade_end))
143
+ return _normalize(hit, 0.90)
144
+
145
+
146
+ def _make_big_don(source: dict[int, np.ndarray]) -> np.ndarray:
147
+ frames = int(round(0.895 * SAMPLE_RATE))
148
+ center = _don_body(source, 856550, frames, 0.020)
149
+ hit = center + 0.60 * _fit(_transient(source), frames)
150
+ hit = np.tanh(_tail_fade(hit, 0.380, 0.800))
151
+ return _normalize(hit, 0.94)
152
+
153
+
154
+ def _make_ka(source: dict[int, np.ndarray], primary_id: int,
155
+ secondary_id: int, secondary_gain: float) -> np.ndarray:
156
+ frames = int(round(0.407 * SAMPLE_RATE))
157
+ primary_raw = _pitch(source[primary_id], 2.15)
158
+ primary = _normalize(_highpass(primary_raw, 450.0))
159
+ secondary = _normalize(_highpass(_pitch(source[secondary_id], 0.85), 450.0))
160
+ hit = _fit(primary, frames) + secondary_gain * _fit(secondary, frames)
161
+ bright = _normalize(_highpass(_pitch(source[primary_id], 4.30), 1_800.0))
162
+ search = bright[: int(round(0.050 * SAMPLE_RATE))]
163
+ bright_peak = int(np.argmax(np.abs(search)))
164
+ bright_start = max(0, bright_peak - int(round(0.001 * SAMPLE_RATE)))
165
+ bright = _fit(bright[bright_start:], int(round(0.015 * SAMPLE_RATE)))
166
+ hit += 0.90 * _fit(bright, frames)
167
+ hit = _tail_fade(hit, 0.230, 0.407)
168
+ return _normalize(hit, 0.94)
169
+
170
+
171
+ def _short_hit(signal: np.ndarray, duration: float, fade_start: float,
172
+ peak: float) -> np.ndarray:
173
+ frames = int(round(duration * SAMPLE_RATE))
174
+ hit = _fit(signal, frames)
175
+ hit = _tail_fade(hit, fade_start, duration)
176
+ return _normalize(hit, peak)
177
+
178
+
179
+ def _build_assets(source: dict[int, np.ndarray]) -> dict[str, tuple[str, int, np.ndarray, list[int]]]:
180
+ don_1 = _make_don(source, 856552, 0.280, 0.710)
181
+ don_2 = _make_don(source, 856550, 0.220, 0.600)
182
+ don_big = _make_big_don(source)
183
+ ka_1 = _make_ka(source, 856559, 856557, 0.20)
184
+ ka_2 = _make_ka(source, 856557, 856558, 0.15)
185
+ ka_big = _normalize(0.70 * ka_1 + 0.70 * ka_2, 0.96)
186
+ roll_1 = _short_hit(don_1, 0.150, 0.065, 0.56)
187
+ roll_2 = _short_hit(don_2, 0.150, 0.065, 0.56)
188
+ roll_big_1 = _short_hit(don_big, 0.175, 0.080, 0.66)
189
+ roll_big_2 = _short_hit(0.72 * don_big + 0.28 * _fit(don_1, len(don_big)), 0.175, 0.080, 0.66)
190
+ balloon_1 = _short_hit(don_1, 0.135, 0.055, 0.54)
191
+ balloon_2 = _short_hit(don_2, 0.135, 0.055, 0.54)
192
+ return {
193
+ "don-01.wav": ("don", 1, don_1, [856552, 856559]),
194
+ "don-02.wav": ("don", 2, don_2, [856550, 856559]),
195
+ "don-big.wav": ("don_big", 1, don_big, [856550, 856559]),
196
+ "ka-01.wav": ("ka", 1, ka_1, [856559, 856557]),
197
+ "ka-02.wav": ("ka", 2, ka_2, [856557, 856558]),
198
+ "ka-big.wav": ("ka_big", 1, ka_big, [856559, 856557, 856558]),
199
+ "roll-01.wav": ("roll", 1, roll_1, [856552, 856559]),
200
+ "roll-02.wav": ("roll", 2, roll_2, [856550, 856559]),
201
+ "roll-big-01.wav": ("roll_big", 1, roll_big_1, [856550, 856559]),
202
+ "roll-big-02.wav": ("roll_big", 2, roll_big_2, [856550, 856552, 856559]),
203
+ "balloon-01.wav": ("balloon", 1, balloon_1, [856552, 856559]),
204
+ "balloon-02.wav": ("balloon", 2, balloon_2, [856550, 856559]),
205
+ }
206
+
207
+
208
+ def _profile(signal: np.ndarray) -> dict[str, float]:
209
+ energy = signal * signal
210
+ cumulative = np.cumsum(energy)
211
+ spectrum = np.abs(np.fft.rfft(signal * np.hanning(len(signal)))) ** 2
212
+ frequency = np.fft.rfftfreq(len(signal), 1.0 / SAMPLE_RATE)
213
+ spectrum /= max(float(np.sum(spectrum)), 1e-15)
214
+ attack = signal[: int(round(0.010 * SAMPLE_RATE))]
215
+ attack_spectrum = np.abs(np.fft.rfft(attack * np.hanning(len(attack)))) ** 2
216
+ attack_frequency = np.fft.rfftfreq(len(attack), 1.0 / SAMPLE_RATE)
217
+ attack_spectrum /= max(float(np.sum(attack_spectrum)), 1e-15)
218
+ return {
219
+ "centroid_hz": round(float(frequency @ spectrum), 3),
220
+ "attack_centroid_hz": round(float(attack_frequency @ attack_spectrum), 3),
221
+ "t90_seconds": round(float(np.searchsorted(cumulative, 0.90 * cumulative[-1]) / SAMPLE_RATE), 6),
222
+ "t99_seconds": round(float(np.searchsorted(cumulative, 0.99 * cumulative[-1]) / SAMPLE_RATE), 6),
223
+ "crest_db": round(float(20.0 * math.log10(np.max(np.abs(signal)) / np.sqrt(np.mean(energy)))), 3),
224
+ }
225
+
226
+
227
+ def _write_pcm16(path: Path, signal: np.ndarray) -> tuple[str, str, int]:
228
+ pcm = np.rint(np.clip(signal, -1.0, 1.0) * 32767.0).astype("<i2")
229
+ with wave.open(str(path), "wb") as output:
230
+ output.setnchannels(1)
231
+ output.setsampwidth(2)
232
+ output.setframerate(SAMPLE_RATE)
233
+ output.writeframes(pcm.tobytes())
234
+ return _sha256(path), hashlib.sha256(pcm.tobytes()).hexdigest(), len(pcm)
235
+
236
+
237
+ def build(source_dir: Path, output_dir: Path) -> None:
238
+ source = {sound_id: _load_source(source_dir, sound_id) for sound_id in SOURCES}
239
+ output_dir.mkdir(parents=True, exist_ok=True)
240
+ files = []
241
+ for filename, (kind, round_robin, signal, source_ids) in _build_assets(source).items():
242
+ file_hash, pcm_hash, frames = _write_pcm16(output_dir / filename, signal)
243
+ peak = float(np.max(np.abs(signal)))
244
+ anchor = int(np.flatnonzero(np.abs(signal) >= peak * 10.0 ** (-30.0 / 20.0))[0])
245
+ files.append({
246
+ "filename": filename,
247
+ "kind": kind,
248
+ "round_robin": round_robin,
249
+ "sha256": file_hash,
250
+ "pcm_sha256": pcm_hash,
251
+ "sample_rate": SAMPLE_RATE,
252
+ "channels": 1,
253
+ "frames": frames,
254
+ "anchor_frame": anchor,
255
+ "source_sound_ids": source_ids,
256
+ "profile": _profile(signal),
257
+ })
258
+ manifest = {
259
+ "schema_version": 1,
260
+ "kit_id": "softchart-arcade-taiko-cc0",
261
+ "license": "CC0-1.0",
262
+ "license_url": CC0_URL,
263
+ "source_pack_url": PACK_URL,
264
+ "source_creator": "sazanami12",
265
+ "source_instrument": "Chinese Huapengu / Dagu recorded in Taipei, Taiwan",
266
+ "source_encoding": "public Freesound HQ OGG preview of the CC0 original WAV",
267
+ "sources": [spec.__dict__ for spec in SOURCES.values()],
268
+ "files": sorted(files, key=lambda item: item["filename"]),
269
+ }
270
+ (output_dir / "manifest.json").write_text(
271
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
272
+ encoding="utf-8",
273
+ )
274
+
275
+
276
+ def main() -> None:
277
+ parser = argparse.ArgumentParser()
278
+ parser.add_argument("--source-dir", type=Path, required=True)
279
+ parser.add_argument("--output-dir", type=Path, required=True)
280
+ args = parser.parse_args()
281
+ build(args.source_dir, args.output_dir)
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
softchart/assets/taiko/README.md CHANGED
@@ -5,7 +5,8 @@ Drum Dagu Oneshots** pack recorded in Taipei by `sazanami12`:
5
 
6
  - Source: https://freesound.org/people/sazanami12/packs/45781/
7
  - License: CC0 1.0 — https://creativecommons.org/publicdomain/zero/1.0/
8
- - Build recipe: `scripts/build_taiko_bank.py`
 
9
  - Exact source and output hashes: `manifest.json`
10
 
11
  The source instrument is a Chinese Huapengu / Dagu, selected as a
 
5
 
6
  - Source: https://freesound.org/people/sazanami12/packs/45781/
7
  - License: CC0 1.0 — https://creativecommons.org/publicdomain/zero/1.0/
8
+ - Build recipe: `scripts/build_taiko_bank.py` in both the source repository and
9
+ the self-contained Space payload
10
  - Exact source and output hashes: `manifest.json`
11
 
12
  The source instrument is a Chinese Huapengu / Dagu, selected as a
softchart/assets/taiko/manifest.json CHANGED
@@ -8,24 +8,12 @@
8
  "source_instrument": "Chinese Huapengu / Dagu recorded in Taipei, Taiwan",
9
  "source_encoding": "public Freesound HQ OGG preview of the CC0 original WAV",
10
  "sources": [
11
- {
12
- "sound_id": 856549,
13
- "label": "center-loud",
14
- "sha256": "26d230aff7bb043fc88e823d7eecf00f807208f4c1d4d27c2cc1a7a2773b9d87",
15
- "preview_url": "https://cdn.freesound.org/previews/856/856549_17110092-hq.ogg"
16
- },
17
  {
18
  "sound_id": 856550,
19
  "label": "center-soft",
20
  "sha256": "78ed9dea56102480811e90a9653c08fe20a0eca5793c9c936f1c2592042b9a46",
21
  "preview_url": "https://cdn.freesound.org/previews/856/856550_17110092-hq.ogg"
22
  },
23
- {
24
- "sound_id": 856551,
25
- "label": "edge-loud",
26
- "sha256": "85ff2986eb902296e541a9d44b21bb61b9c2b8a05b5510be2fac9fbc99738399",
27
- "preview_url": "https://cdn.freesound.org/previews/856/856551_17110092-hq.ogg"
28
- },
29
  {
30
  "sound_id": 856552,
31
  "label": "edge-soft",
@@ -49,12 +37,6 @@
49
  "label": "rim-both-loud",
50
  "sha256": "840a559e5d979e483cec2afb64e4f71a27d31c753c1bb138395303c5bd0d8253",
51
  "preview_url": "https://cdn.freesound.org/previews/856/856559_17110092-hq.ogg"
52
- },
53
- {
54
- "sound_id": 856560,
55
- "label": "rim-both-soft",
56
- "sha256": "4d66f33d35d2fe160da02c9a310231cdd6f2e8724dcfa7706de9c8409b5b7f97",
57
- "preview_url": "https://cdn.freesound.org/previews/856/856560_17110092-hq.ogg"
58
  }
59
  ],
60
  "files": [
 
8
  "source_instrument": "Chinese Huapengu / Dagu recorded in Taipei, Taiwan",
9
  "source_encoding": "public Freesound HQ OGG preview of the CC0 original WAV",
10
  "sources": [
 
 
 
 
 
 
11
  {
12
  "sound_id": 856550,
13
  "label": "center-soft",
14
  "sha256": "78ed9dea56102480811e90a9653c08fe20a0eca5793c9c936f1c2592042b9a46",
15
  "preview_url": "https://cdn.freesound.org/previews/856/856550_17110092-hq.ogg"
16
  },
 
 
 
 
 
 
17
  {
18
  "sound_id": 856552,
19
  "label": "edge-soft",
 
37
  "label": "rim-both-loud",
38
  "sha256": "840a559e5d979e483cec2afb64e4f71a27d31c753c1bb138395303c5bd0d8253",
39
  "preview_url": "https://cdn.freesound.org/previews/856/856559_17110092-hq.ogg"
 
 
 
 
 
 
40
  }
41
  ],
42
  "files": [