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