eloigil6 Cursor commited on
Commit
722a5d8
·
1 Parent(s): 7b00de0

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 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 audio.
 
 
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 two keys: {"music_prompt": "...", "title": "..."}
52
 
53
  Build music_prompt from this template, in this order:
54
- "lofi chill, <instrument 1>, <instrument 2>, <instrument 3>, <background noise>, <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
- - 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, ocean waves in the background, breezy and warm, slow tempo, 75 bpm, instrumental", "title": "Coconut Daydream"}
70
  user: studying at midnight
71
- {"music_prompt": "lofi chill, rhodes piano, muted guitar, soft bass, vinyl crackle in the background, focused and calm, slow tempo, 75 bpm, instrumental", "title": "Midnight Study Session"}"""
 
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
- # …and there must always be a background noise layer
101
- noise_words = (
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
- "vinyl crackle, slow tempo, instrumental",
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) -> Path:
 
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
- rate = model.config.audio_encoder.sampling_rate
190
- return write_wav(samples, rate)
191
 
192
 
193
- def stub_engine(_music_prompt: str) -> Path:
194
  """A short audible tone — handy when developing without the heavy model."""
195
- sample_rate = 22050
196
- seconds = 2.0
197
- frames = bytearray()
198
- for i in range(int(sample_rate * seconds)):
199
- t = i / sample_rate
200
- fade = min(1.0, t * 4, (seconds - t) * 4)
201
- sample = int(0.25 * fade * 32767 * math.sin(2 * math.pi * 220 * t))
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
- music_prompt, title = enrich_prompt(prompt)
218
- print(f"[lofinity] brewing {title!r} :: {music_prompt}")
 
 
219
  engine = stub_engine if ENGINE == "stub" else musicgen_engine
220
- out = engine(music_prompt)
 
 
 
 
 
221
  # sidecar metadata so the tape shows up in the collection later
222
  out.with_suffix(".json").write_text(
223
- json.dumps({"title": title, "prompt": prompt, "created": time.time()})
 
 
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())