Replace Comfy YuE with standalone YuEGP profile 1
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||
import { createYueGpHost } from './yuegp-host.mjs'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
@@ -143,7 +144,7 @@ let proxyTarget = 0
|
||||
|
||||
function ensureProxyListening() {
|
||||
if (proxyServer) return
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork })
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yueGp.busy() })
|
||||
proxyServer.on('error', (error) => {
|
||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||
})
|
||||
@@ -812,6 +813,7 @@ function purgeDesktopFiles(body) {
|
||||
}
|
||||
|
||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
if (yueGp.busy()) return false
|
||||
if ((await trainingLock()).busy) return false
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
@@ -822,9 +824,39 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||
} })
|
||||
|
||||
const yueGp = createYueGpHost({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => {
|
||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
const queue = await fetchLocalQueue(healthy)
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuEGP cannot start.')
|
||||
await stopComfyProcesses()
|
||||
markAsleep()
|
||||
}
|
||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleControl(req, res) {
|
||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||
const url = new URL(req.url || '/', 'http://localhost')
|
||||
if (url.pathname.startsWith('/yuegp/')) {
|
||||
const match = url.pathname.match(/^\/yuegp\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
||||
if (req.method === 'GET' && url.pathname === '/yuegp/status') return json(res, 200, { configured: yueGp.configured(), busy: yueGp.busy(), backend: 'yuegp' })
|
||||
if (req.method === 'POST' && url.pathname === '/yuegp/jobs') return json(res, 200, await yueGp.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yueGp.cancel(match[1]))
|
||||
if (match && req.method === 'GET' && match[2] === '/audio') {
|
||||
const path = yueGp.audio(match[1])
|
||||
return path ? streamFile(res, path) : json(res, 404, { error: 'Audio not ready' })
|
||||
}
|
||||
if (match && req.method === 'GET' && !match[2]) {
|
||||
const job = yueGp.read(match[1])
|
||||
return json(res, job ? 200 : 404, job || { error: 'YuEGP job not found' })
|
||||
}
|
||||
return json(res, 404, { error: 'Unknown YuEGP endpoint' })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
|
||||
const body = await readJson(req)
|
||||
let result
|
||||
@@ -855,10 +887,12 @@ async function handleControl(req, res) {
|
||||
port: healthyPort || null,
|
||||
proxyPort,
|
||||
gpu: gpuReservation.availability(),
|
||||
training: { busy: lastTraining.busy }
|
||||
training: { busy: lastTraining.busy },
|
||||
yuegp: { busy: yueGp.busy(), configured: yueGp.configured() }
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/start') {
|
||||
if (yueGp.busy()) return json(res, 409, { message: 'YuEGP is using the GPU.' })
|
||||
const training = await trainingLock()
|
||||
if (training.busy) {
|
||||
return json(res, 409, {
|
||||
@@ -954,6 +988,7 @@ const server = http.createServer(async (req, res) => {
|
||||
} else await handleControl(req, res)
|
||||
} catch (error) {
|
||||
req.resume()
|
||||
if (String(req.url || '').startsWith('/yuegp/') && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'YuEGP request failed' })
|
||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -177,7 +177,6 @@ $vae = Join-Path $root 'models\vae'
|
||||
$ttsAce = Join-Path $root 'models\TTS\ACE-Step-v1-3.5B'
|
||||
$customAce = Join-Path $root 'custom_nodes\ComfyUI-ACE-Step\models'
|
||||
$yueRoot = Join-Path $root 'models\YuE'
|
||||
$yueCustom = Join-Path $root 'custom_nodes\ComfyUI-YuE\models'
|
||||
|
||||
Write-Step "ComfyUI root"
|
||||
Write-Host " $root"
|
||||
@@ -283,30 +282,7 @@ if (-not $SkipAceStep15) {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipYue) {
|
||||
$s1 = Join-Path $yueRoot 'YuE-s1-7B-anneal-en-cot'
|
||||
$s2 = Join-Path $yueRoot 'YuE-s2-1B-general'
|
||||
$codec = Join-Path $yueRoot 'xcodec_mini_infer'
|
||||
|
||||
Pull-IfMissing -Label 'YuE stage-1 (s1 7B anneal EN CoT)' -Target $s1 -MinBytes 1GB -Download {
|
||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s1-7B-anneal-en-cot' -LocalDir $s1 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
|
||||
}
|
||||
Pull-IfMissing -Label 'YuE stage-2 (s2 1B general)' -Target $s2 -MinBytes 200MB -Download {
|
||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s2-1B-general' -LocalDir $s2 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
|
||||
}
|
||||
Pull-IfMissing -Label 'YuE vocoder (xcodec_mini_infer)' -Target $codec -MinBytes 20MB -Download {
|
||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/xcodec_mini_infer' -LocalDir $codec -Include @('*.safetensors', '*.pth', '*.pt', '*.json', '*.yaml', '*.txt')
|
||||
}
|
||||
|
||||
if (Test-Path (Split-Path $yueCustom -Parent)) {
|
||||
Pull-IfMissing -Label 'YuE custom-node models copy' -Target $yueCustom -MinBytes 1GB -Download {
|
||||
Ensure-Dir $yueCustom
|
||||
Copy-Item -Recurse -Force (Join-Path $yueRoot '*') $yueCustom
|
||||
}
|
||||
} else {
|
||||
Write-Skip "ComfyUI-YuE custom node not installed — YuE weights stay in models\YuE"
|
||||
}
|
||||
}
|
||||
if (-not $SkipYue) { Write-Host 'YuE now uses standalone YuEGP. Run scripts/setup-yuegp.ps1.' }
|
||||
|
||||
$after = Get-DirBytes (Join-Path $root 'models')
|
||||
$delta = [Math]::Max(0, $after - $before)
|
||||
|
||||
@@ -9,7 +9,7 @@ function reply(res, status, body) {
|
||||
}
|
||||
|
||||
/** Streams Comfy HTTP/WebSocket traffic; all mutations require the current reservation. */
|
||||
export function createGpuProxy({ target, reservation, authorized = () => true, markWork = () => {} }) {
|
||||
export function createGpuProxy({ target, reservation, authorized = () => true, markWork = () => {}, externalBusy = () => false }) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const path = new URL(req.url || '/', 'http://localhost').pathname.replace(/\/+$/, '') || '/'
|
||||
// A global history list can expose another studio's prompts and outputs.
|
||||
@@ -55,6 +55,7 @@ export function createGpuProxy({ target, reservation, authorized = () => true, m
|
||||
try {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') await forward()
|
||||
else {
|
||||
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'YuEGP is using the GPU.' }, code: 'GPU_BUSY' }) }
|
||||
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
|
||||
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createGpuReservation({ idle, now = Date.now, ttlMs = 60_000, tic
|
||||
for (const [ticket, seen] of waiting) if (now() - seen >= ticketTtlMs) waiting.delete(ticket)
|
||||
}
|
||||
return {
|
||||
isOwner: valid,
|
||||
acquire(ticket) {
|
||||
return serialize(async () => {
|
||||
if (typeof ticket !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(ticket)) throw new Error('Invalid reservation ticket')
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Make YuE's HF token samplers honor Comfy's cancel flag. Run with the node directory."""
|
||||
import ast
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
CHECK = ' from comfy.model_management import throw_exception_if_processing_interrupted\n throw_exception_if_processing_interrupted()\n'
|
||||
|
||||
def patch(source):
|
||||
tree = ast.parse(source)
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'BlockTokenRangeProcessor')
|
||||
method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == '__call__')
|
||||
if 'throw_exception_if_processing_interrupted' in ast.get_source_segment(source, method):
|
||||
return source
|
||||
lines = source.splitlines(keepends=True)
|
||||
lines.insert(method.body[0].lineno - 1, CHECK)
|
||||
result = ''.join(lines)
|
||||
ast.parse(result)
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
root = pathlib.Path(sys.argv[1]).resolve(strict=True)
|
||||
plans = []
|
||||
for name in ('infer.py', 'common.py'):
|
||||
path = root / 'inference' / name
|
||||
source = path.read_text(encoding='utf-8')
|
||||
plans.append((path, source, patch(source)))
|
||||
for path, source, result in plans:
|
||||
if source == result:
|
||||
print(f'Already patched: {path.name}')
|
||||
continue
|
||||
backup = path.with_suffix('.py.before-aigen-cancel')
|
||||
if backup.exists():
|
||||
raise RuntimeError(f'Backup already exists: {backup}')
|
||||
shutil.copy2(path, backup)
|
||||
path.write_text(result, encoding='utf-8')
|
||||
print(f'Patched {path.name}; original backed up.')
|
||||
@@ -0,0 +1,47 @@
|
||||
# Standalone YuEGP only. Never install into Comfy's Python environment.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Root = (Join-Path (Split-Path $PSScriptRoot -Parent) '..\YuEGP'),
|
||||
[string]$Uv = 'uv',
|
||||
[string]$Models = (Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\yue')
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = [IO.Path]::GetFullPath($Root)
|
||||
$revision = '2d72ff734b7a127324353c0dcd0f95ca4cc0b797'
|
||||
function Run([string]$Exe, [string[]]$Arguments) {
|
||||
& $Exe @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Exe failed ($LASTEXITCODE)" }
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $Root)) {
|
||||
Run git @('clone', 'https://github.com/deepbeepmeep/YuEGP.git', $Root)
|
||||
Run git @('-C', $Root, 'checkout', '--detach', $revision)
|
||||
}
|
||||
$head = & git -C $Root rev-parse HEAD
|
||||
if ($head.Trim() -ne $revision) { throw "YuEGP must be checked out at $revision. No existing checkout was changed." }
|
||||
Run $Uv @('venv', '--allow-existing', '--python', '3.10', (Join-Path $Root '.venv'))
|
||||
$python = Join-Path $Root '.venv\Scripts\python.exe'
|
||||
Run $Uv @('pip', 'install', '--python', $python, 'torch==2.7.1', 'torchaudio==2.7.1', '--index-url', 'https://download.pytorch.org/whl/cu128')
|
||||
Run $Uv @('pip', 'install', '--python', $python, 'torch==2.7.1', 'torchaudio==2.7.1', 'numpy<2', 'scipy==1.10.1', 'transformers==4.48.1', 'mmgp==3.1.4-post15', 'peft==0.14.0', 'omegaconf', 'einops', 'sentencepiece', 'tqdm', 'tensorboard', 'descript-audiotools==0.7.2', 'descript-audio-codec', 'soundfile', 'psutil')
|
||||
# Apply YuEGP's own upstream optimization files to its own environment only.
|
||||
Copy-Item -LiteralPath (Join-Path $Root 'transformers\generation\utils.py') -Destination (Join-Path $Root '.venv\Lib\site-packages\transformers\generation\utils.py')
|
||||
Copy-Item -LiteralPath (Join-Path $Root 'transformers\models\llama\modeling_llama.py') -Destination (Join-Path $Root '.venv\Lib\site-packages\transformers\models\llama\modeling_llama.py')
|
||||
$codec = Join-Path $Root 'inference\xcodec_mini_infer'
|
||||
if (!(Test-Path -LiteralPath $codec)) {
|
||||
$previousSmudge = $env:GIT_LFS_SKIP_SMUDGE
|
||||
try { $env:GIT_LFS_SKIP_SMUDGE = '1'; Run git @('clone', '--depth', '1', 'https://huggingface.co/m-a-p/xcodec_mini_infer', $codec) }
|
||||
finally { $env:GIT_LFS_SKIP_SMUDGE = $previousSmudge }
|
||||
}
|
||||
# Reuse local codec weights. Download missing assets explicitly from their HF repo.
|
||||
$weights = @('final_ckpt\ckpt_00360000.pth', 'decoders\decoder_131000.pth', 'decoders\decoder_151000.pth', 'semantic_ckpts\hf_1_325000\pytorch_model.bin')
|
||||
foreach ($weight in $weights) {
|
||||
$target = Join-Path $codec $weight
|
||||
if ((Test-Path -LiteralPath $target) -and (Get-Item -LiteralPath $target).Length -gt 1024) { continue }
|
||||
$source = Join-Path (Join-Path $Models 'xcodec_mini_infer') $weight
|
||||
if ((Test-Path -LiteralPath $source) -and (Get-Item -LiteralPath $source).Length -gt 1024) { Copy-Item -LiteralPath $source -Destination $target }
|
||||
else {
|
||||
Run $python @('-c', 'from huggingface_hub import hf_hub_download; import sys; hf_hub_download("m-a-p/xcodec_mini_infer",sys.argv[1],local_dir=sys.argv[2])', $weight.Replace('\','/'), $codec)
|
||||
}
|
||||
}
|
||||
Run $python @('-c', 'import torch,transformers; from mmgp import offload; assert torch.cuda.is_available(); print(torch.__version__,transformers.__version__,torch.cuda.get_device_name(0))')
|
||||
@{ revision=$revision; modelsDir=$Models; profile=1; compile=$false } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Root 'aigen-ready.json') -Encoding utf8
|
||||
Write-Host "YuEGP ready at $Root. Set YUEGP_ROOT on the GPU host if using a different location. No Comfy environment was modified."
|
||||
@@ -0,0 +1,137 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function validateYueGpRequest(body) {
|
||||
const profile = body.profile ?? 1
|
||||
const duration = body.duration ?? 60
|
||||
if (![1, 3].includes(profile)) throw new Error('YuEGP profile must be 1 or manual fallback 3.')
|
||||
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw new Error('YuEGP duration must be 30–150 seconds.')
|
||||
if (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > 2147483647) throw new Error('Invalid seed.')
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body.id || '')) throw new Error('Invalid job ID.')
|
||||
const tags = String(body.tags || '').trim()
|
||||
const lyrics = String(body.lyrics || '').trim()
|
||||
if (!tags || tags.length > 2000 || !lyrics || lyrics.length > 8000) throw new Error('Genre tags and one lyric section are required.')
|
||||
const sections = [...lyrics.matchAll(/\[([^\]]+)\]\s*([^\[]*)/gs)].filter(m => m[2].trim())
|
||||
if (sections.length !== 1) throw new Error('YuEGP requires one non-empty lyric section.')
|
||||
return { id: body.id, profile, duration, seed: body.seed, tags, lyrics }
|
||||
}
|
||||
|
||||
/** One isolated Python process per song. A process exit is the GPU release boundary. */
|
||||
export function createYueGpHost({ prepare, leaseValid, spawnProcess = spawn, root, python, dataDir } = {}) {
|
||||
const repo = resolve(root || process.env.YUEGP_ROOT || join(fileURLToPath(new URL('../..', import.meta.url)), 'YuEGP'))
|
||||
const executable = python || process.env.YUEGP_PYTHON || join(repo, '.venv', 'Scripts', 'python.exe')
|
||||
const data = resolve(dataDir || process.env.YUEGP_JOBS_DIR || join(repo, 'aigen-jobs'))
|
||||
const worker = fileURLToPath(new URL('./yuegp-worker.py', import.meta.url))
|
||||
let active = null
|
||||
// A worker whose parent died exits within two seconds. Block new GPU owners
|
||||
// across a host restart until that watchdog has had time to run.
|
||||
const restartHoldUntil = existsSync(data) && readdirSync(data).some(id => {
|
||||
try { return ['running', 'starting', 'cancelling'].includes(JSON.parse(readFileSync(join(data, id, 'status.json'), 'utf8')).status) }
|
||||
catch { return false }
|
||||
}) ? Date.now() + 10000 : 0
|
||||
const dir = id => {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid job ID.')
|
||||
return join(data, id)
|
||||
}
|
||||
const persist = job => {
|
||||
const target = join(dir(job.id), 'status.json')
|
||||
writeFileSync(target + '.tmp', JSON.stringify(job))
|
||||
renameSync(target + '.tmp', target)
|
||||
}
|
||||
const read = id => {
|
||||
if (active?.job.id === id) return { ...active.job }
|
||||
const path = join(dir(id), 'status.json')
|
||||
if (!existsSync(path)) return null
|
||||
const job = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (['running', 'starting', 'cancelling'].includes(job.status)) {
|
||||
job.status = 'error'; job.error = 'YuEGP host restarted. Intermediate files are preserved.'
|
||||
}
|
||||
return job
|
||||
}
|
||||
return {
|
||||
busy: () => Boolean(active) || Date.now() < restartHoldUntil,
|
||||
configured: () => existsSync(executable) && existsSync(join(repo, 'aigen-ready.json')),
|
||||
read,
|
||||
audio(id) {
|
||||
return read(id)?.status === 'complete' ? join(dir(id), 'audio.wav') : null
|
||||
},
|
||||
async start(body, lease) {
|
||||
const request = validateYueGpRequest(body)
|
||||
const previous = read(request.id)
|
||||
if (previous) return previous // POST retries cannot generate the same song twice.
|
||||
if (active || Date.now() < restartHoldUntil) throw Object.assign(new Error('YuEGP is already running or releasing a previous worker.'), { statusCode: 409 })
|
||||
if (!existsSync(executable) || !existsSync(join(repo, 'aigen-ready.json'))) throw new Error('YuEGP is not installed. Run scripts/setup-yuegp.ps1 on the GPU host.')
|
||||
const job = { id: request.id, status: 'starting', message: 'Preparing GPU for YuEGP', profile: request.profile, progress: 0, startedAt: Date.now(), checkedAt: Date.now() }
|
||||
active = { job, child: null, cancelled: false }
|
||||
const run = active
|
||||
mkdirSync(dir(job.id), { recursive: true })
|
||||
persist(job)
|
||||
try {
|
||||
await prepare()
|
||||
if (run.cancelled || !leaseValid(lease)) throw new Error('YuEGP start cancelled or GPU reservation expired.')
|
||||
const installation = JSON.parse(readFileSync(join(repo, 'aigen-ready.json'), 'utf8').replace(/^\uFEFF/, ''))
|
||||
const localModels = process.env.YUEGP_MODELS_DIR || installation.modelsDir
|
||||
const model = (key, name) => process.env[key] || (localModels && existsSync(join(localModels, name)) ? join(localModels, name) : `m-a-p/${name}`)
|
||||
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request,
|
||||
stage1Model: model('YUEGP_STAGE1_MODEL', 'YuE-s1-7B-anneal-en-cot'),
|
||||
stage2Model: model('YUEGP_STAGE2_MODEL', 'YuE-s2-1B-general') }))
|
||||
const child = spawnProcess(executable, ['-u', worker, '--root', repo, '--request', join(dir(job.id), 'request.json'), '--profile', String(request.profile)],
|
||||
{ cwd: join(repo, 'inference'), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONUTF8: '1', TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD: '1' } })
|
||||
run.child = child
|
||||
job.status = 'running'; job.message = 'Loading YuEGP'; persist(job)
|
||||
let tail = ''
|
||||
let lines = ''
|
||||
const log = chunk => { appendFileSync(join(dir(job.id), 'worker.log'), chunk); tail = (tail + chunk.toString()).slice(-6000) }
|
||||
child.stderr.on('data', log)
|
||||
child.stdout.on('data', chunk => {
|
||||
log(chunk); lines += chunk.toString()
|
||||
const parts = lines.split(/\r?\n/); lines = parts.pop().slice(-64000)
|
||||
for (const line of parts) {
|
||||
if (!line.startsWith('AIGEN_EVENT ')) continue
|
||||
try {
|
||||
const event = JSON.parse(line.slice(12))
|
||||
for (const key of ['stage', 'message', 'progress', 'step', 'maxStep', 'error', 'duration']) if (event[key] !== undefined) job[key] = event[key]
|
||||
job.checkedAt = Date.now(); persist(job)
|
||||
} catch { /* Invalid log lines are not state transitions. */ }
|
||||
}
|
||||
})
|
||||
// The worker also exits if this host dies, using its parent PID watchdog.
|
||||
const watchdog = setInterval(() => {
|
||||
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; YuEGP stopped.'; child.kill() }
|
||||
}, 5000)
|
||||
let finished = false
|
||||
const finish = (code, error) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(watchdog)
|
||||
job.status = run.cancelled ? 'cancelled' : !error && code === 0 && existsSync(join(dir(job.id), 'audio.wav')) ? 'complete' : 'error'
|
||||
if (job.status === 'error') job.error ||= error?.message || tail || `YuEGP exited with code ${code}`
|
||||
job.message = job.status === 'complete' ? 'Audio ready' : job.status === 'cancelled' ? 'Cancelled' : job.error
|
||||
job.checkedAt = Date.now(); persist(job)
|
||||
if (active === run) active = null
|
||||
}
|
||||
child.once('error', error => finish(null, error))
|
||||
child.once('close', code => finish(code))
|
||||
return { ...job }
|
||||
} catch (error) {
|
||||
job.status = run.cancelled ? 'cancelled' : 'error'; job.error = error.message; persist(job)
|
||||
if (active === run) active = null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async cancel(id) {
|
||||
if (active?.job.id !== id) return read(id)
|
||||
const run = active
|
||||
run.cancelled = true
|
||||
run.job.status = 'cancelling'
|
||||
persist(run.job)
|
||||
if (run.child) {
|
||||
const child = run.child
|
||||
await new Promise(resolve => { child.once('close', resolve); child.kill() })
|
||||
}
|
||||
return read(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Headless adapter for pinned deepbeepmeep/YuEGP inference functions.
|
||||
|
||||
No Comfy imports, no Gradio import/server, and no automatic profile fallback.
|
||||
Stdout AIGEN_EVENT lines are consumed by the device-local host agent.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from types import SimpleNamespace, FunctionType
|
||||
|
||||
YUEGP_REVISION = '2d72ff734b7a127324353c0dcd0f95ca4cc0b797'
|
||||
|
||||
|
||||
def emit(**event):
|
||||
print('AIGEN_EVENT ' + json.dumps(event), flush=True)
|
||||
|
||||
|
||||
def normalize_lyrics(text):
|
||||
# Accept our existing chips (Verse 1, Pre-Chorus); upstream only accepts \w+.
|
||||
sections = re.findall(r'\[([^\]]+)\]\s*([^\[]*)', text.strip(), re.S)
|
||||
sections = [(name, words.strip()) for name, words in sections if words.strip()]
|
||||
if len(sections) != 1:
|
||||
raise ValueError('YuEGP requires one non-empty lyric section. Combine the lyrics under one heading.')
|
||||
name, words = sections[0]
|
||||
return '[' + re.sub(r'\W+', '', name).lower() + ']\n' + words + '\n\n'
|
||||
|
||||
|
||||
def load_functions(root):
|
||||
source = root / 'inference' / 'gradio_server.py'
|
||||
text = source.read_text(encoding='utf-8')
|
||||
if hashlib.sha256(text.encode()).hexdigest() != '567b91b3100c4fb2c95496e83b135e9d55fd03714e83912f9586037805fdecc9':
|
||||
raise RuntimeError('YuEGP inference source differs from the validated revision.')
|
||||
tree = ast.parse(text)
|
||||
names = {'BlockTokenRangeProcessor', 'load_audio_mono', 'encode_audio',
|
||||
'stage1_inference', 'stage2_generate', 'stage2_inference'}
|
||||
# Load the library functions only. Never execute upstream CLI/UI startup.
|
||||
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||
if {n.name for n in nodes} != names:
|
||||
raise RuntimeError('Unsupported YuEGP source: reinstall the pinned revision.')
|
||||
ns = {'__file__': str(source)}
|
||||
imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))
|
||||
and not (isinstance(n, ast.Import) and any(a.name == 'gradio' for a in n.names))]
|
||||
exec(compile(ast.Module(body=imports + nodes, type_ignores=[]), str(source), 'exec'), ns)
|
||||
return ns
|
||||
|
||||
|
||||
def load_codec_on_cpu(ns, torch):
|
||||
with torch.device('cpu'):
|
||||
config = ns['OmegaConf'].load('xcodec_mini_infer/final_ckpt/config.yaml')
|
||||
codec = ns['SoundStream'](**config.generator.config)
|
||||
codec.load_state_dict(torch.load('xcodec_mini_infer/final_ckpt/ckpt_00360000.pth', map_location='cpu', weights_only=False)['codec_model'])
|
||||
codec.eval()
|
||||
return codec
|
||||
|
||||
|
||||
def main():
|
||||
import psutil
|
||||
parent = psutil.Process(os.getppid())
|
||||
def parent_watchdog():
|
||||
while parent.is_running():
|
||||
time.sleep(2)
|
||||
os._exit(2) # The host died: never leave an orphan consuming the GPU.
|
||||
threading.Thread(target=parent_watchdog, daemon=True).start()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', required=True)
|
||||
parser.add_argument('--request', required=True)
|
||||
parser.add_argument('--profile', type=int, choices=[1, 3], default=1)
|
||||
parser.add_argument('--compile', action='store_true')
|
||||
cli = parser.parse_args()
|
||||
request = json.loads(Path(cli.request).read_text(encoding='utf-8-sig'))
|
||||
root = Path(cli.root).resolve()
|
||||
output = Path(cli.request).resolve().parent
|
||||
duration = int(request.get('duration', 60))
|
||||
if not 30 <= duration <= 150:
|
||||
raise ValueError('Duration must be 30–150 seconds.')
|
||||
lyrics = normalize_lyrics(request['lyrics'])
|
||||
tags = ' '.join(str(request['tags']).split())
|
||||
seed = int(request['seed'])
|
||||
max_tokens = duration * 100
|
||||
if max_tokens >= 16000:
|
||||
raise ValueError('YuEGP supports at most 150 seconds per section.')
|
||||
compile_enabled = False
|
||||
if cli.compile:
|
||||
import triton # Explicit opt-in AND an actual successful import required.
|
||||
compile_enabled = True
|
||||
os.chdir(root / 'inference')
|
||||
sys.path[:0] = [str(root / 'inference'), str(root / 'inference/xcodec_mini_infer'),
|
||||
str(root / 'inference/xcodec_mini_infer/descriptaudiocodec')]
|
||||
emit(stage='loading', progress=1, message=f'Loading YuEGP profile {cli.profile}', profile=cli.profile,
|
||||
compile=compile_enabled, sections=1, maxNewTokens=max_tokens)
|
||||
ns = load_functions(root)
|
||||
torch, np, sf = ns['torch'], ns['np'], ns['sf']
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError('YuEGP requires a CUDA GPU; CPU fallback is disabled.')
|
||||
# The upstream transformer patch is part of YuEGP, in its own environment.
|
||||
import transformers.generation.utils as generation
|
||||
if 'callback' not in Path(generation.__file__).read_text(encoding='utf-8'):
|
||||
raise RuntimeError('YuEGP transformer functions are missing. Run setup-yuegp.ps1.')
|
||||
attention = 'sdpa'
|
||||
try:
|
||||
import flash_attn
|
||||
attention = 'flash_attention_2'
|
||||
except ImportError:
|
||||
pass
|
||||
ns['random'].seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
device = torch.device('cuda:0')
|
||||
stage1 = request.get('stage1Model') or 'm-a-p/YuE-s1-7B-anneal-en-cot'
|
||||
stage2 = request.get('stage2Model') or 'm-a-p/YuE-s2-1B-general'
|
||||
model = ns['AutoModelForCausalLM'].from_pretrained(stage1, torch_dtype=torch.bfloat16, attn_implementation=attention).eval()
|
||||
model2 = ns['AutoModelForCausalLM'].from_pretrained(stage2, torch_dtype=torch.float16, attn_implementation=attention).eval()
|
||||
if not compile_enabled:
|
||||
# Transformers 4.48 selects DynamicCache with None; the literal
|
||||
# string "dynamic" is not an accepted configuration value.
|
||||
model.generation_config.cache_implementation = None
|
||||
model2.generation_config.cache_implementation = None
|
||||
model._validate_model_kwargs = lambda _: None
|
||||
model2._validate_model_kwargs = lambda _: None
|
||||
offloader = ns['offload'].profile({'transformer': model, 'stage2': model2}, profile_no=cli.profile,
|
||||
quantizeTransformer=cli.profile == 3, compile=compile_enabled, verboseLevel=1)
|
||||
args = SimpleNamespace(use_audio_prompt=False, use_dual_tracks_prompt=False, rescale=True,
|
||||
output_dir=str(output), cuda_idx=0)
|
||||
ns.update(model=model, model_stage2=model2, device=device, codec_model=None, args=args,
|
||||
mmtokenizer=ns['_MMSentencePieceTokenizer']('./mm_tokenizer_v0.2_hf/tokenizer.model'),
|
||||
codectool=ns['CodecManipulator']('xcodec', 0, 1), codectool_stage2=ns['CodecManipulator']('xcodec', 0, 8),
|
||||
stage1_output_dir=str(output / 'stage1'),
|
||||
split_lyrics=lambda _: [lyrics], get_song_id=lambda *a: 'song')
|
||||
(output / 'stage1').mkdir(exist_ok=True)
|
||||
(output / 'stage2').mkdir(exist_ok=True)
|
||||
state = {}
|
||||
last = [0.0]
|
||||
def callback(done, total):
|
||||
now = time.monotonic()
|
||||
if now - last[0] < 1 and done < total:
|
||||
return
|
||||
last[0] = now
|
||||
stage = state.get('stage', 'Generating')
|
||||
# Percent is explicitly local to the reported stage, never a song ETA.
|
||||
emit(stage=stage, message=stage, step=int(done), maxStep=int(total),
|
||||
progress=round(100 * done / max(1, total), 1))
|
||||
emit(stage='stage1', message='Generating song tokens', progress=0)
|
||||
stems = ns['stage1_inference'](tags, lyrics, 1, max_tokens, seed, state, callback)
|
||||
emit(stage='stage2', message='Generating audio detail', progress=0)
|
||||
results = ns['stage2_inference'](model2, stems, str(output / 'stage2'),
|
||||
batch_size=20 if cli.profile == 1 else 4, state=state, callback=callback)
|
||||
# Release transformer allocations before codec/vocoder decode.
|
||||
offloader.unload_all()
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
# MMGP sets the default device to CUDA. Explicit CPU construction prevents
|
||||
# decoder initialization from competing with the language models for VRAM.
|
||||
emit(stage='decoding', message='Loading audio decoder', progress=0)
|
||||
codec = load_codec_on_cpu(ns, torch)
|
||||
codec.to(device)
|
||||
emit(stage='decoding', message='Decoding and mixing audio', progress=0)
|
||||
low_tracks = []
|
||||
for path in results:
|
||||
codes = np.load(path)
|
||||
with torch.no_grad():
|
||||
wave = codec.decode(torch.as_tensor(codes.astype(np.int16), dtype=torch.long).unsqueeze(0).permute(1, 0, 2).to(device))
|
||||
low_tracks.append(wave.cpu().squeeze().numpy())
|
||||
sf.write(str(output / 'mix16.wav'), low_tracks[0] + low_tracks[1], 16000)
|
||||
with torch.device('cpu'):
|
||||
vocal_decoder, inst_decoder = ns['build_codec_model']('xcodec_mini_infer/decoders/config.yaml',
|
||||
'xcodec_mini_infer/decoders/decoder_131000.pth', 'xcodec_mini_infer/decoders/decoder_151000.pth')
|
||||
# Keep upstream neural decoding, but write WAV directly: no MP3/FFmpeg
|
||||
# backend dependency in the isolated Windows environment.
|
||||
process_audio = ns['process_audio']
|
||||
audio_globals = dict(process_audio.__globals__)
|
||||
def save_wave(wave, path, sample_rate, rescale=False):
|
||||
data = wave.detach().cpu().numpy()
|
||||
peak = float(np.max(np.abs(data)))
|
||||
if rescale and peak > 0.99:
|
||||
data = data * (0.99 / peak)
|
||||
sf.write(str(path), data.T, sample_rate, subtype='PCM_16')
|
||||
audio_globals['save_audio'] = save_wave
|
||||
decode_audio = FunctionType(process_audio.__code__, audio_globals)
|
||||
tracks = []
|
||||
for path in results:
|
||||
instrumental = '_itrack' in path
|
||||
decoder = inst_decoder if instrumental else vocal_decoder
|
||||
with torch.no_grad():
|
||||
tracks.append(decode_audio(path, str(output / ('instrumental.wav' if instrumental else 'vocal.wav')),
|
||||
True, args, decoder, codec))
|
||||
decoder.to('cpu')
|
||||
torch.cuda.empty_cache()
|
||||
mixed = (tracks[0] + tracks[1]).detach().cpu().squeeze().numpy()
|
||||
sf.write(str(output / 'mix44.wav'), mixed, 44100)
|
||||
ns['replace_low_freq_with_energy_matched'](a_file=str(output / 'mix16.wav'), b_file=str(output / 'mix44.wav'),
|
||||
c_file=str(output / 'audio.wav'), cutoff_freq=5500.0)
|
||||
info = sf.info(str(output / 'audio.wav'))
|
||||
if info.frames <= 0:
|
||||
raise RuntimeError('YuEGP produced empty audio.')
|
||||
emit(stage='complete', message='Audio ready', progress=100, duration=info.duration)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
emit(stage='error', message=str(error), error=str(error))
|
||||
raise
|
||||
Reference in New Issue
Block a user