Tune fullscreen beat response

This commit is contained in:
lucast committed 2026-10-05 21:48:48 +02:00
1 parent 1d6422f422
commit 27c289483b
2 files changed
+153 -2

No files matched your search

@@ -8,6 +8,7 @@ import {
const props = defineProps<{ src: string; active: boolean }>() const props = defineProps<{ src: string; active: boolean }>()
const ANIMATION_SPEED = 0.3 const ANIMATION_SPEED = 0.3
const { beatEnergy } = usePlayer()
const canvas = ref<HTMLCanvasElement | null>(null) const canvas = ref<HTMLCanvasElement | null>(null)
const palette = ref<AlbumPalette | null>(null) const palette = ref<AlbumPalette | null>(null)
let frame = 0 let frame = 0
@@ -152,12 +153,16 @@ function draw(now: number) {
const px = x / (width - 1) const px = x / (width - 1)
const py = y / (height - 1) const py = y / (height - 1)
const point = flowPoint(px - 0.5, py - 0.5, motionTime) const point = flowPoint(px - 0.5, py - 0.5, motionTime)
const blend = smoothstep(-0.3, 0.2, point[0]) const blend = smoothstep(-0.34, 0.16, point[0])
const transition = 1 - Math.abs(blend * 2 - 1) const transition = 1 - Math.abs(blend * 2 - 1)
const lab = dominant.map( const lab = dominant.map(
(value, channel) => (value, channel) =>
value * (1 - blend) + accent[channel] * blend, value * (1 - blend) + accent[channel] * blend,
) as [number, number, number] ) as [number, number, number]
const beat = beatEnergy.value
lab[0] = Math.min(1, lab[0] + beat * 0.01)
lab[1] *= 1 + beat * 0.02
lab[2] *= 1 + beat * 0.02
const color = fromOkLab(lab) const color = fromOkLab(lab)
const offset = (y * width + x) * 4 const offset = (y * width + x) * 4
image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3 image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3
@@ -166,6 +171,7 @@ function draw(now: number) {
image.data[offset + 3] = 255 image.data[offset + 3] = 255
} }
} }
element.style.transform = `scale(${1.08 + beatEnergy.value * 0.003})`
context.putImageData(image, 0, 0) context.putImageData(image, 0, 0)
if (props.active) frame = requestAnimationFrame(draw) if (props.active) frame = requestAnimationFrame(draw)
} }
+146 -1
View File
@@ -1,10 +1,26 @@
import { Howl } from "howler" import { Howl, Howler } from "howler"
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
const { close: closeFullscreen } = useFullscreen() const { close: closeFullscreen } = useFullscreen()
if (import.meta.client) {
const howlerInternal = Howler as unknown as {
_obtainHtml5Audio: () => HTMLAudioElement
_musicV2CorsEnabled?: boolean
}
if (!howlerInternal._musicV2CorsEnabled) {
const obtainHtml5Audio = howlerInternal._obtainHtml5Audio
howlerInternal._obtainHtml5Audio = function () {
const audio = obtainHtml5Audio.call(Howler)
audio.crossOrigin = "anonymous"
return audio
}
howlerInternal._musicV2CorsEnabled = true
}
}
const VOLUME_STORAGE_KEY = "music-v2-player-volume" const VOLUME_STORAGE_KEY = "music-v2-player-volume"
const DEFAULT_VOLUME = 0.5 const DEFAULT_VOLUME = 0.5
const DEBUG_BEAT_ANALYSIS = true
const sound = ref<Howl | null>(null) const sound = ref<Howl | null>(null)
const queue = ref<Track[]>([]) const queue = ref<Track[]>([])
@@ -13,7 +29,16 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentSong = ref<Track | null>(null) const currentSong = ref<Track | null>(null)
const repeat = ref(false) const repeat = ref(false)
const isPlaying = ref(false) const isPlaying = ref(false)
const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME) const storedVolume = ref(DEFAULT_VOLUME)
type HowlWithMediaNode = Howl & {
_sounds?: Array<{ _node?: HTMLMediaElement }>
}
let analysisContext: AudioContext | null = null
let analysisSource: MediaStreamAudioSourceNode | null = null
let analysisNode: AnalyserNode | null = null
let analysisFrame = 0
let isAnalyzing = false
const clampVolume = (value: number) => Math.min(1, Math.max(0, value)) const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
@@ -66,6 +91,121 @@ export default defineNuxtPlugin((nuxtApp) => {
let lastPositionUpdate = 0 let lastPositionUpdate = 0
let mediaSessionSongId: string | null = null let mediaSessionSongId: string | null = null
const stopBeatAnalysis = (reason = "stopped") => {
if (isAnalyzing && DEBUG_BEAT_ANALYSIS)
console.info(`[beat analyzer] stopped: ${reason}`)
isAnalyzing = false
if (analysisFrame) cancelAnimationFrame(analysisFrame)
analysisFrame = 0
analysisSource?.disconnect()
analysisSource = null
analysisNode = null
beatEnergy.value = 0
}
const startBeatAnalysis = (howl: Howl) => {
stopBeatAnalysis("restarted")
const mediaElement = (howl as HowlWithMediaNode)._sounds?.[0]?._node
if (!mediaElement) {
if (DEBUG_BEAT_ANALYSIS)
console.warn("[beat analyzer] failed: Howler media element unavailable")
return
}
const elementWithCapture = mediaElement as HTMLMediaElement & {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
webkitCaptureStream?: () => MediaStream
}
const capture =
elementWithCapture.captureStream ??
elementWithCapture.mozCaptureStream ??
elementWithCapture.webkitCaptureStream
if (!capture) {
if (DEBUG_BEAT_ANALYSIS)
console.warn("[beat analyzer] failed: media capture unsupported")
return
}
try {
const stream = capture.call(mediaElement)
if (stream.getAudioTracks().length === 0) {
if (DEBUG_BEAT_ANALYSIS)
console.warn("[beat analyzer] failed: capture has no audio tracks")
return
}
analysisContext ??= new AudioContext()
if (analysisContext.state === "suspended")
void analysisContext.resume().catch(() => {})
analysisNode = analysisContext.createAnalyser()
analysisNode.fftSize = 2048
analysisNode.smoothingTimeConstant = 0.3
analysisSource = analysisContext.createMediaStreamSource(stream)
analysisSource.connect(analysisNode)
const samples = new Uint8Array(analysisNode.frequencyBinCount)
let previousBass: number | null = null
let pulse = 0
let lastBeatLogAt = 0
let lastLevelLogAt = 0
const attackNoiseFloor = 0.05
const strongThumpThreshold = 0.05
const thumpCooldown = 800
const sample = () => {
if (!analysisNode) return
analysisNode.getByteFrequencyData(samples)
const sampleRate = analysisContext!.sampleRate
const firstBin = Math.max(1, Math.floor((45 * 2048) / sampleRate))
const lastBin = Math.min(
samples.length - 1,
Math.ceil((130 * 2048) / sampleRate),
)
let bass = 0
for (let bin = firstBin; bin <= lastBin; bin++) bass += samples[bin]
bass /= Math.max(1, lastBin - firstBin + 1) * 255
if (previousBass === null) previousBass = bass
const attack = Math.max(0, bass - previousBass - attackNoiseFloor)
previousBass = previousBass * 0.985 + bass * 0.015
const now = performance.now()
const isStrongThump =
attack >= strongThumpThreshold && now - lastBeatLogAt > thumpCooldown
if (isStrongThump) {
const thumpStrength = Math.min(
0.38,
0.1 + (attack - strongThumpThreshold) * 2.5,
)
pulse = Math.max(pulse, thumpStrength)
} else pulse *= 0.94
beatEnergy.value = Math.min(1, pulse)
if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) {
lastLevelLogAt = now
console.info(
"[beat level]",
`bass=${bass.toFixed(3)}`,
`attack=${attack.toFixed(3)}`,
`pulse=${beatEnergy.value.toFixed(2)}`,
)
}
if (isStrongThump) {
lastBeatLogAt = now
console.log("[fullscreen beat]", beatEnergy.value.toFixed(2))
}
analysisFrame = requestAnimationFrame(sample)
}
analysisFrame = requestAnimationFrame(sample)
isAnalyzing = true
if (DEBUG_BEAT_ANALYSIS)
console.info("[beat analyzer] analysis started")
} catch (error) {
stopBeatAnalysis()
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed while setting up audio capture",
error,
)
}
}
const aniFrame = ref<number | null>(null) const aniFrame = ref<number | null>(null)
// Helpers // Helpers
@@ -197,11 +337,13 @@ export default defineNuxtPlugin((nuxtApp) => {
updateMediaSession() updateMediaSession()
}, },
onend: () => { onend: () => {
stopBeatAnalysis("track ended")
void closeFullscreen() void closeFullscreen()
next() next()
stopTracking() stopTracking()
}, },
onstop: () => { onstop: () => {
stopBeatAnalysis("playback stopped")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -209,11 +351,13 @@ export default defineNuxtPlugin((nuxtApp) => {
}, },
onplay: () => { onplay: () => {
isPlaying.value = true isPlaying.value = true
startBeatAnalysis(newSound)
startTracking() startTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
navigator.mediaSession.playbackState = "playing" navigator.mediaSession.playbackState = "playing"
}, },
onpause: () => { onpause: () => {
stopBeatAnalysis("paused")
isPlaying.value = false isPlaying.value = false
stopTracking() stopTracking()
if (navigator.mediaSession) if (navigator.mediaSession)
@@ -392,6 +536,7 @@ export default defineNuxtPlugin((nuxtApp) => {
provide: { provide: {
player: { player: {
isPlaying, isPlaying,
beatEnergy,
currentSong, currentSong,
repeat, repeat,
play, play,