diff --git a/CLAUDE.md b/CLAUDE.md index 8830928..9d11471 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, | `emote` | No | Custom emote to display | | `color` | No | Custom color (hex, e.g., `#FF9900`) | | `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` | -| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `none` | +| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none` | ## Priority System @@ -123,7 +123,6 @@ The optimal state face is set once per day on `/wake` (random pick). Each mornin | Emote | Animation | Vibe | |-------|-----------|------| | `( ^_^)` | breathing | calm | -| `( ˙▿˙)` | floating | content | | `(◕‿◕)` | bouncing | cheerful | | `( ・ω・)` | swaying | curious | | `( ˘▽˘)` | breathing | cozy | @@ -177,6 +176,7 @@ Use in automations: ├── kao.py # Unified entry point ├── aggregator.py # Event broker/API server ├── index.html # OLED-optimized frontend +├── kao_tui.py # Developer TUI for testing sounds/events ├── config.json # Runtime configuration ├── openapi.yaml # API documentation (OpenAPI 3.0) ├── detectors/ diff --git a/README.md b/README.md index e64c355..2d45b43 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ automation: - `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`) - `color`: Hex color (e.g., `#FF9900`) - `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` -- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `none` +- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none` ## Developer TUI @@ -206,7 +206,7 @@ python kao_tui.py http://192.168.1.x:5100 # custom URL ``` Four tabs: -- **Sounds** — fire any of the 17 sounds via `/notify` +- **Sounds** — fire any of the 18 sounds via `/notify` - **Faces** — send any preset emote/animation combo via `/notify` - **Events** — post Critical/Warning/Notify events (10s TTL) or clear all - **Controls** — Sleep, Wake, Clear all @@ -245,7 +245,7 @@ The emote has personality! In optimal state it: - Critical: urgent descending tone - Notify: gentle ping - Recovery: happy ascending chirp -- 10 additional synthesized sounds for `/notify`: `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm` (alarm loops until tapped) +- 11 additional synthesized sounds for `/notify`: `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon` (alarm and klaxon loop until tapped) ## License diff --git a/index.html b/index.html index 5ea3845..823c7f3 100644 --- a/index.html +++ b/index.html @@ -217,7 +217,7 @@ const emoteEl = document.getElementById("emote"); const messageEl = document.getElementById("message"); const POLL_INTERVAL = 2000; - const VERSION = "v2.1.0"; + const VERSION = "v2.2.0"; // Sound system let audioCtx = null; @@ -254,9 +254,9 @@ } function playWarningSound() { - // Soft double-beep - playTone(440, 0.15); - setTimeout(() => playTone(440, 0.15), 180); + // Two-tone warning beep - differentiated pitches + playTone(440, 0.2, "sine", 0.18); + setTimeout(() => playTone(550, 0.2, "sine", 0.18), 220); } function playCriticalSound() { @@ -334,8 +334,8 @@ playTone(1047, 0.15, "sine", 0.1); } function playBubbleSound() { - playTone(400, 0.06, "sine", 0.06); - setTimeout(() => playTone(600, 0.04, "sine", 0.04), 40); + playTone(400, 0.15, "sine", 0.10); + setTimeout(() => playTone(650, 0.12, "sine", 0.08), 100); } function playFanfareSound() { playTone(523, 0.1, "sine", 0.1); @@ -364,9 +364,28 @@ } } + // Klaxon — looping wah-wah horn, loops until manually stopped + let klaxonInterval = null; + function playKlaxonTick() { + playTone(500, 0.22, "sawtooth", 0.22); + setTimeout(() => playTone(380, 0.22, "sawtooth", 0.22), 230); + } + function startKlaxon() { + if (klaxonInterval) return; + playKlaxonTick(); + klaxonInterval = setInterval(playKlaxonTick, 470); + } + function stopKlaxon() { + if (klaxonInterval) { + clearInterval(klaxonInterval); + klaxonInterval = null; + } + } + // Play sound by name function playSoundByName(name) { - if (name === "alarm") { startAlarm(); return; } + if (name === "alarm") { startAlarm(); return; } + if (name === "klaxon") { startKlaxon(); return; } const sounds = { chime: playChimeSound, alert: playAlertSound, @@ -403,7 +422,8 @@ playSoundByName(customSound); lastCustomSound = customSound; } else if (!customSound) { - stopAlarm(); // stop any looping alarm + stopAlarm(); // stop any looping sounds + stopKlaxon(); lastCustomSound = null; } @@ -434,7 +454,8 @@ // Handle tap - enable sound and show reaction document.body.addEventListener("click", () => { - stopAlarm(); // stop any looping alarm + stopAlarm(); // stop any looping sounds + stopKlaxon(); // Enable sound on first tap (browser autoplay policy) if (!soundEnabled) { soundEnabled = true; diff --git a/kao_tui.py b/kao_tui.py index 1f2b44d..3efcbdd 100644 --- a/kao_tui.py +++ b/kao_tui.py @@ -11,7 +11,7 @@ BASE_URL = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:5 SOUNDS = [ "chime", "alert", "warning", "critical", "success", "notify", "doorbell", "knock", "ding", "blip", "siren", "tada", - "ping", "bubble", "fanfare", "alarm", "none", + "ping", "bubble", "fanfare", "alarm", "klaxon", "none", ] FACES = [ @@ -43,12 +43,12 @@ CONTROLS = [ ] -def post(path: str, data: dict | None = None) -> bool: +def post(path: str, data: dict | None = None) -> tuple[bool, str]: try: resp = requests.post(f"{BASE_URL}/{path}", json=data or {}, timeout=5) - return resp.ok - except Exception: - return False + return resp.ok, "" if resp.ok else f"HTTP {resp.status_code}: {resp.text[:80]}" + except Exception as e: + return False, str(e) class KaoTUI(App): @@ -95,37 +95,37 @@ class KaoTUI(App): if item_id.startswith("sound-"): name = item_id[len("sound-"):] - ok = post("notify", {"message": f"sound: {name}", "sound": name, "duration": 5}) - self.notify(f"♪ {name} {'sent' if ok else 'FAILED'}", severity="information" if ok else "error") + ok, err = post("notify", {"message": f"sound: {name}", "sound": name, "duration": 2}) + self.notify(f"♪ {name} sent" if ok else f"♪ {name} FAILED: {err}", severity="information" if ok else "error") elif item_id.startswith("face-"): desc = item_id[len("face-"):] face = next((f for f in FACES if f["desc"] == desc), None) if face: - ok = post("notify", { + ok, err = post("notify", { "emote": face["emote"], "animation": face["animation"], "color": face["color"], "message": f"face: {face['desc']}", - "duration": 5, + "duration": 2, }) - self.notify(f"{face['emote']} {face['desc']} {'sent' if ok else 'FAILED'}", severity="information" if ok else "error") + self.notify(f"{face['emote']} {face['desc']} sent" if ok else f"{face['desc']} FAILED: {err}", severity="information" if ok else "error") elif item_id.startswith("event-"): suffix = item_id[len("event-"):] if suffix == "clearall": - ok = post("clear-all") - self.notify(f"clear-all {'sent' if ok else 'FAILED'}", severity="information" if ok else "error") + ok, err = post("clear-all") + self.notify("clear-all sent" if ok else f"clear-all FAILED: {err}", severity="information" if ok else "error") else: ev = next((e for e in EVENTS if e["id"] == suffix), None) if ev: - ok = post("event", {"id": ev["id"], "priority": ev["priority"], "message": ev["message"], "ttl": 10}) - self.notify(f"{ev['label'].strip()} {'sent' if ok else 'FAILED'}", severity="information" if ok else "error") + ok, err = post("event", {"id": ev["id"], "priority": ev["priority"], "message": ev["message"], "ttl": 10}) + self.notify(f"{ev['label'].strip()} sent" if ok else f"{ev['label'].strip()} FAILED: {err}", severity="information" if ok else "error") elif item_id.startswith("ctrl-"): action = item_id[len("ctrl-"):] - ok = post(action) - self.notify(f"{action} {'sent' if ok else 'FAILED'}", severity="information" if ok else "error") + ok, err = post(action) + self.notify(f"{action} sent" if ok else f"{action} FAILED: {err}", severity="information" if ok else "error") if __name__ == "__main__": diff --git a/openapi.yaml b/openapi.yaml index 2426eba..8066b9d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14,7 +14,7 @@ info: ## TTL/Heartbeat Pattern Events can have a TTL (time-to-live) that auto-expires them. Detectors typically send heartbeat events that expire if not refreshed, indicating loss of communication. - version: 1.5.0 + version: 2.2.0 license: name: MIT @@ -323,6 +323,7 @@ components: - bubble - fanfare - alarm + - klaxon - none example: "chime"