Tune fullscreen beat response
This commit is contained in:
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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user