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.
|
||||
// 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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
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,
|
||||
} 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user