Gameplay (you should now be able to lose balls): - flipper length 170 -> 154, so the rest-position tips meet with a ~50px gap instead of ~18px - balls dropping down the middle now drain freely - keep the outlane guard posts so side drains stay fair (all drains now happen through the widened centre), and verify with autoplay: centre drains = 40, side = 0, ~10s average ball life for the bot Audio (was a harsh machine-gun): - completely rewrote both music loops: no more square waves or noise hats in the music; sparse triangle bass + soft melody with rests, at lower volume (play loop now ~47% audible windows, was a solid wall) - softened the wall-hit thud (frequent during rallies) and the charge ticks
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""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
|
|
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 _build_music(self):
|
|
"""Two short generative loops, deliberately sparse & soft: a calm menu
|
|
loop and a gentle bouncy play loop. (Square waves and hats are used in
|
|
SFX only - never in the music - to keep it from getting harsh.)"""
|
|
|
|
# ---- menu: dreamy, mostly rests, C - Am - F - G ------------------------
|
|
seq = [
|
|
(("C", 2), ["C", "E", "G", "D"], "E"),
|
|
(("A", 2), ["A", "C", "E", "B"], "C"),
|
|
(("F", 2), ["F", "A", "C", "G"], "A"),
|
|
(("G", 2), ["G", "B", "D", "A"], "B"),
|
|
]
|
|
tracks = []
|
|
CH = 1.8
|
|
for ci, (bass, arp, twinkle) in enumerate(seq):
|
|
t = ci * CH
|
|
tracks.append((t, bass[0], bass[1], CH * 0.72, 0.12, "tri")) # bass
|
|
for k in range(4): # arpeggio
|
|
oc = 4 if k < 2 else 5
|
|
tracks.append((t + 0.35 + k * 0.26, arp[k], oc, 0.24, 0.08, "tri"))
|
|
tracks.append((t + 1.3, twinkle, 5, 0.55, 0.045, "tri")) # twinkle
|
|
menu_total = 4 * CH + 0.8
|
|
self._menu_music = self._music_buffer(tracks, menu_total)
|
|
|
|
# ---- play: bouncy but gentle, 16 eighth-notes @ ~0.30s ----------------
|
|
step = 0.30
|
|
bass_seq = [("C", 2), ("C", 2), ("G", 2), ("G", 2),
|
|
("A", 2), ("A", 2), ("F", 2), ("G", 2),
|
|
("C", 2), ("C", 2), ("G", 2), ("G", 2),
|
|
("A", 2), ("A", 2), ("G", 2), ("C", 2)]
|
|
lead_seq = [None, None, ("E", 4), None, ("G", 4), None, ("E", 4), None,
|
|
("D", 4), None, ("F", 4), None, ("A", 4), None, ("G", 4), None]
|
|
tracks = []
|
|
for i in range(16):
|
|
t = i * step
|
|
if i % 2 == 0: # bass on the beat
|
|
bn, bo = bass_seq[i]
|
|
tracks.append((t, bn, bo, step * 1.25, 0.15, "tri"))
|
|
if lead_seq[i]: # light melody
|
|
nm, oc = lead_seq[i]
|
|
tracks.append((t + 0.02, nm, oc, step * 0.85, 0.085, "tri"))
|
|
play_total = 16 * step + 1.2
|
|
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.40)
|
|
self._play.set_volume(0.30)
|
|
|
|
# ---- 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)
|
|
|