diff --git a/scripts/render_finale_wav.py b/scripts/render_finale_wav.py new file mode 100644 index 0000000..5dee407 --- /dev/null +++ b/scripts/render_finale_wav.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Renderizza la musica del finale di "A Scream from the Dark" in un file WAV. +Estrae le sequenze CH1 (melodia), CH2 (basso) e CH4 (rintocchi) dal sorgente +sound.c e le sintetizza in un file audio. + +Uso: python3 render_finale_wav.py [output.wav] +Output di default: /tmp/finale_music.wav +""" + +import re +import struct +import wave +import sys +import math + +# === Costanti delle note (dal sound.c) === +# Valore Game Boy -> frequenza: freq = 131072 / (2048 - value) +R = 0 # rest (silenzio) +NOTES = { + 'N_C2': 44, 'N_E2': 458, 'N_F2': 547, 'N_G2': 742, 'N_A2': 857, + 'N_AS2': 907, 'N_B2': 961, + 'N_C3': 1046, 'N_CS3': 1103, 'N_D3': 1155, 'N_E3': 1253, + 'N_F3': 1298, 'N_FS3': 1340, 'N_G3': 1380, 'N_GS3': 1417, + 'N_A3': 1453, 'N_AS3': 1486, 'N_B3': 1517, + 'N_C4': 1547, 'N_CS4': 1575, 'N_D4': 1602, 'N_E4': 1651, + 'N_F4': 1673, 'N_FS4': 1694, 'N_G4': 1714, 'N_GS4': 1732, + 'N_A4': 1751, 'N_AS4': 1767, 'N_B4': 1783, + 'N_C5': 1798, 'N_CS5': 1812, 'N_D5': 1825, 'N_E5': 1849, + 'N_F5': 1860, 'N_FS5': 1871, 'N_G5': 1881, 'N_GS5': 1890, + 'N_A5': 1899, + 'R': 0, +} + +def gb_freq(value): + """Converte un valore Game Boy in frequenza Hz.""" + if value == 0: + return 0 + return 131072.0 / (2048 - value) + +# === Estrai le sequenze dal sound.c === +def extract_array(filename, array_name): + with open(filename) as f: + text = f.read() + pattern = rf'const uint16_t {array_name}\[192\] = \{{(.*?)\}};' + m = re.search(pattern, text, re.DOTALL) + if not m: + raise ValueError(f"Array {array_name} not found in {filename}") + body = m.group(1) + tokens = re.findall(r'N_\w+|R\b', body) + values = [] + for tok in tokens: + values.append(NOTES.get(tok, 0)) + return values + +SOUND_C = 'src/sound.c' +ch1_seq = extract_array(SOUND_C, 'finale_ch1_seq') +ch2_seq = extract_array(SOUND_C, 'finale_ch2_seq') +print(f"CH1: {len(ch1_seq)} steps, CH2: {len(ch2_seq)} steps") + +# === Parametri audio === +SAMPLE_RATE = 22050 # Hz +FRAMES_PER_NOTE = 14 # 14 frame/nota a 60fps -> ~0.233s/nota +NOTE_DURATION = FRAMES_PER_NOTE / 60.0 # secondi per nota +SAMPLES_PER_NOTE = int(SAMPLE_RATE * NOTE_DURATION) +TOTAL_STEPS = 192 # un loop completo + +# Envelope del Game Boy (semplificato): volume con fade +# CH1: NR12=0xA2 -> volume iniziale 10 (0xA), fade down (sweep -2) +# CH2: NR22=0xD1 -> volume iniziale 13 (0xD), fade down (sweep -1) +# CH4: NR42=0xB2 -> volume iniziale 11 (0xB), fade down (sweep -2) + +def generate_tone(freq, duration, sample_rate, volume=0.5, waveform='square', + envelope_init=15, envelope_sweep=-2): + """Genera un'onda quadra con envelope del Game Boy.""" + if freq == 0 or volume == 0: + return [0.0] * int(sample_rate * duration) + + n_samples = int(sample_rate * duration) + samples = [] + + # Game Boy envelope: parte da envelope_init, cambia di envelope_sweep + # ogni 1/64 secondo. sweep negativo = fade out. + env_step_samples = sample_rate / 64.0 # campioni per step di envelope + for i in range(n_samples): + # Calcola envelope corrente (0-15, mappa a 0.0-1.0) + env_steps_elapsed = i / env_step_samples + env_val = envelope_init + envelope_sweep * env_steps_elapsed + env_val = max(0, min(15, env_val)) + amp = (env_val / 15.0) * volume + + # Onda quadra (50% duty cycle come NR11=0x80 / NR21=0x80) + phase = (i * freq / sample_rate) % 1.0 + if waveform == 'square': + val = amp if phase < 0.5 else -amp + elif waveform == 'sine': + val = amp * math.sin(2 * math.pi * phase) + samples.append(val) + + return samples + +def generate_noise(duration, sample_rate, volume=0.3, envelope_init=11, envelope_sweep=-2, + noise_type='mid'): + """Genera rumore per i rintocchi di campana (CH4).""" + n_samples = int(sample_rate * duration) + samples = [] + env_step_samples = sample_rate / 64.0 + + # Game Boy noise: clock_divider + shift + # mid toll: NR43=0x68 -> shift=13, divider=1 -> ~500Hz + # crash: NR43=0x42 -> shift=8, divider=2 -> ~4kHz + # deep toll: NR43=0x70 -> shift=14, divider=1 -> ~250Hz + noise_params = { + 'mid': (13, 1, 0.3), + 'crash': (8, 2, 0.4), + 'deep': (14, 1, 0.25), + } + shift, div, vol_mult = noise_params.get(noise_type, noise_params['mid']) + + # Frequenza approssimativa del rumore + base_freq = 262144.0 / (div * (1 << (shift + 1))) + + # LFSR (Linear Feedback Shift Register) del Game Boy per rumore autentico + lfsr = 0x7FFF # 15-bit LFSR + samples_per_noise = max(1, int(sample_rate / base_freq)) + + for i in range(n_samples): + env_steps_elapsed = i / env_step_samples + env_val = envelope_init + envelope_sweep * env_steps_elapsed + env_val = max(0, min(15, env_val)) + amp = (env_val / 15.0) * volume * vol_mult + + # Aggiorna LFSR + if i % samples_per_noise == 0: + bit = ((lfsr >> 0) ^ (lfsr >> 1)) & 1 + lfsr = (lfsr >> 1) | (bit << 14) + + val = amp if (lfsr & 1) else -amp + samples.append(val) + + return samples + +# === Genera le tracce audio === +print("Generando CH1 (melodia)...") +ch1_audio = [] +for step in range(TOTAL_STEPS): + note_val = ch1_seq[step % len(ch1_seq)] + freq = gb_freq(note_val) + if freq > 0: + # CH1: NR12=0xA2 (vol 10, fade -2), duty 50% + tone = generate_tone(freq, NOTE_DURATION, SAMPLE_RATE, volume=0.4, + envelope_init=10, envelope_sweep=-2) + else: + tone = [0.0] * SAMPLES_PER_NOTE + ch1_audio.extend(tone) + +print("Generando CH2 (basso)...") +ch2_audio = [] +for step in range(TOTAL_STEPS): + note_val = ch2_seq[step % len(ch2_seq)] + freq = gb_freq(note_val) + if freq > 0: + # CH2: NR22=0xD1 (vol 13, fade -1), duty 50% + tone = generate_tone(freq, NOTE_DURATION, SAMPLE_RATE, volume=0.3, + envelope_init=13, envelope_sweep=-1) + else: + tone = [0.0] * SAMPLES_PER_NOTE + ch2_audio.extend(tone) + +print("Generando CH4 (rintocchi)...") +ch4_audio = [0.0] * len(ch1_audio) # traccia silenziosa di base +for step in range(TOTAL_STEPS): + if step % 8 == 0: # rintocco ogni 8 step + if step >= 160: + noise_type = 'deep' + elif step == 80 or step == 88 or step == 112: + noise_type = 'crash' + else: + noise_type = 'mid' + + noise = generate_noise(NOTE_DURATION, SAMPLE_RATE, volume=0.2, + envelope_init=11, envelope_sweep=-2, + noise_type=noise_type) + start = step * SAMPLES_PER_NOTE + for j, s in enumerate(noise): + if start + j < len(ch4_audio): + ch4_audio[start + j] += s + +# === Mix delle tracce === +print("Mixing...") +n_total = len(ch1_audio) +mixed = [] +for i in range(n_total): + val = ch1_audio[i] * 0.5 + ch2_audio[i] * 0.4 + ch4_audio[i] * 0.3 + # Clamping a [-1, 1] + val = max(-1.0, min(1.0, val)) + mixed.append(val) + +# === Scrivi il WAV === +output_file = sys.argv[1] if len(sys.argv) > 1 else '/tmp/finale_music.wav' +print(f"Scrivendo {output_file}...") +duration = n_total / SAMPLE_RATE +print(f" Durata: {duration:.1f}s ({TOTAL_STEPS} step x {NOTE_DURATION:.3f}s)") + +with wave.open(output_file, 'w') as wav: + wav.setnchannels(1) # mono + wav.setsampwidth(2) # 16-bit + wav.setframerate(SAMPLE_RATE) + + for val in mixed: + # Converti a 16-bit signed + sample = int(val * 32767) + wav.writeframes(struct.pack('