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

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