JacobLinCool commited on
Commit
c0221ab
·
verified ·
1 Parent(s): 0755c2a

Retune the original Taiko kit for arcade punch

Browse files
Files changed (2) hide show
  1. README.md +3 -2
  2. softchart/preview_audio.py +103 -67
README.md CHANGED
@@ -29,8 +29,9 @@ 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 game-style wadaiko kit is synthesized by
33
- SoftChart and does not contain audio taken from a commercial game.
 
34
 
35
  The generation API streams the real processing stages to the interface:
36
  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 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,
softchart/preview_audio.py CHANGED
@@ -236,73 +236,85 @@ def _falling_mode(t: np.ndarray, start_hz: float, end_hz: float,
236
  return np.sin(phase) * np.exp(-t / decay)
237
 
238
 
239
- def _add_reflection(signal: np.ndarray, sample_rate: int, delay_seconds: float,
240
- gain: float) -> None:
241
- delay = max(1, int(round(delay_seconds * sample_rate)))
242
- if delay < len(signal):
243
- signal[delay:] += gain * signal[:-delay]
 
 
 
 
244
 
245
 
246
  def _make_don(sample_rate: int, variant: int, big: bool = False,
247
  tap: bool = False) -> np.ndarray:
248
- duration = 0.19 if tap else (0.62 if big else 0.43)
 
 
 
 
249
  n = int(round(duration * sample_rate))
250
  t = np.arange(n, dtype=np.float64) / sample_rate
251
  rng = np.random.default_rng(12_710 + 101 * variant + 1_009 * int(big) + 7_003 * int(tap))
252
- pitch = (0.965, 0.985, 1.010, 1.030)[variant % 4] * (0.84 if big else 1.0)
253
- if tap:
254
- pitch *= 1.10
255
- attack = 1.0 - np.exp(-t / 0.00075)
256
 
257
  modes = np.zeros(n, dtype=np.float64)
258
- frequencies = (104.0, 166.0, 248.0, 362.0)
259
- weights = (1.00, 0.54, 0.31, 0.17)
260
- decays = (0.27, 0.19, 0.125, 0.080)
261
- decay_scale = 0.55 if tap else (1.35 if big else 1.0)
262
  for frequency, weight, decay in zip(frequencies, weights, decays):
263
  end_hz = frequency * pitch
264
  modes += weight * _falling_mode(
265
- t, end_hz * (1.24 + 0.015 * variant), end_hz,
266
- 0.017 if tap else 0.026, decay * decay_scale,
267
  )
268
 
269
- skin = _filtered_noise(rng, n, sample_rate, 150.0, 3_600.0)
270
- skin *= np.exp(-t / (0.012 if tap else 0.021))
271
- stick = _filtered_noise(rng, n, sample_rate, 900.0, 7_200.0)
272
- stick *= np.exp(-t / 0.0045)
273
- signal = attack * modes + 0.26 * skin + 0.075 * stick
274
- if big:
275
- signal += 0.34 * _falling_mode(t, 89.0, 66.0, 0.038, 0.38)
276
- _add_reflection(signal, sample_rate, 0.0083, 0.075 if tap else 0.11)
277
- _add_reflection(signal, sample_rate, 0.0171, 0.035 if tap else 0.065)
278
- signal *= np.exp(-np.maximum(t - duration * 0.78, 0.0) / 0.035)
279
- target = 0.43 if tap else (0.94 if big else 0.78)
 
 
280
  signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
281
  return signal.astype(np.float32)
282
 
283
 
284
  def _make_ka(sample_rate: int, variant: int, big: bool = False) -> np.ndarray:
285
- duration = 0.35 if big else 0.25
 
 
 
286
  n = int(round(duration * sample_rate))
287
  t = np.arange(n, dtype=np.float64) / sample_rate
288
  rng = np.random.default_rng(37_901 + 131 * variant + 2_003 * int(big))
289
- pitch = (0.970, 0.992, 1.015, 1.035)[variant % 4] * (0.90 if big else 1.0)
290
- attack = 1.0 - np.exp(-t / 0.00035)
291
 
292
- rim = _filtered_noise(rng, n, sample_rate, 620.0, 9_200.0)
293
- rim *= np.exp(-t / (0.024 if big else 0.016))
294
  modes = np.zeros(n, dtype=np.float64)
295
  for frequency, weight, decay in (
296
- (535.0, 0.48, 0.105), (885.0, 0.36, 0.074),
297
- (1_410.0, 0.27, 0.050), (2_270.0, 0.16, 0.032),
 
298
  ):
299
  phase = 2.0 * np.pi * frequency * pitch * t + variant * 0.31
300
- modes += weight * np.sin(phase) * np.exp(-t / (decay * (1.25 if big else 1.0)))
301
- signal = 0.72 * rim + attack * modes
302
- _add_reflection(signal, sample_rate, 0.0047, 0.16)
303
- _add_reflection(signal, sample_rate, 0.0119, 0.085 if big else 0.060)
304
- signal *= np.exp(-np.maximum(t - duration * 0.72, 0.0) / 0.025)
305
- target = 0.90 if big else 0.70
306
  signal *= target / max(float(np.max(np.abs(signal))), 1e-12)
307
  return signal.astype(np.float32)
308
 
@@ -332,13 +344,13 @@ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
332
  ordinary_index = 0
333
  for event in events:
334
  if event.kind in {"don", "ka", "don_big", "ka_big"}:
335
- if event.kind == "ka":
336
- pan = -0.28 if ordinary_index % 2 == 0 else 0.28
337
- elif event.kind == "ka_big":
338
- pan = -0.10 if ordinary_index % 2 == 0 else 0.10
339
  else:
340
- pan = -0.035 if ordinary_index % 2 == 0 else 0.035
341
- depth = 0.20 if event.kind.endswith("_big") else 0.145
342
  strikes.append(_Strike(event.time, event.kind, pan, depth, False))
343
  ordinary_index += 1
344
  continue
@@ -401,7 +413,7 @@ def _load_stereo_audio(audio_path: str, sample_rate: int) -> np.ndarray:
401
 
402
 
403
  def _place_strike(drums: np.ndarray, sample: np.ndarray, strike: _Strike,
404
- sample_rate: int) -> bool:
405
  start = int(round(strike.time * sample_rate))
406
  sample_start = max(0, -start)
407
  output_start = max(0, start)
@@ -415,26 +427,33 @@ def _place_strike(drums: np.ndarray, sample: np.ndarray, strike: _Strike,
415
  drums[output_start:output_start + count, 0] += segment * left_gain
416
  drums[output_start:output_start + count, 1] += segment * right_gain
417
 
418
- # A quiet, opposite-side early reflection creates believable width while
419
- # preserving a precise, dry transient at the chart time.
420
- reflection_delay = int(round(0.011 * sample_rate))
421
- reflected_start = output_start + reflection_delay
422
- reflected_count = min(count, len(drums) - reflected_start)
423
- if reflected_count > 0:
424
- reflected = segment[:reflected_count] * 0.095
425
- drums[reflected_start:reflected_start + reflected_count, 0] += reflected * right_gain
426
- drums[reflected_start:reflected_start + reflected_count, 1] += reflected * left_gain
 
 
 
 
 
 
 
427
  return True
428
 
429
 
430
  def _duck_envelope(frame_count: int, strikes: list[_Strike], sample_rate: int,
431
  drum_gain: float) -> tuple[np.ndarray, float]:
432
  reduction = np.zeros(frame_count, dtype=np.float32)
433
- attack_frames = max(1, int(round(0.006 * sample_rate)))
434
- release_frames = max(1, int(round(0.115 * sample_rate)))
435
  attack = 0.5 - 0.5 * np.cos(np.linspace(0.0, np.pi, attack_frames, dtype=np.float32))
436
  release = np.exp(-np.arange(release_frames, dtype=np.float32) /
437
- max(1.0, 0.042 * sample_rate))
438
  shape = np.concatenate((attack, release))
439
  gain_scale = min(drum_gain / 0.8, 1.0) if drum_gain > 0.0 else 0.0
440
 
@@ -450,7 +469,7 @@ def _duck_envelope(frame_count: int, strikes: list[_Strike], sample_rate: int,
450
  target = reduction[output_start:output_start + count]
451
  np.maximum(target, envelope, out=target)
452
 
453
- np.minimum(reduction, 0.32, out=reduction)
454
  gain = 1.0 - reduction
455
  max_duck_db = -20.0 * math.log10(max(float(np.min(gain)), 1e-12))
456
  return gain, max_duck_db
@@ -466,6 +485,16 @@ def _write_pcm16_wav(out_path: str, pcm: np.ndarray, sample_rate: int) -> None:
466
  wav.writeframes(np.ascontiguousarray(pcm.astype("<i2", copy=False)).tobytes())
467
 
468
 
 
 
 
 
 
 
 
 
 
 
469
  def synthesize_taiko_preview(
470
  audio_path: str,
471
  tja_text: str,
@@ -475,9 +504,9 @@ def synthesize_taiko_preview(
475
  ) -> dict[str, float | int]:
476
  """Mix a synchronized stereo PCM16 WAV preview and return numeric stats.
477
 
478
- Music is held at 2 dB of mix headroom and briefly ducked under each drum
479
- transient. The final mix uses transparent global peak reduction when
480
- needed and is quantized below a -1 dBFS sample-peak ceiling.
481
  """
482
 
483
  if not isinstance(sample_rate, int) or sample_rate < 16_000 or sample_rate > 192_000:
@@ -507,21 +536,28 @@ def synthesize_taiko_preview(
507
  for index, strike in enumerate(strikes):
508
  samples = bank[strike.kind]
509
  sample = samples[index % len(samples)]
510
- if _place_strike(drums, sample, strike, sample_rate):
 
 
 
 
 
511
  audible_strikes.append(strike)
512
 
 
513
  duck_gain, max_duck_db = _duck_envelope(
514
  len(music), audible_strikes, sample_rate, drum_gain,
515
  )
516
- music_gain = 10.0 ** (-2.0 / 20.0)
517
  mixed = music * (music_gain * duck_gain[:, None])
518
  mixed += drums * float(drum_gain)
519
 
520
  pre_limit_peak = float(np.max(np.abs(mixed))) if mixed.size else 0.0
521
  float_ceiling = _PCM16_CEILING / 32767.0
522
- limiter_gain = min(1.0, float_ceiling / pre_limit_peak) if pre_limit_peak > 0.0 else 1.0
523
- mixed *= limiter_gain
524
  np.clip(mixed, -float_ceiling, float_ceiling, out=mixed)
 
 
525
  pcm = np.rint(mixed * 32767.0).astype(np.int16)
526
  _write_pcm16_wav(out_path, pcm, sample_rate)
527
 
 
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
 
 
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
 
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)
 
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
 
449
  def _duck_envelope(frame_count: int, strikes: list[_Strike], sample_rate: int,
450
  drum_gain: float) -> tuple[np.ndarray, float]:
451
  reduction = np.zeros(frame_count, dtype=np.float32)
452
+ attack_frames = max(1, int(round(0.0025 * sample_rate)))
453
+ release_frames = max(1, int(round(0.080 * sample_rate)))
454
  attack = 0.5 - 0.5 * np.cos(np.linspace(0.0, np.pi, attack_frames, dtype=np.float32))
455
  release = np.exp(-np.arange(release_frames, dtype=np.float32) /
456
+ max(1.0, 0.027 * sample_rate))
457
  shape = np.concatenate((attack, release))
458
  gain_scale = min(drum_gain / 0.8, 1.0) if drum_gain > 0.0 else 0.0
459
 
 
469
  target = reduction[output_start:output_start + count]
470
  np.maximum(target, envelope, out=target)
471
 
472
+ np.minimum(reduction, 0.38, out=reduction)
473
  gain = 1.0 - reduction
474
  max_duck_db = -20.0 * math.log10(max(float(np.min(gain)), 1e-12))
475
  return gain, max_duck_db
 
485
  wav.writeframes(np.ascontiguousarray(pcm.astype("<i2", copy=False)).tobytes())
486
 
487
 
488
+ def _soft_limit(signal: np.ndarray, threshold: float, ceiling: float) -> np.ndarray:
489
+ """Limit only hot samples instead of turning down the entire preview."""
490
+
491
+ magnitude = np.abs(signal)
492
+ width = ceiling - threshold
493
+ over = np.maximum(magnitude - threshold, 0.0) / width
494
+ shaped = threshold + width * np.tanh(over)
495
+ return np.copysign(np.where(magnitude <= threshold, magnitude, shaped), signal)
496
+
497
+
498
  def synthesize_taiko_preview(
499
  audio_path: str,
500
  tja_text: str,
 
504
  ) -> dict[str, float | int]:
505
  """Mix a synchronized stereo PCM16 WAV preview and return numeric stats.
506
 
507
+ Music is held at 3.5 dB of mix headroom and briefly ducked under each drum
508
+ transient. A local soft limiter controls only hot samples before PCM16
509
+ quantization at a -1 dBFS sample-peak ceiling.
510
  """
511
 
512
  if not isinstance(sample_rate, int) or sample_rate < 16_000 or sample_rate > 192_000:
 
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
  )
551
+ music_gain = 10.0 ** (-3.5 / 20.0)
552
  mixed = music * (music_gain * duck_gain[:, None])
553
  mixed += drums * float(drum_gain)
554
 
555
  pre_limit_peak = float(np.max(np.abs(mixed))) if mixed.size else 0.0
556
  float_ceiling = _PCM16_CEILING / 32767.0
557
+ mixed = _soft_limit(mixed, threshold=0.68, ceiling=float_ceiling)
 
558
  np.clip(mixed, -float_ceiling, float_ceiling, out=mixed)
559
+ limited_peak = float(np.max(np.abs(mixed))) if mixed.size else 0.0
560
+ limiter_gain = min(1.0, limited_peak / pre_limit_peak) if pre_limit_peak > 0.0 else 1.0
561
  pcm = np.rint(mixed * 32767.0).astype(np.int16)
562
  _write_pcm16_wav(out_path, pcm, sample_rate)
563