diff --git a/client/public/sounds/clock/el640/0.ogg b/client/public/sounds/clock/el640/0.ogg new file mode 100644 index 0000000..636cb59 Binary files /dev/null and b/client/public/sounds/clock/el640/0.ogg differ diff --git a/client/public/sounds/clock/el640/1.ogg b/client/public/sounds/clock/el640/1.ogg new file mode 100644 index 0000000..50b3529 Binary files /dev/null and b/client/public/sounds/clock/el640/1.ogg differ diff --git a/client/public/sounds/clock/el640/10.ogg b/client/public/sounds/clock/el640/10.ogg new file mode 100644 index 0000000..862f32c Binary files /dev/null and b/client/public/sounds/clock/el640/10.ogg differ diff --git a/client/public/sounds/clock/el640/11.ogg b/client/public/sounds/clock/el640/11.ogg new file mode 100644 index 0000000..b1c2339 Binary files /dev/null and b/client/public/sounds/clock/el640/11.ogg differ diff --git a/client/public/sounds/clock/el640/12.ogg b/client/public/sounds/clock/el640/12.ogg new file mode 100644 index 0000000..293891c Binary files /dev/null and b/client/public/sounds/clock/el640/12.ogg differ diff --git a/client/public/sounds/clock/el640/13.ogg b/client/public/sounds/clock/el640/13.ogg new file mode 100644 index 0000000..eddae8f Binary files /dev/null and b/client/public/sounds/clock/el640/13.ogg differ diff --git a/client/public/sounds/clock/el640/14.ogg b/client/public/sounds/clock/el640/14.ogg new file mode 100644 index 0000000..fc65798 Binary files /dev/null and b/client/public/sounds/clock/el640/14.ogg differ diff --git a/client/public/sounds/clock/el640/15.ogg b/client/public/sounds/clock/el640/15.ogg new file mode 100644 index 0000000..779ea8e Binary files /dev/null and b/client/public/sounds/clock/el640/15.ogg differ diff --git a/client/public/sounds/clock/el640/16.ogg b/client/public/sounds/clock/el640/16.ogg new file mode 100644 index 0000000..c8434e2 Binary files /dev/null and b/client/public/sounds/clock/el640/16.ogg differ diff --git a/client/public/sounds/clock/el640/17.ogg b/client/public/sounds/clock/el640/17.ogg new file mode 100644 index 0000000..0a37b40 Binary files /dev/null and b/client/public/sounds/clock/el640/17.ogg differ diff --git a/client/public/sounds/clock/el640/18.ogg b/client/public/sounds/clock/el640/18.ogg new file mode 100644 index 0000000..c8f48f7 Binary files /dev/null and b/client/public/sounds/clock/el640/18.ogg differ diff --git a/client/public/sounds/clock/el640/19.ogg b/client/public/sounds/clock/el640/19.ogg new file mode 100644 index 0000000..9be8e6f Binary files /dev/null and b/client/public/sounds/clock/el640/19.ogg differ diff --git a/client/public/sounds/clock/el640/2.ogg b/client/public/sounds/clock/el640/2.ogg new file mode 100644 index 0000000..8b72374 Binary files /dev/null and b/client/public/sounds/clock/el640/2.ogg differ diff --git a/client/public/sounds/clock/el640/20.ogg b/client/public/sounds/clock/el640/20.ogg new file mode 100644 index 0000000..196fa09 Binary files /dev/null and b/client/public/sounds/clock/el640/20.ogg differ diff --git a/client/public/sounds/clock/el640/3.ogg b/client/public/sounds/clock/el640/3.ogg new file mode 100644 index 0000000..8b883f4 Binary files /dev/null and b/client/public/sounds/clock/el640/3.ogg differ diff --git a/client/public/sounds/clock/el640/30.ogg b/client/public/sounds/clock/el640/30.ogg new file mode 100644 index 0000000..c7d90b6 Binary files /dev/null and b/client/public/sounds/clock/el640/30.ogg differ diff --git a/client/public/sounds/clock/el640/4.ogg b/client/public/sounds/clock/el640/4.ogg new file mode 100644 index 0000000..e2408ad Binary files /dev/null and b/client/public/sounds/clock/el640/4.ogg differ diff --git a/client/public/sounds/clock/el640/40.ogg b/client/public/sounds/clock/el640/40.ogg new file mode 100644 index 0000000..2e89926 Binary files /dev/null and b/client/public/sounds/clock/el640/40.ogg differ diff --git a/client/public/sounds/clock/el640/5.ogg b/client/public/sounds/clock/el640/5.ogg new file mode 100644 index 0000000..af573f9 Binary files /dev/null and b/client/public/sounds/clock/el640/5.ogg differ diff --git a/client/public/sounds/clock/el640/50.ogg b/client/public/sounds/clock/el640/50.ogg new file mode 100644 index 0000000..bcb9434 Binary files /dev/null and b/client/public/sounds/clock/el640/50.ogg differ diff --git a/client/public/sounds/clock/el640/6.ogg b/client/public/sounds/clock/el640/6.ogg new file mode 100644 index 0000000..8be311f Binary files /dev/null and b/client/public/sounds/clock/el640/6.ogg differ diff --git a/client/public/sounds/clock/el640/7.ogg b/client/public/sounds/clock/el640/7.ogg new file mode 100644 index 0000000..d5b05d2 Binary files /dev/null and b/client/public/sounds/clock/el640/7.ogg differ diff --git a/client/public/sounds/clock/el640/8.ogg b/client/public/sounds/clock/el640/8.ogg new file mode 100644 index 0000000..674718f Binary files /dev/null and b/client/public/sounds/clock/el640/8.ogg differ diff --git a/client/public/sounds/clock/el640/9.ogg b/client/public/sounds/clock/el640/9.ogg new file mode 100644 index 0000000..d89164a Binary files /dev/null and b/client/public/sounds/clock/el640/9.ogg differ diff --git a/client/public/sounds/clock/el640/AM.ogg b/client/public/sounds/clock/el640/AM.ogg new file mode 100644 index 0000000..fc31b53 Binary files /dev/null and b/client/public/sounds/clock/el640/AM.ogg differ diff --git a/client/public/sounds/clock/el640/PM.ogg b/client/public/sounds/clock/el640/PM.ogg new file mode 100644 index 0000000..196a1f2 Binary files /dev/null and b/client/public/sounds/clock/el640/PM.ogg differ diff --git a/client/public/sounds/clock/el640/alarm.ogg b/client/public/sounds/clock/el640/alarm.ogg new file mode 100644 index 0000000..e44f222 Binary files /dev/null and b/client/public/sounds/clock/el640/alarm.ogg differ diff --git a/client/public/sounds/clock/el640/announcement.ogg b/client/public/sounds/clock/el640/announcement.ogg new file mode 100644 index 0000000..de08b13 Binary files /dev/null and b/client/public/sounds/clock/el640/announcement.ogg differ diff --git a/client/public/sounds/clock/el640/hour1.ogg b/client/public/sounds/clock/el640/hour1.ogg new file mode 100644 index 0000000..1c564b8 Binary files /dev/null and b/client/public/sounds/clock/el640/hour1.ogg differ diff --git a/client/public/sounds/clock/el640/hour2.ogg b/client/public/sounds/clock/el640/hour2.ogg new file mode 100644 index 0000000..231bff2 Binary files /dev/null and b/client/public/sounds/clock/el640/hour2.ogg differ diff --git a/client/public/sounds/clock/el640/its.ogg b/client/public/sounds/clock/el640/its.ogg new file mode 100644 index 0000000..84d07de Binary files /dev/null and b/client/public/sounds/clock/el640/its.ogg differ diff --git a/client/public/sounds/clock/el640/o.ogg b/client/public/sounds/clock/el640/o.ogg new file mode 100644 index 0000000..0830ba4 Binary files /dev/null and b/client/public/sounds/clock/el640/o.ogg differ diff --git a/client/public/version.js b/client/public/version.js index 94f4e3d..423285a 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.25 R268"; +window.CHGRID_WEB_VERSION = "2026.02.25 R269"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index a8b2b8b..6e02bf5 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -406,6 +406,60 @@ export class AudioEngine { } } + /** Plays one spatial sample and resolves when playback finishes. */ + async playSpatialSampleAndWait( + url: string, + sourcePosition: { x: number; y: number }, + playerPosition: { x: number; y: number }, + gain = 1, + ): Promise { + await this.ensureContext(); + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode) return; + + try { + const buffer = await this.getSampleBuffer(url); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + const gainNode = audioCtx.createGain(); + gainNode.gain.setValueAtTime(0, audioCtx.currentTime); + source.connect(gainNode); + let pannerNode: StereoPannerNode | null = null; + if (this.supportsStereoPanner() && this.outputMode === 'stereo') { + pannerNode = audioCtx.createStereoPanner(); + gainNode.connect(pannerNode).connect(sfxGainNode); + } else { + gainNode.connect(sfxGainNode); + } + const runtime: ActiveSpatialSampleRuntime = { + sourceX: sourcePosition.x, + sourceY: sourcePosition.y, + baseGain: gain, + gainNode, + pannerNode, + sourceNode: source, + }; + this.activeSpatialSamples.add(runtime); + this.applySpatialSampleRuntime(runtime, playerPosition, true); + await new Promise((resolve) => { + source.onended = () => { + this.activeSpatialSamples.delete(runtime); + try { + source.disconnect(); + } catch { + // Ignore stale graph disconnects. + } + gainNode.disconnect(); + pannerNode?.disconnect(); + resolve(); + }; + source.start(); + }); + } catch { + // Ignore sample decode/load errors. + } + } + async playSample(url: string, gain = 1, fadeInMs = 0): Promise { await this.ensureContext(); const { audioCtx, sfxGainNode } = this; diff --git a/client/src/audio/clockAnnouncer.ts b/client/src/audio/clockAnnouncer.ts new file mode 100644 index 0000000..2367196 --- /dev/null +++ b/client/src/audio/clockAnnouncer.ts @@ -0,0 +1,26 @@ +import { AudioEngine } from './audioEngine'; + +type ListenerPositionGetter = () => { x: number; y: number }; + +/** + * Plays server-provided clock speech sequences as spatial one-shots. + */ +export class ClockAnnouncer { + private playToken = 0; + + constructor( + private readonly audio: AudioEngine, + private readonly getListenerPosition: ListenerPositionGetter, + ) {} + + async playSequence(sounds: string[], sourceX: number, sourceY: number): Promise { + if (sounds.length === 0) return; + const token = ++this.playToken; + for (const sound of sounds) { + if (token !== this.playToken) return; + const listener = this.getListenerPosition(); + await this.audio.playSpatialSampleAndWait(sound, { x: sourceX, y: sourceY }, listener, 1); + } + } +} + diff --git a/client/src/main.ts b/client/src/main.ts index 5184dd3..91263e5 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -9,6 +9,7 @@ import { shouldProxyStreamUrl, } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; +import { ClockAnnouncer } from './audio/clockAnnouncer'; import { normalizeDegrees } from './audio/spatial'; import { applyPastedText, @@ -243,6 +244,7 @@ const messageBuffer: string[] = []; let messageCursor = -1; const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig); const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig); +const clockAnnouncer = new ClockAnnouncer(audio, () => ({ x: state.player.x, y: state.player.y })); let internalClipboardText = ''; let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; @@ -1658,6 +1660,9 @@ const onAppMessage = createOnMessageHandler({ playIncomingItemUseSound: (url, x, y) => { void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1); }, + playClockAnnouncement: (sounds, x, y) => { + void clockAnnouncer.playSequence(sounds.map(resolveIncomingSoundUrl), x, y); + }, handleAuthRequired, handleAuthResult, isPeerNegotiationReady: () => peerNegotiationReady, diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 800d78c..f4079ed 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -69,6 +69,7 @@ type MessageHandlerDeps = { playLocateToneAt: (x: number, y: number) => void; resolveIncomingSoundUrl: (url: string) => string; playIncomingItemUseSound: (url: string, x: number, y: number) => void; + playClockAnnouncement: (sounds: string[], x: number, y: number) => void; handleAuthRequired: (message: Extract) => void; handleAuthResult: (message: Extract) => Promise; isPeerNegotiationReady: () => boolean; @@ -267,19 +268,26 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco if (handledByItemBehavior) { break; } + const text = message.message.trim(); if (message.ok) { if (message.action === 'use' || message.action === 'secondary_use') { - deps.pushChatMessage(message.message); + if (text) { + deps.pushChatMessage(text); + } const item = message.itemId ? deps.getItemById(message.itemId) : null; if (message.action === 'use' && !item?.useSound && item && item.type !== 'piano') { deps.playLocateToneAt(item.x, item.y); } } else if (message.action !== 'update') { - deps.pushChatMessage(message.message); + if (text) { + deps.pushChatMessage(text); + } deps.audioUiConfirm(); } } else { - deps.pushChatMessage(message.message); + if (text) { + deps.pushChatMessage(text); + } deps.audioUiCancel(); } break; @@ -300,6 +308,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco break; } + case 'item_clock_announce': { + if (!deps.getAudioLayers().world) break; + deps.playClockAnnouncement(message.sounds, message.x, message.y); + break; + } + case 'item_piano_status': { deps.handlePianoStatus(message); break; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index b432f1e..0d6003a 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -215,6 +215,14 @@ export const itemUseSoundSchema = z.object({ y: z.number().int(), }); +export const itemClockAnnounceSchema = z.object({ + type: z.literal('item_clock_announce'), + itemId: z.string(), + sounds: z.array(z.string()), + x: z.number().int(), + y: z.number().int(), +}); + export const itemPianoNoteSchema = z.object({ type: z.literal('item_piano_note'), itemId: z.string(), @@ -265,6 +273,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [ itemRemoveSchema, itemActionResultSchema, itemUseSoundSchema, + itemClockAnnounceSchema, itemPianoNoteSchema, itemPianoStatusSchema, ]); diff --git a/docs/item-schema.md b/docs/item-schema.md index 234260f..94d68ce 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -117,7 +117,8 @@ ```json { "timeZone": "America/Detroit", - "use24Hour": false + "use24Hour": false, + "topOfHourAnnounce": true } ``` @@ -132,7 +133,9 @@ `Europe/London`, `Europe/Moscow`, `Pacific/Apia`, `Pacific/Auckland`, `Pacific/Chatham`, `Pacific/Honolulu`, `Pacific/Kiritimati`, `Pacific/Noumea`, `Pacific/Pago_Pago`, `UTC`. - `use24Hour`: boolean (or `on/off` in updates), default `false`. +- `topOfHourAnnounce`: boolean (or `on/off` in updates), default `true`. - Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`. +- Clock speech announcement audio is emitted via `item_clock_announce` packets using `/sounds/clock/el640/*.ogg`. ### `widget` @@ -241,6 +244,18 @@ } ``` +- `item_clock_announce`: + +```json +{ + "type": "item_clock_announce", + "itemId": "item-id", + "sounds": ["/sounds/clock/el640/its.ogg", "/sounds/clock/el640/2.ogg", "/sounds/clock/el640/PM.ogg"], + "x": 12, + "y": 8 +} +``` + - `item_piano_note`: ```json diff --git a/docs/item-types.md b/docs/item-types.md index 9725b4c..43087d5 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -101,6 +101,7 @@ This is behavior-focused documentation for item types and their defaults. - Params: - `timeZone="America/Detroit"` - `use24Hour=false` + - `topOfHourAnnounce=true` - Global: - `useSound=none` - `emitSound=sounds/clock.ogg` @@ -109,11 +110,17 @@ This is behavior-focused documentation for item types and their defaults. - `directional=false` ### Use -- Reports current time from item timezone and format. +- Broadcasts a spoken EL640-style time announcement as spatial audio from the clock position. +- No text chat line is emitted for clock `use`. ### Validation - `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py` - `use24Hour`: boolean or on/off style input +- `topOfHourAnnounce`: boolean or on/off style input + +### Audio +- Spoken clock assets live under `client/public/sounds/clock/el640/`. +- Top-of-hour routine (when enabled) uses `hour1.ogg` + time phrase + `hour2.ogg`. ## `widget` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 8783fd4..0cbcf3a 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -39,6 +39,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_remove`: item deletion. - `item_action_result`: action success/failure and user-facing message. - `item_use_sound`: spatial one-shot sound on successful item use (if `useSound` configured). +- `item_clock_announce`: ordered list of clock speech samples to play sequentially as spatial audio. - `item_piano_note`: broadcast piano note on/off with resolved instrument/envelope/spatial params. - `item_piano_status`: structured piano mode/record/playback state events for client runtime control. @@ -50,6 +51,11 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_piano_status` carries machine-readable piano events (`use_mode_entered`, record/playback transitions). - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. - For carried items, source coordinates resolve to the carrier's current position. +- `item_clock_announce` contains: + - `itemId` + - `sounds`: ordered sample URLs (EL640 phrase parts) + - absolute source coordinates `x`, `y` + - generated by server for manual clock `use` and top-of-hour auto announce (when enabled) - `teleport_complete` contains absolute player world coordinates (`x`, `y`) at teleport landing. - Radio metadata (`params.stationName`, `params.nowPlaying`) is server-managed and delivered through normal `item_upsert` updates. - `item_piano_note` contains: diff --git a/server/app/items/types/clock/actions.py b/server/app/items/types/clock/actions.py index 8296a13..75207b5 100644 --- a/server/app/items/types/clock/actions.py +++ b/server/app/items/types/clock/actions.py @@ -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="", ) diff --git a/server/app/items/types/clock/definition.py b/server/app/items/types/clock/definition.py index 2e30c2d..c848d45 100644 --- a/server/app/items/types/clock/definition.py +++ b/server/app/items/types/clock/definition.py @@ -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."}, } diff --git a/server/app/items/types/clock/validator.py b/server/app/items/types/clock/validator.py index 08269c2..ae628c0 100644 --- a/server/app/items/types/clock/validator.py +++ b/server/app/items/types/clock/validator.py @@ -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) diff --git a/server/app/models.py b/server/app/models.py index aa26609..1967990 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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 diff --git a/server/app/server.py b/server/app/server.py index a6c8ec6..7ddc310 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -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, diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 190ac5d..546761d 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -240,13 +240,12 @@ async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pyte monkeypatch.setattr(server, "_send", fake_send) monkeypatch.setattr(server, "_broadcast", fake_broadcast) monkeypatch.setattr(server.item_service, "now_ms", lambda: 30_000) - monkeypatch.setattr(server, "_format_clock_display_time", lambda _params: "2:15 PM") - await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) assert send_payloads[-1].ok is True - assert send_payloads[-1].message == f"{item.title} says 2:15 PM." + assert send_payloads[-1].message == "" assert not any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads) + assert any(getattr(packet, "type", "") == "item_clock_announce" for packet in broadcast_payloads) @pytest.mark.asyncio