mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 08:00:05 +00:00
Vuenodes/audio widgets (#5627)
This pull request introduces a new audio playback widget for node UIs and integrates it into the node widget system. The main changes include the implementation of the `WidgetAudioUI` component, its registration in the widget registry, and updates to pass node data to the new widget. Additionally, some logging was added for debugging purposes. **Audio Widget Implementation and Integration:** * Added a new `WidgetAudioUI.vue` component that provides audio playback controls (play/pause, progress slider, volume, options) and loads audio files from the server based on node data. * Registered the new `WidgetAudioUI` component in the widget registry by importing it and adding an entry for the `audioUI` type. [[1]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR21) [[2]](diffhunk://#diff-c2a60954f7fdf638716fa1f83e437774d5250e9c99f3aa83c84a1c0e9cc5769bR112-R115) * Updated `NodeWidgets.vue` to pass `nodeInfo` as the `node-data` prop to widgets of type `audioUI`, enabling the widget to access node-specific audio file information. **Debugging and Logging:** * Added logging of `nodeData` in `LGraphNode.vue` and `WidgetAudioUI.vue` to help with debugging and understanding the data structure. [[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R188-R189) [[2]](diffhunk://#diff-71cce190d74c6b5359288857ab9917caededb8cdf1a7e6377578b78aa32be2fcR1-R284) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5627-Vuenodes-audio-widgets-2716d73d365081fbbc06c1e6cf4ebf4d) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Arjan Singh <1598641+arjansingh@users.noreply.github.com> Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Jin Yi <jin12cc@gmail.com> Co-authored-by: DrJKL <DrJKL@users.noreply.github.com> Co-authored-by: Robin Huang <robin.j.huang@gmail.com> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
committed by
GitHub
parent
4404c0461d
commit
d7796fcda4
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
@@ -11,7 +11,10 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import {
|
||||
getResourceURL,
|
||||
splitFilePath
|
||||
} from '@/renderer/extensions/vueNodes/widgets/utils/audioUtils'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
@@ -21,32 +24,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
audioWidget: IStringWidget,
|
||||
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
|
||||
@@ -123,7 +100,6 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
@@ -199,6 +175,7 @@ app.registerExtension({
|
||||
const audioUIWidget = node.widgets.find(
|
||||
(w) => w.name === 'audioUI'
|
||||
) as unknown as DOMWidget<HTMLAudioElement, string>
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
const onAudioWidgetUpdate = () => {
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
@@ -273,9 +250,9 @@ app.registerExtension({
|
||||
audio.controls = true
|
||||
audio.classList.add('comfy-audio')
|
||||
audio.setAttribute('name', 'media')
|
||||
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let isRecording = false
|
||||
|
||||
@@ -79,6 +79,7 @@ export type IWidget =
|
||||
| ISelectButtonWidget
|
||||
| ITextareaWidget
|
||||
| IAssetWidget
|
||||
| IAudioRecordWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -227,6 +228,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
|
||||
type: 'audiorecord'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IAssetWidget
|
||||
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
|
||||
type: 'asset'
|
||||
|
||||
@@ -182,7 +182,17 @@
|
||||
"nodeHeaderError": "Node Header Error",
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes"
|
||||
"frameNodes": "Frame Nodes",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"playRecording": "Play Recording",
|
||||
"playing": "Playing",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"volume": "Volume",
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -120,12 +120,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
for (const widget of widgets) {
|
||||
// Skip if widget is in the hidden list for this node type
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
const vueComponent =
|
||||
getComponent(widget.type, widget.name) || WidgetInputText
|
||||
|
||||
const slotMetadata = widget.slotMetadata
|
||||
|
||||
@@ -150,6 +152,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
// Update the widget value directly
|
||||
widget.value = value as WidgetValue
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<WidgetSelect v-model="modelValue" :widget />
|
||||
<div class="my-4">
|
||||
<AudioPreviewPlayer
|
||||
:audio-url="audioUrlFromWidget"
|
||||
:readonly="readonly"
|
||||
:hide-when-empty="isOutputNodeRef"
|
||||
:show-options-button="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
|
||||
import { getAudioUrlFromPath } from '../utils/audioUtils'
|
||||
import WidgetSelect from './WidgetSelect.vue'
|
||||
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
readonly?: boolean
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Get litegraph node
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
// Check if this is an output node (PreviewAudio, SaveAudio, etc)
|
||||
const isOutputNodeRef = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return false
|
||||
return isOutputNode(node)
|
||||
})
|
||||
|
||||
const audioFilePath = computed(() => props.widget.value as string)
|
||||
|
||||
// Computed audio URL from widget value (for input files)
|
||||
const audioUrlFromWidget = computed(() => {
|
||||
const path = audioFilePath.value
|
||||
if (!path) return ''
|
||||
return getAudioUrlFromPath(path, 'input')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="mb-4">
|
||||
<Button
|
||||
class="w-[413px] border-0 bg-zinc-500/10 text-zinc-400 dark-theme:bg-charcoal-600 dark-theme:text-white"
|
||||
:disabled="isRecording || readonly"
|
||||
@click="handleStartRecording"
|
||||
>
|
||||
{{ t('g.startRecording', 'Start Recording') }}
|
||||
<i-lucide:mic class="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isRecording || isPlaying || recordedURL"
|
||||
class="flex h-14 w-[413px] items-center gap-4 rounded-lg bg-zinc-500/10 px-4 text-zinc-400 dark-theme:bg-node-component-surface dark-theme:text-white"
|
||||
>
|
||||
<!-- Recording Status -->
|
||||
<div class="flex min-w-30 items-center gap-2">
|
||||
<span class="min-w-20 text-xs">
|
||||
{{
|
||||
isRecording
|
||||
? t('g.listening', 'Listening...')
|
||||
: isPlaying
|
||||
? t('g.playing', 'Playing...')
|
||||
: recordedURL
|
||||
? t('g.ready', 'Ready')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Waveform Visualization -->
|
||||
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip">
|
||||
<div
|
||||
v-for="(bar, index) in waveformBars"
|
||||
:key="index"
|
||||
class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-slate-100 transition-all duration-100"
|
||||
:style="{ height: bar.height + 'px' }"
|
||||
:title="`Bar ${index + 1}: ${bar.height}px`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Control Button -->
|
||||
<button
|
||||
v-if="isRecording"
|
||||
:title="t('g.stopRecording', 'Stop Recording')"
|
||||
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handleStopRecording"
|
||||
>
|
||||
<div class="size-2.5 rounded-sm bg-[#C02323]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="!isRecording && recordedURL && !isPlaying"
|
||||
:title="t('g.playRecording') || 'Play Recording'"
|
||||
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handlePlayRecording"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--play] size-4 text-zinc-400 dark-theme:text-white"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="isPlaying"
|
||||
:title="t('g.stopPlayback') || 'Stop Playback'"
|
||||
class="flex size-8 items-center justify-center rounded-full border-0 bg-gray-500/33 transition-colors"
|
||||
@click="handleStopPlayback"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--square] size-4 text-zinc-400 dark-theme:text-white"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
v-if="recordedURL"
|
||||
ref="audioRef"
|
||||
:key="audioElementKey"
|
||||
:src="recordedURL"
|
||||
class="hidden"
|
||||
@ended="playback.onPlaybackEnded"
|
||||
@loadedmetadata="playback.onMetadataLoaded"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { Button } from 'primevue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
readonly?: boolean
|
||||
modelValue: string
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
// Audio element ref
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
|
||||
// Keep track of the last uploaded path as a backup
|
||||
let lastUploadedPath = ''
|
||||
|
||||
// Composables
|
||||
const recorder = useAudioRecorder({
|
||||
onRecordingComplete: handleRecordingComplete,
|
||||
onError: () => {
|
||||
useToastStore().addAlert(
|
||||
t('g.micPermissionDenied') || 'Microphone permission denied'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const waveform = useAudioWaveform({
|
||||
barCount: 18,
|
||||
minHeight: 4,
|
||||
maxHeight: 32
|
||||
})
|
||||
|
||||
const playback = useAudioPlayback(audioRef, {
|
||||
onPlaybackEnded: handlePlaybackEnded,
|
||||
onMetadataLoaded: (duration) => {
|
||||
if (!isPlaying.value && !isRecording.value) {
|
||||
timer.value = Math.floor(duration)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Timer for recording
|
||||
const timer = ref(0)
|
||||
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(
|
||||
() => {
|
||||
timer.value += 1
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Destructure for template access
|
||||
const { isRecording, recordedURL } = recorder
|
||||
const { waveformBars } = waveform
|
||||
const { isPlaying, audioElementKey } = playback
|
||||
|
||||
// Computed for waveform animation
|
||||
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget as SimplifiedWidget<string, Record<string, string>>,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
async function handleRecordingComplete(blob: Blob) {
|
||||
try {
|
||||
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
|
||||
localValue.value = path
|
||||
lastUploadedPath = path
|
||||
onChange(path)
|
||||
} catch (e) {
|
||||
useToastStore().addAlert('Failed to upload recorded audio')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
if (props.readonly) return
|
||||
|
||||
try {
|
||||
await waveform.setupAudioContext()
|
||||
await recorder.startRecording()
|
||||
|
||||
// Setup waveform visualization for recording
|
||||
if (recorder.mediaRecorder.value) {
|
||||
const stream = recorder.mediaRecorder.value.stream
|
||||
if (stream) {
|
||||
await waveform.setupRecordingVisualization(stream)
|
||||
}
|
||||
}
|
||||
|
||||
// Start timer
|
||||
timer.value = 0
|
||||
resumeTimer()
|
||||
waveform.initWaveform()
|
||||
waveform.updateWaveform(isWaveformActive)
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopRecording() {
|
||||
recorder.stopRecording()
|
||||
pauseTimer()
|
||||
waveform.stopWaveform()
|
||||
}
|
||||
|
||||
async function handlePlayRecording() {
|
||||
if (!recordedURL.value) return
|
||||
|
||||
// Reset timer
|
||||
timer.value = 0
|
||||
|
||||
// Reset and setup audio element
|
||||
await playback.resetAudioElement()
|
||||
|
||||
// Wait for audio element to be ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
if (!audioRef.value) return
|
||||
|
||||
// Setup waveform visualization for playback
|
||||
const setupSuccess = await waveform.setupPlaybackVisualization(audioRef.value)
|
||||
if (!setupSuccess) return
|
||||
|
||||
// Start playback
|
||||
await playback.play()
|
||||
|
||||
// Update waveform
|
||||
waveform.initWaveform()
|
||||
waveform.updateWaveform(isWaveformActive)
|
||||
|
||||
// Update timer from audio current time
|
||||
const timerInterval = setInterval(() => {
|
||||
timer.value = Math.floor(playback.getCurrentTime())
|
||||
}, 100)
|
||||
|
||||
// Store interval for cleanup
|
||||
playback.playbackTimerInterval.value = timerInterval
|
||||
}
|
||||
|
||||
function handleStopPlayback() {
|
||||
playback.stop()
|
||||
handlePlaybackEnded()
|
||||
}
|
||||
|
||||
function handlePlaybackEnded() {
|
||||
waveform.stopWaveform()
|
||||
|
||||
// Clear playback timer interval
|
||||
if (playback.playbackTimerInterval.value !== null) {
|
||||
clearInterval(playback.playbackTimerInterval.value)
|
||||
playback.playbackTimerInterval.value = null
|
||||
}
|
||||
|
||||
const duration = playback.getDuration()
|
||||
if (duration) {
|
||||
timer.value = Math.floor(duration)
|
||||
} else {
|
||||
timer.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization function for workflow execution
|
||||
async function serializeValue() {
|
||||
if (isRecording.value && recorder.mediaRecorder.value) {
|
||||
recorder.mediaRecorder.value.stop()
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
|
||||
const checkRecording = () => {
|
||||
if (!isRecording.value && props.modelValue) {
|
||||
resolve(undefined)
|
||||
} else if (++attempts >= maxAttempts) {
|
||||
reject(new Error('Recording serialization timeout after 5 seconds'))
|
||||
} else {
|
||||
setTimeout(checkRecording, 100)
|
||||
}
|
||||
}
|
||||
checkRecording()
|
||||
})
|
||||
}
|
||||
|
||||
return props.modelValue || lastUploadedPath || ''
|
||||
}
|
||||
|
||||
function registerWidgetSerialization() {
|
||||
const node = litegraphNode.value
|
||||
if (!node?.widgets) return
|
||||
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
|
||||
if (targetWidget) {
|
||||
targetWidget.serializeValue = serializeValue
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
waveform.initWaveform()
|
||||
registerWidgetSerialization()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (playback.playbackTimerInterval.value !== null) {
|
||||
clearInterval(playback.playbackTimerInterval.value)
|
||||
playback.playbackTimerInterval.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="!hidden"
|
||||
:class="
|
||||
cn(
|
||||
'bg-zinc-500/10 dark-theme:bg-charcoal-600 box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
|
||||
{ hidden: hideWhenEmpty && !hasAudio }
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Hidden audio element -->
|
||||
<audio
|
||||
ref="audioRef"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
/>
|
||||
|
||||
<!-- Left Actions -->
|
||||
<div class="relative flex shrink-0 items-center justify-start gap-2">
|
||||
<!-- Play/Pause Button -->
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Play/Pause"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="icon-[lucide--play] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--pause] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div
|
||||
class="text-sm font-normal text-nowrap text-black dark-theme:text-white"
|
||||
>
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
class="relative h-0.5 flex-1 rounded-full bg-gray-300 dark-theme:bg-stone-200"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-gray-600 transition-all dark-theme:bg-white/50"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
:value="progressPercentage"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="absolute inset-0 w-full cursor-pointer opacity-0"
|
||||
@input="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="relative flex shrink-0 items-center justify-start gap-2">
|
||||
<!-- Volume Button -->
|
||||
<div
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Volume"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
v-if="showVolumeTwo"
|
||||
class="icon-[lucide--volume-2] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else-if="showVolumeOne"
|
||||
class="icon-[lucide--volume-1] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--volume-x] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Options Button -->
|
||||
<div
|
||||
v-if="showOptionsButton"
|
||||
ref="optionsButtonRef"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="More Options"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
@click="toggleOptionsMenu"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-vertical] size-4 text-gray-600 dark-theme:text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Menu -->
|
||||
<TieredMenu
|
||||
ref="optionsMenu"
|
||||
:model="menuItems"
|
||||
popup
|
||||
class="audio-player-menu"
|
||||
pt:root:class="!bg-white dark-theme:!bg-charcoal-800 !border-sand-100 dark-theme:!border-charcoal-600"
|
||||
pt:submenu:class="!bg-white dark-theme:!bg-charcoal-800"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
|
||||
<label
|
||||
class="mb-2 block text-xs text-black dark-theme:text-white"
|
||||
>{{ item.label }}</label
|
||||
>
|
||||
<Slider
|
||||
:model-value="volume * 10"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="handleVolumeChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
|
||||
@click="item.onClick?.()"
|
||||
>
|
||||
<span class="text-black dark-theme:text-white">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<i
|
||||
v-if="item.selected"
|
||||
class="ml-auto icon-[lucide--check] size-4 text-black dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TieredMenu>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
|
||||
import { isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { formatTime, getResourceURL } from '../../utils/audioUtils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
readonly?: boolean
|
||||
hideWhenEmpty?: boolean
|
||||
showOptionsButton?: boolean
|
||||
modelValue?: string
|
||||
nodeId?: string
|
||||
audioUrl?: string
|
||||
}>(),
|
||||
{
|
||||
hideWhenEmpty: true
|
||||
}
|
||||
)
|
||||
|
||||
// Refs
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const optionsMenu = ref()
|
||||
const optionsButtonRef = ref<HTMLElement>()
|
||||
const isPlaying = ref(false)
|
||||
const isMuted = ref(false)
|
||||
const volume = ref(1)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const hasAudio = ref(false)
|
||||
const playbackRate = ref(1)
|
||||
|
||||
// Computed
|
||||
const progressPercentage = computed(() => {
|
||||
if (!duration.value || duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const showVolumeTwo = computed(() => !isMuted.value && volume.value > 0.5)
|
||||
const showVolumeOne = computed(() => isMuted.value && volume.value > 0)
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
const hidden = computed(() => {
|
||||
if (!litegraphNode.value) return false
|
||||
// dont show if its a LoadAudio and we have nodeId
|
||||
const isLoadAudio =
|
||||
litegraphNode.value.constructor?.comfyClass === 'LoadAudio'
|
||||
return isLoadAudio && !!props.nodeId
|
||||
})
|
||||
|
||||
// Check if this is an output node
|
||||
const isOutputNodeRef = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
return !!node && isOutputNode(node)
|
||||
})
|
||||
|
||||
const nodeLocatorId = computed(() => {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return null
|
||||
return getLocatorIdFromNodeData(node)
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
// Computed audio URL from node output (for output nodes)
|
||||
const audioUrlFromOutput = computed(() => {
|
||||
if (!isOutputNodeRef.value || !nodeLocatorId.value) return ''
|
||||
|
||||
const nodeOutput = nodeOutputStore.nodeOutputs[nodeLocatorId.value]
|
||||
if (!nodeOutput?.audio || nodeOutput.audio.length === 0) return ''
|
||||
|
||||
const audio = nodeOutput.audio[0]
|
||||
if (!audio.filename) return ''
|
||||
|
||||
return api.apiURL(
|
||||
getResourceURL(
|
||||
audio.subfolder || '',
|
||||
audio.filename,
|
||||
audio.type || 'output'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Combined audio URL (output takes precedence for output nodes)
|
||||
const finalAudioUrl = computed(() => {
|
||||
return audioUrlFromOutput.value || props.audioUrl || ''
|
||||
})
|
||||
|
||||
// Playback controls
|
||||
const togglePlayPause = () => {
|
||||
if (!audioRef.value || !audioRef.value.src) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
void audioRef.value.play()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (audioRef.value) {
|
||||
isMuted.value = !isMuted.value
|
||||
audioRef.value.muted = isMuted.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeek = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = parseFloat(target.value)
|
||||
if (audioRef.value && duration.value > 0) {
|
||||
const newTime = (value / 100) * duration.value
|
||||
audioRef.value.currentTime = newTime
|
||||
currentTime.value = newTime
|
||||
}
|
||||
}
|
||||
|
||||
// Audio events
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.value) {
|
||||
duration.value = audioRef.value.duration
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.value) {
|
||||
currentTime.value = audioRef.value.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
// Options menu
|
||||
const toggleOptionsMenu = (event: Event) => {
|
||||
optionsMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const setPlaybackSpeed = (speed: number) => {
|
||||
playbackRate.value = speed
|
||||
if (audioRef.value) {
|
||||
audioRef.value.playbackRate = speed
|
||||
}
|
||||
}
|
||||
|
||||
const handleVolumeChange = (value: number | number[]) => {
|
||||
const numValue = Array.isArray(value) ? value[0] : value
|
||||
volume.value = numValue / 10
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = volume.value
|
||||
if (volume.value > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
audioRef.value.muted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('g.playbackSpeed'),
|
||||
items: [
|
||||
{
|
||||
label: t('g.halfSpeed'),
|
||||
onClick: () => setPlaybackSpeed(0.5),
|
||||
selected: playbackRate.value === 0.5
|
||||
},
|
||||
{
|
||||
label: t('g.1x'),
|
||||
onClick: () => setPlaybackSpeed(1),
|
||||
selected: playbackRate.value === 1
|
||||
},
|
||||
{
|
||||
label: t('g.2x'),
|
||||
onClick: () => setPlaybackSpeed(2),
|
||||
selected: playbackRate.value === 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('g.volume'),
|
||||
key: 'volume'
|
||||
}
|
||||
])
|
||||
|
||||
// Load audio from URL
|
||||
const loadAudioFromUrl = (url: string) => {
|
||||
if (!audioRef.value) return
|
||||
isPlaying.value = false
|
||||
audioRef.value.pause()
|
||||
audioRef.value.src = url
|
||||
void audioRef.value.load()
|
||||
hasAudio.value = !!url
|
||||
}
|
||||
|
||||
// Watch for finalAudioUrl changes
|
||||
watch(
|
||||
finalAudioUrl,
|
||||
(newUrl) => {
|
||||
if (newUrl) {
|
||||
void nextTick(() => {
|
||||
loadAudioFromUrl(newUrl)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.src = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-player-menu {
|
||||
--p-tieredmenu-item-focus-background: rgba(255, 255, 255, 0.1);
|
||||
--p-tieredmenu-item-active-background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface AudioPlaybackOptions {
|
||||
onPlaybackEnded?: () => void
|
||||
onMetadataLoaded?: (duration: number) => void
|
||||
}
|
||||
|
||||
export function useAudioPlayback(
|
||||
audioRef: Ref<HTMLAudioElement | undefined>,
|
||||
options: AudioPlaybackOptions = {}
|
||||
) {
|
||||
const isPlaying = ref(false)
|
||||
const audioElementKey = ref(0)
|
||||
const playbackTimerInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
async function play() {
|
||||
if (!audioRef.value) return false
|
||||
|
||||
try {
|
||||
await audioRef.value.play()
|
||||
isPlaying.value = true
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Audio playback failed:', error)
|
||||
isPlaying.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.currentTime = 0
|
||||
}
|
||||
isPlaying.value = false
|
||||
if (options.onPlaybackEnded) {
|
||||
options.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
function onPlaybackEnded() {
|
||||
isPlaying.value = false
|
||||
if (options.onPlaybackEnded) {
|
||||
options.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
function onMetadataLoaded() {
|
||||
if (audioRef.value?.duration && options.onMetadataLoaded) {
|
||||
options.onMetadataLoaded(audioRef.value.duration)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAudioElement() {
|
||||
audioElementKey.value += 1
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function getCurrentTime() {
|
||||
return audioRef.value?.currentTime || 0
|
||||
}
|
||||
|
||||
function getDuration() {
|
||||
return audioRef.value?.duration || 0
|
||||
}
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
audioElementKey,
|
||||
play,
|
||||
stop,
|
||||
onPlaybackEnded,
|
||||
onMetadataLoaded,
|
||||
resetAudioElement,
|
||||
getCurrentTime,
|
||||
getDuration,
|
||||
playbackTimerInterval
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
|
||||
interface AudioRecorderOptions {
|
||||
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function useAudioRecorder(options: AudioRecorderOptions = {}) {
|
||||
const isRecording = ref(false)
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||
const audioChunks = ref<Blob[]>([])
|
||||
const stream = ref<MediaStream | null>(null)
|
||||
const recordedURL = ref<string | null>(null)
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
// Clean up previous recording
|
||||
if (recordedURL.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
}
|
||||
|
||||
// Initialize
|
||||
audioChunks.value = []
|
||||
recordedURL.value = null
|
||||
|
||||
// Register wav encoder and get media stream
|
||||
await useAudioService().registerWavEncoder()
|
||||
stream.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
// Create media recorder
|
||||
mediaRecorder.value = new ExtendableMediaRecorder(stream.value, {
|
||||
mimeType: 'audio/wav'
|
||||
}) as unknown as MediaRecorder
|
||||
|
||||
mediaRecorder.value.ondataavailable = (e) => {
|
||||
audioChunks.value.push(e.data)
|
||||
}
|
||||
|
||||
mediaRecorder.value.onstop = async () => {
|
||||
const blob = new Blob(audioChunks.value, { type: 'audio/wav' })
|
||||
|
||||
// Create blob URL for preview
|
||||
if (recordedURL.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
}
|
||||
recordedURL.value = URL.createObjectURL(blob)
|
||||
|
||||
// Notify completion
|
||||
if (options.onRecordingComplete) {
|
||||
await options.onRecordingComplete(blob)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.value.start(100)
|
||||
isRecording.value = true
|
||||
} catch (err) {
|
||||
if (options.onError) {
|
||||
options.onError(err as Error)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
|
||||
mediaRecorder.value.stop()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isRecording.value = false
|
||||
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach((track) => track.stop())
|
||||
stream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopRecording()
|
||||
if (recordedURL.value) {
|
||||
URL.revokeObjectURL(recordedURL.value)
|
||||
recordedURL.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordedURL,
|
||||
mediaRecorder,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface WaveformBar {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface AudioWaveformOptions {
|
||||
barCount?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export function useAudioWaveform(options: AudioWaveformOptions = {}) {
|
||||
const { barCount = 18, minHeight = 4, maxHeight = 32 } = options
|
||||
|
||||
const waveformBars = ref<WaveformBar[]>(
|
||||
Array.from({ length: barCount }, () => ({ height: 16 }))
|
||||
)
|
||||
const audioContext = ref<AudioContext | null>(null)
|
||||
const analyser = ref<AnalyserNode | null>(null)
|
||||
const dataArray = ref<Uint8Array | null>(null)
|
||||
const animationId = ref<number | null>(null)
|
||||
const mediaElementSource = ref<MediaElementAudioSourceNode | null>(null)
|
||||
|
||||
function initWaveform() {
|
||||
waveformBars.value = Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * (maxHeight - minHeight) + minHeight
|
||||
}))
|
||||
}
|
||||
|
||||
function updateWaveform(isActive: Ref<boolean>) {
|
||||
if (!isActive.value) return
|
||||
|
||||
if (analyser.value && dataArray.value) {
|
||||
updateWaveformFromAudio()
|
||||
} else {
|
||||
updateWaveformRandom()
|
||||
}
|
||||
|
||||
animationId.value = requestAnimationFrame(() => updateWaveform(isActive))
|
||||
}
|
||||
|
||||
function updateWaveformFromAudio() {
|
||||
if (!analyser.value || !dataArray.value) return
|
||||
|
||||
analyser.value.getByteFrequencyData(
|
||||
dataArray.value as Uint8Array<ArrayBuffer>
|
||||
)
|
||||
const samplesPerBar = Math.floor(dataArray.value.length / barCount)
|
||||
|
||||
waveformBars.value = waveformBars.value.map((_, i) => {
|
||||
let sum = 0
|
||||
for (let j = 0; j < samplesPerBar; j++) {
|
||||
sum += dataArray.value![i * samplesPerBar + j] || 0
|
||||
}
|
||||
const average = sum / samplesPerBar
|
||||
const normalizedHeight =
|
||||
(average / 255) * (maxHeight - minHeight) + minHeight
|
||||
return { height: normalizedHeight }
|
||||
})
|
||||
}
|
||||
|
||||
function updateWaveformRandom() {
|
||||
waveformBars.value = waveformBars.value.map((bar) => ({
|
||||
height: Math.max(
|
||||
minHeight,
|
||||
Math.min(maxHeight, bar.height + (Math.random() - 0.5) * 4)
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
async function setupAudioContext() {
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
await audioContext.value.close()
|
||||
}
|
||||
audioContext.value = null
|
||||
mediaElementSource.value = null
|
||||
}
|
||||
|
||||
async function setupRecordingVisualization(stream: MediaStream) {
|
||||
audioContext.value = new window.AudioContext()
|
||||
analyser.value = audioContext.value.createAnalyser()
|
||||
const source = audioContext.value.createMediaStreamSource(stream)
|
||||
source.connect(analyser.value)
|
||||
|
||||
analyser.value.fftSize = 256
|
||||
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
|
||||
}
|
||||
|
||||
async function setupPlaybackVisualization(audioElement: HTMLAudioElement) {
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
await audioContext.value.close()
|
||||
}
|
||||
|
||||
mediaElementSource.value = null
|
||||
|
||||
if (!audioElement) return false
|
||||
|
||||
audioContext.value = new window.AudioContext()
|
||||
analyser.value = audioContext.value.createAnalyser()
|
||||
|
||||
mediaElementSource.value =
|
||||
audioContext.value.createMediaElementSource(audioElement)
|
||||
|
||||
mediaElementSource.value.connect(analyser.value)
|
||||
analyser.value.connect(audioContext.value.destination)
|
||||
|
||||
analyser.value.fftSize = 256
|
||||
dataArray.value = new Uint8Array(analyser.value.frequencyBinCount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function stopWaveform() {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
animationId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stopWaveform()
|
||||
if (audioContext.value && audioContext.value.state !== 'closed') {
|
||||
void audioContext.value.close()
|
||||
}
|
||||
audioContext.value = null
|
||||
mediaElementSource.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
waveformBars,
|
||||
initWaveform,
|
||||
updateWaveform,
|
||||
setupAudioContext,
|
||||
setupRecordingVisualization,
|
||||
setupPlaybackVisualization,
|
||||
stopWaveform,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
AudioRecordInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
|
||||
const {
|
||||
name,
|
||||
default: defaultValue = '',
|
||||
options = {}
|
||||
} = inputSpec as AudioRecordInputSpec
|
||||
|
||||
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
|
||||
serialize: true,
|
||||
...options
|
||||
}) as IAudioRecordWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import WidgetAudioUI from '../components/WidgetAudioUI.vue'
|
||||
import WidgetButton from '../components/WidgetButton.vue'
|
||||
import WidgetChart from '../components/WidgetChart.vue'
|
||||
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
|
||||
@@ -13,11 +14,13 @@ import WidgetInputNumber from '../components/WidgetInputNumber.vue'
|
||||
import WidgetInputText from '../components/WidgetInputText.vue'
|
||||
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
|
||||
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
|
||||
import WidgetRecordAudio from '../components/WidgetRecordAudio.vue'
|
||||
import WidgetSelect from '../components/WidgetSelect.vue'
|
||||
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
|
||||
import WidgetTextarea from '../components/WidgetTextarea.vue'
|
||||
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
|
||||
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
|
||||
import AudioPreviewPlayer from '../components/audio/AudioPreviewPlayer.vue'
|
||||
|
||||
interface WidgetDefinition {
|
||||
component: Component
|
||||
@@ -108,9 +111,29 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
[
|
||||
'markdown',
|
||||
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
|
||||
],
|
||||
[
|
||||
'audiorecord',
|
||||
{
|
||||
component: WidgetRecordAudio,
|
||||
aliases: ['AUDIO_RECORD', 'AUDIORECORD'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'audioUI',
|
||||
{
|
||||
component: AudioPreviewPlayer,
|
||||
aliases: ['AUDIOUI', 'AUDIO_UI'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
const getComboWidgetAdditions = (): Map<string, Component> => {
|
||||
return new Map([['audio', WidgetAudioUI]])
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const widgets = new Map<string, WidgetDefinition>()
|
||||
const aliasMap = new Map<string, string>()
|
||||
@@ -125,7 +148,13 @@ for (const [type, def] of coreWidgetDefinitions) {
|
||||
// Utility functions
|
||||
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
|
||||
|
||||
export const getComponent = (type: string): Component | null => {
|
||||
export const getComponent = (type: string, name: string): Component | null => {
|
||||
if (type == 'combo') {
|
||||
const comboAdditions = getComboWidgetAdditions()
|
||||
if (comboAdditions.has(name)) {
|
||||
return comboAdditions.get(name) || null
|
||||
}
|
||||
}
|
||||
const canonicalType = getCanonicalType(type)
|
||||
return widgets.get(canonicalType)?.component || null
|
||||
}
|
||||
|
||||
54
src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
Normal file
54
src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
*/
|
||||
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')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full audio URL from path
|
||||
*/
|
||||
export function getAudioUrlFromPath(
|
||||
path: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const [subfolder, filename] = splitFilePath(path)
|
||||
return api.apiURL(getResourceURL(subfolder, filename, type))
|
||||
}
|
||||
|
||||
function getRandParam() {
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
export function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
getRandParam().substring(1)
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
}
|
||||
|
||||
export function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
return ['', path]
|
||||
}
|
||||
return [
|
||||
path.substring(0, folder_separator),
|
||||
path.substring(folder_separator + 1)
|
||||
]
|
||||
}
|
||||
@@ -152,6 +152,13 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zAudioRecordInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('AUDIORECORD'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
@@ -167,6 +174,7 @@ const zInputSpec = z.union([
|
||||
zColorInputSpec,
|
||||
zFileUploadInputSpec,
|
||||
zImageInputSpec,
|
||||
zAudioRecordInputSpec,
|
||||
zImageCompareInputSpec,
|
||||
zMarkdownInputSpec,
|
||||
zTreeSelectInputSpec,
|
||||
@@ -222,6 +230,7 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
|
||||
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
export type OutputSpec = z.infer<typeof zOutputSpec>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IStringWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
|
||||
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
@@ -304,5 +305,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WidgetAudioUI from '@/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue'
|
||||
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
|
||||
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
|
||||
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
|
||||
@@ -26,81 +27,81 @@ describe('widgetRegistry', () => {
|
||||
// Test number type mappings
|
||||
describe('number types', () => {
|
||||
it('should map int types to slider widget', () => {
|
||||
expect(getComponent('int')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('INT')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('int', 'bar')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('INT', 'bar')).toBe(WidgetInputNumber)
|
||||
})
|
||||
|
||||
it('should map float types to slider widget', () => {
|
||||
expect(getComponent('float')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('number')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('slider')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('float', 'cfg')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('FLOAT', 'cfg')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('number', 'cfg')).toBe(WidgetInputNumber)
|
||||
expect(getComponent('slider', 'cfg')).toBe(WidgetInputNumber)
|
||||
})
|
||||
})
|
||||
|
||||
// Test text type mappings
|
||||
describe('text types', () => {
|
||||
it('should map text variations to input text widget', () => {
|
||||
expect(getComponent('text')).toBe(WidgetInputText)
|
||||
expect(getComponent('string')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING')).toBe(WidgetInputText)
|
||||
expect(getComponent('text', 'text')).toBe(WidgetInputText)
|
||||
expect(getComponent('string', 'text')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING', 'text')).toBe(WidgetInputText)
|
||||
})
|
||||
|
||||
it('should map multiline text types to textarea widget', () => {
|
||||
expect(getComponent('multiline')).toBe(WidgetTextarea)
|
||||
expect(getComponent('textarea')).toBe(WidgetTextarea)
|
||||
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
|
||||
expect(getComponent('customtext')).toBe(WidgetTextarea)
|
||||
expect(getComponent('multiline', 'text')).toBe(WidgetTextarea)
|
||||
expect(getComponent('textarea', 'text')).toBe(WidgetTextarea)
|
||||
expect(getComponent('TEXTAREA', 'text')).toBe(WidgetTextarea)
|
||||
expect(getComponent('customtext', 'text')).toBe(WidgetTextarea)
|
||||
})
|
||||
|
||||
it('should map markdown to markdown widget', () => {
|
||||
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
|
||||
expect(getComponent('markdown')).toBe(WidgetMarkdown)
|
||||
expect(getComponent('MARKDOWN', 'text')).toBe(WidgetMarkdown)
|
||||
expect(getComponent('markdown', 'text')).toBe(WidgetMarkdown)
|
||||
})
|
||||
})
|
||||
|
||||
// Test selection type mappings
|
||||
describe('selection types', () => {
|
||||
it('should map combo types to select widget', () => {
|
||||
expect(getComponent('combo')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO')).toBe(WidgetSelect)
|
||||
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO', 'video')).toBe(WidgetSelect)
|
||||
})
|
||||
})
|
||||
|
||||
// Test boolean type mappings
|
||||
describe('boolean types', () => {
|
||||
it('should map boolean types to toggle switch widget', () => {
|
||||
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('toggle', 'image')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('boolean', 'image')).toBe(WidgetToggleSwitch)
|
||||
expect(getComponent('BOOLEAN', 'image')).toBe(WidgetToggleSwitch)
|
||||
})
|
||||
})
|
||||
|
||||
// Test advanced widget mappings
|
||||
describe('advanced widgets', () => {
|
||||
it('should map color types to color picker widget', () => {
|
||||
expect(getComponent('color')).toBe(WidgetColorPicker)
|
||||
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
|
||||
expect(getComponent('color', 'color')).toBe(WidgetColorPicker)
|
||||
expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker)
|
||||
})
|
||||
|
||||
it('should map file types to file upload widget', () => {
|
||||
expect(getComponent('file')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('fileupload')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('file', 'file')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('fileupload', 'file')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('FILEUPLOAD', 'file')).toBe(WidgetFileUpload)
|
||||
})
|
||||
|
||||
it('should map button types to button widget', () => {
|
||||
expect(getComponent('button')).toBe(WidgetButton)
|
||||
expect(getComponent('BUTTON')).toBe(WidgetButton)
|
||||
expect(getComponent('button', '')).toBe(WidgetButton)
|
||||
expect(getComponent('BUTTON', '')).toBe(WidgetButton)
|
||||
})
|
||||
})
|
||||
|
||||
// Test fallback behavior
|
||||
describe('fallback behavior', () => {
|
||||
it('should return null for unknown types', () => {
|
||||
expect(getComponent('unknown')).toBe(null)
|
||||
expect(getComponent('custom_widget')).toBe(null)
|
||||
expect(getComponent('')).toBe(null)
|
||||
expect(getComponent('unknown', 'unknown')).toBe(null)
|
||||
expect(getComponent('custom_widget', 'custom_widget')).toBe(null)
|
||||
expect(getComponent('', '')).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -165,10 +166,16 @@ describe('widgetRegistry', () => {
|
||||
|
||||
it('should handle case sensitivity correctly through aliases', () => {
|
||||
// Test that both lowercase and uppercase work
|
||||
expect(getComponent('string')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING')).toBe(WidgetInputText)
|
||||
expect(getComponent('combo')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO')).toBe(WidgetSelect)
|
||||
expect(getComponent('string', '')).toBe(WidgetInputText)
|
||||
expect(getComponent('STRING', '')).toBe(WidgetInputText)
|
||||
expect(getComponent('combo', '')).toBe(WidgetSelect)
|
||||
expect(getComponent('COMBO', '')).toBe(WidgetSelect)
|
||||
})
|
||||
|
||||
it('should handle combo additional widgets', () => {
|
||||
// Test that both lowercase and uppercase work
|
||||
expect(getComponent('combo', 'audio')).toBe(WidgetAudioUI)
|
||||
expect(getComponent('combo', 'image')).toBe(WidgetSelect)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { vi } from 'vitest'
|
||||
import 'vue'
|
||||
|
||||
// Define global variables for tests
|
||||
@@ -7,3 +8,12 @@ globalThis.__SENTRY_DSN__ = ''
|
||||
globalThis.__ALGOLIA_APP_ID__ = ''
|
||||
globalThis.__ALGOLIA_API_KEY__ = ''
|
||||
globalThis.__USE_PROD_CONFIG__ = false
|
||||
|
||||
// Mock Worker for extendable-media-recorder
|
||||
globalThis.Worker = vi.fn().mockImplementation(() => ({
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user