Flux/Krea, MiniMax/LTX, and ACE/YuE sit on the top kind cards so you choose the engine before the prompt form.
757 lines
28 KiB
Vue
757 lines
28 KiB
Vue
<template>
|
||
<div class="min-h-dvh min-w-0">
|
||
<AppHeader
|
||
:subtitle="headerSubtitle"
|
||
current="music"
|
||
:queue-count="queueCount"
|
||
/>
|
||
|
||
<main class="mx-auto min-w-0 max-w-7xl space-y-6 px-4 py-6 sm:px-6">
|
||
<StudioKindCards
|
||
current="music"
|
||
:music-engine="engineFamily"
|
||
@select="goStudio"
|
||
@music-engine="selectEngineFamily"
|
||
/>
|
||
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||
<section class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
|
||
<div>
|
||
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
||
<p class="mt-1 text-sm text-zinc-400">
|
||
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="engineFamily === 'ace'" class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
||
<span class="min-w-0">
|
||
<span class="flex items-center gap-2">
|
||
<span class="block font-medium text-zinc-200">ACE-Step 1.5</span>
|
||
<InfoTip
|
||
aria-label="What ACE-Step 1.5 does"
|
||
:text="ace15Info"
|
||
/>
|
||
</span>
|
||
<span class="mt-0.5 block text-xs text-zinc-500">Turbo AIO checkpoint. Different graph than v1, usually 8 steps.</span>
|
||
</span>
|
||
<label class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center" title="Toggle ACE-Step 1.5">
|
||
<input v-model="ace15" type="checkbox" class="peer sr-only" role="switch">
|
||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||
</label>
|
||
</div>
|
||
|
||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Genre & style tags</span>
|
||
<input
|
||
v-model="tags"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||
:placeholder="tagsPlaceholder"
|
||
maxlength="2000"
|
||
>
|
||
</label>
|
||
|
||
<div>
|
||
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||
<input v-model="instrumental" type="checkbox" class="accent-amber-400">
|
||
Instrumental
|
||
</label>
|
||
</div>
|
||
<div class="mb-2 flex flex-wrap gap-1.5">
|
||
<button
|
||
v-for="chip in chips"
|
||
:key="chip"
|
||
type="button"
|
||
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
|
||
:disabled="instrumental"
|
||
@click="insertChip(chip)"
|
||
>{{ chip }}</button>
|
||
</div>
|
||
<textarea
|
||
ref="lyricsEl"
|
||
v-model="lyrics"
|
||
rows="12"
|
||
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
|
||
:disabled="instrumental"
|
||
:placeholder="lyricsPlaceholder"
|
||
/>
|
||
</div>
|
||
|
||
<div class="grid gap-4 sm:grid-cols-2">
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Duration · {{ duration }}s</span>
|
||
<input
|
||
v-model.number="duration"
|
||
type="range"
|
||
class="aigen-slider w-full"
|
||
:min="durationMin"
|
||
:max="durationMax"
|
||
step="5"
|
||
>
|
||
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'YuE uses this as stage-A length' : `${durationMin}–${durationMax} seconds` }}</span>
|
||
</label>
|
||
<label v-if="engineFamily !== 'yue'" class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
||
<input
|
||
v-model.number="steps"
|
||
type="range"
|
||
class="aigen-slider w-full"
|
||
:min="stepsMin"
|
||
:max="stepsMax"
|
||
step="1"
|
||
>
|
||
<span class="mt-1 block text-[11px] text-zinc-500">{{ ace15 ? '8 is the usual 1.5 turbo range' : '30–50 is the usual range' }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
|
||
<input
|
||
v-model="seed"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||
placeholder="random"
|
||
>
|
||
</label>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Folder</span>
|
||
<select v-model="folderId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Name</span>
|
||
<input
|
||
v-model="name"
|
||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||
maxlength="80"
|
||
placeholder="Optional track name"
|
||
>
|
||
</label>
|
||
|
||
<button
|
||
type="button"
|
||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="busy || blocked"
|
||
@click="generate"
|
||
>
|
||
<span
|
||
v-if="busy"
|
||
class="h-4 w-4 animate-spin rounded-full border-2 border-zinc-950/20 border-t-zinc-950"
|
||
/>
|
||
{{ busy ? (status || 'Generating…') : 'Generate' }}
|
||
</button>
|
||
<button
|
||
v-if="busy || queued"
|
||
type="button"
|
||
class="inline-flex w-full items-center justify-center rounded-2xl border border-red-400/40 px-4 py-2.5 text-sm text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||
:disabled="forceClearing"
|
||
title="Cancel stuck music/studio jobs and kill Comfy if wedged"
|
||
@click="forceClearQueue"
|
||
>
|
||
{{ forceClearing ? 'Force clearing…' : 'Force clear stuck queue' }}
|
||
</button>
|
||
<p v-if="blockReason" class="text-xs text-zinc-400">{{ blockReason }}</p>
|
||
<p v-else-if="queued" class="text-xs text-zinc-400">
|
||
Waiting in the job queue.
|
||
<button type="button" class="text-amber-200 hover:text-amber-100" @click="inspectJobId = studioJobId || liveMusicId">Inspect</button>
|
||
·
|
||
<NuxtLink to="/queue" class="text-amber-200 hover:text-amber-100">Open Queue</NuxtLink>
|
||
</p>
|
||
|
||
<div v-if="musicPresets.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
|
||
<label class="block text-sm">
|
||
<span class="mb-1 block font-medium text-zinc-300">Templates</span>
|
||
<select v-model="presetId" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" @change="applyPreset">
|
||
<option value="">Load a saved template</option>
|
||
<option v-for="preset in musicPresets" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="glass min-w-0 max-w-full space-y-4 rounded-3xl p-5 sm:p-6">
|
||
<div>
|
||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||
<p class="mt-1 text-sm text-zinc-400">{{ status || 'Waiting for a generate.' }}</p>
|
||
<button
|
||
v-if="studioJobId || liveMusicId"
|
||
type="button"
|
||
class="mt-2 text-xs text-amber-200 hover:text-amber-100"
|
||
@click="inspectJobId = studioJobId || liveMusicId"
|
||
>Inspect this job</button>
|
||
</div>
|
||
<div v-if="busy" class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||
<div class="progress-glow h-full rounded-full bg-amber-400 transition-[width]" :style="{ width: `${Math.max(progress, 4)}%` }" />
|
||
</div>
|
||
<AudioPlayer
|
||
v-if="audioUrl"
|
||
:src="audioUrl"
|
||
:download-name="downloadName"
|
||
/>
|
||
<div v-else class="flex min-h-44 items-center justify-center rounded-2xl border border-dashed border-white/10 text-sm text-zinc-500">
|
||
Generated FLAC / WAV plays here
|
||
</div>
|
||
<p v-if="trackId" class="text-xs text-zinc-500">
|
||
Saved to library.
|
||
<NuxtLink to="/library" class="text-amber-200 hover:text-amber-100">Open Library</NuxtLink>
|
||
</p>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
<QueuedJobModal :job-id="inspectJobId" @close="inspectJobId = ''" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
DEFAULT_MUSIC_LYRICS,
|
||
DEFAULT_MUSIC_TAGS,
|
||
MUSIC_CFG_DEFAULT,
|
||
MUSIC_CFG_DEFAULT_15,
|
||
MUSIC_DURATION_DEFAULT,
|
||
MUSIC_DURATION_MAX,
|
||
MUSIC_DURATION_MIN,
|
||
MUSIC_STEPS_DEFAULT,
|
||
MUSIC_STEPS_DEFAULT_15,
|
||
MUSIC_STEPS_MAX,
|
||
MUSIC_STEPS_MIN,
|
||
MUSIC_STRUCTURE_CHIPS,
|
||
parseMusicEngine,
|
||
type MusicEngine
|
||
} from '~/utils/music'
|
||
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
||
import type { LibraryFolder } from '~/utils/libraryBrowse'
|
||
import { LOAD_GENERATION_PAYLOAD_KEY, LOAD_MUSIC_JOB_KEY } from '~/utils/queuedJob'
|
||
import type { GenerationPreset, MusicPresetSettings } from '~/utils/generationPresets'
|
||
|
||
const tagsPlaceholder = DEFAULT_MUSIC_TAGS
|
||
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
||
const chips = MUSIC_STRUCTURE_CHIPS
|
||
const durationMin = MUSIC_DURATION_MIN
|
||
const durationMax = MUSIC_DURATION_MAX
|
||
const stepsMin = MUSIC_STEPS_MIN
|
||
const stepsMax = MUSIC_STEPS_MAX
|
||
|
||
const ace15Info = 'Turns on ACE-Step 1.5 Turbo, the all-in-one checkpoint already on Beast. 1.5 uses a different Comfy graph than v1: a 1.5 text encoder (tags, BPM, key, duration) and EmptyAceStep1.5LatentAudio, usually with 8 sampler steps and CFG 1. Songs often come back faster and cleaner. Lyrics-strength from v1 does not apply. Leave this off to stay on ACE-Step v1 3.5B, which is the graph that already generated on this GPU.'
|
||
|
||
const tags = ref(DEFAULT_MUSIC_TAGS)
|
||
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
||
const instrumental = ref(false)
|
||
const duration = ref(MUSIC_DURATION_DEFAULT)
|
||
const steps = ref(MUSIC_STEPS_DEFAULT)
|
||
const seed = ref('')
|
||
const name = ref('')
|
||
const folderId = ref('')
|
||
const folders = ref<LibraryFolder[]>([])
|
||
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
||
const ace15 = ref(false)
|
||
const engineFamily = ref<'ace' | 'yue'>('ace')
|
||
const busy = ref(false)
|
||
const forceClearing = ref(false)
|
||
const error = ref('')
|
||
const status = ref('')
|
||
const progress = ref(0)
|
||
const audioUrl = ref('')
|
||
const trackId = ref('')
|
||
const jobId = ref('')
|
||
const studioJobId = ref('')
|
||
const queued = ref(false)
|
||
const queueCount = ref(0)
|
||
const downloadName = ref('track.flac')
|
||
const inspectJobId = ref('')
|
||
const liveMusicId = ref('')
|
||
const musicPresets = ref<GenerationPreset[]>([])
|
||
const presetId = ref('')
|
||
let source: EventSource | null = null
|
||
let listenGen = 0
|
||
let settled = false
|
||
let queuePoll: ReturnType<typeof setInterval> | null = null
|
||
let recoverPoll: ReturnType<typeof setInterval> | null = null
|
||
|
||
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
|
||
const headerSubtitle = computed(() => {
|
||
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
|
||
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
|
||
})
|
||
const selectedEngine = computed<MusicEngine>(() => {
|
||
if (engineFamily.value === 'yue') return 'yue'
|
||
return ace15.value ? 'ace-step-1.5' : 'ace-step'
|
||
})
|
||
const blockReason = computed(() => {
|
||
if (!folderId.value) return 'Choose a library folder before generating.'
|
||
if (!tags.value.trim()) return 'Add genre and style tags.'
|
||
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
||
return ''
|
||
})
|
||
|
||
function selectEngineFamily(family: 'ace' | 'yue') {
|
||
engineFamily.value = family
|
||
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
|
||
steps.value = MUSIC_STEPS_DEFAULT_15
|
||
}
|
||
if (family === 'ace' && !ace15.value && steps.value === MUSIC_STEPS_DEFAULT_15) {
|
||
steps.value = MUSIC_STEPS_DEFAULT
|
||
}
|
||
}
|
||
|
||
function goStudio(kind: 'video' | 'image') {
|
||
void navigateTo({ path: '/', query: { studio: kind } })
|
||
}
|
||
|
||
function applyEngine(engine: MusicEngine) {
|
||
if (engine === 'yue') {
|
||
engineFamily.value = 'yue'
|
||
ace15.value = false
|
||
return
|
||
}
|
||
engineFamily.value = 'ace'
|
||
ace15.value = engine === 'ace-step-1.5'
|
||
}
|
||
|
||
watch(ace15, (on) => {
|
||
if (engineFamily.value !== 'ace') return
|
||
if (on && (steps.value === MUSIC_STEPS_DEFAULT || steps.value >= 30)) steps.value = MUSIC_STEPS_DEFAULT_15
|
||
if (!on && steps.value === MUSIC_STEPS_DEFAULT_15) steps.value = MUSIC_STEPS_DEFAULT
|
||
})
|
||
|
||
function insertChip(chip: string) {
|
||
const el = lyricsEl.value
|
||
const token = `${chip}\n`
|
||
if (!el) {
|
||
lyrics.value = `${lyrics.value}${lyrics.value.endsWith('\n') || !lyrics.value ? '' : '\n'}${token}`
|
||
return
|
||
}
|
||
const start = el.selectionStart ?? lyrics.value.length
|
||
const end = el.selectionEnd ?? start
|
||
lyrics.value = `${lyrics.value.slice(0, start)}${token}${lyrics.value.slice(end)}`
|
||
nextTick(() => {
|
||
const pos = start + token.length
|
||
el.focus()
|
||
el.setSelectionRange(pos, pos)
|
||
})
|
||
}
|
||
|
||
async function loadLibrary() {
|
||
const payload = await $fetch<{ folders?: LibraryFolder[] }>('/api/library?light=1').catch(() => ({ folders: [] }))
|
||
folders.value = payload.folders || []
|
||
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
||
folderId.value = folders.value.find(folder => folder.unlocked)?.id || folders.value[0]?.id || ''
|
||
}
|
||
}
|
||
|
||
function stopListen() {
|
||
listenGen += 1
|
||
source?.close()
|
||
source = null
|
||
}
|
||
|
||
function stopQueuePoll() {
|
||
if (queuePoll) {
|
||
clearInterval(queuePoll)
|
||
queuePoll = null
|
||
}
|
||
}
|
||
|
||
function stopRecoverPoll() {
|
||
if (recoverPoll) {
|
||
clearInterval(recoverPoll)
|
||
recoverPoll = null
|
||
}
|
||
}
|
||
|
||
async function tryRecover() {
|
||
if (settled || !busy.value || queued.value) return
|
||
try {
|
||
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
|
||
method: 'POST',
|
||
body: { jobId: jobId.value || undefined }
|
||
})
|
||
if (recovered?.trackId) applyEvent(recovered)
|
||
} catch {
|
||
/* still running */
|
||
}
|
||
}
|
||
|
||
async function attachLiveMusic(opts: {
|
||
studioId?: string
|
||
liveId: string
|
||
message?: string
|
||
progress?: number
|
||
}) {
|
||
const liveId = String(opts.liveId || '').trim()
|
||
if (!liveId) return
|
||
if (opts.studioId) studioJobId.value = opts.studioId
|
||
liveMusicId.value = opts.studioId || liveMusicId.value || liveId
|
||
queued.value = false
|
||
busy.value = true
|
||
settled = false
|
||
if (typeof opts.progress === 'number') progress.value = opts.progress
|
||
if (opts.message) status.value = opts.message
|
||
else if (!status.value || /waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
||
status.value = selectedEngine.value === 'yue'
|
||
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
|
||
: 'Generating…'
|
||
}
|
||
if (jobId.value !== liveId) {
|
||
jobId.value = liveId
|
||
listen(liveId)
|
||
}
|
||
if (!recoverPoll) {
|
||
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
|
||
}
|
||
}
|
||
|
||
async function refreshStudioQueue() {
|
||
const data = await $fetch<{
|
||
jobs?: Array<{
|
||
id: string
|
||
status: string
|
||
kind?: string
|
||
liveJobId?: string
|
||
lastError?: string
|
||
}>
|
||
waitingCount?: number
|
||
}>('/api/studio-queue').catch(() => ({ jobs: [] as Array<{ id: string; status: string; kind?: string; liveJobId?: string; lastError?: string }>, waitingCount: 0 }))
|
||
const rows = data.jobs || []
|
||
const musicRow = rows.find(job => job.kind === 'music' && (job.status === 'waiting' || job.status === 'running' || job.status === 'held'))
|
||
liveMusicId.value = musicRow?.id || ''
|
||
queueCount.value = data.waitingCount || rows.filter(job => job.status === 'waiting').length
|
||
|
||
if (musicRow?.status === 'running' && musicRow.liveJobId) {
|
||
await attachLiveMusic({
|
||
studioId: musicRow.id,
|
||
liveId: musicRow.liveJobId
|
||
})
|
||
} else if (musicRow && (musicRow.status === 'waiting' || (musicRow.status === 'held' && !musicRow.liveJobId))) {
|
||
studioJobId.value = musicRow.id
|
||
queued.value = true
|
||
busy.value = true
|
||
status.value = musicRow.status === 'held'
|
||
? 'Paused in the job queue'
|
||
: (selectedEngine.value === 'yue'
|
||
? 'Waiting for GPU… YuE is heavy; do not spam Generate'
|
||
: 'Waiting in the job queue…')
|
||
}
|
||
|
||
if (!studioJobId.value) return
|
||
const row = rows.find(job => job.id === studioJobId.value)
|
||
if (!row) {
|
||
if (queued.value && !jobId.value) {
|
||
queued.value = false
|
||
}
|
||
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
||
status.value = selectedEngine.value === 'yue'
|
||
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
|
||
: 'Generating…'
|
||
}
|
||
return
|
||
}
|
||
if (row.status === 'waiting' || (row.status === 'held' && !row.liveJobId)) {
|
||
queued.value = true
|
||
busy.value = true
|
||
status.value = row.status === 'held'
|
||
? 'Paused in the job queue'
|
||
: (selectedEngine.value === 'yue'
|
||
? 'Waiting for GPU… YuE is heavy; do not spam Generate'
|
||
: 'Waiting in the job queue…')
|
||
return
|
||
}
|
||
if (row.status === 'running' && row.liveJobId) {
|
||
await attachLiveMusic({ studioId: row.id, liveId: row.liveJobId })
|
||
}
|
||
if (row.status === 'error') {
|
||
queued.value = false
|
||
busy.value = false
|
||
error.value = row.lastError || 'Generation failed'
|
||
status.value = error.value
|
||
stopQueuePoll()
|
||
return
|
||
}
|
||
if (row.liveJobId && row.liveJobId !== jobId.value) {
|
||
await attachLiveMusic({ studioId: row.id, liveId: row.liveJobId })
|
||
}
|
||
}
|
||
|
||
async function resumeActiveMusic() {
|
||
const active = await $fetch<{
|
||
music?: { jobId?: string; message?: string; progress?: number; status?: string }
|
||
} | null>('/api/generate/active').catch(() => null)
|
||
const liveId = active?.music?.jobId
|
||
if (!liveId) return
|
||
if (active?.music?.status === 'error' || active?.music?.status === 'complete') return
|
||
await attachLiveMusic({
|
||
liveId,
|
||
message: active?.music?.message,
|
||
progress: typeof active?.music?.progress === 'number' ? active.music.progress : undefined
|
||
})
|
||
}
|
||
|
||
function applyEvent(payload: Record<string, any>) {
|
||
if (payload.message) status.value = payload.message
|
||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||
if (payload.type === 'complete' || payload.status === 'complete') {
|
||
settled = true
|
||
busy.value = false
|
||
status.value = payload.message || 'Track ready'
|
||
progress.value = 100
|
||
if (payload.trackId) {
|
||
trackId.value = payload.trackId
|
||
audioUrl.value = `/api/library/tracks/${payload.trackId}/audio`
|
||
downloadName.value = `${(name.value || 'track').replace(/[^\w.-]+/g, '-')}.flac`
|
||
} else if (payload.filename) {
|
||
const params = new URLSearchParams({
|
||
filename: payload.filename,
|
||
subfolder: payload.subfolder || 'audio',
|
||
type: payload.mediaType || 'output'
|
||
})
|
||
audioUrl.value = `/api/media?${params.toString()}`
|
||
}
|
||
stopListen()
|
||
queued.value = false
|
||
studioJobId.value = ''
|
||
stopRecoverPoll()
|
||
stopQueuePoll()
|
||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
||
void notifyGenerationComplete('music')
|
||
}
|
||
if (payload.type === 'error' || payload.status === 'error') {
|
||
settled = true
|
||
busy.value = false
|
||
error.value = payload.error || payload.message || 'Generation failed'
|
||
status.value = error.value
|
||
queued.value = false
|
||
stopListen()
|
||
stopRecoverPoll()
|
||
stopQueuePoll()
|
||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
||
}
|
||
}
|
||
|
||
function listen(id: string) {
|
||
stopListen()
|
||
const gen = listenGen
|
||
settled = false
|
||
const stream = new EventSource(`/api/generate/${id}/stream`)
|
||
source = stream
|
||
stream.onmessage = (event) => {
|
||
if (gen !== listenGen) return
|
||
applyEvent(JSON.parse(event.data))
|
||
}
|
||
stream.onerror = () => {
|
||
if (gen !== listenGen) return
|
||
stream.close()
|
||
setTimeout(() => {
|
||
if (gen !== listenGen || settled) return
|
||
listen(id)
|
||
}, 2000)
|
||
}
|
||
if (!recoverPoll) {
|
||
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
|
||
}
|
||
}
|
||
|
||
async function forceClearQueue() {
|
||
if (forceClearing.value) return
|
||
if (!confirm(
|
||
'Force clear cancels every running and waiting job (including this track), wipes shot batches, and kills Comfy if wedged. Continue?'
|
||
)) return
|
||
forceClearing.value = true
|
||
error.value = ''
|
||
try {
|
||
await $fetch('/api/comfy/force-reset', { method: 'POST' })
|
||
settled = true
|
||
busy.value = false
|
||
queued.value = false
|
||
progress.value = 0
|
||
status.value = 'Queue force-cleared. Poke Comfy when you want the GPU again.'
|
||
studioJobId.value = ''
|
||
liveMusicId.value = ''
|
||
jobId.value = ''
|
||
if (source) {
|
||
source.close()
|
||
source = null
|
||
}
|
||
} catch (err: any) {
|
||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not force-clear the queue'
|
||
} finally {
|
||
forceClearing.value = false
|
||
}
|
||
}
|
||
|
||
async function generate() {
|
||
if (blocked.value) {
|
||
error.value = blockReason.value
|
||
return
|
||
}
|
||
error.value = ''
|
||
await ensureGenerationNotifyPermission()
|
||
busy.value = true
|
||
settled = false
|
||
queued.value = false
|
||
progress.value = 2
|
||
audioUrl.value = ''
|
||
trackId.value = ''
|
||
jobId.value = ''
|
||
studioJobId.value = ''
|
||
status.value = engineFamily.value === 'yue'
|
||
? 'Queueing YuE…'
|
||
: ace15.value
|
||
? 'Queueing ACE-Step 1.5…'
|
||
: 'Queueing ACE-Step…'
|
||
try {
|
||
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
|
||
'/api/generate/music',
|
||
{
|
||
method: 'POST',
|
||
body: {
|
||
tags: tags.value,
|
||
lyrics: lyrics.value,
|
||
duration: duration.value,
|
||
steps: steps.value,
|
||
seed: seed.value || 'random',
|
||
cfg: selectedEngine.value === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT,
|
||
instrumental: instrumental.value,
|
||
folderId: folderId.value,
|
||
name: name.value.trim(),
|
||
engine: selectedEngine.value
|
||
}
|
||
}
|
||
)
|
||
studioJobId.value = started.studioJobId || ''
|
||
if (started.name) downloadName.value = `${started.name}.flac`
|
||
await refreshStudioQueue()
|
||
stopQueuePoll()
|
||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 2500)
|
||
if (started.queued) {
|
||
queued.value = true
|
||
status.value = 'Waiting in the job queue…'
|
||
return
|
||
}
|
||
jobId.value = started.jobId
|
||
listen(started.jobId)
|
||
stopRecoverPoll()
|
||
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
|
||
window.setTimeout(() => { void tryRecover() }, 12000)
|
||
} catch (err: any) {
|
||
busy.value = false
|
||
queued.value = false
|
||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not queue music generation'
|
||
status.value = error.value
|
||
}
|
||
}
|
||
|
||
watch(instrumental, (on) => {
|
||
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
|
||
})
|
||
|
||
function applyIncomingMusic() {
|
||
try {
|
||
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||
if (genRaw) {
|
||
const loaded = JSON.parse(genRaw) as { kind?: string; payload?: Record<string, any> }
|
||
if (loaded?.kind === 'music' && loaded.payload) {
|
||
sessionStorage.removeItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||
const payload = loaded.payload
|
||
if (typeof payload.prompt === 'string') tags.value = payload.prompt
|
||
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
|
||
instrumental.value = payload.instrumental === true
|
||
if (typeof payload.duration === 'number') duration.value = payload.duration
|
||
if (typeof payload.steps === 'number') steps.value = payload.steps
|
||
if (payload.seed != null) seed.value = String(payload.seed)
|
||
if (typeof payload.name === 'string') name.value = payload.name
|
||
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
||
if (payload.musicEngine || payload.engine) applyEngine(parseMusicEngine(payload.musicEngine || payload.engine))
|
||
status.value = 'Loaded from generation log'
|
||
return
|
||
}
|
||
}
|
||
} catch { /* fall through */ }
|
||
|
||
try {
|
||
const raw = sessionStorage.getItem(LOAD_MUSIC_JOB_KEY)
|
||
if (!raw) return
|
||
sessionStorage.removeItem(LOAD_MUSIC_JOB_KEY)
|
||
const loaded = JSON.parse(raw) as {
|
||
name?: string
|
||
tags?: string
|
||
lyrics?: string
|
||
instrumental?: boolean
|
||
duration?: number
|
||
steps?: number
|
||
seed?: string
|
||
folderId?: string
|
||
engine?: string
|
||
}
|
||
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
||
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
||
instrumental.value = loaded.instrumental === true
|
||
if (typeof loaded.duration === 'number') duration.value = loaded.duration
|
||
if (typeof loaded.steps === 'number') steps.value = loaded.steps
|
||
if (typeof loaded.seed === 'string') seed.value = loaded.seed
|
||
if (typeof loaded.name === 'string') name.value = loaded.name
|
||
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
||
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
|
||
status.value = 'Loaded from the queue'
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadMusicPresets() {
|
||
const data = await $fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'music' } }).catch(() => ({ presets: [] }))
|
||
musicPresets.value = data.presets || []
|
||
}
|
||
|
||
function applyPreset() {
|
||
const preset = musicPresets.value.find(item => item.id === presetId.value)
|
||
if (!preset) return
|
||
const settings = preset.settings as MusicPresetSettings
|
||
if (typeof settings.tags === 'string') tags.value = settings.tags
|
||
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
|
||
instrumental.value = settings.instrumental === true
|
||
if (typeof settings.duration === 'number') duration.value = settings.duration
|
||
if (typeof settings.steps === 'number') steps.value = settings.steps
|
||
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
||
if (settings.engine) applyEngine(settings.engine)
|
||
name.value = preset.name
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadLibrary()
|
||
await loadMusicPresets()
|
||
const route = useRoute()
|
||
const engineQ = String(route.query.engine || '').toLowerCase()
|
||
if (engineQ === 'yue' || engineQ === 'ace') selectEngineFamily(engineQ)
|
||
applyIncomingMusic()
|
||
await resumeActiveMusic()
|
||
await refreshStudioQueue()
|
||
stopQueuePoll()
|
||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
||
const query = route.query
|
||
if (typeof query.track === 'string' && query.track) {
|
||
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
||
trackId.value = query.track
|
||
status.value = 'Loaded from library'
|
||
return
|
||
}
|
||
if (!audioUrl.value && !busy.value) {
|
||
try {
|
||
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', { method: 'POST', body: {} })
|
||
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
|
||
applyEvent({ ...recovered, type: 'complete' })
|
||
}
|
||
} catch { /* nothing waiting in ComfyUI */ }
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopListen()
|
||
stopRecoverPoll()
|
||
stopQueuePoll()
|
||
})
|
||
</script>
|