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

Replace synthesized hits with a licensed sampled arcade kit

Browse files
README.md CHANGED
@@ -29,9 +29,11 @@ The Space uses Gradio Server mode with a fully custom, responsive interface.
29
  Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
30
  full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
31
  synchronized WAV mix that lets you hear the generated chart performed over
32
- the source music immediately. The original, arcade-tuned wadaiko kit is
33
- synthesized by SoftChart and does not contain audio taken from a commercial
34
- game.
 
 
35
 
36
  The generation API streams the real processing stages to the interface:
37
  audio analysis, beat-grid fitting, song-level planning, note generation,
 
29
  Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
30
  full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
31
  synchronized WAV mix that lets you hear the generated chart performed over
32
+ the source music immediately. The original, arcade-tuned kit is built from
33
+ hash-pinned CC0 head and rim recordings from the
34
+ [Chinese Bass Drum Dagu Oneshots](https://freesound.org/people/sazanami12/packs/45781/)
35
+ pack. SoftChart transforms those recordings offline and does not contain audio
36
+ taken from a commercial game.
37
 
38
  The generation API streams the real processing stages to the interface:
39
  audio analysis, beat-grid fitting, song-level planning, note generation,
softchart/assets/taiko/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SoftChart Arcade Taiko Kit
2
+
3
+ These one-shots are original SoftChart transformations of the **Chinese Bass
4
+ 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
12
+ redistributable large East-Asian barrel-drum recording with isolated head and
13
+ rim articulations. It is not represented as a Japanese wadaiko recording.
14
+
15
+ No audio from Taiko no Tatsujin or any other commercial game is included,
16
+ transformed, embedded, or required by the build.
softchart/assets/taiko/balloon-01.wav ADDED
Binary file (12 kB). View file
 
softchart/assets/taiko/balloon-02.wav ADDED
Binary file (12 kB). View file
 
softchart/assets/taiko/don-01.wav ADDED
Binary file (73.3 kB). View file
 
softchart/assets/taiko/don-02.wav ADDED
Binary file (73.3 kB). View file
 
softchart/assets/taiko/don-big.wav ADDED
Binary file (79 kB). View file
 
softchart/assets/taiko/ka-01.wav ADDED
Binary file (35.9 kB). View file
 
softchart/assets/taiko/ka-02.wav ADDED
Binary file (35.9 kB). View file
 
softchart/assets/taiko/ka-big.wav ADDED
Binary file (35.9 kB). View file
 
softchart/assets/taiko/manifest.json ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "kit_id": "softchart-arcade-taiko-cc0",
4
+ "license": "CC0-1.0",
5
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
6
+ "source_pack_url": "https://freesound.org/people/sazanami12/packs/45781/",
7
+ "source_creator": "sazanami12",
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",
32
+ "sha256": "85600ed1ddfab1c56fe07268f5fd68073706da8cd9a2f0cf7f16bde30bb8bb3e",
33
+ "preview_url": "https://cdn.freesound.org/previews/856/856552_17110092-hq.ogg"
34
+ },
35
+ {
36
+ "sound_id": 856557,
37
+ "label": "rim-right",
38
+ "sha256": "6c24ee738d5995121fa85a432223a2afadb585f2a67b253747e2db0b3796bca0",
39
+ "preview_url": "https://cdn.freesound.org/previews/856/856557_17110092-hq.ogg"
40
+ },
41
+ {
42
+ "sound_id": 856558,
43
+ "label": "rim-left",
44
+ "sha256": "5ac219e4254f27c4a3b5b581f5c67e7c72fe91fa3d0fb2e03d021793febc1872",
45
+ "preview_url": "https://cdn.freesound.org/previews/856/856558_17110092-hq.ogg"
46
+ },
47
+ {
48
+ "sound_id": 856559,
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": [
61
+ {
62
+ "filename": "balloon-01.wav",
63
+ "kind": "balloon",
64
+ "round_robin": 1,
65
+ "sha256": "241042aeb27bf7ca310c65fb1130554c3f5501b735d4178c9a65227a3c3962b7",
66
+ "pcm_sha256": "d86c418410b1f009dbc0a1f1cbc81dbe6ac52b1707002f7595c48c973fd309c0",
67
+ "sample_rate": 44100,
68
+ "channels": 1,
69
+ "frames": 5954,
70
+ "anchor_frame": 0,
71
+ "source_sound_ids": [
72
+ 856552,
73
+ 856559
74
+ ],
75
+ "profile": {
76
+ "centroid_hz": 144.684,
77
+ "attack_centroid_hz": 2085.291,
78
+ "t90_seconds": 0.088254,
79
+ "t99_seconds": 0.101973,
80
+ "crest_db": 10.255
81
+ }
82
+ },
83
+ {
84
+ "filename": "balloon-02.wav",
85
+ "kind": "balloon",
86
+ "round_robin": 2,
87
+ "sha256": "9e9b9ae2067e3b8c35d4bef011aea160ac6b2c955b861b48fcdd977e9784a96e",
88
+ "pcm_sha256": "90a5802c2b25e55fdfd39b394f505e302aa13e4435418c494aff807a51bdfefd",
89
+ "sample_rate": 44100,
90
+ "channels": 1,
91
+ "frames": 5954,
92
+ "anchor_frame": 0,
93
+ "source_sound_ids": [
94
+ 856550,
95
+ 856559
96
+ ],
97
+ "profile": {
98
+ "centroid_hz": 154.871,
99
+ "attack_centroid_hz": 2160.392,
100
+ "t90_seconds": 0.087846,
101
+ "t99_seconds": 0.101927,
102
+ "crest_db": 11.393
103
+ }
104
+ },
105
+ {
106
+ "filename": "don-01.wav",
107
+ "kind": "don",
108
+ "round_robin": 1,
109
+ "sha256": "b0ae8c300223ca2092da3ebac7d5318978926935cc5f5de63339ca06e4552c94",
110
+ "pcm_sha256": "2fcc51eed16cf540288ad5b99153afea9f4ba599dc4921595852792cd4ee2163",
111
+ "sample_rate": 44100,
112
+ "channels": 1,
113
+ "frames": 36603,
114
+ "anchor_frame": 0,
115
+ "source_sound_ids": [
116
+ 856552,
117
+ 856559
118
+ ],
119
+ "profile": {
120
+ "centroid_hz": 106.922,
121
+ "attack_centroid_hz": 2085.291,
122
+ "t90_seconds": 0.279342,
123
+ "t99_seconds": 0.424104,
124
+ "crest_db": 13.942
125
+ }
126
+ },
127
+ {
128
+ "filename": "don-02.wav",
129
+ "kind": "don",
130
+ "round_robin": 2,
131
+ "sha256": "668748702af4b40922954685d4b416f6402c905f1ef7e9172f13d1a201f05c14",
132
+ "pcm_sha256": "5116770059c6891ce90b2f0eb39e15ee9cc37a851d9df10165c9e15638f18ad7",
133
+ "sample_rate": 44100,
134
+ "channels": 1,
135
+ "frames": 36603,
136
+ "anchor_frame": 0,
137
+ "source_sound_ids": [
138
+ 856550,
139
+ 856559
140
+ ],
141
+ "profile": {
142
+ "centroid_hz": 113.558,
143
+ "attack_centroid_hz": 2160.392,
144
+ "t90_seconds": 0.286984,
145
+ "t99_seconds": 0.403673,
146
+ "crest_db": 14.121
147
+ }
148
+ },
149
+ {
150
+ "filename": "don-big.wav",
151
+ "kind": "don_big",
152
+ "round_robin": 1,
153
+ "sha256": "38d7bee2a772c45fe935bab378f7aedaf73746fb78972fc538cf37d8c81c20cd",
154
+ "pcm_sha256": "29823b5ff47f93217360db5019f2c805bf5e36c0658b7fb8bdf9fec051636ecf",
155
+ "sample_rate": 44100,
156
+ "channels": 1,
157
+ "frames": 39470,
158
+ "anchor_frame": 0,
159
+ "source_sound_ids": [
160
+ 856550,
161
+ 856559
162
+ ],
163
+ "profile": {
164
+ "centroid_hz": 105.346,
165
+ "attack_centroid_hz": 2440.045,
166
+ "t90_seconds": 0.379365,
167
+ "t99_seconds": 0.557619,
168
+ "crest_db": 13.246
169
+ }
170
+ },
171
+ {
172
+ "filename": "ka-01.wav",
173
+ "kind": "ka",
174
+ "round_robin": 1,
175
+ "sha256": "7091db93a0d8bcb9a58d08e00c1052c9c057784a72ed7a445e7e04e1fb854192",
176
+ "pcm_sha256": "c7d95d820c95ecaa955b0a47739b1c7ab025b4a99f9c076bbef7a1b18f30215d",
177
+ "sample_rate": 44100,
178
+ "channels": 1,
179
+ "frames": 17949,
180
+ "anchor_frame": 0,
181
+ "source_sound_ids": [
182
+ 856559,
183
+ 856557
184
+ ],
185
+ "profile": {
186
+ "centroid_hz": 2083.859,
187
+ "attack_centroid_hz": 4187.299,
188
+ "t90_seconds": 0.061202,
189
+ "t99_seconds": 0.123469,
190
+ "crest_db": 20.018
191
+ }
192
+ },
193
+ {
194
+ "filename": "ka-02.wav",
195
+ "kind": "ka",
196
+ "round_robin": 2,
197
+ "sha256": "6dfcaad357908e139e98e62d748659ef0484337b7f04b66db6a4f4c4071120a8",
198
+ "pcm_sha256": "b8d50d62a9a71db0effbb34421958aa40776e44f0573baa271f32cb6881e96ce",
199
+ "sample_rate": 44100,
200
+ "channels": 1,
201
+ "frames": 17949,
202
+ "anchor_frame": 0,
203
+ "source_sound_ids": [
204
+ 856557,
205
+ 856558
206
+ ],
207
+ "profile": {
208
+ "centroid_hz": 1926.337,
209
+ "attack_centroid_hz": 3862.82,
210
+ "t90_seconds": 0.058141,
211
+ "t99_seconds": 0.107732,
212
+ "crest_db": 22.327
213
+ }
214
+ },
215
+ {
216
+ "filename": "ka-big.wav",
217
+ "kind": "ka_big",
218
+ "round_robin": 1,
219
+ "sha256": "dd72eb84c579eaa9ec2f04fe1e78f1cc497b5bf425b32a75788398379b1f2964",
220
+ "pcm_sha256": "40e876586926aecc06b91bfc60a1d00d33832fd34f334799d7c46e16921a6ea4",
221
+ "sample_rate": 44100,
222
+ "channels": 1,
223
+ "frames": 17949,
224
+ "anchor_frame": 0,
225
+ "source_sound_ids": [
226
+ 856559,
227
+ 856557,
228
+ 856558
229
+ ],
230
+ "profile": {
231
+ "centroid_hz": 1986.528,
232
+ "attack_centroid_hz": 4484.582,
233
+ "t90_seconds": 0.061224,
234
+ "t99_seconds": 0.125624,
235
+ "crest_db": 19.782
236
+ }
237
+ },
238
+ {
239
+ "filename": "roll-01.wav",
240
+ "kind": "roll",
241
+ "round_robin": 1,
242
+ "sha256": "12bdee9caf1c8b17f2c56cce90078f25837a9670c939ceaabdd4cc524ebd9100",
243
+ "pcm_sha256": "19c20b070f3f23eab146d1a3b864a5827e68afe52224ccd3d7b23a2413f242a6",
244
+ "sample_rate": 44100,
245
+ "channels": 1,
246
+ "frames": 6615,
247
+ "anchor_frame": 0,
248
+ "source_sound_ids": [
249
+ 856552,
250
+ 856559
251
+ ],
252
+ "profile": {
253
+ "centroid_hz": 139.363,
254
+ "attack_centroid_hz": 2085.291,
255
+ "t90_seconds": 0.094785,
256
+ "t99_seconds": 0.113311,
257
+ "crest_db": 9.782
258
+ }
259
+ },
260
+ {
261
+ "filename": "roll-02.wav",
262
+ "kind": "roll",
263
+ "round_robin": 2,
264
+ "sha256": "2abd129bdb4e8c73aa44d071c386a8fc643f613c9b0cec1c84a339073e8bc681",
265
+ "pcm_sha256": "d4f9ca68263e903188eed4844a70041c38bb8f1c026331c28802771de606274b",
266
+ "sample_rate": 44100,
267
+ "channels": 1,
268
+ "frames": 6615,
269
+ "anchor_frame": 0,
270
+ "source_sound_ids": [
271
+ 856550,
272
+ 856559
273
+ ],
274
+ "profile": {
275
+ "centroid_hz": 154.227,
276
+ "attack_centroid_hz": 2160.392,
277
+ "t90_seconds": 0.096259,
278
+ "t99_seconds": 0.114195,
279
+ "crest_db": 11.106
280
+ }
281
+ },
282
+ {
283
+ "filename": "roll-big-01.wav",
284
+ "kind": "roll_big",
285
+ "round_robin": 1,
286
+ "sha256": "23fea6acc0d2c554353af445ea8aa887555ce2df3268540256922f695158909a",
287
+ "pcm_sha256": "7d3c290e8c18c786dd46633483d03c7ae3b83dfd83410c1262bd3e2d4dd8e82e",
288
+ "sample_rate": 44100,
289
+ "channels": 1,
290
+ "frames": 7717,
291
+ "anchor_frame": 0,
292
+ "source_sound_ids": [
293
+ 856550,
294
+ 856559
295
+ ],
296
+ "profile": {
297
+ "centroid_hz": 157.954,
298
+ "attack_centroid_hz": 2440.045,
299
+ "t90_seconds": 0.100317,
300
+ "t99_seconds": 0.129501,
301
+ "crest_db": 9.349
302
+ }
303
+ },
304
+ {
305
+ "filename": "roll-big-02.wav",
306
+ "kind": "roll_big",
307
+ "round_robin": 2,
308
+ "sha256": "edfd7d6225ac7e14d554b7ec946455d1d942f87c00e370d0cae2b3e62a22e2fd",
309
+ "pcm_sha256": "20289804cdb38f0b6dc6e300e7e8b0b7f32c7cc485e990db4a0a53a0055c47e5",
310
+ "sample_rate": 44100,
311
+ "channels": 1,
312
+ "frames": 7717,
313
+ "anchor_frame": 0,
314
+ "source_sound_ids": [
315
+ 856550,
316
+ 856552,
317
+ 856559
318
+ ],
319
+ "profile": {
320
+ "centroid_hz": 153.15,
321
+ "attack_centroid_hz": 2438.852,
322
+ "t90_seconds": 0.094399,
323
+ "t99_seconds": 0.125283,
324
+ "crest_db": 9.633
325
+ }
326
+ }
327
+ ]
328
+ }
softchart/assets/taiko/roll-01.wav ADDED
Binary file (13.3 kB). View file
 
softchart/assets/taiko/roll-02.wav ADDED
Binary file (13.3 kB). View file
 
softchart/assets/taiko/roll-big-01.wav ADDED
Binary file (15.5 kB). View file
 
softchart/assets/taiko/roll-big-02.wav ADDED
Binary file (15.5 kB). View file
 
softchart/preview_audio.py CHANGED
@@ -1,28 +1,31 @@
1
- """Deterministic game-style wadaiko preview synthesis.
2
 
3
  The chart is the timing authority: :func:`parse_tja_events` converts the
4
  repo-emitted TJA subset to audio-clock events, including OFFSET, measure-local
5
- subdivisions, and piecewise BPM changes. Drum timbres are synthesized from
6
- membrane/rim resonances and filtered noise; no recorded or proprietary game
7
- assets are used.
8
  """
9
 
10
  from __future__ import annotations
11
 
 
 
12
  import math
13
  import os
14
  import wave
15
  from dataclasses import dataclass
16
  from fractions import Fraction
 
17
 
18
  import numpy as np
19
- from scipy.signal import butter, resample_poly, sosfilt
20
 
21
 
22
  _HIT_KIND = {"1": "don", "2": "ka", "3": "don_big", "4": "ka_big"}
23
  _SPAN_KIND = {"5": "roll", "6": "roll_big", "7": "balloon"}
24
  _ALLOWED_COMMANDS = {"#GOGOSTART", "#GOGOEND"}
25
  _PCM16_CEILING = int(math.floor(32767 * 10.0 ** (-1.0 / 20.0)))
 
26
 
27
 
28
  @dataclass(frozen=True)
@@ -56,6 +59,12 @@ class _Strike:
56
  autoplay: bool
57
 
58
 
 
 
 
 
 
 
59
  def _finite_float(value: str, field: str) -> float:
60
  try:
61
  result = float(value)
@@ -217,140 +226,92 @@ def parse_tja_events(tja_text: str) -> list[TaikoEvent]:
217
  return sorted(events, key=lambda event: event.time)
218
 
219
 
220
- def _filtered_noise(rng: np.random.Generator, length: int, sample_rate: int,
221
- low_hz: float, high_hz: float) -> np.ndarray:
222
- nyquist = sample_rate / 2.0
223
- low = max(20.0, min(low_hz, nyquist * 0.90))
224
- high = max(low + 20.0, min(high_hz, nyquist * 0.97))
225
- sos = butter(3, (low, high), btype="bandpass", fs=sample_rate, output="sos")
226
- noise = sosfilt(sos, rng.standard_normal(length)).astype(np.float64)
227
- rms = float(np.sqrt(np.mean(noise * noise)))
228
- return noise / max(rms, 1e-12)
229
-
230
-
231
- def _falling_mode(t: np.ndarray, start_hz: float, end_hz: float,
232
- pitch_tau: float, decay: float) -> np.ndarray:
233
- phase = 2.0 * np.pi * (
234
- end_hz * t + (start_hz - end_hz) * pitch_tau * (1.0 - np.exp(-t / pitch_tau))
235
- )
236
- return np.sin(phase) * np.exp(-t / decay)
237
-
238
-
239
- def _highpass(signal: np.ndarray, sample_rate: int, cutoff_hz: float) -> np.ndarray:
240
- sos = butter(2, cutoff_hz, btype="highpass", fs=sample_rate, output="sos")
241
- return sosfilt(sos, signal).astype(np.float64)
242
-
243
-
244
- def _soft_clip(signal: np.ndarray, drive: float) -> np.ndarray:
245
- """Compress peaks while keeping the small-signal gain at unity."""
246
-
247
- return np.tanh(signal * drive) / drive
248
-
249
-
250
- def _make_don(sample_rate: int, variant: int, big: bool = False,
251
- tap: bool = False) -> np.ndarray:
252
- # Compact arcade feedback needs the mid-bass membrane cluster and bachi
253
- # attack to survive small speakers. The frequencies are based on measured
254
- # wadaiko mode relationships, deliberately omitting kick-like sub-bass and
255
- # the large downward pitch sweep used by the previous voice.
256
- duration = 0.105 if tap else (0.29 if big else 0.27)
257
- n = int(round(duration * sample_rate))
258
- t = np.arange(n, dtype=np.float64) / sample_rate
259
- rng = np.random.default_rng(12_710 + 101 * variant + 1_009 * int(big) + 7_003 * int(tap))
260
- pitch = (0.982, 0.994, 1.008, 1.019)[variant % 4]
261
- attack = 1.0 - np.exp(-t / 0.00024)
262
-
263
- modes = np.zeros(n, dtype=np.float64)
264
- frequencies = (255.0, 295.0, 560.0, 690.0, 1_015.0)
265
- weights = (0.82, 1.00, 0.47, 0.32, 0.17)
266
- decays = (0.185, 0.165, 0.095, 0.068, 0.040)
267
- decay_scale = 0.31 if tap else (1.06 if big else 1.0)
268
- for frequency, weight, decay in zip(frequencies, weights, decays):
269
- end_hz = frequency * pitch
270
- modes += weight * _falling_mode(
271
- t, end_hz * (1.012 + 0.002 * variant), end_hz,
272
- 0.009, decay * decay_scale,
273
- )
274
-
275
- skin = _filtered_noise(rng, n, sample_rate, 380.0, 5_400.0)
276
- skin *= np.exp(-t / (0.0042 if tap else 0.0075))
277
- stick = _filtered_noise(rng, n, sample_rate, 1_300.0, 9_500.0)
278
- stick *= np.exp(-t / 0.0018)
279
- signal = (
280
- attack * modes
281
- + (0.30 if tap else 0.20) * skin
282
- + (0.17 if tap else 0.105) * stick
283
- )
284
- signal = _highpass(signal, sample_rate, 115.0 if tap else 62.0)
285
- signal = _soft_clip(signal, 1.42)
286
- signal *= np.exp(-np.maximum(t - duration * 0.72, 0.0) / 0.018)
287
- target = 0.59 if tap else (0.90 if big else 0.84)
288
- signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
289
- return signal.astype(np.float32)
290
-
291
-
292
- def _make_ka(sample_rate: int, variant: int, big: bool = False) -> np.ndarray:
293
- # Ka is a dry wooden rim crack: a millisecond-scale excitation followed by
294
- # short, irregular high modes. Broadband noise is only the bachi contact,
295
- # not the body of the sound.
296
- duration = 0.16 if big else 0.14
297
- n = int(round(duration * sample_rate))
298
- t = np.arange(n, dtype=np.float64) / sample_rate
299
- rng = np.random.default_rng(37_901 + 131 * variant + 2_003 * int(big))
300
- pitch = (0.981, 0.995, 1.009, 1.021)[variant % 4]
301
- attack = 1.0 - np.exp(-t / 0.00016)
302
-
303
- rim = _filtered_noise(rng, n, sample_rate, 1_200.0, 10_500.0)
304
- rim *= np.exp(-t / 0.0028)
305
- modes = np.zeros(n, dtype=np.float64)
306
- for frequency, weight, decay in (
307
- (650.0, 0.42, 0.064), (1_050.0, 1.00, 0.048),
308
- (1_700.0, 0.63, 0.034), (2_700.0, 0.35, 0.022),
309
- (4_200.0, 0.19, 0.012),
310
- ):
311
- phase = 2.0 * np.pi * frequency * pitch * t + variant * 0.31
312
- modes += weight * np.sin(phase) * np.exp(-t / (decay * (1.04 if big else 1.0)))
313
- signal = 0.22 * rim + attack * modes
314
- signal = _highpass(signal, sample_rate, 390.0)
315
- signal = _soft_clip(signal, 1.28)
316
- signal *= np.exp(-np.maximum(t - duration * 0.68, 0.0) / 0.012)
317
- target = 0.91 if big else 0.79
318
- signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
319
- return signal.astype(np.float32)
320
-
321
-
322
- _BANKS: dict[int, dict[str, tuple[np.ndarray, ...]]] = {}
323
-
324
-
325
- def _sample_bank(sample_rate: int) -> dict[str, tuple[np.ndarray, ...]]:
326
  bank = _BANKS.get(sample_rate)
327
- if bank is None:
328
- bank = {
329
- "don": tuple(_make_don(sample_rate, i) for i in range(4)),
330
- "ka": tuple(_make_ka(sample_rate, i) for i in range(4)),
331
- "don_big": tuple(_make_don(sample_rate, i, big=True) for i in range(4)),
332
- "ka_big": tuple(_make_ka(sample_rate, i, big=True) for i in range(4)),
333
- "roll": tuple(_make_don(sample_rate, i, tap=True) for i in range(4)),
334
- "roll_big": tuple(_make_don(sample_rate, i, big=True, tap=True) for i in range(4)),
335
- "balloon": tuple(_make_don(sample_rate, i, tap=True) for i in range(4)),
336
- }
337
- _BANKS[sample_rate] = bank
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  return bank
339
 
340
 
 
 
 
341
  def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
342
  strikes: list[_Strike] = []
343
  autoplay_count = 0
344
  ordinary_index = 0
345
  for event in events:
346
  if event.kind in {"don", "ka", "don_big", "ka_big"}:
347
- if event.kind.endswith("_big"):
348
- pan = -0.35 if ordinary_index % 2 == 0 else 0.35
349
- elif event.kind == "ka":
350
- pan = -0.14 if ordinary_index % 2 == 0 else 0.14
351
- else:
352
- pan = -0.02 if ordinary_index % 2 == 0 else 0.02
353
- depth = 0.30 if event.kind.endswith("_big") else 0.22
354
  strikes.append(_Strike(event.time, event.kind, pan, depth, False))
355
  ordinary_index += 1
356
  continue
@@ -371,7 +332,7 @@ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
371
  kind = event.kind
372
  depth = 0.085 if event.kind == "roll_big" else 0.060
373
  for index, time in enumerate(times):
374
- pan_width = 0.24 if kind == "roll_big" else 0.18
375
  pan = -pan_width if index % 2 == 0 else pan_width
376
  strikes.append(_Strike(float(time), kind, pan, depth, True))
377
  autoplay_count += 1
@@ -412,9 +373,10 @@ def _load_stereo_audio(audio_path: str, sample_rate: int) -> np.ndarray:
412
  return np.ascontiguousarray(audio, dtype=np.float32)
413
 
414
 
415
- def _place_strike(drums: np.ndarray, sample: np.ndarray, strike: _Strike,
416
- sample_rate: int, layered_sample: np.ndarray | None = None) -> bool:
417
- start = int(round(strike.time * sample_rate))
 
418
  sample_start = max(0, -start)
419
  output_start = max(0, start)
420
  count = min(len(sample) - sample_start, len(drums) - output_start)
@@ -426,23 +388,6 @@ def _place_strike(drums: np.ndarray, sample: np.ndarray, strike: _Strike,
426
  segment = sample[sample_start:sample_start + count]
427
  drums[output_start:output_start + count, 0] += segment * left_gain
428
  drums[output_start:output_start + count, 1] += segment * right_gain
429
-
430
- # A big note represents both hands, not a lower-pitched or longer drum.
431
- # Layer an independently synthesized variant on the opposite side a few
432
- # milliseconds later while keeping the first transient exactly on-grid.
433
- if layered_sample is not None:
434
- layer_delay = int(round(0.0018 * sample_rate))
435
- layer_start = output_start + layer_delay
436
- layer_source_start = sample_start
437
- layer_count = min(
438
- len(layered_sample) - layer_source_start,
439
- len(drums) - layer_start,
440
- )
441
- if layer_count > 0:
442
- layer = layered_sample[layer_source_start:layer_source_start + layer_count]
443
- layer_gain = 0.72 if strike.kind == "roll_big" else 0.92
444
- drums[layer_start:layer_start + layer_count, 0] += layer * right_gain * layer_gain
445
- drums[layer_start:layer_start + layer_count, 1] += layer * left_gain * layer_gain
446
  return True
447
 
448
 
@@ -519,14 +464,15 @@ def synthesize_taiko_preview(
519
  events = parse_tja_events(tja_text)
520
  music = _load_stereo_audio(audio_path, sample_rate)
521
  strikes, autoplay_count = _expand_strikes(events)
522
- bank = _sample_bank(sample_rate)
523
  source_frames = len(music)
524
  output_frames = source_frames
525
  for index, strike in enumerate(strikes):
526
- sample = bank[strike.kind][index % len(bank[strike.kind])]
 
527
  output_frames = max(
528
  output_frames,
529
- max(0, int(round(strike.time * sample_rate)) + len(sample)),
530
  )
531
  if output_frames > source_frames:
532
  music = np.pad(music, ((0, output_frames - source_frames), (0, 0)))
@@ -534,17 +480,11 @@ def synthesize_taiko_preview(
534
  audible_strikes: list[_Strike] = []
535
 
536
  for index, strike in enumerate(strikes):
537
- samples = bank[strike.kind]
538
- sample = samples[index % len(samples)]
539
- layered_sample = None
540
- if strike.kind in {"don_big", "ka_big", "roll_big"}:
541
- layered_sample = samples[(index + 1) % len(samples)]
542
- if _place_strike(
543
- drums, sample, strike, sample_rate, layered_sample=layered_sample,
544
- ):
545
  audible_strikes.append(strike)
546
 
547
- drums = _soft_clip(drums, 1.10).astype(np.float32)
548
  duck_gain, max_duck_db = _duck_envelope(
549
  len(music), audible_strikes, sample_rate, drum_gain,
550
  )
 
1
+ """Deterministic sample-based arcade Taiko preview mixing.
2
 
3
  The chart is the timing authority: :func:`parse_tja_events` converts the
4
  repo-emitted TJA subset to audio-clock events, including OFFSET, measure-local
5
+ subdivisions, and piecewise BPM changes. Drum timbres come from a bundled,
6
+ hash-validated CC0 large barrel-drum kit; no commercial-game audio is used.
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
+ import hashlib
12
+ import json
13
  import math
14
  import os
15
  import wave
16
  from dataclasses import dataclass
17
  from fractions import Fraction
18
+ from pathlib import Path
19
 
20
  import numpy as np
21
+ from scipy.signal import resample_poly
22
 
23
 
24
  _HIT_KIND = {"1": "don", "2": "ka", "3": "don_big", "4": "ka_big"}
25
  _SPAN_KIND = {"5": "roll", "6": "roll_big", "7": "balloon"}
26
  _ALLOWED_COMMANDS = {"#GOGOSTART", "#GOGOEND"}
27
  _PCM16_CEILING = int(math.floor(32767 * 10.0 ** (-1.0 / 20.0)))
28
+ _ASSET_DIR = Path(__file__).with_name("assets") / "taiko"
29
 
30
 
31
  @dataclass(frozen=True)
 
59
  autoplay: bool
60
 
61
 
62
+ @dataclass(frozen=True)
63
+ class _Voice:
64
+ samples: np.ndarray
65
+ anchor_frame: int
66
+
67
+
68
  def _finite_float(value: str, field: str) -> float:
69
  try:
70
  result = float(value)
 
226
  return sorted(events, key=lambda event: event.time)
227
 
228
 
229
+ _BANKS: dict[int, dict[str, tuple[_Voice, ...]]] = {}
230
+ _REQUIRED_VOICES = {
231
+ "don": 2,
232
+ "ka": 2,
233
+ "don_big": 1,
234
+ "ka_big": 1,
235
+ "roll": 2,
236
+ "roll_big": 2,
237
+ "balloon": 2,
238
+ }
239
+
240
+
241
+ def _load_pcm16_voice(path: Path, anchor_frame: int,
242
+ sample_rate: int) -> _Voice:
243
+ with wave.open(str(path), "rb") as source:
244
+ if source.getnchannels() != 1 or source.getsampwidth() != 2:
245
+ raise ValueError(f"Taiko asset must be mono PCM16: {path.name}")
246
+ source_rate = source.getframerate()
247
+ pcm = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
248
+ if source_rate != 44_100:
249
+ raise ValueError(f"Taiko asset must be 44.1 kHz: {path.name}")
250
+ samples = pcm.astype(np.float32) / 32767.0
251
+ if sample_rate != source_rate:
252
+ ratio = Fraction(sample_rate, source_rate).limit_denominator()
253
+ samples = resample_poly(samples, ratio.numerator, ratio.denominator).astype(np.float32)
254
+ anchor_frame = int(round(anchor_frame * sample_rate / source_rate))
255
+ if not 0 <= anchor_frame < len(samples):
256
+ raise ValueError(f"Taiko asset anchor is outside the sample: {path.name}")
257
+ return _Voice(np.ascontiguousarray(samples), anchor_frame)
258
+
259
+
260
+ def _sample_bank(sample_rate: int) -> dict[str, tuple[_Voice, ...]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  bank = _BANKS.get(sample_rate)
262
+ if bank is not None:
263
+ return bank
264
+
265
+ manifest_path = _ASSET_DIR / "manifest.json"
266
+ if not manifest_path.is_file():
267
+ raise FileNotFoundError(f"Taiko asset manifest is missing: {manifest_path}")
268
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
269
+ if manifest.get("schema_version") != 1 or manifest.get("license") != "CC0-1.0":
270
+ raise ValueError("Taiko asset manifest must be schema 1 and CC0-1.0")
271
+
272
+ grouped: dict[str, list[tuple[int, _Voice]]] = {kind: [] for kind in _REQUIRED_VOICES}
273
+ for item in manifest.get("files", []):
274
+ kind = item.get("kind")
275
+ if kind not in grouped:
276
+ raise ValueError(f"unsupported Taiko asset kind in manifest: {kind!r}")
277
+ filename = item.get("filename")
278
+ if not isinstance(filename, str) or Path(filename).name != filename:
279
+ raise ValueError("Taiko asset filename must be a plain basename")
280
+ path = _ASSET_DIR / filename
281
+ if not path.is_file():
282
+ raise FileNotFoundError(f"Taiko asset is missing: {path}")
283
+ file_hash = hashlib.sha256(path.read_bytes()).hexdigest()
284
+ if file_hash != item.get("sha256"):
285
+ raise ValueError(f"Taiko asset hash mismatch: {filename}")
286
+ voice = _load_pcm16_voice(path, int(item["anchor_frame"]), sample_rate)
287
+ grouped[kind].append((int(item["round_robin"]), voice))
288
+
289
+ bank = {}
290
+ for kind, expected_count in _REQUIRED_VOICES.items():
291
+ entries = sorted(grouped[kind], key=lambda entry: entry[0])
292
+ if len(entries) != expected_count:
293
+ raise ValueError(
294
+ f"Taiko kit requires {expected_count} {kind} voice(s), got {len(entries)}"
295
+ )
296
+ if [index for index, _ in entries] != list(range(1, expected_count + 1)):
297
+ raise ValueError(f"Taiko {kind} round-robin indices must be contiguous from 1")
298
+ bank[kind] = tuple(voice for _, voice in entries)
299
+ _BANKS[sample_rate] = bank
300
  return bank
301
 
302
 
303
+ _DEFAULT_BANK = _sample_bank(44_100)
304
+
305
+
306
  def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
307
  strikes: list[_Strike] = []
308
  autoplay_count = 0
309
  ordinary_index = 0
310
  for event in events:
311
  if event.kind in {"don", "ka", "don_big", "ka_big"}:
312
+ pan_width = 0.08 if event.kind in {"ka", "ka_big"} else 0.015
313
+ pan = -pan_width if ordinary_index % 2 == 0 else pan_width
314
+ depth = 0.26 if event.kind.endswith("_big") else 0.22
 
 
 
 
315
  strikes.append(_Strike(event.time, event.kind, pan, depth, False))
316
  ordinary_index += 1
317
  continue
 
332
  kind = event.kind
333
  depth = 0.085 if event.kind == "roll_big" else 0.060
334
  for index, time in enumerate(times):
335
+ pan_width = 0.10 if kind == "roll_big" else 0.06
336
  pan = -pan_width if index % 2 == 0 else pan_width
337
  strikes.append(_Strike(float(time), kind, pan, depth, True))
338
  autoplay_count += 1
 
373
  return np.ascontiguousarray(audio, dtype=np.float32)
374
 
375
 
376
+ def _place_strike(drums: np.ndarray, voice: _Voice, strike: _Strike,
377
+ sample_rate: int) -> bool:
378
+ sample = voice.samples
379
+ start = int(round(strike.time * sample_rate)) - voice.anchor_frame
380
  sample_start = max(0, -start)
381
  output_start = max(0, start)
382
  count = min(len(sample) - sample_start, len(drums) - output_start)
 
388
  segment = sample[sample_start:sample_start + count]
389
  drums[output_start:output_start + count, 0] += segment * left_gain
390
  drums[output_start:output_start + count, 1] += segment * right_gain
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  return True
392
 
393
 
 
464
  events = parse_tja_events(tja_text)
465
  music = _load_stereo_audio(audio_path, sample_rate)
466
  strikes, autoplay_count = _expand_strikes(events)
467
+ bank = _DEFAULT_BANK if sample_rate == 44_100 else _sample_bank(sample_rate)
468
  source_frames = len(music)
469
  output_frames = source_frames
470
  for index, strike in enumerate(strikes):
471
+ voice = bank[strike.kind][index % len(bank[strike.kind])]
472
+ start = int(round(strike.time * sample_rate)) - voice.anchor_frame
473
  output_frames = max(
474
  output_frames,
475
+ max(0, start + len(voice.samples)),
476
  )
477
  if output_frames > source_frames:
478
  music = np.pad(music, ((0, output_frames - source_frames), (0, 0)))
 
480
  audible_strikes: list[_Strike] = []
481
 
482
  for index, strike in enumerate(strikes):
483
+ voices = bank[strike.kind]
484
+ voice = voices[index % len(voices)]
485
+ if _place_strike(drums, voice, strike, sample_rate):
 
 
 
 
 
486
  audible_strikes.append(strike)
487
 
 
488
  duck_gain, max_duck_db = _duck_envelope(
489
  len(music), audible_strikes, sample_rate, drum_gain,
490
  )