"""Hollow's voice: Kokoro-82M whispered through a locked DSP recipe (the validated 'af_nicole, 28% fog' cast). CPU only — no GPU budget. Import is guarded: where kokoro is unavailable (e.g. the Python 3.13 dev venv) the app runs silent instead of crashing.""" import os # cap CPU math threads BEFORE numpy/torch load — on many-core machines under # memory pressure OpenBLAS spawns one buffer-hungry thread per core and the CPU # allocator fails, so Kokoro won't load. 4 is plenty for our CPU-side TTS work. os.environ.setdefault("OMP_NUM_THREADS", "4") os.environ.setdefault("OPENBLAS_NUM_THREADS", "4") os.environ.setdefault("MKL_NUM_THREADS", "4") import base64 import importlib.util import re import subprocess import sys import threading import numpy as np from sting import _wav _SR = 24000 _VOICE = "af_nicole" _SPEED = 0.94 # a touch quicker than 0.88 so the finale recital isn't dense; still a slow whisper _FILTER_INTENSITY = 0.28 # the "verylight" setting the user picked # Kokoro runs in a SEPARATE process (voice_worker.py) with CUDA hidden, so the # CPU TTS never initializes a CUDA context in THIS process. On ZeroGPU that is # mandatory: any CUDA init in the main app process poisons the @spaces.GPU worker # fork ("No CUDA GPUs are available") and every model turn dies. The DSP below # stays here (pure numpy); only the torch model lives in the subprocess. # `_PIPELINE` is kept as a public availability flag (None = silent); speak() and # the tests gate on it. HOLLOW_VOICE_OFF=1 forces silence. _VOICE_OFF = os.environ.get("HOLLOW_VOICE_OFF") == "1" _KOKORO_AVAILABLE = (not _VOICE_OFF) and importlib.util.find_spec("kokoro") is not None _PIPELINE = True if _KOKORO_AVAILABLE else None if _VOICE_OFF: print("[voice] disabled (HOLLOW_VOICE_OFF=1)") elif not _KOKORO_AVAILABLE: print("[voice] disabled (kokoro not installed)") _WORKER = None _WORKER_LOCK = threading.Lock() _WORKER_DEAD = False # set if the worker can't start, so we don't retry forever def _start_worker(): """Lazily spawn the CUDA-free Kokoro worker and reuse it across calls. Returns the Popen handle, or None if voice is unavailable / it won't start.""" global _WORKER, _WORKER_DEAD if not _KOKORO_AVAILABLE or _WORKER_DEAD: return None if _WORKER is not None and _WORKER.poll() is None: return _WORKER try: worker = os.path.join(os.path.dirname(os.path.abspath(__file__)), "voice_worker.py") env = dict(os.environ) env["CUDA_VISIBLE_DEVICES"] = "" # the worker also sets it; belt-and-suspenders # stderr is inherited (worker noise -> the app log) so a Kokoro/torch # failure in the worker is visible; the protocol pipe (stdout) stays clean. p = subprocess.Popen([sys.executable, worker], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, bufsize=0, env=env) ready = b"" for _ in range(200): # skip any stray noise, wait for READY ready = p.stdout.readline() # blocks until Kokoro has loaded if not ready or b"READY" in ready: break if b"READY" not in ready: try: p.kill() except Exception: pass _WORKER_DEAD = True print("[voice] worker did not report READY; voice disabled") return None print("[voice] worker ready") _WORKER = p return _WORKER except Exception as e: print(f"[voice] worker failed to start: {e!r}") _WORKER_DEAD = True return None def _synth(cleaned: str): """Send cleaned text to the worker; return raw float32 audio (or None).""" global _WORKER p = _start_worker() if p is None: return None try: with _WORKER_LOCK: p.stdin.write(base64.b64encode(cleaned.encode("utf-8")) + b"\n") p.stdin.flush() line = p.stdout.readline() if not line or line.strip() == b"ERR": return None return np.frombuffer(base64.b64decode(line.strip()), dtype=np.float32) except Exception as e: print(f"[voice] worker synth failed: {e!r}") _WORKER = None # drop the (maybe broken) worker; next call respawns return None def _pitch_up(x, factor): n = int(len(x) / factor) idx = np.linspace(0, len(x) - 1, n) return np.interp(idx, np.arange(len(x)), x).astype(np.float32) def _breath(x, amount): rng = np.random.default_rng(3) env = np.abs(x) k = np.hanning(441); k /= k.sum() env = np.convolve(env, k, mode="same") noise = rng.standard_normal(len(x)).astype(np.float32) * env return x * (1 - amount) + noise * amount * 2.0 def _fog_reverb(x, taps): out = x.copy() for delay_s, gain in taps: d = int(delay_s * _SR) if d < len(x): echo = np.zeros_like(x) echo[d:] = x[:-d] * gain out = out + echo return out def _fog(x, intensity=_FILTER_INTENSITY, pitch=1.16): y = _pitch_up(x, pitch) y = _breath(y, amount=0.38 * intensity) taps = [(0.06, 0.5 * intensity), (0.13, 0.32 * intensity), (0.23, 0.18 * intensity)] y = _fog_reverb(y, taps) return (y / (np.max(np.abs(y)) + 1e-9) * 0.85).astype(np.float32) def _clean_for_tts(text: str) -> str: """Kokoro verbalizes dashes ('—', '-') as words. Replace em/en dashes with a comma pause and hyphens with a space, then collapse whitespace.""" text = text.replace("—", ", ").replace("–", ", ").replace("-", " ") return re.sub(r"\s+", " ", text).strip() def speak(text: str) -> str | None: """Synthesize `text` as Hollow's whisper; return base64 WAV, or None if voice is unavailable or anything fails (never raises).""" if _PIPELINE is None or not text or not text.strip(): return None try: cleaned = _clean_for_tts(text) if not cleaned: return None audio = _synth(cleaned) if audio is None or audio.size == 0: return None audio = np.asarray(audio, dtype=np.float32) audio = audio / (np.max(np.abs(audio)) + 1e-9) * 0.9 return base64.b64encode(_wav(_fog(audio), _SR)).decode() except Exception as e: print(f"[voice] speak failed: {e!r}") return None