From 657ec749553870ba0bee1508b6e18d5189e22d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Leszko?= Date: Mon, 15 Jun 2026 13:58:42 +0000 Subject: [PATCH] feat(web): optional loop-phrase prompt prefix (loop-focused workflow) Acts on the loop-prompting experiment finding: prepending "a short perfect loop of " lowers the loop-seam discontinuity ~12-14% on average (techno -22%, deep house -18%; neutral on smooth pads) with no measured downside. Mirrors the auto_prepend_lora_triggers pattern exactly: a client-side, WYSIWYG-respecting wire transform. The Tags A/B textareas stay the operator's clean text; the phrase is injected onto the wire at send-time in wirePromptTransform and stripped on the next send, so there is no double-prepend. Rides the existing `prompt` WS command (tags/tags_b) -- no server, protocol, or wire-type change. - engine.auto_prepend_loop_phrase (default false, opt-in pending ear confirmation) + engine.loop_phrase (default "a short perfect loop of"). Toggle live via web/public/config.json + refresh, same as the LoRA flag. - lib/loopPhrase.ts: loopPhrasePrefix() + stripLeadingLoopPhrase(). - wirePromptTransform composes . - 14 unit tests: gating, custom phrase, idempotency, trigger composition. typecheck + next build clean; vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/lib/config.ts | 20 +++ .../web/lib/loopPhrase.ts | 65 +++++++++ .../web/lib/loraTriggers.ts | 8 +- .../web/tests/unit/loopPhrase.test.ts | 128 ++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 demos/realtime_motion_graph_web/web/lib/loopPhrase.ts create mode 100644 demos/realtime_motion_graph_web/web/tests/unit/loopPhrase.test.ts diff --git a/demos/realtime_motion_graph_web/web/lib/config.ts b/demos/realtime_motion_graph_web/web/lib/config.ts index e5875f54..d1c7e9fe 100644 --- a/demos/realtime_motion_graph_web/web/lib/config.ts +++ b/demos/realtime_motion_graph_web/web/lib/config.ts @@ -86,6 +86,24 @@ export interface RtmgConfigEngine { * removes its trigger from the prompt when it's still at the head. * Defaults to true. */ auto_prepend_lora_triggers?: boolean; + /** When true, the loop-focused workflow prepends a "loopiness" phrase + * (``loop_phrase``) onto the prompt ON THE WIRE — exactly like + * ``auto_prepend_lora_triggers`` — so the text encoder is told the + * section is a seamless loop. An offline A/B study found this lowers + * the loop-seam discontinuity ~12–14% on average, concentrated on + * rhythmic material (techno −22%, deep house −18%) and neutral on + * smooth pads, with no measured downside. The Tags A/B textareas stay + * the operator's clean text; the phrase is injected at send-time and + * stripped on the next send, so toggling it (config.json + refresh) + * immediately changes what the encoder sees. Default false (opt-in, + * pending ear confirmation). See scripts/experiments/loop_prompting/. */ + auto_prepend_loop_phrase?: boolean; + /** The phrase prepended when ``auto_prepend_loop_phrase`` is true. The + * operator's prompt is appended after it, i.e. ``" "``. + * Defaults to ``"a short perfect loop of"`` (the study's best + * performer; it edged ``"seamless repeating loop of"``). Edit to + * experiment with phrasing. */ + loop_phrase?: string; /** When true, the LoRA library shows every entry regardless of * whether its trained ``base_model_scale`` matches the active * checkpoint. Useful for inspecting your full collection while @@ -356,6 +374,8 @@ export const DEFAULT_CONFIG: RtmgConfig = { time_signature: DEFAULT_TIME_SIGNATURE, enabled_loras: [], auto_prepend_lora_triggers: true, + auto_prepend_loop_phrase: false, + loop_phrase: "a short perfect loop of", show_incompatible_loras: false, }, prompts: { diff --git a/demos/realtime_motion_graph_web/web/lib/loopPhrase.ts b/demos/realtime_motion_graph_web/web/lib/loopPhrase.ts new file mode 100644 index 00000000..fcce6e41 --- /dev/null +++ b/demos/realtime_motion_graph_web/web/lib/loopPhrase.ts @@ -0,0 +1,65 @@ +"use client"; + +// Wire-side loop-phrase injection. +// +// The loop-focused workflow loops a single generated section. Telling +// the text encoder up front that the section is a *loop* measurably +// tightens the loop seam (offline A/B study in +// scripts/experiments/loop_prompting/: ~12–14% lower seam discontinuity +// on average, concentrated on rhythmic material, no downside). +// +// Like LoRA triggers, we do NOT store the phrase in promptA/promptB (the +// Tags A/B textareas stay the operator's clean text); we prepend it onto +// the WIRE at send-time in `wirePromptTransform` (lib/loraTriggers.ts). +// The prefix is recomputed on every send and stripped from the incoming +// text first, so there is no double-prepend and toggling the flag +// (engine.auto_prepend_loop_phrase via config.json + refresh) immediately +// changes what the encoder sees on the next send. +// +// Gated on `engine.auto_prepend_loop_phrase` (default false, opt-in). The +// phrase itself is `engine.loop_phrase` (default below). + +import { getConfig } from "@/lib/config"; + +/** Fallback when `engine.loop_phrase` is absent. The study's best + * performer (it edged "seamless repeating loop of"). */ +export const DEFAULT_LOOP_PHRASE = "a short perfect loop of"; + +function configuredPhrase(): string { + return (getConfig().engine.loop_phrase ?? DEFAULT_LOOP_PHRASE).trim(); +} + +/** The loop phrase with a trailing space, ready to concatenate ahead of + * a prompt — or "" when the flag is off or the phrase is empty. */ +export function loopPhrasePrefix(): string { + if ((getConfig().engine.auto_prepend_loop_phrase ?? false) !== true) { + return ""; + } + const phrase = configuredPhrase(); + return phrase ? `${phrase} ` : ""; +} + +/** Strip a leading copy (or accidental stack) of the configured loop + * phrase off a prompt, returning the operator's clean text. Inverse of + * `loopPhrasePrefix`; case-insensitive; best-effort (only the currently + * configured phrase is recognised, mirroring the LoRA-trigger strip's + * best-effort contract). Requires a trailing space after the phrase so a + * bare prompt equal to the phrase is left untouched. */ +export function stripLeadingLoopPhrase(text: string): string { + if (!text) return text; + const phrase = configuredPhrase(); + if (!phrase) return text; + const needle = `${phrase.toLowerCase()} `; + let out = text; + // Bounded loop: drop repeated leading occurrences (prefix drift / a + // phrase change that stacked), capped so a pathological input can't spin. + for (let guard = 0; guard < 8; guard += 1) { + const lead = out.replace(/^\s+/, ""); + if (!lead.toLowerCase().startsWith(needle)) { + out = lead; + break; + } + out = lead.slice(phrase.length).replace(/^\s+/, ""); + } + return out; +} diff --git a/demos/realtime_motion_graph_web/web/lib/loraTriggers.ts b/demos/realtime_motion_graph_web/web/lib/loraTriggers.ts index 315d913f..4f2f4f9a 100644 --- a/demos/realtime_motion_graph_web/web/lib/loraTriggers.ts +++ b/demos/realtime_motion_graph_web/web/lib/loraTriggers.ts @@ -23,6 +23,7 @@ // is empty. import { getConfig } from "@/lib/config"; +import { loopPhrasePrefix, stripLeadingLoopPhrase } from "@/lib/loopPhrase"; import { useLoraStore } from "@/store/useLoraStore"; /** Comma-joined trigger prefix for the currently-enabled LoRAs, with a @@ -108,5 +109,10 @@ export function stripLeadingTriggers(text: string): string { * disabled LoRA's trigger zero times and an enabled LoRA's trigger * exactly once — per tag (A and B alike). */ export function wirePromptTransform(tags: string): string { - return enabledLoraTriggerPrefix() + stripLeadingTriggers(tags); + // Strip ANY prefix already on the text (stale/stacked LoRA triggers, + // and a leading loop phrase), then re-prepend the current prefixes. + // Wire order: — triggers stay + // at the head as activation tokens; the loop phrase wraps the prompt. + const clean = stripLeadingLoopPhrase(stripLeadingTriggers(tags)); + return enabledLoraTriggerPrefix() + loopPhrasePrefix() + clean; } diff --git a/demos/realtime_motion_graph_web/web/tests/unit/loopPhrase.test.ts b/demos/realtime_motion_graph_web/web/tests/unit/loopPhrase.test.ts new file mode 100644 index 00000000..a0925444 --- /dev/null +++ b/demos/realtime_motion_graph_web/web/tests/unit/loopPhrase.test.ts @@ -0,0 +1,128 @@ +// Loop-phrase wire injection: gating, strip/re-prepend idempotency, and +// composition with the LoRA-trigger prefix in `wirePromptTransform`. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mutable mock state, hoisted so the vi.mock factories (which run before +// the imports) can close over it. Each test tweaks these fields. +const state = vi.hoisted(() => ({ + engine: { + auto_prepend_lora_triggers: false, + auto_prepend_loop_phrase: false, + loop_phrase: "a short perfect loop of", + } as Record, + lora: { enabled: new Set(), catalog: [] as Array<{ id: string; metadata?: { primary_trigger_word?: string } }> }, +})); + +vi.mock("@/lib/config", () => ({ getConfig: () => ({ engine: state.engine }) })); +vi.mock("@/store/useLoraStore", () => ({ + useLoraStore: { getState: () => state.lora }, +})); + +import { loopPhrasePrefix, stripLeadingLoopPhrase } from "@/lib/loopPhrase"; +import { wirePromptTransform } from "@/lib/loraTriggers"; + +beforeEach(() => { + state.engine = { + auto_prepend_lora_triggers: false, + auto_prepend_loop_phrase: false, + loop_phrase: "a short perfect loop of", + }; + state.lora = { enabled: new Set(), catalog: [] }; +}); + +describe("loopPhrasePrefix", () => { + it("is empty when the flag is off (default)", () => { + expect(loopPhrasePrefix()).toBe(""); + }); + + it("is the phrase + trailing space when on", () => { + state.engine.auto_prepend_loop_phrase = true; + expect(loopPhrasePrefix()).toBe("a short perfect loop of "); + }); + + it("honours a custom phrase", () => { + state.engine.auto_prepend_loop_phrase = true; + state.engine.loop_phrase = "seamless repeating loop of"; + expect(loopPhrasePrefix()).toBe("seamless repeating loop of "); + }); + + it("is empty when the phrase is blank even if the flag is on", () => { + state.engine.auto_prepend_loop_phrase = true; + state.engine.loop_phrase = " "; + expect(loopPhrasePrefix()).toBe(""); + }); +}); + +describe("stripLeadingLoopPhrase", () => { + it("removes a single leading phrase", () => { + expect(stripLeadingLoopPhrase("a short perfect loop of driving techno")).toBe( + "driving techno", + ); + }); + + it("removes accidental stacking", () => { + expect( + stripLeadingLoopPhrase( + "a short perfect loop of a short perfect loop of pads", + ), + ).toBe("pads"); + }); + + it("is case-insensitive", () => { + expect(stripLeadingLoopPhrase("A Short Perfect Loop Of pads")).toBe("pads"); + }); + + it("leaves a prompt without the phrase untouched", () => { + expect(stripLeadingLoopPhrase("driving techno")).toBe("driving techno"); + }); + + it("does not strip a bare phrase with no trailing content", () => { + // No trailing space after the phrase -> not treated as a prefix. + expect(stripLeadingLoopPhrase("a short perfect loop of")).toBe( + "a short perfect loop of", + ); + }); +}); + +describe("wirePromptTransform (loop phrase)", () => { + it("passes the prompt through untouched when both features are off", () => { + expect(wirePromptTransform("driving techno")).toBe("driving techno"); + }); + + it("prepends the loop phrase when enabled", () => { + state.engine.auto_prepend_loop_phrase = true; + expect(wirePromptTransform("driving techno")).toBe( + "a short perfect loop of driving techno", + ); + }); + + it("is idempotent: re-sending its own output does not double-prepend", () => { + state.engine.auto_prepend_loop_phrase = true; + const once = wirePromptTransform("driving techno"); + expect(wirePromptTransform(once)).toBe(once); + }); + + it("orders triggers before the loop phrase, then the clean prompt", () => { + state.engine.auto_prepend_lora_triggers = true; + state.engine.auto_prepend_loop_phrase = true; + state.lora = { + enabled: new Set(["phonk"]), + catalog: [{ id: "phonk", metadata: { primary_trigger_word: "phonk" } }], + }; + expect(wirePromptTransform("driving techno")).toBe( + "phonk, a short perfect loop of driving techno", + ); + }); + + it("stays idempotent with both prefixes active", () => { + state.engine.auto_prepend_lora_triggers = true; + state.engine.auto_prepend_loop_phrase = true; + state.lora = { + enabled: new Set(["phonk"]), + catalog: [{ id: "phonk", metadata: { primary_trigger_word: "phonk" } }], + }; + const once = wirePromptTransform("driving techno"); + expect(wirePromptTransform(once)).toBe(once); + }); +});