File size: 5,686 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""Procedural audio synth (numpy + stdlib wave).

Generates calm, professional UI SFX and a sparse ambient music loop in the spirit of
Minecraft's quiet themes. Used as the offline placeholder until an open audio model
(Stable Audio Open / MusicGen) bakes higher-fidelity assets via scripts/prebake_audio.py.
"""

from __future__ import annotations

import wave
from pathlib import Path

import numpy as np

from .manifest import MUSIC_DIR, SFX_DIR

SAMPLE_RATE = 22050


def _write_wav(path: Path, samples: np.ndarray) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    clipped = np.clip(samples, -1.0, 1.0)
    pcm = (clipped * 32767.0).astype("<i2")
    with wave.open(str(path), "wb") as wav:
        wav.setnchannels(1)
        wav.setsampwidth(2)
        wav.setframerate(SAMPLE_RATE)
        wav.writeframes(pcm.tobytes())


def _t(duration: float) -> np.ndarray:
    return np.linspace(0.0, duration, int(SAMPLE_RATE * duration), endpoint=False)


def _adsr(n: int, attack: float = 0.01, release: float = 0.2) -> np.ndarray:
    env = np.ones(n)
    a = min(max(1, int(SAMPLE_RATE * attack)), n)
    r = min(max(1, int(SAMPLE_RATE * release)), max(0, n - a))
    if a:
        env[:a] = np.linspace(0.0, 1.0, a)
    if r:
        env[-r:] = np.linspace(1.0, 0.0, r)
    return env


def _tone(freq: float, duration: float, *, kind: str = "sine", gain: float = 0.4,
          attack: float = 0.01, release: float = 0.2) -> np.ndarray:
    t = _t(duration)
    phase = 2 * np.pi * freq * t
    if kind == "square":
        wave_data = np.sign(np.sin(phase))
    elif kind == "triangle":
        wave_data = 2 / np.pi * np.arcsin(np.sin(phase))
    else:
        wave_data = np.sin(phase)
    return wave_data * _adsr(len(t), attack, release) * gain


def _sweep(f0: float, f1: float, duration: float, gain: float = 0.35) -> np.ndarray:
    t = _t(duration)
    freqs = np.linspace(f0, f1, len(t))
    phase = 2 * np.pi * np.cumsum(freqs) / SAMPLE_RATE
    return np.sin(phase) * _adsr(len(t), 0.005, 0.15) * gain


def _noise(duration: float, gain: float = 0.25) -> np.ndarray:
    n = int(SAMPLE_RATE * duration)
    rng = np.random.default_rng(7)
    return rng.uniform(-1, 1, n) * _adsr(n, 0.002, 0.08) * gain


def _sfx(name: str) -> np.ndarray:
    if name == "click":
        return _tone(660, 0.06, kind="triangle", release=0.05)
    if name == "select":
        return np.concatenate([_tone(523, 0.06, release=0.04), _tone(784, 0.08, release=0.06)])
    if name == "present":
        return _sweep(400, 900, 0.22)
    if name == "accuse":
        return _tone(110, 0.5, kind="triangle", gain=0.5, release=0.4)
    if name == "success":
        return np.concatenate([_tone(f, 0.12, release=0.1) for f in (523, 659, 784, 1046)])
    if name == "fail":
        return np.concatenate([_tone(f, 0.16, kind="triangle", release=0.12) for f in (392, 311, 233)])
    if name == "page":
        return _noise(0.18)
    return _tone(440, 0.1)


def generate_sfx(out_dir: Path | None = None) -> list[Path]:
    out = out_dir or SFX_DIR
    from .manifest import SFX_EVENTS

    paths: list[Path] = []
    for event, filename in SFX_EVENTS.items():
        path = out / filename
        _write_wav(path, _sfx(event))
        paths.append(path)
    return paths


# A slow lofi-noir jazz loop: i - iv - V7 - i in A minor, soft Rhodes-like pads,
# a gentle bass, brushed percussion, and faint vinyl crackle.
_CHORDS: tuple[tuple[float, tuple[float, ...]], ...] = (
    (110.00, (261.63, 329.63, 392.00)),   # Am7  (A bass; C E G)
    (146.83, (349.23, 440.00, 523.25)),   # Dm7  (D bass; F A C)
    (164.81, (415.30, 493.88, 587.33)),   # E7   (E bass; G# B D)
    (110.00, (261.63, 329.63, 392.00)),   # Am7
)


def _pad(freqs: tuple[float, ...], duration: float) -> np.ndarray:
    t = _t(duration)
    vibrato = 1.0 + 0.004 * np.sin(2 * np.pi * 5.0 * t)
    tone = np.zeros(len(t))
    for f in freqs:
        tone += np.sin(2 * np.pi * f * t * vibrato) + 0.4 * np.sin(2 * np.pi * 2 * f * t)
    env = np.ones(len(t))
    a, r = int(SAMPLE_RATE * 0.25), int(SAMPLE_RATE * 0.6)
    env[:a] = np.linspace(0, 1, a)
    env[-r:] = np.linspace(1, 0.0, r)
    return tone / len(freqs) * env * 0.16


def generate_music(out_dir: Path | None = None, chord_seconds: float = 4.0) -> Path:
    out = out_dir or MUSIC_DIR
    duration = chord_seconds * len(_CHORDS)
    n = int(SAMPLE_RATE * duration)
    mix = np.zeros(n)
    rng = np.random.default_rng(1989)

    for i, (bass, voicing) in enumerate(_CHORDS):
        start = int(i * chord_seconds * SAMPLE_RATE)
        chord = _pad(voicing, chord_seconds)
        bass_line = _tone(bass, chord_seconds, kind="sine", gain=0.18, attack=0.05,
                          release=chord_seconds * 0.4)
        seg = chord[: len(bass_line)] + bass_line[: len(chord)]
        end = min(n, start + len(seg))
        mix[start:end] += seg[: end - start]

    # Brushed percussion on a slow ~80 BPM pulse.
    beat = 60.0 / 80.0
    pos = 0.0
    while pos < duration - 0.1:
        tick = _noise(0.05, gain=0.04)
        s = int(pos * SAMPLE_RATE)
        e = min(n, s + len(tick))
        mix[s:e] += tick[: e - s]
        pos += beat

    # Faint vinyl crackle.
    crackle = rng.uniform(-1, 1, n)
    crackle[np.abs(crackle) < 0.985] = 0.0
    mix += crackle * 0.05

    # Seamless loop fade.
    fade = int(SAMPLE_RATE * 0.8)
    mix[:fade] *= np.linspace(0, 1, fade)
    mix[-fade:] *= np.linspace(1, 0, fade)
    mix *= 0.85 / (np.max(np.abs(mix)) or 1.0)

    path = out / "ambient_theme.wav"
    _write_wav(path, mix)
    return path


def generate_placeholder_pack() -> list[Path]:
    return [*generate_sfx(), generate_music()]