@adea-ai/audio 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +27 -0
- package/src/config.ts +9 -0
- package/src/controller.ts +165 -0
- package/src/index.ts +12 -0
- package/src/react.tsx +99 -0
- package/src/scene-music.ts +11 -0
- package/tsconfig.json +10 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adea-ai/audio",
|
|
3
|
+
"version": "0.10.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^19.2.18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"lucide-react": "^1.33.0",
|
|
25
|
+
"react": "19.2.8"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Optional soundtrack hooks for Agent HQ. */
|
|
2
|
+
export const AUDIO_BASE_URL = "/assets/audio";
|
|
3
|
+
export const MUSIC_DIR = `${AUDIO_BASE_URL}/sounds/music`;
|
|
4
|
+
|
|
5
|
+
export const MUSIC_FILES = {
|
|
6
|
+
silent: "",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export type MusicId = keyof typeof MUSIC_FILES;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { MUSIC_DIR, MUSIC_FILES, type MusicId } from "./config";
|
|
2
|
+
|
|
3
|
+
export type MusicOptions = {
|
|
4
|
+
/** 0..1 track gain (default 0.8). */
|
|
5
|
+
volume?: number;
|
|
6
|
+
/** Crossfade/fade duration in ms (default 500). */
|
|
7
|
+
fadeMs?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const MUSIC_MUTE_KEY = "agent-hq:audio:music-muted";
|
|
11
|
+
const LEGACY_MUTE_KEY = "agent-hq:audio:muted";
|
|
12
|
+
const DEFAULT_MUSIC_VOLUME = 0.8;
|
|
13
|
+
const FADE_STEP_MS = 30;
|
|
14
|
+
|
|
15
|
+
function clampVolume(value: number): number {
|
|
16
|
+
return value <= 0 ? 0 : value > 1 ? 1 : value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readStorage(key: string): string | null {
|
|
20
|
+
if (typeof window === "undefined") return null;
|
|
21
|
+
try {
|
|
22
|
+
return window.localStorage.getItem(key);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeStorage(key: string, muted: boolean): void {
|
|
29
|
+
if (typeof window === "undefined") return;
|
|
30
|
+
try {
|
|
31
|
+
if (muted) window.localStorage.setItem(key, "1");
|
|
32
|
+
else window.localStorage.removeItem(key);
|
|
33
|
+
} catch {
|
|
34
|
+
// Storage can be unavailable; the in-memory mute still applies.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadMusicMuted(): boolean {
|
|
39
|
+
return readStorage(MUSIC_MUTE_KEY) === "1" || readStorage(LEGACY_MUTE_KEY) === "1";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SoundController {
|
|
43
|
+
private musicElements = new Map<MusicId, HTMLAudioElement>();
|
|
44
|
+
private desiredMusic: MusicId | null = null;
|
|
45
|
+
private currentMusic: MusicId | null = null;
|
|
46
|
+
private activeFades = new Set<number>();
|
|
47
|
+
private _musicMuted: boolean;
|
|
48
|
+
|
|
49
|
+
constructor() {
|
|
50
|
+
this._musicMuted = loadMusicMuted();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get musicMuted(): boolean {
|
|
54
|
+
return this._musicMuted;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Retry queued playback after a user gesture. */
|
|
58
|
+
async unlock(): Promise<void> {
|
|
59
|
+
this.startDesiredMusic();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Start or switch to a looping soundtrack. */
|
|
63
|
+
playMusic(id: MusicId, options: MusicOptions = {}): void {
|
|
64
|
+
if (!MUSIC_FILES[id]) return;
|
|
65
|
+
this.desiredMusic = id;
|
|
66
|
+
this.prepareMusicElement(id);
|
|
67
|
+
if (!this.musicMuted) this.startMusic(id, options);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setMusicMuted(muted: boolean): void {
|
|
71
|
+
this._musicMuted = muted;
|
|
72
|
+
writeStorage(MUSIC_MUTE_KEY, muted);
|
|
73
|
+
for (const element of this.musicElements.values()) {
|
|
74
|
+
if (muted) element.pause();
|
|
75
|
+
}
|
|
76
|
+
if (!muted) this.startDesiredMusic();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toggleMusicMute(): boolean {
|
|
80
|
+
this.setMusicMuted(!this._musicMuted);
|
|
81
|
+
return this._musicMuted;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private prepareMusicElement(id: MusicId): void {
|
|
85
|
+
if (this.musicElements.has(id)) return;
|
|
86
|
+
const element = new Audio(`${MUSIC_DIR}/${MUSIC_FILES[id]}`);
|
|
87
|
+
element.loop = true;
|
|
88
|
+
// Defer the request until playback is allowed so audio never competes with
|
|
89
|
+
// the scene's first visual and physics assets.
|
|
90
|
+
element.preload = "none";
|
|
91
|
+
this.musicElements.set(id, element);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private startMusic(id: MusicId, options: MusicOptions = {}): void {
|
|
95
|
+
if (this.currentMusic === id) {
|
|
96
|
+
const active = this.musicElements.get(id);
|
|
97
|
+
if (active?.paused) void active.play().catch(() => undefined);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fadeMs = options.fadeMs ?? 500;
|
|
102
|
+
const target = clampVolume(options.volume ?? DEFAULT_MUSIC_VOLUME);
|
|
103
|
+
const previousId = this.currentMusic;
|
|
104
|
+
const previous = previousId ? (this.musicElements.get(previousId) ?? null) : null;
|
|
105
|
+
this.currentMusic = id;
|
|
106
|
+
const element = this.musicElements.get(id);
|
|
107
|
+
if (!element) return;
|
|
108
|
+
element.volume = 0;
|
|
109
|
+
element.currentTime = 0;
|
|
110
|
+
void element
|
|
111
|
+
.play()
|
|
112
|
+
.then(() => {
|
|
113
|
+
if (this.currentMusic !== id) {
|
|
114
|
+
element.pause();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (previous) {
|
|
118
|
+
this.fadeMusic(previous, 0, fadeMs, () => {
|
|
119
|
+
previous.pause();
|
|
120
|
+
previous.currentTime = 0;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
this.fadeMusic(element, target, fadeMs);
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {
|
|
126
|
+
// Autoplay is blocked before the first user gesture. Keep the desired
|
|
127
|
+
// track so unlock() can retry it later.
|
|
128
|
+
if (previous) this.currentMusic = previousId;
|
|
129
|
+
else this.currentMusic = null;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private startDesiredMusic(): void {
|
|
134
|
+
if (this.musicMuted || !this.desiredMusic) return;
|
|
135
|
+
const desired = this.desiredMusic;
|
|
136
|
+
if (this.currentMusic !== desired) {
|
|
137
|
+
this.startMusic(desired);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const element = this.musicElements.get(desired);
|
|
141
|
+
if (element?.paused) void element.play().catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fadeMusic(
|
|
145
|
+
element: HTMLAudioElement,
|
|
146
|
+
target: number,
|
|
147
|
+
durationMs: number,
|
|
148
|
+
onDone?: () => void
|
|
149
|
+
): void {
|
|
150
|
+
const start = element.volume;
|
|
151
|
+
const startTime = performance.now();
|
|
152
|
+
const interval = window.setInterval(() => {
|
|
153
|
+
const progress = Math.min(1, (performance.now() - startTime) / Math.max(durationMs, 1));
|
|
154
|
+
element.volume = start + (target - start) * progress;
|
|
155
|
+
if (progress >= 1) {
|
|
156
|
+
window.clearInterval(interval);
|
|
157
|
+
this.activeFades.delete(interval);
|
|
158
|
+
onDone?.();
|
|
159
|
+
}
|
|
160
|
+
}, FADE_STEP_MS);
|
|
161
|
+
this.activeFades.add(interval);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const soundController = new SoundController();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Optional soundtrack hooks for Agent HQ.
|
|
2
|
+
|
|
3
|
+
export { AUDIO_BASE_URL, MUSIC_FILES, type MusicId } from "./config";
|
|
4
|
+
export { SoundController, soundController, type MusicOptions } from "./controller";
|
|
5
|
+
export { musicForScene, sceneMusicTracks } from "./scene-music";
|
|
6
|
+
export {
|
|
7
|
+
MusicToggle,
|
|
8
|
+
SoundProvider,
|
|
9
|
+
useSceneMusic,
|
|
10
|
+
useSound,
|
|
11
|
+
type SoundContextValue,
|
|
12
|
+
} from "./react";
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
|
+
import { Music, Music2 } from "lucide-react";
|
|
5
|
+
import { soundController, type MusicOptions } from "./controller";
|
|
6
|
+
import { musicForScene } from "./scene-music";
|
|
7
|
+
import type { MusicId } from "./config";
|
|
8
|
+
|
|
9
|
+
export type SoundContextValue = {
|
|
10
|
+
controller: typeof soundController;
|
|
11
|
+
ready: boolean;
|
|
12
|
+
musicMuted: boolean;
|
|
13
|
+
toggleMusicMute: () => void;
|
|
14
|
+
playMusic: (id: MusicId, options?: MusicOptions) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SoundContext = createContext<SoundContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export function SoundProvider({ children }: { children: ReactNode }) {
|
|
20
|
+
const [musicMuted, setMusicMuted] = useState(false);
|
|
21
|
+
const [ready, setReady] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let mounted = true;
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
if (mounted) setMusicMuted(soundController.musicMuted);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const onGesture = () => {
|
|
30
|
+
void soundController.unlock().then(() => {
|
|
31
|
+
if (mounted) setReady(true);
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
window.addEventListener("pointerdown", onGesture, { capture: true });
|
|
35
|
+
window.addEventListener("keydown", onGesture, { capture: true });
|
|
36
|
+
window.addEventListener("touchstart", onGesture, { capture: true });
|
|
37
|
+
if (new URLSearchParams(window.location.search).has("debug")) {
|
|
38
|
+
(window as unknown as { __agentHqSound?: typeof soundController }).__agentHqSound =
|
|
39
|
+
soundController;
|
|
40
|
+
}
|
|
41
|
+
return () => {
|
|
42
|
+
mounted = false;
|
|
43
|
+
window.removeEventListener("pointerdown", onGesture, { capture: true });
|
|
44
|
+
window.removeEventListener("keydown", onGesture, { capture: true });
|
|
45
|
+
window.removeEventListener("touchstart", onGesture, { capture: true });
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const value = useMemo<SoundContextValue>(
|
|
50
|
+
() => ({
|
|
51
|
+
controller: soundController,
|
|
52
|
+
ready,
|
|
53
|
+
musicMuted,
|
|
54
|
+
toggleMusicMute: () => setMusicMuted(soundController.toggleMusicMute()),
|
|
55
|
+
playMusic: (id, options) => soundController.playMusic(id, options),
|
|
56
|
+
}),
|
|
57
|
+
[musicMuted, ready]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useSound(): SoundContextValue {
|
|
64
|
+
const value = useContext(SoundContext);
|
|
65
|
+
if (!value) throw new Error("useSound must be used within <SoundProvider>");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useSceneMusic(sceneId: string | null | undefined): void {
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const timeout = window.setTimeout(() => {
|
|
72
|
+
soundController.playMusic(musicForScene(sceneId));
|
|
73
|
+
}, 1500);
|
|
74
|
+
return () => window.clearTimeout(timeout);
|
|
75
|
+
}, [sceneId]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function MusicButton({ muted, onToggle }: { muted: boolean; onToggle: () => void }) {
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
aria-label={muted ? "Unmute music" : "Mute music"}
|
|
83
|
+
aria-pressed={muted}
|
|
84
|
+
onClick={onToggle}
|
|
85
|
+
className={`inline-flex size-9 items-center justify-center rounded-lg border border-input bg-background transition-colors ${muted ? "text-muted-foreground hover:text-foreground" : "bg-primary text-primary-foreground"}`}
|
|
86
|
+
>
|
|
87
|
+
{muted ? (
|
|
88
|
+
<Music className="size-5" aria-hidden="true" />
|
|
89
|
+
) : (
|
|
90
|
+
<Music2 className="size-5" aria-hidden="true" />
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function MusicToggle() {
|
|
97
|
+
const { musicMuted, toggleMusicMute } = useSound();
|
|
98
|
+
return <MusicButton muted={musicMuted} onToggle={toggleMusicMute} />;
|
|
99
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MusicId } from "./config";
|
|
2
|
+
|
|
3
|
+
/** Scene route id -> optional track. Deployments can add tracks without changing scene code. */
|
|
4
|
+
export const sceneMusicTracks: Record<string, MusicId> = {
|
|
5
|
+
"hq-home": "silent",
|
|
6
|
+
"hq-work": "silent",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function musicForScene(sceneId: string | null | undefined): MusicId {
|
|
10
|
+
return sceneMusicTracks[sceneId ?? ""] ?? "silent";
|
|
11
|
+
}
|