Files
aigen/pages/music.vue
T

502 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
subtitle="ACE-Step · vocals"
current="music"
:queue-count="queueCount"
/>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-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>
<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>
<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">{{ durationMin }}–{{ durationMax }} seconds</span>
</label>
<label 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">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>
<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>
</main>
<QueuedJobModal :job-id="inspectJobId" @close="inspectJobId = ''" />
</div>
</template>
<script setup lang="ts">
import {
DEFAULT_MUSIC_LYRICS,
DEFAULT_MUSIC_TAGS,
MUSIC_DURATION_DEFAULT,
MUSIC_DURATION_MAX,
MUSIC_DURATION_MIN,
MUSIC_STEPS_DEFAULT,
MUSIC_STEPS_MAX,
MUSIC_STEPS_MIN,
MUSIC_STRUCTURE_CHIPS
} from '~/utils/music'
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
import type { LibraryFolder } from '~/utils/libraryBrowse'
import { 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 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 busy = 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
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
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 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
}
}
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 (!studioJobId.value) return
const row = rows.find(job => job.id === studioJobId.value)
if (!row) {
if (queued.value && !jobId.value) {
queued.value = false
}
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 in the job queue…'
return
}
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) {
queued.value = false
jobId.value = row.liveJobId
status.value = status.value || 'Generating…'
listen(row.liveJobId)
}
}
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 = ''
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()
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)
}
}
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 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',
instrumental: instrumental.value,
folderId: folderId.value,
name: name.value.trim()
}
}
)
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)
} 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 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
}
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
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)
name.value = preset.name
}
onMounted(async () => {
await loadLibrary()
await loadMusicPresets()
applyIncomingMusic()
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
const query = useRoute().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'
}
})
onBeforeUnmount(() => {
stopListen()
stopQueuePoll()
})
</script>