diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-27 19:56:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-27 19:56:28 +0200 |
| commit | ff6736d6ae7a1274587394fba34da34bb2cb1d8a (patch) | |
| tree | 172474cfe9a1053a3ecaca1f2d23b0b83d70e8a5 | |
| parent | 4d8524d7674f362ccdb4cee8500822c3fde28d48 (diff) | |
| download | cal-notif-ff6736d6ae7a1274587394fba34da34bb2cb1d8a.tar.gz cal-notif-ff6736d6ae7a1274587394fba34da34bb2cb1d8a.zip | |
Speak announcements with Kokoro, leading silence against clipped first word
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rwxr-xr-x | cal-notif | 29 |
1 files changed, 28 insertions, 1 deletions
@@ -296,13 +296,40 @@ def announce(occ, now, vcfg): # ---- voice ---- +def say(text, vcfg): + import numpy as np + from kokoro_onnx import Kokoro + k = Kokoro(vcfg["model"], vcfg["voices"]) + style = sum(w * k.get_voice_style(n) for n, w in vcfg["blend"].items()) + audio, sr = k.create(text, voice=style, lang=vcfg["lang"]) + # A new playback stream wakes the suspended sink and loses its first + # ~0.2 s: lead with silence so it eats that, not the first word. + lead = bytes(int(sr * vcfg["lead_silence"]) * 2) + pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes() + subprocess.run(["aplay", "-q", "-r", str(sr), "-f", "S16_LE", "-c", "1"], + input=lead + pcm, check=True) + + +def voice_ready(vcfg): + if not vcfg["enabled"]: + return False + missing = [p for p in (vcfg["model"], vcfg["voices"]) if not Path(p).exists()] + if missing or not shutil.which("aplay"): + print(f"cal-notif: voice off, missing {missing or 'aplay'}", file=sys.stderr) + return False + return True + # ---- daemon ---- # ---- picker ---- def main(): - sys.exit(__doc__) + cmd = sys.argv[1:2] + if cmd == ["say"] and len(sys.argv) > 2: + say(" ".join(sys.argv[2:]), load_config()["voice"]) + else: + sys.exit(__doc__) if __name__ == "__main__": |
