lingdev
↓ 0 downloadsLingDev: a Godot / Unity-style game editor written in Ling. 2-D and 3-D scenes, Flash-style timeline and cutscenes, HUDs over 3-D, Ling scripting, audio / music / MIDI, builds for Windows, Linux, macOS, web, Raspberry Pi and LingOS. Ships with Ling Agent (an N64-style spy FPS), Ling Breaker (a 3-D breakout) and Moko's Picture Book (a kawaii storybook platformer).
Keywords game-engine, editor, 3d, 2d, cutscene, timeline, ling, lingos, raspberry-pi
lingfu install lingdevCode / Files (136)
tools/make_breaker_audio.py
#!/usr/bin/env python3
"""make_breaker_audio.py - builds the audio for the Ling Breaker demo.
python tools/make_breaker_audio.py (from the LingDev folder)
1. Synthesises the sound effects (numpy) into assets/sfx/breaker/*.wav, 22.05 kHz mono:
FM bells (one sample, pitched at play time to the song's key), glass shatter, metal clank,
firework pop / crackle / whistle, boom, laser, power-up chimes, 1UP fanfare, menu blips,
each with a small Schroeder reverb so they sit in a space.
2. Converts the chosen "<name>_Master.wav" tracks from ~/Music into assets/music/breaker/
(ffmpeg, mono 22.05 kHz to keep the app small).
3. Reads each track's MIDI (mido) and writes a .beats text file next to it:
bpm <n> / len <s> / key <pitch class> <major|minor>
kick|snare|hat|bass <t> <t> ... onset times in seconds
The game pulses the bricks, the floor and the lights on those onsets and tunes the brick
chimes to the key.
"""
import os, sys, math, subprocess, wave
import numpy as np
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SFX = os.path.join(ROOT, "assets", "sfx", "breaker")
MUS = os.path.join(ROOT, "assets", "music", "breaker")
MUSIC_HOME = os.path.join(os.path.expanduser("~"), "Music")
SR = 22050
rng = np.random.default_rng(7)
# (game name, wav relative to ~/Music, midi relative to ~/Music)
TRACKS = [
("title", "Crazy41 this way 41 that way, 414/Main_Menu_Master.wav", "Crazy41 this way 41 that way, 414/Main_Menu.mid"),
("stage1", "Pepper/Pepper_Master.wav", "Pepper/Pepper.mid"),
("stage2", "Prog/Progress_Instrumental_Master.wav", "Prog/Prog_2.mid"),
("stage3", "KooKooClock/KooKooClock_Master.wav", "KooKooClock/KooKooClock_3.mid"),
("stage4", "Battle/Battle_Master.wav", "Battle/Battle_2.mid"),
("stage5", "ChipTuneTwo/ChipTuneTwo_Master.wav", "ChipTuneTwo/ChipTune.mid"),
]
# ── synthesis helpers ────────────────────────────────────────────────────────
def t_(d):
return np.arange(int(SR * d)) / SR
def env(d, a=0.003, k=6.0):
t = t_(d)
e = np.exp(-k * t)
na = max(1, int(SR * a))
e[:na] *= np.linspace(0, 1, na)
return e
def sweep(f0, f1, d, curve=1.0):
t = t_(d)
x = (t / d) ** curve
f = f0 * (f1 / f0) ** x
return np.sin(2 * np.pi * np.cumsum(f) / SR)
def noise(d):
return rng.uniform(-1, 1, int(SR * d))
def lowpass(x, fc):
a = math.exp(-2 * math.pi * fc / SR)
y = np.zeros_like(x)
s = 0.0
for i, v in enumerate(x):
s = (1 - a) * v + a * s
y[i] = s
return y
def bandpass(x, fc, q=4.0):
w = 2 * math.pi * fc / SR
al = math.sin(w) / (2 * q)
c = math.cos(w)
b0, b2, a0, a1, a2 = al, -al, 1 + al, -2 * c, 1 - al
y = np.zeros_like(x)
x1 = x2 = y1 = y2 = 0.0
for i, v in enumerate(x):
o = (b0 * v + b2 * x2 - a1 * y1 - a2 * y2) / a0
x2, x1, y2, y1 = x1, v, y1, o
y[i] = o
return y
def pad(x, d):
return np.concatenate([x, np.zeros(max(0, int(SR * d) - len(x)))])
def mix(*parts):
n = max(len(p) for p in parts)
out = np.zeros(n)
for p in parts:
out[:len(p)] += p
return out
def at(x, sec, total):
out = np.zeros(int(SR * total))
i = int(SR * sec)
n = min(len(x), len(out) - i)
if n > 0:
out[i:i + n] += x[:n]
return out
def comb(x, d, g):
# y[n] = x[n] + g*y[n-d], processed in blocks of d so numpy does the work
y = x.copy()
for s in range(d, len(y), d):
e = min(s + d, len(y))
y[s:e] += g * y[s - d:e - d]
return y
def allpass(x, d, g=0.5):
y = np.zeros_like(x)
buf = np.zeros(len(x))
for s in range(0, len(x), d):
e = min(s + d, len(x))
prev = buf[s - d:e - d] if s >= d else np.zeros(e - s)
v = x[s:e] + g * prev
buf[s:e] = v
y[s:e] = -g * v + prev
return y
def reverb(x, wet=0.25, size=1.0):
x = pad(x, len(x) / SR + 0.9 * size)
y = np.zeros_like(x)
for dl, g in [(1557, .84), (1617, .83), (1491, .82), (1422, .81)]:
y += comb(x, max(1, int(dl * size * SR / 44100)), g)
for dl in (225, 556, 441):
y = allpass(y, max(1, int(dl * SR / 44100)))
return x * (1 - wet) + y * wet * 0.18
def save(name, x, peak=0.89):
x = np.asarray(x, dtype=np.float64)
m = np.max(np.abs(x)) or 1.0
x = x / m * peak
nf = int(SR * 0.004)
x[-nf:] *= np.linspace(1, 0, nf)
with wave.open(os.path.join(SFX, name + ".wav"), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes((x * 32767).astype("<i2").tobytes())
print("sfx", name, "%.2fs" % (len(x) / SR))
def bell(f, d=1.4, bright=1.0):
t = t_(d)
idx = 3.2 * bright * np.exp(-5 * t)
mod = np.sin(2 * np.pi * f * 3.5 * t) * idx
car = np.sin(2 * np.pi * f * t + mod) * np.exp(-3.2 * t)
shimmer = 0.25 * np.sin(2 * np.pi * f * 2.0 * t) * np.exp(-6 * t) + 0.12 * np.sin(2 * np.pi * f * 4.07 * t) * np.exp(-9 * t)
att = 0.4 * np.sin(2 * np.pi * f * 6.3 * t) * np.exp(-40 * t)
return (car + shimmer + att) * env(d, 0.002, 0.0)
def chip(f, d, duty=0.5):
t = t_(d)
ph = (f * t) % 1.0
return np.where(ph < duty, 1.0, -1.0) * env(d, 0.002, 5.0)
def crackles(out, d, n, start, spread, decay, lo=2500, hi=7500):
for _ in range(n):
s = start + rng.exponential(spread)
if s < d - 0.05:
c = bandpass(noise(0.012), rng.uniform(lo, hi), 3) * env(.012, .0002, 300) * rng.uniform(.15, .55) * math.exp(-(s - start) * decay)
out = mix(out, at(c, s, d))
return out
def make_sfx():
os.makedirs(SFX, exist_ok=True)
# brick chime: C5, pitched by the game (rate = 2^(semitones/12)) into the song's key
save("bell", reverb(bell(523.25), 0.3))
save("bell_hi", reverb(bell(1046.5, 0.9, 0.7), 0.35))
# paddle: rubbery boing + click + a soft tonic
d = 0.32
t = t_(d)
boing = np.sin(2 * np.pi * np.cumsum(180 + 160 * np.exp(-18 * t)) / SR) * env(d, 0.002, 9)
click = bandpass(noise(0.02), 3000, 2) * env(0.02, 0.0005, 120)
save("paddle", reverb(mix(boing, 0.6 * click, 0.35 * np.sin(2 * np.pi * 523.25 * t) * env(d, .002, 14)), 0.2))
# wall: glassy tick
d = 0.14
t = t_(d)
save("wall", reverb(mix(np.sin(2 * np.pi * 1760 * t) * env(d, .001, 40), 0.5 * bandpass(noise(d), 4200, 6) * env(d, .0005, 70)), 0.25))
# glass shatter: filtered noise + random tinkles + a low thud
d = 0.9
out = 0.8 * bandpass(noise(d), 5200, 1.2) * env(d, .001, 11)
for _ in range(18):
f = rng.uniform(2200, 7800)
s = rng.uniform(0, 0.35)
ln = rng.uniform(0.08, 0.4)
out = mix(out, at(0.35 * np.sin(2 * np.pi * f * t_(ln)) * env(ln, .0005, 14), s, d))
out = mix(out, 0.6 * lowpass(noise(0.12), 900) * env(0.12, .001, 30))
save("shatter", reverb(out, 0.3))
# metal clank (steel / tough brick)
d = 0.7
t = t_(d)
out = np.zeros(len(t))
for r, a, k in [(1, 1, 7), (2.76, .6, 9), (5.40, .4, 12), (8.93, .25, 16), (13.3, .15, 22)]:
out += a * np.sin(2 * np.pi * 420 * r * t) * np.exp(-k * t)
save("clank", reverb(mix(out * env(d, .0005, 0), 0.5 * bandpass(noise(.03), 2500, 2) * env(.03, .0003, 90)), 0.25))
# crack (tough brick damaged)
d = 0.25
save("crack", reverb(mix(bandpass(noise(d), 1800, 1.5) * env(d, .0005, 25), 0.5 * np.sin(2 * np.pi * 300 * t_(d)) * env(d, .001, 30)), 0.2))
# firework pop + crackle (the ball bursting on impact)
d = 0.9
out = 0.9 * lowpass(noise(0.08), 1400) * env(0.08, .0005, 45)
out = mix(out, 0.6 * np.sin(2 * np.pi * np.cumsum(np.linspace(220, 70, int(SR * .12))) / SR) * env(.12, .001, 25))
out = crackles(out, d, 40, 0.08, 0.18, 2.5)
save("pop", reverb(out, 0.35))
# full firework: whistle up, boom, long crackle tail (ball lost / stage clear)
d = 2.6
wh = 0.35 * sweep(700, 2600, 0.6, 0.8) * np.linspace(0.2, 1, int(SR * .6))
boom = lowpass(noise(1.2), 500) * env(1.2, .002, 3.5) + 0.8 * np.sin(2 * np.pi * np.cumsum(np.linspace(110, 35, int(SR * 1.2))) / SR) * env(1.2, .002, 4)
out = mix(at(wh, 0, d), at(boom, 0.6, d))
out = crackles(out, d, 120, 0.75, 0.5, 1.6, 2500, 8000)
save("firework", reverb(out, 0.4, 1.3))
# explosion (explosive brick)
d = 1.4
boom = lowpass(noise(d), 700) * env(d, .002, 4) + 0.9 * np.sin(2 * np.pi * np.cumsum(np.linspace(90, 30, int(SR * d))) / SR) * env(d, .002, 5)
save("boom", reverb(mix(boom, 0.4 * bandpass(noise(.3), 3000, 1) * env(.3, .001, 15)), 0.3, 1.2))
# laser zap
d = 0.18
t = t_(d)
f = 2200 * (220 / 2200) ** (t / d)
sq = np.sign(np.sin(2 * np.pi * np.cumsum(f) / SR)) * env(d, .001, 10)
save("laser", reverb(mix(0.6 * sq, 0.6 * np.sin(2 * np.pi * np.cumsum(f * 1.5) / SR) * env(d, .001, 12)), 0.2))
# power-up released: short sparkle arpeggio
d = 0.8
out = np.zeros(int(SR * d))
for k, semi in enumerate([0, 4, 7, 12, 16]):
out = mix(out, at(0.5 * bell(1046.5 * 2 ** (semi / 12), 0.5, 0.6), k * 0.045, d))
save("drop", reverb(out, 0.35))
# power-up caught: whoosh up + major chord
d = 1.2
up = 0.4 * sweep(300, 1800, 0.35, 1.5) * np.linspace(0.3, 1, int(SR * .35))
chord = sum(0.35 * bell(523.25 * 2 ** (s / 12), 0.9, 0.8) for s in (0, 4, 7, 12))
save("powerup", reverb(mix(at(up, 0, d), at(chord, 0.3, d)), 0.35))
# power-down
d = 0.7
save("powerdown", reverb(0.6 * sweep(1200, 200, 0.6, 1.0) * env(0.6, .002, 3), 0.3))
# 1UP fanfare (chip)
d = 1.0
out = np.zeros(int(SR * d))
for k, semi in enumerate([0, 4, 7, 12, 7, 12, 16, 19]):
out = mix(out, at(0.35 * chip(659.25 * 2 ** (semi / 12), 0.11, 0.25), k * 0.075, d))
save("oneup", reverb(out, 0.25))
# ball lost: falling sweep with a wobble
d = 1.1
t = t_(d)
f = 700 * (70 / 700) ** (t / d) * (1 + 0.04 * np.sin(2 * np.pi * 9 * t))
save("lose", reverb(np.sin(2 * np.pi * np.cumsum(f) / SR) * env(d, .002, 2.2) + 0.3 * np.sign(np.sin(2 * np.pi * np.cumsum(f / 2) / SR)) * env(d, .002, 3), 0.3))
# launch whoosh
d = 0.4
save("launch", reverb(bandpass(noise(d), 1400, 1.0) * np.sin(np.linspace(0, np.pi, int(SR * d))) * 0.9 + 0.3 * sweep(300, 900, d) * env(d, .01, 6), 0.25))
# stage clear jingle (bells, maj7 arpeggio)
d = 2.4
out = np.zeros(int(SR * d))
for k, semi in enumerate([0, 4, 7, 11, 12, 16, 19, 24]):
out = mix(out, at(0.5 * bell(523.25 * 2 ** (semi / 12), 1.4), k * 0.11, d))
save("clear", reverb(out, 0.4, 1.2))
# game over (minor descent)
d = 2.4
out = np.zeros(int(SR * d))
for k, semi in enumerate([12, 10, 7, 3, 0, -5]):
out = mix(out, at(0.5 * bell(392 * 2 ** (semi / 12), 1.3, 0.5), k * 0.22, d))
save("gameover", reverb(out, 0.4, 1.3))
# menu
save("blip", reverb(0.6 * chip(1318.5, 0.05, 0.5), 0.15))
d = 0.35
save("select", reverb(mix(at(0.5 * bell(1046.5, .3, .6), 0, d), at(0.5 * bell(1567.98, .3, .6), 0.06, d)), 0.25))
# magnet catch thunk
d = 0.2
save("stick", reverb(mix(np.sin(2 * np.pi * np.cumsum(np.linspace(260, 120, int(SR * d))) / SR) * env(d, .001, 18), 0.3 * bandpass(noise(d), 900, 2) * env(d, .001, 30)), 0.2))
# ── music + beats ────────────────────────────────────────────────────────────
DRUM_KICK = {35, 36}
DRUM_SNARE = {37, 38, 39, 40}
DRUM_HAT = {42, 44, 46, 49, 51, 53, 57, 59}
MAJ = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
MIN = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
def corr(a, b):
ma, mb = sum(a) / 12, sum(b) / 12
num = sum((x - ma) * (y - mb) for x, y in zip(a, b))
den = math.sqrt(sum((x - ma) ** 2 for x in a) * sum((y - mb) ** 2 for y in b)) or 1
return num / den
def dedupe(xs, gap=0.05):
out = []
for x in sorted(xs):
if not out or x - out[-1] > gap:
out.append(x)
return out
def beats_for(mid_path, length):
import mido
m = mido.MidiFile(mid_path)
kick, snare, hat, bass = [], [], [], []
hist = [0.0] * 12
bpm = 120.0
tracks = {}
t = 0.0
for msg in m: # merged playback order, times in seconds
t += msg.time
if msg.type == "set_tempo":
bpm = mido.tempo2bpm(msg.tempo)
if msg.type == "note_on" and msg.velocity > 0:
if msg.channel == 9:
if msg.note in DRUM_KICK:
kick.append(t)
elif msg.note in DRUM_SNARE:
snare.append(t)
elif msg.note in DRUM_HAT:
hat.append(t)
else:
hist[msg.note % 12] += 1.0
tracks.setdefault(msg.channel, []).append((t, msg.note))
# bass line: the channel with the lowest average pitch
if tracks:
ch = min(tracks, key=lambda c: sum(n for _, n in tracks[c]) / len(tracks[c]))
last = -1.0
for tt, n in tracks[ch]:
if tt - last > 0.09:
bass.append(tt)
last = tt
# no drum channel: the beat grid stands in for kick (1 and 3) and snare (2 and 4)
if not kick:
sp = 60.0 / bpm
b = 0
x = 0.0
while x < length:
(kick if b % 2 == 0 else snare).append(x)
b += 1
x = b * sp
best = max((corr(hist[k:] + hist[:k], prof), k, mode) for k in range(12) for prof, mode in ((MAJ, "major"), (MIN, "minor")))
lines = ["bpm %.2f" % bpm, "len %.2f" % length, "key %d %s" % (best[1], best[2])]
for name, xs in (("kick", kick), ("snare", snare), ("hat", hat), ("bass", bass)):
lines.append(name + " " + " ".join("%.3f" % x for x in dedupe(xs)))
return "\n".join(lines) + "\n", bpm, best
def make_music():
os.makedirs(MUS, exist_ok=True)
for name, wav, mid in TRACKS:
src = os.path.join(MUSIC_HOME, wav)
dst = os.path.join(MUS, name + ".wav")
if not os.path.exists(dst):
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", src, "-ac", "1", "-ar", str(SR), "-sample_fmt", "s16", dst], check=True)
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", dst], capture_output=True, text=True)
length = float(out.stdout.strip() or 0)
text, bpm, key = beats_for(os.path.join(MUSIC_HOME, mid), length)
with open(os.path.join(MUS, name + ".beats"), "w") as f:
f.write(text)
print("music", name, "%.0fs" % length, "bpm %.0f" % bpm, "key", key[1], key[2])
with open(os.path.join(MUS, "CREDITS.txt"), "w", encoding="utf-8") as f:
f.write("Ling Breaker music: original tracks by Ling Lin (taellinglin), from the author's own masters.\n\n")
for name, wav, mid in TRACKS:
f.write("%-7s %s\n" % (name, os.path.splitext(os.path.basename(wav))[0].replace("_Master", "")))
if __name__ == "__main__":
what = sys.argv[1:] or ["sfx", "music"]
if "sfx" in what:
make_sfx()
if "music" in what:
make_music()