fix: singing bowls ambient + Next.js 16 async params/cookies

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 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 02:44:45 +01:00
parent 00abbfda51
commit 313b5ff7fd
7 changed files with 113 additions and 166 deletions
+1 -1
View File
@@ -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() })
}
+3 -2
View File
@@ -25,9 +25,10 @@ const MIME: Record<string, string> = {
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')
+8 -7
View File
@@ -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 })
}
+12 -9
View File
@@ -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 })
}
-10
View File
@@ -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()
+4 -5
View File
@@ -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<string, 'photo' | 'video' | 'music'> = {
}
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'
}
+79 -126
View File
@@ -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 38 s between strikes, so it breathes naturally.
// Reverb: two long delay tails for a large, warm hall.
function useAmbient() {
const ctxRef = useRef<AudioContext | null>(null)
const oscsRef = useRef<OscillatorNode[]>([])
const timerRef = useRef<ReturnType<typeof setTimeout>[]>([])
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] => {
// ── 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 // 712 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 = freq
osc.frequency.value = f
const g = ctx.createGain()
g.gain.value = 0
// 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(hall1); g.connect(hall2); g.connect(hall3)
osc.start()
oscsRef.current.push(osc)
return [osc, 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 = () => {
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 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))
}
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 gap = 3000 + Math.random() * 5000 // 38 s between strikes
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))
// ~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(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))
timerRef.current.push(setTimeout(scheduleNext, gap))
}
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"
>
<p className="font-lora text-amber-100/30 text-sm mb-8 leading-relaxed max-w-xs mx-auto">
Ein sanftes Klangteppich begleitet dich,
während du durch die Erinnerungen scrollst.
Sanfte Klangschalen begleiten dich
lade eigene Musik hoch, um sie hier abzuspielen.
</p>
<div className="flex flex-col items-center gap-5">
@@ -371,7 +324,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
)}
<p className="font-cormorant italic text-amber-200/35 text-lg">
Stille Begleitung
Klangschalen
</p>
</div>
</motion.div>