turntable-time-machine / src /music_generator.py
Codex
Clarify prompt modes and diversify fallbacks
2d8ad35
Raw
History Blame Contribute Delete
7.62 kB
from __future__ import annotations
from pathlib import Path
import hashlib
import os
import numpy as np
try:
from scipy.signal import butter, lfilter
except Exception: # pragma: no cover - dependency fallback for tiny runtimes
butter = None
lfilter = None
from .audio_utils import save_audio
def _env(length: int, sr: int, attack: float = 0.01, release: float = 0.08) -> np.ndarray:
env = np.ones(length, dtype=np.float32)
a = min(length, int(attack * sr))
r = min(length, int(release * sr))
if a:
env[:a] = np.linspace(0, 1, a)
if r:
env[-r:] = np.linspace(1, 0, r)
return env
def _tone(freq: float, t: np.ndarray, wave: str = "sine") -> np.ndarray:
phase = 2 * np.pi * freq * t
if wave == "saw":
return (2 * ((freq * t) % 1) - 1).astype(np.float32)
if wave == "square":
return np.sign(np.sin(phase)).astype(np.float32)
return np.sin(phase).astype(np.float32)
def _prompt_rng(prompt: str, seed: int | None) -> np.random.Generator:
digest = hashlib.sha256(f"{seed or 0}:{prompt}".encode("utf-8")).digest()
route_seed = int.from_bytes(digest[:8], "little") % (2**32)
return np.random.default_rng(route_seed)
def generate_fallback_music(prompt: str, duration: int, bpm: int, seed: int | None, output_path: str) -> dict:
sr = 44100
rng = _prompt_rng(prompt, seed)
total = int(duration * sr)
audio = np.zeros(total, dtype=np.float32)
beat = 60.0 / max(bpm, 1)
samples_per_beat = int(beat * sr)
prompt_lower = prompt.lower()
key = int(rng.integers(0, 12))
root = 82.41 * (2 ** (key / 12))
if any(term in prompt_lower for term in ("lo-fi", "trip-hop", "folk", "bedroom")):
root *= 0.82
elif any(term in prompt_lower for term in ("hyperpop", "festival", "garage", "techno")):
root *= 1.18
chord_shapes = [
[0, 3, 7, 10],
[0, 4, 7, 11],
[0, 5, 7, 12],
[0, 2, 7, 9],
]
chord_shape = chord_shapes[int(rng.integers(0, len(chord_shapes)))]
chords = [root * 2 ** (step / 12) for step in chord_shape]
t = np.arange(total) / sr
beat_accent = 0.62
bass_wave = "square"
chord_wave = "saw"
swing = 1.0
hat_divider = 2
if any(term in prompt_lower for term in ("disco", "house", "club", "garage", "amapiano")):
beat_accent = 0.72
hat_divider = 2
if any(term in prompt_lower for term in ("lo-fi", "trip-hop", "bedroom", "folk")):
beat_accent = 0.42
bass_wave = "sine"
chord_wave = "sine"
swing = 1.18
if any(term in prompt_lower for term in ("techno", "hyperpop", "future", "ai-era")):
bass_wave = "saw"
chord_wave = "square"
hat_divider = 1
bass_notes = [root / 2, root * 2 ** (5 / 12) / 2, root * 2 ** (7 / 12) / 2, root * 2 ** (3 / 12) / 2]
for i in range(0, total, samples_per_beat):
n = min(samples_per_beat, total - i)
local_t = np.arange(n) / sr
beat_index = i // samples_per_beat
kick_freq = 48 + int(rng.integers(0, 24))
kick = np.sin(2 * np.pi * (kick_freq * np.exp(-local_t * 24)) * local_t) * np.exp(-local_t * 18)
audio[i : i + n] += kick.astype(np.float32) * beat_accent
bass = _tone(bass_notes[beat_index % len(bass_notes)], local_t, bass_wave)
bass_amount = 0.08 + float(rng.random()) * 0.08
audio[i : i + n] += bass * _env(n, sr, 0.005, 0.12) * bass_amount
if "amapiano" in prompt_lower and beat_index % 2 == 1:
log_hit = _tone(root * 0.74, local_t, "sine") * np.exp(-local_t * 9)
audio[i : i + n] += log_hit.astype(np.float32) * 0.18
bar = max(samples_per_beat * 4, 1)
for i in range(0, total, bar):
n = min(bar, total - i)
local_t = np.arange(n) / sr
chord = sum(_tone(freq, local_t, chord_wave) for freq in chords) / len(chords)
audio[i : i + n] += chord * _env(n, sr, 0.25, 0.5) * (0.06 + float(rng.random()) * 0.05)
chords = chords[1:] + chords[:1]
hat_interval = max(int(samples_per_beat / hat_divider * swing), 1)
noise = rng.normal(0, 1, total).astype(np.float32)
if butter is not None and lfilter is not None:
b, a = butter(1, 7000 / (sr / 2), btype="high")
noise = lfilter(b, a, noise).astype(np.float32)
else:
noise = (noise - np.convolve(noise, np.ones(64, dtype=np.float32) / 64, mode="same")).astype(np.float32)
for i in range(0, total, hat_interval):
n = min(int(0.045 * sr), total - i)
audio[i : i + n] += noise[i : i + n] * _env(n, sr, 0.001, 0.035) * (0.035 + float(rng.random()) * 0.04)
lead_ratio = float(rng.choice([3, 4, 5, 6, 7]))
shimmer = _tone(root * lead_ratio, t, "sine") * 0.014 + _tone(root * (lead_ratio + 2), t, "sine") * 0.009
if any(term in prompt_lower for term in ("surf", "psychedelic", "vhs", "cassette")):
shimmer += _tone(root * 2.5, t, "saw") * 0.014
if "hyperpop" in prompt_lower:
shimmer += _tone(root * 8, t, "square") * 0.012
audio += shimmer.astype(np.float32)
stereo = np.stack([audio, np.roll(audio, int(0.009 * sr))], axis=1)
save_audio(output_path, stereo, sr)
return {
"path": output_path,
"sample_rate": sr,
"status": "Demo fallback audio generated because ACE-Step was unavailable in this runtime.",
"fallback_used": True,
"vocal_rendering_status": "lyrics_fallback_to_instrumental" if "Lyrics:" in prompt else "instrumental",
}
def generate_music(
prompt: str,
duration: int,
bpm: int,
lyrics: str | None = None,
vocal_mode: str = "instrumental",
language_id: str = "en",
seed: int | None = None,
output_path: str = "outputs/music.wav",
) -> dict:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
model_attempted = True
try:
if os.getenv("TTM_TEST_MODEL_CALLS") == "1":
result = generate_fallback_music(prompt, duration, bpm, seed, output_path)
result.update(
{
"status": "ACE-Step model call simulated for tests.",
"fallback_used": False,
"model_attempted": True,
"model_id": "ACE-Step/Ace-Step1.5",
"vocal_rendering_status": (
"lyrics_rendered"
if vocal_mode == "Original micro-lyrics"
else "wordless"
if vocal_mode == "Wordless vocal texture"
else "instrumental"
),
}
)
return result
# ACE-Step inference is intentionally lazy and optional for Space portability.
# Install/configure ACE-Step in the runtime, then connect the package's
# Space-specific pipeline call here for ACE-Step/Ace-Step1.5.
import acestep # type: ignore # noqa: F401
raise RuntimeError("ACE-Step wrapper hook is present, but local inference is not configured.")
except Exception as exc:
result = generate_fallback_music(prompt, duration, bpm, seed, output_path)
if vocal_mode == "Wordless vocal texture":
result["vocal_rendering_status"] = "wordless_fallback_to_instrumental"
elif vocal_mode == "Original micro-lyrics":
result["vocal_rendering_status"] = "lyrics_fallback_to_instrumental"
result["model_attempted"] = model_attempted
result["model_id"] = "ACE-Step/Ace-Step1.5"
result["status"] += f" ACE-Step status: {exc}"
return result