v1.6: voice casting system + sampler-path audit fixes
Browse files- ComfyUI_JoyAI_Echo_v1.6_COMPLETE.zip +3 -0
- README.md +49 -1
- joyecho_prompt_source.py +36 -4
- joyecho_script_picker.py +75 -4
- libs/ltx_distillation/inference/memory_multishot.py +143 -5
- nodes.py +0 -0
ComfyUI_JoyAI_Echo_v1.6_COMPLETE.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9dfda4540dac568972c32f7e66017037caa00d82a40918bb3b1f924cb592fff1
|
| 3 |
+
size 2484894
|
README.md
CHANGED
|
@@ -80,7 +80,7 @@ a whole scene.
|
|
| 80 |
|
| 81 |
*(For the node pack — if you want the dependency-free Lite workflow instead, grab `LTX23_Multishot_Lite_v1.0.zip` above.)*
|
| 82 |
|
| 83 |
-
**Just the zip: `ComfyUI_JoyAI_Echo_v1.
|
| 84 |
every patch file, the workflow, an example prompt file, and step-by-step
|
| 85 |
instructions. Nothing else on this page is required.
|
| 86 |
|
|
@@ -343,6 +343,54 @@ frames. Audio is untouched.
|
|
| 343 |
- fp8 gemma swap accepts both `.scale_weight` and `.weight_scale` layouts
|
| 344 |
and warns loudly on zero matches instead of silently staying bf16.
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
### 19. Finishing: who builds your master (READ THIS before touching hires)
|
| 347 |
`hires_factor` is a ROUTING switch, not a quality slider - it decides which
|
| 348 |
pipeline builds your final video:
|
|
|
|
| 80 |
|
| 81 |
*(For the node pack — if you want the dependency-free Lite workflow instead, grab `LTX23_Multishot_Lite_v1.0.zip` above.)*
|
| 82 |
|
| 83 |
+
**Just the zip: `ComfyUI_JoyAI_Echo_v1.6_COMPLETE.zip`.** That is the whole thing -
|
| 84 |
every patch file, the workflow, an example prompt file, and step-by-step
|
| 85 |
instructions. Nothing else on this page is required.
|
| 86 |
|
|
|
|
| 343 |
- fp8 gemma swap accepts both `.scale_weight` and `.weight_scale` layouts
|
| 344 |
and warns loudly on zero matches instead of silently staying bf16.
|
| 345 |
|
| 346 |
+
### 20. Voice casting - per-character voices from files (NEW, 2026-07-29)
|
| 347 |
+
|
| 348 |
+
The memory bank guarantees voice CONSISTENCY, not correctness: shot 1 rolls
|
| 349 |
+
its voice from text conditioning alone, and whatever it rolls, the bank then
|
| 350 |
+
carries faithfully. This release makes the voice a CASTING decision instead
|
| 351 |
+
of a roll, with zero per-run typing:
|
| 352 |
+
|
| 353 |
+
- **Folder casting.** Put a clip of the character speaking (>=4 s, mp4 or
|
| 354 |
+
wav) in `ComfyUI/input/joyecho_voices/<speaker-tag-lowercase>/`. Any script
|
| 355 |
+
whose speaker tag matches the folder gets that voice seeded into the memory
|
| 356 |
+
bank as a character-tagged anchor slot BEFORE shot 1 - the first shot
|
| 357 |
+
*continues* the cast voice instead of auditioning a new one, and the same
|
| 358 |
+
file re-casts the same voice in every future render. The pick is
|
| 359 |
+
deterministic (alphabetically first file). Replace the file to recast.
|
| 360 |
+
- **Script-carried casting.** A `"voice_refs": {"Alice": "path/clip.mp4"}`
|
| 361 |
+
key in the script JSON overrides the folder scan per character.
|
| 362 |
+
- **Audio-only anchors.** A bare wav/flac anchor pairs its voice with the
|
| 363 |
+
character's ref image from `joyecho_refs/<tag>/` so the slot keeps its
|
| 364 |
+
face+voice contract.
|
| 365 |
+
- **Speaker order is derived from the script** - an explicit
|
| 366 |
+
`"speakers": [...]` array, or the `"<Name> is talking"` attribution in each
|
| 367 |
+
shot. It now travels INSIDE the conditioning (and its disk cache), so it
|
| 368 |
+
can never go stale or leak between graphs. The `speaker_order` widget
|
| 369 |
+
remains as a manual override only.
|
| 370 |
+
- **Anchor + latest policy.** A speaker's audio context is their anchor
|
| 371 |
+
slot(s) plus their most recent shot only - one drifted shot can no longer
|
| 372 |
+
accumulate a majority and take over the rest of the video.
|
| 373 |
+
- **Cold-start fix.** A speaker's first line falls back to the unfiltered
|
| 374 |
+
bank instead of a zeroed (silent) one - the regression that previously
|
| 375 |
+
made per-character filtering unusable.
|
| 376 |
+
|
| 377 |
+
Two-character staging note that saves you a night: a2v cross-attention has
|
| 378 |
+
no spatial addressing - audio at time t drives EVERY face in frame, however
|
| 379 |
+
small or distant. Stage ONE face per shot (shot-reverse-shot) and put only
|
| 380 |
+
the visible character's description in that shot's prompt.
|
| 381 |
+
|
| 382 |
+
### 21. Correctness fixes from a full sampler-path audit (2026-07-29)
|
| 383 |
+
|
| 384 |
+
- **Hires refine now respects your seed.** Its re-noise fields were seeded
|
| 385 |
+
from a hardcoded constant - every render's refine detail layer was
|
| 386 |
+
identical regardless of the seed widget, for months.
|
| 387 |
+
- **Chained SingleShot graphs no longer condition on the PREVIOUS queue
|
| 388 |
+
run's output.** The memory bank object was mutated in place through
|
| 389 |
+
ComfyUI's output cache; incoming banks are now cloned.
|
| 390 |
+
- **The conditioning disk cache key includes the checkpoint** - a model swap
|
| 391 |
+
can no longer be served the previous model's conditioning tensors.
|
| 392 |
+
(Existing cache files are invalidated once; they rebuild on first render.)
|
| 393 |
+
|
| 394 |
### 19. Finishing: who builds your master (READ THIS before touching hires)
|
| 395 |
`hires_factor` is a ROUTING switch, not a quality slider - it decides which
|
| 396 |
pipeline builds your final video:
|
joyecho_prompt_source.py
CHANGED
|
@@ -60,8 +60,11 @@ def _list_files() -> list[str]:
|
|
| 60 |
for p in sorted(root.rglob("*.txt")):
|
| 61 |
out.append(_TXT_PREFIX + str(p.relative_to(root)))
|
| 62 |
jroot = _json_root()
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
return out or [_EMPTY]
|
| 66 |
|
| 67 |
|
|
@@ -134,10 +137,39 @@ class JoyEcho_PromptSource:
|
|
| 134 |
arr = data.get("prompts") or data.get("shots")
|
| 135 |
if not isinstance(arr, list) or not arr:
|
| 136 |
raise ValueError(f"{p.name} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return ([text], [override], 1)
|
| 139 |
|
| 140 |
-
# TXT: LPFF-style blocks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
items, names = [], []
|
| 142 |
for blk in _BLOCK_SPLIT.split(text):
|
| 143 |
m = _BLOCK_PATTERN.search(blk)
|
|
|
|
| 60 |
for p in sorted(root.rglob("*.txt")):
|
| 61 |
out.append(_TXT_PREFIX + str(p.relative_to(root)))
|
| 62 |
jroot = _json_root()
|
| 63 |
+
# rglob, matching the .txt branch above: a flat glob hides scripts filed in
|
| 64 |
+
# subfolders, which is how anyone organises more than a handful of them.
|
| 65 |
+
# _resolve() already joins the relative path, so nested names round-trip.
|
| 66 |
+
for p in sorted(jroot.rglob("*.json")):
|
| 67 |
+
out.append(_JSON_PREFIX + str(p.relative_to(jroot)).replace("\\", "/"))
|
| 68 |
return out or [_EMPTY]
|
| 69 |
|
| 70 |
|
|
|
|
| 137 |
arr = data.get("prompts") or data.get("shots")
|
| 138 |
if not isinstance(arr, list) or not arr:
|
| 139 |
raise ValueError(f"{p.name} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 140 |
+
# Per-character audio memory: derive the speaker order from the
|
| 141 |
+
# script (an explicit "speakers" array, else the "<ID> is talking"
|
| 142 |
+
# attribution in each shot) and stash it for JoyEcho_Generate. The
|
| 143 |
+
# derivation lives in joyecho_script_picker; both loaders feed the
|
| 144 |
+
# same stash so it works regardless of which node the graph uses -
|
| 145 |
+
# the first wiring of this feature went ONLY into ScriptPicker and
|
| 146 |
+
# the live workflow loads through THIS node, so the 11:23 render
|
| 147 |
+
# silently ran with per-character memory off.
|
| 148 |
+
try:
|
| 149 |
+
from .joyecho_script_picker import (derive_speakers, set_last_speakers,
|
| 150 |
+
set_last_voice_refs)
|
| 151 |
+
except ImportError:
|
| 152 |
+
from joyecho_script_picker import (derive_speakers, set_last_speakers,
|
| 153 |
+
set_last_voice_refs)
|
| 154 |
+
speakers = derive_speakers(data, arr)
|
| 155 |
+
set_last_speakers(speakers)
|
| 156 |
+
vrefs = data.get("voice_refs") or {}
|
| 157 |
+
set_last_voice_refs(vrefs)
|
| 158 |
+
print(f"[JoyEcho] PromptSource: {p.name} (json, {len(arr)} shots, 1 item)"
|
| 159 |
+
+ (f", speakers: {' '.join(speakers)}" if speakers else ", no speaker tags")
|
| 160 |
+
+ (f", voice anchors: {', '.join(vrefs)}" if vrefs else "")
|
| 161 |
+
+ ".", flush=True)
|
| 162 |
return ([text], [override], 1)
|
| 163 |
|
| 164 |
+
# TXT: LPFF-style blocks. Clear the speaker stash - it holds whatever the
|
| 165 |
+
# LAST json load derived, and stale speakers applied to an unrelated
|
| 166 |
+
# script would filter the audio bank to the wrong characters.
|
| 167 |
+
try:
|
| 168 |
+
from .joyecho_script_picker import set_last_speakers, set_last_voice_refs
|
| 169 |
+
except ImportError:
|
| 170 |
+
from joyecho_script_picker import set_last_speakers, set_last_voice_refs
|
| 171 |
+
set_last_speakers([])
|
| 172 |
+
set_last_voice_refs({})
|
| 173 |
items, names = [], []
|
| 174 |
for blk in _BLOCK_SPLIT.split(text):
|
| 175 |
m = _BLOCK_PATTERN.search(blk)
|
joyecho_script_picker.py
CHANGED
|
@@ -12,6 +12,7 @@ automatically (IS_CHANGED tracks mtime) — no need to reselect.
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import json
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
import folder_paths
|
|
@@ -19,6 +20,71 @@ import folder_paths
|
|
| 19 |
_PROMPTS_SUBDIR = "joyecho_prompts"
|
| 20 |
_EMPTY = "(no .json in input/joyecho_prompts)"
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def _scripts_dir() -> Path:
|
| 24 |
d = Path(folder_paths.get_input_directory()) / _PROMPTS_SUBDIR
|
|
@@ -45,8 +111,8 @@ class JoyEcho_ScriptPicker:
|
|
| 45 |
def INPUT_TYPES(cls):
|
| 46 |
return {"required": {"script": (_list_scripts(),)}}
|
| 47 |
|
| 48 |
-
RETURN_TYPES = ("STRING", "STRING",)
|
| 49 |
-
RETURN_NAMES = ("prompts_json", "path",)
|
| 50 |
FUNCTION = "load"
|
| 51 |
CATEGORY = "JoyAI-Echo"
|
| 52 |
|
|
@@ -81,8 +147,13 @@ class JoyEcho_ScriptPicker:
|
|
| 81 |
arr = data.get("shots")
|
| 82 |
if not isinstance(arr, list) or not arr:
|
| 83 |
raise ValueError(f"{script} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
NODE_CLASS_MAPPINGS = {"JoyEcho_ScriptPicker": JoyEcho_ScriptPicker}
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import json
|
| 15 |
+
import re
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
import folder_paths
|
|
|
|
| 20 |
_PROMPTS_SUBDIR = "joyecho_prompts"
|
| 21 |
_EMPTY = "(no .json in input/joyecho_prompts)"
|
| 22 |
|
| 23 |
+
# Per-character audio memory needs to know WHO speaks in each shot. That used to
|
| 24 |
+
# be a hand-typed speaker_order widget on the Generate node, which does not
|
| 25 |
+
# survive a queue-driven workflow - nobody is going to retype it per script. The
|
| 26 |
+
# script already states the speaker in every shot, so derive it here and hand it
|
| 27 |
+
# downstream. The widget remains as a manual override.
|
| 28 |
+
#
|
| 29 |
+
# The tags are opaque: save_memory_slot(character=...) and get_memory_audio(
|
| 30 |
+
# speaker=...) only ever compare them for equality, so "ID_A" works as well as
|
| 31 |
+
# "zara" and needs no mapping to a refs folder.
|
| 32 |
+
_SPEAKER_RE = re.compile(r"\b(ID_[A-Z]|[A-Z][a-z]+)\s+is\s+talking\b")
|
| 33 |
+
LAST_SPEAKERS: list[str] = []
|
| 34 |
+
|
| 35 |
+
# Voice anchors: {"character": "path/to/clip.mp4"} carried by the script's
|
| 36 |
+
# "voice_refs" key. Same stash pattern as LAST_SPEAKERS; the Generate node
|
| 37 |
+
# encodes each clip's audio into a tagged memory-bank slot before shot 1, so
|
| 38 |
+
# the character's voice is CAST from a file instead of rolled from text.
|
| 39 |
+
# Keys must exactly match the script's speaker tags.
|
| 40 |
+
LAST_VOICE_REFS: dict = {}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def set_last_voice_refs(refs: dict) -> None:
|
| 44 |
+
"""Stash the current script's voice anchors (empty dict clears)."""
|
| 45 |
+
global LAST_VOICE_REFS
|
| 46 |
+
LAST_VOICE_REFS = dict(refs) if isinstance(refs, dict) else {}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def set_last_speakers(speakers: list[str]) -> None:
|
| 50 |
+
"""Stash the current script's speaker order for the Generate node.
|
| 51 |
+
|
| 52 |
+
Wiring the `speakers` output is the explicit path; this module-level stash is
|
| 53 |
+
the zero-rewiring fallback so existing saved workflows and queued runs pick
|
| 54 |
+
it up with no canvas edits. ComfyUI executes a graph's nodes in dependency
|
| 55 |
+
order within one prompt, so the picker always runs before the generator it
|
| 56 |
+
feeds, and each execution overwrites the previous value.
|
| 57 |
+
"""
|
| 58 |
+
global LAST_SPEAKERS
|
| 59 |
+
LAST_SPEAKERS = list(speakers)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def derive_speakers(data: dict, shots: list) -> list[str]:
|
| 63 |
+
"""Speaker tag per shot: an explicit "speakers" array wins, else the prose.
|
| 64 |
+
|
| 65 |
+
Returns [] when the script declares nothing and no shot names a speaker -
|
| 66 |
+
callers then fall back to character-blind memory, i.e. old behaviour.
|
| 67 |
+
"""
|
| 68 |
+
if isinstance(data, dict):
|
| 69 |
+
declared = data.get("speakers") or data.get("speaker_order")
|
| 70 |
+
if isinstance(declared, str):
|
| 71 |
+
declared = [t for t in re.split(r"[,\s]+", declared.strip()) if t]
|
| 72 |
+
if isinstance(declared, list) and declared:
|
| 73 |
+
return [str(declared[i % len(declared)]) for i in range(len(shots))]
|
| 74 |
+
|
| 75 |
+
out, seen_any = [], False
|
| 76 |
+
for shot in shots:
|
| 77 |
+
m = _SPEAKER_RE.search(str(shot))
|
| 78 |
+
if m:
|
| 79 |
+
out.append(m.group(1))
|
| 80 |
+
seen_any = True
|
| 81 |
+
else:
|
| 82 |
+
# Unattributed shot: reuse the previous speaker rather than guessing.
|
| 83 |
+
# A wrong tag is worse than a repeated one - it would filter the bank
|
| 84 |
+
# to the wrong character and hand this shot the wrong voice.
|
| 85 |
+
out.append(out[-1] if out else "")
|
| 86 |
+
return out if seen_any and all(out) else []
|
| 87 |
+
|
| 88 |
|
| 89 |
def _scripts_dir() -> Path:
|
| 90 |
d = Path(folder_paths.get_input_directory()) / _PROMPTS_SUBDIR
|
|
|
|
| 111 |
def INPUT_TYPES(cls):
|
| 112 |
return {"required": {"script": (_list_scripts(),)}}
|
| 113 |
|
| 114 |
+
RETURN_TYPES = ("STRING", "STRING", "STRING",)
|
| 115 |
+
RETURN_NAMES = ("prompts_json", "path", "speakers",)
|
| 116 |
FUNCTION = "load"
|
| 117 |
CATEGORY = "JoyAI-Echo"
|
| 118 |
|
|
|
|
| 147 |
arr = data.get("shots")
|
| 148 |
if not isinstance(arr, list) or not arr:
|
| 149 |
raise ValueError(f"{script} must contain a non-empty 'prompts' (or 'shots') array.")
|
| 150 |
+
speakers = derive_speakers(data, arr)
|
| 151 |
+
set_last_speakers(speakers)
|
| 152 |
+
set_last_voice_refs(data.get("voice_refs") or {})
|
| 153 |
+
print(f"[JoyEcho] ScriptPicker: {script} ({len(arr)} shots)"
|
| 154 |
+
+ (f", speakers: {' '.join(speakers)}" if speakers else ", no speaker tags")
|
| 155 |
+
+ ".", flush=True)
|
| 156 |
+
return (text, str(p), " ".join(speakers),)
|
| 157 |
|
| 158 |
|
| 159 |
NODE_CLASS_MAPPINGS = {"JoyEcho_ScriptPicker": JoyEcho_ScriptPicker}
|
libs/ltx_distillation/inference/memory_multishot.py
CHANGED
|
@@ -109,11 +109,12 @@ def build_paired_audio_memory_kwargs(
|
|
| 109 |
enable_audio_memory: bool,
|
| 110 |
v2a_grad_scale: float = 1.0,
|
| 111 |
memory_position_mode: str = "reference",
|
|
|
|
| 112 |
) -> dict[str, Any]:
|
| 113 |
if not enable_audio_memory:
|
| 114 |
return {}
|
| 115 |
|
| 116 |
-
memory_audio = memory_bank.get_memory_audio()
|
| 117 |
if memory_audio is None:
|
| 118 |
raise RuntimeError("audio memory was requested but the memory bank contains entries without audio latents")
|
| 119 |
|
|
@@ -305,7 +306,47 @@ class PairedAudioVideoMemoryBank:
|
|
| 305 |
fixed = self.memory[:n_fixed]
|
| 306 |
tail = self.memory[self.num_fix_frames :]
|
| 307 |
keep_tail = self.max_size - len(fixed)
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
def save_memory_slot(
|
| 311 |
self,
|
|
@@ -324,6 +365,7 @@ class PairedAudioVideoMemoryBank:
|
|
| 324 |
audio_memory_n_fft: int = 1024,
|
| 325 |
audio_memory_downsample_factor: int = 4,
|
| 326 |
audio_memory_is_causal: bool = True,
|
|
|
|
| 327 |
) -> dict[str, Any]:
|
| 328 |
audio_latent = self._prepare_audio_latent(audio_latent)
|
| 329 |
if audio_latent is None:
|
|
@@ -379,7 +421,33 @@ class PairedAudioVideoMemoryBank:
|
|
| 379 |
video_clip_num_frames=video_clip_num_frames,
|
| 380 |
)
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
metadata = {"selection_mode": "paired_audio_window", **audio_metadata, **video_metadata}
|
|
|
|
|
|
|
| 383 |
entry = MemoryEntry(frame=video_clip, audio_latent=window_latent, metadata=metadata)
|
| 384 |
fixed = self.memory[: self.num_fix_frames]
|
| 385 |
free = self.memory[self.num_fix_frames :]
|
|
@@ -394,8 +462,72 @@ class PairedAudioVideoMemoryBank:
|
|
| 394 |
def get_memory_metadata(self) -> list[dict[str, Any]]:
|
| 395 |
return [dict(entry.metadata) for entry in self.memory]
|
| 396 |
|
| 397 |
-
def get_memory_audio(self) -> Optional[torch.Tensor]:
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
if not audio_latents or any(audio_latent is None for audio_latent in audio_latents):
|
| 400 |
return None
|
| 401 |
first = audio_latents[0]
|
|
@@ -409,7 +541,13 @@ class PairedAudioVideoMemoryBank:
|
|
| 409 |
"All memory audio latents must share batch and channel dimensions, "
|
| 410 |
f"got first={tuple(first.shape)} current={tuple(audio_latent.shape)}"
|
| 411 |
)
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
def get_memory_audio_segment_lengths(self) -> tuple[tuple[int, ...], ...]:
|
| 415 |
audio_latents = [entry.audio_latent for entry in self.memory]
|
|
|
|
| 109 |
enable_audio_memory: bool,
|
| 110 |
v2a_grad_scale: float = 1.0,
|
| 111 |
memory_position_mode: str = "reference",
|
| 112 |
+
speaker: Optional[str] = None,
|
| 113 |
) -> dict[str, Any]:
|
| 114 |
if not enable_audio_memory:
|
| 115 |
return {}
|
| 116 |
|
| 117 |
+
memory_audio = memory_bank.get_memory_audio(speaker=speaker)
|
| 118 |
if memory_audio is None:
|
| 119 |
raise RuntimeError("audio memory was requested but the memory bank contains entries without audio latents")
|
| 120 |
|
|
|
|
| 306 |
fixed = self.memory[:n_fixed]
|
| 307 |
tail = self.memory[self.num_fix_frames :]
|
| 308 |
keep_tail = self.max_size - len(fixed)
|
| 309 |
+
if keep_tail <= 0:
|
| 310 |
+
self.memory = fixed
|
| 311 |
+
return
|
| 312 |
+
|
| 313 |
+
# Per-character reservation. The plain recency window lets a talkative
|
| 314 |
+
# character's slots evict a quieter one's ONLY voice exemplar, after
|
| 315 |
+
# which that character has nothing to be continued from and re-renders
|
| 316 |
+
# with a new voice. Guarantee every character seen in the tail keeps
|
| 317 |
+
# their most recent slot, and only evict from characters that still
|
| 318 |
+
# have another slot left.
|
| 319 |
+
#
|
| 320 |
+
# Indices are used throughout rather than entry objects: MemoryEntry is
|
| 321 |
+
# an eq=True dataclass holding tensors, so `entry in list` would run a
|
| 322 |
+
# tensor comparison and raise.
|
| 323 |
+
kept = list(range(len(tail)))[-keep_tail:]
|
| 324 |
+
last_by_char: dict[str, int] = {}
|
| 325 |
+
for i, entry in enumerate(tail):
|
| 326 |
+
owner = entry.metadata.get("character")
|
| 327 |
+
if owner:
|
| 328 |
+
last_by_char[owner] = i # later shot wins = most recent
|
| 329 |
+
|
| 330 |
+
for reserved in sorted(last_by_char.values()):
|
| 331 |
+
if reserved in kept:
|
| 332 |
+
continue
|
| 333 |
+
counts: dict[Any, int] = {}
|
| 334 |
+
for i in kept:
|
| 335 |
+
owner = tail[i].metadata.get("character")
|
| 336 |
+
counts[owner] = counts.get(owner, 0) + 1
|
| 337 |
+
victim = next(
|
| 338 |
+
(i for i in kept
|
| 339 |
+
if tail[i].metadata.get("character") is not None
|
| 340 |
+
and counts[tail[i].metadata.get("character")] > 1),
|
| 341 |
+
None,
|
| 342 |
+
)
|
| 343 |
+
if victim is None:
|
| 344 |
+
break # nothing safe to drop; leave as-is
|
| 345 |
+
kept.remove(victim)
|
| 346 |
+
kept.append(reserved)
|
| 347 |
+
kept.sort()
|
| 348 |
+
|
| 349 |
+
self.memory = fixed + [tail[i] for i in kept]
|
| 350 |
|
| 351 |
def save_memory_slot(
|
| 352 |
self,
|
|
|
|
| 365 |
audio_memory_n_fft: int = 1024,
|
| 366 |
audio_memory_downsample_factor: int = 4,
|
| 367 |
audio_memory_is_causal: bool = True,
|
| 368 |
+
character: Optional[str] = None,
|
| 369 |
) -> dict[str, Any]:
|
| 370 |
audio_latent = self._prepare_audio_latent(audio_latent)
|
| 371 |
if audio_latent is None:
|
|
|
|
| 421 |
video_clip_num_frames=video_clip_num_frames,
|
| 422 |
)
|
| 423 |
|
| 424 |
+
# "last": take the video half of the slot from the END of the shot
|
| 425 |
+
# instead of from wherever the loudest speech happened.
|
| 426 |
+
#
|
| 427 |
+
# The audio window is still chosen by max_response - that is the right
|
| 428 |
+
# exemplar for the VOICE. But pairing it with a mid-shot video clip
|
| 429 |
+
# makes the next shot resume from mid-shot, which reads on screen as the
|
| 430 |
+
# take rewinding a second or two before continuing. Decoupling the two
|
| 431 |
+
# keeps the best voice reference AND gives the next shot a genuine
|
| 432 |
+
# continuation point.
|
| 433 |
+
if str(video_frame_selection_mode).lower() == "last" and frames:
|
| 434 |
+
n = max(1, int(video_clip_num_frames))
|
| 435 |
+
clip_start = max(0, len(frames) - n)
|
| 436 |
+
video_clip = list(frames[clip_start:])
|
| 437 |
+
if len(video_clip) < n:
|
| 438 |
+
video_clip.extend([video_clip[-1]] * (n - len(video_clip)))
|
| 439 |
+
video_metadata = {
|
| 440 |
+
"video_clip_start": int(clip_start),
|
| 441 |
+
"video_clip_end": int(len(frames)),
|
| 442 |
+
"video_clip_length": int(len(video_clip)),
|
| 443 |
+
"video_clip_center_frame": int(len(frames) - 1),
|
| 444 |
+
"video_total_frames": int(len(frames)),
|
| 445 |
+
"video_frame_selection_mode": "last",
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
metadata = {"selection_mode": "paired_audio_window", **audio_metadata, **video_metadata}
|
| 449 |
+
if character:
|
| 450 |
+
metadata["character"] = str(character)
|
| 451 |
entry = MemoryEntry(frame=video_clip, audio_latent=window_latent, metadata=metadata)
|
| 452 |
fixed = self.memory[: self.num_fix_frames]
|
| 453 |
free = self.memory[self.num_fix_frames :]
|
|
|
|
| 462 |
def get_memory_metadata(self) -> list[dict[str, Any]]:
|
| 463 |
return [dict(entry.metadata) for entry in self.memory]
|
| 464 |
|
| 465 |
+
def get_memory_audio(self, speaker: Optional[str] = None) -> Optional[torch.Tensor]:
|
| 466 |
+
"""Concatenated memory audio, optionally scoped to ONE speaker.
|
| 467 |
+
|
| 468 |
+
Slots are written one per shot and the bank is character-blind, so with
|
| 469 |
+
two characters alternating shots the audio lane carries BOTH voices into
|
| 470 |
+
every shot. The model then has two equally valid voices to continue and
|
| 471 |
+
picks either - the same-gender voice merge.
|
| 472 |
+
|
| 473 |
+
When `speaker` is given, slots belonging to a DIFFERENT named character
|
| 474 |
+
are zeroed instead of dropped. Zeroing (not dropping) is deliberate: the
|
| 475 |
+
slot count, the per-slot lengths and therefore
|
| 476 |
+
`_build_paired_memory_cross_mask`'s positional pairing all stay exactly
|
| 477 |
+
as they were, so this cannot re-introduce the even-split desync that
|
| 478 |
+
shredded stored voices. The video half is untouched, so the non-speaker
|
| 479 |
+
keeps full face continuity - their slot simply pairs their face with
|
| 480 |
+
silence, which is what was true in that shot anyway.
|
| 481 |
+
|
| 482 |
+
Untagged slots (character is None) are always kept: a script that does
|
| 483 |
+
not declare speakers behaves exactly as before.
|
| 484 |
+
"""
|
| 485 |
+
# COLD START. If this speaker owns no slot yet, filtering would zero
|
| 486 |
+
# EVERY slot and hand them a bank of pure silence to continue from -
|
| 487 |
+
# strictly worse than no filtering at all. Measured on the first attempt
|
| 488 |
+
# at this feature: Glyph's first line generated against an all-silent
|
| 489 |
+
# bank and her pitch wandered 219 -> 276 -> 258Hz across three lines,
|
| 490 |
+
# where with filtering off she was identical on all three. That single
|
| 491 |
+
# regression is what got per-character memory shelved.
|
| 492 |
+
#
|
| 493 |
+
# So: a character's FIRST line falls back to the unfiltered bank, which
|
| 494 |
+
# is exactly the pre-per-character behaviour for that one shot. From
|
| 495 |
+
# their second line on they have an exemplar of their own and get full
|
| 496 |
+
# isolation. The failure mode this removes (silence) is worse than the
|
| 497 |
+
# one it briefly tolerates (one shot of shared bank).
|
| 498 |
+
owns_a_slot = speaker is not None and any(
|
| 499 |
+
entry.metadata.get("character") == speaker for entry in self.memory)
|
| 500 |
+
effective_speaker = speaker if owns_a_slot else None
|
| 501 |
+
|
| 502 |
+
# ANCHOR + LATEST (2026-07-28). A speaker's audio context is their
|
| 503 |
+
# anchor slot(s) plus their MOST RECENT generated slot - older generated
|
| 504 |
+
# slots are zeroed like other characters'. Measured motivation: shot 5's
|
| 505 |
+
# line flipped Zara's accent against a context of three American
|
| 506 |
+
# exemplars (text pull beat the whole bank), and then shots 6-7 stayed
|
| 507 |
+
# flipped because the drifted slot outnumbered nothing - consistency
|
| 508 |
+
# machinery faithfully propagated the poison. With this policy a drifted
|
| 509 |
+
# shot contributes exactly one slot against the permanent anchor instead
|
| 510 |
+
# of accumulating a majority, so a single bad roll cannot own the rest
|
| 511 |
+
# of the video. Untagged slots and no-speaker calls are untouched.
|
| 512 |
+
latest_gen_idx = -1
|
| 513 |
+
if effective_speaker is not None:
|
| 514 |
+
for _i, entry in enumerate(self.memory):
|
| 515 |
+
if (entry.metadata.get("character") == effective_speaker
|
| 516 |
+
and not entry.metadata.get("voice_anchor")):
|
| 517 |
+
latest_gen_idx = _i
|
| 518 |
+
|
| 519 |
+
audio_latents = []
|
| 520 |
+
for _i, entry in enumerate(self.memory):
|
| 521 |
+
audio_latent = entry.audio_latent
|
| 522 |
+
if audio_latent is not None and effective_speaker is not None:
|
| 523 |
+
owner = entry.metadata.get("character")
|
| 524 |
+
if owner is not None and owner != effective_speaker:
|
| 525 |
+
audio_latent = torch.zeros_like(audio_latent)
|
| 526 |
+
elif (owner == effective_speaker
|
| 527 |
+
and not entry.metadata.get("voice_anchor")
|
| 528 |
+
and _i != latest_gen_idx):
|
| 529 |
+
audio_latent = torch.zeros_like(audio_latent)
|
| 530 |
+
audio_latents.append(audio_latent)
|
| 531 |
if not audio_latents or any(audio_latent is None for audio_latent in audio_latents):
|
| 532 |
return None
|
| 533 |
first = audio_latents[0]
|
|
|
|
| 541 |
"All memory audio latents must share batch and channel dimensions, "
|
| 542 |
f"got first={tuple(first.shape)} current={tuple(audio_latent.shape)}"
|
| 543 |
)
|
| 544 |
+
# Harmonize dtypes before the cat: a pre-seeded voice-anchor slot may
|
| 545 |
+
# carry a different dtype than the pipeline's generated latents, and
|
| 546 |
+
# torch.cat requires one dtype. The LAST slot is the most recently
|
| 547 |
+
# generated one, so its dtype is the pipeline's - cast everything to it.
|
| 548 |
+
target_dtype = audio_latents[-1].dtype
|
| 549 |
+
return torch.cat([al.to(target_dtype) for al in audio_latents],
|
| 550 |
+
dim=1).contiguous()
|
| 551 |
|
| 552 |
def get_memory_audio_segment_lengths(self) -> tuple[tuple[int, ...], ...]:
|
| 553 |
audio_latents = [entry.audio_latent for entry in self.memory]
|
nodes.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|