Lane E R11 (audio): procedural audio pack — 23 assets, free/on-device
gen_audio.py: a numpy synthesis engine (osc / ADSR / filters / reverb /
seamless-loop crossfade + note sequencer + drum synth). Renders 6 ambience
beds (street day/night, rain, 3 interior roomtones) + 13 sfx (doorbell,
door, till, record-riffle, toast, tram bell/rumble, 6 footsteps) + 4
instrumental 90s music loops (record-shop, milkbar, video-synth, arcade)
-> web/assets/audio/ as OGG/Opus + M4A/AAC. 8.1 MB of the 25 MB budget.
Tool = 100% procedural synthesis (not a neural model): $0, deterministic/
seeded, perfectly loopable, parody-safe by construction. Documented in
README (§ audio) + AUDIT.md provenance.
manifest audio:{ambience,sfx,music} — file + m4a fallback + loop + gain;
interior roomtone/music carry types[] (seed-pick per shop, like facades);
footstep is {surface:[variants]}. build_manifest.py builds it,
validate_manifest.py checks it. qa.sh --strict green (audio 23, 0 err).
Verified technically (peaks <= -0.4 dBFS, all loops seamless, durations)
via spectrogram sheet + numeric — subjective ear-check is John's/F's.
E leads the audio round; F owns the v2.1 tag + push. Not pushed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4b4e040032
commit
be9f967d19
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ saves/
|
||||
node_modules/
|
||||
.env
|
||||
pipeline/.genprops/
|
||||
pipeline/.genaudio/
|
||||
web/assets/models/
|
||||
pipeline/.peds_orig/
|
||||
pipeline/.props_orig/
|
||||
|
||||
@ -1,8 +1,32 @@
|
||||
# LANE E — ASSETS · progress (PROCITY-E)
|
||||
|
||||
*2026-07-14. Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`, `web/assets/manifest.json`.
|
||||
Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in
|
||||
other lanes' files was touched.*
|
||||
*Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`. Nothing in other lanes' files was touched.*
|
||||
|
||||
## Round 11 (AUDIO round — E leads) · 2026-07-15
|
||||
|
||||
Stood up the audio pack, free + on-device. **Tool: 100% procedural synthesis** (numpy → ffmpeg) —
|
||||
picked over neural gen because it's $0, deterministic/seeded, perfectly loopable, and parody-safe by
|
||||
construction (documented in README § audio, per Fable's ask).
|
||||
|
||||
- **`pipeline/gen_audio.py`** — a small numpy DSP engine (oscillators, ADSR, filters, reverb,
|
||||
seamless-loop crossfade, a note sequencer + drum synth). 23 assets, seeded, `--dry-run`/`--only`.
|
||||
- **`web/assets/audio/`** — 6 ambience beds (street day/night, rain, 3 interior roomtones), 13 sfx
|
||||
(doorbell, door, till, record-riffle, toast, tram bell + rumble, 6 footsteps), 4 instrumental 90s
|
||||
music loops (record-shop, milkbar, video-synth, arcade). OGG/Opus + M4A/AAC. **8.1 MB** of 25 MB.
|
||||
- **Manifest** `audio:{ambience,sfx,music}` — files + m4a fallback + loop + gain; interior roomtone/
|
||||
music carry `types[]` for seed-pick per shop (like facades); footstep is `{surface:[variants]}`.
|
||||
`build_manifest.py` builds it, `validate_manifest.py` checks it. **qa green, audio 23, 0 errors.**
|
||||
- **Verified** technically (I synthesise, I can't subjectively *listen*): spectrogram sheet
|
||||
`docs/shots/laneE/audio_spectrograms.png` + numeric — peaks ≤ -0.4 dBFS (no clip), **all loops
|
||||
seamless**, durations correct. **Subjective ear-check is John's/F's** (flagged honestly).
|
||||
- Provenance in `AUDIT.md` (§ Round 11). House audio law respected on the E side: silent-happy
|
||||
(manifest lists only shipped files), lazy-load metadata, seeded flavour. B wires WebAudio, C gives
|
||||
the `room.audio` contract, F integrates + owns the **v2.1 tag/push** — I did **not** push.
|
||||
|
||||
---
|
||||
|
||||
*Round 3 and earlier below.*
|
||||
*Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`.*
|
||||
|
||||
## Round 9 (Fable) — VRAM audit + depot hygiene (ship-round DD)
|
||||
|
||||
|
||||
BIN
docs/shots/laneE/audio_spectrograms.png
Normal file
BIN
docs/shots/laneE/audio_spectrograms.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 838 KiB |
@ -391,3 +391,29 @@ required for the tag.** Two follow-ups flagged for **v3, not now** (bigger than
|
||||
|
||||
**Depot/manifest hygiene (E2):** `validate --depot` 0 err · pack-QA 3/3 valid · provenance-drift clean
|
||||
(`_published.json` == 27 live procity GLBs) · 15 stock atlases live, **0 orphans**. All green post-R8.
|
||||
|
||||
## Round 11 — audio pack provenance
|
||||
|
||||
**Every audio file is 100% original procedural synthesis** (`pipeline/gen_audio.py`, numpy on the
|
||||
MODELBEAST box → ffmpeg). No neural model, no sample library, no third-party recording touched — so
|
||||
the whole pack is **CC0-equivalent original work** and **parody-safe by construction**: there is
|
||||
nothing to imitate, every waveform is an oscillator/noise render. Music beds are original chord
|
||||
progressions/melodies — no imitation of any specific real recording. Tool choice is documented in
|
||||
`pipeline/README.md` (§ audio): synthesis over local MusicGen/stable-audio because it's $0,
|
||||
deterministic/seeded, perfectly loopable, and licence-clean.
|
||||
|
||||
| group | files | origin |
|
||||
|---|---|---|
|
||||
| ambience (6) | street-day/night, rain, roomtone-retail/milkbar/video | filtered-noise beds + synthesised events (birds/crickets/drops/hum) 🟢 |
|
||||
| sfx (13) | doorbell, door-open, till, riffle, toast, tram-bell, tram-rumble, footsteps ×6 | additive-bell + filtered-noise transients 🟢 |
|
||||
| music (4) | record-shop, milkbar, video-synth, arcade | original synth loops (bass+chords+drums+lead) 🟢 |
|
||||
|
||||
- **Format**: OGG/Opus primary (48 kHz) + M4A/AAC fallback (44.1 kHz). **Loudness**: long beds/music
|
||||
ffmpeg `loudnorm` I=-16 LUFS / TP=-1.5 (single-pass EBU R128); short SFX peak-normalised (-3…-6 dBFS).
|
||||
- **Verified** (I synthesise, I can't subjectively listen): spectrogram sheet
|
||||
`docs/shots/laneE/audio_spectrograms.png` (content/character) + numeric pass — peaks ≤ -0.4 dBFS
|
||||
(no clip), **all loops seamless** (wrap-jump ≤ internal step), durations correct. **Final subjective
|
||||
ear-check is John's/F's** — flagged honestly.
|
||||
- **Budget**: 8.1 MB shipped (2.8 MB ogg + 5.3 MB m4a) vs the 25 MB cap; lazy-loaded per manifest.
|
||||
- Files ship in `web/assets/audio/` (committed, like `gen/` skins); WAV scratch `pipeline/.genaudio/`
|
||||
is git-ignored. Contract in `manifest.json` → `audio:{ambience,sfx,music}`.
|
||||
|
||||
@ -95,13 +95,34 @@ python3 pipeline/stage_local_depot.py # mirror _normalized/*.glb → web/ass
|
||||
# then load the game with ?localdepot=1 — depot:<file> resolves to assets/models/<file> instead of the CDN
|
||||
```
|
||||
|
||||
### 8. Audio pack — `gen_audio.py` *(round-11: DONE, free, on-device)*
|
||||
**Tool choice (Fable asked E to pick + document): pure procedural synthesis in numpy**, not a neural
|
||||
model. MODELBEAST has no audio operator, and for this pack synthesis wins outright — $0, no model
|
||||
download, deterministic/seeded (house law), **perfectly seamless loops** (neural gens don't loop),
|
||||
**parody-safe by construction** (every sample is an original oscillator/noise render — nothing to
|
||||
imitate), and precise ≤25 MB budget control. Pipeline: numpy synth → WAV → ffmpeg OGG/Opus (+ M4A/AAC).
|
||||
```bash
|
||||
PY=~/Documents/MODELBEAST/venvs/mflux/bin/python # numpy lives in the mflux venv
|
||||
$PY pipeline/gen_audio.py --dry-run # list the 23-asset pack + budget
|
||||
$PY pipeline/gen_audio.py # render all → web/assets/audio/*.ogg + *.m4a
|
||||
$PY pipeline/gen_audio.py --only music # just the music beds
|
||||
python3 pipeline/build_manifest.py # catalogue → manifest.json audio:{ambience,sfx,music}
|
||||
```
|
||||
23 assets, **8.1 MB** shipped (opus+aac): 6 ambience beds (street day/night, rain, 3 roomtones), 13
|
||||
sfx (doorbell, door, till, riffle, toast, tram bell/rumble, 6 footsteps), 4 instrumental 90s music
|
||||
loops. Loudness: beds/music `loudnorm` -16 LUFS, sfx peak-normalised. **Ear-ball** = spectrogram sheet
|
||||
`docs/shots/laneE/audio_spectrograms.png` + numeric loop-seam/peak check (I can't subjectively listen —
|
||||
final ear-check is John's/F's). Add assets by editing `PACK` in `gen_audio.py` + the `AUDIO_*` maps in
|
||||
`build_manifest.py`. WAV scratch `pipeline/.genaudio/` is git-ignored; OGG/M4A ship committed.
|
||||
|
||||
## Cost summary
|
||||
|
||||
| step | cost |
|
||||
|---|---|
|
||||
| audit, normalize, manifest, validate, publish | **free** (compute + bandwidth) |
|
||||
| facade skin gap-fill (23 imgs) | **free** — done on-device (MODELBEAST flux_local); cloud fallback ~9¢ |
|
||||
| MeshGod hero props (9 solids) | **~$2.70** — gated on John (local `sf3d`/`trellis_mac` is free but HF-gated) |
|
||||
| hero props (round-3) | **free** — 5 via MODELBEAST trellis_mac; glass-case/bus-shelter on fal.ai list (≤$2.70, John's go) |
|
||||
| audio pack (round-11, 23 assets) | **free** — 100% procedural synthesis (numpy → ffmpeg), $0, no model |
|
||||
|
||||
## Artifacts (not committed — regenerated from the steps)
|
||||
|
||||
|
||||
@ -16,8 +16,28 @@ GEN = os.path.join(ROOT, "web", "assets", "gen")
|
||||
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") # library GLBs
|
||||
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
|
||||
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
||||
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
|
||||
DEPOT = "https://digalot.fyi/3god"
|
||||
|
||||
# ── curated audio contract (files from pipeline/gen_audio.py). Interior beds/music carry types[]
|
||||
# — shop types that use them — so Lane C/F seed-pick from the matching pool, exactly like facades. ──
|
||||
AUDIO_STREET = {"street-day": 0.5, "street-night": 0.5, "rain": 0.45} # Lane B street beds
|
||||
AUDIO_ROOMTONE = { # interior room-tone key -> shop types it suits
|
||||
"retail": ["record", "opshop", "book", "toy", "pawn", "stall", "general"],
|
||||
"milkbar": ["milkbar"],
|
||||
"video": ["video", "dept", "arcade"],
|
||||
}
|
||||
AUDIO_MUSIC = { # interior music key -> (gain, shop types, note)
|
||||
"record-shop": (0.55, ["record"], "record shop plays music"),
|
||||
"milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
|
||||
"video-synth": (0.50, ["video"], "video store synth hum"),
|
||||
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"),
|
||||
}
|
||||
AUDIO_SFX = {"doorbell": 0.7, "door-open": 0.6, "till": 0.7, "riffle": 0.6, # one-shots
|
||||
"toast": 0.5, "tram-bell": 0.7}
|
||||
AUDIO_SFX_LOOP = {"tram-rumble": 0.5}
|
||||
AUDIO_FOOTSTEPS = {"pavement": 3, "timber": 3} # surface -> variants
|
||||
|
||||
# ── depot-reuse: already-published GLBs we point at without a local normalize. The round-3 reuse
|
||||
# check found vintage-cash-register.glb live on 3GOD (from thriftgod_hero) — reuse it for the
|
||||
# universal till instead of re-generating. Manual footprint/height (no local file to measure);
|
||||
@ -83,6 +103,46 @@ def rel(p):
|
||||
return "gen/" + os.path.basename(p)
|
||||
|
||||
|
||||
def build_audio():
|
||||
"""The audio section: ambience (street beds + interior roomtone), sfx (one-shots + footstep
|
||||
variant arrays), music. Only assets present on disk are listed — silent-happy by construction."""
|
||||
def ref(n):
|
||||
return ({"file": f"audio/{n}.ogg", "fallback": f"audio/{n}.m4a"}
|
||||
if os.path.isfile(os.path.join(AUDIO, n + ".ogg")) else None)
|
||||
ambience = {}
|
||||
for k, g in AUDIO_STREET.items():
|
||||
r = ref("ambience-" + k)
|
||||
if r:
|
||||
ambience[k] = {**r, "loop": True, "gain": g, "scope": "street"}
|
||||
for k, types in AUDIO_ROOMTONE.items():
|
||||
r = ref("roomtone-" + k)
|
||||
if r:
|
||||
ambience["roomtone-" + k] = {**r, "loop": True, "gain": 0.35, "scope": "interior", "types": types}
|
||||
music = {}
|
||||
for k, (g, types, note) in AUDIO_MUSIC.items():
|
||||
r = ref("music-" + k)
|
||||
if r:
|
||||
music[k] = {**r, "loop": True, "gain": g, "types": types, "note": note}
|
||||
sfx = {}
|
||||
for k, g in AUDIO_SFX.items():
|
||||
r = ref("sfx-" + k)
|
||||
if r:
|
||||
sfx[k] = {**r, "gain": g}
|
||||
for k, g in AUDIO_SFX_LOOP.items():
|
||||
r = ref("sfx-" + k)
|
||||
if r:
|
||||
sfx[k] = {**r, "gain": g, "loop": True}
|
||||
footsteps = {}
|
||||
for surf, n in AUDIO_FOOTSTEPS.items():
|
||||
vs = [ref(f"step-{surf}-{i}") for i in range(1, n + 1)]
|
||||
vs = [v for v in vs if v]
|
||||
if vs:
|
||||
footsteps[surf] = vs
|
||||
if footsteps:
|
||||
sfx["footstep"] = footsteps
|
||||
return {"ambience": ambience, "sfx": sfx, "music": music}
|
||||
|
||||
|
||||
def build():
|
||||
have = {os.path.splitext(os.path.basename(f))[0] for f in glob.glob(GEN + "/*.jpg")}
|
||||
|
||||
@ -198,11 +258,16 @@ def build():
|
||||
"these to the exposed side face; other types reuse the front skin",
|
||||
"fallbacks": "every asset is optional — missing file => primitive/flat-colour, never a crash",
|
||||
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
|
||||
"audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a "
|
||||
"suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick "
|
||||
"per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any "
|
||||
"missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch.",
|
||||
},
|
||||
"skins": skins,
|
||||
"fittings": fittings,
|
||||
"furniture": furniture,
|
||||
"fittingAliases": fitting_aliases,
|
||||
"audio": build_audio(),
|
||||
}
|
||||
with open(OUT, "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
@ -210,6 +275,8 @@ def build():
|
||||
print(f" facades {len(facade)} skies {len(sky)} grounds {len(ground)} "
|
||||
f"wallpapers {len(wall)} floors {len(floor)} surfaces {len(surface)} awnings {len(awning)}")
|
||||
print(f" fittings {len(fittings)} furniture {len(furniture)}")
|
||||
au = manifest["audio"]
|
||||
print(f" audio: ambience {len(au['ambience'])} sfx {len(au['sfx'])} music {len(au['music'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
649
pipeline/gen_audio.py
Normal file
649
pipeline/gen_audio.py
Normal file
@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY audio pack — 100% procedural synthesis, on-device, $0 (round-11 audio round).
|
||||
|
||||
TOOL CHOICE (Fable asked E to pick + document): **pure procedural synthesis in numpy**, not a
|
||||
neural model. MODELBEAST has no audio operator, and for THIS pack synthesis wins on every axis:
|
||||
* $0, no 2–15 GB model download, runs in the mflux venv (numpy only).
|
||||
* Deterministic + seeded (citySeed/shopId flavour is house law) — same seed, same sound.
|
||||
* Perfect **seamless loops** (we crossfade the tail into the head) — neural gens don't loop.
|
||||
* **Parody-safe by construction**: every sample is an original oscillator/noise render, so there
|
||||
is no way to imitate a specific real recording. Instrumental originals only.
|
||||
* Precise budget control (≤25 MB): we choose length + Opus bitrate per asset.
|
||||
|
||||
Pipeline: synth (numpy float32) -> WAV (stdlib wave, int16) -> ffmpeg -> OGG/Opus (+ M4A/AAC).
|
||||
Loudness: long beds/music -> ffmpeg loudnorm (EBU R128, target -16 LUFS integrated); short SFX ->
|
||||
peak-normalized in-synth (loudnorm is unreliable under ~3 s).
|
||||
|
||||
<mflux-venv-python> pipeline/gen_audio.py --dry-run # list the pack + budget, no render
|
||||
<mflux-venv-python> pipeline/gen_audio.py # render everything -> web/assets/audio/
|
||||
<mflux-venv-python> pipeline/gen_audio.py --only music # just the music beds
|
||||
<mflux-venv-python> pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug)
|
||||
|
||||
Run with the numpy-having interpreter: ~/Documents/MODELBEAST/venvs/mflux/bin/python
|
||||
House audio law: the game runs silent-and-happy with zero assets — none of this blocks anyone.
|
||||
"""
|
||||
import os, sys, math, wave, struct, subprocess, hashlib, json, zlib
|
||||
import numpy as np
|
||||
|
||||
SR = 44100
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAW = os.path.join(ROOT, "pipeline", ".genaudio") # WAV scratch (git-ignored)
|
||||
OUT = os.path.join(ROOT, "web", "assets", "audio") # shipped OGG/M4A
|
||||
os.makedirs(RAW, exist_ok=True)
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
FF = "ffmpeg"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DSP toolkit (pure numpy)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def seed_of(name):
|
||||
return zlib.crc32(name.encode()) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def rng(name):
|
||||
return np.random.default_rng(seed_of(name))
|
||||
|
||||
|
||||
def tarr(dur):
|
||||
return np.arange(int(dur * SR)) / SR
|
||||
|
||||
|
||||
def sine(f, dur, phase=0.0):
|
||||
return np.sin(2 * np.pi * f * tarr(dur) + phase)
|
||||
|
||||
|
||||
def _phase(f, dur):
|
||||
# allow f to be scalar or array (for vibrato/glide)
|
||||
n = int(dur * SR)
|
||||
if np.isscalar(f):
|
||||
f = np.full(n, f, dtype=np.float64)
|
||||
else:
|
||||
f = np.resize(f, n)
|
||||
return np.cumsum(2 * np.pi * f / SR)
|
||||
|
||||
|
||||
def osc(f, dur, kind="sine"):
|
||||
ph = _phase(f, dur)
|
||||
if kind == "sine":
|
||||
return np.sin(ph)
|
||||
if kind == "tri":
|
||||
return 2 / np.pi * np.arcsin(np.sin(ph))
|
||||
if kind == "saw":
|
||||
return 2 * ((ph / (2 * np.pi)) % 1.0) - 1
|
||||
if kind == "square":
|
||||
return np.sign(np.sin(ph))
|
||||
if kind == "pulse25":
|
||||
return np.where((ph / (2 * np.pi)) % 1.0 < 0.25, 1.0, -1.0)
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def noise(dur, r=None, kind="white"):
|
||||
n = int(dur * SR)
|
||||
r = r or np.random.default_rng(0)
|
||||
x = r.standard_normal(n)
|
||||
if kind == "pink": # ~ -3 dB/oct via cheap one-pole cascade
|
||||
b = [0.0, 0.0, 0.0]
|
||||
out = np.empty(n)
|
||||
for i in range(n):
|
||||
b[0] = 0.99765 * b[0] + x[i] * 0.0990460
|
||||
b[1] = 0.96300 * b[1] + x[i] * 0.2965164
|
||||
b[2] = 0.57000 * b[2] + x[i] * 1.0526913
|
||||
out[i] = b[0] + b[1] + b[2] + x[i] * 0.1848
|
||||
return out / 4
|
||||
return x
|
||||
|
||||
|
||||
def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1, sl=None):
|
||||
n = int(dur * SR)
|
||||
sl = (dur - a - d - r) if sl is None else sl
|
||||
sl = max(sl, 0.0)
|
||||
segs = [(a, 0, 1), (d, 1, s), (sl, s, s), (r, s, 0)]
|
||||
env = np.concatenate([np.linspace(x0, x1, max(int(t * SR), 1)) for t, x0, x1 in segs])
|
||||
return np.resize(env, n)
|
||||
|
||||
|
||||
def lp1(x, cutoff):
|
||||
# one-pole low-pass
|
||||
a = math.exp(-2 * math.pi * cutoff / SR)
|
||||
y = np.empty_like(x)
|
||||
acc = 0.0
|
||||
for i in range(len(x)):
|
||||
acc = (1 - a) * x[i] + a * acc
|
||||
y[i] = acc
|
||||
return y
|
||||
|
||||
|
||||
def hp1(x, cutoff):
|
||||
return x - lp1(x, cutoff)
|
||||
|
||||
|
||||
def biquad_lp(x, cutoff, q=0.707):
|
||||
# RBJ low-pass biquad
|
||||
w0 = 2 * math.pi * cutoff / SR
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
b0 = (1 - cw) / 2; b1 = 1 - cw; b2 = (1 - cw) / 2
|
||||
a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha
|
||||
b0, b1, b2, a1, a2 = (b0/a0, b1/a0, b2/a0, a1/a0, a2/a0)
|
||||
y = np.empty_like(x); x1 = x2 = y1 = y2 = 0.0
|
||||
for i in range(len(x)):
|
||||
y0 = b0*x[i] + b1*x1 + b2*x2 - a1*y1 - a2*y2
|
||||
x2, x1 = x1, x[i]; y2, y1 = y1, y0
|
||||
y[i] = y0
|
||||
return y
|
||||
|
||||
|
||||
def bandpass(x, lo, hi):
|
||||
return lp1(hp1(x, lo), hi)
|
||||
|
||||
|
||||
def softclip(x, drive=1.0):
|
||||
return np.tanh(x * drive)
|
||||
|
||||
|
||||
def fade(x, fin=0.01, fout=0.01):
|
||||
n = len(x); ai = int(fin * SR); ao = int(fout * SR)
|
||||
if ai: x[:ai] *= np.linspace(0, 1, ai)
|
||||
if ao: x[-ao:] *= np.linspace(1, 0, ao)
|
||||
return x
|
||||
|
||||
|
||||
def peaknorm(x, target_db=-3.0):
|
||||
p = np.max(np.abs(x)) or 1.0
|
||||
return x * (10 ** (target_db / 20) / p)
|
||||
|
||||
|
||||
def seamless(x, xf=0.75):
|
||||
"""Return a loop of length len(x)-xf*SR that plays end->start seamlessly, by crossfading
|
||||
the tail (xf s) over the head. Input should be rendered xf longer than the wanted loop."""
|
||||
k = int(xf * SR)
|
||||
if k <= 0 or len(x) <= k:
|
||||
return x
|
||||
body = x[:-k].copy()
|
||||
tail = x[-k:]
|
||||
w = np.linspace(0, 1, k)
|
||||
if body.ndim == 2: # stereo: broadcast window across channels
|
||||
w = w[:, None]
|
||||
body[:k] = body[:k] * w + tail * (1 - w) # head = head*rise + tail*fall
|
||||
return body
|
||||
|
||||
|
||||
def stereo(l, r=None):
|
||||
if r is None:
|
||||
r = l
|
||||
n = max(len(l), len(r))
|
||||
out = np.zeros((n, 2), dtype=np.float64)
|
||||
out[:len(l), 0] = l; out[:len(r), 1] = r
|
||||
return out
|
||||
|
||||
|
||||
def pan(x, p): # p in [-1,1]
|
||||
p = (p + 1) / 2
|
||||
return stereo(x * math.cos(p * math.pi / 2), x * math.sin(p * math.pi / 2))
|
||||
|
||||
|
||||
def mix(*layers):
|
||||
n = max(len(a) for a in layers)
|
||||
ch = max((a.ndim == 2 and a.shape[1] or 1) for a in layers)
|
||||
out = np.zeros((n, ch) if ch == 2 else n, dtype=np.float64)
|
||||
for a in layers:
|
||||
if ch == 2 and a.ndim == 1:
|
||||
a = stereo(a)
|
||||
if ch == 2:
|
||||
out[:len(a)] += a
|
||||
else:
|
||||
out[:len(a)] += a
|
||||
return out
|
||||
|
||||
|
||||
def schroeder_reverb(x, mix_amt=0.2, decay=0.5):
|
||||
if x.ndim == 2:
|
||||
return np.stack([schroeder_reverb(x[:, c], mix_amt, decay) for c in range(2)], axis=1)
|
||||
out = np.zeros(len(x) + SR)
|
||||
out[:len(x)] = x
|
||||
for dl, g in [(1116, 0.805), (1188, 0.827), (1277, 0.783), (1356, 0.764)]:
|
||||
buf = np.zeros(len(out))
|
||||
gg = g * decay
|
||||
for i in range(dl, len(out)):
|
||||
buf[i] = out[i] + gg * buf[i - dl]
|
||||
out = out + buf * 0.25
|
||||
wet = out[:len(x)]
|
||||
wet = wet / (np.max(np.abs(wet)) or 1.0)
|
||||
return (1 - mix_amt) * x + mix_amt * wet
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# instruments + drums (for music beds)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def midi_hz(m):
|
||||
return 440.0 * 2 ** ((m - 69) / 12)
|
||||
|
||||
|
||||
def synth_note(midi, dur, kind="saw", a=0.005, d=0.08, s=0.6, rel=0.1,
|
||||
cutoff=2500, detune=0.0, vib=0.0):
|
||||
f = midi_hz(midi)
|
||||
if vib:
|
||||
f = f * (1 + vib * 0.006 * np.sin(2 * np.pi * 5 * tarr(dur)))
|
||||
x = osc(f, dur, kind)
|
||||
if detune:
|
||||
x = 0.5 * x + 0.5 * osc(midi_hz(midi) * (1 + detune), dur, kind)
|
||||
x = x * adsr(dur, a, d, s, rel)
|
||||
if cutoff < SR / 2:
|
||||
x = biquad_lp(x, cutoff, 0.8)
|
||||
return x
|
||||
|
||||
|
||||
def epiano(midi, dur):
|
||||
f = midi_hz(midi)
|
||||
x = (np.sin(2*np.pi*f*tarr(dur)) + 0.5*np.sin(2*np.pi*2*f*tarr(dur)*1.001)
|
||||
+ 0.25*np.sin(2*np.pi*3*f*tarr(dur)))
|
||||
return x * adsr(dur, 0.005, 0.5, 0.25, 0.3)
|
||||
|
||||
|
||||
def pad(midi, dur, detune=0.008):
|
||||
x = (osc(midi_hz(midi), dur, "saw") + osc(midi_hz(midi)*(1+detune), dur, "saw")
|
||||
+ osc(midi_hz(midi)*(1-detune), dur, "saw")) / 3
|
||||
x = biquad_lp(x, 1800, 0.7)
|
||||
return x * adsr(dur, 0.4, 0.2, 0.8, 0.5)
|
||||
|
||||
|
||||
def kick(dur=0.28):
|
||||
n = int(dur * SR)
|
||||
f = np.linspace(120, 45, n)
|
||||
x = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-np.linspace(0, 9, n))
|
||||
click = noise(0.005) * np.exp(-np.linspace(0, 40, int(0.005*SR)))
|
||||
return mix(x, np.concatenate([click, np.zeros(n - len(click))]))
|
||||
|
||||
|
||||
def snare(dur=0.2, r=None):
|
||||
n = int(dur * SR)
|
||||
body = np.sin(2*np.pi*190*tarr(dur)) * np.exp(-np.linspace(0, 14, n))
|
||||
nz = noise(dur, r) * np.exp(-np.linspace(0, 16, n))
|
||||
return 0.5*body + 0.8*bandpass(nz, 1200, 6000)
|
||||
|
||||
|
||||
def hat(dur=0.05, r=None, open_=False):
|
||||
n = int(dur * SR)
|
||||
nz = bandpass(noise(dur, r), 7000, 16000)
|
||||
env = np.exp(-np.linspace(0, 3 if open_ else 30, n))
|
||||
return nz * env
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# sequencer for music beds
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def render_line(notes, bpm, instr, gap=0.0):
|
||||
"""notes: list of (midi_or_None, beats). instr: fn(midi,dur)->array. Returns concatenated."""
|
||||
spb = 60.0 / bpm
|
||||
parts = []
|
||||
for midi, beats in notes:
|
||||
dur = beats * spb
|
||||
if midi is None:
|
||||
parts.append(np.zeros(int(dur * SR)))
|
||||
else:
|
||||
x = instr(midi, max(dur - gap, 0.05))
|
||||
x = np.concatenate([x, np.zeros(int(dur * SR) - len(x))]) if len(x) < int(dur*SR) else x[:int(dur*SR)]
|
||||
parts.append(x)
|
||||
return np.concatenate(parts)
|
||||
|
||||
|
||||
def render_drums(pattern, bpm, bars, r):
|
||||
"""pattern: dict 'k'/'s'/'h' -> list of 16th-step indices (0..15) for one bar."""
|
||||
spb = 60.0 / bpm
|
||||
step = spb / 4
|
||||
barlen = int(16 * step * SR)
|
||||
out = np.zeros(barlen * bars)
|
||||
voices = {"k": lambda: kick(), "s": lambda: snare(r=r), "h": lambda: hat(r=r),
|
||||
"o": lambda: hat(r=r, open_=True)}
|
||||
for b in range(bars):
|
||||
for v, steps in pattern.items():
|
||||
for s in steps:
|
||||
pos = int((b * 16 + s) * step * SR)
|
||||
smp = voices[v]()
|
||||
end = min(pos + len(smp), len(out))
|
||||
out[pos:end] += smp[:end - pos]
|
||||
return out
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AMBIENCE generators (loopable ~40s)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
AMB_LEN = 40.0
|
||||
XF = 1.0
|
||||
|
||||
|
||||
def amb_street_day(name):
|
||||
r = rng(name)
|
||||
dur = AMB_LEN + XF
|
||||
wind = lp1(noise(dur, r, "pink"), 600) * 0.6
|
||||
# distant traffic: slow-swelling band-passed noise
|
||||
traf = bandpass(noise(dur, r), 120, 900)
|
||||
swell = 0.5 + 0.5 * np.sin(2 * np.pi * 0.05 * tarr(dur) + r.random() * 6)
|
||||
traf = traf * swell * 0.35
|
||||
# sparse bird chirps
|
||||
birds = np.zeros(int(dur * SR))
|
||||
for _ in range(int(dur * 0.7)):
|
||||
at = r.uniform(0, dur - 0.4)
|
||||
f0 = r.uniform(2200, 4200)
|
||||
cd = r.uniform(0.06, 0.16)
|
||||
chirp = np.sin(2*np.pi*np.cumsum(np.linspace(f0, f0*r.uniform(0.8,1.4), int(cd*SR)))/SR)
|
||||
chirp *= np.hanning(len(chirp)) * r.uniform(0.05, 0.12)
|
||||
p = int(at * SR); birds[p:p+len(chirp)] += chirp
|
||||
body = mix(pan(wind, -0.2), pan(traf, 0.1), pan(birds, r.uniform(-0.5, 0.5)))
|
||||
return seamless(body, XF)
|
||||
|
||||
|
||||
def amb_street_night(name):
|
||||
r = rng(name)
|
||||
dur = AMB_LEN + XF
|
||||
hum = lp1(noise(dur, r, "pink"), 200) * 0.4 + osc(60, dur, "sine") * 0.02
|
||||
# crickets: rhythmic AM high tone
|
||||
base = osc(4500, dur, "sine")
|
||||
trill = (np.sin(2*np.pi*22*tarr(dur)) > 0.3).astype(float)
|
||||
gate = (0.5 + 0.5*np.sin(2*np.pi*0.7*tarr(dur))) > 0.55
|
||||
crickets = base * trill * gate * 0.04
|
||||
crickets = bandpass(crickets, 3500, 6000)
|
||||
body = mix(pan(hum, 0), pan(crickets, 0.3), pan(bandpass(noise(dur, r), 200, 500)*0.1, -0.3))
|
||||
return seamless(body, XF)
|
||||
|
||||
|
||||
def amb_rain(name):
|
||||
r = rng(name)
|
||||
dur = AMB_LEN + XF
|
||||
base = bandpass(noise(dur, r), 800, 9000) * 0.5 # hiss
|
||||
low = lp1(noise(dur, r), 400) * 0.25 # rumble
|
||||
drops = np.zeros(int(dur * SR))
|
||||
for _ in range(int(dur * 12)):
|
||||
at = r.uniform(0, dur - 0.05); f = r.uniform(1500, 5000)
|
||||
d = np.sin(2*np.pi*f*tarr(0.02)) * np.exp(-np.linspace(0, 30, int(0.02*SR))) * r.uniform(0.05, 0.15)
|
||||
p = int(at*SR); drops[p:p+len(d)] += d
|
||||
body = mix(pan(base, 0), pan(low, 0), pan(drops, r.uniform(-0.4, 0.4)))
|
||||
return seamless(body, XF)
|
||||
|
||||
|
||||
def _roomtone(name, hum_f, hiss_lo, hiss_hi, hiss_g, extra=None):
|
||||
r = rng(name)
|
||||
dur = AMB_LEN + XF
|
||||
hum = (osc(hum_f, dur, "sine")*0.5 + osc(hum_f*2, dur, "sine")*0.2) * 0.06
|
||||
hiss = bandpass(noise(dur, r), hiss_lo, hiss_hi) * hiss_g
|
||||
layers = [pan(hum, 0), pan(hiss, 0)]
|
||||
if extra is not None:
|
||||
layers.append(pan(extra(r, dur), 0.2))
|
||||
return seamless(mix(*layers), XF)
|
||||
|
||||
|
||||
def amb_roomtone_retail(name):
|
||||
return _roomtone(name, 100, 200, 2000, 0.05)
|
||||
|
||||
|
||||
def amb_roomtone_milkbar(name):
|
||||
# fridge compressor buzz
|
||||
return _roomtone(name, 120, 300, 1500, 0.05,
|
||||
extra=lambda r, d: softclip(osc(120, d, "tri")*0.4, 2)*0.05)
|
||||
|
||||
|
||||
def amb_roomtone_video(name):
|
||||
# faint CRT flyback whine (~15.7 kHz) + hum
|
||||
return _roomtone(name, 60, 400, 3000, 0.03,
|
||||
extra=lambda r, d: osc(15734, d, "sine")*0.012)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SFX generators (short, dry, peak-normalized)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _bell(midi, dur, partials, decay):
|
||||
f = midi_hz(midi)
|
||||
x = np.zeros(int(dur * SR))
|
||||
for mult, g in partials:
|
||||
x[:] += g * np.sin(2*np.pi*f*mult*tarr(dur))
|
||||
return x * np.exp(-np.linspace(0, decay, len(x)))
|
||||
|
||||
|
||||
def sfx_doorbell(name):
|
||||
ding = _bell(76, 0.9, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # E5
|
||||
dong = _bell(72, 1.1, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # C5
|
||||
x = np.concatenate([ding[:int(0.45*SR)], dong])
|
||||
return peaknorm(fade(x, 0.002, 0.05), -3)
|
||||
|
||||
|
||||
def sfx_door_open(name):
|
||||
r = rng(name)
|
||||
latch = noise(0.03, r) * np.exp(-np.linspace(0, 30, int(0.03*SR)))
|
||||
latch = bandpass(latch, 800, 5000)
|
||||
creak = bandpass(noise(0.4, r), 300, 1200)
|
||||
creak *= (0.3 + 0.7*np.abs(np.sin(2*np.pi*7*tarr(0.4)))) * np.linspace(1, 0, int(0.4*SR)) * 0.4
|
||||
x = np.concatenate([latch, np.zeros(int(0.05*SR)), creak])
|
||||
return peaknorm(fade(x, 0.002, 0.03), -4)
|
||||
|
||||
|
||||
def sfx_till(name):
|
||||
# pleasant buy-confirm: two-note bell arpeggio + tiny mechanical "cha"
|
||||
r = rng(name)
|
||||
n1 = _bell(72, 0.5, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6)
|
||||
n2 = _bell(79, 0.7, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6)
|
||||
cha = bandpass(noise(0.04, r), 2000, 8000) * np.exp(-np.linspace(0, 25, int(0.04*SR))) * 0.5
|
||||
x = mix(np.concatenate([cha, np.zeros(int(0.6*SR))]),
|
||||
np.concatenate([n1[:int(0.12*SR)], n2]))
|
||||
return peaknorm(fade(x, 0.002, 0.05), -3)
|
||||
|
||||
|
||||
def sfx_riffle(name):
|
||||
# record-bin dig: a run of soft filtered noise "flips"
|
||||
r = rng(name)
|
||||
out = np.zeros(int(0.9 * SR))
|
||||
tpos = 0.02
|
||||
while tpos < 0.85:
|
||||
flip = bandpass(noise(0.05, r), 500, 4000) * np.exp(-np.linspace(0, 22, int(0.05*SR)))
|
||||
flip *= r.uniform(0.4, 1.0)
|
||||
p = int(tpos * SR); out[p:p+len(flip)] += flip
|
||||
tpos += r.uniform(0.05, 0.11)
|
||||
return peaknorm(fade(out, 0.002, 0.03), -5)
|
||||
|
||||
|
||||
def sfx_toast(name):
|
||||
x = np.concatenate([synth_note(84, 0.06, "sine", 0.002, 0.02, 0.3, 0.03, cutoff=SR//2),
|
||||
synth_note(88, 0.12, "sine", 0.002, 0.03, 0.3, 0.06, cutoff=SR//2)])
|
||||
return peaknorm(fade(x, 0.002, 0.03), -6)
|
||||
|
||||
|
||||
def sfx_tram_bell(name):
|
||||
x = _bell(84, 1.2, [(1, 1), (2.4, 0.5), (3.9, 0.25), (5.2, 0.12)], 4)
|
||||
d2 = np.concatenate([np.zeros(int(0.18*SR)),
|
||||
_bell(84, 1.0, [(1, 1), (2.4, 0.5), (3.9, 0.25)], 4)])
|
||||
d2 = np.concatenate([d2, np.zeros(len(x))])[:len(x)] # zero-pad/trim to x
|
||||
return peaknorm(fade(x + 0.5*d2, 0.002, 0.08), -3)
|
||||
|
||||
|
||||
def sfx_tram_rumble(name):
|
||||
# loopable continuous low rumble (~3 s) — smooth (no discrete clacks that would collide at
|
||||
# the loop fold); the wheel-clatter texture is a modulated mid-band noise instead.
|
||||
r = rng(name); dur = 3.0 + XF
|
||||
low = lp1(noise(dur, r), 120) * 0.6 + osc(45, dur, "sine") * 0.2
|
||||
clatter = bandpass(noise(dur, r), 200, 1400)
|
||||
lfo = 0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 6 * tarr(dur))) # 6 Hz wheel wobble
|
||||
body = mix(low, clatter * lfo * 0.18)
|
||||
return seamless(peaknorm(body, -6), XF)
|
||||
|
||||
|
||||
def _footstep(name, bright, res_f):
|
||||
r = rng(name)
|
||||
thump = osc(np.linspace(res_f*1.6, res_f, int(0.05*SR)), 0.05, "sine")
|
||||
thump *= np.exp(-np.linspace(0, 18, int(0.05*SR)))
|
||||
click = bandpass(noise(0.04, r), 1500 if bright else 700, 9000 if bright else 4000)
|
||||
click *= np.exp(-np.linspace(0, 30, int(0.04*SR))) * (0.7 if bright else 0.4)
|
||||
x = mix(thump, click) * r.uniform(0.8, 1.0)
|
||||
return peaknorm(fade(x, 0.001, 0.02), -6)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# MUSIC beds (instrumental, loopable, seeded, parody-safe originals)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def loop_music(body, revmix=0.12, tail=1.2):
|
||||
"""Seamless music loop: render the reverb ring-out PAST the loop end into `tail` seconds of
|
||||
silence, then crossfade that genuine ring-out over the head. Result: when the loop restarts,
|
||||
the decay from the 'previous' pass bleeds into the start — no click. (The broken way is to
|
||||
append a head-copy, which makes tail==head and the crossfade a no-op.)"""
|
||||
if body.ndim == 1:
|
||||
body = stereo(body)
|
||||
T = int(tail * SR)
|
||||
wet = schroeder_reverb(np.concatenate([body, np.zeros((T, 2))]), revmix)
|
||||
return seamless(wet, tail)
|
||||
|
||||
|
||||
def music_record_shop(name):
|
||||
r = rng(name); bpm = 96; bars = 8
|
||||
spb = 60/bpm; barlen = int(4*spb*SR)
|
||||
# Am7 - D7 - Gmaj7 - Cmaj7 kind of groove (original voicing), root midis:
|
||||
prog = [45, 50, 43, 48] * 2 # A, D, G, C (one per bar)
|
||||
third = [+3, +4, +4, +4]; seventh = [+10, +10, +11, +11]
|
||||
bass = render_line(sum([[(root, 0.75), (None, 0.25), (root+7, 0.5), (root+12, 0.5),
|
||||
(root, 1.0), (root+7, 1.0)] for root in prog], []),
|
||||
bpm, lambda m, d: synth_note(m-12, d, "saw", 0.005, 0.1, 0.5, 0.05, cutoff=700))
|
||||
chords = []
|
||||
for i, root in enumerate(prog):
|
||||
for _ in range(4): # comp on each beat
|
||||
ch = mix(epiano(root+12, spb*0.9), epiano(root+12+third[i % 4], spb*0.9),
|
||||
epiano(root+12+seventh[i % 4], spb*0.9))
|
||||
chords.append(ch * 0.5)
|
||||
chords = np.concatenate([np.concatenate([c, np.zeros(max(int(spb*SR)-len(c),0))])[:int(spb*SR)] for c in chords])
|
||||
drums = render_drums({"k": [0, 6, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14]}, bpm, bars, r)
|
||||
body = mix(pan(bass, 0)*0.9, pan(chords, -0.15)*0.7, pan(drums, 0.1)*0.6)
|
||||
return loop_music(body, 0.12)
|
||||
|
||||
|
||||
def music_milkbar(name):
|
||||
r = rng(name); bpm = 108; bars = 8
|
||||
spb = 60/bpm
|
||||
prog = [48, 55, 53, 50] # C G F D-ish bright pop (original)
|
||||
bass = render_line(sum([[(root-12, 1.0), (root-12, 1.0), (root-5, 1.0), (root-12, 1.0)] for root in prog]*2, []),
|
||||
bpm, lambda m, d: synth_note(m, d, "tri", 0.005, 0.1, 0.6, 0.05, cutoff=900))
|
||||
lead = render_line([(r.choice([root, root+4, root+7, root+12]), 0.5) for root in prog for _ in range(8)],
|
||||
bpm, lambda m, d: synth_note(m+12, d, "square", 0.005, 0.05, 0.4, 0.05, cutoff=3000))
|
||||
drums = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [14]}, bpm, bars, r)
|
||||
body = mix(pan(bass, 0)*0.9, pan(lead, 0.2)*0.35, pan(drums, 0)*0.55)
|
||||
return loop_music(body, 0.1)
|
||||
|
||||
|
||||
def music_video_synth(name):
|
||||
r = rng(name); bpm = 84; bars = 8
|
||||
spb = 60/bpm
|
||||
prog = [45, 45, 50, 43] # moody synthwave (original)
|
||||
pads = np.concatenate([mix(pad(root, 4*spb), pad(root+7, 4*spb), pad(root+10, 4*spb))[:int(4*spb*SR)]
|
||||
for root in prog]*2)
|
||||
arp_notes = []
|
||||
for root in prog*2:
|
||||
for s in [0, 7, 12, 7]:
|
||||
arp_notes.append((root+12+s, 0.5))
|
||||
arp = render_line(arp_notes, bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.05, 0.3, 0.05, cutoff=2600))
|
||||
bass = render_line(sum([[(root-12, 2.0), (root-12, 2.0)] for root in prog]*2, []),
|
||||
bpm, lambda m, d: synth_note(m, d, "saw", 0.01, 0.1, 0.7, 0.1, cutoff=500))
|
||||
drums = render_drums({"k": [0, 8], "s": [8], "h": [2, 6, 10, 14]}, bpm, bars, r)
|
||||
body = mix(pan(pads, 0)*0.5, pan(arp, 0.3)*0.3, pan(bass, 0)*0.8, pan(drums, 0)*0.4)
|
||||
return loop_music(body, 0.2)
|
||||
|
||||
|
||||
def music_arcade(name):
|
||||
r = rng(name); bpm = 132; bars = 8
|
||||
spb = 60/bpm
|
||||
prog = [48, 48, 53, 55] # upbeat chiptune (original)
|
||||
lead_scale = [0, 2, 4, 7, 9, 12]
|
||||
lead = render_line([(root+12+r.choice(lead_scale), 0.25) for root in prog for _ in range(16)],
|
||||
bpm, lambda m, d: synth_note(m, d, "square", 0.001, 0.02, 0.5, 0.02, cutoff=SR//2))
|
||||
bass = render_line(sum([[(root-12, 0.5), (root, 0.5)]*4 for root in prog]*2, []),
|
||||
bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.03, 0.5, 0.02, cutoff=SR//2))
|
||||
hats = render_drums({"h": list(range(0, 16, 2)), "k": [0, 4, 8, 12], "s": [4, 12]}, bpm, bars, r)
|
||||
body = mix(pan(lead, 0.1)*0.4, pan(bass, -0.1)*0.5, pan(hats, 0)*0.4)
|
||||
return loop_music(body, 0.03)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# the pack: name -> (category, generator, {loop, gain, note})
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
PACK = {
|
||||
# ambience (loop, quiet beds)
|
||||
"ambience-street-day": ("ambience", amb_street_day, dict(loop=True, gain=0.5)),
|
||||
"ambience-street-night": ("ambience", amb_street_night, dict(loop=True, gain=0.5)),
|
||||
"ambience-rain": ("ambience", amb_rain, dict(loop=True, gain=0.45, note="layer over street beds")),
|
||||
"roomtone-retail": ("ambience", amb_roomtone_retail, dict(loop=True, gain=0.35)),
|
||||
"roomtone-milkbar": ("ambience", amb_roomtone_milkbar, dict(loop=True, gain=0.35)),
|
||||
"roomtone-video": ("ambience", amb_roomtone_video, dict(loop=True, gain=0.3)),
|
||||
# sfx
|
||||
"sfx-doorbell": ("sfx", sfx_doorbell, dict(loop=False, gain=0.7)),
|
||||
"sfx-door-open": ("sfx", sfx_door_open, dict(loop=False, gain=0.6)),
|
||||
"sfx-till": ("sfx", sfx_till, dict(loop=False, gain=0.7)),
|
||||
"sfx-riffle": ("sfx", sfx_riffle, dict(loop=False, gain=0.6)),
|
||||
"sfx-toast": ("sfx", sfx_toast, dict(loop=False, gain=0.5)),
|
||||
"sfx-tram-bell": ("sfx", sfx_tram_bell, dict(loop=False, gain=0.7)),
|
||||
"sfx-tram-rumble":("sfx", sfx_tram_rumble,dict(loop=True, gain=0.5)),
|
||||
# footsteps (2 surfaces x 3 variants)
|
||||
"step-pavement-1": ("sfx", lambda n: _footstep(n, True, 90), dict(loop=False, gain=0.4)),
|
||||
"step-pavement-2": ("sfx", lambda n: _footstep(n, True, 95), dict(loop=False, gain=0.4)),
|
||||
"step-pavement-3": ("sfx", lambda n: _footstep(n, True, 85), dict(loop=False, gain=0.4)),
|
||||
"step-timber-1": ("sfx", lambda n: _footstep(n, False, 70), dict(loop=False, gain=0.4)),
|
||||
"step-timber-2": ("sfx", lambda n: _footstep(n, False, 75), dict(loop=False, gain=0.4)),
|
||||
"step-timber-3": ("sfx", lambda n: _footstep(n, False, 65), dict(loop=False, gain=0.4)),
|
||||
# music beds (loop, instrumental originals)
|
||||
"music-record-shop": ("music", music_record_shop, dict(loop=True, gain=0.55, note="record shop")),
|
||||
"music-milkbar": ("music", music_milkbar, dict(loop=True, gain=0.5, note="milk bar radio")),
|
||||
"music-video-synth": ("music", music_video_synth, dict(loop=True, gain=0.5, note="video store")),
|
||||
"music-arcade": ("music", music_arcade, dict(loop=True, gain=0.5, note="arcade")),
|
||||
}
|
||||
|
||||
|
||||
def write_wav(path, x):
|
||||
if x.ndim == 1:
|
||||
x = stereo(x)
|
||||
peak = np.max(np.abs(x))
|
||||
if peak > 0.95: # limiter: never clip (loudnorm sets final loudness)
|
||||
x = x * (0.95 / peak)
|
||||
x = np.clip(x, -1.0, 1.0)
|
||||
pcm = (x * 32767).astype("<i2")
|
||||
with wave.open(path, "wb") as w:
|
||||
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
|
||||
w.writeframes(pcm.tobytes())
|
||||
|
||||
|
||||
def encode(wav, base, category):
|
||||
"""WAV -> OGG/Opus (+ M4A/AAC). Long beds/music get loudnorm; SFX are pre-peak-normalized."""
|
||||
ogg = os.path.join(OUT, base + ".ogg")
|
||||
m4a = os.path.join(OUT, base + ".m4a")
|
||||
af = ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11"] if category in ("ambience", "music") else []
|
||||
br = "96k" if category == "music" else ("80k" if category == "ambience" else "64k")
|
||||
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "libopus", "-b:a", br, "-ar", "48000", ogg],
|
||||
check=True, capture_output=True)
|
||||
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "aac", "-b:a", "128k", "-ar", "44100", m4a],
|
||||
check=True, capture_output=True)
|
||||
return os.path.getsize(ogg), os.path.getsize(m4a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
|
||||
wav_only = "--wav-only" in sys.argv
|
||||
todo = {k: v for k, v in PACK.items() if not only or only in k or only == v[0]}
|
||||
|
||||
if "--dry-run" in sys.argv:
|
||||
print(f"{len(todo)} audio assets (procedural synth, $0):")
|
||||
by = {}
|
||||
for k, (cat, _, meta) in sorted(todo.items()):
|
||||
by.setdefault(cat, []).append(k)
|
||||
for cat, ks in by.items():
|
||||
print(f" [{cat}] {len(ks)}: {', '.join(ks)}")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"rendering {len(todo)} assets @ {SR}Hz (numpy synth -> ffmpeg opus+aac)")
|
||||
total_ogg = total_m4a = 0
|
||||
for k, (cat, gen, meta) in todo.items():
|
||||
wav = os.path.join(RAW, k + ".wav")
|
||||
x = gen(k)
|
||||
write_wav(wav, x)
|
||||
dur = (len(x) / SR)
|
||||
if wav_only:
|
||||
print(f" [{cat}] {k} {dur:.1f}s (wav only)")
|
||||
continue
|
||||
og, m4 = encode(wav, k, cat)
|
||||
total_ogg += og; total_m4a += m4
|
||||
print(f" [{cat}] {k:22s} {dur:5.1f}s ogg={og//1024:>4}KB m4a={m4//1024:>4}KB")
|
||||
if not wav_only:
|
||||
print(f"\ntotal shipped: ogg {total_ogg/1e6:.2f}MB + m4a {total_m4a/1e6:.2f}MB "
|
||||
f"= {(total_ogg+total_m4a)/1e6:.2f}MB (budget 25MB)")
|
||||
@ -116,6 +116,22 @@ def main():
|
||||
manifest_files.add(entry["file"])
|
||||
n_glb += 1
|
||||
|
||||
# audio pack (round-11): if the manifest names an audio file it must ship locally (both the
|
||||
# ogg primary and the m4a fallback). Silent-happy is a runtime rule, not a licence to dangle refs.
|
||||
n_audio = 0
|
||||
def _chk_audio(e):
|
||||
for key in ("file", "fallback"):
|
||||
if key in e:
|
||||
check_skin(e[key])
|
||||
for grp in m.get("audio", {}).values():
|
||||
for v in grp.values():
|
||||
if isinstance(v, dict) and "file" in v:
|
||||
_chk_audio(v); n_audio += 1
|
||||
elif isinstance(v, dict): # footstep {surface:[variants]}
|
||||
for arr in v.values():
|
||||
for e in arr:
|
||||
_chk_audio(e); n_audio += 1
|
||||
|
||||
# provenance-drift gate: every manifest depot GLB must be recorded in _published.json, so a
|
||||
# clobbered/stale provenance record (the R5 bug) fails QA loudly instead of hiding.
|
||||
recpath = os.path.join(ROOT, "pipeline", "_published.json")
|
||||
@ -138,7 +154,7 @@ def main():
|
||||
|
||||
print(f"manifest v{m.get('version')} — facades {len(facade)}, "
|
||||
f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, "
|
||||
f"GLBs {n_glb}")
|
||||
f"GLBs {n_glb}, audio {n_audio}")
|
||||
for w in warnings:
|
||||
print(f" WARN {w}")
|
||||
if errors:
|
||||
|
||||
BIN
web/assets/audio/ambience-rain.m4a
Normal file
BIN
web/assets/audio/ambience-rain.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/ambience-rain.ogg
Normal file
BIN
web/assets/audio/ambience-rain.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/ambience-street-day.m4a
Normal file
BIN
web/assets/audio/ambience-street-day.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/ambience-street-day.ogg
Normal file
BIN
web/assets/audio/ambience-street-day.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/ambience-street-night.m4a
Normal file
BIN
web/assets/audio/ambience-street-night.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/ambience-street-night.ogg
Normal file
BIN
web/assets/audio/ambience-street-night.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/music-arcade.m4a
Normal file
BIN
web/assets/audio/music-arcade.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/music-arcade.ogg
Normal file
BIN
web/assets/audio/music-arcade.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/music-milkbar.m4a
Normal file
BIN
web/assets/audio/music-milkbar.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/music-milkbar.ogg
Normal file
BIN
web/assets/audio/music-milkbar.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/music-record-shop.m4a
Normal file
BIN
web/assets/audio/music-record-shop.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/music-record-shop.ogg
Normal file
BIN
web/assets/audio/music-record-shop.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/music-video-synth.m4a
Normal file
BIN
web/assets/audio/music-video-synth.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/music-video-synth.ogg
Normal file
BIN
web/assets/audio/music-video-synth.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-milkbar.m4a
Normal file
BIN
web/assets/audio/roomtone-milkbar.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-milkbar.ogg
Normal file
BIN
web/assets/audio/roomtone-milkbar.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-retail.m4a
Normal file
BIN
web/assets/audio/roomtone-retail.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-retail.ogg
Normal file
BIN
web/assets/audio/roomtone-retail.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-video.m4a
Normal file
BIN
web/assets/audio/roomtone-video.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/roomtone-video.ogg
Normal file
BIN
web/assets/audio/roomtone-video.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-door-open.m4a
Normal file
BIN
web/assets/audio/sfx-door-open.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-door-open.ogg
Normal file
BIN
web/assets/audio/sfx-door-open.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-doorbell.m4a
Normal file
BIN
web/assets/audio/sfx-doorbell.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-doorbell.ogg
Normal file
BIN
web/assets/audio/sfx-doorbell.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-riffle.m4a
Normal file
BIN
web/assets/audio/sfx-riffle.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-riffle.ogg
Normal file
BIN
web/assets/audio/sfx-riffle.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-till.m4a
Normal file
BIN
web/assets/audio/sfx-till.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-till.ogg
Normal file
BIN
web/assets/audio/sfx-till.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-toast.m4a
Normal file
BIN
web/assets/audio/sfx-toast.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-toast.ogg
Normal file
BIN
web/assets/audio/sfx-toast.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-tram-bell.m4a
Normal file
BIN
web/assets/audio/sfx-tram-bell.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-tram-bell.ogg
Normal file
BIN
web/assets/audio/sfx-tram-bell.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-tram-rumble.m4a
Normal file
BIN
web/assets/audio/sfx-tram-rumble.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/sfx-tram-rumble.ogg
Normal file
BIN
web/assets/audio/sfx-tram-rumble.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-1.m4a
Normal file
BIN
web/assets/audio/step-pavement-1.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-1.ogg
Normal file
BIN
web/assets/audio/step-pavement-1.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-2.m4a
Normal file
BIN
web/assets/audio/step-pavement-2.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-2.ogg
Normal file
BIN
web/assets/audio/step-pavement-2.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-3.m4a
Normal file
BIN
web/assets/audio/step-pavement-3.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-pavement-3.ogg
Normal file
BIN
web/assets/audio/step-pavement-3.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-1.m4a
Normal file
BIN
web/assets/audio/step-timber-1.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-1.ogg
Normal file
BIN
web/assets/audio/step-timber-1.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-2.m4a
Normal file
BIN
web/assets/audio/step-timber-2.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-2.ogg
Normal file
BIN
web/assets/audio/step-timber-2.ogg
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-3.m4a
Normal file
BIN
web/assets/audio/step-timber-3.m4a
Normal file
Binary file not shown.
BIN
web/assets/audio/step-timber-3.ogg
Normal file
BIN
web/assets/audio/step-timber-3.ogg
Normal file
Binary file not shown.
@ -8,7 +8,8 @@
|
||||
"facade_face": "facade entries without a 'face' key are front elevations (shop or house fronts); face:'side' entries are windowless corner-lot side walls with empty types \u2014 never select one as a shop front",
|
||||
"corner_lots": "corner lots expose a side wall: dedicated facade-<x>-side.jpg skins now exist for brick/render/weatherboard (face:'side') \u2014 Lane B maps these to the exposed side face; other types reuse the front skin",
|
||||
"fallbacks": "every asset is optional \u2014 missing file => primitive/flat-colour, never a crash",
|
||||
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>"
|
||||
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
|
||||
"audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch."
|
||||
},
|
||||
"skins": {
|
||||
"facade": {
|
||||
@ -838,5 +839,180 @@
|
||||
"magazine_rack": "magazine_rack",
|
||||
"fridge": "drinks_fridge",
|
||||
"listening_corner": "listening_booth"
|
||||
},
|
||||
"audio": {
|
||||
"ambience": {
|
||||
"street-day": {
|
||||
"file": "audio/ambience-street-day.ogg",
|
||||
"fallback": "audio/ambience-street-day.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.5,
|
||||
"scope": "street"
|
||||
},
|
||||
"street-night": {
|
||||
"file": "audio/ambience-street-night.ogg",
|
||||
"fallback": "audio/ambience-street-night.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.5,
|
||||
"scope": "street"
|
||||
},
|
||||
"rain": {
|
||||
"file": "audio/ambience-rain.ogg",
|
||||
"fallback": "audio/ambience-rain.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.45,
|
||||
"scope": "street"
|
||||
},
|
||||
"roomtone-retail": {
|
||||
"file": "audio/roomtone-retail.ogg",
|
||||
"fallback": "audio/roomtone-retail.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.35,
|
||||
"scope": "interior",
|
||||
"types": [
|
||||
"record",
|
||||
"opshop",
|
||||
"book",
|
||||
"toy",
|
||||
"pawn",
|
||||
"stall",
|
||||
"general"
|
||||
]
|
||||
},
|
||||
"roomtone-milkbar": {
|
||||
"file": "audio/roomtone-milkbar.ogg",
|
||||
"fallback": "audio/roomtone-milkbar.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.35,
|
||||
"scope": "interior",
|
||||
"types": [
|
||||
"milkbar"
|
||||
]
|
||||
},
|
||||
"roomtone-video": {
|
||||
"file": "audio/roomtone-video.ogg",
|
||||
"fallback": "audio/roomtone-video.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.35,
|
||||
"scope": "interior",
|
||||
"types": [
|
||||
"video",
|
||||
"dept",
|
||||
"arcade"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sfx": {
|
||||
"doorbell": {
|
||||
"file": "audio/sfx-doorbell.ogg",
|
||||
"fallback": "audio/sfx-doorbell.m4a",
|
||||
"gain": 0.7
|
||||
},
|
||||
"door-open": {
|
||||
"file": "audio/sfx-door-open.ogg",
|
||||
"fallback": "audio/sfx-door-open.m4a",
|
||||
"gain": 0.6
|
||||
},
|
||||
"till": {
|
||||
"file": "audio/sfx-till.ogg",
|
||||
"fallback": "audio/sfx-till.m4a",
|
||||
"gain": 0.7
|
||||
},
|
||||
"riffle": {
|
||||
"file": "audio/sfx-riffle.ogg",
|
||||
"fallback": "audio/sfx-riffle.m4a",
|
||||
"gain": 0.6
|
||||
},
|
||||
"toast": {
|
||||
"file": "audio/sfx-toast.ogg",
|
||||
"fallback": "audio/sfx-toast.m4a",
|
||||
"gain": 0.5
|
||||
},
|
||||
"tram-bell": {
|
||||
"file": "audio/sfx-tram-bell.ogg",
|
||||
"fallback": "audio/sfx-tram-bell.m4a",
|
||||
"gain": 0.7
|
||||
},
|
||||
"tram-rumble": {
|
||||
"file": "audio/sfx-tram-rumble.ogg",
|
||||
"fallback": "audio/sfx-tram-rumble.m4a",
|
||||
"gain": 0.5,
|
||||
"loop": true
|
||||
},
|
||||
"footstep": {
|
||||
"pavement": [
|
||||
{
|
||||
"file": "audio/step-pavement-1.ogg",
|
||||
"fallback": "audio/step-pavement-1.m4a"
|
||||
},
|
||||
{
|
||||
"file": "audio/step-pavement-2.ogg",
|
||||
"fallback": "audio/step-pavement-2.m4a"
|
||||
},
|
||||
{
|
||||
"file": "audio/step-pavement-3.ogg",
|
||||
"fallback": "audio/step-pavement-3.m4a"
|
||||
}
|
||||
],
|
||||
"timber": [
|
||||
{
|
||||
"file": "audio/step-timber-1.ogg",
|
||||
"fallback": "audio/step-timber-1.m4a"
|
||||
},
|
||||
{
|
||||
"file": "audio/step-timber-2.ogg",
|
||||
"fallback": "audio/step-timber-2.m4a"
|
||||
},
|
||||
{
|
||||
"file": "audio/step-timber-3.ogg",
|
||||
"fallback": "audio/step-timber-3.m4a"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"music": {
|
||||
"record-shop": {
|
||||
"file": "audio/music-record-shop.ogg",
|
||||
"fallback": "audio/music-record-shop.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.55,
|
||||
"types": [
|
||||
"record"
|
||||
],
|
||||
"note": "record shop plays music"
|
||||
},
|
||||
"milkbar": {
|
||||
"file": "audio/music-milkbar.ogg",
|
||||
"fallback": "audio/music-milkbar.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.5,
|
||||
"types": [
|
||||
"milkbar",
|
||||
"general"
|
||||
],
|
||||
"note": "milk bar radio"
|
||||
},
|
||||
"video-synth": {
|
||||
"file": "audio/music-video-synth.ogg",
|
||||
"fallback": "audio/music-video-synth.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.5,
|
||||
"types": [
|
||||
"video"
|
||||
],
|
||||
"note": "video store synth hum"
|
||||
},
|
||||
"arcade": {
|
||||
"file": "audio/music-arcade.ogg",
|
||||
"fallback": "audio/music-arcade.m4a",
|
||||
"loop": true,
|
||||
"gain": 0.5,
|
||||
"types": [
|
||||
"arcade",
|
||||
"dept"
|
||||
],
|
||||
"note": "arcade cabinet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user