Initial release
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user