Add spoken spatial clock announcements with top-of-hour mode

This commit is contained in:
Jage9
2026-02-27 01:05:23 -05:00
parent 2e532f5471
commit 4ed52649f1
47 changed files with 273 additions and 19 deletions

View File

@@ -11,8 +11,8 @@ from ....models import WorldItem
def use_item(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Read current clock time based on item configuration."""
display_time = clock_formatter(item.params)
_display_time = clock_formatter(item.params)
return ItemUseResult(
self_message=f"{item.title} says {display_time}.",
others_message=f"{nickname} checks {item.title}. {item.title} says {display_time}.",
self_message="",
others_message="",
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
LABEL = "clock"
TOOLTIP = "It tells the time. What did you think it did?"
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "timeZone", "use24Hour")
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "timeZone", "use24Hour", "topOfHourAnnounce")
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None
EMIT_SOUND = "sounds/clock.ogg"
@@ -55,11 +55,12 @@ TIME_ZONE_OPTIONS: tuple[str, ...] = (
"Pacific/Pago_Pago",
"UTC",
)
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False}
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False, "topOfHourAnnounce": True}
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour", "topOfHourAnnounce")
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time.", "options": list(TIME_ZONE_OPTIONS)},
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
"topOfHourAnnounce": {"valueType": "boolean", "tooltip": "Automatically announce time at the top of each hour."},
}

View File

@@ -16,6 +16,10 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict:
use_24_hour = parse_bool_like_or_none(next_params.get("use24Hour"))
if use_24_hour is None:
raise ValueError("use24Hour must be on/off.")
top_of_hour_announce = parse_bool_like_or_none(next_params.get("topOfHourAnnounce"))
if top_of_hour_announce is None:
raise ValueError("topOfHourAnnounce must be on/off.")
next_params["timeZone"] = time_zone
next_params["use24Hour"] = use_24_hour
next_params["topOfHourAnnounce"] = top_of_hour_announce
return keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -294,6 +294,14 @@ class ItemUseSoundPacket(BasePacket):
y: int
class ItemClockAnnouncePacket(BasePacket):
type: Literal["item_clock_announce"]
itemId: str
sounds: list[str]
x: int
y: int
class ItemPianoNoteBroadcastPacket(BasePacket):
type: Literal["item_piano_note"]
itemId: str

View File

@@ -60,6 +60,7 @@ from .models import (
ForwardSignalPacket,
ItemActionResultPacket,
ItemAddPacket,
ItemClockAnnouncePacket,
ItemDeletePacket,
ItemDropPacket,
ItemPianoNoteBroadcastPacket,
@@ -102,6 +103,7 @@ AUTH_FAILURE_JITTER_MIN_MS = 0.02
AUTH_FAILURE_JITTER_MAX_MS = 0.08
RADIO_METADATA_POLL_INTERVAL_S = 10.0
RADIO_METADATA_TIMEOUT_S = 6.0
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
class SignalingServer:
@@ -160,6 +162,8 @@ class SignalingServer:
self._auth_failures_by_ip: dict[str, deque[float]] = {}
self._auth_failures_by_identity: dict[str, deque[float]] = {}
self._radio_metadata_task: asyncio.Task[None] | None = None
self._clock_announce_task: asyncio.Task[None] | None = None
self._clock_top_of_hour_markers: dict[str, str] = {}
@staticmethod
def _resolve_server_version() -> str:
@@ -440,6 +444,98 @@ class SignalingServer:
except asyncio.CancelledError:
return
@classmethod
def _build_clock_announcement_sounds(cls, params: dict, *, top_of_hour: bool) -> list[str]:
"""Build ordered EL640 sample URLs for one clock announcement."""
tz_name = cls._normalize_clock_timezone(params.get("timeZone"))
use_24_hour = cls._parse_clock_use_24_hour(params.get("use24Hour")) is True
now = datetime.now(ZoneInfo(tz_name))
hour24 = now.hour
minute = now.minute
ampm = "AM" if hour24 < 12 else "PM"
hour12 = hour24 % 12 or 12
sounds: list[str] = []
if top_of_hour:
sounds.append("/sounds/clock/el640/hour1.ogg")
sounds.append("/sounds/clock/el640/its.ogg")
if use_24_hour:
if hour24 < 20:
sounds.append(f"/sounds/clock/el640/{hour24}.ogg")
else:
tens = (hour24 // 10) * 10
ones = hour24 % 10
sounds.append(f"/sounds/clock/el640/{tens}.ogg")
if ones != 0:
sounds.append(f"/sounds/clock/el640/{ones}.ogg")
else:
sounds.append(f"/sounds/clock/el640/{hour12}.ogg")
if minute > 0:
if minute < 10:
sounds.append("/sounds/clock/el640/o.ogg")
if minute < 20:
sounds.append(f"/sounds/clock/el640/{minute}.ogg")
else:
tens = (minute // 10) * 10
ones = minute % 10
sounds.append(f"/sounds/clock/el640/{tens}.ogg")
if ones != 0:
sounds.append(f"/sounds/clock/el640/{ones}.ogg")
if not use_24_hour:
sounds.append(f"/sounds/clock/el640/{ampm}.ogg")
if top_of_hour:
sounds.append("/sounds/clock/el640/hour2.ogg")
return sounds
async def _broadcast_clock_announcement(self, item: WorldItem, *, top_of_hour: bool) -> None:
"""Broadcast one server-authoritative clock speech sequence from item position."""
sound_x, sound_y = self._get_item_sound_source_position(item)
sounds = self._build_clock_announcement_sounds(item.params, top_of_hour=top_of_hour)
if not sounds:
return
await self._broadcast(
ItemClockAnnouncePacket(
type="item_clock_announce",
itemId=item.id,
sounds=sounds,
x=sound_x,
y=sound_y,
)
)
async def _run_clock_top_of_hour_loop(self) -> None:
"""Background polling loop that triggers top-of-hour speech for clock items."""
try:
while True:
valid_clock_ids = {item.id for item in self.items.values() if item.type == "clock"}
for stale_id in list(self._clock_top_of_hour_markers.keys()):
if stale_id not in valid_clock_ids:
self._clock_top_of_hour_markers.pop(stale_id, None)
for item in self.items.values():
if item.type != "clock":
continue
enabled = item.params.get("topOfHourAnnounce", True)
if enabled is not True:
continue
tz_name = self._normalize_clock_timezone(item.params.get("timeZone"))
now = datetime.now(ZoneInfo(tz_name))
if now.minute != 0 or now.second > 1:
continue
marker = now.strftime("%Y-%m-%d-%H")
if self._clock_top_of_hour_markers.get(item.id) == marker:
continue
self._clock_top_of_hour_markers[item.id] = marker
await self._broadcast_clock_announcement(item, top_of_hour=True)
await asyncio.sleep(CLOCK_ANNOUNCE_POLL_INTERVAL_S)
except asyncio.CancelledError:
return
def _get_item_sound_source_position(self, item: WorldItem) -> tuple[int, int]:
"""Resolve source position for item-emitted one-shot sounds."""
@@ -933,6 +1029,7 @@ class SignalingServer:
protocol = "wss" if self._ssl_context else "ws"
LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port)
self._radio_metadata_task = asyncio.create_task(self._run_radio_metadata_loop())
self._clock_announce_task = asyncio.create_task(self._run_clock_top_of_hour_loop())
try:
async with serve(
self._handle_client,
@@ -943,6 +1040,11 @@ class SignalingServer:
):
await asyncio.Future()
finally:
if self._clock_announce_task is not None:
self._clock_announce_task.cancel()
with suppress(asyncio.CancelledError):
await self._clock_announce_task
self._clock_announce_task = None
if self._radio_metadata_task is not None:
self._radio_metadata_task.cancel()
with suppress(asyncio.CancelledError):
@@ -1660,10 +1762,11 @@ class SignalingServer:
await self._broadcast_item(item)
self.item_last_use_ms[item.id] = now_ms
await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
exclude=client.websocket,
)
if use_result.others_message:
await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
exclude=client.websocket,
)
use_sound = self._resolve_item_use_sound(item)
if use_sound:
sound_x, sound_y = self._get_item_sound_source_position(item)
@@ -1676,6 +1779,8 @@ class SignalingServer:
y=sound_y,
)
)
if item.type == "clock":
await self._broadcast_clock_announcement(item, top_of_hour=False)
if item.type == "piano":
await self._send_piano_status(
client,
@@ -2087,3 +2192,4 @@ def run() -> None:
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
)
asyncio.run(server.start())
ItemClockAnnouncePacket,