DeepSeekOracle commited on
Commit
dbff8e8
Β·
verified Β·
1 Parent(s): 7998bb1

Create resonance_engine.py

Browse files
Files changed (1) hide show
  1. resonance_engine.py +372 -0
resonance_engine.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ LYGO Resonance Engine v0.3
4
+ Image β†’ Living Stereo Soundscape
5
+ A spectral translator that gives voice to the hidden geometry, texture, and color of any image.
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
+ __version__ = "0.3.0"
20
+
21
+ # Artistic Presets
22
+ PRESETS = {
23
+ "raw": {},
24
+ "ambient": {
25
+ "noise_vol": 0.055,
26
+ "drone_vol": 0.095,
27
+ "note_vol": 0.11,
28
+ "glitch_vol": 0.012,
29
+ "drone_attack": 5.5,
30
+ "drone_decay": 5.5,
31
+ "note_attack": 0.04,
32
+ "note_decay": 0.35,
33
+ "max_glitches": 10,
34
+ "noise_lowpass_hz": 650,
35
+ },
36
+ "glitch": {
37
+ "noise_vol": 0.16,
38
+ "drone_vol": 0.06,
39
+ "note_vol": 0.09,
40
+ "glitch_vol": 0.07,
41
+ "max_notes": 8,
42
+ "max_glitches": 50,
43
+ "note_decay": 0.10,
44
+ "glitch_decay": 0.008,
45
+ "noise_lowpass_hz": 2800,
46
+ },
47
+ "ethereal": {
48
+ "noise_vol": 0.04,
49
+ "drone_vol": 0.08,
50
+ "note_vol": 0.14,
51
+ "glitch_vol": 0.02,
52
+ "root_freq_range": (35, 95),
53
+ "theta_lock_range": (6, 14),
54
+ "note_attack": 0.06,
55
+ "note_decay": 0.45,
56
+ "noise_lowpass_hz": 450,
57
+ },
58
+ "cinematic": {
59
+ "noise_vol": 0.07,
60
+ "drone_vol": 0.11,
61
+ "note_vol": 0.13,
62
+ "glitch_vol": 0.025,
63
+ "drone_attack": 4.0,
64
+ "drone_decay": 4.5,
65
+ "max_drones": 5,
66
+ "noise_lowpass_hz": 900,
67
+ },
68
+ }
69
+
70
+ class ResonanceEngine:
71
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
72
+ self.config = {
73
+ "sr": 44100,
74
+ "duration": 15.0,
75
+ "global_fade": 0.7,
76
+ "soft_clip": True,
77
+ "soft_clip_amount": 1.7,
78
+ "max_drones": 6,
79
+ "max_notes": 12,
80
+ "max_glitches": 30,
81
+ "noise_vol": 0.095,
82
+ "drone_vol": 0.075,
83
+ "note_vol": 0.15,
84
+ "glitch_vol": 0.032,
85
+ "root_freq_range": (28, 72),
86
+ "theta_lock_range": (4.5, 11),
87
+ "drone_attack": 3.2,
88
+ "drone_decay": 3.2,
89
+ "note_attack": 0.022,
90
+ "note_decay": 0.20,
91
+ "glitch_attack": 0.003,
92
+ "glitch_decay": 0.011,
93
+ "noise_lowpass_hz": 0,
94
+ "random_seed": None,
95
+ "verbose": True,
96
+ "export_stems": False,
97
+ "export_midi": False,
98
+ }
99
+ if config:
100
+ self.config.update(config)
101
+
102
+ def _log(self, msg: str):
103
+ if self.config.get("verbose", True):
104
+ print(msg)
105
+
106
+ def analyze_image(self, image_path: str) -> Dict[str, Any]:
107
+ img = cv2.imread(str(image_path))
108
+ if img is None:
109
+ raise FileNotFoundError(f"Could not load image: {image_path}")
110
+
111
+ if len(img.shape) == 2:
112
+ img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
113
+
114
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
115
+ h, w = gray.shape
116
+
117
+ avg_blue, avg_green, avg_red, _ = cv2.mean(img)
118
+ edges = cv2.Canny(gray, 50, 150)
119
+ edge_density = np.sum(edges > 0) / (h * w)
120
+
121
+ contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
122
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 50, minLineLength=28, maxLineGap=12)
123
+ fast = cv2.FastFeatureDetector_create(threshold=38)
124
+ keypoints = fast.detect(gray, None)
125
+
126
+ features = {
127
+ "width": w, "height": h,
128
+ "avg_red": avg_red, "avg_green": avg_green, "avg_blue": avg_blue,
129
+ "edge_density": edge_density,
130
+ "contours": contours,
131
+ "lines": lines if lines is not None else [],
132
+ "keypoints": keypoints,
133
+ }
134
+ return features
135
+
136
+ def _generate_tone(self, freq: float, duration: float, wave_type: str = "sine") -> np.ndarray:
137
+ sr = self.config["sr"]
138
+ t = np.linspace(0, duration, int(sr * duration), False)
139
+ if wave_type == "sine":
140
+ return np.sin(freq * t * 2 * np.pi).astype(np.float32)
141
+ elif wave_type == "sawtooth":
142
+ return (2 * (t * freq - np.floor(0.5 + t * freq))).astype(np.float32)
143
+ elif wave_type == "noise":
144
+ return np.random.uniform(-1.0, 1.0, len(t)).astype(np.float32)
145
+ return np.zeros(len(t), dtype=np.float32)
146
+
147
+ def _apply_envelope(self, audio: np.ndarray, attack: float, decay: float) -> np.ndarray:
148
+ sr = self.config["sr"]
149
+ a = max(1, int(attack * sr))
150
+ d = max(1, int(decay * sr))
151
+ env = np.ones_like(audio, dtype=np.float32)
152
+ if len(audio) > a + d:
153
+ env[:a] = np.linspace(0, 1, a)
154
+ env[-d:] = np.linspace(1, 0, d)
155
+ return audio * env
156
+
157
+ def _stereo_pan(self, mono: np.ndarray, pan: float) -> np.ndarray:
158
+ pan = max(-1.0, min(1.0, pan))
159
+ left = math.cos((pan + 1) * math.pi / 4)
160
+ right = math.sin((pan + 1) * math.pi / 4)
161
+ return np.column_stack((mono * left, mono * right)).astype(np.float32)
162
+
163
+ def _fft_lowpass(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray:
164
+ if cutoff_hz <= 0 or len(audio) < 32:
165
+ return audio
166
+ sr = self.config["sr"]
167
+ n = len(audio)
168
+ fft = np.fft.rfft(audio)
169
+ freqs = np.fft.rfftfreq(n, 1.0 / sr)
170
+ fft[freqs > cutoff_hz] = 0
171
+ return np.fft.irfft(fft, n=n).real.astype(np.float32)
172
+
173
+ def _soft_limit(self, audio: np.ndarray) -> np.ndarray:
174
+ if self.config["soft_clip"]:
175
+ amt = self.config["soft_clip_amount"]
176
+ return np.tanh(audio * amt) / np.tanh(amt)
177
+ return audio
178
+
179
+ def _freq_to_midi(self, freq: float) -> int:
180
+ if freq <= 0:
181
+ return 0
182
+ return max(0, min(127, int(12 * math.log2(freq / 440) + 69)))
183
+
184
+ def synthesize(self, features: Dict[str, Any], output_path: str):
185
+ cfg = self.config
186
+ if cfg["random_seed"] is not None:
187
+ np.random.seed(cfg["random_seed"])
188
+
189
+ sr = cfg["sr"]
190
+ duration = cfg["duration"]
191
+ audio = np.zeros((int(sr * duration), 2), dtype=np.float32)
192
+
193
+ root = np.interp(features["avg_red"], [0, 255], cfg["root_freq_range"])
194
+ theta = np.interp(features["avg_green"], [0, 255], cfg["theta_lock_range"])
195
+ w, h = features["width"], features["height"]
196
+
197
+ # Initialize stem collections
198
+ audio_noise = np.zeros((int(sr * duration), 2), dtype=np.float32)
199
+ audio_drone = np.zeros((int(sr * duration), 2), dtype=np.float32)
200
+ audio_melody = np.zeros((int(sr * duration), 2), dtype=np.float32)
201
+ audio_glitch = np.zeros((int(sr * duration), 2), dtype=np.float32)
202
+ melody_events = []
203
+
204
+ # Layer 1: Texture Floor
205
+ if features["edge_density"] > 0.007:
206
+ noise = self._generate_tone(0, duration, "noise")
207
+ if cfg["noise_lowpass_hz"] > 0:
208
+ noise = self._fft_lowpass(noise, cfg["noise_lowpass_hz"])
209
+ noise = self._apply_envelope(noise, cfg["drone_attack"], cfg["drone_decay"])
210
+ vol = min(features["edge_density"] * 1.6, cfg["noise_vol"])
211
+ stereo_noise = self._stereo_pan(noise, 0.0) * vol
212
+ audio += stereo_noise
213
+ audio_noise += stereo_noise
214
+
215
+ # Layer 2: Drones
216
+ for i, line in enumerate(features["lines"][:cfg["max_drones"]]):
217
+ x1, _, x2, _ = line[0]
218
+ length = math.hypot(x2 - x1, 0)
219
+ detune = (i * 0.7) if cfg["random_seed"] is not None else 0
220
+ freq = root + (max(1, int(length / 48)) * theta * 0.55) + detune
221
+ tone = self._generate_tone(freq, duration, "sawtooth")
222
+ tone = self._apply_envelope(tone, cfg["drone_attack"], cfg["drone_decay"])
223
+ pan = (x1 / w) * 2 - 1
224
+ stereo_drone = self._stereo_pan(tone, pan) * cfg["drone_vol"]
225
+ audio += stereo_drone
226
+ audio_drone += stereo_drone
227
+
228
+ # Layer 3: Contours β†’ Melody
229
+ valid = [c for c in features["contours"] if 90 < cv2.contourArea(c) < (w * h * 0.6)]
230
+ valid.sort(key=lambda c: cv2.boundingRect(c)[0])
231
+
232
+ for i, cnt in enumerate(valid[:cfg["max_notes"]]):
233
+ area = cv2.contourArea(cnt)
234
+ verts = len(cv2.approxPolyDP(cnt, 0.04 * cv2.arcLength(cnt, True), True))
235
+ freq = (root * 3.7) + (verts * theta * 1.6)
236
+ dur = min(2.6, 0.22 + (area / 13500))
237
+ tone = self._generate_tone(freq, dur, "sine")
238
+ tone = self._apply_envelope(tone, cfg["note_attack"], cfg["note_decay"])
239
+
240
+ M = cv2.moments(cnt)
241
+ cx = int(M["m10"] / M["m00"]) if M["m00"] != 0 else cv2.boundingRect(cnt)[0]
242
+ start = (cx / w) * (duration - dur)
243
+ idx = int(start * sr)
244
+ end = min(idx + len(tone), len(audio))
245
+ pan = (cx / w) * 2 - 1
246
+ stereo_note = self._stereo_pan(tone[:end-idx], pan) * cfg["note_vol"]
247
+ audio[idx:end] += stereo_note
248
+ audio_melody[idx:end] += stereo_note
249
+ melody_events.append((freq, dur, start))
250
+
251
+ # Layer 4: Glitch / Micro events
252
+ for i, kp in enumerate(features["keypoints"][:cfg["max_glitches"]]):
253
+ x, y = kp.pt
254
+ freq = root * 13.5 + (y % 85) * 1.4
255
+ tone = self._generate_tone(freq, 0.042, "sine")
256
+ tone = self._apply_envelope(tone, cfg["glitch_attack"], cfg["glitch_decay"])
257
+ start = (y / h) * (duration - 0.05)
258
+ idx = int(start * sr)
259
+ end = min(idx + len(tone), len(audio))
260
+ pan = (x / w) * 2 - 1
261
+ stereo_glitch = self._stereo_pan(tone[:end-idx], pan) * cfg["glitch_vol"]
262
+ audio[idx:end] += stereo_glitch
263
+ audio_glitch[idx:end] += stereo_glitch
264
+
265
+ # Final polish
266
+ audio = self._soft_limit(audio)
267
+ fade = int(cfg["global_fade"] * sr)
268
+ if fade > 0 and len(audio) > fade * 2:
269
+ audio[:fade] *= np.linspace(0, 1, fade)[:, None]
270
+ audio[-fade:] *= np.linspace(1, 0, fade)[:, None]
271
+
272
+ peak = np.max(np.abs(audio))
273
+ if peak > 0:
274
+ audio = audio / peak * 0.97
275
+
276
+ sf.write(output_path, audio, sr)
277
+ self._log(f"βœ“ Saved: {output_path} | Peak: {peak:.3f}")
278
+
279
+ # Export Stems
280
+ if cfg.get("export_stems"):
281
+ base = output_path.replace(".wav", "")
282
+ for stem, name in [(audio_noise, "noise"), (audio_drone, "drone"),
283
+ (audio_melody, "melody"), (audio_glitch, "glitch")]:
284
+ max_val = np.max(np.abs(stem))
285
+ if max_val > 0:
286
+ stem = stem / max_val * 0.97
287
+ sf.write(f"{base}_{name}.wav", stem, sr)
288
+ self._log(f"βœ“ Stem saved: {base}_{name}.wav")
289
+
290
+ # Export MIDI
291
+ if cfg.get("export_midi") and melody_events:
292
+ mid = MidiFile()
293
+ track = MidiTrack()
294
+ mid.tracks.append(track)
295
+ ticks_per_beat = 480
296
+ tempo = 120
297
+ tick_offset = 0
298
+ for freq, dur, start in melody_events:
299
+ midi_note = self._freq_to_midi(freq)
300
+ duration_ticks = int(dur * ticks_per_beat * (tempo / 60))
301
+ start_ticks = int(start * ticks_per_beat * (tempo / 60))
302
+ track.append(Message('note_on', note=midi_note, velocity=64, time=start_ticks - tick_offset))
303
+ track.append(Message('note_off', note=midi_note, velocity=64, time=duration_ticks))
304
+ tick_offset = start_ticks + duration_ticks
305
+ mid_path = output_path.replace(".wav", ".mid")
306
+ mid.save(mid_path)
307
+ self._log(f"βœ“ MIDI saved: {mid_path}")
308
+
309
+ def process(self, image_path: str, output_path: str):
310
+ self._log(f"\n╔════════════════════════════════════════════╗")
311
+ self._log(f"β•‘ LYGO Resonance Engine v{__version__} β•‘")
312
+ self._log(f"β•‘ Image β†’ Living Stereo Soundscape β•‘")
313
+ self._log(f"β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n")
314
+ self._log(f"Analyzing: {image_path}")
315
+ features = self.analyze_image(image_path)
316
+ self.synthesize(features, output_path)
317
+
318
+
319
+ def main():
320
+ parser = argparse.ArgumentParser(
321
+ description="LYGO Resonance Engine β€” Turn any image into a rich stereo soundscape"
322
+ )
323
+ parser.add_argument("image", help="Input image path")
324
+ parser.add_argument("-o", "--output", default=None, help="Output .wav path")
325
+ parser.add_argument("--duration", type=float, default=15.0)
326
+ parser.add_argument("--style", choices=list(PRESETS.keys()), default="cinematic",
327
+ help="Artistic preset")
328
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
329
+ parser.add_argument("--noise-filter", type=float, default=None,
330
+ help="Lowpass cutoff Hz for noise layer (0 = off)")
331
+ parser.add_argument("--stems", action="store_true", help="Export individual stems (noise, drone, melody, glitch)")
332
+ parser.add_argument("--midi", action="store_true", help="Export MIDI file from melody events")
333
+ parser.add_argument("--batch", action="store_true", help="Process all images in a folder")
334
+ parser.add_argument("--quiet", action="store_true")
335
+ args = parser.parse_args()
336
+
337
+ config = {
338
+ "duration": args.duration,
339
+ "random_seed": args.seed,
340
+ "verbose": not args.quiet,
341
+ "export_stems": args.stems,
342
+ "export_midi": args.midi,
343
+ }
344
+ if args.noise_filter is not None:
345
+ config["noise_lowpass_hz"] = args.noise_filter
346
+
347
+ preset = PRESETS.get(args.style, {})
348
+ config.update(preset)
349
+
350
+ if args.batch:
351
+ folder = Path(args.image)
352
+ if not folder.is_dir():
353
+ print("Error: --batch requires a folder path")
354
+ return
355
+ images = sorted(folder.glob("*.jpg")) + sorted(folder.glob("*.png")) + sorted(folder.glob("*.jpeg"))
356
+ if not images:
357
+ print("No images found in folder")
358
+ return
359
+ for img in images:
360
+ print(f"\nProcessing: {img.name}")
361
+ out_path = f"resonance_{img.stem}.wav"
362
+ engine = ResonanceEngine(config)
363
+ engine.process(str(img), out_path)
364
+ return
365
+
366
+ out_path = args.output or f"resonance_{Path(args.image).stem}.wav"
367
+ engine = ResonanceEngine(config)
368
+ engine.process(args.image, out_path)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()