avans06 commited on
Commit
2a00d06
·
1 Parent(s): 4178195

feat(synth): Add adaptive decay and hybrid note smoothing

Browse files

This commit introduces two major enhancements to the 8-bit synthesizer to resolve significant volume loss issues on short, staccato notes.

The original implementation used fixed-time parameters for note decay and smoothing. This caused the perceived volume of fast notes to be drastically lower than that of sustained notes, as their envelopes were prematurely cut off before reaching their peak.

1. **Adaptive Decay:**
- A new, default-on feature that ensures a consistent initial decay rate for all notes, regardless of their duration.
- It works by applying the initial segment of a pre-calculated "ideal" decay curve to each note, rather than scaling the entire curve down.
- This preserves the attack and power of staccato passages, creating a much more balanced and musical output.
- A UI checkbox has been added to control this feature.

2. **Hybrid Note Smoothing:**
- The note smoothing logic has been refactored from a fixed-time fade to a hybrid proportional/capped approach.
- The fade duration is now calculated as a percentage of the note's length, but it is capped at a reasonable absolute maximum (30ms).
- This fixes the volume loss issue for short notes while ensuring the attack of long notes remains crisp and defined.

Files changed (1) hide show
  1. app.py +63 -10
app.py CHANGED
@@ -147,6 +147,7 @@ class AppParameters:
147
  s8bit_distortion_level: float = 0.0
148
  s8bit_fm_modulation_depth: float = 0.0
149
  s8bit_fm_modulation_rate: float = 0.0
 
150
 
151
 
152
  # =================================================================================================
@@ -350,21 +351,68 @@ def synthesize_8bit_style(*, midi_data: pretty_midi.PrettyMIDI, fs: int, params
350
 
351
  if params.s8bit_envelope_type == 'Plucky (AD Envelope)':
352
  attack_samples = min(int(0.005 * fs), num_samples)
353
- decay_samples = min(int(params.s8bit_decay_time_s * fs), num_samples - attack_samples)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
- envelope[:attack_samples] = np.linspace(0, start_amp, attack_samples)
356
- if decay_samples > 0:
357
- envelope[attack_samples:attack_samples+decay_samples] = np.linspace(start_amp, 0, decay_samples)
 
 
 
 
358
  else: # Sustained
359
  envelope = np.linspace(start_amp, 0, num_samples)
360
 
361
- # --- Graded Note Smoothing ---
362
- # The level controls the length of the fade in/out. Max fade is 10ms.
 
 
363
  if params.s8bit_smooth_notes_level > 0 and num_samples > 10:
364
- fade_length = int(fs * 0.01 * params.s8bit_smooth_notes_level)
365
- fade_samples = min(fade_length, num_samples // 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  if fade_samples > 0:
 
367
  envelope[:fade_samples] *= np.linspace(0.5, 1.0, fade_samples)
 
368
  envelope[-fade_samples:] *= np.linspace(1.0, 0.0, fade_samples)
369
 
370
  # Apply envelope to the (potentially combined) waveform
@@ -1381,7 +1429,7 @@ def run_single_file_pipeline(input_file_path: str, timestamp: str, params: AppPa
1381
  if not midi_path_for_rendering or not os.path.exists(midi_path_for_rendering):
1382
  print(f"ERROR: Transcription failed for {filename}. Skipping.")
1383
  return None
1384
-
1385
  # --- Step 2: Render the FINAL MIDI file with selected options ---
1386
  # The progress values are now conditional based on the input file type.
1387
  update_progress(0.1 if is_midi_input else 0.6, "Applying MIDI transformations...")
@@ -2412,7 +2460,7 @@ if __name__ == "__main__":
2412
  s8bit_pulse_width = gr.Slider(
2413
  0.01, 0.99, value=0.5, step=0.01,
2414
  label="Pulse Width (Square Wave Only)",
2415
- info="Changes the character of the Square wave. Low values (~0.1) are thin and nasal, while mid values (~0.5) are full and round."
2416
  )
2417
  s8bit_envelope_type = gr.Dropdown(
2418
  ['Plucky (AD Envelope)', 'Sustained (Full Decay)'],
@@ -2425,6 +2473,11 @@ if __name__ == "__main__":
2425
  label="Decay Time (s)",
2426
  info="For the 'Plucky' envelope, this is the time it takes for a note to fade to silence. Low values are short and staccato; high values are longer and more resonant."
2427
  )
 
 
 
 
 
2428
  s8bit_vibrato_rate = gr.Slider(
2429
  0, 20, value=5,
2430
  label="Vibrato Rate (Hz)",
 
147
  s8bit_distortion_level: float = 0.0
148
  s8bit_fm_modulation_depth: float = 0.0
149
  s8bit_fm_modulation_rate: float = 0.0
150
+ s8bit_adaptive_decay: bool = False
151
 
152
 
153
  # =================================================================================================
 
351
 
352
  if params.s8bit_envelope_type == 'Plucky (AD Envelope)':
353
  attack_samples = min(int(0.005 * fs), num_samples)
354
+
355
+ # --- Adaptive Decay Logic ---
356
+ # This ensures short staccato notes have the same initial decay rate
357
+ # as long notes, fixing the perceived low volume issue.
358
+ if params.s8bit_adaptive_decay:
359
+ # 1. Calculate the "ideal" number of decay samples based on the user's setting.
360
+ ideal_decay_samples = int(params.s8bit_decay_time_s * fs)
361
+ if ideal_decay_samples <= 0:
362
+ ideal_decay_samples = 1 # Avoid division by zero.
363
+
364
+ # 2. Create the full, "ideal" decay curve from peak to zero.
365
+ ideal_decay_curve = np.linspace(start_amp, 0, ideal_decay_samples)
366
+
367
+ # 3. Determine how many decay samples can actually fit in this note's duration.
368
+ actual_decay_samples = num_samples - attack_samples
369
+
370
+ if actual_decay_samples > 0:
371
+ # 4. Take the initial part of the ideal curve, sized to fit the note.
372
+ num_samples_to_take = min(len(ideal_decay_curve), actual_decay_samples)
373
+
374
+ # Apply the attack portion.
375
+ envelope[:attack_samples] = np.linspace(0, start_amp, attack_samples)
376
+ # Apply the truncated decay curve.
377
+ envelope[attack_samples : attack_samples + num_samples_to_take] = ideal_decay_curve[:num_samples_to_take]
378
 
379
+ # --- Original Decay Logic (Fallback) ---
380
+ else:
381
+ decay_samples = min(int(params.s8bit_decay_time_s * fs), num_samples - attack_samples)
382
+ envelope[:attack_samples] = np.linspace(0, start_amp, attack_samples)
383
+ if decay_samples > 0:
384
+ envelope[attack_samples:attack_samples+decay_samples] = np.linspace(start_amp, 0, decay_samples)
385
+
386
  else: # Sustained
387
  envelope = np.linspace(start_amp, 0, num_samples)
388
 
389
+ # --- Hybrid Note Smoothing (Proportional with an Absolute Cap) ---
390
+ # This improved logic calculates the fade duration as a percentage of the note's
391
+ # length but caps it at a fixed maximum duration. This provides the best of both worlds:
392
+ # it preserves volume on short notes while ensuring long notes have a crisp attack.
393
  if params.s8bit_smooth_notes_level > 0 and num_samples > 10:
394
+ # 1. Define the maximum allowable fade time in seconds (e.g., 30ms).
395
+ # This prevents fades from becoming too long on sustained notes.
396
+ max_fade_duration_s = 0.03
397
+
398
+ # 2. Calculate the proportional fade length based on the note's duration.
399
+ # At level 1.0, this is 10% of the note's start and 10% of its end.
400
+ fade_percentage = 0.1 * params.s8bit_smooth_notes_level
401
+ proportional_fade_samples = int(num_samples * fade_percentage)
402
+
403
+ # 3. Calculate the absolute maximum fade length in samples.
404
+ absolute_max_fade_samples = int(fs * max_fade_duration_s)
405
+
406
+ # 4. The final fade_samples is the SMALLEST of the three constraints:
407
+ # a) The proportional length.
408
+ # b) The absolute maximum length.
409
+ # c) Half the note's total length (to prevent overlap).
410
+ fade_samples = min(proportional_fade_samples, absolute_max_fade_samples, num_samples // 2)
411
+
412
  if fade_samples > 0:
413
+ # Apply a fade-in to the attack portion of the envelope.
414
  envelope[:fade_samples] *= np.linspace(0.5, 1.0, fade_samples)
415
+ # Apply a fade-out to the tail portion of the envelope.
416
  envelope[-fade_samples:] *= np.linspace(1.0, 0.0, fade_samples)
417
 
418
  # Apply envelope to the (potentially combined) waveform
 
1429
  if not midi_path_for_rendering or not os.path.exists(midi_path_for_rendering):
1430
  print(f"ERROR: Transcription failed for {filename}. Skipping.")
1431
  return None
1432
+
1433
  # --- Step 2: Render the FINAL MIDI file with selected options ---
1434
  # The progress values are now conditional based on the input file type.
1435
  update_progress(0.1 if is_midi_input else 0.6, "Applying MIDI transformations...")
 
2460
  s8bit_pulse_width = gr.Slider(
2461
  0.01, 0.99, value=0.5, step=0.01,
2462
  label="Pulse Width (Square Wave Only)",
2463
+ info="Changes the character of the Square wave. Low values (\~0.1) are thin and nasal, while mid values (\~0.5) are full and round."
2464
  )
2465
  s8bit_envelope_type = gr.Dropdown(
2466
  ['Plucky (AD Envelope)', 'Sustained (Full Decay)'],
 
2473
  label="Decay Time (s)",
2474
  info="For the 'Plucky' envelope, this is the time it takes for a note to fade to silence. Low values are short and staccato; high values are longer and more resonant."
2475
  )
2476
+ s8bit_adaptive_decay = gr.Checkbox(
2477
+ value=True, # Default to True, as the effect is generally better.
2478
+ label="Enable Adaptive Decay (Fix for Staccato)",
2479
+ info="Recommended! Fixes low volume on fast/short notes by ensuring a consistent decay rate, regardless of note length. Makes staccato passages sound fuller and more powerful."
2480
+ )
2481
  s8bit_vibrato_rate = gr.Slider(
2482
  0, 20, value=5,
2483
  label="Vibrato Rate (Hz)",