hollow / voice.py
Pabloler21's picture
fix(voice): keep worker protocol on a clean fd so Kokoro load noise can't corrupt READY
7358304
Raw
History Blame Contribute Delete
6.43 kB
"""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