Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/site-audio.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Binary file added public/audio/01_obito_heart.mp3
Binary file not shown.
Binary file added public/audio/02_itachi_weak_hate.mp3
Binary file not shown.
Binary file added public/audio/03_itachi_genjutsu.mp3
Binary file not shown.
Binary file added public/audio/04_obito_love_hate.mp3
Binary file not shown.
Binary file added public/audio/05_madara_reality.mp3
Binary file not shown.
Binary file added public/audio/06_pain_partial.mp3
Binary file not shown.
Binary file added public/audio/07_madara_despair.mp3
Binary file not shown.
Binary file added public/audio/08_jiraiya_shinobi_life.mp3
Binary file not shown.
Binary file added public/audio/09_itachi_reality.mp3
Binary file not shown.
43 changes: 43 additions & 0 deletions public/audio/CREDITS.txt
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 128 additions & 0 deletions public/audio/audio-report.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
Binary file added public/audio/mangekyo.mp3
Binary file not shown.
Binary file added public/audio/naruto-theme.mp3
Binary file not shown.
Binary file added public/audio/sharingan.mp3
Binary file not shown.
33 changes: 33 additions & 0 deletions src/app/api/site-audio/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
37 changes: 37 additions & 0 deletions src/app/api/site-audio/route.ts
Original file line number Diff line number Diff line change
@@ -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); }
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -68,6 +69,7 @@ export default async function RootLayout({
<body className="font-body antialiased">
<ExperienceGate />
<CyberCursor />
<SiteAudio />
{/* CRT overlays (scanlines + grain) */}
<div aria-hidden className="pointer-events-none fixed inset-0 z-[70] scanline opacity-[0.35] mix-blend-multiply" />
<div aria-hidden className="pointer-events-none fixed inset-0 z-[70] noise opacity-[0.05]" />
Expand Down
29 changes: 29 additions & 0 deletions src/components/admin/__tests__/audio-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AudioPanel />);
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(<AudioPanel />);
expect(await screen.findByRole('alert')).toHaveTextContent('Settings unavailable');
expect(screen.queryByRole('button', { name: 'Save audio settings' })).not.toBeInTheDocument();
});
Loading
Loading