Replace Comfy YuE with standalone YuEGP profile 1

This commit is contained in:
Towsty
2026-09-07 20:24:47 -05:00
parent eeff12cdab
commit c2b6032e49
33 changed files with 976 additions and 294 deletions
+37 -2
View File
@@ -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.' })
}
})
+1 -25
View File
@@ -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)
+2 -1
View File
@@ -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() })
}
+1
View File
@@ -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')
-37
View File
@@ -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.')
+47
View File
@@ -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."
+137
View File
@@ -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)
}
}
}
+212
View File
@@ -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