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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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";

View File

@@ -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<void> {
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<void>((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<void> {
await this.ensureContext();
const { audioCtx, sfxGainNode } = this;

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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,

View File

@@ -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<IncomingMessage, { type: 'auth_required' }>) => void;
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
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;

View File

@@ -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,
]);

View File

@@ -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

View File

@@ -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`

View File

@@ -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:

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,

View File

@@ -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