Add spoken spatial clock announcements with top-of-hour mode
This commit is contained in:
BIN
client/public/sounds/clock/el640/0.ogg
Normal file
BIN
client/public/sounds/clock/el640/0.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/1.ogg
Normal file
BIN
client/public/sounds/clock/el640/1.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/10.ogg
Normal file
BIN
client/public/sounds/clock/el640/10.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/11.ogg
Normal file
BIN
client/public/sounds/clock/el640/11.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/12.ogg
Normal file
BIN
client/public/sounds/clock/el640/12.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/13.ogg
Normal file
BIN
client/public/sounds/clock/el640/13.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/14.ogg
Normal file
BIN
client/public/sounds/clock/el640/14.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/15.ogg
Normal file
BIN
client/public/sounds/clock/el640/15.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/16.ogg
Normal file
BIN
client/public/sounds/clock/el640/16.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/17.ogg
Normal file
BIN
client/public/sounds/clock/el640/17.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/18.ogg
Normal file
BIN
client/public/sounds/clock/el640/18.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/19.ogg
Normal file
BIN
client/public/sounds/clock/el640/19.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/2.ogg
Normal file
BIN
client/public/sounds/clock/el640/2.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/20.ogg
Normal file
BIN
client/public/sounds/clock/el640/20.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/3.ogg
Normal file
BIN
client/public/sounds/clock/el640/3.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/30.ogg
Normal file
BIN
client/public/sounds/clock/el640/30.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/4.ogg
Normal file
BIN
client/public/sounds/clock/el640/4.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/40.ogg
Normal file
BIN
client/public/sounds/clock/el640/40.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/5.ogg
Normal file
BIN
client/public/sounds/clock/el640/5.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/50.ogg
Normal file
BIN
client/public/sounds/clock/el640/50.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/6.ogg
Normal file
BIN
client/public/sounds/clock/el640/6.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/7.ogg
Normal file
BIN
client/public/sounds/clock/el640/7.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/8.ogg
Normal file
BIN
client/public/sounds/clock/el640/8.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/9.ogg
Normal file
BIN
client/public/sounds/clock/el640/9.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/AM.ogg
Normal file
BIN
client/public/sounds/clock/el640/AM.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/PM.ogg
Normal file
BIN
client/public/sounds/clock/el640/PM.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/alarm.ogg
Normal file
BIN
client/public/sounds/clock/el640/alarm.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/announcement.ogg
Normal file
BIN
client/public/sounds/clock/el640/announcement.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/hour1.ogg
Normal file
BIN
client/public/sounds/clock/el640/hour1.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/hour2.ogg
Normal file
BIN
client/public/sounds/clock/el640/hour2.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/its.ogg
Normal file
BIN
client/public/sounds/clock/el640/its.ogg
Normal file
Binary file not shown.
BIN
client/public/sounds/clock/el640/o.ogg
Normal file
BIN
client/public/sounds/clock/el640/o.ogg
Normal file
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;
|
||||||
|
|||||||
26
client/src/audio/clockAnnouncer.ts
Normal file
26
client/src/audio/clockAnnouncer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,10 +1762,11 @@ 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
|
||||||
await self._broadcast(
|
if use_result.others_message:
|
||||||
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
await self._broadcast(
|
||||||
exclude=client.websocket,
|
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
||||||
)
|
exclude=client.websocket,
|
||||||
|
)
|
||||||
use_sound = self._resolve_item_use_sound(item)
|
use_sound = self._resolve_item_use_sound(item)
|
||||||
if use_sound:
|
if use_sound:
|
||||||
sound_x, sound_y = self._get_item_sound_source_position(item)
|
sound_x, sound_y = self._get_item_sound_source_position(item)
|
||||||
@@ -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