Add optional local table sound controls
Connect browser-local mute, keyboard volume and a test sample with short original Web Audio cues for public phase/status/turn changes and tagged automatic actions. Audio consumes only projected sequences and public phase identity, with no game actions, private eligibility checks, media requests or saved-game writes. Preferences default off at 35%, survive refresh and synchronize across same-origin tabs. Silent initial snapshots, stale sequence rejection, batch priority and a 700ms cooldown avoid historical replay and queued bursts. User gestures unlock audio; hidden tabs stay silent. Mute, backgrounding and unmount stop active voices, pending unlocks are cancelled, and unavailable audio remains cosmetic. Existing automatic visual notices and AI pacing remain independent. Six focused regressions cover cursor consumption, phase priority, room changes, bounded volume and voices, unlock cancellation, disposal, unavailable audio and accessible initial controls. Independent review found a mobile panel overflow; the corrected panel stayed inside the viewport in DOM measurements. Browser controls verified enable/mute, keyboard volume, zero-volume disabling, refresh restoration and cross-tab preference updates. Preferences were restored to off/35%. Physical output and screenshot acceptance remain unverified because screenshot capture timed out. Broader event coverage and final sound design remain unfinished. Types, lint and 4856 offline tests plus production build pass on the unchanged source fingerprint below. The first broad check found the accessibility linter's semantic-output requirement; the status element was corrected before the final checks. HTTP/AI calibration runs were not repeated for this cosmetic-only change. All 1,072 saved games, 2,243 seats and original recovery/entry/handover records match the pre-outage backup; SQLite quick-check and foreign keys pass. The responding server and isolated browser seat were reused after the latest outage, without resetting games or creating automation. Mode and publication gates remain closed. Verified source tree: da84d2cc4ed5b0face56afb034857b85c52708e11d86ff3f1cb3c8abdeee99d0
This commit is contained in:
1 parent
62e87d2b96
commit
bfa24b0c4a
10 files changed
+532
-17
No files matched your search
@@ -30,6 +30,11 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev
|
||||
|
||||
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
|
||||
|
||||
The table header offers optional **Sound effects**, with mute, volume and a test
|
||||
sample. Short cues mark phase changes and automatic action notices. Sound starts
|
||||
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
||||
for activation, background behavior and prototype limits.
|
||||
|
||||
## Verify changes
|
||||
|
||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
||||
|
||||
@@ -105,6 +105,24 @@ a:focus-visible {
|
||||
align-items: center;
|
||||
margin-left: 28px;
|
||||
}
|
||||
.table-sounds { position: relative; max-width: 100%; font-size: 14px; }
|
||||
.table-sounds summary { display: flex; align-items: center; gap: 8px; }
|
||||
.table-sounds summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.table-sounds-panel {
|
||||
position: absolute; right: 0; top: calc(100% + 8px); z-index: 45;
|
||||
display: grid; gap: 12px; width: min(280px, calc(100vw - 48px));
|
||||
padding: 16px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: #141814; color: #f5ecda; box-shadow: 0 8px 28px #0008;
|
||||
font-size: 14px; line-height: 1.5;
|
||||
}
|
||||
.table-sounds-panel p { margin: 0; }
|
||||
.live-table .masthead { flex-wrap: wrap; gap: 12px; }
|
||||
.live-table .masthead-right { flex-wrap: wrap; }
|
||||
@media (max-width: 950px) {
|
||||
.live-table .masthead-right { position: relative; }
|
||||
.table-sounds { position: static; }
|
||||
.table-sounds-panel { left: 0; right: auto; }
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
} from './richese-allied-no-field';
|
||||
import { presenceByLocation } from '@/game/force-presence';
|
||||
import { AutomaticActionNotice } from './automatic-action-notice';
|
||||
import { TableSounds } from './table-sounds';
|
||||
import { EcazSpice } from './ecaz-spice';
|
||||
import { VictoryProgress } from './victory-progress';
|
||||
import { tableActionOwner } from '@/game/table-turn';
|
||||
@@ -902,6 +903,12 @@ export function GameTable({
|
||||
</button>
|
||||
<div className="masthead-right">
|
||||
<AutomaticActionNotice key={g.code} events={automaticEvents} />
|
||||
<TableSounds key={`sound:${g.code}`} frame={{
|
||||
room: g.code,
|
||||
phase: `${g.status}:${g.turn}:${g.phase}`,
|
||||
sequence: g.log.reduce((last, entry) => Math.max(last, entry.seq), -1),
|
||||
automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1),
|
||||
}} />
|
||||
<span className="status-dot" />
|
||||
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`}
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import { LocalTableAudio, TableSoundCursor, soundVolume, type TableSoundFrame } from '@/lib/table-sound';
|
||||
import { Button } from './ui/button';
|
||||
import { Slider } from './ui/slider';
|
||||
|
||||
const KEY = 'dune.table-sounds';
|
||||
const EVENT = 'dune-table-sounds-preference';
|
||||
const DEFAULT = { enabled: false, volume: 35 };
|
||||
let cached = DEFAULT;
|
||||
let lastRaw: string | null | undefined;
|
||||
let memoryOnly = false;
|
||||
|
||||
function readPreference() {
|
||||
if (memoryOnly) return cached;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw !== lastRaw) {
|
||||
lastRaw = raw;
|
||||
try {
|
||||
const value = JSON.parse(raw ?? 'null');
|
||||
cached = { enabled: value?.enabled === true, volume: soundVolume(value?.volume) };
|
||||
} catch { cached = DEFAULT; }
|
||||
}
|
||||
} catch { /* Keep this tab's preference if storage is unavailable. */ }
|
||||
return cached;
|
||||
}
|
||||
function subscribe(listener: () => void) {
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === KEY || event.key === null) {
|
||||
memoryOnly = false;
|
||||
lastRaw = undefined;
|
||||
listener();
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT, listener);
|
||||
window.addEventListener('storage', storage);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, listener);
|
||||
window.removeEventListener('storage', storage);
|
||||
};
|
||||
}
|
||||
function savePreference(value: typeof DEFAULT) {
|
||||
cached = value;
|
||||
memoryOnly = true;
|
||||
try {
|
||||
lastRaw = JSON.stringify(value);
|
||||
localStorage.setItem(KEY, lastRaw);
|
||||
memoryOnly = false;
|
||||
} catch { /* A cosmetic setting must never interrupt a game. */ }
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
const serverPreference = () => DEFAULT;
|
||||
|
||||
export function TableSounds({ frame }: { frame: TableSoundFrame }) {
|
||||
const preference = useSyncExternalStore(subscribe, readPreference, serverPreference);
|
||||
const audio = useRef<LocalTableAudio | null>(null);
|
||||
const cursor = useRef<TableSoundCursor | null>(null);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const player = new LocalTableAudio();
|
||||
audio.current = player;
|
||||
const unlock = () => {
|
||||
if (readPreference().enabled && document.visibilityState === 'visible') void player.unlock();
|
||||
};
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== 'visible') player.stop();
|
||||
};
|
||||
// A saved preference still respects the browser's user-gesture requirement.
|
||||
window.addEventListener('pointerdown', unlock);
|
||||
window.addEventListener('keydown', unlock);
|
||||
document.addEventListener('visibilitychange', hide);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
document.removeEventListener('visibilitychange', hide);
|
||||
player.dispose();
|
||||
audio.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const player = audio.current;
|
||||
const tracker = cursor.current ?? (cursor.current = new TableSoundCursor());
|
||||
const cue = tracker.consume(frame);
|
||||
if (!preference.enabled || preference.volume === 0) player?.stop();
|
||||
if (cue && readPreference().enabled && document.visibilityState === 'visible')
|
||||
player?.play(cue, readPreference().volume);
|
||||
}, [frame, preference]);
|
||||
|
||||
const preview = async () => {
|
||||
const player = audio.current;
|
||||
if (!player) return;
|
||||
const ready = await player.unlock();
|
||||
if (audio.current !== player || !readPreference().enabled || document.visibilityState !== 'visible') return;
|
||||
setUnavailable(!ready);
|
||||
if (ready) player.play('phase', readPreference().volume, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="table-sounds">
|
||||
<summary className="min-h-11 cursor-pointer rounded-md border px-3 py-2 text-sm motion-reduce:transition-none">
|
||||
{preference.enabled ? <Volume2 size={16} aria-hidden="true" /> : <VolumeX size={16} aria-hidden="true" />}
|
||||
Sound effects: {preference.enabled ? 'On' : 'Off'}
|
||||
</summary>
|
||||
<div className="table-sounds-panel">
|
||||
<Button variant="outline" aria-label="Sound effects" aria-pressed={preference.enabled} onClick={() => {
|
||||
const next = !preference.enabled;
|
||||
if (!next) audio.current?.stop();
|
||||
savePreference({ ...preference, enabled: next });
|
||||
setUnavailable(false);
|
||||
if (next) void preview();
|
||||
}}>
|
||||
{preference.enabled ? 'Mute sound effects' : 'Enable sound effects'}
|
||||
</Button>
|
||||
<label id="table-sounds-volume-label">Volume · {preference.volume}%</label>
|
||||
<Slider aria-labelledby="table-sounds-volume-label" min={0} max={100} step={5} value={[preference.volume]} onValueChange={(value) => {
|
||||
audio.current?.stop();
|
||||
savePreference({ ...preference, volume: soundVolume(Array.isArray(value) ? value[0] : value) });
|
||||
}} />
|
||||
<Button variant="outline" disabled={!preference.enabled || preference.volume === 0} onClick={() => void preview()}>Test sound</Button>
|
||||
<p>Short cues for phase changes and automatic actions. Only this browser; silent in background tabs.</p>
|
||||
{unavailable && <output>Sound is unavailable. You can keep playing and try Test sound again.</output>}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
+22
-17
@@ -1,17 +1,17 @@
|
||||
# Current development status
|
||||
|
||||
Updated 14 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 20 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable; public Advanced and unfinished expansion/module starts remain
|
||||
gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest verified checkpoint: `6403425`, [AI arrival preflight](BOT_ARRIVAL_PREFLIGHT.md).
|
||||
Types, lint and **4,829 offline tests** passed. Eighteen genuine base and expansion
|
||||
samples completed in 9,470 accepted actions with 247 periodic save/restore checks
|
||||
and no rejected candidates. Accepted gameplay matched the prior same-seed runs
|
||||
apart from random event identifiers. All 1,038 opening games and original
|
||||
seat/recovery records were unchanged; the browser test seat reconnected.
|
||||
Latest delivered rules checkpoint: `62e87d2`, [Harass allocation](HARASS_ALLOCATION.md).
|
||||
Types, lint, **4,850 offline tests**, production build and 43 HTTP checks passed.
|
||||
Eight genuine card-variant games completed in 9,956 accepted actions with 266
|
||||
periodic save/restore checks and no rejected candidates. Focused and browser
|
||||
cases exercised ambiguous allocation. All 1,038 opening games and original
|
||||
seat/recovery records were unchanged; 1,072 rooms remained after isolated QA.
|
||||
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
|
||||
|
||||
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
|
||||
@@ -24,17 +24,22 @@ card-specific discard precedence are explicitly labeled inferences. The Stone
|
||||
Burner timing question is pending; no user answer is assumed. Reinforcements
|
||||
remains unfinished. This does not open variant, mode or publication gates.
|
||||
|
||||
The existing server remained healthy after the reported power failures. Saved
|
||||
games and original recovery/entry/handover records survived; SQLite integrity
|
||||
and foreign-key checks passed, and refresh restored the test seat. No restart,
|
||||
database reset or recurring automation was needed. Exact verification, source
|
||||
fingerprints and delivery are recorded in the private report and Git message.
|
||||
Current checkpoint: [optional table sounds](TABLE_SOUNDS.md) connects local mute,
|
||||
volume, a test sample and short public phase/automatic-action cues. Initial and
|
||||
background updates stay silent; preferences survive refresh and synchronize
|
||||
between tabs. This adds no gameplay actions, private eligibility checks or game
|
||||
storage. Final source-bound checks and delivery are recorded in the private
|
||||
report and Git message; physical audio and screenshot acceptance remain open.
|
||||
|
||||
Current work: [Harass force allocation](HARASS_ALLOCATION.md) connects the missing
|
||||
player choice after reveal, with shared legal AI, exact winner losses and saved
|
||||
continuation. Unique returns stay automatic and sealed plans stay unchanged.
|
||||
The remaining Leader Skill bands retain their existing pending rulings; this
|
||||
checkpoint introduces no new arrival-order or hidden-reaction policy.
|
||||
On 20 September, the stopped server was started after backing up all 1,072 rooms.
|
||||
Following another reported outage, the server responded normally and every saved
|
||||
game still matched that baseline. The isolated browser seat restored successfully.
|
||||
No database reset or recurring automation was needed.
|
||||
|
||||
Next: prototype ordinary Mirror Weapon copies using the existing shared resolver,
|
||||
while retaining explicit gates for unresolved copied Tooth/Artillery disposal and
|
||||
Stone Burner choices. Sapho battle aggressor is the next independent candidate.
|
||||
The remaining Leader Skill bands retain their existing pending rulings.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 20 September 2026 — Optional table sound prototype
|
||||
|
||||
[Table sound controls](TABLE_SOUNDS.md) add local mute, keyboard volume and a test
|
||||
sample, with short synthesized cues for public phase changes and tagged automatic
|
||||
actions. Silent baseline consumption, coalescing and background suppression avoid
|
||||
replaying old updates. Preferences survive refresh and synchronize between tabs;
|
||||
game actions, AI timing and saved state are unchanged. Focused audio/cursor checks
|
||||
and browser controls cover the connected prototype. Physical output, screenshot
|
||||
acceptance and final event coverage remain unfinished. Required check/build,
|
||||
preservation and verified Git delivery belong to the source-bound private report
|
||||
and commit message. No mode or publication gate is opened.
|
||||
|
||||
The reported outage left all 1,072 baseline games unchanged. The responding server
|
||||
was reused and the existing isolated seat reconnected without a database reset.
|
||||
|
||||
## 14 September 2026 — Harass revealed physical allocation
|
||||
|
||||
Connected ordinary/elite and multi-sector choices after public plan reveal,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Optional table sounds
|
||||
|
||||
The connected sound prototype adds browser-local mute, volume and a test control
|
||||
to the table header. It defaults to off at 35% volume. Settings are independent of
|
||||
automatic visual notices and synchronize between tabs on the same origin. Storage
|
||||
failure retains the preference in memory without interrupting play.
|
||||
|
||||
Two original sine-tone cues are synthesized with Web Audio: one for a new tagged
|
||||
automatic action and two for a phase/status/turn transition. Audio uses only the
|
||||
existing projected log sequences and public phase identity. It does not inspect
|
||||
cards, plans, private resources, pending eligibility or other seats. It sends no
|
||||
actions and changes neither rules, AI pacing nor persistent game state. No audio
|
||||
download, external service or microphone permission is required.
|
||||
|
||||
Initial/restored snapshots establish a silent baseline. Later snapshots are
|
||||
consumed even when muted, hidden or unable to play. A batch produces at most one
|
||||
cue, phase changes take precedence, and a 700ms minimum interval drops rapid cues
|
||||
without keeping a backlog. Log truncation and stale sequences do not replay
|
||||
automatic events. Switching rooms establishes a fresh baseline.
|
||||
|
||||
Audio is unlocked only by a user gesture. A saved enabled preference needs a click
|
||||
or key press after refresh before new cues can sound. Enable and Test sound offer
|
||||
a sample. Volume zero is silent and disables Test sound. Mute, backgrounding and
|
||||
unmount stop active tones; unmount closes the context. Failed/unsupported audio
|
||||
remains cosmetic; a failed explicit preview reports that play can continue.
|
||||
Audio is independent of reduced-motion settings.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
`tests/table-sound.test.tsx` checks initial/stale/duplicate snapshots, room changes,
|
||||
silent consumption, coalescing, priority, bounded volume and voice duration,
|
||||
gesture-only context creation, pending unlock cancellation, mute/disposal,
|
||||
unavailable audio and accessible initial controls. Its fake audio context does
|
||||
not prove physical speaker output or every browser's autoplay policy.
|
||||
|
||||
Browser verification covers the existing isolated saved seat, sound on/off,
|
||||
keyboard volume, zero-volume test disabling, refresh persistence and same-origin
|
||||
tab synchronization. The phone panel's bounds were checked at a 390px requested
|
||||
viewport. Screenshot capture timed out, so this checkpoint does not claim visual
|
||||
screenshot acceptance or audible output verification. No game action was needed
|
||||
to change sound settings. Settings were returned to off and 35% afterward.
|
||||
|
||||
This is a functional prototype, not the final sound design. Successful powers
|
||||
without an automatic notice tag remain outside these cues. Per-component audio,
|
||||
broader device/browser acceptance and final sound mixing remain unfinished.
|
||||
@@ -214,6 +214,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'automatic-responses',
|
||||
'automatic-decisions',
|
||||
'ai-pacing',
|
||||
'table-sounds',
|
||||
'seat-handover',
|
||||
'ecaz-modules',
|
||||
'discoveries',
|
||||
@@ -221,6 +222,29 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'advanced-combat',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'table-sounds',
|
||||
title: 'Sound effects',
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Optional short cues for phase changes and automatic actions, with local mute and volume controls.',
|
||||
steps: [
|
||||
'Open Sound effects in the table header to enable or mute audio, change its volume or test a sample. Sound starts off. Preferences stay in this browser and are shared by its tabs.',
|
||||
'One short tone marks a new automatic action notice. Two tones mark a phase, turn or game-status change. Several updates arriving together produce at most one cue.',
|
||||
'Sound and automatic visual notices have separate controls. Muting sound does not hide notices or change AI pacing.',
|
||||
'Refresh restores your preference without replaying old events. Click or press a key to allow future sounds after refresh. Background tabs stay silent and do not queue old sounds.',
|
||||
'Volume zero is silent and disables Test sound. Muting stops current tones. If audio is unavailable, play continues normally; cues never reveal concealed cards or decisions.',
|
||||
],
|
||||
related: ['automatic-casualties', 'ai-pacing', 'privacy', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Local synthesized phase and tagged automatic-action cues are connected; broader event coverage and final sound design remain unfinished.' },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Mute, keyboard volume, test sample and refresh-persistent browser preferences.' },
|
||||
{ area: 'AI', status: 'Implemented', detail: 'Existing projected human and AI updates share the same cosmetic cues without changing legal actions or pacing.' },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'This guide explains preferences, browser activation, background silence and unavailable audio.' },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused cursor/audio checks and browser preference controls; physical output and broader browser acceptance remain open.', evidence: ['tests/table-sound.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'seat-handover',
|
||||
title: 'Pass a seat to another player',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Cosmetic input only: no cards, plans, resources or private decision eligibility. */
|
||||
export type TableSoundFrame = {
|
||||
room: string;
|
||||
phase: string;
|
||||
sequence: number;
|
||||
automaticSequence: number;
|
||||
};
|
||||
export type TableSoundCue = 'phase' | 'automatic';
|
||||
|
||||
/** Consume silent snapshots too, so enabling sound never replays old events. */
|
||||
export class TableSoundCursor {
|
||||
private previous: TableSoundFrame | null = null;
|
||||
|
||||
consume(frame: TableSoundFrame): TableSoundCue | null {
|
||||
const previous = this.previous;
|
||||
if (!previous || previous.room !== frame.room) {
|
||||
this.previous = { ...frame };
|
||||
return null;
|
||||
}
|
||||
if (frame.sequence < previous.sequence) return null;
|
||||
this.previous = {
|
||||
...frame,
|
||||
automaticSequence: Math.max(previous.automaticSequence, frame.automaticSequence),
|
||||
};
|
||||
// Coalesce a polling batch into one cue, with phase changes taking priority.
|
||||
if (previous.phase !== frame.phase) return 'phase';
|
||||
return frame.automaticSequence > previous.automaticSequence ? 'automatic' : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_SOUND_VOLUME = 35;
|
||||
export function soundVolume(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(100, Math.max(0, Math.round(value)))
|
||||
: DEFAULT_SOUND_VOLUME;
|
||||
}
|
||||
|
||||
/** Short original tones synthesized locally; no downloads, permissions or game writes. */
|
||||
export class LocalTableAudio {
|
||||
private context: AudioContext | null = null;
|
||||
private voices = new Set<{ oscillator: OscillatorNode; gain: GainNode }>();
|
||||
private generation = 0;
|
||||
private lastPlayed = -Infinity;
|
||||
|
||||
constructor(private createContext: () => AudioContext = () => new AudioContext()) {}
|
||||
|
||||
// Call directly from a user gesture, never from a game update or storage event.
|
||||
async unlock(): Promise<boolean> {
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const context = this.context ?? (this.context = this.createContext());
|
||||
if (context.state !== 'running') await context.resume();
|
||||
return generation === this.generation && this.context === context && context.state === 'running';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
play(cue: TableSoundCue, volume: number, preview = false): boolean {
|
||||
const context = this.context;
|
||||
const level = soundVolume(volume);
|
||||
if (!context || context.state !== 'running' || level === 0) return false;
|
||||
if (!preview && context.currentTime - this.lastPlayed < 0.7) return false;
|
||||
this.stop();
|
||||
try {
|
||||
const start = context.currentTime;
|
||||
const notes = cue === 'phase' ? [330, 495] : [440];
|
||||
for (const [index, frequency] of notes.entries()) {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
const voice = { oscillator, gain };
|
||||
this.voices.add(voice);
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(frequency, start);
|
||||
const at = start + index * 0.12;
|
||||
gain.gain.setValueAtTime(0, start);
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(0.12 * level / 100, at + 0.015);
|
||||
gain.gain.linearRampToValueAtTime(0, at + 0.16);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
oscillator.onended = () => {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
this.voices.delete(voice);
|
||||
};
|
||||
oscillator.start(at);
|
||||
oscillator.stop(at + 0.18);
|
||||
}
|
||||
this.lastPlayed = start;
|
||||
return true;
|
||||
} catch {
|
||||
this.stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation++;
|
||||
for (const { oscillator, gain } of this.voices) {
|
||||
try {
|
||||
gain.gain.cancelScheduledValues(0);
|
||||
gain.gain.setValueAtTime(0, this.context?.currentTime ?? 0);
|
||||
oscillator.stop();
|
||||
} catch { /* Already ended or audio is unavailable. */ }
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
}
|
||||
this.voices.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stop();
|
||||
const context = this.context;
|
||||
this.context = null;
|
||||
this.lastPlayed = -Infinity;
|
||||
if (context) void context.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { LocalTableAudio, TableSoundCursor, soundVolume, type TableSoundFrame } from '../lib/table-sound';
|
||||
import { TableSounds } from '../components/table-sounds';
|
||||
|
||||
const opening: TableSoundFrame = { room: 'SOUND001', phase: 'playing:1:2', sequence: 15, automaticSequence: 12 };
|
||||
|
||||
void test('sound cursor consumes refresh history, duplicates, muted/hidden batches and stale snapshots without replay', () => {
|
||||
const cursor = new TableSoundCursor();
|
||||
assert.equal(cursor.consume(opening), null);
|
||||
assert.equal(cursor.consume({ ...opening }), null);
|
||||
const next = { ...opening, sequence: 18, automaticSequence: 18 };
|
||||
// A muted or hidden consumer discards this cue; no retained playback queue exists.
|
||||
assert.equal(cursor.consume(next), 'automatic');
|
||||
assert.equal(cursor.consume(next), null);
|
||||
assert.equal(cursor.consume(opening), null);
|
||||
assert.equal(cursor.consume(next), null);
|
||||
assert.equal(cursor.consume({ ...next, sequence: 19, automaticSequence: -1 }), null);
|
||||
assert.equal(cursor.consume({ ...next, sequence: 20 }), null);
|
||||
assert.equal(cursor.consume({ ...next, sequence: 21, automaticSequence: 21 }), 'automatic');
|
||||
});
|
||||
|
||||
void test('phase cues take priority over automatic batches and switching rooms establishes a new silent baseline', () => {
|
||||
const cursor = new TableSoundCursor();
|
||||
cursor.consume(opening);
|
||||
const phase = { ...opening, phase: 'playing:1:3', sequence: 100, automaticSequence: 99 };
|
||||
assert.equal(cursor.consume(phase), 'phase');
|
||||
assert.equal(cursor.consume(phase), null);
|
||||
const other = { ...opening, room: 'SOUND002' };
|
||||
assert.equal(cursor.consume(other), null);
|
||||
assert.equal(cursor.consume({ ...other, phase: 'finished:1:3', sequence: 16 }), 'phase');
|
||||
assert.equal(new TableSoundCursor().consume(phase), null);
|
||||
});
|
||||
|
||||
function fakeAudio() {
|
||||
const nodes: { starts: number[]; stops: number[]; disconnected: number; onended: (() => void) | null; type: string; frequency: { setValueAtTime: (frequency: number) => void }; connect: () => void; start: (time: number) => void; stop: (time?: number) => void; disconnect: () => void }[] = [];
|
||||
const gains: { values: number[]; disconnected: number; gain: { setValueAtTime: (value: number) => void; linearRampToValueAtTime: (value: number) => void; cancelScheduledValues: () => void }; connect: () => void; disconnect: () => void }[] = [];
|
||||
let created = 0;
|
||||
let closed = 0;
|
||||
let resumed = 0;
|
||||
const context = {
|
||||
state: 'suspended', currentTime: 10, destination: {},
|
||||
async resume() { resumed++; context.state = 'running'; },
|
||||
async close() { closed++; context.state = 'closed'; },
|
||||
createOscillator() {
|
||||
const node = {
|
||||
starts: [] as number[], stops: [] as number[], disconnected: 0, onended: null as (() => void) | null,
|
||||
type: '', frequency: { setValueAtTime() {} }, connect() {},
|
||||
start(time: number) { node.starts.push(time); },
|
||||
stop(time = context.currentTime) { node.stops.push(time); },
|
||||
disconnect() { node.disconnected++; },
|
||||
};
|
||||
nodes.push(node);
|
||||
return node;
|
||||
},
|
||||
createGain() {
|
||||
const gain = {
|
||||
values: [] as number[], disconnected: 0,
|
||||
gain: {
|
||||
setValueAtTime(value: number) { gain.values.push(value); },
|
||||
linearRampToValueAtTime(value: number) { gain.values.push(value); },
|
||||
cancelScheduledValues() {},
|
||||
}, connect() {}, disconnect() { gain.disconnected++; },
|
||||
};
|
||||
gains.push(gain);
|
||||
return gain;
|
||||
},
|
||||
};
|
||||
const audio = new LocalTableAudio(() => { created++; return context as unknown as AudioContext; });
|
||||
return { audio, context, nodes, gains, counts: () => ({ created, closed, resumed }) };
|
||||
}
|
||||
|
||||
void test('audio starts only after explicit unlock, bounds volume and coalesces closely spaced updates', async () => {
|
||||
const fixture = fakeAudio();
|
||||
const { audio, context, nodes, gains } = fixture;
|
||||
assert.equal(audio.play('phase', 35), false);
|
||||
assert.deepEqual(fixture.counts(), { created: 0, closed: 0, resumed: 0 });
|
||||
assert.equal(await audio.unlock(), true);
|
||||
assert.equal(audio.play('phase', 200), true);
|
||||
assert.equal(nodes.length, 2);
|
||||
assert.ok(gains.every((gain) => Math.max(...gain.values) === 0.12));
|
||||
assert.ok(nodes.every((node) => node.stops[0] - node.starts[0] < 0.2));
|
||||
assert.equal(audio.play('automatic', 50), false);
|
||||
context.currentTime += 1;
|
||||
assert.equal(audio.play('automatic', 50), true);
|
||||
assert.equal(nodes.length, 3);
|
||||
assert.ok(nodes.slice(0, 2).every((node) => node.disconnected > 0));
|
||||
assert.equal(Math.max(...gains[2].values), 0.06);
|
||||
nodes[2].onended?.();
|
||||
assert.equal(nodes[2].disconnected, 1);
|
||||
assert.equal(gains[2].disconnected, 1);
|
||||
audio.dispose();
|
||||
assert.equal(fixture.counts().closed, 1);
|
||||
});
|
||||
|
||||
void test('mute, suspension and disposal prevent playback and cancel pending unlocks', async () => {
|
||||
const { audio, context, nodes, gains } = fakeAudio();
|
||||
const pending = audio.unlock();
|
||||
audio.stop();
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(audio.play('automatic', 0), false);
|
||||
assert.equal(await audio.unlock(), true);
|
||||
assert.equal(audio.play('phase', 35), true);
|
||||
audio.stop();
|
||||
assert.ok(gains.every((gain) => gain.values.at(-1) === 0));
|
||||
assert.ok(nodes.every((node) => node.disconnected > 0));
|
||||
context.state = 'suspended';
|
||||
assert.equal(audio.play('automatic', 35, true), false);
|
||||
const pendingAgain = audio.unlock();
|
||||
audio.dispose();
|
||||
assert.equal(await pendingAgain, false);
|
||||
assert.equal(audio.play('phase', 35, true), false);
|
||||
});
|
||||
|
||||
void test('unavailable or rejected audio fails quietly and previews replace active voices', async () => {
|
||||
const absent = new LocalTableAudio(() => { throw new Error('Unavailable'); });
|
||||
assert.equal(await absent.unlock(), false);
|
||||
assert.equal(absent.play('phase', 35), false);
|
||||
absent.dispose();
|
||||
const fixture = fakeAudio();
|
||||
fixture.context.resume = async () => { throw new Error('Gesture required'); };
|
||||
assert.equal(await fixture.audio.unlock(), false);
|
||||
fixture.context.state = 'running';
|
||||
assert.equal(await fixture.audio.unlock(), true);
|
||||
assert.equal(fixture.audio.play('phase', 35, true), true);
|
||||
assert.equal(fixture.audio.play('phase', 35, true), true);
|
||||
assert.equal(fixture.nodes.length, 4);
|
||||
assert.ok(fixture.nodes.slice(0, 2).every((node) => node.disconnected > 0));
|
||||
fixture.audio.dispose();
|
||||
});
|
||||
|
||||
void test('volume input stays finite and initial controls expose an accessible muted preference', () => {
|
||||
assert.equal(soundVolume(NaN), 35);
|
||||
assert.equal(soundVolume(Infinity), 35);
|
||||
assert.equal(soundVolume('80'), 35);
|
||||
assert.equal(soundVolume(-1), 0);
|
||||
assert.equal(soundVolume(250), 100);
|
||||
const html = renderToStaticMarkup(createElement(TableSounds, { frame: opening }));
|
||||
assert.match(html, /Sound effects: Off/);
|
||||
assert.match(html, /aria-label="Sound effects" aria-pressed="false"/);
|
||||
assert.match(html, /Volume · 35%/);
|
||||
assert.match(html, /type="range"/);
|
||||
assert.match(html, /aria-labelledby="table-sounds-volume-label"/);
|
||||
assert.match(html, /disabled=""[^>]*>Test sound/);
|
||||
});
|
||||
Reference in new issue
Block a user