feat(synth): Add adaptive decay and hybrid note smoothing
Browse filesThis 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.
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
354 |
|
355 |
-
|
356 |
-
|
357 |
-
|
|
|
|
|
|
|
|
|
358 |
else: # Sustained
|
359 |
envelope = np.linspace(start_amp, 0, num_samples)
|
360 |
|
361 |
-
# ---
|
362 |
-
#
|
|
|
|
|
363 |
if params.s8bit_smooth_notes_level > 0 and num_samples > 10:
|
364 |
-
|
365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
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)",
|