Compare commits

..

2 Commits

Author SHA1 Message Date
e82151daa0 Add optional sound effects for state changes
- Warning: soft double-beep
- Critical: urgent descending tone
- Notify: gentle ping
- Recovery: happy ascending chirp

Enable with ?sound=on or tap screen to activate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:45:23 -06:00
8d609db90e Improve emote personality system
- Replace dreamy emote with content face ( ˙▿˙)
- Make blinks brief (1-2 seconds) instead of lasting full rotation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:42:34 -06:00
2 changed files with 107 additions and 7 deletions

View File

@@ -23,7 +23,7 @@ CELEBRATION_DURATION = 5 # Seconds to show celebration after recovery
# Emote variations with paired animations # Emote variations with paired animations
OPTIMAL_EMOTES = [ OPTIMAL_EMOTES = [
("( ^_^)", "breathing"), # calm, content ("( ^_^)", "breathing"), # calm, content
("( ᵔᴥᵔ)", "floating"), # dreamy ("( ˙▿˙)", "floating"), # content
("(◕‿◕)", "bouncing"), # cheerful ("(◕‿◕)", "bouncing"), # cheerful
("( ・ω・)", "swaying"), # curious ("( ・ω・)", "swaying"), # curious
("( ˘▽˘)", "breathing"), # cozy ("( ˘▽˘)", "breathing"), # cozy
@@ -50,6 +50,9 @@ active_events = {} # id -> {priority, message, timestamp, ttl}
# State tracking for personality # State tracking for personality
previous_priority = 4 previous_priority = 4
celebrating_until = 0 celebrating_until = 0
blinking_until = 0
blink_emote = None
blink_animation = None
last_emote_change = 0 last_emote_change = 0
current_optimal_emote = OPTIMAL_EMOTES[0][0] current_optimal_emote = OPTIMAL_EMOTES[0][0]
current_optimal_animation = OPTIMAL_EMOTES[0][1] current_optimal_animation = OPTIMAL_EMOTES[0][1]
@@ -63,7 +66,8 @@ SLEEP_ANIMATION = "sleeping"
def get_current_state(): def get_current_state():
"""Determine current state based on active events.""" """Determine current state based on active events."""
global previous_priority, celebrating_until, last_emote_change, current_optimal_emote, current_optimal_animation global previous_priority, celebrating_until, blinking_until, blink_emote, blink_animation
global last_emote_change, current_optimal_emote, current_optimal_animation
# Sleep mode overrides everything # Sleep mode overrides everything
if is_sleeping: if is_sleeping:
@@ -107,15 +111,19 @@ def get_current_state():
if now < celebrating_until: if now < celebrating_until:
# Celebration mode # Celebration mode
emote, animation = CELEBRATION_EMOTE emote, animation = CELEBRATION_EMOTE
elif now < blinking_until:
# Brief blink/wink (1-2 seconds)
emote = blink_emote
animation = blink_animation
else: else:
# Rotate optimal emotes every 5 minutes, occasional idle expression # Rotate optimal emotes every 5 minutes
if now - last_emote_change > 300: if now - last_emote_change > 300:
last_emote_change = now last_emote_change = now
# 15% chance of an idle expression (wink/blink) current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES)
# 15% chance of a brief blink/wink
if random.random() < 0.15: if random.random() < 0.15:
current_optimal_emote, current_optimal_animation = random.choice(IDLE_EMOTES) blink_emote, blink_animation = random.choice(IDLE_EMOTES)
else: blinking_until = now + random.uniform(1, 2)
current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES)
emote = current_optimal_emote emote = current_optimal_emote
animation = current_optimal_animation animation = current_optimal_animation

View File

@@ -199,6 +199,95 @@
const messageEl = document.getElementById('message'); const messageEl = document.getElementById('message');
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 2000;
// Sound system
let audioCtx = null;
let soundEnabled = new URLSearchParams(window.location.search).get('sound') === 'on';
let lastState = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(frequency, duration, type = 'sine', volume = 0.1) {
if (!soundEnabled || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.value = frequency;
gain.gain.value = volume;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playWarningSound() {
// Soft double-beep
playTone(440, 0.15);
setTimeout(() => playTone(440, 0.15), 180);
}
function playCriticalSound() {
// Urgent descending tone
playTone(600, 0.2);
setTimeout(() => playTone(400, 0.3), 220);
}
function playNotifySound() {
// Gentle ping
playTone(880, 0.1, 'sine', 0.08);
}
function playRecoverySound() {
// Happy ascending chirp
playTone(523, 0.1);
setTimeout(() => playTone(659, 0.1), 100);
setTimeout(() => playTone(784, 0.15), 200);
}
function handleStateChange(newState, newEmote) {
if (!lastState) {
lastState = newState;
return;
}
// State transitions that trigger sounds
if (newState !== lastState) {
if (newState === 'critical') {
playCriticalSound();
} else if (newState === 'warning') {
playWarningSound();
} else if (newState === 'notify') {
playNotifySound();
} else if (newState === 'optimal' && lastState !== 'optimal' && lastState !== 'sleeping') {
// Recovery - also check for celebration emote
playRecoverySound();
}
lastState = newState;
}
}
// Enable sound on first tap (browser autoplay policy)
document.body.addEventListener('click', () => {
if (!soundEnabled) {
soundEnabled = true;
initAudio();
// Brief confirmation chirp
playTone(660, 0.08, 'sine', 0.05);
}
}, { once: false });
// Also init if ?sound=on
if (soundEnabled) {
document.addEventListener('DOMContentLoaded', initAudio);
}
async function fetchStatus() { async function fetchStatus() {
try { try {
const response = await fetch('/status'); const response = await fetch('/status');
@@ -216,6 +305,9 @@
} }
function updateDisplay(data) { function updateDisplay(data) {
// Check for state changes and play sounds
handleStateChange(data.current_state, data.active_emote);
emoteEl.textContent = data.active_emote; emoteEl.textContent = data.active_emote;
emoteEl.style.color = data.color; emoteEl.style.color = data.color;
messageEl.style.color = data.color; messageEl.style.color = data.color;