TheBird

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