LYGO-Resonance-Engine / resonance_engine.py
DeepSeekOracle's picture
Update resonance_engine.py
9bfcfa7 verified
Raw
History Blame
17.7 kB
#!/usr/bin/env python3
"""
LYGO Resonance Engine v0.4
Image β†’ Living Stereo Soundscape with LDQ Protocol integration
"""
import cv2
import numpy as np
import soundfile as sf
import math
import argparse
import sys
from pathlib import Path
from typing import Optional, Dict, Any
import mido
from mido import MidiFile, MidiTrack, Message
# LDQ imports
import ldq_fingerprint
import ldq_genre_manifold
import ldq_percussion
import ldq_perceptual_layer
__version__ = "0.4.0"
# Artistic Presets
PRESETS = {
"raw": {},
"ambient": {
"noise_vol": 0.055,
"drone_vol": 0.095,
"note_vol": 0.11,
"glitch_vol": 0.012,
"drone_attack": 5.5,
"drone_decay": 5.5,
"note_attack": 0.04,
"note_decay": 0.35,
"max_glitches": 10,
"noise_lowpass_hz": 650,
},
"glitch": {
"noise_vol": 0.16,
"drone_vol": 0.06,
"note_vol": 0.09,
"glitch_vol": 0.07,
"max_notes": 8,
"max_glitches": 50,
"note_decay": 0.10,
"glitch_decay": 0.008,
"noise_lowpass_hz": 2800,
},
"ethereal": {
"noise_vol": 0.04,
"drone_vol": 0.08,
"note_vol": 0.14,
"glitch_vol": 0.02,
"root_freq_range": (35, 95),
"theta_lock_range": (6, 14),
"note_attack": 0.06,
"note_decay": 0.45,
"noise_lowpass_hz": 450,
},
"cinematic": {
"noise_vol": 0.07,
"drone_vol": 0.11,
"note_vol": 0.13,
"glitch_vol": 0.025,
"drone_attack": 4.0,
"drone_decay": 4.5,
"max_drones": 5,
"noise_lowpass_hz": 900,
},
}
class ResonanceEngine:
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = {
"sr": 44100,
"duration": 15.0,
"global_fade": 0.7,
"soft_clip": True,
"soft_clip_amount": 1.7,
"max_drones": 6,
"max_notes": 12,
"max_glitches": 30,
"noise_vol": 0.095,
"drone_vol": 0.075,
"note_vol": 0.15,
"glitch_vol": 0.032,
"root_freq_range": (28, 72),
"theta_lock_range": (4.5, 11),
"drone_attack": 3.2,
"drone_decay": 3.2,
"note_attack": 0.022,
"note_decay": 0.20,
"glitch_attack": 0.003,
"glitch_decay": 0.011,
"noise_lowpass_hz": 0,
"random_seed": None,
"verbose": True,
"export_stems": False,
"export_midi": False,
# LDQ Protocol flags
"use_ldq": False,
"genre_manifold": "None",
"percussion_mode": "standard",
"perceptual_polish": 0.0,
}
if config:
self.config.update(config)
def _log(self, msg: str):
if self.config.get("verbose", True):
print(msg)
def analyze_image(self, image_path: str) -> Dict[str, Any]:
img = cv2.imread(str(image_path))
if img is None:
raise FileNotFoundError(f"Could not load image: {image_path}")
if len(img.shape) == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
h, w = gray.shape
avg_blue, avg_green, avg_red, _ = cv2.mean(img)
edges = cv2.Canny(gray, 50, 150)
edge_density = np.sum(edges > 0) / (h * w)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 50, minLineLength=28, maxLineGap=12)
fast = cv2.FastFeatureDetector_create(threshold=38)
keypoints = fast.detect(gray, None)
features = {
"width": w, "height": h,
"avg_red": avg_red, "avg_green": avg_green, "avg_blue": avg_blue,
"edge_density": edge_density,
"contours": contours,
"lines": lines if lines is not None else [],
"keypoints": keypoints,
}
return features
def _generate_tone(self, freq: float, duration: float, wave_type: str = "sine") -> np.ndarray:
sr = self.config["sr"]
t = np.linspace(0, duration, int(sr * duration), False)
if wave_type == "sine":
return np.sin(freq * t * 2 * np.pi).astype(np.float32)
elif wave_type == "sawtooth":
return (2 * (t * freq - np.floor(0.5 + t * freq))).astype(np.float32)
elif wave_type == "noise":
return np.random.uniform(-1.0, 1.0, len(t)).astype(np.float32)
return np.zeros(len(t), dtype=np.float32)
def _apply_envelope(self, audio: np.ndarray, attack: float, decay: float) -> np.ndarray:
sr = self.config["sr"]
a = max(1, int(attack * sr))
d = max(1, int(decay * sr))
env = np.ones_like(audio, dtype=np.float32)
if len(audio) > a + d:
env[:a] = np.linspace(0, 1, a)
env[-d:] = np.linspace(1, 0, d)
return audio * env
def _stereo_pan(self, mono: np.ndarray, pan: float) -> np.ndarray:
pan = max(-1.0, min(1.0, pan))
left = math.cos((pan + 1) * math.pi / 4)
right = math.sin((pan + 1) * math.pi / 4)
return np.column_stack((mono * left, mono * right)).astype(np.float32)
def _fft_lowpass(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray:
if cutoff_hz <= 0 or len(audio) < 32:
return audio
sr = self.config["sr"]
n = len(audio)
fft = np.fft.rfft(audio)
freqs = np.fft.rfftfreq(n, 1.0 / sr)
fft[freqs > cutoff_hz] = 0
return np.fft.irfft(fft, n=n).real.astype(np.float32)
def _soft_limit(self, audio: np.ndarray) -> np.ndarray:
if self.config["soft_clip"]:
amt = self.config["soft_clip_amount"]
return np.tanh(audio * amt) / np.tanh(amt)
return audio
def _freq_to_midi(self, freq: float) -> int:
if freq <= 0:
return 0
return max(0, min(127, int(12 * math.log2(freq / 440) + 69)))
def synthesize(self, features: Dict[str, Any], output_path: str):
cfg = self.config
if cfg["random_seed"] is not None:
np.random.seed(cfg["random_seed"])
sr = cfg["sr"]
duration = cfg["duration"]
audio = np.zeros((int(sr * duration), 2), dtype=np.float32)
root = np.interp(features["avg_red"], [0, 255], cfg["root_freq_range"])
theta = np.interp(features["avg_green"], [0, 255], cfg["theta_lock_range"])
w, h = features["width"], features["height"]
# Initialize stem collections for standard mode
audio_noise = np.zeros((int(sr * duration), 2), dtype=np.float32)
audio_drone = np.zeros((int(sr * duration), 2), dtype=np.float32)
audio_melody = np.zeros((int(sr * duration), 2), dtype=np.float32)
audio_glitch = np.zeros((int(sr * duration), 2), dtype=np.float32)
melody_events = []
# ===== LDQ Protocol Integration =====
if cfg.get("use_ldq"):
self._log("πŸ”¬ LDQ Protocol Active")
# 1. Compute Visual Genesis Hash
vgh = ldq_fingerprint.compute_vgh(output_path.replace(".wav", ".jpg"))
self._log(f"πŸ”‘ VGH: {vgh[:16]}...")
# 2. Genre Manifold Projection
genre_params = {}
if cfg.get("genre_manifold") and cfg["genre_manifold"] != "None":
genre_params = ldq_genre_manifold.project_to_genre(features, cfg["genre_manifold"])
self._log(f"🎡 Genre: {cfg['genre_manifold']}")
# Apply genre parameters
theta = theta * (1 + genre_params.get("swing_amount", 0))
cfg["root_freq_range"] = (root * 0.9, root * 1.1)
# 3. Percussion Engine
if cfg.get("percussion_mode") == "ldq":
self._log("πŸ₯ LDQ Percussion Active")
# Generate drums
kick = ldq_percussion.generate_kick(features, sr, duration)
snare = ldq_percussion.generate_snare(features, sr, duration)
hihats = ldq_percussion.generate_hihats(features, sr, 120, duration)
# Mix into audio
audio += self._stereo_pan(kick, 0.0) * 0.4
audio += self._stereo_pan(snare, 0.2) * 0.3
audio += self._stereo_pan(hihats, -0.2) * 0.2
# Skip standard layers
return
# 4. Perceptual Polish
if cfg.get("perceptual_polish", 0.0) > 0:
self._log("🎚️ Applying Perceptual Polish")
audio = ldq_perceptual_layer.apply_perceptual_mixing(audio, features, sr)
# 5. Fingerprinting (applied at end)
audio = ldq_fingerprint.embed_fingerprint(audio, sr, vgh)
self._log("πŸ” Fingerprint Embedded")
# ===== Standard Synthesis (if LDQ not active or partial) =====
# Layer 1: Texture Floor
if features["edge_density"] > 0.007:
noise = self._generate_tone(0, duration, "noise")
if cfg["noise_lowpass_hz"] > 0:
noise = self._fft_lowpass(noise, cfg["noise_lowpass_hz"])
noise = self._apply_envelope(noise, cfg["drone_attack"], cfg["drone_decay"])
vol = min(features["edge_density"] * 1.6, cfg["noise_vol"])
stereo_noise = self._stereo_pan(noise, 0.0) * vol
audio += stereo_noise
audio_noise += stereo_noise
# Layer 2: Drones
for i, line in enumerate(features["lines"][:cfg["max_drones"]]):
x1, _, x2, _ = line[0]
length = math.hypot(x2 - x1, 0)
detune = (i * 0.7) if cfg["random_seed"] is not None else 0
freq = root + (max(1, int(length / 48)) * theta * 0.55) + detune
tone = self._generate_tone(freq, duration, "sawtooth")
tone = self._apply_envelope(tone, cfg["drone_attack"], cfg["drone_decay"])
pan = (x1 / w) * 2 - 1
stereo_drone = self._stereo_pan(tone, pan) * cfg["drone_vol"]
audio += stereo_drone
audio_drone += stereo_drone
# Layer 3: Contours β†’ Melody
valid = [c for c in features["contours"] if 90 < cv2.contourArea(c) < (w * h * 0.6)]
valid.sort(key=lambda c: cv2.boundingRect(c)[0])
for i, cnt in enumerate(valid[:cfg["max_notes"]]):
area = cv2.contourArea(cnt)
verts = len(cv2.approxPolyDP(cnt, 0.04 * cv2.arcLength(cnt, True), True))
freq = (root * 3.7) + (verts * theta * 1.6)
dur = min(2.6, 0.22 + (area / 13500))
tone = self._generate_tone(freq, dur, "sine")
tone = self._apply_envelope(tone, cfg["note_attack"], cfg["note_decay"])
M = cv2.moments(cnt)
cx = int(M["m10"] / M["m00"]) if M["m00"] != 0 else cv2.boundingRect(cnt)[0]
start = (cx / w) * (duration - dur)
idx = int(start * sr)
end = min(idx + len(tone), len(audio))
pan = (cx / w) * 2 - 1
stereo_note = self._stereo_pan(tone[:end-idx], pan) * cfg["note_vol"]
audio[idx:end] += stereo_note
audio_melody[idx:end] += stereo_note
melody_events.append((freq, dur, start))
# Layer 4: Glitch / Micro events
for i, kp in enumerate(features["keypoints"][:cfg["max_glitches"]]):
x, y = kp.pt
freq = root * 13.5 + (y % 85) * 1.4
tone = self._generate_tone(freq, 0.042, "sine")
tone = self._apply_envelope(tone, cfg["glitch_attack"], cfg["glitch_decay"])
start = (y / h) * (duration - 0.05)
idx = int(start * sr)
end = min(idx + len(tone), len(audio))
pan = (x / w) * 2 - 1
stereo_glitch = self._stereo_pan(tone[:end-idx], pan) * cfg["glitch_vol"]
audio[idx:end] += stereo_glitch
audio_glitch[idx:end] += stereo_glitch
# Final polish
audio = self._soft_limit(audio)
fade = int(cfg["global_fade"] * sr)
if fade > 0 and len(audio) > fade * 2:
audio[:fade] *= np.linspace(0, 1, fade)[:, None]
audio[-fade:] *= np.linspace(1, 0, fade)[:, None]
peak = np.max(np.abs(audio))
if peak > 0:
audio = audio / peak * 0.97
sf.write(output_path, audio, sr)
self._log(f"βœ“ Saved: {output_path} | Peak: {peak:.3f}")
# Export Stems
if cfg.get("export_stems"):
base = output_path.replace(".wav", "")
for stem, name in [(audio_noise, "noise"), (audio_drone, "drone"),
(audio_melody, "melody"), (audio_glitch, "glitch")]:
max_val = np.max(np.abs(stem))
if max_val > 0:
stem = stem / max_val * 0.97
sf.write(f"{base}_{name}.wav", stem, sr)
self._log(f"βœ“ Stem saved: {base}_{name}.wav")
# Export MIDI
if cfg.get("export_midi") and melody_events:
mid = MidiFile()
track = MidiTrack()
mid.tracks.append(track)
ticks_per_beat = 480
tempo = 120
tick_offset = 0
for freq, dur, start in melody_events:
midi_note = self._freq_to_midi(freq)
duration_ticks = int(dur * ticks_per_beat * (tempo / 60))
start_ticks = int(start * ticks_per_beat * (tempo / 60))
track.append(Message('note_on', note=midi_note, velocity=64, time=start_ticks - tick_offset))
track.append(Message('note_off', note=midi_note, velocity=64, time=duration_ticks))
tick_offset = start_ticks + duration_ticks
mid_path = output_path.replace(".wav", ".mid")
mid.save(mid_path)
self._log(f"βœ“ MIDI saved: {mid_path}")
def process(self, image_path: str, output_path: str):
self._log(f"\n╔════════════════════════════════════════════╗")
self._log(f"β•‘ LYGO Resonance Engine v{__version__} β•‘")
self._log(f"β•‘ Image β†’ Living Stereo Soundscape β•‘")
self._log(f"β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n")
self._log(f"Analyzing: {image_path}")
features = self.analyze_image(image_path)
self.synthesize(features, output_path)
def main():
parser = argparse.ArgumentParser(
description="LYGO Resonance Engine β€” Turn any image into a rich stereo soundscape"
)
parser.add_argument("image", help="Input image path")
parser.add_argument("-o", "--output", default=None, help="Output .wav path")
parser.add_argument("--duration", type=float, default=15.0)
parser.add_argument("--style", choices=list(PRESETS.keys()), default="cinematic",
help="Artistic preset")
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
parser.add_argument("--noise-filter", type=float, default=None,
help="Lowpass cutoff Hz for noise layer (0 = off)")
parser.add_argument("--stems", action="store_true", help="Export individual stems (noise, drone, melody, glitch)")
parser.add_argument("--midi", action="store_true", help="Export MIDI file from melody events")
parser.add_argument("--batch", action="store_true", help="Process all images in a folder")
parser.add_argument("--quiet", action="store_true")
# LDQ arguments
parser.add_argument("--ldq", action="store_true", help="Enable LDQ Protocol")
parser.add_argument("--genre", choices=["None", "Dubstep", "Phonk", "Industrial"], default="None",
help="Genre manifold projection")
parser.add_argument("--percussion", choices=["standard", "ldq"], default="standard",
help="Percussion engine mode")
parser.add_argument("--polish", type=float, default=0.0,
help="Perceptual polish amount (0.0-1.0)")
args = parser.parse_args()
config = {
"duration": args.duration,
"random_seed": args.seed,
"verbose": not args.quiet,
"export_stems": args.stems,
"export_midi": args.midi,
# LDQ config
"use_ldq": args.ldq,
"genre_manifold": args.genre,
"percussion_mode": args.percussion,
"perceptual_polish": args.polish,
}
if args.noise_filter is not None:
config["noise_lowpass_hz"] = args.noise_filter
preset = PRESETS.get(args.style, {})
config.update(preset)
if args.batch:
folder = Path(args.image)
if not folder.is_dir():
print("Error: --batch requires a folder path")
return
images = sorted(folder.glob("*.jpg")) + sorted(folder.glob("*.png")) + sorted(folder.glob("*.jpeg"))
if not images:
print("No images found in folder")
return
for img in images:
print(f"\nProcessing: {img.name}")
out_path = f"resonance_{img.stem}.wav"
engine = ResonanceEngine(config)
engine.process(str(img), out_path)
return
out_path = args.output or f"resonance_{Path(args.image).stem}.wav"
engine = ResonanceEngine(config)
engine.process(args.image, out_path)
if __name__ == "__main__":
main()