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 ( + + ); +} 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(); + } +}