Files
aigen/pages/music.vue
T

345 lines
12 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"
/>
<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>
</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>
</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>
</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'
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 downloadName = ref('track.flac')
let source: EventSource | null = null
let listenGen = 0
let settled = false
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 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()
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
stopListen()
}
}
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
progress.value = 2
audioUrl.value = ''
trackId.value = ''
status.value = 'Queueing ACE-Step…'
try {
const started = await $fetch<{ jobId: string; 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()
}
}
)
jobId.value = started.jobId
if (started.name) downloadName.value = `${started.name}.flac`
listen(started.jobId)
} catch (err: any) {
busy.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
})
onMounted(async () => {
await loadLibrary()
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()
})
</script>