saga.py
5.8 kB · python · 164 lines
1import numpy as np2import os3from config import FPS, FREEZE_DURATION, HEATMAP_FPS, INTER_SEGMENT_FREEZE4from enums import Way5from frames import flash_count6from models import MusicParams7from .composer import Composer8from .config import Config9from .enums import ChordType, Movement, Scale, WaveType10from .models import Voice11from .renderer import Renderer1213ROOT_NOTE = 431415MUSIC_CONFIG = Config(16 sample_rate=44100,17 fade_duration=1/64,18 scales={Scale.MAJOR: [0, 2, 4, 5, 7, 9, 11]},19 chord_types={ChordType.TRIAD: [0, 2, 4], ChordType.SEVENTH: [0, 2, 4, 6]},20)2122# VOICES2324def _build_voices(scale: Scale, rng):25 intervals = MUSIC_CONFIG.scales[scale]26 lows = [ROOT_NOTE + i for i in intervals]27 mids = [ROOT_NOTE + i + 12 for i in intervals]28 highs = [ROOT_NOTE + i + 24 for i in intervals]29 bass = Voice(30 note_pool=lows if rng.boolean() else lows + mids,31 movements=[Movement.REPEAT, Movement.UP, Movement.DOWN],32 chord_type=rng.choice([ChordType.TRIAD, ChordType.SEVENTH]),33 )34 rhythm = Voice(35 note_pool=mids if rng.boolean() else mids + highs,36 movements=[Movement.REPEAT, Movement.RANDOM],37 chord_type=rng.choice([ChordType.TRIAD, ChordType.SEVENTH]),38 num_notes=[2, 3],39 )40 voices = [bass, rhythm]41 if rng.boolean():42 lead = Voice(43 note_pool=highs,44 movements=[Movement.REPEAT, Movement.RANDOM, Movement.UP, Movement.DOWN, Movement.PAUSE],45 )46 voices.append(lead)47 return voices4849BLUES_PROGRESSION = "CCCCFFCCGFCG"5051def _build_progression(way: Way, rng) -> str:52 if way == Way.CONWAY:53 return BLUES_PROGRESSION54 rhythms = [[8], [4, 4], [2, 2, 2, 2], [1, 1, 1, 1, 1, 1, 1, 1]]55 rhythm = rng.choice(rhythms)56 options = ["C", "D", "E", "F", "G", "A", "B"]57 chords = [rng.choice(options) for _ in range(len(rhythm))]58 progression = []59 for i, duration in enumerate(rhythm):60 progression.extend([chords[i]] * duration)61 return "".join(progression)6263# PARAMS6465def compose_music_params(way: Way, rng) -> MusicParams:66 scale = rng.choice(list(Scale))67 progression = _build_progression(way, rng)68 voices = _build_voices(scale, rng)69 wave_type = rng.choice([WaveType.SINE, WaveType.TRIANGLE])70 num_harmonics = rng.choice([1, 3])71 return MusicParams(72 scale=scale,73 progression=progression,74 voices=voices,75 wave_type=wave_type,76 num_harmonics=num_harmonics,77 )7879# COMPOSE8081def _compose(params: MusicParams, count: int, rng):82 composer = Composer(MUSIC_CONFIG, rng)83 music = composer.compose(84 scale=params.scale,85 progression=params.progression,86 voices=params.voices,87 count=count,88 )89 renderer = Renderer(MUSIC_CONFIG, wave_type=params.wave_type, num_harmonics=params.num_harmonics)90 return music, renderer, MUSIC_CONFIG9192# ASSEMBLY9394def assemble_segment_track(track, beat_duration, open_freeze, close_freeze, rest):95 edge = track or [rest]96 timed = [(edge[0], open_freeze)] if open_freeze > 0 else []97 timed += [(chord, beat_duration) for chord in track]98 timed += [(edge[-1], close_freeze)] if close_freeze > 0 else []99 return timed100101# FLASH102103def flash_track(chord, flashes, frame_duration):104 staccato = [(chord, frame_duration / 2), ([], frame_duration / 2)]105 return staccato * (2 * flashes)106107# HEATMAP TRACK108109def create_heatmap_track(scale, target, target_len, music_config, rng):110 if target_len <= 0:111 return []112 intervals = music_config.scales[scale]113 note_pool = []114 for offset in [0, 12, 24]:115 note_pool.extend([ROOT_NOTE + i + offset for i in intervals])116 step = rng.choice([-1, 1])117 track = [target]118 current = target119 for _ in range(target_len - 1):120 current = [note_pool[(note_pool.index(n) + step) % len(note_pool)] for n in current]121 track.append(current)122 return list(reversed(track))123124# SAGA AUDIO125126def _halve_segment_lengths(segment_lengths):127 halved = []128 cumulative_frames = 0129 cumulative_halved = 0130 for seg_len in segment_lengths:131 cumulative_frames += seg_len132 expected = cumulative_frames // 2133 halved.append(expected - cumulative_halved)134 cumulative_halved = expected135 return halved136137def compose_saga_audio(saga, path: str, rng) -> str:138 os.makedirs(os.path.dirname(path), exist_ok=True)139 heatmap_lengths = _halve_segment_lengths([s.count for s in saga.segments])140 frames_parts = []141 heatmap_parts = []142 n = len(saga.segments)143 composed = [_compose(seg.music, seg.count, rng) for seg in saga.segments]144 first_chord = composed[0][0].track[0]145 for i, (seg, (music, renderer, music_config)) in enumerate(zip(saga.segments, composed)):146 open_freeze = FREEZE_DURATION if i == 0 else 0.0147 close_freeze = FREEZE_DURATION if i == n - 1 else INTER_SEGMENT_FREEZE148 rest = music.track[-1]149 opening = music.track[0]150 flashes = flash_count(seg)151 frames_timed = flash_track(opening, flashes, 1.0 / FPS)152 frames_timed += assemble_segment_track(music.track, 1.0 / FPS, 0.0 if flashes else open_freeze, close_freeze, rest)153 frames_parts.append(renderer.render(frames_timed))154 target = first_chord if i == n - 1 else opening155 heatmap_track = create_heatmap_track(music.scale, target, heatmap_lengths[i], music_config, rng)156 heatmap_timed = assemble_segment_track(heatmap_track, 2.0 / HEATMAP_FPS, open_freeze, close_freeze, rest)157 heatmap_parts.append(renderer.render(heatmap_timed))158 last_renderer = composed[-1][1]159 frames_combined = np.concatenate(frames_parts)160 heatmap_combined = np.concatenate(heatmap_parts)161 full = np.concatenate([frames_combined, heatmap_combined])162 last_renderer.save(path, full)163 print(f"audio {len(full) / 44100:.1f} s -> {path}")164 return path