From 313b5ff7fd2e97c2a0da266de22e71d016ed64b3 Mon Sep 17 00:00:00 2001 From: denshooter Date: Mon, 16 Feb 2026 02:44:45 +0100 Subject: [PATCH] fix: singing bowls ambient + Next.js 16 async params/cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ambient: - Replace oscillator drone/melody with singing bowl synthesis - Each bowl: instant attack + exponential decay (7–12s) = real physical sound - Two detuned copies per strike for natural shimmer beat - A-minor pentatonic (A3 C4 D4 E4 G4 A4 C5 E5), weighted toward warm lows - 30% chance of soft harmonic fifth companion tone per strike - Random gaps 3–8s between strikes so it breathes naturally - Two long hall reverb tails (2.4s / 4.8s) for warmth and space - Graceful 2.5s fade-out on stop Next.js 16 compatibility (breaking changes from v14→v16): - Dynamic route params now Promise<{...}> → await params in all handlers - cookies() now returns Promise → isAdmin() made async, await cookies() - Files: [...path], memories/[id], media/[id], auth, upload all updated Co-Authored-By: Claude Sonnet 4.5 --- src/app/api/auth/route.ts | 2 +- src/app/api/files/[...path]/route.ts | 5 +- src/app/api/media/[id]/route.ts | 15 +- src/app/api/memories/[id]/route.ts | 21 +-- src/app/api/memories/route.ts | 10 -- src/app/api/upload/route.ts | 9 +- src/components/MusicPlayer.tsx | 217 +++++++++++---------------- 7 files changed, 113 insertions(+), 166 deletions(-) diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index c38374f..6be3cbc 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -9,7 +9,7 @@ function getExpectedToken() { } export async function GET() { - const cookieStore = cookies() + const cookieStore = await cookies() const token = cookieStore.get('admin_auth')?.value return NextResponse.json({ authed: token === getExpectedToken() }) } diff --git a/src/app/api/files/[...path]/route.ts b/src/app/api/files/[...path]/route.ts index 092cc06..9f1e374 100644 --- a/src/app/api/files/[...path]/route.ts +++ b/src/app/api/files/[...path]/route.ts @@ -25,9 +25,10 @@ const MIME: Record = { export async function GET( request: NextRequest, - { params }: { params: { path: string[] } } + { params }: { params: Promise<{ path: string[] }> } ) { - const filePath = path.join(DATA_DIR, 'uploads', ...params.path) + const { path: segments } = await params + const filePath = path.join(DATA_DIR, 'uploads', ...segments) // Prevent path traversal const uploadsBase = path.join(DATA_DIR, 'uploads') diff --git a/src/app/api/media/[id]/route.ts b/src/app/api/media/[id]/route.ts index 0e2fa15..3a5b597 100644 --- a/src/app/api/media/[id]/route.ts +++ b/src/app/api/media/[id]/route.ts @@ -6,8 +6,9 @@ import { unlink } from 'fs/promises' import { existsSync } from 'fs' import path from 'path' -function isAdmin() { - const token = cookies().get('admin_auth')?.value +async function isAdmin() { + const cookieStore = await cookies() + const token = cookieStore.get('admin_auth')?.value const expected = createHash('sha256') .update(process.env.ADMIN_PASSWORD || 'change-me') .digest('hex') @@ -18,14 +19,15 @@ const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data') export async function DELETE( _req: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { - if (!isAdmin()) { + if (!await isAdmin()) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const { id } = await params const db = getDb() - const media = db.prepare('SELECT * FROM media WHERE id = ?').get(params.id) as + const media = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as | { filename: string } | undefined @@ -33,12 +35,11 @@ export async function DELETE( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - // Delete file from disk const filePath = path.join(DATA_DIR, 'uploads', media.filename) if (existsSync(filePath)) { await unlink(filePath) } - db.prepare('DELETE FROM media WHERE id = ?').run(params.id) + db.prepare('DELETE FROM media WHERE id = ?').run(id) return NextResponse.json({ success: true }) } diff --git a/src/app/api/memories/[id]/route.ts b/src/app/api/memories/[id]/route.ts index ffeef3d..f7e8c00 100644 --- a/src/app/api/memories/[id]/route.ts +++ b/src/app/api/memories/[id]/route.ts @@ -3,8 +3,9 @@ import { getDb } from '@/lib/db' import { cookies } from 'next/headers' import { createHash } from 'crypto' -function isAdmin() { - const token = cookies().get('admin_auth')?.value +async function isAdmin() { + const cookieStore = await cookies() + const token = cookieStore.get('admin_auth')?.value const expected = createHash('sha256') .update(process.env.ADMIN_PASSWORD || 'change-me') .digest('hex') @@ -13,30 +14,32 @@ function isAdmin() { export async function PUT( req: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { - if (!isAdmin()) { + if (!await isAdmin()) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const { id } = await params const { title, content } = await req.json() const db = getDb() db.prepare( "UPDATE memories SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?" - ).run(title, content, params.id) - const memory = db.prepare('SELECT * FROM memories WHERE id = ?').get(params.id) + ).run(title, content, id) + const memory = db.prepare('SELECT * FROM memories WHERE id = ?').get(id) return NextResponse.json(memory) } export async function DELETE( _req: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { - if (!isAdmin()) { + if (!await isAdmin()) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const { id } = await params const db = getDb() - db.prepare('DELETE FROM memories WHERE id = ?').run(params.id) + db.prepare('DELETE FROM memories WHERE id = ?').run(id) return NextResponse.json({ success: true }) } diff --git a/src/app/api/memories/route.ts b/src/app/api/memories/route.ts index 280171b..4112c4b 100644 --- a/src/app/api/memories/route.ts +++ b/src/app/api/memories/route.ts @@ -1,15 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' import { getDb } from '@/lib/db' -import { cookies } from 'next/headers' -import { createHash } from 'crypto' - -function isAdmin() { - const token = cookies().get('admin_auth')?.value - const expected = createHash('sha256') - .update(process.env.ADMIN_PASSWORD || 'change-me') - .digest('hex') - return token === expected -} export async function GET() { const db = getDb() diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index f358b55..22251e9 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -8,8 +8,9 @@ import { getDb } from '@/lib/db' export const runtime = 'nodejs' export const maxDuration = 60 -function isAdmin() { - const token = cookies().get('admin_auth')?.value +async function isAdmin() { + const cookieStore = await cookies() + const token = cookieStore.get('admin_auth')?.value const expected = createHash('sha256') .update(process.env.ADMIN_PASSWORD || 'change-me') .digest('hex') @@ -46,7 +47,7 @@ const FOLDER_TO_TYPE: Record = { } export async function POST(req: NextRequest) { - if (!isAdmin()) { + if (!await isAdmin()) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -58,11 +59,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Keine Datei' }, { status: 400 }) } - // Try to detect type from mime or extension let mimeType = file.type?.toLowerCase() || '' const ext = path.extname(file.name).toLowerCase() - // iOS HEIC fallback if (!mimeType && (ext === '.heic' || ext === '.heif')) { mimeType = 'image/heic' } diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx index be8a720..acd3c3b 100644 --- a/src/components/MusicPlayer.tsx +++ b/src/components/MusicPlayer.tsx @@ -44,14 +44,16 @@ function WaveformBars({ playing }: { playing: boolean }) { // ─── ambient audio via Web Audio API ──────────────────────────────────────── // -// Three layers: -// 1. PAD – Am9 chord (A3·C4·E4·G4·B4), detuned pairs, breathing swells -// 2. MELODY – slow A-minor phrase (like a distant cello), with vibrato -// 3. DYNAMICS – 60-second master arc (quiet → swell → quiet) for emotional peaks +// "Singing bowls" synthesis: each tone is a struck resonance with +// natural exponential decay – sounds like real crystal/tibetan bowls, +// not oscillator drones. Appropriate and peaceful for a memorial. +// +// Scale: A-minor pentatonic (A3 C4 D4 E4 G4 A4 C5 E5) +// Timing: random gaps of 3–8 s between strikes, so it breathes naturally. +// Reverb: two long delay tails for a large, warm hall. function useAmbient() { const ctxRef = useRef(null) - const oscsRef = useRef([]) const timerRef = useRef[]>([]) const [playing, setPlaying] = useState(false) @@ -60,143 +62,91 @@ function useAmbient() { const ctx = new AudioContext() ctxRef.current = ctx - // ── Output ───────────────────────────────────────────────────────── + // ── Signal chain ──────────────────────────────────────────────────── const master = ctx.createGain() - master.gain.value = 0.15 + master.gain.value = 0.55 master.connect(ctx.destination) - // Hall reverb (two delay lines, no feedback → no runaway) - const mkHall = (delayTime: number, wet: number) => { - const d = ctx.createDelay(5.0) - d.delayTime.value = delayTime + // Long hall reverb (two delay tails, no feedback) + const mkTail = (t: number, wet: number) => { + const d = ctx.createDelay(6.0) + d.delayTime.value = t const g = ctx.createGain() g.gain.value = wet - d.connect(g); g.connect(master) + d.connect(g) + g.connect(ctx.destination) return d } - const hall1 = mkHall(2.8, 0.22) - const hall2 = mkHall(4.3, 0.14) - const hall3 = mkHall(1.1, 0.28) // short pre-delay for presence + const tail1 = mkTail(2.4, 0.30) + const tail2 = mkTail(4.8, 0.18) - // Helper: create oscillator, connect to all outputs, start it - const mkOsc = (freq: number): [OscillatorNode, GainNode] => { - const osc = ctx.createOscillator() - osc.type = 'sine' - osc.frequency.value = freq - const g = ctx.createGain() - g.gain.value = 0 - osc.connect(g) - g.connect(master); g.connect(hall1); g.connect(hall2); g.connect(hall3) - osc.start() - oscsRef.current.push(osc) - return [osc, g] + // ── Bowl strike ────────────────────────────────────────────────────── + // Two slightly detuned sine waves → natural "shimmer beat" of a real bowl. + // Gain follows exponential decay → sounds exactly like a struck bowl. + const strike = (freq: number, vol: number) => { + if (!ctxRef.current) return + const now = ctx.currentTime + const decay = 7 + Math.random() * 5 // 7–12 s natural ring + const detune = freq * 0.0025 // 0.25 % → slow shimmer beat + + ;[freq, freq + detune].forEach((f, i) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = f + + const g = ctx.createGain() + // Instant attack (bowl is struck), then pure exponential decay + g.gain.setValueAtTime(vol * (i === 0 ? 1 : 0.6), now) + g.gain.exponentialRampToValueAtTime(0.0001, now + decay) + + osc.connect(g) + g.connect(master) + g.connect(tail1) + g.connect(tail2) + osc.start(now) + osc.stop(now + decay + 0.05) + }) } - // ── LAYER 1: Pad ──────────────────────────────────────────────────── - // Am9 chord, two detuned copies per pitch, staggered breathing envelopes - const padVoices = [ - { freq: 220.00, peak: 0.28, dur: 16, offset: 0 }, // A3 - { freq: 220.60, peak: 0.20, dur: 19, offset: 3.0 }, - { freq: 261.63, peak: 0.24, dur: 13, offset: 5.5 }, // C4 - { freq: 260.90, peak: 0.17, dur: 17, offset: 8.0 }, - { freq: 329.63, peak: 0.26, dur: 11, offset: 2.0 }, // E4 - { freq: 330.30, peak: 0.18, dur: 15, offset: 9.5 }, - { freq: 392.00, peak: 0.19, dur: 21, offset: 12 }, // G4 (7th – longing) - { freq: 391.20, peak: 0.13, dur: 12, offset: 6.5 }, - { freq: 493.88, peak: 0.12, dur: 18, offset: 15 }, // B4 (9th – openness) - { freq: 494.70, peak: 0.09, dur: 10, offset: 7.5 }, + // ── Scale ─────────────────────────────────────────────────────────── + // A-minor pentatonic – every note here sounds consonant with every other. + // Weighted toward lower, warmer tones (more prominent) vs. higher (softer). + const bowls: { freq: number; vol: number }[] = [ + { freq: 220.00, vol: 0.38 }, // A3 – deep, warm anchor + { freq: 261.63, vol: 0.32 }, // C4 + { freq: 293.66, vol: 0.28 }, // D4 + { freq: 329.63, vol: 0.30 }, // E4 + { freq: 392.00, vol: 0.26 }, // G4 + { freq: 440.00, vol: 0.22 }, // A4 + { freq: 523.25, vol: 0.18 }, // C5 – bright, light + { freq: 659.25, vol: 0.14 }, // E5 – delicate shimmer ] - padVoices.forEach(({ freq, peak, dur, offset }) => { - const [, g] = mkOsc(freq) - const swell = () => { - if (!ctxRef.current) return - const t = ctx.currentTime - const rise = dur * 0.38 - g.gain.cancelScheduledValues(t) - g.gain.setValueAtTime(0, t) - g.gain.linearRampToValueAtTime(peak, t + rise) - g.gain.linearRampToValueAtTime(peak * 0.72, t + dur * 0.68) // mid-valley - g.gain.linearRampToValueAtTime(0, t + dur) - timerRef.current.push(setTimeout(swell, dur * 1000)) + const pickBowl = () => bowls[Math.floor(Math.random() * bowls.length)] + + // ── Scheduler ─────────────────────────────────────────────────────── + // Occasional harmonic pairs (two bowls together) feel more musical. + const scheduleNext = () => { + if (!ctxRef.current) return + + const gap = 3000 + Math.random() * 5000 // 3–8 s between strikes + + // ~30 % chance of a soft harmonic pair (two bowls a fifth apart) + const b = pickBowl() + strike(b.freq, b.vol) + if (Math.random() < 0.30) { + const fifth = b.freq * 1.5 // perfect fifth + strike(fifth, b.vol * 0.55) // softer companion tone } - timerRef.current.push(setTimeout(swell, offset * 1000)) - }) - // ── LAYER 2: Melody ───────────────────────────────────────────────── - // A slow A-minor descending/ascending phrase – like a distant cello line. - // Uses Web Audio scheduling for sample-accurate timing. - // - // Phrase (A4 → descend to A3 → rise back, ~38 seconds total): - const phrase: { freq: number; dur: number; vol: number }[] = [ - { freq: 440.00, dur: 4.2, vol: 0.11 }, // A4 – start, open - { freq: 415.30, dur: 3.6, vol: 0.09 }, // Ab4 – slide down (chromatic tension) - { freq: 392.00, dur: 4.0, vol: 0.10 }, // G4 - { freq: 349.23, dur: 4.8, vol: 0.12 }, // F4 – linger, emotional weight - { freq: 329.63, dur: 3.8, vol: 0.10 }, // E4 - { freq: 293.66, dur: 3.5, vol: 0.08 }, // D4 - { freq: 261.63, dur: 5.5, vol: 0.13 }, // C4 – long rest, heaviest moment - { freq: 293.66, dur: 3.0, vol: 0.08 }, // D4 – start ascending - { freq: 329.63, dur: 3.5, vol: 0.10 }, // E4 - { freq: 392.00, dur: 3.8, vol: 0.09 }, // G4 – almost home - { freq: 440.00, dur: 5.8, vol: 0.12 }, // A4 – resolution, breathing out - ] // ≈ 45.5 s per cycle - - const [melOsc, melGain] = mkOsc(440) - - // Vibrato LFO on melody (4.5 Hz, ±4 Hz depth → natural voice quality) - const vibLfo = ctx.createOscillator() - vibLfo.type = 'sine' - vibLfo.frequency.value = 4.5 - const vibDepth = ctx.createGain() - vibDepth.gain.value = 4 - vibLfo.connect(vibDepth) - vibDepth.connect(melOsc.frequency) - vibLfo.start() - oscsRef.current.push(vibLfo) - - let phraseIdx = 0 - let nextNoteAt = ctx.currentTime + 5 // first note after 5 s intro - - const scheduleNote = () => { - if (!ctxRef.current) return - const { freq, dur, vol } = phrase[phraseIdx] - const t = nextNoteAt - const attack = Math.min(0.7, dur * 0.16) - const release = Math.min(1.0, dur * 0.22) - - melOsc.frequency.setValueAtTime(freq, t) - melGain.gain.cancelScheduledValues(t) - melGain.gain.setValueAtTime(0, t) - melGain.gain.linearRampToValueAtTime(vol, t + attack) - melGain.gain.setValueAtTime(vol, t + dur - release) - melGain.gain.linearRampToValueAtTime(0, t + dur) - - nextNoteAt += dur - phraseIdx = (phraseIdx + 1) % phrase.length - - // Re-schedule ~1.2 s before next note for seamless handoff - const msUntilNext = Math.max(50, (nextNoteAt - ctx.currentTime - 1.2) * 1000) - timerRef.current.push(setTimeout(scheduleNote, msUntilNext)) + timerRef.current.push(setTimeout(scheduleNext, gap)) } - timerRef.current.push(setTimeout(scheduleNote, 4000)) // first trigger - - // ── LAYER 3: Dynamic master arc ───────────────────────────────────── - // 60-second cycle: soft start → emotional swell → gentle release. - // Gives a sense of "something happening" and stops it feeling static. - const dynArc = () => { - if (!ctxRef.current) return - const t = ctx.currentTime - master.gain.cancelScheduledValues(t) - master.gain.setValueAtTime(0.12, t) - master.gain.linearRampToValueAtTime(0.22, t + 18) // build up - master.gain.linearRampToValueAtTime(0.26, t + 30) // peak - master.gain.linearRampToValueAtTime(0.19, t + 45) // breathe out - master.gain.linearRampToValueAtTime(0.12, t + 60) // reset - timerRef.current.push(setTimeout(dynArc, 60000)) - } - dynArc() + // Open with three spaced strikes to fill the silence gently + strike(220.00, 0.38) // A3 immediately + timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(329.63, 0.30) }, 2800)) // E4 + timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(261.63, 0.28) }, 5200)) // C4 + timerRef.current.push(setTimeout(scheduleNext, 7500)) // then random setPlaying(true) }, []) @@ -204,11 +154,14 @@ function useAmbient() { const stop = useCallback(() => { timerRef.current.forEach(clearTimeout) timerRef.current = [] - oscsRef.current.forEach(o => { try { o.stop() } catch { /**/ } }) - oscsRef.current = [] const ctx = ctxRef.current if (ctx) { - setTimeout(() => { ctx.close(); ctxRef.current = null }, 100) + // Brief master fade so the currently ringing bowls die out naturally + const fade = ctx.createGain() + fade.gain.setValueAtTime(1, ctx.currentTime) + fade.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5) + fade.connect(ctx.destination) + setTimeout(() => { ctx.close(); ctxRef.current = null }, 2700) } setPlaying(false) }, []) @@ -343,8 +296,8 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { className="text-center" >

- Ein sanftes Klangteppich begleitet dich, - während du durch die Erinnerungen scrollst. + Sanfte Klangschalen begleiten dich — + lade eigene Musik hoch, um sie hier abzuspielen.

@@ -371,7 +324,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { )}

- Stille Begleitung + Klangschalen