198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
// 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 };
|
|
}
|