diff --git a/docs/site-audio.md b/docs/site-audio.md
new file mode 100644
index 0000000..bbee5bd
--- /dev/null
+++ b/docs/site-audio.md
@@ -0,0 +1,70 @@
+# Site audio
+
+The owner supplied the theme and Sharingan transformation effect and approved
+the six internet dialogue finds. Their three additional dialogue uploads bring
+the playlist to nine clips. The recordings were cleaned without generating or
+replacing voices. Third-party uploads have not been independently authenticated
+as original-cast masters; the owner accepted the candidates for this project.
+
+## Files
+
+- `public/audio/naruto-theme.mp3`: the owner's approximately 29-second theme,
+ normalized with a 500 ms circular crossfade (about 28.5 seconds per loop).
+- `public/audio/mangekyo.mp3`: the owner's transformation effect, with its
+ approximately 300 ms leading silence removed for responsive eye taps.
+- `public/audio/sharingan.mp3`: the existing normal-eye return effect.
+- Nine dialogue clips: Obito's heart and love/hate lines; Itachi's weak/hate,
+ genjutsu and reality passages; Madara's reality and despair lines; Pain;
+ and Jiraiya's shinobi-life passage.
+- Provenance is in `public/audio/CREDITS.txt`.
+- Processing and measured levels are in `public/audio/audio-report.json`.
+- The older draft's Dattebayo, Believe it, and duplicate short Wake up clip have
+ been replaced by the approved nine-clip review playlist.
+
+## Behavior
+
+- The public page has one icon button: mute/unmute theme music only. It never
+ stops dialogue or Sharingan effects. The visitor's theme mute preference is
+ saved separately; old per-browser volume settings are ignored.
+- Automatic startup attempts to play the theme on load. Browser autoplay policy
+ may suspend it until the first pointer or key interaction. That interaction
+ unlocks the same mixer; it never creates duplicate theme sources.
+- The theme starts at zero gain and ramps to its configured level over 4 seconds,
+ then holds there. Default theme is 75%, dialogue 100%, and eye effects 95%.
+- The theme loops continuously beneath voices and effects. A master compressor
+ reduces peaks when loud channels mix; individual sources remain independent.
+- The first dialogue starts after 20 seconds of active playback. Every later
+ dialogue waits 20 seconds after the previous recording ENDS, irrespective of
+ clip length. Settings refreshes do not restart an unchanged countdown.
+- Consecutive recordings do not repeat when multiple clips are enabled.
+- Background tabs suspend the mixer and cancel dialogue/effects; resuming starts
+ a fresh dialogue gap. Typing and active HTML audio/video defer the next quote.
+- One root-layout mixer persists across client navigation. Refresh tries autoplay
+ again while respecting the saved theme mute preference.
+
+## Admin settings
+
+The authenticated dashboard has a **Site audio** section with all three channel
+levels, dialogue gap (1–600 seconds), theme fade (0–30 seconds), automatic
+character dialogue, and individual clip enable switches and preview players.
+Defaults can be restored in the form, then saved explicitly.
+
+`GET /api/site-audio` returns public, non-sensitive settings; before the first
+save it returns the defaults. `PUT /api/site-audio` requires the existing admin
+session and validates every field and playlist ID. Settings are stored in the
+MongoDB `SiteAudioConfig` singleton, not a browser preference or local file.
+Prisma's normal generate step includes the new model; no existing documents or
+fields are changed. The singleton is created on the first authenticated save.
+Open visitor pages fetch changes every minute, while the saving admin tab
+refreshes immediately. Settings failures preserve the built-in playback defaults;
+the admin form reports the error and never pretends an unsuccessful save worked.
+
+## Validation
+
+Run TypeScript, ESLint, Jest, and the production build. Tests cover theme-only
+mute, fade targets, short and long dialogue completion plus the full gap,
+blocked-autoplay unlock without duplicate music, admin-only writes, persistence
+contracts, invalid settings, playlist assets, and public/admin UI separation.
+Browser checks should exercise the fade, theme-only mute with active dialogue
+and effects, all nine audio decoders, and admin preview/save behavior. Actual
+mobile Safari/device listening remains a separate check when available.
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a16fb89..afd65ac 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -272,3 +272,15 @@ model InsightImport {
importedAt DateTime @default(now())
backfill Boolean @default(false)
}
+
+/// Shared site-wide playback settings; public visitors can only mute their theme.
+model SiteAudioConfig {
+ id String @id @map("_id")
+ music Float @default(0.75)
+ dialogue Float @default(1)
+ effects Float @default(0.95)
+ randomDialogue Boolean @default(true)
+ intervalSeconds Int @default(20)
+ fadeSeconds Float @default(4)
+ enabledDialogueIds String[]
+}
diff --git a/public/audio/01_obito_heart.mp3 b/public/audio/01_obito_heart.mp3
new file mode 100644
index 0000000..2fa065f
Binary files /dev/null and b/public/audio/01_obito_heart.mp3 differ
diff --git a/public/audio/02_itachi_weak_hate.mp3 b/public/audio/02_itachi_weak_hate.mp3
new file mode 100644
index 0000000..26491ff
Binary files /dev/null and b/public/audio/02_itachi_weak_hate.mp3 differ
diff --git a/public/audio/03_itachi_genjutsu.mp3 b/public/audio/03_itachi_genjutsu.mp3
new file mode 100644
index 0000000..d7066b0
Binary files /dev/null and b/public/audio/03_itachi_genjutsu.mp3 differ
diff --git a/public/audio/04_obito_love_hate.mp3 b/public/audio/04_obito_love_hate.mp3
new file mode 100644
index 0000000..0da7924
Binary files /dev/null and b/public/audio/04_obito_love_hate.mp3 differ
diff --git a/public/audio/05_madara_reality.mp3 b/public/audio/05_madara_reality.mp3
new file mode 100644
index 0000000..6644840
Binary files /dev/null and b/public/audio/05_madara_reality.mp3 differ
diff --git a/public/audio/06_pain_partial.mp3 b/public/audio/06_pain_partial.mp3
new file mode 100644
index 0000000..39fbe1f
Binary files /dev/null and b/public/audio/06_pain_partial.mp3 differ
diff --git a/public/audio/07_madara_despair.mp3 b/public/audio/07_madara_despair.mp3
new file mode 100644
index 0000000..3b6442a
Binary files /dev/null and b/public/audio/07_madara_despair.mp3 differ
diff --git a/public/audio/08_jiraiya_shinobi_life.mp3 b/public/audio/08_jiraiya_shinobi_life.mp3
new file mode 100644
index 0000000..380ed2e
Binary files /dev/null and b/public/audio/08_jiraiya_shinobi_life.mp3 differ
diff --git a/public/audio/09_itachi_reality.mp3 b/public/audio/09_itachi_reality.mp3
new file mode 100644
index 0000000..31335fb
Binary files /dev/null and b/public/audio/09_itachi_reality.mp3 differ
diff --git a/public/audio/CREDITS.txt b/public/audio/CREDITS.txt
new file mode 100644
index 0000000..2a28a73
--- /dev/null
+++ b/public/audio/CREDITS.txt
@@ -0,0 +1,43 @@
+NARUTO SITE AUDIO — OWNER-APPROVED RECORDINGS
+
+Theme: naruto-theme.mp3
+ Supplied by the owner as "naruto Theme .mp3" on 2026-10-15.
+ Original upload metadata labels it "Itachi Theme Ringtone"; the supplied track
+ is used as requested. Circular crossfade at the loop boundary, normalized.
+Transformation: mangekyo.mp3
+ Supplied by the owner as "Sharingan.mp3" on 2026-10-15.
+ Leading silence trimmed; normalized; short edge fades.
+Normal-eye return: sharingan.mp3
+ https://www.myinstants.com/en/instant/sharingan-begin-9776/
+ Retrieved 2026-10-14; existing short reference retained.
+
+Owner's three dialogue uploads:
+07_madara_despair.mp3: "Madara.mp3" (spoken excerpt, music padding removed).
+08_jiraiya_shinobi_life.mp3: "Jiraya tale of gallent.mp3".
+09_itachi_reality.mp3: "Itachi's word.mp3" (repeated opening at end removed).
+
+Six third-party English dialogue candidates accepted by the owner:
+01_obito_heart.mp3
+ https://www.myinstants.com/en/instant/obito-sad-speech-72170/
+02_itachi_weak_hate.mp3
+ https://www.myinstants.com/en/instant/you-are-still-to-weak-itachi-naruto-part-1-69952/
+03_itachi_genjutsu.mp3
+ https://www.myinstants.com/en/instant/itachi-genjutsu-28835/
+04_obito_love_hate.mp3
+ https://tuna.voicemod.net/sound/0b1d4983-9698-4ad1-b8f2-ca4504060e08
+05_madara_reality.mp3
+ https://tuna.voicemod.net/sound/f1330738-777d-4a7f-961b-82a4ce2e9f12
+06_pain_partial.mp3
+ https://tuna.voicemod.net/sound/67f4da9f-ffe3-43de-8628-df03c9122349
+ English variant omits "contemplate pain".
+Retrieved 2026-10-15. Character labels follow the supplied/source attribution.
+These third-party uploads are not independently authenticated original-cast
+masters. The owner explicitly accepted these recordings for the project.
+
+Dialogue processing: light rumble/hiss reduction, gentle speech EQ, edge fades,
+two-pass loudness normalization near -18 LUFS, 48 kHz / 192 kb/s stereo MP3.
+Existing mixed-in music, reverb and source artifacts may remain. No voice
+synthesis, voice cloning, pitch shifting, or replacement music was used.
+Measured levels and trim notes are recorded in audio-report.json.
+Naruto and the recordings belong to their respective rights holders.
+Soundboard availability does not itself grant a Creative Commons license.
diff --git a/public/audio/audio-report.json b/public/audio/audio-report.json
new file mode 100644
index 0000000..db3e431
--- /dev/null
+++ b/public/audio/audio-report.json
@@ -0,0 +1,128 @@
+[
+ {
+ "file": "01_obito_heart.mp3",
+ "source": "https://www.myinstants.com/en/instant/obito-sad-speech-72170/",
+ "start_seconds": 0,
+ "end_seconds": 7.44,
+ "duration_seconds": 7.44,
+ "loudness_LUFS": -18.26,
+ "true_peak_dBTP": -5.76,
+ "notes": "English matching excerpt; upload is an edit with music; unrelated singing after the requested dialogue was excluded.",
+ "sha256": "afa58b260bcf06ddd796ddfc2bb251fb4c54a91da5fb1d63a01b771460d34891"
+ },
+ {
+ "file": "02_itachi_weak_hate.mp3",
+ "source": "https://www.myinstants.com/en/instant/you-are-still-to-weak-itachi-naruto-part-1-69952/",
+ "start_seconds": 0,
+ "end_seconds": 5.851,
+ "duration_seconds": 5.832,
+ "loudness_LUFS": -18.26,
+ "true_peak_dBTP": -5.86,
+ "notes": "English matching excerpt; unrelated following dialogue excluded.",
+ "sha256": "fb7f6801ecc390cf5f55cbe6575b24c5035beb620599de71234da847e204e150"
+ },
+ {
+ "file": "03_itachi_genjutsu.mp3",
+ "source": "https://www.myinstants.com/en/instant/itachi-genjutsu-28835/",
+ "start_seconds": 0,
+ "end_seconds": 3.024,
+ "duration_seconds": 3.024,
+ "loudness_LUFS": -18.28,
+ "true_peak_dBTP": -5.95,
+ "notes": "English matching excerpt.",
+ "sha256": "c61791d3379c41b98aa0d3e03ecf6d54474b33514a3edf0d308956a6093e1924"
+ },
+ {
+ "file": "04_obito_love_hate.mp3",
+ "source": "https://tuna.voicemod.net/sound/0b1d4983-9698-4ad1-b8f2-ca4504060e08",
+ "start_seconds": 0,
+ "end_seconds": 6.6,
+ "duration_seconds": 6.6,
+ "loudness_LUFS": -18.27,
+ "true_peak_dBTP": -7.1,
+ "notes": "English matching excerpt. The second requested love/hate wording is treated as an alternate translation, not a separate verified clip.",
+ "sha256": "795ebbee81d4c96ba2d674f1c0fd855e760ffb478ee0fb91231b63ebf941c760"
+ },
+ {
+ "file": "05_madara_reality.mp3",
+ "source": "https://tuna.voicemod.net/sound/f1330738-777d-4a7f-961b-82a4ce2e9f12",
+ "start_seconds": 0,
+ "end_seconds": 17.04,
+ "duration_seconds": 17.04,
+ "loudness_LUFS": -18.38,
+ "true_peak_dBTP": -2.27,
+ "notes": "English dub wording differs slightly from the supplied text. Only the requested opening passage is included.",
+ "sha256": "f7c5f4d8a06e7699d69350897b8af7dcd1a51638ff99c491b9fe2508dd89648c"
+ },
+ {
+ "file": "06_pain_partial.mp3",
+ "source": "https://tuna.voicemod.net/sound/67f4da9f-ffe3-43de-8628-df03c9122349",
+ "start_seconds": 0,
+ "end_seconds": 5.538,
+ "duration_seconds": 5.544,
+ "loudness_LUFS": -18.25,
+ "true_peak_dBTP": -3.89,
+ "notes": "PARTIAL: English clip does not contain contemplate pain. Do not label it a complete exact match.",
+ "sha256": "590b59bf406be07eea9985c4d90838afacb564fd55a45cd66b67094849cf1746"
+ },
+ {
+ "file": "07_madara_despair.mp3",
+ "source": "User upload: Madara.mp3",
+ "start_seconds": 5.9,
+ "end_seconds": 11.25,
+ "duration_seconds": 5.376,
+ "loudness_LUFS": -18.26,
+ "true_peak_dBTP": -5.84,
+ "notes": "Speech excerpt; non-dialogue introduction and outro omitted.",
+ "sha256": "6e6caa1fa16549289cc0ed8df0b6d1b26ec9e5645ba46d83b9f1e3eaa1662b10"
+ },
+ {
+ "file": "08_jiraiya_shinobi_life.mp3",
+ "source": "User upload: Jiraya tale of gallent.mp3",
+ "start_seconds": 0.35,
+ "end_seconds": 15.3,
+ "duration_seconds": 14.976,
+ "loudness_LUFS": -18.61,
+ "true_peak_dBTP": -2.71,
+ "notes": "Dialogue retained with brief head and tail padding.",
+ "sha256": "9a89ea0854ad40cab3440ddcd2d5d92774f430b6d5f960d4e6605ec30559b523"
+ },
+ {
+ "file": "09_itachi_reality.mp3",
+ "source": "User upload: Itachi's word.mp3",
+ "start_seconds": 0.55,
+ "end_seconds": 23.18,
+ "duration_seconds": 22.656,
+ "loudness_LUFS": -18.26,
+ "true_peak_dBTP": -2.79,
+ "notes": "Reality monologue; repeated opening at the end omitted.",
+ "sha256": "187d747d91a0df9a6b25050bf4512e5b5afecc73317df178eaef8c08965aba7a"
+ },
+ {
+ "file": "naruto-theme.mp3",
+ "source": "Owner upload: naruto Theme .mp3",
+ "duration_seconds": 28.56,
+ "loudness_LUFS": -18.27,
+ "true_peak_dBTP": -7.76,
+ "notes": "500 ms circular crossfade, re-encoded, normalized near -18 LUFS; no replacement music.",
+ "sha256": "0ba49a29efdd10f2199dc7871e61d6410d624a0542844925356dc94ee3ff67a2"
+ },
+ {
+ "file": "mangekyo.mp3",
+ "source": "Owner upload: Sharingan.mp3",
+ "duration_seconds": 2.064,
+ "loudness_LUFS": -18.24,
+ "true_peak_dBTP": -4.54,
+ "notes": "300 ms leading silence removed; low rumble reduced; short edge fades; normalized near -18 LUFS.",
+ "sha256": "b080b54e8bca1004604da9f150e355910acbea110cf9ebb4e8e56091caf6b344"
+ },
+ {
+ "file": "sharingan.mp3",
+ "source": "https://www.myinstants.com/en/instant/sharingan-begin-9776/",
+ "duration_seconds": 2.09,
+ "loudness_LUFS": -17.97,
+ "true_peak_dBTP": -7.51,
+ "notes": "Existing normal-eye return reference, retained from the draft.",
+ "sha256": "045514b8c4023126891561ab0f7f1d6a126d183666771a0e55241ffbd0df2742"
+ }
+]
diff --git a/public/audio/mangekyo.mp3 b/public/audio/mangekyo.mp3
new file mode 100644
index 0000000..25e88fb
Binary files /dev/null and b/public/audio/mangekyo.mp3 differ
diff --git a/public/audio/naruto-theme.mp3 b/public/audio/naruto-theme.mp3
new file mode 100644
index 0000000..ea7aa57
Binary files /dev/null and b/public/audio/naruto-theme.mp3 differ
diff --git a/public/audio/sharingan.mp3 b/public/audio/sharingan.mp3
new file mode 100644
index 0000000..c66e113
Binary files /dev/null and b/public/audio/sharingan.mp3 differ
diff --git a/src/app/api/site-audio/__tests__/route.test.ts b/src/app/api/site-audio/__tests__/route.test.ts
new file mode 100644
index 0000000..cff2b8f
--- /dev/null
+++ b/src/app/api/site-audio/__tests__/route.test.ts
@@ -0,0 +1,33 @@
+/** @jest-environment node */
+import { NextRequest } from 'next/server';
+import { GET, PUT } from '../route';
+import { requireAdminSession } from '@/lib/admin-auth';
+import { prisma } from '@/lib/prisma';
+import { DEFAULT_AUDIO_SETTINGS } from '@/lib/site-audio-library';
+jest.mock('@/lib/admin-auth', () => ({ requireAdminSession: jest.fn() }));
+jest.mock('@/lib/prisma', () => ({ prisma: { siteAudioConfig: { findUnique: jest.fn(), upsert: jest.fn() } } }));
+jest.mock('@/lib/api', () => ({ readJson: (r: Request) => r.json().catch(() => undefined), jsonError: (error: string, status: number) => Response.json({ error }, { status }) }));
+const req = (body: unknown) => new NextRequest('http://localhost/api/site-audio', { method: 'PUT', body: JSON.stringify(body) });
+beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(requireAdminSession).mockResolvedValue(null);
+ jest.mocked(prisma.siteAudioConfig.findUnique).mockResolvedValue(null);
+});
+test('public read returns defaults before the first admin save; writes require admin', async () => {
+ expect(await (await GET()).json()).toEqual(DEFAULT_AUDIO_SETTINGS);
+ jest.mocked(requireAdminSession).mockResolvedValue(Response.json({}, { status: 401 }) as never);
+ expect((await PUT(req(DEFAULT_AUDIO_SETTINGS))).status).toBe(401);
+ expect(prisma.siteAudioConfig.upsert).not.toHaveBeenCalled();
+});
+test('admin saves a durable singleton; a separate public read receives the saved settings only', async () => {
+ const config = { ...DEFAULT_AUDIO_SETTINGS, music: 0.3, intervalSeconds: 35, enabledDialogueIds: [] };
+ jest.mocked(prisma.siteAudioConfig.upsert).mockResolvedValue({ id: 'site-audio', ...config });
+ expect((await PUT(req(config))).status).toBe(200);
+ expect(prisma.siteAudioConfig.upsert).toHaveBeenCalledWith({ where: { id: 'site-audio' }, update: config, create: { id: 'site-audio', ...config } });
+ jest.mocked(prisma.siteAudioConfig.findUnique).mockResolvedValue({ id: 'site-audio', ...config });
+ expect(await (await GET()).json()).toEqual(config);
+});
+test.each([{ music: 2 }, { intervalSeconds: 0 }, { fadeSeconds: -1 }, { enabledDialogueIds: ['external'] }, { enabledDialogueIds: ['pain', 'pain'] }, { src: 'https://example.com/file.mp3' }])('rejects malformed settings %o', async patch => {
+ expect((await PUT(req({ ...DEFAULT_AUDIO_SETTINGS, ...patch }))).status).toBe(400);
+ expect(prisma.siteAudioConfig.upsert).not.toHaveBeenCalled();
+});
diff --git a/src/app/api/site-audio/route.ts b/src/app/api/site-audio/route.ts
new file mode 100644
index 0000000..7a295e8
--- /dev/null
+++ b/src/app/api/site-audio/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { prisma } from '@/lib/prisma';
+import { requireAdminSession } from '@/lib/admin-auth';
+import { jsonError, readJson } from '@/lib/api';
+import { DIALOGUES, parseAudioSettings } from '@/lib/site-audio-library';
+
+export const dynamic = 'force-dynamic';
+const schema = z.object({
+ music: z.number().min(0).max(1), dialogue: z.number().min(0).max(1),
+ effects: z.number().min(0).max(1), randomDialogue: z.boolean(),
+ intervalSeconds: z.number().int().min(1).max(600), fadeSeconds: z.number().min(0).max(30),
+ enabledDialogueIds: z.array(z.enum(DIALOGUES.map(q => q.id))).max(DIALOGUES.length)
+ .refine(ids => new Set(ids).size === ids.length, 'Duplicate dialogue IDs.'),
+}).strict();
+
+/** Public, non-sensitive playback values only; no visitor write access. */
+export async function GET() {
+ try {
+ const config = await prisma.siteAudioConfig.findUnique({ where: { id: 'site-audio' } });
+ return NextResponse.json(parseAudioSettings(config), { headers: { 'Cache-Control': 'no-store' } });
+ } catch { return jsonError('Could not load audio settings.', 503); }
+}
+
+export async function PUT(request: NextRequest) {
+ const denied = await requireAdminSession(request);
+ if (denied) return denied;
+ const parsed = schema.safeParse(await readJson(request));
+ if (!parsed.success) return jsonError('Invalid audio settings.', 400);
+ try {
+ const config = await prisma.siteAudioConfig.upsert({
+ where: { id: 'site-audio' }, update: parsed.data,
+ create: { id: 'site-audio', ...parsed.data },
+ });
+ return NextResponse.json(parseAudioSettings(config));
+ } catch { return jsonError('Could not save audio settings.', 503); }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7411757..ede87bc 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import "./globals.css";
import { SiteFooter } from "@/components/site-footer";
import { getFooterData } from "@/lib/portfolio-data";
import { ExperienceGate } from "@/components/cyber/experience-gate";
+import { SiteAudio } from '@/components/cyber/site-audio';
import { CyberCursor } from "@/components/cyber/cyber-cursor";
const SITE_TITLE = 'Bikram Dey | Hack. Secure. Defend.';
@@ -68,6 +69,7 @@ export default async function RootLayout({
+
{/* CRT overlays (scanlines + grain) */}
diff --git a/src/components/admin/__tests__/audio-panel.test.tsx b/src/components/admin/__tests__/audio-panel.test.tsx
new file mode 100644
index 0000000..eb48762
--- /dev/null
+++ b/src/components/admin/__tests__/audio-panel.test.tsx
@@ -0,0 +1,29 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { AudioPanel } from '../audio-panel';
+import { api } from '../api-client';
+import { DEFAULT_AUDIO_SETTINGS, DIALOGUES } from '@/lib/site-audio-library';
+jest.mock('../api-client', () => ({ api: jest.fn(), errorMessage: (e: Error) => e.message }));
+jest.mock('lucide-react', () => ({ Loader2: () => null }));
+beforeEach(() => { jest.clearAllMocks(); jest.mocked(api).mockResolvedValue(DEFAULT_AUDIO_SETTINGS); });
+
+test('edits shared levels and playlist and persists them through the protected API', async () => {
+ const { container } = render( );
+ await screen.findByRole('button', { name: 'Save audio settings' });
+ expect(container.querySelectorAll('audio')).toHaveLength(DIALOGUES.length);
+ fireEvent.change(screen.getByRole('slider', { name: 'music volume' }), { target: { value: '65' } });
+ fireEvent.change(screen.getByLabelText('Gap after each dialogue (seconds)'), { target: { value: '30' } });
+ fireEvent.click(screen.getAllByRole('checkbox')[0]);
+ fireEvent.click(screen.getByRole('button', { name: 'Save audio settings' }));
+ await waitFor(() => expect(api).toHaveBeenCalledWith('/api/site-audio', { method: 'PUT', body: {
+ ...DEFAULT_AUDIO_SETTINGS, music: 0.65, intervalSeconds: 30,
+ enabledDialogueIds: DEFAULT_AUDIO_SETTINGS.enabledDialogueIds.slice(1),
+ } }));
+ expect(await screen.findByRole('status')).toHaveTextContent('saved for all visitors');
+});
+
+test('a failed load cannot overwrite unknown saved settings with defaults', async () => {
+ jest.mocked(api).mockRejectedValue(new Error('Settings unavailable'));
+ render( );
+ expect(await screen.findByRole('alert')).toHaveTextContent('Settings unavailable');
+ expect(screen.queryByRole('button', { name: 'Save audio settings' })).not.toBeInTheDocument();
+});
diff --git a/src/components/admin/audio-panel.tsx b/src/components/admin/audio-panel.tsx
new file mode 100644
index 0000000..326ba87
--- /dev/null
+++ b/src/components/admin/audio-panel.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { AUDIO_SETTINGS_EVENT, DEFAULT_AUDIO_SETTINGS, DIALOGUES, parseAudioSettings, type AudioSettings } from '@/lib/site-audio-library';
+import { api, errorMessage } from './api-client';
+import { Btn, Field, Panel, TextInput, Toggle } from './ui';
+
+export function AudioPanel() {
+ const [settings, setSettings] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const [error, setError] = useState(null);
+ const load = async () => {
+ setBusy(true);
+ try { setSettings(parseAudioSettings(await api('/api/site-audio'))); setError(null); }
+ catch (e) { setError(errorMessage(e)); }
+ finally { setBusy(false); }
+ };
+ useEffect(() => { void load(); }, []);
+ const save = async () => {
+ if (!settings) return;
+ setBusy(true); setNotice(null);
+ try {
+ setSettings(parseAudioSettings(await api('/api/site-audio', { method: 'PUT', body: settings })));
+ window.dispatchEvent(new Event(AUDIO_SETTINGS_EVENT));
+ setNotice('Audio settings saved for all visitors. Open pages refresh within one minute.'); setError(null);
+ } catch (e) { setError(errorMessage(e)); }
+ finally { setBusy(false); }
+ };
+ return
+ {error && {error}
}
+ {notice && {notice}
}
+ {!settings ? void load()}>Reload audio settings : <>
+
+
+ {(['music', 'dialogue', 'effects'] as const).map(channel =>
+ setSettings({ ...settings, [channel]: Number(e.target.value)/100 })}
+ className="h-8 w-full accent-[#e3140f]" />
+ )}
+
+
+
+ setSettings({ ...settings, intervalSeconds: Number(e.target.value) })} />
+
+
+ setSettings({ ...settings, fadeSeconds: Number(e.target.value) })} />
+
+
+ setSettings({ ...settings, randomDialogue })} />Automatic character dialogue
+
+
Dialogue playlist
+
Enable individual recordings and preview every clip here. Preview playback does not change the saved playlist.
+ {DIALOGUES.map(quote =>
)}
+
+
+ void save()}>Save audio settings
+ { setSettings({ ...DEFAULT_AUDIO_SETTINGS, enabledDialogueIds: [...DEFAULT_AUDIO_SETTINGS.enabledDialogueIds] }); setNotice('Defaults restored in this form. Save to apply.'); }}>Restore defaults
+
+
+ Theme loops throughout browsing beneath the voices. Browsers may require the first tap or keypress before audio starts. Muting the public theme button leaves dialogue and effects active.
+ >}
+ ;
+}
diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx
index 60a9b44..c1afa74 100644
--- a/src/components/admin/dashboard.tsx
+++ b/src/components/admin/dashboard.tsx
@@ -17,6 +17,7 @@ import {
Upload,
User,
Wrench,
+ Volume2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { api } from './api-client';
@@ -28,6 +29,7 @@ import { GitHubPanel } from './github-panel';
import { InboxPanel } from './inbox-panel';
import { ImportPanel } from './import-panel';
import { SecurityPanel } from './security-panel';
+import { AudioPanel } from './audio-panel';
import { RequestsPanel } from './requests-panel';
import { CURRENCIES, FINDING_PLATFORMS, SEVERITIES, formatMoney } from '@/lib/bounty';
import { Btn } from './ui';
@@ -184,7 +186,8 @@ type TabId =
| 'requests'
| 'inbox'
| 'import'
- | 'security';
+ | 'security'
+ | 'audio';
const TABS: { id: TabId; label: string; icon: ComponentType<{ className?: string }> }[] = [
{ id: 'profile', label: 'Profile', icon: User },
@@ -197,6 +200,7 @@ const TABS: { id: TabId; label: string; icon: ComponentType<{ className?: string
{ id: 'certificates', label: 'Certificates', icon: Award },
{ id: 'skills', label: 'Skills', icon: Wrench },
{ id: 'blog', label: 'Blog', icon: FileText },
+ { id: 'audio', label: 'Site audio', icon: Volume2 },
{ id: 'requests', label: 'Requests', icon: Handshake },
{ id: 'inbox', label: 'Inbox', icon: Inbox },
{ id: 'import', label: 'LinkedIn import', icon: Upload },
@@ -317,6 +321,7 @@ export function AdminDashboard({ onLogout }: { onLogout: () => void }) {
{tab === 'requests' && }
{tab === 'import' && }
{tab === 'security' && }
+ {tab === 'audio' && }
{tab === 'blog' && setBlogVersion(v => v + 1)} />}
{tab in COLLECTIONS && }
diff --git a/src/components/cyber/__tests__/site-audio.test.tsx b/src/components/cyber/__tests__/site-audio.test.tsx
new file mode 100644
index 0000000..ab4acc0
--- /dev/null
+++ b/src/components/cyber/__tests__/site-audio.test.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render, screen, act } from '@testing-library/react';
+import { SiteAudio } from '../site-audio';
+import { DEFAULT_AUDIO_SETTINGS, THEME_MUTE_KEY, SHARINGAN_AUDIO_EVENT } from '@/lib/site-audio-library';
+
+jest.mock('lucide-react', () => ({ Volume2: () => null, VolumeX: () => null }));
+const mockEnable = jest.fn();
+const mockDisable = jest.fn();
+const mockSettings = jest.fn();
+const mockThemeMute = jest.fn();
+const mockUnlock = jest.fn();
+const mockEffect = jest.fn();
+const mockDispose = jest.fn();
+const mockVisibility = jest.fn();
+jest.mock('@/lib/site-audio', () => ({
+ SiteAudioEngine: jest.fn().mockImplementation(() => ({
+ enable: mockEnable, disable: mockDisable, setSettings: mockSettings,
+ setThemeMuted: mockThemeMute, unlock: mockUnlock, playSharingan: mockEffect,
+ dispose: mockDispose, visibilityChanged: mockVisibility,
+ })),
+}));
+const originalFetch = global.fetch;
+beforeEach(() => {
+ jest.clearAllMocks(); jest.useFakeTimers(); localStorage.clear();
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => DEFAULT_AUDIO_SETTINGS });
+});
+afterEach(() => { jest.useRealTimers(); global.fetch = originalFetch; });
+
+test('autostarts with admin settings and exposes only a theme mute button', async () => {
+ const view = render( );
+ await act(async () => {});
+ expect(mockEnable).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByRole('button')).toHaveLength(1);
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ expect(screen.queryByText('Play a dialogue')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Mute theme music' }));
+ expect(mockThemeMute).toHaveBeenLastCalledWith(true);
+ expect(localStorage.getItem(THEME_MUTE_KEY)).toBe('true');
+ expect(mockDisable).not.toHaveBeenCalled();
+ act(() => window.dispatchEvent(new CustomEvent(SHARINGAN_AUDIO_EVENT, { detail: 'mangekyo' })));
+ expect(mockEffect).toHaveBeenCalledWith('mangekyo');
+ fireEvent.pointerDown(window);
+ expect(mockUnlock).toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Unmute theme music' }));
+ expect(mockThemeMute).toHaveBeenLastCalledWith(false);
+ view.unmount(); expect(mockDispose).toHaveBeenCalledTimes(1);
+});
+
+test('visitor volume overrides are ignored; only theme mute persists', async () => {
+ localStorage.setItem('nd-audio-settings-v1', JSON.stringify({ music: 0, dialogue: 0, effects: 0 }));
+ localStorage.setItem(THEME_MUTE_KEY, 'true');
+ const settings = { ...DEFAULT_AUDIO_SETTINGS, music: 0.4, intervalSeconds: 25 };
+ jest.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => settings } as Response);
+ render( ); await act(async () => {});
+ expect(mockThemeMute).toHaveBeenCalledWith(true);
+ expect(mockSettings).toHaveBeenLastCalledWith(settings);
+ expect(screen.getByRole('button', { name: 'Unmute theme music' })).toBeVisible();
+});
diff --git a/src/components/cyber/sharingan.tsx b/src/components/cyber/sharingan.tsx
index 0bd4299..0b33ce9 100644
--- a/src/components/cyber/sharingan.tsx
+++ b/src/components/cyber/sharingan.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
+import { sharinganSound } from '@/lib/site-audio-library';
import * as THREE from 'three';
import { EXPERIENCE_EVENT, getExperienceMode, type ExperienceMode } from './experience-gate';
@@ -410,6 +411,7 @@ export function SharinganEye({ className }: { className?: string }) {
let burst = 0;
let mangeUntil = 0;
const onActivate = () => {
+ if (performance.now() >= mangeUntil) sharinganSound('mangekyo');
burst = 1;
mangeUntil = performance.now() + 3500;
};
@@ -428,6 +430,7 @@ export function SharinganEye({ className }: { className?: string }) {
let nextBlink = performance.now() + 2500;
let blinkStart = -1;
let nextIdleBurst = performance.now() + 9000;
+ let wasMangekyo = false;
const tick = () => {
raf = requestAnimationFrame(tick);
@@ -450,6 +453,8 @@ export function SharinganEye({ className }: { className?: string }) {
const pupilTarget = 0.2 - burst * 0.06 + Math.min(scroll, 1) * 0.05;
eyeUniforms.uPupil.value += (pupilTarget - eyeUniforms.uPupil.value) * 0.12;
const mangeTarget = now < mangeUntil ? 1 : 0;
+ if (!mangeTarget && wasMangekyo) sharinganSound('sharingan');
+ wasMangekyo = !!mangeTarget;
eyeUniforms.uMange.value += (mangeTarget - eyeUniforms.uMange.value) * 0.08;
// blink
diff --git a/src/components/cyber/site-audio.tsx b/src/components/cyber/site-audio.tsx
new file mode 100644
index 0000000..eb8db96
--- /dev/null
+++ b/src/components/cyber/site-audio.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { Volume2, VolumeX } from 'lucide-react';
+import {
+ AUDIO_SETTINGS_EVENT, SHARINGAN_AUDIO_EVENT, THEME_MUTE_KEY,
+ parseAudioSettings, type SharinganSound,
+} from '@/lib/site-audio-library';
+import { SiteAudioEngine } from '@/lib/site-audio';
+
+/** Public visitors control only their own theme mute preference. */
+export function SiteAudio() {
+ const engine = useRef(null);
+ const [muted, setMuted] = useState(false);
+
+ useEffect(() => {
+ const mixer = new SiteAudioEngine(() => {});
+ engine.current = mixer;
+ let saved = false;
+ try { saved = localStorage.getItem(THEME_MUTE_KEY) === 'true'; } catch {}
+ mixer.setThemeMuted(saved);
+ setMuted(saved);
+ const controller = new AbortController();
+ const load = async () => {
+ try {
+ const response = await fetch('/api/site-audio', {
+ cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
+ });
+ if (!response.ok) throw new Error('Settings unavailable');
+ const settings = parseAudioSettings(await response.json());
+ if (!controller.signal.aborted) mixer.setSettings(settings);
+ } catch { /* Defaults keep audio working if settings cannot load. */ }
+ };
+ void load().then(() => { if (!controller.signal.aborted) void mixer.enable(); });
+ const refresh = setInterval(() => { if (!document.hidden) void load(); }, 60_000);
+ const onSettings = () => { void load(); };
+ const onGesture = () => mixer.unlock();
+ const onEye = (event: Event) => {
+ const phase = (event as CustomEvent).detail;
+ if (phase === 'sharingan' || phase === 'mangekyo') void mixer.playSharingan(phase);
+ };
+ const onVisibility = () => { void mixer.visibilityChanged(); };
+ window.addEventListener('pointerdown', onGesture, { capture: true, passive: true });
+ window.addEventListener('keydown', onGesture, true);
+ window.addEventListener(SHARINGAN_AUDIO_EVENT, onEye);
+ window.addEventListener(AUDIO_SETTINGS_EVENT, onSettings);
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => {
+ controller.abort(); clearInterval(refresh);
+ window.removeEventListener('pointerdown', onGesture, true);
+ window.removeEventListener('keydown', onGesture, true);
+ window.removeEventListener(SHARINGAN_AUDIO_EVENT, onEye);
+ window.removeEventListener(AUDIO_SETTINGS_EVENT, onSettings);
+ document.removeEventListener('visibilitychange', onVisibility);
+ mixer.dispose(); engine.current = null;
+ };
+ }, []);
+
+ const toggle = () => {
+ const next = !muted;
+ engine.current?.setThemeMuted(next);
+ setMuted(next);
+ try { localStorage.setItem(THEME_MUTE_KEY, String(next)); } catch {}
+ };
+
+ return (
+
+ {muted ? : }
+
+ );
+}
diff --git a/src/lib/__tests__/site-audio.test.ts b/src/lib/__tests__/site-audio.test.ts
new file mode 100644
index 0000000..0f658cf
--- /dev/null
+++ b/src/lib/__tests__/site-audio.test.ts
@@ -0,0 +1,223 @@
+import { SiteAudioEngine, type AudioStatus } from '../site-audio';
+import { DEFAULT_AUDIO_SETTINGS, DIALOGUES, MUSIC_TRACK, nextDialogueIndex, parseAudioSettings } from '../site-audio-library';
+import { existsSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const sources: Array<{
+ buffer: { duration: number } | null; loop: boolean;
+ connect: jest.Mock; disconnect: jest.Mock; start: jest.Mock; stop: jest.Mock;
+ onended: (() => void) | null;
+}> = [];
+const gains: Array<{ gain: { value: number; setTargetAtTime: jest.Mock; setValueAtTime: jest.Mock; cancelScheduledValues: jest.Mock; linearRampToValueAtTime: jest.Mock }; connect: jest.Mock }> = [];
+const resume = jest.fn().mockResolvedValue(undefined);
+const suspend = jest.fn().mockResolvedValue(undefined);
+const close = jest.fn().mockResolvedValue(undefined);
+const originalFetch = global.fetch;
+const originalContext = window.AudioContext;
+let statuses: AudioStatus[] = [];
+let engine: SiteAudioEngine;
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ sources.length = 0;
+ gains.length = 0;
+ statuses = [];
+ jest.clearAllMocks();
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+ window.AudioContext = jest.fn().mockImplementation(() => ({
+ currentTime: 12,
+ state: 'running',
+ destination: {},
+ resume, suspend, close,
+ decodeAudioData: jest.fn().mockResolvedValue({ duration: 160 }),
+ createDynamicsCompressor: () => ({ threshold: {}, knee: {}, ratio: {}, attack: {}, release: {}, connect: jest.fn() }),
+ createGain: () => {
+ const gain = { gain: { value: 1, setTargetAtTime: jest.fn(), setValueAtTime: jest.fn(), cancelScheduledValues: jest.fn(), linearRampToValueAtTime: jest.fn() }, connect: jest.fn() };
+ gains.push(gain);
+ return gain;
+ },
+ createBufferSource: () => {
+ const source = {
+ buffer: null, loop: false, connect: jest.fn(), disconnect: jest.fn(),
+ start: jest.fn(), stop: jest.fn(), onended: null,
+ };
+ sources.push(source);
+ return source;
+ },
+ }));
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(1) });
+ engine = new SiteAudioEngine((status) => statuses.push(status));
+});
+afterEach(() => {
+ engine.dispose();
+ global.fetch = originalFetch;
+ window.AudioContext = originalContext;
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+ jest.useRealTimers();
+});
+
+test('silent until opt-in; independent louder dialogue leaves looping music running', async () => {
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect(window.AudioContext).not.toHaveBeenCalled();
+ await engine.enable();
+ expect(sources).toHaveLength(1);
+ expect(sources[0].loop).toBe(true);
+ expect(gains[0].gain.value).toBe(0);
+ expect(gains[0].gain.linearRampToValueAtTime).toHaveBeenLastCalledWith(0.75, 16);
+ expect(gains[1].gain.value).toBe(1);
+ await engine.playDialogue();
+ expect(sources).toHaveLength(2);
+ expect(sources[0].stop).not.toHaveBeenCalled();
+ expect(sources[0].start).toHaveBeenCalledTimes(1);
+ expect(statuses.at(-1)?.quote).toBeTruthy();
+ // A second request cannot stack another voice on the current dialogue.
+ await engine.playDialogue();
+ expect(sources).toHaveLength(2);
+ sources[1].onended?.();
+ expect(statuses.at(-1)?.quote).toBeNull();
+});
+
+test('mute stops music, voice and effects; returning to the page stays silent', async () => {
+ await engine.enable();
+ await engine.playDialogue();
+ await engine.playSharingan('mangekyo');
+ engine.disable();
+ expect(sources.every((source) => source.stop.mock.calls.length === 1)).toBe(true);
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+ await engine.visibilityChanged();
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+ await engine.visibilityChanged();
+ expect(resume).toHaveBeenCalledTimes(1);
+ expect(statuses.at(-1)?.enabled).toBe(false);
+});
+
+test('late music cannot start after the visitor mutes while loading', async () => {
+ let finish!: (value: Response) => void;
+ jest.mocked(global.fetch).mockImplementation((url) => String(url) === MUSIC_TRACK
+ ? new Promise((resolve) => { finish = resolve; }) as Promise
+ : Promise.resolve({ ok: true, arrayBuffer: async () => new ArrayBuffer(1) } as Response));
+ const starting = engine.enable();
+ engine.disable();
+ finish({ ok: true, arrayBuffer: async () => new ArrayBuffer(1) } as Response);
+ await starting;
+ expect(sources).toHaveLength(0);
+ expect(statuses.at(-1)?.enabled).toBe(false);
+});
+
+test('background tabs suspend the mixer and cancel speech; resume keeps the same music source', async () => {
+ await engine.enable();
+ await engine.playDialogue();
+ const music = sources[0];
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+ await engine.visibilityChanged();
+ expect(suspend).toHaveBeenCalled();
+ expect(sources[1].stop).toHaveBeenCalled();
+ await engine.playDialogue();
+ await engine.playSharingan('sharingan');
+ expect(sources).toHaveLength(2);
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+ await engine.visibilityChanged();
+ expect(resume).toHaveBeenCalledTimes(2);
+ expect(music.stop).not.toHaveBeenCalled();
+ expect(music.start).toHaveBeenCalledTimes(1);
+});
+
+test('missing music leaves an honest retry state; effect failures do not stop music', async () => {
+ jest.mocked(global.fetch).mockImplementation((url) => Promise.resolve({
+ ok: String(url) !== MUSIC_TRACK, arrayBuffer: async () => new ArrayBuffer(1),
+ } as Response));
+ await engine.enable();
+ expect(statuses.at(-1)?.enabled).toBe(false);
+ expect(statuses.at(-1)?.error).toMatch(/retry/);
+ expect(sources).toHaveLength(0);
+ jest.mocked(global.fetch).mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(1) } as Response);
+ await engine.enable();
+ expect(statuses.at(-1)?.enabled).toBe(true);
+ expect(statuses.at(-1)?.loading).toBe(false);
+ engine.setSettings({ ...DEFAULT_AUDIO_SETTINGS, effects: 0 });
+ await engine.playSharingan('mangekyo');
+ expect(sources).toHaveLength(1);
+ expect(sources[0].stop).not.toHaveBeenCalled();
+});
+
+test('dialogues never immediately repeat and saved settings are validated', () => {
+ for (let previous = 0; previous < DIALOGUES.length; previous++) {
+ for (const random of [0, 0.2, 0.5, 0.999]) {
+ expect(nextDialogueIndex(previous, random)).not.toBe(previous);
+ }
+ }
+ expect(parseAudioSettings('broken')).toEqual(DEFAULT_AUDIO_SETTINGS);
+ expect(parseAudioSettings('{"music":9,"dialogue":-1,"effects":"loud","randomDialogue":false}'))
+ .toEqual({ ...DEFAULT_AUDIO_SETTINGS, music: 1, dialogue: 0, effects: 0.95, randomDialogue: false });
+});
+
+test('the shipped playlist, looping theme, and both eye effects resolve to nonempty local assets', () => {
+ for (const src of [MUSIC_TRACK, '/audio/mangekyo.mp3', '/audio/sharingan.mp3', ...DIALOGUES.map((quote) => quote.src)]) {
+ const file = join(process.cwd(), 'public', src);
+ expect(existsSync(file)).toBe(true);
+ expect(statSync(file).size).toBeGreaterThan(1_000);
+ }
+});
+
+test('turning off dialogue cancels a voice still being downloaded', async () => {
+ await engine.enable();
+ let finish!: (value: Response) => void;
+ jest.mocked(global.fetch).mockImplementation(() => new Promise((resolve) => { finish = resolve; }) as Promise);
+ const playing = engine.playDialogue();
+ engine.setSettings({ ...DEFAULT_AUDIO_SETTINGS, randomDialogue: false });
+ finish({ ok: true, arrayBuffer: async () => new ArrayBuffer(1) } as Response);
+ await playing;
+ expect(sources).toHaveLength(1);
+});
+
+test('theme mute leaves voices and eye effects active', async () => {
+ await engine.enable();
+ engine.setThemeMuted(true);
+ expect(gains[0].gain.linearRampToValueAtTime).toHaveBeenLastCalledWith(0, 12.05);
+ await engine.playDialogue();
+ await engine.playSharingan('mangekyo');
+ expect(sources).toHaveLength(3);
+ expect(gains[1].gain.value).toBe(1);
+ expect(gains[2].gain.value).toBe(0.95);
+ expect(sources[0].stop).not.toHaveBeenCalled();
+});
+
+test.each([3, 23])('waits 20 seconds AFTER a %s-second recording finishes', async seconds => {
+ await engine.enable();
+ await jest.advanceTimersByTimeAsync(19_999);
+ expect(sources).toHaveLength(1);
+ await jest.advanceTimersByTimeAsync(1);
+ expect(sources).toHaveLength(2);
+ await jest.advanceTimersByTimeAsync(seconds * 1000);
+ expect(sources).toHaveLength(2);
+ sources[1].onended?.();
+ await jest.advanceTimersByTimeAsync(19_999);
+ expect(sources).toHaveLength(2);
+ await jest.advanceTimersByTimeAsync(1);
+ expect(sources).toHaveLength(3);
+ expect(sources[0].stop).not.toHaveBeenCalled();
+});
+
+test('settings refresh does not reset an existing gap; disabled clips cannot be selected', async () => {
+ await engine.enable();
+ await jest.advanceTimersByTimeAsync(10_000);
+ engine.setSettings({ ...DEFAULT_AUDIO_SETTINGS });
+ await jest.advanceTimersByTimeAsync(10_000);
+ expect(sources).toHaveLength(2);
+ expect(nextDialogueIndex(0, 0.5, [])).toBe(-1);
+ expect(nextDialogueIndex(0, 0.5, [DIALOGUES[4].id])).toBe(4);
+ expect(nextDialogueIndex(4, 0.5, [DIALOGUES[4].id])).toBe(4);
+});
+
+test('a real gesture unlocks a pending autoplay attempt without starting duplicate music', async () => {
+ let allow!: () => void;
+ resume.mockImplementationOnce(() => new Promise(resolve => { allow = resolve; }));
+ const starting = engine.enable();
+ const context = jest.mocked(window.AudioContext).mock.results[0].value;
+ context.state = 'suspended';
+ engine.unlock();
+ expect(resume).toHaveBeenCalledTimes(2);
+ context.state = 'running'; allow();
+ await starting;
+ expect(sources.filter(s => s.loop)).toHaveLength(1);
+});
diff --git a/src/lib/site-audio-library.ts b/src/lib/site-audio-library.ts
new file mode 100644
index 0000000..0d8cc0e
--- /dev/null
+++ b/src/lib/site-audio-library.ts
@@ -0,0 +1,69 @@
+/** Local files only: browsing never depends on a soundboard being online. */
+export const SHARINGAN_AUDIO_EVENT = 'nd:sharingan-audio';
+export type SharinganSound = 'mangekyo' | 'sharingan';
+
+export function sharinganSound(phase: SharinganSound) {
+ window.dispatchEvent(new CustomEvent(SHARINGAN_AUDIO_EVENT, { detail: phase }));
+}
+
+export const MUSIC_TRACK = '/audio/naruto-theme.mp3';
+
+export const DIALOGUES = [
+ { id: 'obito-heart', character: 'Obito', text: 'Look at me! There is nothing in my heart.', src: '/audio/01_obito_heart.mp3' },
+ { id: 'itachi-hate', character: 'Itachi', text: 'You are still too weak. You do not have enough hate.', src: '/audio/02_itachi_weak_hate.mp3' },
+ { id: 'itachi-genjutsu', character: 'Itachi', text: 'You are already under my genjutsu.', src: '/audio/03_itachi_genjutsu.mp3' },
+ { id: 'obito-love', character: 'Obito', text: 'When a man learns to feel love, he must also bear the risk of feeling hate.', src: '/audio/04_obito_love_hate.mp3' },
+ { id: 'madara-reality', character: 'Madara', text: 'Wake up to reality.', src: '/audio/05_madara_reality.mp3' },
+ { id: 'pain', character: 'Pain', text: 'Feel pain, accept pain, and know pain.', src: '/audio/06_pain_partial.mp3' },
+ { id: 'madara-despair', character: 'Madara', text: 'This world holds only despair. It is not worth existing.', src: '/audio/07_madara_despair.mp3' },
+ { id: 'jiraiya-life', character: 'Jiraiya', text: 'In the shinobi world, it is not how you live. It is how you die.', src: '/audio/08_jiraiya_shinobi_life.mp3' },
+ { id: 'itachi-reality', character: 'Itachi', text: 'Each of us lives dependent and bound by our individual knowledge and our awareness.', src: '/audio/09_itachi_reality.mp3' },
+] as const;
+
+export type Dialogue = (typeof DIALOGUES)[number];
+export type AudioSettings = {
+ music: number; dialogue: number; effects: number; randomDialogue: boolean;
+ intervalSeconds: number; fadeSeconds: number; enabledDialogueIds: string[];
+};
+export const THEME_MUTE_KEY = 'nd-theme-muted-v1';
+export const AUDIO_SETTINGS_EVENT = 'nd:audio-settings';
+export const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
+ music: 0.75, dialogue: 1, effects: 0.95, randomDialogue: true,
+ intervalSeconds: 20, fadeSeconds: 4, enabledDialogueIds: DIALOGUES.map(q => q.id),
+};
+
+export function parseAudioSettings(raw: unknown): AudioSettings {
+ try {
+ const value: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
+ if (!value || typeof value !== 'object') return { ...DEFAULT_AUDIO_SETTINGS };
+ const record = value as Record;
+ const level = (key: 'music' | 'dialogue' | 'effects') => {
+ const v = record[key];
+ return typeof v === 'number' && Number.isFinite(v)
+ ? Math.max(0, Math.min(1, v)) : DEFAULT_AUDIO_SETTINGS[key];
+ };
+ return {
+ music: level('music'), dialogue: level('dialogue'), effects: level('effects'),
+ randomDialogue: typeof record.randomDialogue === 'boolean'
+ ? record.randomDialogue : DEFAULT_AUDIO_SETTINGS.randomDialogue,
+ intervalSeconds: typeof record.intervalSeconds === 'number' && Number.isFinite(record.intervalSeconds)
+ ? Math.max(1, Math.min(600, Math.round(record.intervalSeconds))) : DEFAULT_AUDIO_SETTINGS.intervalSeconds,
+ fadeSeconds: typeof record.fadeSeconds === 'number' && Number.isFinite(record.fadeSeconds)
+ ? Math.max(0, Math.min(30, record.fadeSeconds)) : DEFAULT_AUDIO_SETTINGS.fadeSeconds,
+ enabledDialogueIds: Array.isArray(record.enabledDialogueIds)
+ ? [...new Set(record.enabledDialogueIds.filter((id): id is string =>
+ typeof id === 'string' && DIALOGUES.some(q => q.id === id)))]
+ : [...DEFAULT_AUDIO_SETTINGS.enabledDialogueIds],
+ };
+ } catch {
+ return { ...DEFAULT_AUDIO_SETTINGS };
+ }
+}
+
+/** The scheduler starts its gap after the prior recording ends. */
+export function nextDialogueIndex(previous: number, random = Math.random(), enabledIds = DEFAULT_AUDIO_SETTINGS.enabledDialogueIds): number {
+ const enabled = DIALOGUES.flatMap((q, i) => enabledIds.includes(q.id) ? [i] : []);
+ const choices = enabled.filter(i => i !== previous);
+ const pool = choices.length ? choices : enabled;
+ return pool.length ? pool[Math.min(pool.length - 1, Math.floor(random * pool.length))] : -1;
+}
diff --git a/src/lib/site-audio.ts b/src/lib/site-audio.ts
new file mode 100644
index 0000000..52b53ae
--- /dev/null
+++ b/src/lib/site-audio.ts
@@ -0,0 +1,295 @@
+import {
+ DEFAULT_AUDIO_SETTINGS, DIALOGUES, MUSIC_TRACK, nextDialogueIndex,
+ type AudioSettings, type Dialogue, type SharinganSound,
+} from './site-audio-library';
+
+export type AudioStatus = {
+ enabled: boolean; loading: boolean; error: string | null; quote: Dialogue | null;
+};
+export const SILENT_AUDIO_STATUS: AudioStatus = {
+ enabled: false, loading: false, error: null, quote: null,
+};
+
+type Channel = 'music' | 'dialogue' | 'effects';
+
+/**
+ * One mixer for the root layout. Web Audio gains also work on mobile Safari,
+ * where HTMLMediaElement.volume cannot reliably provide independent levels.
+ * Attempts autoplay on load; unlock() resumes it from a gesture when required.
+ */
+export class SiteAudioEngine {
+ private context: AudioContext | null = null;
+ private gains: Record | null = null;
+ private buffers = new Map>();
+ private music: AudioBufferSourceNode | null = null;
+ private voice: AudioBufferSourceNode | null = null;
+ private effect: AudioBufferSourceNode | null = null;
+ private themeMuted = false;
+ private dialogueTimer: ReturnType | null = null;
+ private musicOffset = 0;
+ private musicStartedAt = 0;
+ private lastDialogue = -1;
+ private voiceLoading = false;
+ private voiceRequest = 0;
+ private effectRequest = 0;
+ private epoch = 0;
+ private disposed = false;
+ private settings = { ...DEFAULT_AUDIO_SETTINGS };
+ private status = { ...SILENT_AUDIO_STATUS };
+
+ constructor(private changed: (status: AudioStatus) => void) {}
+
+ private update(patch: Partial) {
+ this.status = { ...this.status, ...patch };
+ if (!this.disposed) this.changed({ ...this.status });
+ }
+
+ private setup() {
+ if (this.context) return this.context;
+ const BrowserAudioContext = window.AudioContext ||
+ (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!BrowserAudioContext) throw new Error('Audio is unavailable in this browser.');
+ const context = new BrowserAudioContext();
+ this.context = context;
+ const music = context.createGain();
+ const dialogue = context.createGain();
+ const effects = context.createGain();
+ const output = context.createDynamicsCompressor();
+ output.threshold.value = -3;
+ output.knee.value = 0;
+ output.ratio.value = 20;
+ output.attack.value = 0.003;
+ output.release.value = 0.2;
+ output.connect(context.destination);
+ this.gains = { music, dialogue, effects };
+ for (const key of ['music', 'dialogue', 'effects'] as const) {
+ this.gains[key].gain.value = key === 'music' ? 0 : this.settings[key];
+ this.gains[key].connect(output);
+ }
+ return context;
+ }
+
+ setSettings(settings: AudioSettings) {
+ if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
+ this.settings = { ...settings, enabledDialogueIds: [...settings.enabledDialogueIds] };
+ if (this.context && this.gains) {
+ for (const key of ['dialogue', 'effects'] as const) {
+ this.gains[key].gain.setTargetAtTime(settings[key], this.context.currentTime, 0.03);
+ }
+ if (this.music) this.fadeTheme();
+ }
+ if (!settings.randomDialogue || settings.dialogue === 0 ||
+ (this.status.quote && !settings.enabledDialogueIds.includes(this.status.quote.id))) this.stopDialogue();
+ if (!this.voice && !this.voiceLoading) this.scheduleDialogue();
+ }
+
+ setThemeMuted(muted: boolean) {
+ this.themeMuted = muted;
+ if (this.music) this.fadeTheme(muted ? 0.05 : this.settings.fadeSeconds);
+ }
+
+ private fadeTheme(seconds = this.settings.fadeSeconds) {
+ if (!this.context || !this.gains) return;
+ const gain = this.gains.music.gain;
+ const now = this.context.currentTime;
+ gain.cancelScheduledValues(now);
+ gain.setValueAtTime(gain.value, now);
+ gain.linearRampToValueAtTime(this.themeMuted ? 0 : this.settings.music, now + seconds);
+ }
+
+ /** Call synchronously in a pointer/key handler for browser autoplay policy. */
+ unlock() {
+ if (this.disposed) return;
+ if (!this.status.enabled) void this.enable();
+ else if (this.context?.state === 'suspended') void this.context.resume().catch(() => {});
+ }
+
+ private clearDialogueTimer() {
+ if (this.dialogueTimer) clearTimeout(this.dialogueTimer);
+ this.dialogueTimer = null;
+ }
+
+ private scheduleDialogue() {
+ this.clearDialogueTimer();
+ if (this.disposed || !this.status.enabled || this.status.loading || document.hidden ||
+ !this.settings.randomDialogue || !this.settings.dialogue || !this.settings.enabledDialogueIds.length ||
+ this.voice || this.voiceLoading) return;
+ this.dialogueTimer = setTimeout(() => {
+ this.dialogueTimer = null;
+ const editing = document.activeElement?.matches('input, textarea, select, [contenteditable="true"]');
+ const media = Array.from(document.querySelectorAll('audio, video')).some(el => !(el as HTMLMediaElement).paused);
+ if (editing || media) this.scheduleDialogue();
+ else void this.playDialogue();
+ }, this.settings.intervalSeconds * 1000);
+ }
+
+ private buffer(src: string) {
+ let pending = this.buffers.get(src);
+ if (!pending) {
+ const context = this.context;
+ if (!context) return Promise.reject(new Error('Audio is not enabled.'));
+ pending = fetch(src, { signal: AbortSignal.timeout(20_000) })
+ .then((response) => {
+ if (!response.ok) throw new Error('Audio file unavailable.');
+ return response.arrayBuffer();
+ })
+ .then((data) => context.decodeAudioData(data))
+ .catch((error: unknown) => {
+ this.buffers.delete(src); // A failed fetch can be retried.
+ throw error;
+ });
+ this.buffers.set(src, pending);
+ }
+ return pending;
+ }
+
+ private node(buffer: AudioBuffer, channel: Channel) {
+ const node = this.context!.createBufferSource();
+ node.buffer = buffer;
+ node.connect(this.gains![channel]);
+ return node;
+ }
+
+ async enable() {
+ if (this.disposed || this.status.enabled) return;
+ const epoch = ++this.epoch;
+ this.update({ enabled: true, loading: true, error: null });
+ try {
+ const context = this.setup();
+ // Called in the click handler before the first await to unlock iOS audio.
+ const unlocked = context.resume();
+ // Tiny effects are loaded alongside music so the first eye tap is responsive.
+ void this.buffer('/audio/mangekyo.mp3').catch(() => {});
+ void this.buffer('/audio/sharingan.mp3').catch(() => {});
+ const [buffer] = await Promise.all([this.buffer(MUSIC_TRACK), unlocked]);
+ if (!this.status.enabled || this.disposed || epoch !== this.epoch) return;
+ if (document.hidden) await context.suspend();
+ if (!this.status.enabled || this.disposed || epoch !== this.epoch) return;
+ const music = this.node(buffer, 'music');
+ music.loop = true;
+ music.start(0, this.musicOffset % buffer.duration);
+ this.musicStartedAt = context.currentTime;
+ this.music = music;
+ this.fadeTheme();
+ this.update({ loading: false });
+ this.scheduleDialogue();
+ } catch {
+ if (this.disposed || epoch !== this.epoch) return;
+ this.disable();
+ this.update({ error: 'Audio could not start. Tap Sound to retry.' });
+ }
+ }
+
+ private stop(node: AudioBufferSourceNode | null) {
+ if (!node) return;
+ node.onended = null;
+ try { node.stop(); } catch { /* The source may already have ended. */ }
+ node.disconnect();
+ }
+
+ stopDialogue() {
+ this.clearDialogueTimer();
+ ++this.voiceRequest;
+ this.stop(this.voice);
+ this.voice = null;
+ if (this.status.quote) this.update({ quote: null });
+ }
+
+ disable() {
+ ++this.epoch; // In-flight downloads cannot start playback after mute.
+ ++this.effectRequest;
+ if (this.music && this.context) {
+ this.musicOffset += this.context.currentTime - this.musicStartedAt;
+ }
+ this.stop(this.music);
+ this.music = null;
+ this.stopDialogue();
+ this.stop(this.effect);
+ this.effect = null;
+ this.update({ enabled: false, loading: false, quote: null });
+ void this.context?.suspend().catch(() => {});
+ }
+
+ async visibilityChanged() {
+ if (!this.context || !this.status.enabled) return;
+ if (document.hidden) {
+ this.stopDialogue();
+ this.stop(this.effect);
+ this.effect = null;
+ ++this.effectRequest;
+ await this.context.suspend().catch(() => {});
+ } else {
+ try { await this.context.resume(); this.scheduleDialogue(); }
+ catch {
+ this.disable();
+ this.update({ error: 'Tap Sound to resume audio.' });
+ }
+ }
+ }
+
+ async playSharingan(phase: SharinganSound) {
+ if (!this.status.enabled || document.hidden || this.settings.effects === 0) return;
+ const epoch = this.epoch;
+ const request = ++this.effectRequest;
+ try {
+ const buffer = await this.buffer('/audio/' + phase + '.mp3');
+ if (epoch !== this.epoch || request !== this.effectRequest || document.hidden ||
+ !this.status.enabled || this.context?.state !== 'running') return;
+ this.stop(this.effect);
+ const effect = this.node(buffer, 'effects');
+ this.effect = effect;
+ effect.onended = () => {
+ effect.disconnect();
+ if (this.effect === effect) this.effect = null;
+ };
+ effect.start();
+ } catch {
+ // A missing effect must never break the eye or the background music.
+ }
+ }
+
+ async playDialogue() {
+ if (!this.status.enabled || this.status.loading || document.hidden ||
+ this.settings.dialogue === 0 || this.voice || this.voiceLoading) return;
+ this.voiceLoading = true;
+ const request = ++this.voiceRequest;
+ const epoch = this.epoch;
+ this.clearDialogueTimer();
+ const index = nextDialogueIndex(this.lastDialogue, Math.random(), this.settings.enabledDialogueIds);
+ if (index < 0) { this.voiceLoading = false; return; }
+ const quote = DIALOGUES[index];
+ try {
+ const buffer = await this.buffer(quote.src);
+ if (epoch !== this.epoch || request !== this.voiceRequest || !this.status.enabled || document.hidden ||
+ this.settings.dialogue === 0 || this.context?.state !== 'running') return;
+ const voice = this.node(buffer, 'dialogue');
+ this.voice = voice;
+ this.lastDialogue = index;
+ this.update({ quote });
+ voice.onended = () => {
+ voice.disconnect();
+ if (this.voice === voice) {
+ this.voice = null;
+ this.update({ quote: null });
+ this.scheduleDialogue();
+ }
+ };
+ voice.start();
+ // Music is a separate source: no stop, pause, restart, or ducking here.
+ } catch {
+ // Skip an unavailable quote and leave the music running.
+ } finally {
+ this.voiceLoading = false;
+ if (!this.voice) this.scheduleDialogue();
+ }
+ }
+
+ dispose() {
+ this.disposed = true;
+ this.disable();
+ void this.context?.close().catch(() => {});
+ this.context = null;
+ this.gains = null;
+ this.buffers.clear();
+ }
+}