Add spoken spatial clock announcements with top-of-hour mode
This commit is contained in:
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.
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// 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.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -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> {
|
async playSample(url: string, gain = 1, fadeInMs = 0): Promise<void> {
|
||||||
await this.ensureContext();
|
await this.ensureContext();
|
||||||
const { audioCtx, sfxGainNode } = this;
|
const { audioCtx, sfxGainNode } = this;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
shouldProxyStreamUrl,
|
shouldProxyStreamUrl,
|
||||||
} from './audio/radioStationRuntime';
|
} from './audio/radioStationRuntime';
|
||||||
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
||||||
|
import { ClockAnnouncer } from './audio/clockAnnouncer';
|
||||||
import { normalizeDegrees } from './audio/spatial';
|
import { normalizeDegrees } from './audio/spatial';
|
||||||
import {
|
import {
|
||||||
applyPastedText,
|
applyPastedText,
|
||||||
@@ -243,6 +244,7 @@ const messageBuffer: string[] = [];
|
|||||||
let messageCursor = -1;
|
let messageCursor = -1;
|
||||||
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
||||||
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig);
|
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig);
|
||||||
|
const clockAnnouncer = new ClockAnnouncer(audio, () => ({ x: state.player.x, y: state.player.y }));
|
||||||
let internalClipboardText = '';
|
let internalClipboardText = '';
|
||||||
let replaceTextOnNextType = false;
|
let replaceTextOnNextType = false;
|
||||||
let pendingEscapeDisconnect = false;
|
let pendingEscapeDisconnect = false;
|
||||||
@@ -1658,6 +1660,9 @@ const onAppMessage = createOnMessageHandler({
|
|||||||
playIncomingItemUseSound: (url, x, y) => {
|
playIncomingItemUseSound: (url, x, y) => {
|
||||||
void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1);
|
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,
|
handleAuthRequired,
|
||||||
handleAuthResult,
|
handleAuthResult,
|
||||||
isPeerNegotiationReady: () => peerNegotiationReady,
|
isPeerNegotiationReady: () => peerNegotiationReady,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type MessageHandlerDeps = {
|
|||||||
playLocateToneAt: (x: number, y: number) => void;
|
playLocateToneAt: (x: number, y: number) => void;
|
||||||
resolveIncomingSoundUrl: (url: string) => string;
|
resolveIncomingSoundUrl: (url: string) => string;
|
||||||
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
||||||
|
playClockAnnouncement: (sounds: string[], x: number, y: number) => void;
|
||||||
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
|
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
|
||||||
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
||||||
isPeerNegotiationReady: () => boolean;
|
isPeerNegotiationReady: () => boolean;
|
||||||
@@ -267,19 +268,26 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
if (handledByItemBehavior) {
|
if (handledByItemBehavior) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const text = message.message.trim();
|
||||||
if (message.ok) {
|
if (message.ok) {
|
||||||
if (message.action === 'use' || message.action === 'secondary_use') {
|
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;
|
const item = message.itemId ? deps.getItemById(message.itemId) : null;
|
||||||
if (message.action === 'use' && !item?.useSound && item && item.type !== 'piano') {
|
if (message.action === 'use' && !item?.useSound && item && item.type !== 'piano') {
|
||||||
deps.playLocateToneAt(item.x, item.y);
|
deps.playLocateToneAt(item.x, item.y);
|
||||||
}
|
}
|
||||||
} else if (message.action !== 'update') {
|
} else if (message.action !== 'update') {
|
||||||
deps.pushChatMessage(message.message);
|
if (text) {
|
||||||
|
deps.pushChatMessage(text);
|
||||||
|
}
|
||||||
deps.audioUiConfirm();
|
deps.audioUiConfirm();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deps.pushChatMessage(message.message);
|
if (text) {
|
||||||
|
deps.pushChatMessage(text);
|
||||||
|
}
|
||||||
deps.audioUiCancel();
|
deps.audioUiCancel();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -300,6 +308,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'item_clock_announce': {
|
||||||
|
if (!deps.getAudioLayers().world) break;
|
||||||
|
deps.playClockAnnouncement(message.sounds, message.x, message.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'item_piano_status': {
|
case 'item_piano_status': {
|
||||||
deps.handlePianoStatus(message);
|
deps.handlePianoStatus(message);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ export const itemUseSoundSchema = z.object({
|
|||||||
y: z.number().int(),
|
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({
|
export const itemPianoNoteSchema = z.object({
|
||||||
type: z.literal('item_piano_note'),
|
type: z.literal('item_piano_note'),
|
||||||
itemId: z.string(),
|
itemId: z.string(),
|
||||||
@@ -265,6 +273,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
|
|||||||
itemRemoveSchema,
|
itemRemoveSchema,
|
||||||
itemActionResultSchema,
|
itemActionResultSchema,
|
||||||
itemUseSoundSchema,
|
itemUseSoundSchema,
|
||||||
|
itemClockAnnounceSchema,
|
||||||
itemPianoNoteSchema,
|
itemPianoNoteSchema,
|
||||||
itemPianoStatusSchema,
|
itemPianoStatusSchema,
|
||||||
]);
|
]);
|
||||||
|
|||||||
+16
-1
@@ -117,7 +117,8 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timeZone": "America/Detroit",
|
"timeZone": "America/Detroit",
|
||||||
"use24Hour": false
|
"use24Hour": false,
|
||||||
|
"topOfHourAnnounce": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -132,7 +133,9 @@
|
|||||||
`Europe/London`, `Europe/Moscow`, `Pacific/Apia`, `Pacific/Auckland`, `Pacific/Chatham`,
|
`Europe/London`, `Europe/Moscow`, `Pacific/Apia`, `Pacific/Auckland`, `Pacific/Chatham`,
|
||||||
`Pacific/Honolulu`, `Pacific/Kiritimati`, `Pacific/Noumea`, `Pacific/Pago_Pago`, `UTC`.
|
`Pacific/Honolulu`, `Pacific/Kiritimati`, `Pacific/Noumea`, `Pacific/Pago_Pago`, `UTC`.
|
||||||
- `use24Hour`: boolean (or `on/off` in updates), default `false`.
|
- `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`.
|
- 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`
|
### `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`:
|
- `item_piano_note`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+8
-1
@@ -101,6 +101,7 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- Params:
|
- Params:
|
||||||
- `timeZone="America/Detroit"`
|
- `timeZone="America/Detroit"`
|
||||||
- `use24Hour=false`
|
- `use24Hour=false`
|
||||||
|
- `topOfHourAnnounce=true`
|
||||||
- Global:
|
- Global:
|
||||||
- `useSound=none`
|
- `useSound=none`
|
||||||
- `emitSound=sounds/clock.ogg`
|
- `emitSound=sounds/clock.ogg`
|
||||||
@@ -109,11 +110,17 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `directional=false`
|
- `directional=false`
|
||||||
|
|
||||||
### Use
|
### 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
|
### Validation
|
||||||
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
||||||
- `use24Hour`: boolean or on/off style input
|
- `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`
|
## `widget`
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `item_remove`: item deletion.
|
- `item_remove`: item deletion.
|
||||||
- `item_action_result`: action success/failure and user-facing message.
|
- `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_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_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.
|
- `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_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.
|
- `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.
|
- 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.
|
- `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.
|
- Radio metadata (`params.stationName`, `params.nowPlaying`) is server-managed and delivered through normal `item_upsert` updates.
|
||||||
- `item_piano_note` contains:
|
- `item_piano_note` contains:
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ from ....models import WorldItem
|
|||||||
def use_item(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
def use_item(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
"""Read current clock time based on item configuration."""
|
"""Read current clock time based on item configuration."""
|
||||||
|
|
||||||
display_time = clock_formatter(item.params)
|
_display_time = clock_formatter(item.params)
|
||||||
return ItemUseResult(
|
return ItemUseResult(
|
||||||
self_message=f"{item.title} says {display_time}.",
|
self_message="",
|
||||||
others_message=f"{nickname} checks {item.title}. {item.title} says {display_time}.",
|
others_message="",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
LABEL = "clock"
|
LABEL = "clock"
|
||||||
TOOLTIP = "It tells the time. What did you think it did?"
|
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")
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
USE_SOUND: str | None = None
|
USE_SOUND: str | None = None
|
||||||
EMIT_SOUND = "sounds/clock.ogg"
|
EMIT_SOUND = "sounds/clock.ogg"
|
||||||
@@ -55,11 +55,12 @@ TIME_ZONE_OPTIONS: tuple[str, ...] = (
|
|||||||
"Pacific/Pago_Pago",
|
"Pacific/Pago_Pago",
|
||||||
"UTC",
|
"UTC",
|
||||||
)
|
)
|
||||||
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False}
|
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False, "topOfHourAnnounce": True}
|
||||||
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
|
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour", "topOfHourAnnounce")
|
||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"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)},
|
"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."},
|
"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."},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
use_24_hour = parse_bool_like_or_none(next_params.get("use24Hour"))
|
||||||
if use_24_hour is None:
|
if use_24_hour is None:
|
||||||
raise ValueError("use24Hour must be on/off.")
|
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["timeZone"] = time_zone
|
||||||
next_params["use24Hour"] = use_24_hour
|
next_params["use24Hour"] = use_24_hour
|
||||||
|
next_params["topOfHourAnnounce"] = top_of_hour_announce
|
||||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||||
|
|||||||
@@ -294,6 +294,14 @@ class ItemUseSoundPacket(BasePacket):
|
|||||||
y: int
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class ItemClockAnnouncePacket(BasePacket):
|
||||||
|
type: Literal["item_clock_announce"]
|
||||||
|
itemId: str
|
||||||
|
sounds: list[str]
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
class ItemPianoNoteBroadcastPacket(BasePacket):
|
class ItemPianoNoteBroadcastPacket(BasePacket):
|
||||||
type: Literal["item_piano_note"]
|
type: Literal["item_piano_note"]
|
||||||
itemId: str
|
itemId: str
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from .models import (
|
|||||||
ForwardSignalPacket,
|
ForwardSignalPacket,
|
||||||
ItemActionResultPacket,
|
ItemActionResultPacket,
|
||||||
ItemAddPacket,
|
ItemAddPacket,
|
||||||
|
ItemClockAnnouncePacket,
|
||||||
ItemDeletePacket,
|
ItemDeletePacket,
|
||||||
ItemDropPacket,
|
ItemDropPacket,
|
||||||
ItemPianoNoteBroadcastPacket,
|
ItemPianoNoteBroadcastPacket,
|
||||||
@@ -102,6 +103,7 @@ AUTH_FAILURE_JITTER_MIN_MS = 0.02
|
|||||||
AUTH_FAILURE_JITTER_MAX_MS = 0.08
|
AUTH_FAILURE_JITTER_MAX_MS = 0.08
|
||||||
RADIO_METADATA_POLL_INTERVAL_S = 10.0
|
RADIO_METADATA_POLL_INTERVAL_S = 10.0
|
||||||
RADIO_METADATA_TIMEOUT_S = 6.0
|
RADIO_METADATA_TIMEOUT_S = 6.0
|
||||||
|
CLOCK_ANNOUNCE_POLL_INTERVAL_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
class SignalingServer:
|
class SignalingServer:
|
||||||
@@ -160,6 +162,8 @@ class SignalingServer:
|
|||||||
self._auth_failures_by_ip: dict[str, deque[float]] = {}
|
self._auth_failures_by_ip: dict[str, deque[float]] = {}
|
||||||
self._auth_failures_by_identity: dict[str, deque[float]] = {}
|
self._auth_failures_by_identity: dict[str, deque[float]] = {}
|
||||||
self._radio_metadata_task: asyncio.Task[None] | None = None
|
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
|
@staticmethod
|
||||||
def _resolve_server_version() -> str:
|
def _resolve_server_version() -> str:
|
||||||
@@ -440,6 +444,98 @@ class SignalingServer:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
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]:
|
def _get_item_sound_source_position(self, item: WorldItem) -> tuple[int, int]:
|
||||||
"""Resolve source position for item-emitted one-shot sounds."""
|
"""Resolve source position for item-emitted one-shot sounds."""
|
||||||
|
|
||||||
@@ -933,6 +1029,7 @@ class SignalingServer:
|
|||||||
protocol = "wss" if self._ssl_context else "ws"
|
protocol = "wss" if self._ssl_context else "ws"
|
||||||
LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port)
|
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._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:
|
try:
|
||||||
async with serve(
|
async with serve(
|
||||||
self._handle_client,
|
self._handle_client,
|
||||||
@@ -943,6 +1040,11 @@ class SignalingServer:
|
|||||||
):
|
):
|
||||||
await asyncio.Future()
|
await asyncio.Future()
|
||||||
finally:
|
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:
|
if self._radio_metadata_task is not None:
|
||||||
self._radio_metadata_task.cancel()
|
self._radio_metadata_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
@@ -1660,6 +1762,7 @@ class SignalingServer:
|
|||||||
await self._broadcast_item(item)
|
await self._broadcast_item(item)
|
||||||
|
|
||||||
self.item_last_use_ms[item.id] = now_ms
|
self.item_last_use_ms[item.id] = now_ms
|
||||||
|
if use_result.others_message:
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
||||||
exclude=client.websocket,
|
exclude=client.websocket,
|
||||||
@@ -1676,6 +1779,8 @@ class SignalingServer:
|
|||||||
y=sound_y,
|
y=sound_y,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if item.type == "clock":
|
||||||
|
await self._broadcast_clock_announcement(item, top_of_hour=False)
|
||||||
if item.type == "piano":
|
if item.type == "piano":
|
||||||
await self._send_piano_status(
|
await self._send_piano_status(
|
||||||
client,
|
client,
|
||||||
@@ -2087,3 +2192,4 @@ def run() -> None:
|
|||||||
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
|
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
|
||||||
)
|
)
|
||||||
asyncio.run(server.start())
|
asyncio.run(server.start())
|
||||||
|
ItemClockAnnouncePacket,
|
||||||
|
|||||||
@@ -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, "_send", fake_send)
|
||||||
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
||||||
monkeypatch.setattr(server.item_service, "now_ms", lambda: 30_000)
|
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}))
|
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
||||||
|
|
||||||
assert send_payloads[-1].ok is True
|
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 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user