import os import struct import subprocess import logging import wave logger = logging.getLogger(__name__) SF_TARGET_DIRS = [ "/opt/daw_engine/soundfonts", ] def _find_chunk(data, chunk_id, offset=12, max_size=0): pos = offset end = len(data) if max_size == 0 else offset + max_size while pos + 8 <= end: ck_id = data[pos:pos + 4] ck_size = struct.unpack(" bool: has_ogg = self._check_ffmpeg_ogg() if not has_ogg: logger.warning("ffmpeg with libvorbis not available, cannot convert to SF3") return False try: with open(sf2_path, "rb") as f: data = f.read() except Exception as e: logger.error(f"Cannot read {sf2_path}: {e}") return False if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"sfbk": logger.warning("Not a valid SF2 file") return False # Find smpl chunk recursively smpl = _find_chunk(data, b"smpl") if smpl is None: logger.warning("No smpl chunk found in SF2") return False smpl_head_off, _, smpl_old_size, smpl_data_off = smpl sample_data = data[smpl_data_off:smpl_data_off + smpl_old_size] if len(sample_data) < 16: logger.warning("Sample data too small") return False tmp_wav = sf3_path + ".tmp.wav" tmp_ogg = sf3_path + ".tmp.ogg" try: # Write samples as WAV with open(tmp_wav, "wb") as fw: with wave.open(fw, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(44100) w.writeframes(sample_data) # Compress to Ogg Vorbis subprocess.run([ "ffmpeg", "-y", "-i", tmp_wav, "-c:a", "libvorbis", "-q:a", "3", "-f", "ogg", tmp_ogg ], capture_output=True, timeout=600, check=True) with open(tmp_ogg, "rb") as fo: ogg_data = fo.read() compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100 logger.info(f"Compressed {len(sample_data)} -> {len(ogg_data)} bytes ({compression:.0f}%)") ogg_padded = ogg_data if len(ogg_data) % 2 == 0 else ogg_data + b"\x00" new_smpl_size = len(ogg_data) old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0) new_padded = len(ogg_padded) delta = new_padded - old_padded # Rebuild file: replace smpl chunk and update all sizes out = bytearray() out.extend(data[:smpl_head_off]) # up to smpl chunk header out.extend(struct.pack(" str: if not os.path.exists(sf2_path): raise FileNotFoundError(f"Source SF2 file not found: {sf2_path}") sf3_path = os.path.splitext(sf2_path)[0] + ".sf3" if os.path.exists(sf3_path) and os.path.getmtime(sf3_path) >= os.path.getmtime(sf2_path): logger.info(f"SF3 already up-to-date: {sf3_path}") return sf3_path converter = self._find_sf3_converter() try: logger.info(f"Converting '{sf2_path}' -> '{sf3_path}' using {converter}...") if converter == "fluidsynth": converter = "python" if converter == "mscore": cmd = ["mscore", "-o", sf3_path, sf2_path] result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) if result.returncode == 0 and os.path.exists(sf3_path): logger.info(f"Created SF3 via mscore: {sf3_path} ({os.path.getsize(sf3_path)/1024/1024:.1f}MB)") return sf3_path converter = "python" if converter == "python": if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path): size_mb = os.path.getsize(sf3_path) / (1024 * 1024) logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)") return sf3_path logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path") return sf2_path return sf2_path except subprocess.TimeoutExpired: logger.error(f"Conversion timed out for {sf2_path}") return sf2_path except Exception as e: logger.error(f"Error converting {sf2_path}: {e}") return sf2_path def batch_convert_all(self): for sdir in self.target_dirs: if not os.path.isdir(sdir): continue for fname in sorted(os.listdir(sdir)): if fname.lower().endswith(".sf2"): self.convert_sf2_to_sf3(os.path.join(sdir, fname))