DeepSeekOracle commited on
Commit
8443695
Β·
verified Β·
1 Parent(s): 8a2954e

Upload resonance_engine.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. resonance_engine.py +527 -0
resonance_engine.py ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ LYGO Resonance Engine v0.5.2
4
+ Image β†’ Living Stereo Soundscape
5
+ Full standard synthesis + LDQ percussion mode.
6
+ """
7
+
8
+ import cv2
9
+ import numpy as np
10
+ import soundfile as sf
11
+ import math
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any
16
+ import mido
17
+ from mido import MidiFile, MidiTrack, Message
18
+
19
+ # LDQ imports are LAZY: only loaded inside the LDQ branch.
20
+ # This keeps the "Standard Beat Tools / Factory Default" path completely independent
21
+ # of any LDQ code so the default engine always works even if LDQ modules have issues.
22
+
23
+ __version__ = "0.5.2"
24
+
25
+ # Artistic Presets
26
+ PRESETS = {
27
+ "raw": {},
28
+ "ambient": {
29
+ "noise_vol": 0.03,
30
+ "drone_vol": 0.04,
31
+ "note_vol": 0.08,
32
+ "glitch_vol": 0.005,
33
+ "drone_attack": 6.0,
34
+ "drone_decay": 6.0,
35
+ "note_attack": 0.08,
36
+ "note_decay": 0.45,
37
+ "max_glitches": 5,
38
+ "noise_lowpass_hz": 350,
39
+ "root_freq_range": (40, 80),
40
+ "theta_lock_range": (5, 9),
41
+ },
42
+ # "musical" / factory-strong defaults (for Standard Beat Tools to produce nice non-static output
43
+ # matching historical working versions from LDQ protocol history + clean skill base).
44
+ # These ensure the 4-layer (noise + drones + melody + glitch) has energy + filtered texture.
45
+ "musical": {
46
+ "noise_vol": 0.09,
47
+ "drone_vol": 0.08,
48
+ "note_vol": 0.13,
49
+ "glitch_vol": 0.02,
50
+ "noise_lowpass_hz": 650,
51
+ "drone_attack": 4.0,
52
+ "drone_decay": 5.0,
53
+ "note_decay": 0.35,
54
+ },
55
+ "glitch": {
56
+ "noise_vol": 0.08,
57
+ "drone_vol": 0.04,
58
+ "note_vol": 0.06,
59
+ "glitch_vol": 0.03,
60
+ "max_notes": 6,
61
+ "max_glitches": 20,
62
+ "note_decay": 0.20,
63
+ "glitch_decay": 0.02,
64
+ "noise_lowpass_hz": 1800,
65
+ "root_freq_range": (30, 60),
66
+ "theta_lock_range": (4, 8),
67
+ },
68
+ "ethereal": {
69
+ "noise_vol": 0.02,
70
+ "drone_vol": 0.04,
71
+ "note_vol": 0.10,
72
+ "glitch_vol": 0.008,
73
+ "root_freq_range": (40, 95),
74
+ "theta_lock_range": (6, 13),
75
+ "note_attack": 0.10,
76
+ "note_decay": 0.55,
77
+ "noise_lowpass_hz": 300,
78
+ "max_glitches": 4,
79
+ },
80
+ "cinematic": {
81
+ "noise_vol": 0.04,
82
+ "drone_vol": 0.06,
83
+ "note_vol": 0.10,
84
+ "glitch_vol": 0.015,
85
+ "drone_attack": 4.5,
86
+ "drone_decay": 4.5,
87
+ "max_drones": 4,
88
+ "noise_lowpass_hz": 700,
89
+ "root_freq_range": (30, 70),
90
+ "theta_lock_range": (4.5, 10),
91
+ },
92
+ }
93
+
94
+ class ResonanceEngine:
95
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
96
+ self.config = {
97
+ "sr": 44100,
98
+ "duration": 15.0,
99
+ "global_fade": 0.7,
100
+ "soft_clip": True,
101
+ "soft_clip_amount": 1.4,
102
+ "max_drones": 4,
103
+ "max_notes": 8,
104
+ "max_glitches": 15,
105
+ "noise_vol": 0.05,
106
+ "drone_vol": 0.04,
107
+ "note_vol": 0.10,
108
+ "glitch_vol": 0.015,
109
+ "root_freq_range": (30, 70),
110
+ "theta_lock_range": (4.5, 11),
111
+ "drone_attack": 3.5,
112
+ "drone_decay": 3.5,
113
+ "note_attack": 0.04,
114
+ "note_decay": 0.30,
115
+ "glitch_attack": 0.005,
116
+ "glitch_decay": 0.015,
117
+ "noise_lowpass_hz": 700,
118
+ "random_seed": None,
119
+ "verbose": True,
120
+ "export_stems": False,
121
+ "export_midi": False,
122
+ "use_ldq": False,
123
+ "genre_manifold": "None",
124
+ "percussion_mode": "standard",
125
+ "perceptual_polish": 0.5,
126
+ "tempo_bpm": 140,
127
+ "tempo_subdivision": 16,
128
+ "swing": 0.0,
129
+ }
130
+ if config:
131
+ self.config.update(config)
132
+ self.image_path = None
133
+ self.tempo_grid = None
134
+
135
+ def _log(self, msg: str):
136
+ if self.config.get("verbose", True):
137
+ print(msg)
138
+
139
+ def analyze_image(self, image_path: str) -> Dict[str, Any]:
140
+ img = cv2.imread(str(image_path))
141
+ if img is None:
142
+ raise FileNotFoundError(f"Could not load image: {image_path}")
143
+ if len(img.shape) == 2:
144
+ img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
145
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
146
+ h, w = gray.shape
147
+ avg_blue, avg_green, avg_red, _ = cv2.mean(img)
148
+ edges = cv2.Canny(gray, 50, 150)
149
+ edge_density = np.sum(edges > 0) / (h * w)
150
+ contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
151
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 50, minLineLength=28, maxLineGap=12)
152
+ fast = cv2.FastFeatureDetector_create(threshold=38)
153
+ keypoints = fast.detect(gray, None)
154
+ return {
155
+ "width": w, "height": h,
156
+ "avg_red": avg_red, "avg_green": avg_green, "avg_blue": avg_blue,
157
+ "edge_density": edge_density,
158
+ "contours": contours,
159
+ "lines": lines if lines is not None else [],
160
+ "keypoints": keypoints,
161
+ }
162
+
163
+ def _generate_tone(self, freq: float, duration: float, wave_type: str = "sine") -> np.ndarray:
164
+ sr = self.config["sr"]
165
+ n = int(sr * duration)
166
+ if n <= 0:
167
+ return np.zeros(1, dtype=np.float32)
168
+ t = np.linspace(0, duration, n, dtype=np.float32)
169
+ if wave_type == "sine":
170
+ return np.sin(freq * t * 2 * np.pi).astype(np.float32)
171
+ elif wave_type == "sawtooth":
172
+ return (2 * (t * freq - np.floor(0.5 + t * freq)) * 0.6).astype(np.float32)
173
+ elif wave_type == "noise":
174
+ return np.random.uniform(-0.3, 0.3, n).astype(np.float32)
175
+ return np.zeros(n, dtype=np.float32)
176
+
177
+ def _apply_envelope(self, audio: np.ndarray, attack: float, decay: float) -> np.ndarray:
178
+ sr = self.config["sr"]
179
+ n = len(audio)
180
+ if n <= 0:
181
+ return audio
182
+ a = max(1, int(attack * sr))
183
+ d = max(1, int(decay * sr))
184
+ env = np.ones(n, dtype=np.float32)
185
+ if n > a + d:
186
+ env[:a] = np.linspace(0, 1, a, dtype=np.float32)
187
+ env[-d:] = np.linspace(1, 0, d, dtype=np.float32)
188
+ return (audio * env).astype(np.float32)
189
+
190
+ def _stereo_pan(self, mono: np.ndarray, pan: float) -> np.ndarray:
191
+ if mono.ndim == 1:
192
+ mono = mono[:, np.newaxis]
193
+ pan = max(-1.0, min(1.0, pan))
194
+ left = math.cos((pan + 1) * math.pi / 4)
195
+ right = math.sin((pan + 1) * math.pi / 4)
196
+ return np.column_stack((mono * left, mono * right)).astype(np.float32)
197
+
198
+ def _fft_lowpass(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray:
199
+ if cutoff_hz <= 0 or len(audio) < 32:
200
+ return audio
201
+ sr = self.config["sr"]
202
+ n = len(audio)
203
+ fft = np.fft.rfft(audio)
204
+ freqs = np.fft.rfftfreq(n, 1.0 / sr)
205
+ fft[freqs > cutoff_hz] = 0
206
+ return np.fft.irfft(fft, n=n).real.astype(np.float32)
207
+
208
+ def _soft_limit(self, audio: np.ndarray) -> np.ndarray:
209
+ if self.config["soft_clip"]:
210
+ amt = self.config["soft_clip_amount"]
211
+ return (np.tanh(audio * amt) / np.tanh(amt)).astype(np.float32)
212
+ return audio
213
+
214
+ def _freq_to_midi(self, freq: float) -> int:
215
+ if freq <= 0:
216
+ return 0
217
+ return max(0, min(127, int(12 * math.log2(freq / 440) + 69)))
218
+
219
+ def synthesize(self, features: Dict[str, Any], output_path: str):
220
+ cfg = self.config
221
+ if cfg["random_seed"] is not None:
222
+ np.random.seed(cfg["random_seed"])
223
+
224
+ sr = cfg["sr"]
225
+ duration = cfg["duration"]
226
+ n_total = int(sr * duration)
227
+ if n_total <= 0:
228
+ sf.write(output_path, np.zeros((1, 2)), sr)
229
+ return
230
+
231
+ audio = np.zeros((n_total, 2), dtype=np.float32)
232
+ root = np.interp(features["avg_red"], [0, 255], cfg["root_freq_range"])
233
+ theta = np.interp(features["avg_green"], [0, 255], cfg["theta_lock_range"])
234
+ w, h = features["width"], features["height"]
235
+
236
+ # ---- LDQ Protocol Percussion Mode (LAZY imports + guarded) ----
237
+ if cfg.get("use_ldq") and cfg.get("percussion_mode") == "ldq":
238
+ # Lazy load only when the user explicitly enables advanced LDQ mode.
239
+ # Factory / Standard Beat Tools path never executes any of this.
240
+ import ldq_fingerprint
241
+ import ldq_genre_manifold
242
+ import ldq_percussion
243
+ import ldq_perceptual_layer
244
+ import ldq_tempo_grid
245
+ import ldq_music_production
246
+
247
+ self.tempo_grid = ldq_tempo_grid.TempoGrid(
248
+ bpm=cfg.get("tempo_bpm", 140),
249
+ subdivision=cfg.get("tempo_subdivision", 16),
250
+ swing=cfg.get("swing", 0.0)
251
+ )
252
+
253
+ self._log("πŸ”¬ LDQ Percussion Mode active")
254
+ if self.image_path is not None:
255
+ vgh = ldq_fingerprint.compute_vgh(self.image_path)
256
+ self._log(f"πŸ”‘ VGH: {vgh[:16]}...")
257
+ else:
258
+ vgh = None
259
+
260
+ if cfg.get("genre_manifold") and cfg["genre_manifold"] != "None":
261
+ self._log(f"🎡 Genre: {cfg['genre_manifold']}")
262
+ genre_params = ldq_genre_manifold.project_to_genre(features, cfg["genre_manifold"])
263
+ theta = theta * (1 + genre_params.get("swing_amount", 0))
264
+ if cfg["genre_manifold"] == "Dubstep":
265
+ cfg["tempo_bpm"] = 140
266
+ cfg["swing"] = 0.25
267
+ elif cfg["genre_manifold"] == "Phonk":
268
+ cfg["tempo_bpm"] = 100
269
+ cfg["swing"] = 0.33
270
+ elif cfg["genre_manifold"] == "Industrial":
271
+ cfg["tempo_bpm"] = 120
272
+ cfg["swing"] = 0.15
273
+ self.tempo_grid = ldq_tempo_grid.TempoGrid(
274
+ bpm=cfg["tempo_bpm"],
275
+ subdivision=cfg["tempo_subdivision"],
276
+ swing=cfg["swing"]
277
+ )
278
+
279
+ # generate drums
280
+ kick = ldq_percussion.generate_kick(features, sr, duration)
281
+ snare = ldq_percussion.generate_snare(features, sr, duration)
282
+ hihats = ldq_percussion.generate_hihats(features, sr, cfg["tempo_bpm"], duration)
283
+
284
+ min_len = min(len(kick), len(snare), len(hihats), n_total)
285
+ if min_len == 0:
286
+ min_len = n_total
287
+
288
+ audio[:min_len] += self._stereo_pan(kick[:min_len], 0.0) * 0.6
289
+ audio[:min_len] += self._stereo_pan(snare[:min_len], 0.2) * 0.4
290
+ audio[:min_len] += self._stereo_pan(hihats[:min_len], -0.2) * 0.3
291
+
292
+ # music production layer (the sidechain that was crashing in old deploys)
293
+ audio = ldq_music_production.apply_music_production(
294
+ audio, features, sr, bpm=cfg["tempo_bpm"], sidechain=True
295
+ )
296
+
297
+ if cfg.get("perceptual_polish", 0.0) > 0:
298
+ audio = ldq_perceptual_layer.apply_perceptual_mixing(audio, features, sr)
299
+
300
+ if vgh is not None:
301
+ audio = ldq_fingerprint.embed_fingerprint(audio, sr, vgh)
302
+ self._log("πŸ” Fingerprint embedded")
303
+
304
+ audio = self._soft_limit(audio)
305
+ fade = int(cfg["global_fade"] * sr)
306
+ if fade > 0 and n_total > fade * 2:
307
+ audio[:fade] *= np.linspace(0, 1, fade)[:, np.newaxis]
308
+ audio[-fade:] *= np.linspace(1, 0, fade)[:, np.newaxis]
309
+
310
+ sf.write(output_path, audio, sr)
311
+ self._log(f"βœ“ Saved: {output_path}")
312
+ return
313
+
314
+ # ===== STANDARD SYNTHESIS (Original 4-layer system) =====
315
+ # FOUNDATION LOG: This is the rock-solid working base ("buzzing + clear sound").
316
+ # All advanced controls (BPM, swing, etc.) are mapped here lightly as STRICT modules
317
+ # so they enhance without pulling full LDQ (per rebuild plan + user testing).
318
+ self._log("🎡 Standard Synthesis active (FOUNDATION)")
319
+
320
+ # === DETAILED FOUNDATION LOGGING (for methodical debugging) ===
321
+ use_bpm_influence = True # strict module: light tempo effect even in pure factory
322
+ effective_bpm = cfg.get("tempo_bpm", 140)
323
+ effective_swing = cfg.get("swing", 0.0)
324
+ self._log(f" LOG: PATH=STANDARD | bpm={effective_bpm} | swing={effective_swing} | seed={cfg.get('random_seed')} | noise_lowpass={cfg.get('noise_lowpass_hz')}")
325
+ self._log(f" LOG: PRESET_VOLS noise={cfg.get('noise_vol'):.3f} drone={cfg.get('drone_vol'):.3f} note={cfg.get('note_vol'):.3f} glitch={cfg.get('glitch_vol'):.3f}")
326
+
327
+ # Collections for stems
328
+ audio_noise = np.zeros((n_total, 2), dtype=np.float32)
329
+ audio_drone = np.zeros((n_total, 2), dtype=np.float32)
330
+ audio_melody = np.zeros((n_total, 2), dtype=np.float32)
331
+ audio_glitch = np.zeros((n_total, 2), dtype=np.float32)
332
+ melody_events = []
333
+
334
+ # Layer 1: Texture Floor (Noise)
335
+ if features["edge_density"] > 0.007:
336
+ noise = self._generate_tone(0, duration, "noise")
337
+ if cfg["noise_lowpass_hz"] > 0:
338
+ noise = self._fft_lowpass(noise, cfg["noise_lowpass_hz"])
339
+ noise = self._apply_envelope(noise, cfg["drone_attack"], cfg["drone_decay"])
340
+ vol = min(features["edge_density"] * 1.0, cfg["noise_vol"])
341
+ stereo_noise = self._stereo_pan(noise, 0.0) * vol
342
+ audio += stereo_noise
343
+ audio_noise += stereo_noise
344
+
345
+ # Layer 2: Drones (Lines)
346
+ for i, line in enumerate(features["lines"][:cfg["max_drones"]]):
347
+ x1, _, x2, _ = line[0]
348
+ length = math.hypot(x2 - x1, 0)
349
+ detune = (i * 0.7) if cfg["random_seed"] is not None else 0
350
+ freq = root + (max(1, int(length / 48)) * theta * 0.55) + detune
351
+ tone = self._generate_tone(freq, duration, "sawtooth")
352
+ tone = self._apply_envelope(tone, cfg["drone_attack"], cfg["drone_decay"])
353
+ pan = (x1 / w) * 2 - 1
354
+ stereo_drone = self._stereo_pan(tone, pan) * cfg["drone_vol"]
355
+ audio += stereo_drone
356
+ audio_drone += stereo_drone
357
+
358
+ # Layer 3: Contours β†’ Melody
359
+ valid = [c for c in features["contours"] if 90 < cv2.contourArea(c) < (w * h * 0.6)]
360
+ valid.sort(key=lambda c: cv2.boundingRect(c)[0])
361
+
362
+ # STRICT BPM MODULE (light, foundation-safe): scale timing by tempo so BPM slider does audible work
363
+ # without full LDQ TempoGrid. This is the "mapped perfectly" enhancement.
364
+ tempo_factor = 120.0 / max(1, effective_bpm) # >1 for slower BPM = longer spacing
365
+ swing_amount = effective_swing
366
+
367
+ for i, cnt in enumerate(valid[:cfg["max_notes"]]):
368
+ area = cv2.contourArea(cnt)
369
+ verts = len(cv2.approxPolyDP(cnt, 0.04 * cv2.arcLength(cnt, True), True))
370
+ freq = (root * 3.7) + (verts * theta * 1.6)
371
+ dur = min(2.6, 0.22 + (area / 13500))
372
+ tone = self._generate_tone(freq, dur, "sine")
373
+ tone = self._apply_envelope(tone, cfg["note_attack"], cfg["note_decay"])
374
+
375
+ M = cv2.moments(cnt)
376
+ cx = int(M["m10"] / M["m00"]) if M["m00"] != 0 else cv2.boundingRect(cnt)[0]
377
+ start = (cx / w) * (duration - dur)
378
+
379
+ # Apply light BPM + swing module (deterministic but controllable)
380
+ if use_bpm_influence:
381
+ start = start * tempo_factor
382
+ if swing_amount > 0:
383
+ start += (i % 3 - 1) * swing_amount * 0.08 # micro humanizing swing
384
+
385
+ idx = int(start * sr)
386
+ end = min(idx + len(tone), n_total)
387
+ pan = (cx / w) * 2 - 1
388
+ stereo_note = self._stereo_pan(tone[:end-idx], pan) * cfg["note_vol"]
389
+ audio[idx:end] += stereo_note
390
+ audio_melody[idx:end] += stereo_note
391
+ melody_events.append((freq, dur, start))
392
+
393
+ if i == 0:
394
+ self._log(f" LOG: MELODY_MODULE first_event_start={start:.2f}s (bpm_factor={tempo_factor:.2f}, swing={swing_amount})")
395
+
396
+ # Layer 4: Glitch / Micro events
397
+ for i, kp in enumerate(features["keypoints"][:cfg["max_glitches"]]):
398
+ x, y = kp.pt
399
+ freq = root * 13.5 + (y % 85) * 1.4
400
+ tone = self._generate_tone(freq, 0.05, "sine")
401
+ tone = self._apply_envelope(tone, cfg["glitch_attack"], cfg["glitch_decay"])
402
+ start = (y / h) * (duration - 0.05)
403
+ idx = int(start * sr)
404
+ end = min(idx + len(tone), n_total)
405
+ pan = (x / w) * 2 - 1
406
+ stereo_glitch = self._stereo_pan(tone[:end-idx], pan) * cfg["glitch_vol"]
407
+ audio[idx:end] += stereo_glitch
408
+ audio_glitch[idx:end] += stereo_glitch
409
+
410
+ # Final polish
411
+ audio = self._soft_limit(audio)
412
+ fade = int(cfg["global_fade"] * sr)
413
+ if fade > 0 and n_total > fade * 2:
414
+ audio[:fade] *= np.linspace(0, 1, fade)[:, np.newaxis]
415
+ audio[-fade:] *= np.linspace(1, 0, fade)[:, np.newaxis]
416
+
417
+ peak = np.max(np.abs(audio))
418
+ if peak > 0:
419
+ audio = audio / peak * 0.97
420
+
421
+ sf.write(output_path, audio, sr)
422
+ self._log(f"βœ“ Saved: {output_path} | Peak: {peak:.3f}")
423
+ self._log(f" LOG_WORKING: STANDARD_FOUNDATION | effective_bpm={effective_bpm} | events={len(melody_events)} | peak={peak:.3f} | (bpm/swing now influence timing β€” test with extreme values)")
424
+
425
+ # Export Stems
426
+ if cfg.get("export_stems"):
427
+ base = output_path.replace(".wav", "")
428
+ for stem, name in [(audio_noise, "noise"), (audio_drone, "drone"),
429
+ (audio_melody, "melody"), (audio_glitch, "glitch")]:
430
+ max_val = np.max(np.abs(stem))
431
+ if max_val > 0:
432
+ stem = stem / max_val * 0.97
433
+ sf.write(f"{base}_{name}.wav", stem, sr)
434
+ self._log(f"βœ“ Stem saved: {base}_{name}.wav")
435
+
436
+ # Export MIDI
437
+ if cfg.get("export_midi") and melody_events:
438
+ mid = MidiFile()
439
+ track = MidiTrack()
440
+ mid.tracks.append(track)
441
+ ticks_per_beat = 480
442
+ tempo = cfg.get("tempo_bpm", 120)
443
+ tick_offset = 0
444
+ for freq, dur, start in melody_events:
445
+ midi_note = self._freq_to_midi(freq)
446
+ duration_ticks = int(dur * ticks_per_beat * (tempo / 60))
447
+ start_ticks = int(start * ticks_per_beat * (tempo / 60))
448
+ track.append(Message('note_on', note=midi_note, velocity=64, time=start_ticks - tick_offset))
449
+ track.append(Message('note_off', note=midi_note, velocity=64, time=duration_ticks))
450
+ tick_offset = start_ticks + duration_ticks
451
+ mid_path = output_path.replace(".wav", ".mid")
452
+ mid.save(mid_path)
453
+ self._log(f"βœ“ MIDI saved: {mid_path}")
454
+
455
+ def process(self, image_path: str, output_path: str):
456
+ self.image_path = image_path
457
+ self._log(f"\n╔════════════════════════════════════════════╗")
458
+ self._log(f"β•‘ LYGO Resonance Engine v{__version__} β•‘")
459
+ self._log(f"β•‘ Image β†’ Living Stereo Soundscape β•‘")
460
+ self._log(f"β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n")
461
+ self._log(f"Analyzing: {image_path}")
462
+ features = self.analyze_image(image_path)
463
+ self.synthesize(features, output_path)
464
+
465
+
466
+ def main():
467
+ parser = argparse.ArgumentParser()
468
+ parser.add_argument("image", help="Input image path")
469
+ parser.add_argument("-o", "--output", default=None)
470
+ parser.add_argument("--duration", type=float, default=15.0)
471
+ parser.add_argument("--style", choices=list(PRESETS.keys()), default="cinematic")
472
+ parser.add_argument("--seed", type=int, default=None)
473
+ parser.add_argument("--noise-filter", type=float, default=None)
474
+ parser.add_argument("--stems", action="store_true")
475
+ parser.add_argument("--midi", action="store_true")
476
+ parser.add_argument("--batch", action="store_true")
477
+ parser.add_argument("--quiet", action="store_true")
478
+ parser.add_argument("--ldq", action="store_true")
479
+ parser.add_argument("--genre", choices=["None", "Dubstep", "Phonk", "Industrial"], default="None")
480
+ parser.add_argument("--percussion", choices=["standard", "ldq"], default="standard")
481
+ parser.add_argument("--polish", type=float, default=0.5)
482
+ parser.add_argument("--bpm", type=int, default=140)
483
+ parser.add_argument("--swing", type=float, default=0.0)
484
+ args = parser.parse_args()
485
+
486
+ config = {
487
+ "duration": args.duration,
488
+ "random_seed": args.seed,
489
+ "verbose": not args.quiet,
490
+ "export_stems": args.stems,
491
+ "export_midi": args.midi,
492
+ "use_ldq": args.ldq,
493
+ "genre_manifold": args.genre,
494
+ "percussion_mode": args.percussion,
495
+ "perceptual_polish": args.polish,
496
+ "tempo_bpm": args.bpm,
497
+ "swing": args.swing,
498
+ }
499
+ if args.noise_filter is not None:
500
+ config["noise_lowpass_hz"] = args.noise_filter
501
+
502
+ preset = PRESETS.get(args.style, {})
503
+ config.update(preset)
504
+
505
+ if args.batch:
506
+ folder = Path(args.image)
507
+ if not folder.is_dir():
508
+ print("Error: --batch requires a folder path")
509
+ return
510
+ images = list(folder.glob("*.jpg")) + list(folder.glob("*.png")) + list(folder.glob("*.jpeg"))
511
+ if not images:
512
+ print("No images found")
513
+ return
514
+ for img in images:
515
+ print(f"\nProcessing: {img.name}")
516
+ out_path = f"resonance_{img.stem}.wav"
517
+ engine = ResonanceEngine(config)
518
+ engine.process(str(img), out_path)
519
+ return
520
+
521
+ out_path = args.output or f"resonance_{Path(args.image).stem}.wav"
522
+ engine = ResonanceEngine(config)
523
+ engine.process(args.image, out_path)
524
+
525
+
526
+ if __name__ == "__main__":
527
+ main()