diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 74845409fae..499745a6f1e 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -631,3 +631,10 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean { mediaType === '3D' ) } + +export function formatTime(seconds: number): string { + if (isNaN(seconds) || seconds === 0) return '0:00' + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` +} diff --git a/src/components/common/WaveAudioPlayer.stories.ts b/src/components/common/WaveAudioPlayer.stories.ts new file mode 100644 index 00000000000..55e32cd6dae --- /dev/null +++ b/src/components/common/WaveAudioPlayer.stories.ts @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import WaveAudioPlayer from './WaveAudioPlayer.vue' + +const meta: Meta = { + title: 'Components/Audio/WaveAudioPlayer', + component: WaveAudioPlayer, + tags: ['autodocs'], + parameters: { layout: 'centered' } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + src: '/assets/audio/sample.wav', + barCount: 40, + height: 32 + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export const BottomAligned: Story = { + args: { + src: '/assets/audio/sample.wav', + barCount: 40, + height: 48, + align: 'bottom' + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export const Expanded: Story = { + args: { + src: '/assets/audio/sample.wav', + variant: 'expanded', + barCount: 80, + height: 120 + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} diff --git a/src/components/common/WaveAudioPlayer.vue b/src/components/common/WaveAudioPlayer.vue new file mode 100644 index 00000000000..c462dffb6fc --- /dev/null +++ b/src/components/common/WaveAudioPlayer.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/components/sidebar/tabs/queue/ResultAudio.vue b/src/components/sidebar/tabs/queue/ResultAudio.vue index 7a55fb85434..03156922ed7 100644 --- a/src/components/sidebar/tabs/queue/ResultAudio.vue +++ b/src/components/sidebar/tabs/queue/ResultAudio.vue @@ -1,19 +1,21 @@ diff --git a/src/composables/useWaveAudioPlayer.test.ts b/src/composables/useWaveAudioPlayer.test.ts new file mode 100644 index 00000000000..e84b73b7741 --- /dev/null +++ b/src/composables/useWaveAudioPlayer.test.ts @@ -0,0 +1,130 @@ +import { ref } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useWaveAudioPlayer } from './useWaveAudioPlayer' + +vi.mock('@vueuse/core', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + useMediaControls: () => ({ + playing: ref(false), + currentTime: ref(0), + duration: ref(0) + }) + } +}) + +const mockFetchApi = vi.fn() +const originalAudioContext = globalThis.AudioContext + +afterEach(() => { + globalThis.AudioContext = originalAudioContext + mockFetchApi.mockReset() +}) + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (route: string) => '/api' + route, + fetchApi: (...args: unknown[]) => mockFetchApi(...args) + } +})) + +describe('useWaveAudioPlayer', () => { + it('initializes with default bar count', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src }) + expect(bars.value).toHaveLength(40) + }) + + it('initializes with custom bar count', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src, barCount: 20 }) + expect(bars.value).toHaveLength(20) + }) + + it('returns playedBarIndex as -1 when duration is 0', () => { + const src = ref('') + const { playedBarIndex } = useWaveAudioPlayer({ src }) + expect(playedBarIndex.value).toBe(-1) + }) + + it('generates bars with heights between 10 and 70', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src }) + for (const bar of bars.value) { + expect(bar.height).toBeGreaterThanOrEqual(10) + expect(bar.height).toBeLessThanOrEqual(70) + } + }) + + it('starts in paused state', () => { + const src = ref('') + const { isPlaying } = useWaveAudioPlayer({ src }) + expect(isPlaying.value).toBe(false) + }) + + it('shows 0:00 for formatted times initially', () => { + const src = ref('') + const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({ + src + }) + expect(formattedCurrentTime.value).toBe('0:00') + expect(formattedDuration.value).toBe('0:00') + }) + + it('fetches and decodes audio when src changes', async () => { + const mockAudioBuffer = { + getChannelData: vi.fn(() => new Float32Array(80)) + } + + const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer)) + const mockClose = vi.fn().mockResolvedValue(undefined) + globalThis.AudioContext = class { + decodeAudioData = mockDecodeAudioData + close = mockClose + } as unknown as typeof AudioContext + + mockFetchApi.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + headers: { get: () => 'audio/wav' } + }) + + const src = ref('/api/view?filename=audio.wav&type=output') + const { bars, loading } = useWaveAudioPlayer({ src, barCount: 10 }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(mockFetchApi).toHaveBeenCalledWith( + '/view?filename=audio.wav&type=output' + ) + expect(mockDecodeAudioData).toHaveBeenCalled() + expect(bars.value).toHaveLength(10) + }) + + it('clears blobUrl and shows placeholder bars when fetch fails', async () => { + mockFetchApi.mockRejectedValue(new Error('Network error')) + + const src = ref('/api/view?filename=audio.wav&type=output') + const { bars, loading, audioSrc } = useWaveAudioPlayer({ + src, + barCount: 10 + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(bars.value).toHaveLength(10) + expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output') + }) + + it('does not call decodeAudioSource when src is empty', () => { + const src = ref('') + useWaveAudioPlayer({ src }) + expect(mockFetchApi).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/useWaveAudioPlayer.ts b/src/composables/useWaveAudioPlayer.ts new file mode 100644 index 00000000000..3fd12ca377c --- /dev/null +++ b/src/composables/useWaveAudioPlayer.ts @@ -0,0 +1,205 @@ +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 + barCount?: number +} + +export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) { + const { src, barCount = 40 } = options + + const audioRef = ref() + const waveformRef = ref() + const blobUrl = ref() + const loading = ref(false) + let decodeRequestId = 0 + const bars = ref(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 + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 11f24ab0d56..291e3a207fa 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -343,9 +343,13 @@ "frameNodes": "Frame Nodes", "listening": "Listening...", "ready": "Ready", + "play": "Play", + "pause": "Pause", "playPause": "Play/Pause", "playRecording": "Play Recording", "playing": "Playing", + "skipToStart": "Skip to Start", + "skipToEnd": "Skip to End", "stopPlayback": "Stop Playback", "playbackSpeed": "Playback Speed", "volume": "Volume", diff --git a/src/platform/assets/components/MediaAudioTop.vue b/src/platform/assets/components/MediaAudioTop.vue index 64ea8bbcbfa..777692e3a68 100644 --- a/src/platform/assets/components/MediaAudioTop.vue +++ b/src/platform/assets/components/MediaAudioTop.vue @@ -8,16 +8,20 @@ $t('assetBrowser.media.audioPlaceholder') }} -