diff --git a/client/public/version.js b/client/public/version.js index 2992dbf..cacb346 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -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.21 R99"; +window.CHGRID_WEB_VERSION = "2026.02.21 R100"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 2d9349c..1578910 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -169,6 +169,10 @@ export class AudioEngine { return this.outputMode; } + getOutputMode(): OutputMode { + return this.outputMode; + } + toggleLoopback(): boolean { this.loopbackEnabled = !this.loopbackEnabled; this.rebuildOutboundEffectGraph(); diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts new file mode 100644 index 0000000..dd7a81b --- /dev/null +++ b/client/src/audio/itemEmitRuntime.ts @@ -0,0 +1,111 @@ +import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; +import { AudioEngine } from './audioEngine'; + +type EmitOutput = { + soundUrl: string; + element: HTMLAudioElement; + source: MediaElementAudioSourceNode; + gain: GainNode; + panner: StereoPannerNode | null; +}; + +export class ItemEmitRuntime { + private readonly outputs = new Map(); + + constructor( + private readonly audio: AudioEngine, + private readonly resolveSoundUrl: (soundPath: string) => string, + ) {} + + cleanup(itemId: string): void { + const output = this.outputs.get(itemId); + if (!output) return; + output.element.pause(); + output.element.src = ''; + output.source.disconnect(); + output.gain.disconnect(); + output.panner?.disconnect(); + this.outputs.delete(itemId); + } + + cleanupAll(): void { + for (const itemId of Array.from(this.outputs.keys())) { + this.cleanup(itemId); + } + } + + async sync(items: Iterable): Promise { + const validIds = new Set(); + await this.audio.ensureContext(); + const audioCtx = this.audio.context; + if (!audioCtx) return; + + for (const item of items) { + const soundUrl = this.resolveSoundUrl(String(item.emitSound ?? '').trim()); + if (!soundUrl || item.carrierId) { + this.cleanup(item.id); + continue; + } + validIds.add(item.id); + const existing = this.outputs.get(item.id); + if (existing && existing.soundUrl === soundUrl) { + continue; + } + if (existing) { + this.cleanup(item.id); + } + const element = new Audio(soundUrl); + element.loop = true; + element.preload = 'none'; + element.crossOrigin = 'anonymous'; + const source = audioCtx.createMediaElementSource(element); + const gain = audioCtx.createGain(); + gain.gain.value = 0; + let panner: StereoPannerNode | null = null; + source.connect(gain); + if (this.audio.supportsStereoPanner()) { + panner = audioCtx.createStereoPanner(); + gain.connect(panner).connect(audioCtx.destination); + } else { + gain.connect(audioCtx.destination); + } + this.outputs.set(item.id, { soundUrl, element, source, gain, panner }); + void element.play().catch(() => undefined); + } + + for (const itemId of Array.from(this.outputs.keys())) { + if (!validIds.has(itemId)) { + this.cleanup(itemId); + } + } + } + + updateSpatialAudio(items: Map, playerPosition: { x: number; y: number }): void { + const audioCtx = this.audio.context; + if (!audioCtx) return; + + for (const [itemId, output] of this.outputs.entries()) { + const item = items.get(itemId); + if (!item || item.carrierId) { + output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); + continue; + } + const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); + let gainValue = 0; + let panValue = 0; + if (dist < HEARING_RADIUS) { + gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); + panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); + } + if (dist <= 1) { + gainValue = 1; + panValue = 0; + } + output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1); + if (output.panner) { + const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); + output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); + } + } + } +} diff --git a/client/src/main.ts b/client/src/main.ts index 87b9a34..0d155b4 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -7,6 +7,7 @@ import { type EffectId, } from './audio/effects'; import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime'; +import { ItemEmitRuntime } from './audio/itemEmitRuntime'; import { applyPastedText, applyTextInput, @@ -152,10 +153,10 @@ dom.appVersion.textContent = APP_VERSION : 'Another AI experiment with Jage. Version unknown'; const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; const ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { - radio_station: { emitSound: 'none', useCooldownMs: 1000 }, - dice: { emitSound: 'sounds/roll.ogg', useCooldownMs: 1000 }, - wheel: { emitSound: 'sounds/spin.ogg', useCooldownMs: 4000 }, - clock: { emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 }, + radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 }, + dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 }, + wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 }, + clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 }, }; const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ 'title', @@ -210,6 +211,7 @@ let connecting = false; const messageBuffer: string[] = []; let messageCursor = -1; const radioRuntime = new RadioStationRuntime(audio); +const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl); let internalClipboardText = ''; let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; @@ -510,7 +512,19 @@ function getInspectItemPropertyKeys(item: WorldItem): string[] { const seen = new Set(editableKeys); const allKeys: string[] = [...editableKeys]; - const baseKeys = ['type', 'x', 'y', 'carrierId', 'version', 'createdBy', 'createdAt', 'updatedAt', 'capabilities', 'emitSound']; + const baseKeys = [ + 'type', + 'x', + 'y', + 'carrierId', + 'version', + 'createdBy', + 'createdAt', + 'updatedAt', + 'capabilities', + 'useSound', + 'emitSound', + ]; for (const key of baseKeys) { if (seen.has(key)) continue; seen.add(key); @@ -666,6 +680,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'createdAt') return formatTimestampMs(item.createdAt); if (key === 'updatedAt') return formatTimestampMs(item.updatedAt); if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; + if (key === 'useSound') return item.useSound ?? 'none'; if (key === 'emitSound') return item.emitSound ?? 'none'; if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]); @@ -709,6 +724,7 @@ function gameLoop(): void { handleMovement(); audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); + itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0; renderer.draw(state); requestAnimationFrame(gameLoop); @@ -897,6 +913,7 @@ function disconnect(): void { peerManager.cleanupAll(); radioRuntime.cleanupAll(); + itemEmitRuntime.cleanupAll(); state.running = false; state.keysPressed = {}; state.peers.clear(); @@ -961,6 +978,7 @@ async function onMessage(message: IncomingMessage): Promise { }); } await radioRuntime.sync(state.items.values()); + await itemEmitRuntime.sync(state.items.values()); gameLoop(); break; @@ -1064,6 +1082,7 @@ async function onMessage(message: IncomingMessage): Promise { } } await radioRuntime.sync(state.items.values()); + await itemEmitRuntime.sync(state.items.values()); break; } @@ -1071,6 +1090,7 @@ async function onMessage(message: IncomingMessage): Promise { state.items.delete(message.itemId); state.carriedItemId = getCarriedItem()?.id ?? null; radioRuntime.cleanup(message.itemId); + itemEmitRuntime.cleanup(message.itemId); break; } @@ -1079,7 +1099,7 @@ async function onMessage(message: IncomingMessage): Promise { if (message.action === 'use') { pushChatMessage(message.message); const item = message.itemId ? state.items.get(message.itemId) : null; - if (!item?.emitSound && item) { + if (!item?.useSound && item) { audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); } } else if (message.action !== 'update') { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index a2e46e7..d4dc8b6 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -11,6 +11,7 @@ export const itemSchema = z.object({ updatedAt: z.number().int(), version: z.number().int(), capabilities: z.array(z.string()), + useSound: z.string().optional(), emitSound: z.string().optional(), params: z.record(z.string(), z.unknown()), carrierId: z.string().nullable().optional(), diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index e09f6a6..a7d3f36 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -15,6 +15,7 @@ export type WorldItem = { updatedAt: number; version: number; capabilities: string[]; + useSound?: string; emitSound?: string; params: Record; carrierId?: string | null; diff --git a/docs/item-schema.md b/docs/item-schema.md index b5f9c07..4609180 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -14,14 +14,16 @@ "updatedAt": 1735689600000, "version": 1, "capabilities": ["editable", "carryable", "deletable", "usable"], - "emitSound": "sounds/roll.ogg", + "useSound": "sounds/roll.ogg", + "emitSound": "sounds/clock.ogg", "params": {}, "carrierId": null } ``` -- `emitSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1. -- `capabilities` and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). +- `useSound`: optional client-played one-shot sound when item `use` succeeds; global item field and not user-editable in V1. +- `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1. +- `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). - `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`), not per-instance editable. ## Persisted Item State (`server/runtime/items.json`) @@ -115,6 +117,7 @@ `Europe/London`, `Europe/Moscow`, `Pacific/Apia`, `Pacific/Auckland`, `Pacific/Chatham`, `Pacific/Honolulu`, `Pacific/Kiritimati`, `Pacific/Noumea`, `Pacific/Pago_Pago`, `UTC`. - `use24Hour`: boolean (or `on/off` in updates), default `false`. +- Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`. ## Packet Shapes diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 48f11bb..80e78d6 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -53,6 +53,7 @@ CLOCK_TIME_ZONE_OPTIONS: tuple[str, ...] = ( class ItemDefinition: default_title: str capabilities: tuple[str, ...] + use_sound: str | None emit_sound: str | None default_params: dict use_cooldown_ms: int = 1000 @@ -62,25 +63,29 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { "radio_station": ItemDefinition( default_title="radio", capabilities=("editable", "carryable", "deletable", "usable"), + use_sound=None, emit_sound=None, default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50}, ), "dice": ItemDefinition( default_title="Dice", capabilities=("editable", "carryable", "deletable", "usable"), - emit_sound="sounds/roll.ogg", + use_sound="sounds/roll.ogg", + emit_sound=None, default_params={"sides": 6, "number": 2}, ), "wheel": ItemDefinition( default_title="wheel", capabilities=("editable", "carryable", "deletable", "usable"), - emit_sound="sounds/spin.ogg", + use_sound="sounds/spin.ogg", + emit_sound=None, default_params={"spaces": "yes, no"}, use_cooldown_ms=4000, ), "clock": ItemDefinition( default_title="clock", capabilities=("editable", "carryable", "deletable", "usable"), + use_sound=None, emit_sound="sounds/clock.ogg", default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False}, ), diff --git a/server/app/item_service.py b/server/app/item_service.py index ad23b73..148c17b 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -39,6 +39,7 @@ class ItemService: updatedAt=now, version=1, capabilities=list(item_def.capabilities), + useSound=item_def.use_sound, emitSound=item_def.emit_sound, params=deepcopy(item_def.default_params), carrierId=None, @@ -95,6 +96,7 @@ class ItemService: updatedAt=persisted.updatedAt, version=persisted.version, capabilities=list(item_def.capabilities), + useSound=item_def.use_sound, emitSound=item_def.emit_sound, params=persisted.params, carrierId=persisted.carrierId, diff --git a/server/app/models.py b/server/app/models.py index 4336440..4b7c416 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -161,6 +161,7 @@ class WorldItem(BaseModel): updatedAt: int version: int capabilities: list[str] + useSound: str | None = None emitSound: str | None = None params: dict carrierId: str | None = None diff --git a/server/app/server.py b/server/app/server.py index 6d6e69b..422d4e5 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -548,12 +548,12 @@ class SignalingServer: BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True), exclude=client.websocket, ) - if item.emitSound: + if item.useSound: await self._broadcast( ItemUseSoundPacket( type="item_use_sound", itemId=item.id, - sound=item.emitSound, + sound=item.useSound, x=item.x, y=item.y, ) diff --git a/server/tests/test_item_persistence.py b/server/tests/test_item_persistence.py index 9954068..2c73a8b 100644 --- a/server/tests/test_item_persistence.py +++ b/server/tests/test_item_persistence.py @@ -27,9 +27,11 @@ def test_item_persistence_omits_global_type_properties(tmp_path: Path) -> None: assert isinstance(saved, list) assert len(saved) == 1 assert "capabilities" not in saved[0] + assert "useSound" not in saved[0] assert "emitSound" not in saved[0] reloaded = ItemService(state_file=state_file) loaded_item = reloaded.items[item.id] - assert loaded_item.emitSound == "sounds/roll.ogg" + assert loaded_item.useSound == "sounds/roll.ogg" + assert loaded_item.emitSound is None assert "usable" in loaded_item.capabilities diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 97775ec..d6c89e6 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -120,7 +120,7 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) - @pytest.mark.asyncio -async def test_clock_use_reports_time_and_emits_sound(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pytest.MonkeyPatch) -> None: server = SignalingServer("127.0.0.1", 8765, None, None) ws = _fake_ws() client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6) @@ -146,7 +146,7 @@ async def test_clock_use_reports_time_and_emits_sound(monkeypatch: pytest.Monkey assert send_payloads[-1].ok is True assert send_payloads[-1].message == f"{item.title} says 2:15 PM." - assert 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) @pytest.mark.asyncio