Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
@@ -0,0 +1,197 @@
// generation/adapterSections.ts — Per-section adapter masking (regional LoRA)
//
// Parses inline per-section adapter-influence directives from the lyrics, e.g.
//
// [Intro]{greenday_idiot=1; blink_selftitled=0}
// [Verse 1]{greenday_idiot=0.5; blink_selftitled=0.5}
// ...lines...
// [Chorus]{#1=0; #2=1} (positional #N / bare N also accepted)
//
// and turns them into a per-section weight table indexed to the loaded adapter
// stack, plus the lyrics with the {…} directives stripped (the model must never
// see them). Keyed by trigger word (adapter filename stem), or positional #N.
//
// Sum/Blend (issue #72) is reused per section; directive-less sections fall back
// to the stack's normal effective scales ("uniform blend of the stack").
// See docs/plans/per-section-adapter-masking.md.
export interface AdapterSection {
weights: number[]; // effective per-adapter scale, indexed to the stack
size: number; // relative frame-allocation hint (section char count)
}
export interface ParsedAdapterSections {
lyrics: string; // directives stripped
sections?: AdapterSection[]; // undefined when the feature is inactive
}
/** filename stem (trigger word) for an adapter path */
function triggerOf(p: string): string {
return (p.split(/[\\/]/).pop() || p).replace(/\.safetensors$/i, '');
}
/** Resolve a directive key (trigger word = filename stem, or positional "#2"/"2") to a stack index, or -1. */
function resolveKey(key: string, triggers: string[]): number {
const k = key.trim().toLowerCase();
const byTrigger = triggers.findIndex(t => t.toLowerCase() === k);
if (byTrigger >= 0) return byTrigger;
const m = k.match(/^#?(\d+)$/);
if (m) {
const idx = parseInt(m[1], 10) - 1; // 1-based
if (idx >= 0 && idx < triggers.length) return idx;
}
return -1;
}
interface DirectiveParse {
raw: number[]; // per-adapter weights (unmentioned → 0)
pairs: number; // `key=val` pairs found (0 → not a directive at all)
resolved: number; // pairs whose key matched a stacked adapter
unresolved: string[]; // keys that parsed but matched nothing (typos)
}
/** Parse a `key=val; key=val` directive body into raw per-adapter weights. */
function parseDirective(body: string, triggers: string[]): DirectiveParse {
const raw = new Array(triggers.length).fill(0);
let pairs = 0, resolved = 0;
const unresolved: string[] = [];
for (const part of body.split(/[;,]/)) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = parseFloat(part.slice(eq + 1).trim());
if (!key || !Number.isFinite(val)) continue;
pairs++;
const idx = resolveKey(key, triggers);
if (idx >= 0) { raw[idx] = Math.max(0, val); resolved++; }
else unresolved.push(key);
}
return { raw, pairs, resolved, unresolved };
}
/** True when a `{…}` body is directive-shaped (≥1 key=val pair), regardless of key resolution. */
function isDirectiveShaped(body: string): boolean {
for (const part of body.split(/[;,]/)) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = parseFloat(part.slice(eq + 1).trim());
if (key && Number.isFinite(val)) return true;
}
return false;
}
/**
* Strip directive-shaped `[Header]{…}` blocks from lyrics WITHOUT applying them.
* Used when the ≥2-adapter gate is not met, so stray directives never reach the
* LM/encoder as garbage tokens. Non-directive `{…}` (no key=val pair, e.g. a
* stylistic `{softly}`) is left untouched.
*/
export function stripAdapterDirectives(lyrics: string): string {
if (!lyrics) return lyrics;
return lyrics.replace(/(\[[^\]\n]+\])[ \t]*\{([^}]*)\}/g, (full, header, body) =>
isDirectiveShaped(body) ? header : full);
}
/** Apply the #72 Sum/Blend transform to a section's raw weights. */
function applyMode(raw: number[], mode: string, budget: number): number[] {
if (mode === 'blend') {
const sum = raw.reduce((a, b) => a + (b || 0), 0);
if (sum > 0) return raw.map(w => +(budget * (w || 0) / sum).toFixed(4));
return raw.map(() => 0); // explicit all-zero directive → base only
}
return raw.map(w => w || 0); // sum: raw as-is
}
/**
* Parse per-section adapter directives from lyrics.
* @param lyrics raw lyrics (may contain `[Section]{…}` directives)
* @param stack loaded adapter stack (effective scales), order matches the engine
* @param mode 'sum' | 'blend'
* @param budget combined-strength budget (blend)
*/
export function parseAdapterSections(
lyrics: string,
stack: { path: string; scale: number }[],
mode: string,
budget: number,
): ParsedAdapterSections {
if (!lyrics || !Array.isArray(stack) || stack.length < 2) return { lyrics };
// Fast bail-out: no directive syntax at all.
if (!/\]\s*\{[^}]*\}/.test(lyrics) && !/^\s*\{[^}]*\}/.test(lyrics)) return { lyrics };
const triggers = stack.map(s => triggerOf(s.path));
const defaultWeights = stack.map(s => s.scale); // uniform blend of the stack
// Split into sections at [Header] lines, capturing an optional {…} directive
// that follows the header. Content before the first header is an implicit
// directive-less section.
const headerRe = /\[[^\]\n]+\]/g;
const sections: AdapterSection[] = [];
let cleaned = '';
let lastIndex = 0;
// Helper to push a section given its body text and directive (raw weights or null).
const pushSection = (body: string, raw: number[] | null) => {
const size = Math.max(1, body.replace(/\s+/g, ' ').trim().length);
const weights = raw ? applyMode(raw, mode, budget) : defaultWeights.slice();
sections.push({ weights, size });
};
const matches = [...lyrics.matchAll(headerRe)];
if (matches.length === 0) return { lyrics };
// Preamble before the first header (rare) → implicit default section.
const firstStart = matches[0].index ?? 0;
if (firstStart > 0 && lyrics.slice(0, firstStart).trim().length > 0) {
pushSection(lyrics.slice(0, firstStart), null);
}
cleaned += lyrics.slice(0, firstStart);
lastIndex = firstStart;
for (let mi = 0; mi < matches.length; mi++) {
const h = matches[mi];
const hStart = h.index ?? 0;
const header = h[0];
let cursor = hStart + header.length;
// Optional directive immediately after the header (allowing whitespace).
// A `{…}` block is only treated as a directive when it contains at least
// one key=val pair — `[Verse] {softly}` is lyric text, not an all-zero
// directive, and must stay in the lyrics untouched.
let raw: number[] | null = null;
const after = lyrics.slice(cursor);
const dm = after.match(/^[ \t]*\{([^}]*)\}/);
const headerOut = header;
if (dm) {
const p = parseDirective(dm[1], triggers);
if (p.pairs > 0) {
cursor += dm[0].length; // directive-shaped → strip from the output
if (p.unresolved.length) {
console.warn(`[AdapterSections] ${header} directive: unknown adapter key(s) ${p.unresolved.map(k => `"${k}"`).join(', ')} — loaded triggers: ${triggers.join(', ')}`);
}
if (p.resolved > 0) {
raw = p.raw;
} else {
// Every key was a typo — fall back to the stack defaults rather
// than silently disabling all adapters for this section.
console.warn(`[AdapterSections] ${header} directive: no keys resolved, using stack default weights`);
raw = null;
}
}
// p.pairs === 0 → not a directive: leave the `{…}` in the body/lyrics.
}
// Body runs until the next header (or end).
const bodyEnd = (mi + 1 < matches.length) ? (matches[mi + 1].index ?? lyrics.length) : lyrics.length;
const body = lyrics.slice(cursor, bodyEnd);
pushSection(body, raw);
cleaned += headerOut + body;
lastIndex = bodyEnd;
}
cleaned += lyrics.slice(lastIndex);
if (sections.length === 0) return { lyrics };
return { lyrics: cleaned, sections };
}