Spaces:
Running on Zero
Running on Zero
Add ambience generation features and assets. Introduced ambience.py for procedural and sampled ambience beds, updated app.py to integrate ambience selection into music generation, and modified requirements.txt to include new dependencies. Added scripts for fetching and rendering ambience samples, along with new audio assets and credits for attribution.
Browse files- .gitattributes +1 -0
- ambience.py +197 -0
- app.py +44 -45
- assets/ambience/CREDITS.md +46 -0
- assets/ambience/birdsong.wav +3 -0
- assets/ambience/cafe_murmur.wav +3 -0
- assets/ambience/credits.json +51 -0
- assets/ambience/fireplace_crackle.wav +3 -0
- assets/ambience/night_crickets.wav +3 -0
- assets/ambience/ocean_waves.wav +3 -0
- assets/ambience/soft_rain.wav +3 -0
- assets/ambience/wind_in_trees.wav +3 -0
- requirements.txt +6 -0
- scripts/fetch_ambience.py +405 -0
- scripts/make_ambience.py +109 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
ambience.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ambience beds for LoFinity tapes.
|
| 2 |
+
|
| 3 |
+
MusicGen ignores texture words ("vinyl crackle", "ocean waves"), so the
|
| 4 |
+
background layer is mixed in here instead: a bed is rendered at song length
|
| 5 |
+
and summed a few dB under the music. Lofi ambience loops through the whole
|
| 6 |
+
track anyway, so nothing needs to be generated per song.
|
| 7 |
+
|
| 8 |
+
vinyl_crackle and tape_hiss are synthesized procedurally (cheap, and never
|
| 9 |
+
sound repeated); the other seven are loops in assets/ambience/<slug>.wav,
|
| 10 |
+
rendered once by scripts/make_ambience.py and tiled with crossfades. A
|
| 11 |
+
missing asset falls back to vinyl crackle so every tape still has texture.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import wave
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
ASSETS = Path(__file__).parent / "assets" / "ambience"
|
| 20 |
+
|
| 21 |
+
# Bed RMS relative to the music RMS, in dB. Starting points — tune by ear:
|
| 22 |
+
# spiky textures (crackle, fire) read louder than their RMS suggests.
|
| 23 |
+
GAIN_DB = {
|
| 24 |
+
"vinyl_crackle": -14.0,
|
| 25 |
+
"tape_hiss": -18.0,
|
| 26 |
+
"soft_rain": -14.0,
|
| 27 |
+
"ocean_waves": -12.0,
|
| 28 |
+
"fireplace_crackle": -14.0,
|
| 29 |
+
"birdsong": -16.0,
|
| 30 |
+
"night_crickets": -16.0,
|
| 31 |
+
"cafe_murmur": -16.0,
|
| 32 |
+
"wind_in_trees": -14.0,
|
| 33 |
+
}
|
| 34 |
+
DEFAULT = "vinyl_crackle"
|
| 35 |
+
|
| 36 |
+
# Checked in order; first hit wins ("fireplace crackle" must match fire
|
| 37 |
+
# before crackle can claim it for vinyl).
|
| 38 |
+
_KEYWORDS = (
|
| 39 |
+
("fire", "fireplace_crackle"),
|
| 40 |
+
("rain", "soft_rain"),
|
| 41 |
+
("wave", "ocean_waves"),
|
| 42 |
+
("ocean", "ocean_waves"),
|
| 43 |
+
("sea", "ocean_waves"),
|
| 44 |
+
("bird", "birdsong"),
|
| 45 |
+
("cricket", "night_crickets"),
|
| 46 |
+
("cafe", "cafe_murmur"),
|
| 47 |
+
("coffee", "cafe_murmur"),
|
| 48 |
+
("murmur", "cafe_murmur"),
|
| 49 |
+
("chatter", "cafe_murmur"),
|
| 50 |
+
("wind", "wind_in_trees"),
|
| 51 |
+
("tree", "wind_in_trees"),
|
| 52 |
+
("leaves", "wind_in_trees"),
|
| 53 |
+
("vinyl", "vinyl_crackle"),
|
| 54 |
+
("crackle", "vinyl_crackle"),
|
| 55 |
+
("static", "vinyl_crackle"),
|
| 56 |
+
("record", "vinyl_crackle"),
|
| 57 |
+
("hiss", "tape_hiss"),
|
| 58 |
+
("tape", "tape_hiss"),
|
| 59 |
+
("noise", "tape_hiss"),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def normalize_slug(value) -> str:
|
| 64 |
+
"""Map whatever the LLM produced onto a known slug ("Ocean waves!" ->
|
| 65 |
+
ocean_waves); anything unrecognizable becomes the default crackle."""
|
| 66 |
+
text = str(value or "").strip().lower()
|
| 67 |
+
slug = text.replace(" ", "_").replace("-", "_")
|
| 68 |
+
if slug in GAIN_DB:
|
| 69 |
+
return slug
|
| 70 |
+
for word, match in _KEYWORDS:
|
| 71 |
+
if word in text:
|
| 72 |
+
return match
|
| 73 |
+
return DEFAULT
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --- procedural beds ----------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _lowpassed_noise(n: int, rate: int, cutoff: float, rng) -> np.ndarray:
|
| 80 |
+
"""Cheap dull noise: draw at ~2*cutoff and linearly upsample (the
|
| 81 |
+
interpolation is the lowpass)."""
|
| 82 |
+
low_rate = max(int(cutoff * 2), 200)
|
| 83 |
+
m = max(int(n * low_rate / rate) + 2, 2)
|
| 84 |
+
coarse = rng.standard_normal(m)
|
| 85 |
+
return np.interp(np.arange(n) * (low_rate / rate), np.arange(m), coarse)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _vinyl_crackle(n: int, rate: int, rng) -> np.ndarray:
|
| 89 |
+
"""Dusty surface noise plus sparse pops, tiny pops, not loud."""
|
| 90 |
+
out = _lowpassed_noise(n, rate, 2500, rng) * 0.06
|
| 91 |
+
for pos in rng.integers(0, n, max(int(n / rate * 9), 1)):
|
| 92 |
+
length = int(rate * rng.uniform(0.001, 0.004))
|
| 93 |
+
amp = rng.uniform(0.15, 1.0) ** 2 * np.sign(rng.standard_normal())
|
| 94 |
+
pop = amp * np.exp(-np.arange(length) / (length / 5))
|
| 95 |
+
end = min(pos + length, n)
|
| 96 |
+
out[pos:end] += pop[: end - pos]
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _tape_hiss(n: int, rate: int, rng) -> np.ndarray:
|
| 101 |
+
white = rng.standard_normal(n)
|
| 102 |
+
# first difference tilts the spectrum toward the highs, where hiss lives
|
| 103 |
+
tilted = np.zeros(n)
|
| 104 |
+
tilted[1:] = np.diff(white)
|
| 105 |
+
hiss = 0.35 * white + 0.65 * tilted
|
| 106 |
+
# slow wobble so it breathes like a real transport
|
| 107 |
+
lfo = 0.3 # Hz
|
| 108 |
+
phase = rng.uniform(0, 2 * np.pi)
|
| 109 |
+
return hiss * (1.0 + 0.08 * np.sin(2 * np.pi * lfo * np.arange(n) / rate + phase))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
_PROCEDURAL = {"vinyl_crackle": _vinyl_crackle, "tape_hiss": _tape_hiss}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --- sampled beds ---------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _read_wav(path: Path) -> tuple[np.ndarray, int]:
|
| 119 |
+
with wave.open(str(path), "rb") as w:
|
| 120 |
+
rate, channels, width = w.getframerate(), w.getnchannels(), w.getsampwidth()
|
| 121 |
+
raw = w.readframes(w.getnframes())
|
| 122 |
+
if width != 2:
|
| 123 |
+
raise ValueError(f"{path.name}: expected 16-bit wav, got {width * 8}-bit")
|
| 124 |
+
data = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
|
| 125 |
+
if channels > 1:
|
| 126 |
+
data = data.reshape(-1, channels).mean(axis=1)
|
| 127 |
+
return data, rate
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _resample(data: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
| 131 |
+
if src_rate == dst_rate:
|
| 132 |
+
return data
|
| 133 |
+
m = int(len(data) * dst_rate / src_rate)
|
| 134 |
+
return np.interp(np.arange(m) * (src_rate / dst_rate), np.arange(len(data)), data)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _tile(loop: np.ndarray, n: int, rate: int) -> np.ndarray:
|
| 138 |
+
"""Repeat the loop out to n samples, crossfading each seam so it
|
| 139 |
+
doesn't click. The loop does not need to be seamless.
|
| 140 |
+
|
| 141 |
+
The fade uses equal-power (sqrt) ramps, not linear: the tail and head
|
| 142 |
+
being blended are uncorrelated audio, so linear ramps would sum to ~3-6 dB
|
| 143 |
+
below the surrounding level at the crossfade midpoint (an audible dip every
|
| 144 |
+
loop). With sqrt ramps gain_out**2 + gain_in**2 == 1, holding power steady."""
|
| 145 |
+
if len(loop) >= n:
|
| 146 |
+
return loop[:n].copy()
|
| 147 |
+
fade = min(int(rate * 0.5), len(loop) // 4)
|
| 148 |
+
if fade == 0:
|
| 149 |
+
return np.tile(loop, n // len(loop) + 1)[:n]
|
| 150 |
+
ramp = np.sqrt(np.linspace(0.0, 1.0, fade))
|
| 151 |
+
out = np.zeros(n + len(loop))
|
| 152 |
+
pos = 0
|
| 153 |
+
while pos < n:
|
| 154 |
+
seg = loop.copy()
|
| 155 |
+
if pos:
|
| 156 |
+
seg[:fade] *= ramp
|
| 157 |
+
seg[-fade:] *= ramp[::-1]
|
| 158 |
+
out[pos : pos + len(seg)] += seg
|
| 159 |
+
pos += len(loop) - fade
|
| 160 |
+
return out[:n]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --- public API -----------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def render(slug: str, n: int, rate: int) -> np.ndarray:
|
| 167 |
+
"""A peak-normalized bed of n samples at `rate`; the caller sets the level."""
|
| 168 |
+
if slug in _PROCEDURAL:
|
| 169 |
+
bed = _PROCEDURAL[slug](n, rate, np.random.default_rng())
|
| 170 |
+
else:
|
| 171 |
+
loop, loop_rate = _read_wav(ASSETS / f"{slug}.wav")
|
| 172 |
+
bed = _tile(_resample(loop, loop_rate, rate), n, rate)
|
| 173 |
+
return bed / (float(np.abs(bed).max()) or 1.0)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def mix(music, rate: int, slug: str) -> np.ndarray:
|
| 177 |
+
"""Sum the ambience bed under the music at its slug's relative RMS level."""
|
| 178 |
+
slug = normalize_slug(slug)
|
| 179 |
+
if slug not in _PROCEDURAL and not (ASSETS / f"{slug}.wav").exists():
|
| 180 |
+
print(
|
| 181 |
+
f"[lofinity] no ambience asset for {slug!r} "
|
| 182 |
+
"(run scripts/make_ambience.py), using vinyl crackle"
|
| 183 |
+
)
|
| 184 |
+
slug = DEFAULT
|
| 185 |
+
music = np.asarray(music, dtype=np.float64)
|
| 186 |
+
music_rms = float(np.sqrt(np.mean(music**2)))
|
| 187 |
+
if music_rms < 1e-6: # silence in, silence out
|
| 188 |
+
return music
|
| 189 |
+
bed = render(slug, len(music), rate)
|
| 190 |
+
bed_rms = float(np.sqrt(np.mean(bed**2))) or 1.0
|
| 191 |
+
bed *= music_rms * 10 ** (GAIN_DB[slug] / 20) / bed_rms
|
| 192 |
+
edge = min(int(rate * 0.75), len(bed) // 4)
|
| 193 |
+
if edge:
|
| 194 |
+
ramp = np.linspace(0.0, 1.0, edge)
|
| 195 |
+
bed[:edge] *= ramp
|
| 196 |
+
bed[-edge:] *= ramp[::-1]
|
| 197 |
+
return music + bed
|
app.py
CHANGED
|
@@ -4,7 +4,9 @@ Gradio Server backend: serves the Three.js frontend and exposes the
|
|
| 4 |
generation API.
|
| 5 |
|
| 6 |
Pipeline: user vibe -> Ollama (small LLM) enriches it into a MusicGen
|
| 7 |
-
prompt + cassette title -> MusicGen renders the
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Env knobs:
|
| 10 |
LOFINITY_ENGINE musicgen (default) | stub
|
|
@@ -15,9 +17,7 @@ Env knobs:
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
import json
|
| 18 |
-
import math
|
| 19 |
import os
|
| 20 |
-
import struct
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
import uuid
|
|
@@ -48,32 +48,35 @@ app = Server(title="LoFinity")
|
|
| 48 |
ENRICH_SYSTEM = """\
|
| 49 |
You are the creative brain of LoFinity, a magical vending machine that sells
|
| 50 |
lofi cassette tapes. The user gives you a vibe. Reply ONLY with JSON with
|
| 51 |
-
exactly these
|
| 52 |
|
| 53 |
Build music_prompt from this template, in this order:
|
| 54 |
-
"lofi chill, <instrument 1>, <instrument 2>, <instrument 3>, <
|
| 55 |
|
| 56 |
- instruments: 2-3 picked to EVOKE the user's vibe, never a default set
|
| 57 |
(island -> ukulele, kalimba, steel pan; rainy city -> rhodes piano, soft
|
| 58 |
guitar; winter -> felt piano, soft strings; desert -> slide guitar, hand drums)
|
| 59 |
-
- background noise: exactly one, matched to the vibe: vinyl crackle, tape
|
| 60 |
-
hiss, soft rain, ocean waves, fireplace crackle, birdsong, night crickets,
|
| 61 |
-
cafe murmur, or wind in trees
|
| 62 |
- mood: one or two calm words; never energetic, no vocals
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
title: a cozy cassette tape title inspired by the vibe, max 5 words,
|
| 65 |
Title Case, no quotes or emoji.
|
| 66 |
|
| 67 |
Examples:
|
| 68 |
user: island summer
|
| 69 |
-
{"music_prompt": "lofi chill, ukulele, kalimba, steel pan,
|
| 70 |
user: studying at midnight
|
| 71 |
-
{"music_prompt": "lofi chill, rhodes piano, muted guitar, soft bass,
|
|
|
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
def enrich_prompt(prompt: str) -> tuple[str, str]:
|
| 75 |
-
"""Vibe -> (music_prompt, cassette title), with a plain fallback if the
|
| 76 |
-
local LLM is unreachable or returns junk."""
|
| 77 |
try:
|
| 78 |
r = httpx.post(
|
| 79 |
f"{OLLAMA_URL}/api/chat",
|
|
@@ -97,21 +100,16 @@ def enrich_prompt(prompt: str) -> tuple[str, str]:
|
|
| 97 |
# belt and suspenders: the genre must lead even if the LLM drifts
|
| 98 |
if "lofi" not in music_prompt.lower():
|
| 99 |
music_prompt = f"lofi chill, {music_prompt}"
|
| 100 |
-
#
|
| 101 |
-
|
| 102 |
-
"crackle", "hiss", "rain", "waves", "noise", "birdsong",
|
| 103 |
-
"crickets", "murmur", "wind", "fireplace", "static",
|
| 104 |
-
)
|
| 105 |
-
if not any(w in music_prompt.lower() for w in noise_words):
|
| 106 |
-
music_prompt += ", soft vinyl crackle in the background"
|
| 107 |
-
return music_prompt, title
|
| 108 |
except Exception as e: # noqa: BLE001 — any failure means "use fallback"
|
| 109 |
print(f"[lofinity] ollama enrichment failed ({e!r}), using fallback")
|
| 110 |
fallback_title = f"{prompt[:28].title()} Tape" if prompt.strip() else "Untitled Tape"
|
| 111 |
return (
|
| 112 |
f"lofi chill, {prompt}, mellow and warm, soft drums, "
|
| 113 |
-
"
|
| 114 |
fallback_title,
|
|
|
|
| 115 |
)
|
| 116 |
|
| 117 |
|
|
@@ -161,7 +159,8 @@ def write_wav(samples, rate: int) -> Path:
|
|
| 161 |
return out
|
| 162 |
|
| 163 |
|
| 164 |
-
def musicgen_engine(music_prompt: str) ->
|
|
|
|
| 165 |
import torch
|
| 166 |
|
| 167 |
processor, model, device = load_musicgen()
|
|
@@ -186,27 +185,18 @@ def musicgen_engine(music_prompt: str) -> Path:
|
|
| 186 |
samples = run("cpu")
|
| 187 |
else:
|
| 188 |
raise
|
| 189 |
-
|
| 190 |
-
return write_wav(samples, rate)
|
| 191 |
|
| 192 |
|
| 193 |
-
def stub_engine(_music_prompt: str) ->
|
| 194 |
"""A short audible tone — handy when developing without the heavy model."""
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
frames += struct.pack("<h", sample)
|
| 203 |
-
out = SONGS_DIR / f"{uuid.uuid4().hex}.wav"
|
| 204 |
-
with wave.open(str(out), "wb") as w:
|
| 205 |
-
w.setnchannels(1)
|
| 206 |
-
w.setsampwidth(2)
|
| 207 |
-
w.setframerate(sample_rate)
|
| 208 |
-
w.writeframes(bytes(frames))
|
| 209 |
-
return out
|
| 210 |
|
| 211 |
|
| 212 |
# --- API -----------------------------------------------------------------------
|
|
@@ -214,13 +204,22 @@ def stub_engine(_music_prompt: str) -> Path:
|
|
| 214 |
|
| 215 |
@app.api(name="generate_song", concurrency_limit=1)
|
| 216 |
def generate_song(prompt: str) -> dict:
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
engine = stub_engine if ENGINE == "stub" else musicgen_engine
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
# sidecar metadata so the tape shows up in the collection later
|
| 222 |
out.with_suffix(".json").write_text(
|
| 223 |
-
json.dumps(
|
|
|
|
|
|
|
| 224 |
)
|
| 225 |
return {
|
| 226 |
"title": title,
|
|
|
|
| 4 |
generation API.
|
| 5 |
|
| 6 |
Pipeline: user vibe -> Ollama (small LLM) enriches it into a MusicGen
|
| 7 |
+
prompt + cassette title + ambience pick -> MusicGen renders the music ->
|
| 8 |
+
ambience.py loops a background bed (waves, crackle, rain…) underneath.
|
| 9 |
+
MusicGen ignores texture words in prompts, hence the separate bed.
|
| 10 |
|
| 11 |
Env knobs:
|
| 12 |
LOFINITY_ENGINE musicgen (default) | stub
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
import json
|
|
|
|
| 20 |
import os
|
|
|
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
import uuid
|
|
|
|
| 48 |
ENRICH_SYSTEM = """\
|
| 49 |
You are the creative brain of LoFinity, a magical vending machine that sells
|
| 50 |
lofi cassette tapes. The user gives you a vibe. Reply ONLY with JSON with
|
| 51 |
+
exactly these three keys: {"music_prompt": "...", "title": "...", "ambience": "..."}
|
| 52 |
|
| 53 |
Build music_prompt from this template, in this order:
|
| 54 |
+
"lofi chill, <instrument 1>, <instrument 2>, <instrument 3>, <mood>, slow tempo, 75 bpm, instrumental"
|
| 55 |
|
| 56 |
- instruments: 2-3 picked to EVOKE the user's vibe, never a default set
|
| 57 |
(island -> ukulele, kalimba, steel pan; rainy city -> rhodes piano, soft
|
| 58 |
guitar; winter -> felt piano, soft strings; desert -> slide guitar, hand drums)
|
|
|
|
|
|
|
|
|
|
| 59 |
- mood: one or two calm words; never energetic, no vocals
|
| 60 |
|
| 61 |
+
ambience: the background sound layered under the music. Exactly one of:
|
| 62 |
+
vinyl_crackle, tape_hiss, soft_rain, ocean_waves, fireplace_crackle,
|
| 63 |
+
birdsong, night_crickets, cafe_murmur, wind_in_trees. Match it to the vibe.
|
| 64 |
+
|
| 65 |
title: a cozy cassette tape title inspired by the vibe, max 5 words,
|
| 66 |
Title Case, no quotes or emoji.
|
| 67 |
|
| 68 |
Examples:
|
| 69 |
user: island summer
|
| 70 |
+
{"music_prompt": "lofi chill, ukulele, kalimba, steel pan, breezy and warm, slow tempo, 75 bpm, instrumental", "title": "Coconut Daydream", "ambience": "ocean_waves"}
|
| 71 |
user: studying at midnight
|
| 72 |
+
{"music_prompt": "lofi chill, rhodes piano, muted guitar, soft bass, focused and calm, slow tempo, 75 bpm, instrumental", "title": "Midnight Study Session", "ambience": "vinyl_crackle"}"""
|
| 73 |
+
|
| 74 |
|
| 75 |
+
def enrich_prompt(prompt: str) -> tuple[str, str, str]:
|
| 76 |
+
"""Vibe -> (music_prompt, cassette title, ambience slug), with a plain
|
| 77 |
+
fallback if the local LLM is unreachable or returns junk."""
|
| 78 |
+
import ambience
|
| 79 |
|
|
|
|
|
|
|
|
|
|
| 80 |
try:
|
| 81 |
r = httpx.post(
|
| 82 |
f"{OLLAMA_URL}/api/chat",
|
|
|
|
| 100 |
# belt and suspenders: the genre must lead even if the LLM drifts
|
| 101 |
if "lofi" not in music_prompt.lower():
|
| 102 |
music_prompt = f"lofi chill, {music_prompt}"
|
| 103 |
+
# whatever the LLM picked, snap it to a bed we can actually render
|
| 104 |
+
return music_prompt, title, ambience.normalize_slug(data.get("ambience"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
except Exception as e: # noqa: BLE001 — any failure means "use fallback"
|
| 106 |
print(f"[lofinity] ollama enrichment failed ({e!r}), using fallback")
|
| 107 |
fallback_title = f"{prompt[:28].title()} Tape" if prompt.strip() else "Untitled Tape"
|
| 108 |
return (
|
| 109 |
f"lofi chill, {prompt}, mellow and warm, soft drums, "
|
| 110 |
+
"slow tempo, instrumental",
|
| 111 |
fallback_title,
|
| 112 |
+
ambience.DEFAULT,
|
| 113 |
)
|
| 114 |
|
| 115 |
|
|
|
|
| 159 |
return out
|
| 160 |
|
| 161 |
|
| 162 |
+
def musicgen_engine(music_prompt: str) -> tuple:
|
| 163 |
+
"""Returns (samples, sample_rate); the caller mixes and writes."""
|
| 164 |
import torch
|
| 165 |
|
| 166 |
processor, model, device = load_musicgen()
|
|
|
|
| 185 |
samples = run("cpu")
|
| 186 |
else:
|
| 187 |
raise
|
| 188 |
+
return samples, model.config.audio_encoder.sampling_rate
|
|
|
|
| 189 |
|
| 190 |
|
| 191 |
+
def stub_engine(_music_prompt: str) -> tuple:
|
| 192 |
"""A short audible tone — handy when developing without the heavy model."""
|
| 193 |
+
import numpy as np
|
| 194 |
+
|
| 195 |
+
rate = 22050
|
| 196 |
+
seconds = 8.0
|
| 197 |
+
t = np.arange(int(rate * seconds)) / rate
|
| 198 |
+
fade = np.minimum(1.0, np.minimum(t * 4, (seconds - t) * 4))
|
| 199 |
+
return 0.25 * fade * np.sin(2 * np.pi * 220 * t), rate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
# --- API -----------------------------------------------------------------------
|
|
|
|
| 204 |
|
| 205 |
@app.api(name="generate_song", concurrency_limit=1)
|
| 206 |
def generate_song(prompt: str) -> dict:
|
| 207 |
+
import ambience
|
| 208 |
+
|
| 209 |
+
music_prompt, title, bed = enrich_prompt(prompt)
|
| 210 |
+
print(f"[lofinity] brewing {title!r} :: {music_prompt} [+ {bed}]")
|
| 211 |
engine = stub_engine if ENGINE == "stub" else musicgen_engine
|
| 212 |
+
samples, rate = engine(music_prompt)
|
| 213 |
+
try:
|
| 214 |
+
samples = ambience.mix(samples, rate, bed)
|
| 215 |
+
except Exception as e: # noqa: BLE001 — a dry tape beats a failed vend
|
| 216 |
+
print(f"[lofinity] ambience mix failed ({e!r}), vending without the bed")
|
| 217 |
+
out = write_wav(samples, rate)
|
| 218 |
# sidecar metadata so the tape shows up in the collection later
|
| 219 |
out.with_suffix(".json").write_text(
|
| 220 |
+
json.dumps(
|
| 221 |
+
{"title": title, "prompt": prompt, "ambience": bed, "created": time.time()}
|
| 222 |
+
)
|
| 223 |
)
|
| 224 |
return {
|
| 225 |
"title": title,
|
assets/ambience/CREDITS.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ambience sample credits
|
| 2 |
+
|
| 3 |
+
Auto-fetched from Wikimedia Commons by `scripts/fetch_ambience.py`.
|
| 4 |
+
vinyl_crackle and tape_hiss are synthesized in `ambience.py` and not listed.
|
| 5 |
+
|
| 6 |
+
## birdsong
|
| 7 |
+
- **Birds singing in Fribourg 01.ogg**
|
| 8 |
+
- Author: Martin Thurnherr
|
| 9 |
+
- Licence: CC BY-SA 4.0
|
| 10 |
+
- Source: https://commons.wikimedia.org/wiki/File:Birds_singing_in_Fribourg_01.ogg
|
| 11 |
+
|
| 12 |
+
## cafe_murmur
|
| 13 |
+
- **Shopping mall less crowded.ogg**
|
| 14 |
+
- Author: natalie
|
| 15 |
+
- Licence: Public domain
|
| 16 |
+
- Source: https://commons.wikimedia.org/wiki/File:Shopping_mall_less_crowded.ogg
|
| 17 |
+
|
| 18 |
+
## fireplace_crackle
|
| 19 |
+
- **WWS Fireoftheforge.ogg**
|
| 20 |
+
- Author: Work With Sounds / La Fonderie
|
| 21 |
+
- Licence: CC BY 4.0
|
| 22 |
+
- Source: https://commons.wikimedia.org/wiki/File:WWS_Fireoftheforge.ogg
|
| 23 |
+
|
| 24 |
+
## night_crickets
|
| 25 |
+
- **Black-Prince-Cicada- Psaltoda-plaga.wav**
|
| 26 |
+
- Author: 7575u
|
| 27 |
+
- Licence: CC BY-SA 4.0
|
| 28 |
+
- Source: https://commons.wikimedia.org/wiki/File:Black-Prince-Cicada-_Psaltoda-plaga.wav
|
| 29 |
+
|
| 30 |
+
## ocean_waves
|
| 31 |
+
- **Sea waves.wav**
|
| 32 |
+
- Author: Ganesh Mohan T
|
| 33 |
+
- Licence: CC BY-SA 4.0
|
| 34 |
+
- Source: https://commons.wikimedia.org/wiki/File:Sea_waves.wav
|
| 35 |
+
|
| 36 |
+
## soft_rain
|
| 37 |
+
- **Lluvia en techo de lamina.wav**
|
| 38 |
+
- Author: Doggo19292
|
| 39 |
+
- Licence: CC BY-SA 4.0
|
| 40 |
+
- Source: https://commons.wikimedia.org/wiki/File:Lluvia_en_techo_de_lamina.wav
|
| 41 |
+
|
| 42 |
+
## wind_in_trees
|
| 43 |
+
- **Wind in forest (Gravity Sound).wav**
|
| 44 |
+
- Author: Gravity Sound
|
| 45 |
+
- Licence: CC BY 4.0
|
| 46 |
+
- Source: https://commons.wikimedia.org/wiki/File:Wind_in_forest_(Gravity_Sound).wav
|
assets/ambience/birdsong.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f3dcf170ccf3838158b83ef34e667d111838a46a97d1854aa9aaea5d6981e73c
|
| 3 |
+
size 960044
|
assets/ambience/cafe_murmur.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5d27ec1738dcae636032775479478d797cfb51f9cd79ce43e8f7bd61cdd28777
|
| 3 |
+
size 1920044
|
assets/ambience/credits.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"birdsong": {
|
| 3 |
+
"artist": "Martin Thurnherr",
|
| 4 |
+
"license": "CC BY-SA 4.0",
|
| 5 |
+
"page": "https://commons.wikimedia.org/wiki/File:Birds_singing_in_Fribourg_01.ogg",
|
| 6 |
+
"slug": "birdsong",
|
| 7 |
+
"title": "Birds singing in Fribourg 01.ogg"
|
| 8 |
+
},
|
| 9 |
+
"cafe_murmur": {
|
| 10 |
+
"artist": "natalie",
|
| 11 |
+
"license": "Public domain",
|
| 12 |
+
"page": "https://commons.wikimedia.org/wiki/File:Shopping_mall_less_crowded.ogg",
|
| 13 |
+
"slug": "cafe_murmur",
|
| 14 |
+
"title": "Shopping mall less crowded.ogg"
|
| 15 |
+
},
|
| 16 |
+
"fireplace_crackle": {
|
| 17 |
+
"artist": "Work With Sounds / La Fonderie",
|
| 18 |
+
"license": "CC BY 4.0",
|
| 19 |
+
"page": "https://commons.wikimedia.org/wiki/File:WWS_Fireoftheforge.ogg",
|
| 20 |
+
"slug": "fireplace_crackle",
|
| 21 |
+
"title": "WWS Fireoftheforge.ogg"
|
| 22 |
+
},
|
| 23 |
+
"night_crickets": {
|
| 24 |
+
"artist": "7575u",
|
| 25 |
+
"license": "CC BY-SA 4.0",
|
| 26 |
+
"page": "https://commons.wikimedia.org/wiki/File:Black-Prince-Cicada-_Psaltoda-plaga.wav",
|
| 27 |
+
"slug": "night_crickets",
|
| 28 |
+
"title": "Black-Prince-Cicada- Psaltoda-plaga.wav"
|
| 29 |
+
},
|
| 30 |
+
"ocean_waves": {
|
| 31 |
+
"artist": "Ganesh Mohan T",
|
| 32 |
+
"license": "CC BY-SA 4.0",
|
| 33 |
+
"page": "https://commons.wikimedia.org/wiki/File:Sea_waves.wav",
|
| 34 |
+
"slug": "ocean_waves",
|
| 35 |
+
"title": "Sea waves.wav"
|
| 36 |
+
},
|
| 37 |
+
"soft_rain": {
|
| 38 |
+
"artist": "Doggo19292",
|
| 39 |
+
"license": "CC BY-SA 4.0",
|
| 40 |
+
"page": "https://commons.wikimedia.org/wiki/File:Lluvia_en_techo_de_lamina.wav",
|
| 41 |
+
"slug": "soft_rain",
|
| 42 |
+
"title": "Lluvia en techo de lamina.wav"
|
| 43 |
+
},
|
| 44 |
+
"wind_in_trees": {
|
| 45 |
+
"artist": "Gravity Sound",
|
| 46 |
+
"license": "CC BY 4.0",
|
| 47 |
+
"page": "https://commons.wikimedia.org/wiki/File:Wind_in_forest_(Gravity_Sound).wav",
|
| 48 |
+
"slug": "wind_in_trees",
|
| 49 |
+
"title": "Wind in forest (Gravity Sound).wav"
|
| 50 |
+
}
|
| 51 |
+
}
|
assets/ambience/fireplace_crackle.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6a96f285a6d709bbff6812ec66db19fa023e8fd4e392758ca15e278e63d85dbc
|
| 3 |
+
size 1129090
|
assets/ambience/night_crickets.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c3b5583e377e7c18ea2ab0d2efad75e27a51ba367fb9129a48ffaf151947439f
|
| 3 |
+
size 1920044
|
assets/ambience/ocean_waves.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:53a245f048e3f5f11673e5917fd0694696dfa841ca649d23a601ee99e0188adb
|
| 3 |
+
size 1336364
|
assets/ambience/soft_rain.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ecb4d29fe29b3d9012f3614f4312aeb36a6bcf03c238b60470a6bc564a306344
|
| 3 |
+
size 741420
|
assets/ambience/wind_in_trees.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3de3eb816e3ba8aacc3de300efafd8a2994b62278b44340be4d65b39a94b6ba3
|
| 3 |
+
size 1920044
|
requirements.txt
CHANGED
|
@@ -3,3 +3,9 @@ torch
|
|
| 3 |
transformers
|
| 4 |
# Ollama must be running locally with the model below pulled:
|
| 5 |
# ollama pull llama3.2:3b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
transformers
|
| 4 |
# Ollama must be running locally with the model below pulled:
|
| 5 |
# ollama pull llama3.2:3b
|
| 6 |
+
# The app runtime needs nothing more (ambience.py reads beds via stdlib `wave`).
|
| 7 |
+
# To (re)populate the sampled ambience beds in assets/ambience/, pick one:
|
| 8 |
+
# download from Wikimedia Commons: uv pip install soundfile av
|
| 9 |
+
# python scripts/fetch_ambience.py
|
| 10 |
+
# or generate them: uv pip install diffusers
|
| 11 |
+
# python scripts/make_ambience.py
|
scripts/fetch_ambience.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download the sampled ambience beds from Wikimedia Commons.
|
| 2 |
+
|
| 3 |
+
A no-GPU alternative to make_ambience.py: instead of generating the seven
|
| 4 |
+
sampled beds, this pulls real field recordings from Wikimedia Commons
|
| 5 |
+
(public-domain / CC-licensed), trims each to a steady ~14 s loop, and writes
|
| 6 |
+
mono 16-bit wavs into assets/ambience/ — the format ambience.py expects.
|
| 7 |
+
|
| 8 |
+
It auto-selects: for each slug it searches Commons, drops obvious junk
|
| 9 |
+
(alarms, music, traffic…) by keyword, then downloads candidates in turn and
|
| 10 |
+
measures them, keeping the first that is long enough and not near-silent.
|
| 11 |
+
Provenance + licence for every pick is written to assets/ambience/CREDITS.md
|
| 12 |
+
so attribution can be honored when the Space ships.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
uv pip install soundfile # bundles libsndfile (ogg/mp3/flac/wav)
|
| 16 |
+
python scripts/fetch_ambience.py # fill in what's missing
|
| 17 |
+
python scripts/fetch_ambience.py ocean_waves --force
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import io
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
import unicodedata
|
| 27 |
+
import urllib.parse
|
| 28 |
+
import urllib.request
|
| 29 |
+
import wave
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 35 |
+
OUT_DIR = ROOT / "assets" / "ambience"
|
| 36 |
+
API = "https://commons.wikimedia.org/w/api.php"
|
| 37 |
+
UA = "LoFinity/0.1 (lofi hackathon ambience fetcher; https://huggingface.co/spaces)"
|
| 38 |
+
|
| 39 |
+
TARGET_S = 30.0 # loop length we keep == default song length, so a bed
|
| 40 |
+
# this long tiles to a 30 s song with zero seams
|
| 41 |
+
MIN_SRC_DUR = 8.0 # too short to be useful ambience
|
| 42 |
+
MAX_SRC_DUR = 400.0 # skip anything longer (podcasts, mixes)
|
| 43 |
+
MAX_BYTES = 30_000_000 # don't pull giant wavs
|
| 44 |
+
MAX_RATE = 32000 # cap stored rate (== musicgen rate); keeps files small
|
| 45 |
+
|
| 46 |
+
# How to find each bed: a list of probes whose results are unioned. Commons
|
| 47 |
+
# search ANDs every word in a probe, so each probe stays 1-2 words; more
|
| 48 |
+
# probes = more candidates to fall back through. ("category", name) lists a
|
| 49 |
+
# curated category; ("search", terms) is a File-namespace full-text search.
|
| 50 |
+
SOURCES = {
|
| 51 |
+
"soft_rain": [("category", "Sounds of rain"), ("search", "rain ambience")],
|
| 52 |
+
"ocean_waves": [("search", "ocean waves"), ("search", "sea waves"),
|
| 53 |
+
("search", "surf beach")],
|
| 54 |
+
"fireplace_crackle": [("search", "campfire"), ("search", "fireplace"),
|
| 55 |
+
("search", "fire crackling")],
|
| 56 |
+
"birdsong": [("search", "birdsong"), ("search", "dawn chorus"),
|
| 57 |
+
("search", "birds chirping")],
|
| 58 |
+
"night_crickets": [("search", "crickets"), ("search", "cricket chirping"),
|
| 59 |
+
("search", "cicada")],
|
| 60 |
+
"wind_in_trees": [("search", "wind trees"), ("search", "wind forest"),
|
| 61 |
+
("search", "wind leaves")],
|
| 62 |
+
"cafe_murmur": [("search", "restaurant ambience"), ("search", "cafe ambience"),
|
| 63 |
+
("search", "crowd murmur")],
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# Hand-vetted Commons files tried before falling back to search — auto-selection
|
| 67 |
+
# can't judge "continuous dawn chorus" vs "one repetitive cuckoo", so the good
|
| 68 |
+
# picks found during development are pinned here. Still run through every gate
|
| 69 |
+
# below, so a renamed/deleted file just falls through to search.
|
| 70 |
+
PREFERRED = {
|
| 71 |
+
"soft_rain": "File:Lluvia en techo de lamina.wav",
|
| 72 |
+
"ocean_waves": "File:Sea waves.wav",
|
| 73 |
+
"fireplace_crackle": "File:WWS Fireoftheforge.ogg",
|
| 74 |
+
"birdsong": "File:Birds singing in Fribourg 01.ogg",
|
| 75 |
+
"night_crickets": "File:Black-Prince-Cicada- Psaltoda-plaga.wav",
|
| 76 |
+
"wind_in_trees": "File:Wind in forest (Gravity Sound).wav",
|
| 77 |
+
"cafe_murmur": "File:Shopping mall less crowded.ogg",
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# Title contains any of these (lowercased) -> not ambience, skip it. This is
|
| 81 |
+
# what keeps "fire" from returning fire *alarms*, "sea" from podcasts, and
|
| 82 |
+
# "waves" from sine-wave test tones.
|
| 83 |
+
BLOCKLIST = (
|
| 84 |
+
"alarm", "podcast", "episode", "interview", "speech", "talk", "lecture",
|
| 85 |
+
"music", "song -", "band", "orchestra", "anthem", "hymn", "vocal", "choir",
|
| 86 |
+
"dance", "ritual", "march", "siren", "horn", "traffic", "tram", "engine",
|
| 87 |
+
"motor", "gun", "explosion", "war", "radio", "national", "voice", "demo",
|
| 88 |
+
"sine", "tone", "hz", "sweep", "beep", "dtmf", "calibration", "signal",
|
| 89 |
+
"woodwind", "clarinet", "flute", "accordion", "instrument", "guitar",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Chosen file's title must contain one of these (accent-stripped) — a sound
|
| 93 |
+
# actually related to the slug. Multilingual because Commons is international.
|
| 94 |
+
RELEVANCE = {
|
| 95 |
+
"soft_rain": ("rain", "lluvia", "regen", "pluie", "pioggia", "chuva",
|
| 96 |
+
"downpour", "drizzle", "storm"),
|
| 97 |
+
"ocean_waves": ("ocean", "wave", "sea", "surf", "beach", "mar", "ola",
|
| 98 |
+
"vague", "welle", "tide", "shore", "playa", "costa"),
|
| 99 |
+
"fireplace_crackle": ("fire", "campfire", "fireplace", "crackl", "crepit",
|
| 100 |
+
"feu", "fuego", "hoguera", "fogata", "ember", "hearth"),
|
| 101 |
+
"birdsong": ("bird", "song", "chorus", "dawn", "chirp", "cuckoo", "wren",
|
| 102 |
+
"sparrow", "robin", "blackbird", "finch", "warbler", "thrush",
|
| 103 |
+
"nightingale", "lark", "vogel", "oiseau", "pajaro", "canto"),
|
| 104 |
+
"night_crickets": ("cricket", "cicada", "cicad", "cigarra", "grasshopper",
|
| 105 |
+
"grillo", "grille", "katydid", "locust", "insect", "chirp"),
|
| 106 |
+
"wind_in_trees": ("wind", "breeze", "gust", "rustl", "viento", "vent",
|
| 107 |
+
"howl", "gale", "brisa", "blowing"),
|
| 108 |
+
"cafe_murmur": ("cafe", "restaurant", "crowd", "murmur", "coffee", "bar",
|
| 109 |
+
"pub", "chatter", "ambien", "mall", "station", "people",
|
| 110 |
+
"plaza", "market", "tunnel", "hall", "lobby", "gente"),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _norm(s):
|
| 115 |
+
"""Lowercase + strip accents so 'pájaro'/'Pajaro' both match 'pajaro'."""
|
| 116 |
+
s = unicodedata.normalize("NFKD", str(s))
|
| 117 |
+
return "".join(c for c in s if not unicodedata.combining(c)).lower()
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def commons_api(params, tries=5):
|
| 121 |
+
params = {**params, "format": "json", "formatversion": "2"}
|
| 122 |
+
url = API + "?" + urllib.parse.urlencode(params)
|
| 123 |
+
for i in range(tries):
|
| 124 |
+
try:
|
| 125 |
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
| 126 |
+
with urllib.request.urlopen(req, timeout=30) as r:
|
| 127 |
+
return json.load(r)
|
| 128 |
+
except urllib.error.HTTPError as e:
|
| 129 |
+
if e.code == 429 and i < tries - 1:
|
| 130 |
+
time.sleep(2 * (i + 1))
|
| 131 |
+
continue
|
| 132 |
+
raise
|
| 133 |
+
return {}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def find_titles(slug):
|
| 137 |
+
titles = []
|
| 138 |
+
for kind, value in SOURCES[slug]:
|
| 139 |
+
if kind == "category":
|
| 140 |
+
res = commons_api({"action": "query", "list": "categorymembers",
|
| 141 |
+
"cmtitle": f"Category:{value}", "cmtype": "file",
|
| 142 |
+
"cmlimit": "30"})
|
| 143 |
+
hits = [m["title"] for m in res.get("query", {}).get("categorymembers", [])]
|
| 144 |
+
else:
|
| 145 |
+
res = commons_api({"action": "query", "list": "search", "srnamespace": "6",
|
| 146 |
+
"srsearch": f"filetype:audio {value}", "srlimit": "15"})
|
| 147 |
+
hits = [h["title"] for h in res.get("query", {}).get("search", [])]
|
| 148 |
+
titles += hits
|
| 149 |
+
time.sleep(1)
|
| 150 |
+
# dedupe (keep order); drop junk, then require a slug-relevant word
|
| 151 |
+
seen, kept = set(), []
|
| 152 |
+
for t in titles:
|
| 153 |
+
nt = _norm(t)
|
| 154 |
+
if t in seen or any(b in nt for b in BLOCKLIST):
|
| 155 |
+
continue
|
| 156 |
+
if not any(kw in nt for kw in RELEVANCE[slug]):
|
| 157 |
+
continue
|
| 158 |
+
seen.add(t)
|
| 159 |
+
kept.append(t)
|
| 160 |
+
return kept
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def file_info(titles):
|
| 164 |
+
"""title -> dict(url, dur, license, artist, page) for a batch of titles."""
|
| 165 |
+
out = {}
|
| 166 |
+
for i in range(0, len(titles), 20):
|
| 167 |
+
info = commons_api({"action": "query", "titles": "|".join(titles[i:i + 20]),
|
| 168 |
+
"prop": "imageinfo",
|
| 169 |
+
"iiprop": "url|size|mediatype|extmetadata"})
|
| 170 |
+
for page in info.get("query", {}).get("pages", []):
|
| 171 |
+
ii = (page.get("imageinfo") or [{}])[0]
|
| 172 |
+
ext = ii.get("extmetadata", {})
|
| 173 |
+
def field(k):
|
| 174 |
+
return ext.get(k, {}).get("value", "")
|
| 175 |
+
out[page.get("title", "?")] = {
|
| 176 |
+
"url": ii.get("url", ""),
|
| 177 |
+
"dur": float(ii.get("duration") or 0.0),
|
| 178 |
+
"mediatype": ii.get("mediatype", ""),
|
| 179 |
+
"license": field("LicenseShortName") or "?",
|
| 180 |
+
"artist": _strip_html(field("Artist")) or "Unknown",
|
| 181 |
+
"page": ii.get("descriptionurl", ""),
|
| 182 |
+
}
|
| 183 |
+
time.sleep(1)
|
| 184 |
+
return out
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _strip_html(s):
|
| 188 |
+
return re.sub(r"<[^>]+>", "", s).strip()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def spectral_flatness(mono, rate):
|
| 192 |
+
"""Ratio of geometric to arithmetic mean of the power spectrum. ~0 for a
|
| 193 |
+
pure tone, higher for broadband texture — catches test tones that slip
|
| 194 |
+
past the title filter (a 'Sine Wave' file is named like a sea 'wave').
|
| 195 |
+
|
| 196 |
+
The signal is detrended and high-passed (first difference) first: crowd
|
| 197 |
+
and surf ambience carries heavy low-frequency rumble that otherwise
|
| 198 |
+
dominates the spectrum and reads as falsely 'tonal' (calibration showed
|
| 199 |
+
real cafe recordings at 2e-5 raw vs 1e-12 for a true sine — too close;
|
| 200 |
+
after the high-pass they separate to 2e-3 vs 1e-12)."""
|
| 201 |
+
seg = mono[: rate * 4].astype(np.float64)
|
| 202 |
+
if len(seg) < 256:
|
| 203 |
+
return 1.0
|
| 204 |
+
seg = np.diff(seg - seg.mean())
|
| 205 |
+
power = np.abs(np.fft.rfft(seg * np.hanning(len(seg)))) ** 2 + 1e-12
|
| 206 |
+
return float(np.exp(np.mean(np.log(power))) / np.mean(power))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def download(url):
|
| 210 |
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
| 211 |
+
with urllib.request.urlopen(req, timeout=60) as r:
|
| 212 |
+
length = int(r.headers.get("Content-Length") or 0)
|
| 213 |
+
if length and length > MAX_BYTES:
|
| 214 |
+
raise ValueError(f"too big ({length / 1e6:.0f} MB)")
|
| 215 |
+
return r.read(MAX_BYTES + 1)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def decode_mono(blob):
|
| 219 |
+
import soundfile as sf
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
data, rate = sf.read(io.BytesIO(blob), dtype="float64", always_2d=True)
|
| 223 |
+
return data.mean(axis=1), rate
|
| 224 |
+
except sf.LibsndfileError:
|
| 225 |
+
return _decode_av(blob) # Opus/other codecs libsndfile can't open
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _decode_av(blob):
|
| 229 |
+
"""Fallback decoder via PyAV (bundles ffmpeg) — most Commons crowd/cafe
|
| 230 |
+
recordings are Ogg/Opus, which libsndfile doesn't support."""
|
| 231 |
+
import av
|
| 232 |
+
|
| 233 |
+
with av.open(io.BytesIO(blob)) as container:
|
| 234 |
+
stream = container.streams.audio[0]
|
| 235 |
+
rate = stream.codec_context.sample_rate
|
| 236 |
+
chunks = []
|
| 237 |
+
resampler = av.AudioResampler(format="flt", layout="mono", rate=rate)
|
| 238 |
+
for frame in container.decode(stream):
|
| 239 |
+
for out in resampler.resample(frame):
|
| 240 |
+
chunks.append(out.to_ndarray().reshape(-1))
|
| 241 |
+
if not chunks:
|
| 242 |
+
raise ValueError("no audio frames decoded")
|
| 243 |
+
return np.concatenate(chunks).astype(np.float64), rate
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def steady_window(mono, rate):
|
| 247 |
+
"""Pick the best TARGET_S loop window. Short clips are returned whole (the
|
| 248 |
+
mixer tiles them). The window is scored on three things, because the mixer
|
| 249 |
+
crossfades the loop's tail back into its head:
|
| 250 |
+
- steady interior (low RMS variation) so it doesn't swell or drop
|
| 251 |
+
- head and tail at matched energy, so the crossfade blends like-for-like
|
| 252 |
+
- neither boundary in a lull, so the loop point doesn't briefly drop out
|
| 253 |
+
The last two matter for sparse textures (birdsong, fireplace): a window
|
| 254 |
+
that merely minimizes variance can still start/end in a gap, dipping ~10 dB
|
| 255 |
+
every loop."""
|
| 256 |
+
n = int(TARGET_S * rate)
|
| 257 |
+
if len(mono) <= n:
|
| 258 |
+
return mono
|
| 259 |
+
hop = max(int(rate * 0.1), 1) # 100 ms frames: fine enough to see the seam
|
| 260 |
+
frame_rms = np.array([
|
| 261 |
+
np.sqrt(np.mean(mono[i:i + hop] ** 2)) for i in range(0, len(mono) - hop, hop)
|
| 262 |
+
])
|
| 263 |
+
median = float(np.median(frame_rms)) or 1.0
|
| 264 |
+
win_frames = max(n // hop, 1)
|
| 265 |
+
edge = max(int(rate * 0.5) // hop, 1) # frames spanning one crossfade (~0.5 s)
|
| 266 |
+
best, best_score = None, 1e9
|
| 267 |
+
for start in range(0, len(frame_rms) - win_frames, max(win_frames // 8, 1)):
|
| 268 |
+
seg = frame_rms[start:start + win_frames]
|
| 269 |
+
mean = float(seg.mean())
|
| 270 |
+
if mean < 0.5 * median: # window mostly in a lull
|
| 271 |
+
continue
|
| 272 |
+
head, tail = float(seg[:edge].mean()), float(seg[-edge:].mean())
|
| 273 |
+
cv = float(seg.std()) / (mean or 1.0)
|
| 274 |
+
mismatch = abs(head - tail) / median
|
| 275 |
+
lull = max(0.0, 1.0 - min(head, tail) / median) # 0 once boundary >= median
|
| 276 |
+
score = cv + 2.0 * mismatch + 2.0 * lull
|
| 277 |
+
if score < best_score:
|
| 278 |
+
best_score, best = score, start * hop
|
| 279 |
+
start = best if best is not None else (len(mono) - n) // 2
|
| 280 |
+
return mono[start:start + n]
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def resample(mono, src, dst):
|
| 284 |
+
if src <= dst:
|
| 285 |
+
return mono, src
|
| 286 |
+
m = int(len(mono) * dst / src)
|
| 287 |
+
return np.interp(np.arange(m) * (src / dst), np.arange(len(mono)), mono), dst
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def write_wav(mono, rate, path):
|
| 291 |
+
peak = float(np.abs(mono).max() or 1.0)
|
| 292 |
+
pcm = (mono * (0.9 / peak) * 32767).astype("<i2")
|
| 293 |
+
with wave.open(str(path), "wb") as w:
|
| 294 |
+
w.setnchannels(1)
|
| 295 |
+
w.setsampwidth(2)
|
| 296 |
+
w.setframerate(rate)
|
| 297 |
+
w.writeframes(pcm.tobytes())
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def fetch_one(slug):
|
| 301 |
+
"""Return a credit dict on success, or None if nothing usable was found."""
|
| 302 |
+
found = find_titles(slug)
|
| 303 |
+
pref = PREFERRED.get(slug)
|
| 304 |
+
# the pinned pick is tried first; search results (relevance order) back it up
|
| 305 |
+
lookup, seen = [], set()
|
| 306 |
+
for t in ([pref] if pref else []) + found:
|
| 307 |
+
if t not in seen:
|
| 308 |
+
seen.add(t)
|
| 309 |
+
lookup.append(t)
|
| 310 |
+
if not lookup:
|
| 311 |
+
print(f" no candidates found for {slug}")
|
| 312 |
+
return None
|
| 313 |
+
info = file_info(lookup)
|
| 314 |
+
for title in [t for t in lookup if info.get(t, {}).get("url")][:8]:
|
| 315 |
+
meta = info[title]
|
| 316 |
+
if meta["dur"] and meta["dur"] > MAX_SRC_DUR:
|
| 317 |
+
continue
|
| 318 |
+
try:
|
| 319 |
+
blob = download(meta["url"])
|
| 320 |
+
mono, rate = decode_mono(blob)
|
| 321 |
+
except Exception as e: # noqa: BLE001 — try the next candidate
|
| 322 |
+
print(f" skip {title[5:][:40]!r}: {e}")
|
| 323 |
+
continue
|
| 324 |
+
dur = len(mono) / rate
|
| 325 |
+
rms = float(np.sqrt(np.mean(mono ** 2)))
|
| 326 |
+
flat = spectral_flatness(mono, rate)
|
| 327 |
+
if dur < MIN_SRC_DUR or dur > MAX_SRC_DUR or rms < 5e-3:
|
| 328 |
+
print(f" skip {title[5:][:40]!r}: dur={dur:.0f}s rms={rms:.3f}")
|
| 329 |
+
continue
|
| 330 |
+
if flat < 1e-3: # essentially a pure tone, not ambience (sines ~1e-12)
|
| 331 |
+
print(f" skip {title[5:][:40]!r}: too tonal (flatness {flat:.0e})")
|
| 332 |
+
continue
|
| 333 |
+
seg = steady_window(mono, rate)
|
| 334 |
+
seg, out_rate = resample(seg, rate, MAX_RATE)
|
| 335 |
+
write_wav(seg, out_rate, OUT_DIR / f"{slug}.wav")
|
| 336 |
+
seams = "no seam" if len(seg) / out_rate >= 30 else "1 seam @30s"
|
| 337 |
+
print(f" {slug} <- {title[5:][:42]!r} "
|
| 338 |
+
f"({dur:.0f}s src -> {len(seg)/out_rate:.0f}s, {seams}, {meta['license']})")
|
| 339 |
+
return {"slug": slug, "title": title[5:], "license": meta["license"],
|
| 340 |
+
"artist": meta["artist"], "page": meta["page"]}
|
| 341 |
+
print(f" no usable file for {slug} (all candidates failed checks)")
|
| 342 |
+
return None
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def save_credits(new_credits):
|
| 346 |
+
"""Merge this run's picks into credits.json (the source of truth, keyed by
|
| 347 |
+
slug) and re-render CREDITS.md. Merging means fetching one slug doesn't
|
| 348 |
+
drop the others' attribution."""
|
| 349 |
+
store = OUT_DIR / "credits.json"
|
| 350 |
+
merged = {}
|
| 351 |
+
if store.exists():
|
| 352 |
+
try:
|
| 353 |
+
merged = json.loads(store.read_text())
|
| 354 |
+
except ValueError:
|
| 355 |
+
pass
|
| 356 |
+
for c in new_credits:
|
| 357 |
+
merged[c["slug"]] = c
|
| 358 |
+
store.write_text(json.dumps(merged, indent=2, sort_keys=True))
|
| 359 |
+
|
| 360 |
+
lines = ["# Ambience sample credits", "",
|
| 361 |
+
"Auto-fetched from Wikimedia Commons by `scripts/fetch_ambience.py`.",
|
| 362 |
+
"vinyl_crackle and tape_hiss are synthesized in `ambience.py` and not listed.", ""]
|
| 363 |
+
for slug in sorted(merged):
|
| 364 |
+
c = merged[slug]
|
| 365 |
+
lines += [
|
| 366 |
+
f"## {slug}",
|
| 367 |
+
f"- **{c['title']}**",
|
| 368 |
+
f"- Author: {c['artist']}",
|
| 369 |
+
f"- Licence: {c['license']}",
|
| 370 |
+
f"- Source: {c['page']}",
|
| 371 |
+
"",
|
| 372 |
+
]
|
| 373 |
+
(OUT_DIR / "CREDITS.md").write_text("\n".join(lines))
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def main():
|
| 377 |
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
| 378 |
+
parser.add_argument("slugs", nargs="*", choices=[*SOURCES, []], metavar="slug",
|
| 379 |
+
help=f"beds to fetch (default: missing ones). One of: {', '.join(SOURCES)}")
|
| 380 |
+
parser.add_argument("--force", action="store_true", help="re-fetch even if the wav exists")
|
| 381 |
+
args = parser.parse_args()
|
| 382 |
+
|
| 383 |
+
todo = args.slugs or [s for s in SOURCES if args.force or not (OUT_DIR / f"{s}.wav").exists()]
|
| 384 |
+
if not todo:
|
| 385 |
+
print("all sampled beds already present — use --force to refetch")
|
| 386 |
+
return 0
|
| 387 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 388 |
+
|
| 389 |
+
credits = []
|
| 390 |
+
for slug in todo:
|
| 391 |
+
print(f"\n[{slug}]")
|
| 392 |
+
c = fetch_one(slug)
|
| 393 |
+
if c:
|
| 394 |
+
credits.append(c)
|
| 395 |
+
time.sleep(1)
|
| 396 |
+
|
| 397 |
+
if credits:
|
| 398 |
+
save_credits(credits) # merges into credits.json, won't drop other slugs
|
| 399 |
+
got = len(credits)
|
| 400 |
+
print(f"\nfetched {got}/{len(todo)} beds -> {OUT_DIR.relative_to(ROOT)}")
|
| 401 |
+
return 0 if got else 1
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
if __name__ == "__main__":
|
| 405 |
+
sys.exit(main())
|
scripts/make_ambience.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-off renderer for the sampled ambience beds.
|
| 2 |
+
|
| 3 |
+
vinyl_crackle and tape_hiss are synthesized live in ambience.py; the seven
|
| 4 |
+
beds below only need to exist once on disk. This script fills
|
| 5 |
+
assets/ambience/ with text-to-audio renders from AudioLDM2.
|
| 6 |
+
|
| 7 |
+
(AudioGen would also work, but it lives in the unmaintained audiocraft
|
| 8 |
+
package which doesn't install on Python 3.13; AudioLDM2 ships in diffusers
|
| 9 |
+
and runs next to the project's torch/transformers as-is.)
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
pip install diffusers
|
| 13 |
+
python scripts/make_ambience.py # render whatever is missing
|
| 14 |
+
python scripts/make_ambience.py ocean_waves --force # redo one
|
| 15 |
+
|
| 16 |
+
Each clip is ~12 s; the runtime mixer tiles it with crossfades, so it does
|
| 17 |
+
not need to loop perfectly. Re-run any slug whose render sounds off —
|
| 18 |
+
text-to-audio is a slot machine, two pulls usually land one keeper.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
import wave
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
OUT_DIR = ROOT / "assets" / "ambience"
|
| 29 |
+
|
| 30 |
+
PROMPTS = {
|
| 31 |
+
"soft_rain": "gentle steady rain falling on leaves, calm rain ambience, no thunder",
|
| 32 |
+
"ocean_waves": "calm ocean waves gently rolling onto a sandy beach, soft surf",
|
| 33 |
+
"fireplace_crackle": "cozy fireplace, fire crackling and popping softly",
|
| 34 |
+
"birdsong": "soft morning birdsong, small birds chirping in a quiet garden",
|
| 35 |
+
"night_crickets": "crickets chirping steadily on a calm summer night",
|
| 36 |
+
"cafe_murmur": "quiet coffee shop ambience, soft murmur of distant conversation, occasional clink of cups",
|
| 37 |
+
"wind_in_trees": "soft wind rustling through tree leaves, gentle breeze",
|
| 38 |
+
}
|
| 39 |
+
NEGATIVE = "music, melody, singing, speech, voice, loud, harsh, low quality, distortion"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def write_wav(samples, rate: int, path: Path) -> None:
|
| 43 |
+
import numpy as np
|
| 44 |
+
|
| 45 |
+
peak = float(np.abs(samples).max() or 1.0)
|
| 46 |
+
pcm = (samples * (0.9 / peak) * 32767).astype("<i2")
|
| 47 |
+
with wave.open(str(path), "wb") as w:
|
| 48 |
+
w.setnchannels(1)
|
| 49 |
+
w.setsampwidth(2)
|
| 50 |
+
w.setframerate(rate)
|
| 51 |
+
w.writeframes(pcm.tobytes())
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main() -> int:
|
| 55 |
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
| 56 |
+
parser.add_argument("slugs", nargs="*", choices=[*PROMPTS, []], metavar="slug",
|
| 57 |
+
help=f"which beds to render (default: all missing). One of: {', '.join(PROMPTS)}")
|
| 58 |
+
parser.add_argument("--force", action="store_true", help="re-render even if the wav exists")
|
| 59 |
+
parser.add_argument("--duration", type=float, default=12.0, help="clip length in seconds")
|
| 60 |
+
parser.add_argument("--steps", type=int, default=200, help="diffusion steps (more = cleaner, slower)")
|
| 61 |
+
parser.add_argument("--candidates", type=int, default=2,
|
| 62 |
+
help="waveforms per prompt; the pipeline keeps the best text match")
|
| 63 |
+
args = parser.parse_args()
|
| 64 |
+
|
| 65 |
+
todo = args.slugs or [s for s in PROMPTS if args.force or not (OUT_DIR / f"{s}.wav").exists()]
|
| 66 |
+
if not todo:
|
| 67 |
+
print("all ambience beds already rendered — use --force to redo")
|
| 68 |
+
return 0
|
| 69 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
|
| 71 |
+
import torch
|
| 72 |
+
from diffusers import AudioLDM2Pipeline
|
| 73 |
+
|
| 74 |
+
device = os.getenv("LOFINITY_DEVICE") or ("mps" if torch.backends.mps.is_available() else "cpu")
|
| 75 |
+
print(f"first run downloads ~3 GB (cvssp/audioldm2); rendering on {device}")
|
| 76 |
+
pipe = AudioLDM2Pipeline.from_pretrained("cvssp/audioldm2")
|
| 77 |
+
pipe.to(device)
|
| 78 |
+
|
| 79 |
+
for slug in todo:
|
| 80 |
+
path = OUT_DIR / f"{slug}.wav"
|
| 81 |
+
if path.exists() and not args.force and not args.slugs:
|
| 82 |
+
continue
|
| 83 |
+
print(f"rendering {slug}: {PROMPTS[slug]!r}")
|
| 84 |
+
|
| 85 |
+
def run():
|
| 86 |
+
return pipe(
|
| 87 |
+
prompt=PROMPTS[slug],
|
| 88 |
+
negative_prompt=NEGATIVE,
|
| 89 |
+
num_inference_steps=args.steps,
|
| 90 |
+
audio_length_in_s=args.duration,
|
| 91 |
+
num_waveforms_per_prompt=args.candidates,
|
| 92 |
+
).audios[0] # audios come back ranked by text alignment
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
audio = run()
|
| 96 |
+
except Exception as e: # noqa: BLE001 — mps kernels are still patchy
|
| 97 |
+
if device == "cpu":
|
| 98 |
+
raise
|
| 99 |
+
print(f" {device} failed ({e!r}), retrying on cpu")
|
| 100 |
+
pipe.to("cpu")
|
| 101 |
+
device = "cpu"
|
| 102 |
+
audio = run()
|
| 103 |
+
write_wav(audio, 16000, path)
|
| 104 |
+
print(f" -> {path.relative_to(ROOT)}")
|
| 105 |
+
return 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
sys.exit(main())
|