"""Procedural chiptune SFX & music synthesis. No audio files - everything is generated into PCM buffers at startup.""" import array import math import random import pygame SR = 22050 # sample rate # note -> frequency NOTE = { "C": 261.63, "C#": 277.18, "D": 293.66, "D#": 311.13, "E": 329.63, "F": 349.23, "F#": 369.99, "G": 392.00, "G#": 415.30, "A": 440.00, "A#": 466.16, "B": 493.88, } def freq(note, octave): return NOTE[note] * (2 ** (octave - 4)) class Synth: """Builds Sound objects through pygame.mixer (falls back silently if no audio).""" def __init__(self): self.ok = False self.sounds = {} try: if not pygame.mixer.get_init(): pygame.mixer.init(SR, -16, 1, 512) self.ok = True except Exception: return self._rng = random.Random(7) self._build_all() self.music_channel = pygame.mixer.Channel(0) self.sfx_channels = [pygame.mixer.Channel(i) for i in range(1, 6)] self._ch_i = 0 # ---- sample builders ------------------------------------------------- @staticmethod def _env(n, attack=0.005, release=0.25): """Apply simple AD envelope to n samples.""" a = max(1, int(attack * SR)) r = max(1, int(release * SR)) out = [] for i in range(n): if i < a: v = i / a elif i > n - r: v = max(0.0, (n - i) / r) else: v = 1.0 out.append(v) return out @staticmethod def _wave(shape, phase, duty=0.5): p = phase % 1.0 if shape == "square": return 1.0 if p < duty else -1.0 if shape == "saw": return 2.0 * p - 1.0 if shape == "tri": return 4.0 * abs(p - 0.5) - 1.0 if shape == "noise": return 1.0 return math.sin(2 * math.pi * p) def tone(self, f, dur, vol=0.5, shape="square", slide=0.0, duty=0.5): """slide: freq multiplier change over duration (e.g. 0.7 sweeps down).""" n = max(1, int(SR * dur)) env = self._env(n) out = array.array("h") phase = 0.0 for i in range(n): t = i / n ff = f * (1.0 + (slide - 1.0) * t) phase += ff / SR v = self._wave(shape, phase, duty) * env[i] * vol out.append(int(max(-1.0, min(1.0, v)) * 32000)) return out def noise(self, dur, vol=0.35, lp=0.3, slide=1.0): """Filtered-ish noise burst. lp = lowpass smoothing (0..1).""" n = max(1, int(SR * dur)) env = self._env(n) out = array.array("h") acc = 0.0 for i in range(n): acc += (self._rng.uniform(-1, 1) - acc) * lp v = acc * env[i] * vol out.append(int(max(-1.0, min(1.0, v)) * 32000)) return out @staticmethod def _mix(*arrays): ln = max(len(a) for a in arrays) acc = [0] * ln for a in arrays: for i, v in enumerate(a): acc[i] += v peak = max(1, max(abs(v) for v in acc)) scale = 32000.0 / peak if peak > 32000 else 1.0 return array.array("h", (int(v * scale) for v in acc)) def _snd(self, name, samples): try: self.sounds[name] = pygame.mixer.Sound(buffer=samples.tobytes()) except Exception: self.sounds[name] = None # ---- all effects ----------------------------------------------------- def _build_all(self): self._snd("flipL", self.tone(180, 0.07, 0.4, "square", slide=0.7)) self._snd("flipR", self.tone(210, 0.07, 0.4, "square", slide=0.7)) self._snd("wall", self.tone(140, 0.05, 0.09, "tri")) self._snd("launch", self._mix( self.noise(0.28, 0.5, lp=0.25), self.tone(160, 0.3, 0.35, "saw", slide=1.9))) self._snd("drain", self.tone(500, 0.55, 0.5, "saw", slide=0.18)) self._snd("tick", self.tone(2400, 0.015, 0.12, "square")) # bumpers: pitched by combo (choose at play time via 'bump' variants) for i in range(12): f = 300 * (1.08 ** i) self._snd("bump%d" % i, self.tone(f, 0.16, 0.5, "square", slide=0.85)) self._snd("sling", self._mix( self.tone(700, 0.05, 0.4, "square", slide=0.5), self.tone(350, 0.09, 0.4, "saw", slide=0.8))) self._snd("star", self.tone(880, 0.12, 0.42, "tri", slide=1.5)) self._snd("starLit", self._mix( self.tone(660, 0.09, 0.4, "tri"), self.tone(990, 0.16, 0.4, "tri"))) # arpeggio helpers def arp(notes, step=0.11, vol=0.42): parts = [] for i, (nm, oc) in enumerate(notes): parts.append(self.tone(freq(nm, oc), step * 1.6, vol, "square", slide=1.1)) if i < len(notes) - 1: parts.append(array.array("h", [0]) * int(SR * step * 0.55)) return self._mix(*parts) if parts else array.array("h", [0]) self._snd("fever", arp([("C", 5), ("E", 5), ("G", 5), ("C", 6), ("E", 6), ("G", 6)], 0.09, 0.45)) self._snd("extra", arp([("C", 4), ("C", 5), ("G", 5), ("E", 5), ("C", 6)], 0.13, 0.5)) self._snd("milestone", arp([("C", 5), ("E", 5), ("A", 5), ("E", 6)], 0.1, 0.45)) self._snd("combo", arp([("A", 4), ("C", 5), ("E", 5)], 0.06, 0.35)) def seq(note_spec, step=0.13, vol=0.4, shape="square"): parts = [] for nm, oc in note_spec: parts.append(self.tone(freq(nm, oc), step * 1.35, vol, shape)) return self._mix(*parts) self._snd("gameover", seq( [("E", 5), ("C", 5), ("A", 4), ("F", 4), ("C", 4)], 0.24, 0.4, "saw")) self._snd("start", seq( [("C", 4), ("E", 4), ("G", 4), ("C", 5), ("E", 5), ("G", 5)], 0.09, 0.45)) self._snd("hi", seq([("C", 6), ("E", 6), ("G", 6), ("C", 7)], 0.08, 0.3, "tri")) self._build_music() # ------------------------------------------------------------------ music # A real melody! Public-domain "Twinkle Twinkle Little Star" (Mozart's # variations), arranged as a gentle 4-bar loop with a soft bass line. # Fits the star-lane theme; unmistakably a tune. # Each bar is 8 eighth-note slots; attacks are (note, n_slots, octave). MELODY = [ # Twinkle, twinkle, little star [("C", 1, 4), ("C", 1, 4), ("G", 1, 4), ("G", 1, 4), ("A", 1, 4), ("A", 1, 4), ("G", 2, 4)], # how I wonder what you are [("F", 1, 4), ("F", 1, 4), ("E", 1, 4), ("E", 1, 4), ("D", 1, 4), ("D", 1, 4), ("C", 2, 4)], # up above the world so high / like a diamond in the sky [("G", 1, 4), ("G", 1, 4), ("F", 1, 4), ("F", 1, 4), ("E", 1, 4), ("E", 1, 4), ("D", 2, 4)], [("G", 1, 4), ("G", 1, 4), ("F", 1, 4), ("F", 1, 4), ("E", 1, 4), ("E", 1, 4), ("D", 2, 4)], ] BASS_ROOTS = ["C", "F", "C", "C"] # harmony for each of the 4 bars def _music_buffer(self, tracks, total_s): """Render time-placed notes (start_s, note, octave, dur_s, vol, shape) into one softly-mixed, normalized buffer.""" n = int(SR * total_s) buf = [0] * n for (t0, nm, oc, dur, vol, shape) in tracks: samples = self.tone(freq(nm, oc), dur, vol, shape) off = int(SR * t0) for i, v in enumerate(samples): j = off + i if j < n: buf[j] = max(-32767, min(32767, buf[j] + v)) peak = max(1, max(abs(v) for v in buf)) sc = 28000.0 / peak if peak > 28000 else 1.0 return array.array("h", (int(v * sc) for v in buf)) def _render_loop(self, eighth_s, lead_vol, lead_shape, bass_vol, offset_s=0.0, bars=None, melody_oct=4, with_bass=True): """Schedule the MELODY (per-bar eighth attacks) plus a soft oom-pah bass. `offset_s` shifts the start (used for B sections / echoes); `bars` picks which MELODY bars to play in order.""" bars = bars if bars is not None else list(range(4)) tracks = [] for idx, bi in enumerate(bars): bar_start = offset_s + idx * 8 * eighth_s if with_bass: root = self.BASS_ROOTS[bi % len(self.BASS_ROOTS)] fifth = "G" if root == "C" else "C" tracks.append((bar_start, root, 2, eighth_s * 4.2, bass_vol, "tri")) tracks.append((bar_start + eighth_s * 4, fifth, 2, eighth_s * 4.2, bass_vol, "tri")) slot = 0 for (nm, nslots, oc) in self.MELODY[bi % len(self.MELODY)]: t = bar_start + slot * eighth_s tracks.append((t + eighth_s * 0.08, nm, oc + melody_oct - 4, eighth_s * nslots * 0.9, lead_vol, lead_shape)) slot += nslots return tracks def _build_music(self): """A real tune: public-domain "Twinkle Twinkle Little Star" arranged in C - soft sine lullaby on the title, bouncy triangle version during play. Gentle oom-pah bass, nothing harsh, no percussion.""" # ---- menu: slow lullaby ---------------------------------------------- e = 0.30 # one bar = 8 * 0.30 = 2.4 s tracks = self._render_loop(e, 0.085, "sine", 0.055) menu_total = 4 * 8 * e + 2.6 # 4 bars + tail self._menu_music = self._music_buffer(tracks, menu_total) # ---- play: bouncy version, with a brighter octave-up repeat ---------- e2 = 0.225 # ~130 bpm eighth feel tracks = self._render_loop(e2, 0.115, "tri", 0.07) # B section: same tune one octave up, lighter, then the loop restarts tracks += self._render_loop(e2, 0.045, "sine", 0.0, offset_s=4 * 8 * e2, bars=[0, 1, 2, 3], melody_oct=5, with_bass=False) play_total = 2 * 4 * 8 * e2 + 1.6 # two passes, ~16 s of music self._play_music = self._music_buffer(tracks, play_total) self._menu = pygame.mixer.Sound(buffer=self._menu_music.tobytes()) self._play = pygame.mixer.Sound(buffer=self._play_music.tobytes()) self._menu.set_volume(0.42) self._play.set_volume(0.34) # ---- playback -------------------------------------------------------- def play(self, name, vol=1.0, pitch_var=0): if not self.ok: return key = name if pitch_var: key = "%s%d" % (name, pitch_var) s = self.sounds.get(key) if s is None: s = self.sounds.get(name) if s is None: return ch = self.sfx_channels[self._ch_i] self._ch_i = (self._ch_i + 1) % len(self.sfx_channels) ch.set_volume(min(1.0, vol)) ch.play(s) def start_menu_music(self): if not self.ok: return self.music_channel.set_volume(0.8) self.music_channel.play(self._menu, loops=-1) def start_play_music(self): if not self.ok: return self.music_channel.set_volume(0.8) self.music_channel.play(self._play, loops=-1) def stop_music(self): if self.ok: self.music_channel.stop() def set_muted(self, m): self.muted = m if self.ok: self.music_channel.set_volume(0.0 if m else 0.8) def set_sfx_muted(self, m): self.sfx_muted = m if self.ok: for ch in self.sfx_channels: ch.set_volume(0.0 if m else 1.0)