Files
aigen/pages/music.vue
T

695 lines
27 KiB
Vue

<template>
<div class="min-h-dvh min-w-0">
<AppHeader
:subtitle="advanced ? 'YuE2 · lyrics-to-song' : 'Create music from text'"
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
:advanced="advanced"
current="music"
music-engine="yue2"
@select="goStudio"
/>
<div class="flex items-center justify-between gap-3"><span class="rounded-full border border-amber-300/60 px-4 py-2 text-sm text-amber-100">From text</span><label class="flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label></div>
<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. Multiple sections are OK.
</p>
</div>
<GenerationSettings :advanced="advanced">
<p class="text-xs text-zinc-400">YuE2-3B · lyrics with section tags · plan → synthesize → decode · no score editor</p>
<div v-if="!advanced" class="space-y-4">
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
</div>
</GenerationSettings>
<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 &amp; 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>
</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"
@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"
: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">Approximate length · multiple sections OK</span>
</label>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label v-if="advanced" 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…') : advanced ? 'Generate' : 'Queue' }}
</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" role="progressbar" aria-label="Music generation" :aria-valuenow="indeterminateMusic ? undefined : progress" :aria-valuetext="indeterminateMusic ? 'Completion percentage unavailable' : `${progress}%`">
<div class="progress-glow h-full rounded-full bg-amber-400 transition-[width]" :class="{ 'music-activity': indeterminateMusic && recentMusicCheck }" :style="{ width: indeterminateMusic ? '30%' : `${Math.max(progress, 4)}%` }" />
</div>
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
{{ musicElapsed }} elapsed · {{ musicActivityText }}
<span v-if="!indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
</p>
<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">
const { advanced } = useStudioLayout()
import {
DEFAULT_MUSIC_LYRICS,
DEFAULT_MUSIC_TAGS,
MUSIC_DURATION_DEFAULT,
MUSIC_DURATION_MAX,
MUSIC_DURATION_MIN,
MUSIC_STRUCTURE_CHIPS,
yueLyricsProblem,
} 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 tags = ref(DEFAULT_MUSIC_TAGS)
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
const duration = ref(MUSIC_DURATION_DEFAULT)
const seed = ref('')
const name = ref('')
const folderId = ref('')
const folders = ref<LibraryFolder[]>([])
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
const busy = ref(false)
const forceClearing = ref(false)
const error = ref('')
const status = ref('')
const progress = ref(0)
const musicClock = ref(Date.now())
const musicStartedAt = ref(0)
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
let musicClockTimer: ReturnType<typeof setInterval> | null = null
const indeterminateMusic = computed(() => !queued.value && progress.value < 98 && !musicActivity.value?.running)
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
const musicElapsed = computed(() => {
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
})
const musicActivityText = computed(() => {
if (!musicActivity.value) return 'Waiting for a generator activity check…'
const age = Math.max(0, Math.floor((musicClock.value - musicActivity.value.checkedAt) / 1000))
if (age >= 15) return `Activity check delayed · last response ${age}s ago`
return musicActivity.value.running ? `Generator confirms this job is running · checked ${age}s ago` : 'Generator responded · checking for the result…'
})
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(() => Boolean(blockReason.value))
const blockReason = computed(() => {
if (!folderId.value) return 'Choose a library folder before generating.'
if (duration.value > 150) return 'YuE2 supports up to 150 seconds.'
if (!tags.value.trim()) return 'Add genre and style tags.'
const problem = yueLyricsProblem(lyrics.value)
if (problem) return problem
return ''
})
function goStudio(kind: 'video' | 'image') {
void navigateTo({ path: '/', query: { studio: kind } })
}
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
const currentId = jobId.value
if (currentId) {
try {
const snapshot = await $fetch<Record<string, any>>(`/api/generate/${currentId}`, { timeout: 10000 })
if (jobId.value !== currentId || settled) return
applyEvent(snapshot)
return
} catch {
// Fall back to saved-output recovery when the live job is unavailable.
}
}
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST',
body: { jobId: jobId.value || undefined }
})
if (jobId.value === currentId && !settled && 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 || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
status.value = 'YuE2 is generating your song'
}
if (jobId.value !== liveId) {
musicActivity.value = null
musicStartedAt.value = Date.now()
jobId.value = liveId
listen(liveId)
void tryRecover()
}
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.id === studioJobId.value)
|| rows.find(job => job.kind === 'music' && job.status === 'running')
|| rows.find(job => job.kind === 'music' && (job.status === 'waiting' || 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'
: 'Waiting for the GPU to run YuE2'
}
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 = 'YuE2 is generating your song'
}
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'
: 'Waiting for the GPU to run YuE2'
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 (typeof payload.elapsedMs === 'number') musicStartedAt.value = Date.now() - payload.elapsedMs
if (payload.musicActivity) musicActivity.value = payload.musicActivity
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, '-')}.${payload.audioExt === 'wav' ? 'wav' : '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 = 'Queueing YuE2…'
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,
seed: seed.value || 'random',
folderId: folderId.value,
name: name.value.trim(),
engine: 'yue2'
}
}
)
studioJobId.value = started.studioJobId || ''
if (started.name) downloadName.value = `${started.name}.flac`
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 2500)
if (started.queued && !jobId.value) {
queued.value = true
status.value = 'Waiting in the job queue…'
return
}
if (!jobId.value) await attachLiveMusic({ studioId: started.studioJobId, liveId: 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
}
}
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
if (typeof payload.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, payload.duration)
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
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
duration?: number
seed?: string
folderId?: string
}
if (typeof loaded.tags === 'string') tags.value = loaded.tags
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
if (typeof loaded.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, loaded.duration)
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
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
if (typeof settings.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, settings.duration)
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
name.value = preset.name
}
onMounted(async () => {
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
await loadLibrary()
await loadMusicPresets()
applyIncomingMusic()
await resumeActiveMusic()
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
const route = useRoute()
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 */ }
}
})
onBeforeUnmount(() => {
if (musicClockTimer) clearInterval(musicClockTimer)
stopListen()
stopRecoverPoll()
stopQueuePoll()
})
</script>
<style scoped>
.music-activity { animation: music-travel 2s ease-in-out infinite alternate; }
@keyframes music-travel { from { transform: translateX(0); } to { transform: translateX(233%); } }
@media (prefers-reduced-motion: reduce) { .music-activity { animation: none; } }
</style>