灵符lingfu

lingdev

↓ 0 downloads

LingDev: 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).

Windows · x86-64Linux · x86-64LingOS · x86-64LingOS · Pi 5 ARM64Web · WASM
Keywords game-engine, editor, 3d, 2d, cutscene, timeline, ling, lingos, raspberry-pi
lingfu install lingdev
LicenseApache-2.0 OR MIT
Authorsling-lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / 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()

← Back to index