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.
|
||||
// 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;
|
||||
|
||||
@@ -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