aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 19:56:28 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 19:56:28 +0200
commitff6736d6ae7a1274587394fba34da34bb2cb1d8a (patch)
tree172474cfe9a1053a3ecaca1f2d23b0b83d70e8a5
parent4d8524d7674f362ccdb4cee8500822c3fde28d48 (diff)
downloadcal-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-xcal-notif29
1 files changed, 28 insertions, 1 deletions
diff --git a/cal-notif b/cal-notif
index 3726200..d577d13 100755
--- a/cal-notif
+++ b/cal-notif
@@ -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__":