CodeAntidote commited on
Commit
e76d2b4
·
verified ·
1 Parent(s): 389f320

Initial Kids Rhyme Studio setup

Browse files
Files changed (1) hide show
  1. audio_engine.py +183 -0
audio_engine.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a short song with sung vocals from the edited rhyme."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+
10
+ # Import spaces before torch/diffusers so ZeroGPU can install its CUDA shim.
11
+ try:
12
+ import spaces
13
+ except ModuleNotFoundError:
14
+ if os.environ.get("SPACE_ID"):
15
+ raise # A Space needs the ZeroGPU runtime and its spaces module.
16
+
17
+ # Local CPU machines can still open the form and write rhymes.
18
+ class _LocalSpaces:
19
+ @staticmethod
20
+ def GPU(**_kwargs):
21
+ return lambda fn: fn
22
+
23
+ spaces = _LocalSpaces()
24
+
25
+ import numpy as np
26
+ from rhyme_engine import LANGUAGES, validate_lyric_for_audio
27
+
28
+ logging.basicConfig(level=logging.INFO)
29
+ logger = logging.getLogger("kids_rhyme.audio")
30
+
31
+
32
+ MODEL_ID = "ACE-Step/acestep-v15-xl-turbo-diffusers"
33
+ VOCAL_LANGUAGE = {key: value["code"] for key, value in LANGUAGES.items()}
34
+ # Released ACE-Step checkpoints use 48 kHz; prefer the pipeline's own value.
35
+ # https://huggingface.co/docs/diffusers/api/pipelines/ace_step
36
+ FALLBACK_SAMPLE_RATE = 48000
37
+
38
+ # ZeroGPU expects the model to be placed on CUDA at module import time. Its
39
+ # CUDA shim makes this possible even before a real GPU has been allocated.
40
+ # On a local machine without CUDA, lyric writing still works and the song
41
+ # button explains what is needed.
42
+ _pipe = None
43
+ _load_error = None
44
+ try:
45
+ import torch
46
+ except ModuleNotFoundError:
47
+ torch = None
48
+ if torch is not None and torch.cuda.is_available():
49
+ try:
50
+ from diffusers import AceStepPipeline
51
+
52
+ _pipe = AceStepPipeline.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda")
53
+ logger.info("Singing model loaded: %s", MODEL_ID)
54
+ except Exception as exc:
55
+ _load_error = exc
56
+ logger.exception("Singing model failed to load")
57
+
58
+
59
+ def _require_pipeline():
60
+ if _pipe is None:
61
+ if _load_error is not None:
62
+ raise RuntimeError("The singing model could not load. Check the Space logs.") from _load_error
63
+ raise RuntimeError("Singing needs GPU hardware. Set the Space hardware to ZeroGPU.")
64
+ return _pipe
65
+
66
+
67
+ def _sample_rate() -> int:
68
+ rate = getattr(_require_pipeline(), "sample_rate", None)
69
+ if rate is None:
70
+ logger.warning("Pipeline has no sample_rate; using %s Hz for %s", FALLBACK_SAMPLE_RATE, MODEL_ID)
71
+ rate = FALLBACK_SAMPLE_RATE
72
+ if isinstance(rate, (bool, np.bool_)) or not isinstance(rate, (int, np.integer)) or rate <= 0:
73
+ raise ValueError("The pipeline returned an invalid sample rate.")
74
+ return int(rate)
75
+
76
+
77
+ def _song_lyrics(text: str) -> str:
78
+ """Preserve every edited line and mark its two parts for the song model."""
79
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
80
+ stanzas = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
81
+ if len(stanzas) >= 2:
82
+ first = stanzas[0]
83
+ second = "\n".join(stanzas[1:])
84
+ else:
85
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
86
+ if len(lines) < 2:
87
+ raise ValueError("Add at least two short lines to sing.")
88
+ midpoint = (len(lines) + 1) // 2
89
+ first, second = "\n".join(lines[:midpoint]), "\n".join(lines[midpoint:])
90
+ return f"[verse]\n{first}\n[chorus]\n{second}"
91
+
92
+
93
+ def _song_settings(text: str, language: str, mood: str,
94
+ theme: str = "", theme_prompt: str = "") -> dict:
95
+ text = validate_lyric_for_audio(text)
96
+ if language not in VOCAL_LANGUAGE or mood not in ("Bouncy", "Calm"):
97
+ raise ValueError("Write a rhyme first to select its language and music mood.")
98
+ if mood == "Calm":
99
+ prompt = (
100
+ "Gentle original children's lullaby, a clear warm voice SINGING a simple "
101
+ "memorable melody in the language of the lyrics. Soft piano, glockenspiel, "
102
+ "light acoustic guitar, slow swaying rhythm. Vocal-forward mix. "
103
+ "Sing the supplied lyrics; no spoken words or narration."
104
+ )
105
+ bpm = 82
106
+ else:
107
+ prompt = (
108
+ "Playful original children's sing-along, a clear cheerful voice SINGING "
109
+ "a simple catchy melody in the language of the lyrics. Ukulele, handclaps, "
110
+ "toy piano, bright steady beat. Vocal-forward mix. "
111
+ "Sing the supplied lyrics; no spoken words or narration."
112
+ )
113
+ bpm = 112
114
+ language_name = LANGUAGES[language]["name"]
115
+ prompt = f"Sing all vocals in {language_name}. " + prompt
116
+ if theme:
117
+ prompt += f" Song theme: {theme}."
118
+ if theme_prompt:
119
+ prompt += f" Topic: {theme_prompt}."
120
+ return dict(
121
+ prompt=prompt,
122
+ lyrics=_song_lyrics(text),
123
+ vocal_language=VOCAL_LANGUAGE[language],
124
+ # Give longer rhymes time for every line: 40 seconds for 8 lines,
125
+ # up to 56 seconds for 12 lines when all ten words are supplied.
126
+ audio_duration=min(56.0, 40.0 + 4.0 * max(0, sum(bool(line.strip()) for line in text.splitlines()) - 8)),
127
+ num_inference_steps=8,
128
+ bpm=bpm,
129
+ task_type="text2music",
130
+ output_type="np",
131
+ )
132
+
133
+
134
+ def _polish(wave: np.ndarray, sr: int) -> np.ndarray:
135
+ """Peak-normalize and fade the tail; this cannot recover omitted lyrics."""
136
+ wave = np.array(wave, dtype=np.float32, copy=True)
137
+ peak = float(np.max(np.abs(wave)))
138
+ if peak > 0:
139
+ wave *= 0.89 / peak
140
+ n = min(int(0.6 * sr), wave.shape[1] // 4)
141
+ if n > 0:
142
+ wave[:, -n:] *= np.linspace(1.0, 0.0, n, dtype=np.float32)
143
+ return wave
144
+
145
+
146
+ def generate_song(settings: dict) -> tuple[int, np.ndarray]:
147
+ """Inference core without a UI decorator; caller must provide GPU access.
148
+
149
+ A future API server needs CUDA hardware or the ZeroGPU wrapper below.
150
+ This function is not a CPU inference path.
151
+ """
152
+ pipe = _require_pipeline()
153
+ sr = _sample_rate()
154
+ started = time.monotonic()
155
+ result = pipe(**settings)
156
+ # ACE-Step returns [batch, channels, samples]; Gradio takes [samples, channels].
157
+ waveform = np.asarray(result.audios[0], dtype=np.float32)
158
+ if waveform.ndim != 2 or waveform.shape[0] not in (1, 2) or waveform.shape[1] < sr:
159
+ raise ValueError(f"Unexpected audio shape {waveform.shape}")
160
+ if not np.isfinite(waveform).all() or np.max(np.abs(waveform)) < 0.005:
161
+ raise ValueError("Model returned empty or invalid audio")
162
+ waveform = _polish(waveform, sr)
163
+ pcm = (np.clip(waveform.T, -1.0, 1.0) * 32767).astype(np.int16)
164
+ logger.info("Generated %.1fs of audio at %s Hz in %.1fs", len(pcm) / sr, sr, time.monotonic() - started)
165
+ return sr, pcm
166
+
167
+
168
+ @spaces.GPU(duration=120) # Keep this budget until real deployed runs have been timed.
169
+ def _generate_on_gpu(settings: dict) -> tuple[int, np.ndarray]:
170
+ return generate_song(settings)
171
+
172
+
173
+ def make_sung_song(text: str, language: str, mood: str,
174
+ theme: str = "", theme_prompt: str = "") -> tuple[tuple[int, np.ndarray], str]:
175
+ """Validate before requesting a GPU; retain the app's five-argument API."""
176
+ settings = _song_settings(text, language, mood, theme, theme_prompt)
177
+ _require_pipeline()
178
+ try:
179
+ audio = _generate_on_gpu(settings)
180
+ except Exception as exc:
181
+ logger.exception("Song generation failed")
182
+ raise RuntimeError("Singing failed this time. Please try again.") from exc
183
+ return audio, "Your sung song is ready to listen to and download."