Files
ComfyUI_frontend/src/composables/useWaveAudioPlayer.ts
Jin Yi 7d9fa2bfc5 feat: add WaveAudioPlayer with waveform visualization and authenticated audio fetch (#10158)
## Summary

Add a waveform-based audio player component (`WaveAudioPlayer`)
replacing the native `<audio>` element, with authenticated API fetch for
cloud audio playback.

## Changes

- **What**:
- Add `useWaveAudioPlayer` composable with waveform visualization from
audio data (Web Audio API `decodeAudioData`), playback controls, and
seek support
- Add `WaveAudioPlayer.vue` component with compact (inline waveform +
time) and expanded (full transport controls) variants
- Replace native `<audio>` in `MediaAudioTop.vue` and `ResultAudio.vue`
with `WaveAudioPlayer`
- Use `api.fetchApi()` instead of bare `fetch()` to include Firebase JWT
auth headers, fixing 401 errors in cloud environments
  - Add Storybook stories and unit tests

## Review Focus

- The audio URL is fetched via `api.fetchApi()` with auth headers,
converted to a Blob URL, then passed to the native `<audio>` element.
This avoids 401 Unauthorized in cloud environments where `/api/view`
requires authentication.
- URL-to-route extraction logic (`url.includes(apiBase)`) handles both
full API URLs and relative paths.


[screen-capture.webm](https://github.com/user-attachments/assets/44e61812-0391-4b47-a199-92927e75f8b4)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10158-feat-add-WaveAudioPlayer-with-waveform-visualization-and-authenticated-audio-fetch-3266d73d365081beab3fc6274c39fcd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-20 16:48:28 -07:00

206 lines
5.3 KiB
TypeScript

import { useMediaControls, whenever } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import { api } from '@/scripts/api'
import { formatTime } from '@/utils/formatUtil'
interface WaveformBar {
height: number
}
interface UseWaveAudioPlayerOptions {
src: Ref<string>
barCount?: number
}
export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
const { src, barCount = 40 } = options
const audioRef = ref<HTMLAudioElement>()
const waveformRef = ref<HTMLElement>()
const blobUrl = ref<string>()
const loading = ref(false)
let decodeRequestId = 0
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
const { playing, currentTime, duration, volume, muted } =
useMediaControls(audioRef)
const playedBarIndex = computed(() => {
if (duration.value === 0) return -1
return Math.floor((currentTime.value / duration.value) * barCount) - 1
})
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedDuration = computed(() => formatTime(duration.value))
const audioSrc = computed(() =>
src.value ? (blobUrl.value ?? src.value) : ''
)
function generatePlaceholderBars(): WaveformBar[] {
return Array.from({ length: barCount }, () => ({
height: Math.random() * 60 + 10
}))
}
function generateBarsFromBuffer(buffer: AudioBuffer) {
const channelData = buffer.getChannelData(0)
if (channelData.length === 0) {
bars.value = generatePlaceholderBars()
return
}
const averages: number[] = []
for (let i = 0; i < barCount; i++) {
const start = Math.floor((i * channelData.length) / barCount)
const end = Math.max(
start + 1,
Math.floor(((i + 1) * channelData.length) / barCount)
)
let sum = 0
for (let j = start; j < end && j < channelData.length; j++) {
sum += Math.abs(channelData[j])
}
averages.push(sum / (end - start))
}
const peak = Math.max(...averages) || 1
bars.value = averages.map((avg) => ({
height: Math.max(8, (avg / peak) * 100)
}))
}
async function decodeAudioSource(url: string) {
const requestId = ++decodeRequestId
loading.value = true
let ctx: AudioContext | undefined
try {
const apiBase = api.apiURL('/')
const route = url.includes(apiBase)
? url.slice(url.indexOf(apiBase) + api.apiURL('').length)
: url
const response = await api.fetchApi(route)
if (requestId !== decodeRequestId) return
if (!response.ok) {
throw new Error(`Failed to fetch audio (${response.status})`)
}
const arrayBuffer = await response.arrayBuffer()
if (requestId !== decodeRequestId) return
const blob = new Blob([arrayBuffer.slice(0)], {
type: response.headers.get('content-type') ?? 'audio/wav'
})
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
blobUrl.value = URL.createObjectURL(blob)
ctx = new AudioContext()
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
if (requestId !== decodeRequestId) return
generateBarsFromBuffer(audioBuffer)
} catch {
if (requestId === decodeRequestId) {
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
bars.value = generatePlaceholderBars()
}
} finally {
await ctx?.close()
if (requestId === decodeRequestId) {
loading.value = false
}
}
}
const progressRatio = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
function togglePlayPause() {
playing.value = !playing.value
}
function seekToStart() {
currentTime.value = 0
}
function seekToEnd() {
currentTime.value = duration.value
playing.value = false
}
function seekToRatio(ratio: number) {
const clamped = Math.max(0, Math.min(1, ratio))
currentTime.value = clamped * duration.value
}
function toggleMute() {
muted.value = !muted.value
}
const volumeIcon = computed(() => {
if (muted.value || volume.value === 0) return 'icon-[lucide--volume-x]'
if (volume.value < 0.5) return 'icon-[lucide--volume-1]'
return 'icon-[lucide--volume-2]'
})
function handleWaveformClick(event: MouseEvent) {
if (!waveformRef.value || duration.value === 0) return
const rect = waveformRef.value.getBoundingClientRect()
const ratio = Math.max(
0,
Math.min(1, (event.clientX - rect.left) / rect.width)
)
currentTime.value = ratio * duration.value
if (!playing.value) {
playing.value = true
}
}
whenever(
src,
(url) => {
playing.value = false
currentTime.value = 0
void decodeAudioSource(url)
},
{ immediate: true }
)
onUnmounted(() => {
decodeRequestId += 1
audioRef.value?.pause()
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
})
return {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying: playing,
playedBarIndex,
progressRatio,
formattedCurrentTime,
formattedDuration,
togglePlayPause,
seekToStart,
seekToEnd,
volume,
volumeIcon,
toggleMute,
seekToRatio,
handleWaveformClick
}
}